diff --git a/_workspace/04_backend_public_auth.md b/_workspace/04_backend_public_auth.md new file mode 100644 index 0000000..92152f4 --- /dev/null +++ b/_workspace/04_backend_public_auth.md @@ -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` 봉투. 비밀번호·재설정 코드·해시는 응답/로그 미노출(계약 §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. diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/PublicAuthController.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/PublicAuthController.java new file mode 100644 index 0000000..feed2ff --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/PublicAuthController.java @@ -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). + *

기존 {@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 register(@Valid @RequestBody RegisterRequest request) { + return ApiResponse.ok(service.register(request)); + } + + /** POST /api/auth/password/forgot — 항상 200(사용자 열거 방지), 존재 시 재설정 코드 발급. */ + @PostMapping("/password/forgot") + public ApiResponse forgot(@Valid @RequestBody ForgotPasswordRequest request) { + return ApiResponse.ok(service.forgotPassword(request)); + } + + /** POST /api/auth/password/reset — 코드 검증 후 비밀번호 갱신. */ + @PostMapping("/password/reset") + public ApiResponse reset(@Valid @RequestBody ResetPasswordRequest request) { + return ApiResponse.ok(service.resetPassword(request)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/PublicAuthService.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/PublicAuthService.java new file mode 100644 index 0000000..051bddf --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/PublicAuthService.java @@ -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 프론트 계약 정합). + * + *

기존 {@link AuthServiceImpl}(로그인)·Phase B(TotpService·LoginAttempt) 로직은 교체하지 않고 + * 순증한다. 비밀번호/재설정 코드/해시는 응답·로그에 절대 노출하지 않으며(계약 §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 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; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ForgotPasswordRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ForgotPasswordRequest.java new file mode 100644 index 0000000..080c227 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ForgotPasswordRequest.java @@ -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 +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/MessageResponse.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/MessageResponse.java new file mode 100644 index 0000000..fa878a1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/MessageResponse.java @@ -0,0 +1,5 @@ +package com.zioinfo.kintex.auth.dto; + +/** 단순 안내 메시지 응답(비밀번호 찾기/초기화). 민감정보·재설정 코드는 절대 포함하지 않는다. */ +public record MessageResponse(String message) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/RegisterRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/RegisterRequest.java new file mode 100644 index 0000000..b64367d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/RegisterRequest.java @@ -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'. + *

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 +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/RegisterResponse.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/RegisterResponse.java new file mode 100644 index 0000000..e6697e6 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/RegisterResponse.java @@ -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 +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ResetPasswordRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ResetPasswordRequest.java new file mode 100644 index 0000000..29d9cb7 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/dto/ResetPasswordRequest.java @@ -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 +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/PublicAuthMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/PublicAuthMapper.java new file mode 100644 index 0000000..42b31e3 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/mapper/PublicAuthMapper.java @@ -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; + +/** + * 공개 인증(회원가입·비밀번호 재설정) 영속 매퍼. + *

기존 {@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 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); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/common/error/ErrorCode.java b/src/backend/src/main/java/com/zioinfo/kintex/common/error/ErrorCode.java index 816183e..c9c111e 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/common/error/ErrorCode.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/common/error/ErrorCode.java @@ -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차 인증이 필요합니다."), diff --git a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java index 1a3744e..b0deebb 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java @@ -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만 반환. diff --git a/src/backend/src/main/resources/db/migration/V9__public_auth_password_reset.sql b/src/backend/src/main/resources/db/migration/V9__public_auth_password_reset.sql new file mode 100644 index 0000000..d2e0863 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V9__public_auth_password_reset.sql @@ -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); diff --git a/src/backend/src/main/resources/mybatis/mapper/PublicAuthMapper.xml b/src/backend/src/main/resources/mybatis/mapper/PublicAuthMapper.xml new file mode 100644 index 0000000..be01af1 --- /dev/null +++ b/src/backend/src/main/resources/mybatis/mapper/PublicAuthMapper.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + 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 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 INTO password_reset (email, code_hash, expires_at, used, attempts) + VALUES (#{email}, #{codeHash}, #{expiresAt}, false, 0) + + + + + + + + UPDATE password_reset SET used = true WHERE id = #{id} + + + + + UPDATE password_reset SET attempts = attempts + 1 WHERE id = #{id} + + + + + UPDATE app_user + SET password_hash = #{passwordHash}, + failed_login_count = 0, + locked_until = NULL, + updated_at = now() + WHERE email = #{email} + + +