feat(auth): 공개 인증 엔드포인트 — 회원가입·비밀번호 찾기/초기화 + V9
- POST /api/auth/register(BCrypt·중복 EMAIL_TAKEN·초대코드 best-effort event_member) - POST /api/auth/password/forgot(사용자 열거 방지·항상 200·OTP 해시 저장 10분 1회성) - POST /api/auth/password/reset(코드 검증·시도제한·비번 갱신·잠금 리셋) - V9: password_reset 테이블 + app_user.company_name(V1~V8 불변·멱등) - SecurityConfig permitAll 3경로, 비번/코드/해시/스택트레이스 미노출. 기존 auth/2FA/M2~M5 불변 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4e9ec42444
commit
7c830ddea0
76
_workspace/04_backend_public_auth.md
Normal file
76
_workspace/04_backend_public_auth.md
Normal file
@ -0,0 +1,76 @@
|
||||
# 킨텍스 — 공개 인증 API 계약 (회원가입·비밀번호 찾기/초기화)
|
||||
|
||||
> 작성: kintex-backend-dev · 근거: `docs/design.md`(SCR-01) 프론트 계약 정합 + `_workspace/01_backend_contracts.md`(§0 공통규약 준수)
|
||||
> 기존 인증(JWT/RBAC `auth`)·Phase B(`security`: TotpService·LoginAttempt·app_user otp/lock)는 **재사용·불변**. 본 3종은 순증.
|
||||
> 전부 **공개**(SecurityConfig `permitAll` 추가). 응답은 표준 `ApiResponse<T>` 봉투. 비밀번호·재설정 코드·해시는 응답/로그 미노출(계약 §0-3).
|
||||
|
||||
---
|
||||
|
||||
## 1. POST /api/auth/register — 회원가입 (공개)
|
||||
|
||||
요청
|
||||
```json
|
||||
{ "email": "a@x.com", "displayName": "홍길동", "password": "min8chars",
|
||||
"companyName": "지오인포(선택)", "inviteCode": "evt-2026(선택)" }
|
||||
```
|
||||
- 검증: email @Email, displayName 필수(≤120), password 8~100자. companyName·inviteCode 선택.
|
||||
|
||||
응답 200
|
||||
```json
|
||||
{ "success": true, "data": {
|
||||
"userId": "usr-xxxxxxxxxxxxxxxxxxxx", "email": "a@x.com",
|
||||
"displayName": "홍길동", "joinedEvent": "evt-2026" // inviteCode 매핑 성공 시 eventId, 아니면 null
|
||||
}, "error": null }
|
||||
```
|
||||
- 생성 계정: `role_code='USER'`, `status='ACTIVE'`(즉시 로그인 가능), `verify_method='EMAIL'`, `hall_manager=false`. 비밀번호 BCrypt 해시 저장.
|
||||
- `inviteCode`: **행사 식별자(event.id)로 해석** → 활성 행사면 `event_member`(role `EXHIBITOR`) best-effort 매핑. 매칭 실패/에러는 무시(가입은 성립, joinedEvent=null).
|
||||
- `companyName`: `app_user.company_name`(V9 순증 컬럼)에 보존(선택).
|
||||
- 응답에 password_hash·otp 등 민감정보 제외.
|
||||
|
||||
오류
|
||||
| 상황 | code | HTTP |
|
||||
|---|---|---|
|
||||
| email 중복 | `EMAIL_TAKEN` | 409 |
|
||||
| 검증 실패 | `VALIDATION` | 400 |
|
||||
|
||||
---
|
||||
|
||||
## 2. POST /api/auth/password/forgot — 비밀번호 찾기 (공개)
|
||||
|
||||
요청 `{ "email": "a@x.com" }`
|
||||
|
||||
응답 **항상 200**(사용자 열거 방지 — 존재 여부와 무관하게 동일 응답)
|
||||
```json
|
||||
{ "success": true, "data": {
|
||||
"message": "입력하신 이메일이 등록되어 있으면 비밀번호 재설정 안내를 보내드립니다." },
|
||||
"error": null }
|
||||
```
|
||||
- 활성 계정 존재 시에만 6자리 재설정 코드 생성 → BCrypt 해시로 `password_reset` 저장(만료 10분·1회성).
|
||||
- 이메일 발송 인프라 부재 → **dev 로깅으로 대체**. 로그에는 코드 마스킹(`1****6`)·이메일 마스킹만. 응답에 코드 미포함.
|
||||
|
||||
---
|
||||
|
||||
## 3. POST /api/auth/password/reset — 비밀번호 초기화 (공개)
|
||||
|
||||
요청 `{ "email": "a@x.com", "code": "123456", "newPassword": "min8chars" }`
|
||||
|
||||
응답 200 `{ "success": true, "data": { "message": "비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요." }, "error": null }`
|
||||
- 검증: 최신 미사용 코드 대조(만료·1회성·시도제한 max 5). 성공 시 `password_hash` 갱신 + `failed_login_count=0`·`locked_until=NULL` 리셋 + 코드 `used=true`.
|
||||
- 실패(코드 불일치/만료/시도초과/없음): `OTP_INVALID` (401), 메시지 일반화("재설정 코드가 올바르지 않거나 만료되었습니다.").
|
||||
|
||||
---
|
||||
|
||||
## 4. 스키마 — 마이그레이션 V9 (V1~V8 불변·멱등·순증)
|
||||
|
||||
`V9__public_auth_password_reset.sql`
|
||||
- `ALTER TABLE app_user ADD COLUMN IF NOT EXISTS company_name varchar(200);`
|
||||
- `CREATE TABLE IF NOT EXISTS password_reset (id bigserial PK, email, code_hash BCrypt, expires_at, used, attempts, created_at)` + `idx_password_reset_email(email, used, created_at DESC)`.
|
||||
- `code_hash`는 저장/검증 전용 — 어떤 조회에서도 응답 SELECT 금지.
|
||||
|
||||
## 5. 코드 산출물
|
||||
- 컨트롤러 `auth/PublicAuthController` (신규, `/api/auth` 순증 — 기존 AuthController·TwoFactorController 불변)
|
||||
- 서비스 `auth/PublicAuthService`
|
||||
- DTO `auth/dto/{RegisterRequest,RegisterResponse,ForgotPasswordRequest,ResetPasswordRequest,MessageResponse}`
|
||||
- 매퍼 `auth/mapper/PublicAuthMapper` + `mybatis/mapper/PublicAuthMapper.xml`
|
||||
- `ErrorCode.EMAIL_TAKEN`(409) 추가 / `SecurityConfig` permitAll 3경로 추가 / `OTP_INVALID` 재사용
|
||||
- **미변경:** M2~M5, 기존 auth 로그인, Phase B 2FA, 프론트. `./gradlew build -x test` BUILD SUCCESSFUL.
|
||||
@ -0,0 +1,47 @@
|
||||
package com.zioinfo.kintex.auth;
|
||||
|
||||
import com.zioinfo.kintex.auth.dto.ForgotPasswordRequest;
|
||||
import com.zioinfo.kintex.auth.dto.MessageResponse;
|
||||
import com.zioinfo.kintex.auth.dto.RegisterRequest;
|
||||
import com.zioinfo.kintex.auth.dto.RegisterResponse;
|
||||
import com.zioinfo.kintex.auth.dto.ResetPasswordRequest;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 공개 인증 API (C-1) — 회원가입·비밀번호 찾기/초기화. 전부 공개(SecurityConfig permitAll).
|
||||
* <p>기존 {@link AuthController}(로그인·워크스페이스)·Phase B(2FA) 는 건드리지 않고 본 컨트롤러만 추가한다.
|
||||
* 응답은 표준 {@link ApiResponse} 봉투를 쓰며 비밀번호·재설정 코드·해시는 절대 포함하지 않는다(계약 §0-3).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class PublicAuthController {
|
||||
|
||||
private final PublicAuthService service;
|
||||
|
||||
public PublicAuthController(PublicAuthService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/** POST /api/auth/register — 공개 회원가입(email 중복 시 409). */
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<RegisterResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
return ApiResponse.ok(service.register(request));
|
||||
}
|
||||
|
||||
/** POST /api/auth/password/forgot — 항상 200(사용자 열거 방지), 존재 시 재설정 코드 발급. */
|
||||
@PostMapping("/password/forgot")
|
||||
public ApiResponse<MessageResponse> forgot(@Valid @RequestBody ForgotPasswordRequest request) {
|
||||
return ApiResponse.ok(service.forgotPassword(request));
|
||||
}
|
||||
|
||||
/** POST /api/auth/password/reset — 코드 검증 후 비밀번호 갱신. */
|
||||
@PostMapping("/password/reset")
|
||||
public ApiResponse<MessageResponse> reset(@Valid @RequestBody ResetPasswordRequest request) {
|
||||
return ApiResponse.ok(service.resetPassword(request));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
package com.zioinfo.kintex.auth;
|
||||
|
||||
import com.zioinfo.kintex.auth.dto.ForgotPasswordRequest;
|
||||
import com.zioinfo.kintex.auth.dto.MessageResponse;
|
||||
import com.zioinfo.kintex.auth.dto.RegisterRequest;
|
||||
import com.zioinfo.kintex.auth.dto.RegisterResponse;
|
||||
import com.zioinfo.kintex.auth.dto.ResetPasswordRequest;
|
||||
import com.zioinfo.kintex.auth.mapper.PublicAuthMapper;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 공개 인증 서비스 — 회원가입·비밀번호 찾기/초기화(§C-1 프론트 계약 정합).
|
||||
*
|
||||
* <p>기존 {@link AuthServiceImpl}(로그인)·Phase B(TotpService·LoginAttempt) 로직은 <b>교체하지 않고</b>
|
||||
* 순증한다. 비밀번호/재설정 코드/해시는 응답·로그에 절대 노출하지 않으며(계약 §0-3),
|
||||
* 비밀번호 찾기는 계정 존재 여부와 무관하게 항상 성공 안내를 반환한다(사용자 열거 방지).
|
||||
*/
|
||||
@Service
|
||||
public class PublicAuthService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PublicAuthService.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
/** 재설정 코드 유효시간(분). */
|
||||
private static final int RESET_TTL_MINUTES = 10;
|
||||
/** 재설정 코드 검증 최대 시도(초과 시 코드 무효화). */
|
||||
private static final int RESET_MAX_ATTEMPTS = 5;
|
||||
private static final String GENERIC_FORGOT_MESSAGE =
|
||||
"입력하신 이메일이 등록되어 있으면 비밀번호 재설정 안내를 보내드립니다.";
|
||||
|
||||
private final PublicAuthMapper mapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public PublicAuthService(PublicAuthMapper mapper, PasswordEncoder passwordEncoder) {
|
||||
this.mapper = mapper;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
/** 회원가입 — 이메일 중복 시 409, 성공 시 민감정보 제외 요약 반환. */
|
||||
@Transactional
|
||||
public RegisterResponse register(RegisterRequest req) {
|
||||
String email = normalize(req.email());
|
||||
Integer existing = mapper.countByEmail(email);
|
||||
if (existing != null && existing > 0) {
|
||||
throw new ApiException(ErrorCode.EMAIL_TAKEN);
|
||||
}
|
||||
String userId = "usr-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20);
|
||||
String hash = passwordEncoder.encode(req.password());
|
||||
String companyName = blankToNull(req.companyName());
|
||||
mapper.insertUser(userId, email, req.displayName().trim(), hash, companyName);
|
||||
|
||||
String joinedEvent = tryInviteMapping(userId, req.inviteCode());
|
||||
return new RegisterResponse(userId, email, req.displayName().trim(), joinedEvent);
|
||||
}
|
||||
|
||||
/** inviteCode → 행사 event_member 매핑(best-effort). 실패해도 회원가입은 성립. */
|
||||
private String tryInviteMapping(String userId, String inviteCode) {
|
||||
String code = blankToNull(inviteCode);
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String eventId = mapper.findEventIdByInvite(code);
|
||||
if (eventId == null) {
|
||||
return null; // 매칭되는 행사 없음 → 무시(가입은 정상).
|
||||
}
|
||||
mapper.insertEventMember("em-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20),
|
||||
eventId, userId);
|
||||
return eventId;
|
||||
} catch (RuntimeException e) {
|
||||
// 초대 매핑 실패는 가입 실패로 확산시키지 않는다(요약만 로그).
|
||||
log.warn("초대코드 매핑 건너뜀(가입은 정상): {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호 찾기 — 항상 200. 존재 시에만 재설정 코드 생성·저장(dev 로깅으로 발송 대체). */
|
||||
@Transactional
|
||||
public MessageResponse forgotPassword(ForgotPasswordRequest req) {
|
||||
String email = normalize(req.email());
|
||||
String userId = mapper.findActiveUserIdByEmail(email);
|
||||
if (userId != null) {
|
||||
String code = generateCode();
|
||||
String codeHash = passwordEncoder.encode(code);
|
||||
mapper.insertResetCode(email, codeHash,
|
||||
OffsetDateTime.now().plusMinutes(RESET_TTL_MINUTES));
|
||||
// 이메일 발송 인프라 부재 → dev 로깅. 코드 값은 마스킹(원문·해시 노출 금지, 계약 §0-3).
|
||||
log.info("비밀번호 재설정 코드 발급(email={}, code={}, ttl={}m)",
|
||||
maskEmail(email), maskCode(code), RESET_TTL_MINUTES);
|
||||
}
|
||||
// 존재 여부와 무관하게 동일 응답(사용자 열거 방지).
|
||||
return new MessageResponse(GENERIC_FORGOT_MESSAGE);
|
||||
}
|
||||
|
||||
/** 비밀번호 초기화 — 코드 검증(만료·1회성·시도제한) 성공 시 해시 갱신. */
|
||||
@Transactional
|
||||
public MessageResponse resetPassword(ResetPasswordRequest req) {
|
||||
String email = normalize(req.email());
|
||||
Map<String, Object> row = mapper.findLatestActiveReset(email);
|
||||
if (row == null) {
|
||||
throw new ApiException(ErrorCode.OTP_INVALID, "재설정 코드가 올바르지 않거나 만료되었습니다.");
|
||||
}
|
||||
long id = toLong(row.get("id"));
|
||||
int attempts = toInt(row.get("attempts"));
|
||||
OffsetDateTime expiresAt = toTime(row.get("expiresAt"));
|
||||
|
||||
if (attempts >= RESET_MAX_ATTEMPTS || expiresAt == null
|
||||
|| expiresAt.isBefore(OffsetDateTime.now())) {
|
||||
mapper.markResetUsed(id); // 만료·시도초과 코드는 소진 처리.
|
||||
throw new ApiException(ErrorCode.OTP_INVALID, "재설정 코드가 올바르지 않거나 만료되었습니다.");
|
||||
}
|
||||
|
||||
String codeHash = str(row.get("codeHash"));
|
||||
if (codeHash == null || !passwordEncoder.matches(req.code(), codeHash)) {
|
||||
mapper.incrementResetAttempts(id);
|
||||
if (attempts + 1 >= RESET_MAX_ATTEMPTS) {
|
||||
mapper.markResetUsed(id); // 시도 초과 → 코드 무효화.
|
||||
}
|
||||
throw new ApiException(ErrorCode.OTP_INVALID, "재설정 코드가 올바르지 않거나 만료되었습니다.");
|
||||
}
|
||||
|
||||
// 성공: 새 해시 저장 + 실패 카운트/잠금 해제 + 코드 1회성 소진.
|
||||
mapper.updatePasswordByEmail(email, passwordEncoder.encode(req.newPassword()));
|
||||
mapper.markResetUsed(id);
|
||||
log.info("비밀번호 재설정 완료(email={})", maskEmail(email));
|
||||
return new MessageResponse("비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.");
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
private static String generateCode() {
|
||||
return String.format("%06d", RANDOM.nextInt(1_000_000));
|
||||
}
|
||||
|
||||
private static String normalize(String email) {
|
||||
return email == null ? null : email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
|
||||
private static String maskEmail(String email) {
|
||||
if (email == null) {
|
||||
return "***";
|
||||
}
|
||||
int at = email.indexOf('@');
|
||||
if (at <= 1) {
|
||||
return "***";
|
||||
}
|
||||
return email.charAt(0) + "***" + email.substring(at);
|
||||
}
|
||||
|
||||
private static String maskCode(String code) {
|
||||
if (code == null || code.length() < 2) {
|
||||
return "****";
|
||||
}
|
||||
return code.charAt(0) + "****" + code.charAt(code.length() - 1);
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
private static long toLong(Object o) {
|
||||
if (o instanceof Number n) {
|
||||
return n.longValue();
|
||||
}
|
||||
try {
|
||||
return o == null ? 0L : Long.parseLong(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static int toInt(Object o) {
|
||||
if (o instanceof Number n) {
|
||||
return n.intValue();
|
||||
}
|
||||
try {
|
||||
return o == null ? 0 : Integer.parseInt(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static OffsetDateTime toTime(Object o) {
|
||||
if (o instanceof OffsetDateTime odt) {
|
||||
return odt;
|
||||
}
|
||||
if (o instanceof java.time.LocalDateTime ldt) {
|
||||
return ldt.atOffset(OffsetDateTime.now().getOffset());
|
||||
}
|
||||
if (o instanceof java.sql.Timestamp ts) {
|
||||
return ts.toInstant().atOffset(OffsetDateTime.now().getOffset());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.zioinfo.kintex.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/** 비밀번호 찾기 요청 — 존재 여부와 무관하게 항상 200(사용자 열거 방지). */
|
||||
public record ForgotPasswordRequest(
|
||||
@NotBlank @Email String email
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.zioinfo.kintex.auth.dto;
|
||||
|
||||
/** 단순 안내 메시지 응답(비밀번호 찾기/초기화). 민감정보·재설정 코드는 절대 포함하지 않는다. */
|
||||
public record MessageResponse(String message) {
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.zioinfo.kintex.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 공개 회원가입 요청(SCR-01 회원가입). 생성 계정은 role_code='USER'·status='ACTIVE'.
|
||||
* <p>companyName·inviteCode 는 선택. inviteCode 있으면 해당 행사 event_member 매핑을 best-effort 시도한다.
|
||||
* 보안(계약 §0-3): password 는 BCrypt 해시로만 저장하며 응답/로그에 원문·해시 노출 금지.
|
||||
*/
|
||||
public record RegisterRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(max = 120) String displayName,
|
||||
@NotBlank @Size(min = 8, max = 100) String password,
|
||||
@Size(max = 200) String companyName,
|
||||
@Size(max = 80) String inviteCode
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package com.zioinfo.kintex.auth.dto;
|
||||
|
||||
/**
|
||||
* 회원가입 응답 — 비밀번호 해시·OTP 시크릿 등 민감정보는 절대 포함하지 않는다(계약 §0-3).
|
||||
* joinedEvent 는 inviteCode 로 행사 멤버십이 매핑된 경우만 채워지고, 아니면 null.
|
||||
*/
|
||||
public record RegisterResponse(
|
||||
String userId,
|
||||
String email,
|
||||
String displayName,
|
||||
String joinedEvent
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.zioinfo.kintex.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 비밀번호 초기화 요청 — 재설정 코드(6자리) 검증 후 새 비밀번호로 갱신.
|
||||
* 보안(계약 §0-3): code·newPassword 원문은 응답/로그 노출 금지.
|
||||
*/
|
||||
public record ResetPasswordRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(min = 4, max = 12) String code,
|
||||
@NotBlank @Size(min = 8, max = 100) String newPassword
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package com.zioinfo.kintex.auth.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 공개 인증(회원가입·비밀번호 재설정) 영속 매퍼.
|
||||
* <p>기존 {@link UserMapper}/보안 매퍼는 교체하지 않고 순증한다. 보안 불변(계약 §0-3):
|
||||
* password_hash·code_hash 는 저장/검증 경로에서만 다루며 응답 DTO·로그로 노출 금지.
|
||||
*/
|
||||
@Mapper
|
||||
public interface PublicAuthMapper {
|
||||
|
||||
/** 이메일 중복 확인(대소문자 무시 정규화된 값 기준). 존재하면 1, 없으면 null. */
|
||||
Integer countByEmail(@Param("email") String email);
|
||||
|
||||
/** 신규 사용자 삽입(role_code='USER', status='ACTIVE', verify_method='EMAIL'). */
|
||||
int insertUser(@Param("id") String id,
|
||||
@Param("email") String email,
|
||||
@Param("displayName") String displayName,
|
||||
@Param("passwordHash") String passwordHash,
|
||||
@Param("companyName") String companyName);
|
||||
|
||||
/** inviteCode 를 행사 식별자로 해석 — 존재하는 활성 행사면 eventId 반환, 없으면 null(best-effort). */
|
||||
String findEventIdByInvite(@Param("code") String code);
|
||||
|
||||
/** 초대 매핑 — event_member 삽입(EXHIBITOR). 중복이면 무시. */
|
||||
int insertEventMember(@Param("id") String id,
|
||||
@Param("eventId") String eventId,
|
||||
@Param("userId") String userId);
|
||||
|
||||
/** 활성 사용자 존재 시 userId 반환(비밀번호 찾기 — 존재 여부는 응답에 노출 안 함). 없으면 null. */
|
||||
String findActiveUserIdByEmail(@Param("email") String email);
|
||||
|
||||
/** 재설정 코드 저장(만료·1회성). code_hash 는 BCrypt. */
|
||||
int insertResetCode(@Param("email") String email,
|
||||
@Param("codeHash") String codeHash,
|
||||
@Param("expiresAt") OffsetDateTime expiresAt);
|
||||
|
||||
/** 이메일의 최신 미사용 재설정 행(id, codeHash, expiresAt, attempts). 없으면 null. */
|
||||
Map<String, Object> findLatestActiveReset(@Param("email") String email);
|
||||
|
||||
/** 재설정 코드 1회성 소진. */
|
||||
int markResetUsed(@Param("id") long id);
|
||||
|
||||
/** 재설정 코드 검증 실패 누적(시도제한). */
|
||||
int incrementResetAttempts(@Param("id") long id);
|
||||
|
||||
/** 비밀번호 갱신 + 로그인 실패 카운트/잠금 해제(재설정 성공). */
|
||||
int updatePasswordByEmail(@Param("email") String email, @Param("passwordHash") String passwordHash);
|
||||
}
|
||||
@ -13,6 +13,7 @@ public enum ErrorCode {
|
||||
COMPLIANCE_BLOCKED(HttpStatus.UNPROCESSABLE_ENTITY, "규정 위반(차단)으로 진행할 수 없습니다."),
|
||||
RENDER_QUOTA_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "행사 이미지 생성 쿼터를 초과했습니다."),
|
||||
NOT_REGISTERED_COMPANY(HttpStatus.FORBIDDEN, "킨텍스 등록업체만 참여할 수 있습니다."),
|
||||
EMAIL_TAKEN(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."),
|
||||
NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "아직 구현되지 않은 기능입니다."),
|
||||
ACCOUNT_LOCKED(HttpStatus.LOCKED, "로그인 시도가 일시적으로 제한되었습니다."),
|
||||
OTP_REQUIRED(HttpStatus.UNAUTHORIZED, "2차 인증이 필요합니다."),
|
||||
|
||||
@ -34,7 +34,9 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/health", "/api/auth/login", "/ws/**",
|
||||
"/api/internal/render/callback",
|
||||
"/api/auth/login/secure", "/api/auth/otp/verify").permitAll()
|
||||
"/api/auth/login/secure", "/api/auth/otp/verify",
|
||||
"/api/auth/register", "/api/auth/password/forgot",
|
||||
"/api/auth/password/reset").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(eh -> eh.authenticationEntryPoint((req, res, ex) -> {
|
||||
// 스택트레이스 미노출 — 표준 봉투로 401만 반환.
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
-- 킨텍스 — 공개 인증(회원가입·비밀번호 재설정) 순증 스키마
|
||||
-- V1~V8 불변. 본 마이그레이션은 순증(additive)·멱등만 수행한다.
|
||||
-- 보안 불변(계약 §0-3): code_hash·password_hash 는 API 응답/로그 노출 금지.
|
||||
|
||||
-- 회원가입 시 입력한 소속사명(선택) 보존용 순증 컬럼. 미입력 허용.
|
||||
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS company_name varchar(200);
|
||||
|
||||
-- 비밀번호 재설정 코드(6자리 OTP) — code_hash 만 저장(평문·해시 응답/로그 금지),
|
||||
-- 만료(expires_at, 10분)·1회성(used)·시도제한(attempts). 이메일 발송 인프라 부재 시 dev 로깅으로 대체.
|
||||
CREATE TABLE IF NOT EXISTS password_reset (
|
||||
id bigserial PRIMARY KEY,
|
||||
email varchar(200) NOT NULL,
|
||||
code_hash varchar(120) NOT NULL, -- BCrypt(6자리 코드). 응답/로그 노출 금지.
|
||||
expires_at timestamptz NOT NULL, -- 발급 + 10분
|
||||
used boolean NOT NULL DEFAULT false,
|
||||
attempts integer NOT NULL DEFAULT 0, -- 검증 실패 누적(임계 초과 시 코드 무효화)
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_email ON password_reset(email, used, created_at DESC);
|
||||
@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<!--
|
||||
PublicAuthMapper — 공개 회원가입·비밀번호 재설정 영속.
|
||||
보안(계약 §0-3): password_hash·code_hash 는 저장/검증 용도로만 다루며 어떤 조회에서도 응답으로 SELECT 하지 않는다.
|
||||
-->
|
||||
<mapper namespace="com.zioinfo.kintex.auth.mapper.PublicAuthMapper">
|
||||
|
||||
<!-- 이메일 중복(정규화된 소문자 기준). 존재 시 1. -->
|
||||
<select id="countByEmail" resultType="int">
|
||||
SELECT COUNT(*) FROM app_user WHERE email = #{email}
|
||||
</select>
|
||||
|
||||
<!-- 신규 사용자. 표준 기본값(USER/ACTIVE/EMAIL), hall_manager=false. -->
|
||||
<insert id="insertUser">
|
||||
INSERT INTO app_user (id, email, display_name, password_hash, hall_manager,
|
||||
role_code, status, verify_method, company_name)
|
||||
VALUES (#{id}, #{email}, #{displayName}, #{passwordHash}, false,
|
||||
'USER', 'ACTIVE', 'EMAIL', #{companyName})
|
||||
</insert>
|
||||
|
||||
<!-- inviteCode → 활성 행사 식별자(best-effort). 없으면 null. -->
|
||||
<select id="findEventIdByInvite" resultType="string">
|
||||
SELECT id FROM event
|
||||
WHERE id = #{code} AND status = 'active'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 초대 매핑(EXHIBITOR). 유니크(event_id,user_id,role_code) 충돌 시 무시. -->
|
||||
<insert id="insertEventMember">
|
||||
INSERT INTO event_member (id, event_id, user_id, role_code)
|
||||
VALUES (#{id}, #{eventId}, #{userId}, 'EXHIBITOR')
|
||||
ON CONFLICT (event_id, user_id, role_code) DO NOTHING
|
||||
</insert>
|
||||
|
||||
<!-- 활성 사용자 존재 시 userId(비밀번호 찾기). 없으면 null. -->
|
||||
<select id="findActiveUserIdByEmail" resultType="string">
|
||||
SELECT id FROM app_user
|
||||
WHERE email = #{email} AND status = 'ACTIVE'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 재설정 코드 저장. -->
|
||||
<insert id="insertResetCode">
|
||||
INSERT INTO password_reset (email, code_hash, expires_at, used, attempts)
|
||||
VALUES (#{email}, #{codeHash}, #{expiresAt}, false, 0)
|
||||
</insert>
|
||||
|
||||
<!-- 최신 미사용 재설정 행. code_hash 는 검증에만 사용. -->
|
||||
<select id="findLatestActiveReset" resultType="map">
|
||||
SELECT id AS "id",
|
||||
code_hash AS "codeHash",
|
||||
expires_at AS "expiresAt",
|
||||
attempts AS "attempts"
|
||||
FROM password_reset
|
||||
WHERE email = #{email} AND used = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 1회성 소진. -->
|
||||
<update id="markResetUsed">
|
||||
UPDATE password_reset SET used = true WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<!-- 검증 실패 누적. -->
|
||||
<update id="incrementResetAttempts">
|
||||
UPDATE password_reset SET attempts = attempts + 1 WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<!-- 비밀번호 갱신 + 실패 카운트/잠금 해제. -->
|
||||
<update id="updatePasswordByEmail">
|
||||
UPDATE app_user
|
||||
SET password_hash = #{passwordHash},
|
||||
failed_login_count = 0,
|
||||
locked_until = NULL,
|
||||
updated_at = now()
|
||||
WHERE email = #{email}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
Loading…
Reference in New Issue
Block a user