diff --git a/.gitignore b/.gitignore index 9ed683f..64c8530 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ work/ outputs/ /.env +babi-backup-before-schema-restore.sql diff --git a/build.gradle b/build.gradle index af815c1..bb7ed61 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-mail' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/compose.yml b/compose.yml index 3c020f6..9e14e48 100644 --- a/compose.yml +++ b/compose.yml @@ -10,6 +10,9 @@ services: MYSQL_PASSWORD: ${MYSQL_PASSWORD?required} volumes: - babi_mysql_data:/var/lib/mysql - + redis: + image: redis:7.4-alpine + ports: + - "127.0.0.1:6379:6379" volumes: babi_mysql_data: diff --git a/docs/2026-10-25-email-signup-integration.md b/docs/2026-10-25-email-signup-integration.md new file mode 100644 index 0000000..a26568a --- /dev/null +++ b/docs/2026-10-25-email-signup-integration.md @@ -0,0 +1,100 @@ +# 이메일 인증과 회원가입 연동 결과 + +> 이 문서는 verificationToken 도입 전 구현과 테스트의 기록입니다. 이메일 전역 값 `1`에 기반한 아래 가입 계약은 폐기됐습니다. 현재 서비스는 일회성 토큰을 필수로 요구하며, 최신 요청·응답 및 Redis 처리 방식은 [이메일 인증 토큰 API 계약](api/email-verification.md)을 따릅니다. 과거의 165개 테스트 결과를 현재 토큰 보안 검증 결과로 해석하지 마세요. + +2026-10-25 KST. 대상: `/Users/dgsw49/Desktop/Development/BABI-server`, 브랜치 `feature/email-verification`. + +## 확인 및 변경 범위 + +현재 체크아웃과 상위 경로에는 AGENTS.md/CLAUDE.md가 없었다. 동일 저장소의 다른 로컬 체크아웃에 있는 SELECT/BABI AGENTS.md와 CLAUDE.md를 참고해 기존 레이어, record DTO, 생성자 주입, ApiResponse/ErrorCode 및 Gradle Wrapper 관례를 유지했다. 브랜치와 origin/status를 먼저 확인했고 기존 이메일 인증/DB 작업을 덮어쓰지 않았다. + +수정 파일: + +- `src/main/java/com/select/domain/auth/service/EmailVerificationService.java`: 이메일 정규화, 유형별 예외, 가입용 인증 예약 및 트랜잭션 완료 처리 +- `src/main/java/com/select/domain/user/service/UserService.java`: 기본 가입 검증 후 인증 예약, 로그인과 공통 이메일 정규화 +- `src/main/java/com/select/domain/auth/dto/request/EmailSendRequest.java` +- `src/main/java/com/select/domain/auth/dto/request/EmailVerifyRequest.java` +- `src/main/java/com/select/domain/user/dto/request/SignupRequest.java` +- `src/main/java/com/select/domain/user/dto/request/LoginRequest.java`: 위 네 record DTO 모두 같은 정규화 사용. 인증 DTO 이메일 길이도 320자로 제한 +- `src/main/java/com/select/global/exception/ErrorCode.java` +- `src/main/java/com/select/global/exception/GlobalExceptionHandler.java`: 인증 실패의 구조화된 상태/오류 응답 +- 기존 테스트 4개: UserServiceTest, UserEmailConstraintTest, UserControllerTest, JwtAuthenticationIntegrationTest. 생성자 의존성을 반영하고 기존 테스트 목적을 유지 + +추가 파일: + +- `src/main/java/com/select/global/util/EmailNormalizer.java` +- `src/main/java/com/select/domain/auth/exception/EmailVerificationException.java` +- `src/test/java/com/select/domain/auth/service/EmailVerificationServiceTest.java` +- `src/test/java/com/select/domain/auth/service/EmailSignupIntegrationTest.java` +- `src/test/java/com/select/domain/auth/controller/EmailVerificationControllerTest.java` +- 이 문서 + +## 이메일과 API 계약 + +발송·검증·회원가입·로그인 모두 `trim().toLowerCase(Locale.ROOT)`을 사용한다. DTO 생성 시 검증 전에 적용하며 서비스 직접 호출도 같은 공통 함수를 통과한다. 이메일 별칭/점 제거 등의 추가 규칙은 넣지 않았다. 인증번호는 그대로 검증하며 기존 HMAC-SHA256 저장 방식과 인증/시도/재발송 TTL은 유지했다. + +회원가입 요청/응답 record와 성공 201, 기존 로그인/JWT/중복 이메일 409/비밀번호 불일치 처리는 유지한다. 가입에 유효한 이메일 인증이 추가로 필요하다. 비밀번호 불일치 및 이미 존재하는 이메일은 예약 전에 거부하므로 인증을 소비하지 않는다. + +| 오류 코드 | HTTP | 의미 | +|---|---|---| +| EMAIL_CODE_MISMATCH | 400 | 인증번호 불일치 | +| EMAIL_CODE_EXPIRED | 400 | 인증번호 없음/만료/이미 사용 | +| EMAIL_ATTEMPTS_EXCEEDED | 429 | 최대 입력 횟수 초과 | +| EMAIL_RESEND_TOO_SOON | 429 | 재발송 제한 | +| EMAIL_NOT_VERIFIED | 400 | 인증 완료 정보 없음/만료 | +| EMAIL_VERIFICATION_IN_PROGRESS | 409 | 같은 인증을 다른 가입 요청이 예약 중 | +| EMAIL_VERIFICATION_UNAVAILABLE | 503 | Redis 장애/예상하지 못한 인증 처리 결과 | +| EMAIL_SEND_FAILED | 503 | 인증 메일 전송 실패 | + +응답은 기존 ApiResponse의 success/message/data/error.code/error.details 형식을 따른다. 내부 Redis 오류나 인증번호/secret을 응답에 담지 않는다. + +## Redis 일회성 사용과 실제 DB 커밋 + +기존 인증 완료 키 `email:verified:{정규화 이메일}`, 값 `1`, TTL 10분을 유지한다. 조회 후 삭제하는 구현 대신 Lua 두 개를 추가했다. 기존 발송/번호검증 Lua 본문은 변경하지 않았다. + +1. `UserService.signup()`의 실제 Spring 트랜잭션 안에서 기본 가입 검증 후 예약한다. +2. 예약 Lua는 값이 `1`이며 PTTL이 양수일 때에만 `signup:{임의 UUID}`로 원자적으로 변경한다. Redis KEEPTTL로 원래 만료 시각을 유지한다. 다른 요청은 예약 중 오류를 받는다. 만료/TTL 없는 키/임의 값은 인증으로 인정하지 않는다. +3. 비밀번호 해시 및 saveAndFlush를 실행한다. flush는 커밋이 아니므로 메서드 내부에서 인증을 삭제하지 않는다. +4. Spring TransactionSynchronization.afterCompletion의 실제 결과가 COMMITTED일 때만 내 예약 토큰을 삭제한다. +5. 확정된 ROLLED_BACK이면 내 토큰이 아직 유효한 경우 `1`로 복원한다. KEEPTTL이므로 남은 TTL만 유지한다. 이미 만료됐거나 새 인증/다른 요청으로 바뀐 키는 복원하거나 지우지 않는다. +6. UNKNOWN은 복원하지 않고 내 예약을 소비한다. 성공 여부가 불명확한 DB 작업에 대해 인증 재사용을 허용하지 않는다. + +한 인증을 동시에 두 가입 요청이 예약할 수 없다. MySQL 이메일 UNIQUE도 중복 계정 생성을 최종 차단한다. 새 인증이 진행되어 키 값이 바뀌어도 이전 요청의 완료 처리가 새 인증을 제거하지 않도록 토큰을 비교한다. + +## 실패 처리와 남은 위험 + +MySQL과 Redis 사이에 분산 트랜잭션/자동 공동 롤백은 없다. 이 구현은 원자적 예약과 실제 트랜잭션 결과에 따른 조건부 보상 방식이다. + +- DB 저장/flush/커밋 전 단계 실패가 명확히 롤백되면 인증은 원래 남은 시간 안에서 재사용 가능하다. +- DB 커밋 후 Redis 삭제 실패가 발생해도 이미 완료된 가입을 실패 응답으로 바꾸지 않는다. 경고를 남기며 예약 값은 `1`이 아니므로 가입 인증으로 재사용할 수 없다. 원래 TTL 또는 새 인증으로 정리된다. +- 롤백 후 Redis 복구 실패나 프로세스 강제 종료가 발생하면 예약이 TTL까지 남을 수 있어 재인증이 필요할 수 있다. 보안을 위해 자동으로 `1`로 간주하지 않는다. +- Redis 장애로 예약 성공 여부를 확인할 수 없으면 회원 저장 전에 가입을 거부한다. Redis가 예약을 반영한 뒤 응답이 유실되면 역시 TTL까지 대기/재인증이 필요할 수 있다. +- Redis 재시작·비동기 복제 failover까지 포함하는 엄밀한 전역 exactly-once를 보장하는 설계는 아니다. 영속 outbox/예약 테이블/재처리 작업은 DB 스키마 변경 금지와 현재 범위를 고려해 도입하지 않았다. +- 가입 요청 처리 중 인증 유효기간이 끝나도, 만료 전에 예약한 요청은 완료될 수 있다. 이후 요청에는 만료된 키를 새로 사용할 수 없다. +- 이전 버전이 만든 대문자/공백 이메일 Redis 키는 새 정규화 키와 연결하지 않는다. 기존 TTL로 만료되며 필요한 사용자는 재인증한다. + +## 검증 + +실행 명령(Java 21 경로만 환경에 맞게 변경): + +```bash +JAVA_HOME=/Users/dgsw49/Library/Java/JavaVirtualMachines/ms-21.0.10/Contents/Home BABI_REDIS_TEST_PORT=6379 ./gradlew compileJava test --offline --console=plain +``` + +결과: 컴파일 성공, 총 **165건 통과**, 실패/오류/건너뜀 0. 기존 130건과 신규 35건이다. 실제 Redis 통합 테스트 13건이 포함됐다. + +- 미인증/만료 가입 거부, 인증 후 가입·로그인·JWT 발급, 중복 이메일, 인증 재사용 거부 +- 이메일 발송/검증 API의 대소문자·공백 일치, 재발송 제한, 불일치/시도 초과/만료, 전송 실패 정리 +- 실제 HTTP 동시 가입 두 요청 중 1건만 성공, 첫 트랜잭션이 열린 동안 다른 요청 예약 차단 +- saveAndFlush 후 외부 트랜잭션 롤백 및 beforeCommit 실패 보상, 실제 저장 실패 후 인증 복구 +- 만료 인증 복구 방지, 오래된 완료 작업이 새 인증을 덮어쓰거나 지우지 않음 +- UNKNOWN 결과 소비, Redis 장애 거부, 커밋 후 Redis 정리 실패가 성공 응답을 뒤집지 않음 +- 기존 가입/로그인/탈퇴 상태/JWT 보안 테스트 유지. 기존 JWT 테스트는 인증 서비스 mock을 사용하고 새 통합 테스트는 실제 인증 서비스/Lua를 사용 + +통합 테스트는 H2와 실제 127.0.0.1 Redis를 사용하며 이메일은 UUID 기반 `babi-test-…@example.invalid`이다. 해당 테스트가 만든 네 종류의 정확한 Redis 키만 정리하고 FLUSHDB/FLUSHALL은 사용하지 않는다. SMTP EmailSender는 MockitoBean으로 교체해 외부 Gmail 발송을 차단한다. BABI_REDIS_TEST_PORT가 없으면 실제 Redis 통합 테스트만 건너뛰며 나머지 단위/회귀 테스트는 실행 가능하다. 환경 변수를 바꿔 다시 검증할 때에는 Gradle 캐시 영향을 피하려고 `--rerun-tasks`를 추가할 수 있다. + +Spring Boot는 테스트의 RANDOM_PORT에서 실제 HTTP를 처리했다. 실제 개발 MySQL에는 연결/변경하지 않았다. 따라서 이번 연동의 MySQL 직접 통합 검증과 실 Gmail 전송은 수행하지 않았으며, 기존 MySQL UNIQUE 및 트랜잭션 동작을 전제로 한다. + +GmailEmailSender, Entity, build.gradle, compose.yml, application 설정 3개의 총 7개 보호 파일은 최초와 해시가 일치한다. `.env`는 읽거나 출력하지 않았다. 실제 회원 데이터, DB 스키마, Docker 볼륨을 변경하지 않았고 commit/push/merge/stage도 수행하지 않았다. + +`git diff --check` 및 신규 파일 별도 공백 검사를 통과했다. 기존 Git 변경은 그대로 남아 있으므로 전체 git diff에는 이전 이메일/DB 작업도 함께 보인다. 이 문서의 파일 목록이 이번 연동의 변경 범위다. diff --git a/docs/api/email-verification.md b/docs/api/email-verification.md new file mode 100644 index 0000000..b2033ca --- /dev/null +++ b/docs/api/email-verification.md @@ -0,0 +1,133 @@ +# 이메일 인증 토큰과 회원가입 API 계약 + +2026-10-25. `feature/email-verification`의 클라이언트 결합 인증 토큰 변경 사항이다. 공통 `ApiResponse`와 기존 오류 코드는 그대로 사용한다. 원본 API 명세 전체는 접근 가능한 저장소/문서에서 확인되지 않았으므로, 사용자가 지정한 성공 응답과 현재 `ErrorCode`를 이 변경의 기준으로 삼았다. + +## 프론트엔드 흐름 + +1. 이메일·비밀번호·비밀번호 확인·닉네임을 입력 상태에 보관한다. +2. `POST /auth/email/send`로 인증번호를 요청한다. +3. `POST /auth/email/verify`에 이메일과 인증번호를 보낸다. +4. 성공 응답의 `data.verificationToken`을 받자마자 보관 중인 가입 정보와 함께 `POST /auth/signup`을 호출한다. +5. 가입 201 응답을 받은 경우에만 완료 화면을 표시한다. 인증 성공만으로 가입 완료로 표시하지 않는다. + +별도의 두 번째 가입 버튼은 필요하지 않다. 프론트엔드 코드는 이번 백엔드 변경 범위에 포함하지 않았다. 이메일 인증 토큰은 가입 권한만 증명하며, 로그인 JWT로 사용할 수 없다. 비밀번호·토큰은 콘솔/분석 이벤트/서버 로그에 기록하지 말고 브라우저 입력 상태에서만 짧게 보관한다. + +## 인증번호 검증 + +`POST /auth/email/verify`, 인증 불필요. + +```json +{ + "email": "user@example.com", + "code": "123456" +} +``` + +200 성공 응답: + +```json +{ + "success": true, + "message": "회원가입 이메일 인증번호 확인 요청이 처리되었습니다.", + "data": { + "email": "user@example.com", + "verified": true, + "expiresIn": 600, + "verificationToken": "발급된-43자리-Base64URL-토큰" + } +} +``` + +위 토큰 문자열은 설명용 자리표시자다. 실제 값은 `SecureRandom` 32바이트(256비트)를 패딩 없는 Base64URL로 인코딩한 ASCII 43자리다. 이 값은 검증에 성공한 요청의 응답에만 반환한다. `expiresIn`은 초 단위이고 Redis 설정에 사용하는 동일한 600초 Duration에서 계산한다. 실제 만료 기준은 발급 시점의 서버 Redis TTL이다. 네트워크 지연 동안에도 시간이 흐르며 가입 요청은 만료 전에 토큰을 예약해야 한다. + +기존 `email`, `verified`, `expiresIn` 응답 필드를 포함하고 `verificationToken`을 추가했다. DTO는 `EmailVerificationResponse` record다. 현재 작업 전 구현은 data=null이었으며, 이번 변경에서 요청된 명세 필드를 구현했다. + +## 회원가입 + +`POST /auth/signup`, 인증 불필요. 요청에 `verificationToken`이 새 필수 필드로 추가된다. + +```json +{ + "email": "user@example.com", + "password": "Test1234!", + "passwordConfirm": "Test1234!", + "nickname": "테스트", + "verificationToken": "이메일-검증-응답에서-받은-43자리-값" +} +``` + +토큰 자리표시자는 실제 검증 응답 값으로 바꿔야 한다. 토큰은 trim·소문자 변환하지 않으며 정확히 전달한다. 이메일만 기존 규칙대로 trim + Locale.ROOT 소문자 변환한다. + +201 성공 응답은 기존 계약을 유지한다. + +```json +{ + "success": true, + "message": "회원가입이 완료되었습니다.", + "data": { + "userId": 123, + "email": "user@example.com", + "nickname": "테스트" + } +} +``` + +인증되지 않은 이메일, 다른 이메일의 토큰, 잘못된 토큰, 만료·소비된 토큰은 가입할 수 없다. 새 인증번호 발송은 기존 인증을 폐기하고, 새 번호 검증 완료 시 새 토큰 해시가 저장되어 이전 토큰으로 새 예약을 만들 수 없다. 이미 만료 전에 올바른 토큰으로 예약한 DB 트랜잭션은 기존 동작대로 완료될 수 있다. 재인증이 이미 진행 중인 DB 트랜잭션을 취소하는 것은 아니다. + +## 실패 응답과 상태 코드 + +모든 실패는 기존 `ApiResponse`의 `success=false`, `message`, `data=null`, `error.code`, `error.details`를 사용한다. 새 `TOO_MANY_ATTEMPTS` 코드를 만들지 않았다. + +| 상황 | HTTP | 기존 ErrorCode | +|---|---|---| +| 인증번호 불일치 | 400 | EMAIL_CODE_MISMATCH | +| 인증번호 없음/만료/이미 소비됨 | 400 | EMAIL_CODE_EXPIRED | +| 인증번호 입력 횟수 초과 | 429 | EMAIL_ATTEMPTS_EXCEEDED | +| 60초 재발송 제한 | 429 | EMAIL_RESEND_TOO_SOON | +| 토큰 누락·빈 값·43자리 Base64URL 규칙 불일치 | 400 | VALIDATION_ERROR | +| 형태는 맞으나 토큰/이메일 불일치·만료·소비, 과거 값 1 | 400 | EMAIL_NOT_VERIFIED | +| 같은 올바른 토큰의 다른 가입 요청이 예약 중 | 409 | EMAIL_VERIFICATION_IN_PROGRESS | +| 이미 가입한 이메일 | 409 | DUPLICATE_EMAIL | +| 비밀번호 확인 불일치 | 400 | PASSWORD_MISMATCH | +| Redis 장애/예상하지 못한 결과 | 503 | EMAIL_VERIFICATION_UNAVAILABLE | +| 메일 발송 실패 | 503 | EMAIL_SEND_FAILED | + +가입의 기존 비밀번호 확인/중복 이메일 선행 검사 순서는 유지한다. 잘못된 토큰을 제출한 요청에는 예약 중 상태를 노출하지 않는다. DTO를 거치지 않는 서비스 호출도 토큰 형식과 해시를 검증한다. + +입력 횟수 초과의 실제 응답: + +```json +{ + "success": false, + "message": "인증번호 입력 횟수를 초과했습니다.", + "data": null, + "error": { + "code": "EMAIL_ATTEMPTS_EXCEEDED", + "details": [] + } +} +``` + +`VALIDATION_ERROR`의 details에는 해당 필드의 검증 이유가 들어가며 토큰 원문은 포함하지 않는다. 실패 응답에서 새 인증 토큰을 발급하지 않는다. + +## Redis 일회성과 DB 정합성 + +- `email:verified:{정규화 이메일}`에는 원문 토큰 대신 `proof:<이메일과 토큰에 결합된 SHA-256 해시>`를 저장한다. 도메인 구분 문자열과 구분자를 포함한다. 랜덤 토큰 원문은 Redis 키·값·Lua 인자에 넣지 않는다. +- 번호 검증 성공·번호 소비·기존 토큰 교체·600초 TTL 설정은 하나의 Lua 스크립트로 수행한다. +- 가입 Lua는 해시가 일치하고 PTTL이 양수일 때만 값을 `signup:<같은 해시>:<요청별 UUID>`로 바꾸며 KEEPTTL을 사용한다. 같은 올바른 토큰의 두 요청은 동시에 예약할 수 없다. +- `saveAndFlush()`는 커밋이 아니다. `afterCompletion(COMMITTED)`에서 내 예약 값이 일치할 때만 삭제한다. +- 확정 롤백이면 내 예약이 아직 살아 있을 때 같은 proof 해시를 KEEPTTL로 복구한다. 만료된 토큰이나 다른 새 토큰을 복구/덮어쓰기하지 않는다. 결과 불명(UNKNOWN)은 토큰을 복구하지 않는다. +- 커밋 후 Redis 정리 오류를 이유로 이미 완료된 가입을 실패로 응답하지 않는다. 예약 값은 인증 가능 proof가 아니므로 원래 TTL까지 재사용되지 않는다. +- 기존 Redis 값 `1`을 허용하는 호환 경로나 토큰 없는 가입 오버로드는 없다. 이전 인증 사용자는 재인증해야 한다. 실제 Redis 데이터를 마이그레이션하거나 전체 삭제하지 않았다. + +토큰은 bearer 증명이므로 탈취한 사람도 사용할 수 있다. 실제 배포는 HTTPS를 사용해야 하며 클라이언트/프록시 로그의 body 수집을 피해야 한다. MySQL과 Redis는 분산 트랜잭션이 아니므로 프로세스 종료·Redis 장애·failover 시 재인증이 필요할 수 있다. 한 토큰으로 동시에 만든 계정의 중복은 기존 DB UNIQUE가 마지막으로 차단한다. + +## 테스트 + +실제 Redis 통합 테스트는 `BABI_REDIS_TEST_PORT`를 지정해 실행한다. H2 + 실제 로컬 Redis + mock EmailSender를 사용하며 개발 MySQL/Gmail은 사용하지 않는다. + +```bash +JAVA_HOME=/path/to/java21 BABI_REDIS_TEST_PORT=6379 ./gradlew clean test --offline --console=plain +``` + +검증 항목: 정상 검증→자동 가입→로그인, 누락/잘못된/다른 이메일/만료/소비 토큰 거부, 동일 토큰 동시 요청, 실제 flush 및 커밋 전 실패 보상, 원래 TTL 유지, 재인증 시 이전 토큰 무효화, 과거 전역 값 1 거부, 다른 HTTP 클라이언트의 계정 선점 차단, Redis 오류 처리 및 기존 JWT 회귀 테스트. diff --git a/src/main/java/com/select/domain/auth/controller/EmailVerificationController.java b/src/main/java/com/select/domain/auth/controller/EmailVerificationController.java new file mode 100644 index 0000000..db1a0ee --- /dev/null +++ b/src/main/java/com/select/domain/auth/controller/EmailVerificationController.java @@ -0,0 +1,33 @@ +package com.select.domain.auth.controller; + +import com.select.domain.auth.dto.request.EmailSendRequest; +import com.select.domain.auth.dto.request.EmailVerifyRequest; +import com.select.domain.auth.dto.response.EmailVerificationResponse; +import com.select.domain.auth.service.EmailVerificationService; +import com.select.global.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/auth/email") +@RequiredArgsConstructor +public class EmailVerificationController { + + private final EmailVerificationService emailVerificationService; + + @PostMapping("/send") + public ApiResponse send(@Valid @RequestBody EmailSendRequest request) { + emailVerificationService.sendVerificationCode(request.email()); + return new ApiResponse<>(true,"인증번호가 발송되었습니다.",null); + } + + @PostMapping("/verify") + public ApiResponse verify(@Valid @RequestBody EmailVerifyRequest request) { + EmailVerificationResponse response = emailVerificationService.verifyCode(request.email(),request.code()); + return new ApiResponse<>(true,"회원가입 이메일 인증번호 확인 요청이 처리되었습니다.",response); + } +} diff --git a/src/main/java/com/select/domain/auth/dto/request/EmailSendRequest.java b/src/main/java/com/select/domain/auth/dto/request/EmailSendRequest.java new file mode 100644 index 0000000..aafaf85 --- /dev/null +++ b/src/main/java/com/select/domain/auth/dto/request/EmailSendRequest.java @@ -0,0 +1,18 @@ +package com.select.domain.auth.dto.request; + +import com.select.global.util.EmailNormalizer; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.NotBlank; + +public record EmailSendRequest( + @Email + @Size(max = 320) + @NotBlank + String email +) { + public EmailSendRequest { + email = EmailNormalizer.normalize(email); + } +} diff --git a/src/main/java/com/select/domain/auth/dto/request/EmailVerifyRequest.java b/src/main/java/com/select/domain/auth/dto/request/EmailVerifyRequest.java new file mode 100644 index 0000000..93ae97e --- /dev/null +++ b/src/main/java/com/select/domain/auth/dto/request/EmailVerifyRequest.java @@ -0,0 +1,23 @@ +package com.select.domain.auth.dto.request; + +import com.select.global.util.EmailNormalizer; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record EmailVerifyRequest( + @Email + @Size(max = 320) + @NotBlank + String email, + + @Pattern(regexp = "\\d{6}") + @NotBlank + String code +) { + public EmailVerifyRequest { + email = EmailNormalizer.normalize(email); + } +} diff --git a/src/main/java/com/select/domain/auth/dto/response/EmailVerificationResponse.java b/src/main/java/com/select/domain/auth/dto/response/EmailVerificationResponse.java new file mode 100644 index 0000000..dba5570 --- /dev/null +++ b/src/main/java/com/select/domain/auth/dto/response/EmailVerificationResponse.java @@ -0,0 +1,9 @@ +package com.select.domain.auth.dto.response; + +public record EmailVerificationResponse( + String email, + boolean verified, + long expiresIn, + String verificationToken +) { +} diff --git a/src/main/java/com/select/domain/auth/exception/EmailVerificationException.java b/src/main/java/com/select/domain/auth/exception/EmailVerificationException.java new file mode 100644 index 0000000..4f3134b --- /dev/null +++ b/src/main/java/com/select/domain/auth/exception/EmailVerificationException.java @@ -0,0 +1,20 @@ +package com.select.domain.auth.exception; + +import com.select.global.exception.ErrorCode; +import lombok.Getter; + +@Getter +public class EmailVerificationException extends RuntimeException { + + private final ErrorCode errorCode; + + public EmailVerificationException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public EmailVerificationException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/select/domain/auth/mail/EmailSender.java b/src/main/java/com/select/domain/auth/mail/EmailSender.java new file mode 100644 index 0000000..6c79454 --- /dev/null +++ b/src/main/java/com/select/domain/auth/mail/EmailSender.java @@ -0,0 +1,6 @@ +package com.select.domain.auth.mail; + +public interface EmailSender { + + void send(String to, String subject, String body); +} diff --git a/src/main/java/com/select/domain/auth/mail/GmailEmailSender.java b/src/main/java/com/select/domain/auth/mail/GmailEmailSender.java new file mode 100644 index 0000000..8cedcc2 --- /dev/null +++ b/src/main/java/com/select/domain/auth/mail/GmailEmailSender.java @@ -0,0 +1,31 @@ +package com.select.domain.auth.mail; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +@Component +public class GmailEmailSender implements EmailSender { + + private final JavaMailSender mailSender; + private final String from; + + public GmailEmailSender( + JavaMailSender mailSender, + @Value("${spring.mail.username}") String from + ) { + this.mailSender = mailSender; + this.from = from; + } + + @Override + public void send(String to, String subject, String body) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject(subject); + message.setText(body); + message.setFrom(from); + mailSender.send(message); + } +} diff --git a/src/main/java/com/select/domain/auth/service/EmailVerificationService.java b/src/main/java/com/select/domain/auth/service/EmailVerificationService.java new file mode 100644 index 0000000..e92e686 --- /dev/null +++ b/src/main/java/com/select/domain/auth/service/EmailVerificationService.java @@ -0,0 +1,339 @@ +package com.select.domain.auth.service; + +import com.select.domain.auth.mail.EmailSender; +import com.select.domain.auth.dto.response.EmailVerificationResponse; +import com.select.domain.auth.exception.EmailVerificationException; +import com.select.global.exception.ErrorCode; +import com.select.global.util.EmailNormalizer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.HexFormat; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +@Service +public class EmailVerificationService { + + private static final Logger log = LoggerFactory.getLogger(EmailVerificationService.class); + + private final EmailSender emailSender; + private final SecureRandom secureRandom = new SecureRandom(); + private final StringRedisTemplate redisTemplate; + private static final String EMAIL_VERIFICATION_PREFIX = "email:verification:"; + private static final Duration VERIFICATION_TTL = Duration.ofMinutes(5); + private final String verificationSecret; + private static final Duration RESEND_COOLDOWN = Duration.ofSeconds(60); + private static final String RESEND_PREFIX = "email:resend:"; + private static final DefaultRedisScript DELETE_IF_MATCHES_SCRIPT; + private static final DefaultRedisScript VERIFY_CODE_SCRIPT; + private static final String ATTEMPTS_PREFIX = "email:attempts:"; + private static final int MAX_VERIFY_ATTEMPTS = 5; + private static final String VERIFIED_PREFIX = "email:verified:"; + private static final Duration ATTEMPTS_TTL = Duration.ofMinutes(10); + private static final Duration VERIFIED_TTL = Duration.ofMinutes(10); + private static final DefaultRedisScript SAVE_CODE_SCRIPT; + + private static final DefaultRedisScript RESERVE_SIGNUP_SCRIPT = new DefaultRedisScript<>(""" + local value = redis.call('GET', KEYS[1]) + local ttl = redis.call('PTTL', KEYS[1]) + if not value or ttl <= 0 then + return -1 + end + if value == 'proof:' .. ARGV[1] then + redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') + return 1 + end + local reservationPrefix = 'signup:' .. ARGV[1] .. ':' + if string.sub(value, 1, string.len(reservationPrefix)) == reservationPrefix then + return -2 + end + return -1 + """, Long.class); + + private static final DefaultRedisScript COMPLETE_SIGNUP_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return 0 + end + if ARGV[2] == 'rollback' and redis.call('PTTL', KEYS[1]) > 0 then + redis.call('SET', KEYS[1], ARGV[3], 'KEEPTTL') + return 1 + end + return redis.call('DEL', KEYS[1]) + """, Long.class); + + static { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(""" + if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) + else + return 0 + end + """); + script.setResultType(Long.class); + DELETE_IF_MATCHES_SCRIPT = script; + } + + static { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(""" + local savedHash = redis.call('GET', KEYS[1]) + if not savedHash then + return -1 + end + local attempts = tonumber(redis.call('GET', KEYS[2]) or '0') + local maxAttempts = tonumber(ARGV[2]) + if attempts >= maxAttempts then + return -2 + end + if savedHash ~= ARGV[1] then + local newAttempts = redis.call('INCR', KEYS[2]) + if newAttempts == 1 then + redis.call('PEXPIRE', KEYS[2], ARGV[4]) + end + if newAttempts >= maxAttempts then + return -2 + end + return 0 + end + redis.call('SET', KEYS[3], ARGV[5], 'PX', ARGV[3]) + redis.call('DEL', KEYS[1]) + return 1 + """); + script.setResultType(Long.class); + VERIFY_CODE_SCRIPT = script; + } + + static { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptText(""" + -- A slow SMTP request must not overwrite a newer resend's state. + if redis.call('GET', KEYS[3]) ~= ARGV[3] or redis.call('PTTL', KEYS[3]) <= 0 then + return 0 + end + redis.call('DEL', KEYS[2]) + redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) + return 1 + """); + script.setResultType(Long.class); + SAVE_CODE_SCRIPT = script; + } + + public EmailVerificationService( + EmailSender emailSender, + StringRedisTemplate redisTemplate, + @Value("${email.verification.secret}") String verificationSecret + ) { + this.emailSender = emailSender; + this.redisTemplate = redisTemplate; + this.verificationSecret = verificationSecret; + } + + private String generateCode() { + int number = secureRandom.nextInt(1_000_000); + return String.format(Locale.ROOT, "%06d", number); + } + + private String generateKey(String email) { + return EMAIL_VERIFICATION_PREFIX + email; + } + + public void sendVerificationCode(String email) { + email = EmailNormalizer.normalize(email); + String resendKey = RESEND_PREFIX + email; + String requestId = UUID.randomUUID().toString(); + Boolean result; + try { + result = redisTemplate.opsForValue().setIfAbsent(resendKey,requestId,RESEND_COOLDOWN); + } catch (DataAccessException e) { + throw new EmailVerificationException(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE, e); + } + if (!Boolean.TRUE.equals(result)) { + throw new EmailVerificationException(ErrorCode.EMAIL_RESEND_TOO_SOON); + } + String key = generateKey(email); + String verifiedKey = VERIFIED_PREFIX + email; + try { + String code = generateCode(); + String hashedCode = hashCode(email, code); + // 기존 번호와 인증 완료 상태는 SMTP 실패 시에도 사용할 수 있어야 한다. + emailSender.send(email,"[BABI] 이메일 인증번호 안내", + "BABI 이메일 인증번호는 " + code + "입니다.\n인증번호는 5분 동안 유효합니다."); + Long saveResult = redisTemplate.execute( + SAVE_CODE_SCRIPT, + List.of(key, verifiedKey, resendKey), + hashedCode, + String.valueOf(VERIFICATION_TTL.toMillis()), + requestId + ); + if (!Long.valueOf(1L).equals(saveResult)) { + throw new EmailVerificationException(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE); + } + } catch(RuntimeException e) { + try { + deleteIfMatches(resendKey, requestId); + } catch (RuntimeException cleanupException) { + e.addSuppressed(cleanupException); + } + if (e instanceof EmailVerificationException verificationException) { + throw verificationException; + } + throw new EmailVerificationException( + e instanceof DataAccessException ? ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE : ErrorCode.EMAIL_SEND_FAILED, e); + } + } + + private String hashCode(String email, String code) { + try { + SecretKeySpec keySpec = new SecretKeySpec( + verificationSecret.getBytes(StandardCharsets.UTF_8), + "HmacSHA256" + ); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(keySpec); + + String data = email +":" + code; + byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + + return HexFormat.of().formatHex(hash); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("이메일 인증번호 암호화 처리에 실패했습니다.",e); + } + } + + private void deleteIfMatches(String key, String expectedValue) { + redisTemplate.execute( + DELETE_IF_MATCHES_SCRIPT, + List.of(key), + expectedValue + ); + } + + public EmailVerificationResponse verifyCode(String email, String code) { + email = EmailNormalizer.normalize(email); + String key = generateKey(email); + String attemptsKey = ATTEMPTS_PREFIX + email; + String verifiedKey = VERIFIED_PREFIX + email; + String hashedCode = hashCode(email, code); + byte[] tokenBytes = new byte[32]; + secureRandom.nextBytes(tokenBytes); + String verificationToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + String tokenHash = hashVerificationToken(email, verificationToken); + Long result; + try { + result = redisTemplate.execute( + VERIFY_CODE_SCRIPT, + List.of(key, attemptsKey, verifiedKey), + hashedCode, + String.valueOf(MAX_VERIFY_ATTEMPTS), + String.valueOf(VERIFIED_TTL.toMillis()), + String.valueOf(ATTEMPTS_TTL.toMillis()), + "proof:" + tokenHash + ); + } catch (DataAccessException e) { + throw new EmailVerificationException(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE, e); + } + if (Long.valueOf(-1L).equals(result)) { + throw new EmailVerificationException(ErrorCode.EMAIL_CODE_EXPIRED); + } + if (Long.valueOf(-2L).equals(result)) { + throw new EmailVerificationException(ErrorCode.EMAIL_ATTEMPTS_EXCEEDED); + } + if (Long.valueOf(0L).equals(result)) { + throw new EmailVerificationException(ErrorCode.EMAIL_CODE_MISMATCH); + } + if (!Long.valueOf(1L).equals(result)) { + throw new EmailVerificationException(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE); + } + return new EmailVerificationResponse(email, true, VERIFIED_TTL.toSeconds(), verificationToken); + } + + private String hashVerificationToken(String email, String verificationToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + String input = "BABI_EMAIL_VERIFICATION_V1\0" + email + "\0" + verificationToken; + return HexFormat.of().formatHex(digest.digest(input.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("이메일 인증 토큰 해시 처리에 실패했습니다.", e); + } + } + + public void reserveForSignup(String email, String verificationToken) { + if (verificationToken == null || !verificationToken.matches("[A-Za-z0-9_-]{43}")) { + throw new EmailVerificationException(ErrorCode.EMAIL_NOT_VERIFIED); + } + if (!TransactionSynchronizationManager.isActualTransactionActive() + || !TransactionSynchronizationManager.isSynchronizationActive()) { + throw new IllegalStateException("이메일 인증 예약에는 회원가입 트랜잭션이 필요합니다."); + } + String normalizedEmail = EmailNormalizer.normalize(email); + String verifiedKey = VERIFIED_PREFIX + normalizedEmail; + String tokenHash = hashVerificationToken(normalizedEmail, verificationToken); + String reservation = "signup:" + tokenHash + ":" + UUID.randomUUID(); + Long result; + try { + result = redisTemplate.execute(RESERVE_SIGNUP_SCRIPT, List.of(verifiedKey), tokenHash, reservation); + } catch (DataAccessException e) { + throw new EmailVerificationException(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE, e); + } + if (Long.valueOf(-2L).equals(result)) { + throw new EmailVerificationException(ErrorCode.EMAIL_VERIFICATION_IN_PROGRESS); + } + if (Long.valueOf(-1L).equals(result)) { + throw new EmailVerificationException(ErrorCode.EMAIL_NOT_VERIFIED); + } + if (!Long.valueOf(1L).equals(result)) { + throw new EmailVerificationException(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE); + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + String action = status == STATUS_ROLLED_BACK ? "rollback" : "consume"; + try { + Long cleanupResult = redisTemplate.execute( + COMPLETE_SIGNUP_SCRIPT, List.of(verifiedKey), reservation, action, "proof:" + tokenHash); + if (!Long.valueOf(0L).equals(cleanupResult) && !Long.valueOf(1L).equals(cleanupResult)) { + log.warn("회원가입 이메일 인증 예약 정리 결과가 올바르지 않습니다. cleanupId={} action={} transactionStatus={} result={}", + UUID.randomUUID(), action, status, cleanupResult); + } + } catch (RuntimeException e) { + log.warn("회원가입 이메일 인증 예약 정리에 실패했습니다. cleanupId={} action={} transactionStatus={}", + UUID.randomUUID(), action, status, sanitizedFailure(e)); + } + } + }); + } + + private Throwable sanitizedFailure(Throwable failure) { + Throwable sanitized = new Throwable(failure.getClass().getName()); + sanitized.setStackTrace(failure.getStackTrace()); + Throwable target = sanitized; + Throwable cause = failure.getCause(); + for (int depth = 0; cause != null && depth < 5; depth++) { + Throwable safeCause = new Throwable(cause.getClass().getName()); + safeCause.setStackTrace(cause.getStackTrace()); + target.initCause(safeCause); + target = safeCause; + cause = cause.getCause(); + } + return sanitized; + } + +} diff --git a/src/main/java/com/select/domain/user/dto/request/LoginRequest.java b/src/main/java/com/select/domain/user/dto/request/LoginRequest.java index cb367ef..4c33129 100644 --- a/src/main/java/com/select/domain/user/dto/request/LoginRequest.java +++ b/src/main/java/com/select/domain/user/dto/request/LoginRequest.java @@ -1,5 +1,7 @@ package com.select.domain.user.dto.request; +import com.select.global.util.EmailNormalizer; + import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -15,8 +17,6 @@ public record LoginRequest( String password ) { public LoginRequest{ - if (email != null) { - email = email.trim(); - } + email = EmailNormalizer.normalize(email); } } diff --git a/src/main/java/com/select/domain/user/dto/request/SignupRequest.java b/src/main/java/com/select/domain/user/dto/request/SignupRequest.java index 981505d..2c67c4f 100644 --- a/src/main/java/com/select/domain/user/dto/request/SignupRequest.java +++ b/src/main/java/com/select/domain/user/dto/request/SignupRequest.java @@ -1,5 +1,7 @@ package com.select.domain.user.dto.request; +import com.select.global.util.EmailNormalizer; + import jakarta.validation.constraints.*; public record SignupRequest( @@ -23,12 +25,14 @@ public record SignupRequest( @NotBlank @Size(max = 50) - String nickname + String nickname, + + @NotBlank + @Pattern(regexp = "[A-Za-z0-9_-]{43}") + String verificationToken ) { public SignupRequest { - if (email != null) { - email = email.trim(); - } + email = EmailNormalizer.normalize(email); } } diff --git a/src/main/java/com/select/domain/user/service/UserService.java b/src/main/java/com/select/domain/user/service/UserService.java index b8f4e7d..d5be520 100644 --- a/src/main/java/com/select/domain/user/service/UserService.java +++ b/src/main/java/com/select/domain/user/service/UserService.java @@ -1,5 +1,7 @@ package com.select.domain.user.service; +import com.select.domain.auth.service.EmailVerificationService; +import com.select.global.util.EmailNormalizer; import com.select.domain.user.dto.request.LoginRequest; import com.select.domain.user.dto.request.SignupRequest; import com.select.domain.user.dto.response.LoginResponse; @@ -25,20 +27,23 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtProvider jwtProvider; - // 미가입 이메일도 실제 BCrypt 검증 비용을 거치도록 사용하는 cost 10 더미 해시입니다. + private final EmailVerificationService emailVerificationService; private static final String DUMMY_PASSWORD_HASH = "$2a$10$G48i72gfQyJ7URWzsalGbeZsaw79R8FDiHbAQBaAoYOLgHqoRBQiG"; - public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtProvider jwtProvider) { + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtProvider jwtProvider, + EmailVerificationService emailVerificationService) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; this.jwtProvider = jwtProvider; + this.emailVerificationService = emailVerificationService; } @Transactional public SignupResponse signup(SignupRequest request) { String email = normalizeEmail(request.email()); validateSignup(request, email); + emailVerificationService.reserveForSignup(email, request.verificationToken()); User user = User .builder() .email(email) @@ -57,7 +62,7 @@ public SignupResponse signup(SignupRequest request) { throw e; } } - //회원가입이 올바른지 설명 + private void validateSignup(SignupRequest request, String email) { if (!request.password().equals(request.passwordConfirm())) { throw new PasswordMismatchException(); @@ -68,12 +73,11 @@ private void validateSignup(SignupRequest request, String email) { } } - //정규화한 이메일 반환 + private String normalizeEmail(String email) { - return email.trim().toLowerCase(Locale.ROOT); + return EmailNormalizer.normalize(email); } - //데이터베이스에서 나는 오류가 이메일 때문인지 확인 private boolean isDuplicateEmailViolation(DataIntegrityViolationException e) { Throwable cause = e; while (cause != null) { @@ -88,7 +92,6 @@ private boolean isDuplicateEmailViolation(DataIntegrityViolationException e) { return false; } - //제약조건 오류가 이메일 중복때문에 발생했는지 확인 private boolean matchesEmailConstraint(String constraintName) { if (constraintName == null) { return false; diff --git a/src/main/java/com/select/global/exception/ErrorCode.java b/src/main/java/com/select/global/exception/ErrorCode.java index 37b38f7..5d3852c 100644 --- a/src/main/java/com/select/global/exception/ErrorCode.java +++ b/src/main/java/com/select/global/exception/ErrorCode.java @@ -6,6 +6,15 @@ @Getter public enum ErrorCode { + EMAIL_CODE_MISMATCH(HttpStatus.BAD_REQUEST, "인증번호가 일치하지 않습니다."), + EMAIL_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "인증번호가 없거나 만료되었습니다."), + EMAIL_ATTEMPTS_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "인증번호 입력 횟수를 초과했습니다."), + EMAIL_RESEND_TOO_SOON(HttpStatus.TOO_MANY_REQUESTS, "인증번호를 재발송하려면 60초가 지나야 합니다."), + EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST, "이메일 인증이 필요하거나 인증 유효기간이 만료되었습니다."), + EMAIL_VERIFICATION_IN_PROGRESS(HttpStatus.CONFLICT, "해당 이메일의 회원가입이 진행 중입니다."), + EMAIL_VERIFICATION_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "이메일 인증 서비스를 일시적으로 사용할 수 없습니다."), + EMAIL_SEND_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "인증번호 전송에 실패했습니다."), + DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 가입된 이메일입니다."), PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), diff --git a/src/main/java/com/select/global/exception/GlobalExceptionHandler.java b/src/main/java/com/select/global/exception/GlobalExceptionHandler.java index 86d5acd..6be2daf 100644 --- a/src/main/java/com/select/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/select/global/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package com.select.global.exception; +import com.select.domain.auth.exception.EmailVerificationException; import com.select.domain.user.exception.DuplicateEmailException; import com.select.domain.user.exception.InvalidCredentialsException; import com.select.domain.user.exception.PasswordMismatchException; @@ -30,6 +31,11 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(EmailVerificationException.class) + public ResponseEntity> handleEmailVerification(EmailVerificationException e) { + return createErrorResponse(e.getErrorCode(), List.of()); + } + @ExceptionHandler(DuplicateEmailException.class) public ResponseEntity> handleDuplicateEmail( DuplicateEmailException e diff --git a/src/main/java/com/select/global/security/SecurityConfig.java b/src/main/java/com/select/global/security/SecurityConfig.java index efb51b9..9124f75 100644 --- a/src/main/java/com/select/global/security/SecurityConfig.java +++ b/src/main/java/com/select/global/security/SecurityConfig.java @@ -33,7 +33,11 @@ public SecurityFilterChain securityFilterChain( session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); http.authorizeHttpRequests(auth -> { - auth.requestMatchers(SecurityPaths.LOGIN,SecurityPaths.SIGNUP).permitAll().anyRequest().authenticated(); + auth.requestMatchers( + SecurityPaths.LOGIN, + SecurityPaths.SIGNUP, + SecurityPaths.EMAIL_SEND, + SecurityPaths.EMAIL_VERIFY).permitAll().anyRequest().authenticated(); }); http.exceptionHandling(exception -> { exception.authenticationEntryPoint((request, response, authException) -> { diff --git a/src/main/java/com/select/global/security/SecurityPaths.java b/src/main/java/com/select/global/security/SecurityPaths.java index b5a6b52..146b63e 100644 --- a/src/main/java/com/select/global/security/SecurityPaths.java +++ b/src/main/java/com/select/global/security/SecurityPaths.java @@ -3,6 +3,8 @@ public class SecurityPaths { public static final String LOGIN = "/auth/login"; public static final String SIGNUP = "/auth/signup"; + public static final String EMAIL_SEND = "/auth/email/send"; + public static final String EMAIL_VERIFY = "/auth/email/verify"; private SecurityPaths() {} } diff --git a/src/main/java/com/select/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/select/global/security/jwt/JwtAuthenticationFilter.java index 4847174..a8604d7 100644 --- a/src/main/java/com/select/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/select/global/security/jwt/JwtAuthenticationFilter.java @@ -59,7 +59,10 @@ protected void doFilterInternal(HttpServletRequest request, protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getServletPath(); return path.equals(SecurityPaths.SIGNUP) - || path.equals(SecurityPaths.LOGIN) || "OPTIONS".equals(request.getMethod()); + || path.equals(SecurityPaths.LOGIN) + || path.equals(SecurityPaths.EMAIL_SEND) + || path.equals(SecurityPaths.EMAIL_VERIFY) + || "OPTIONS".equals(request.getMethod()); } diff --git a/src/main/java/com/select/global/util/EmailNormalizer.java b/src/main/java/com/select/global/util/EmailNormalizer.java new file mode 100644 index 0000000..f699280 --- /dev/null +++ b/src/main/java/com/select/global/util/EmailNormalizer.java @@ -0,0 +1,12 @@ +package com.select.global.util; + +import java.util.Locale; + +public final class EmailNormalizer { + + private EmailNormalizer() {} + + public static String normalize(String email) { + return email == null ? null : email.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index fa84d92..f2c9f6f 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -8,6 +8,27 @@ spring: password: ${MYSQL_PASSWORD} jpa: hibernate: - ddl-auto: update + ddl-auto: validate + data: + redis: + host: localhost + port: 6379 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 +email: + verification: + secret: ${EMAIL_VERIFICATION_SECRET} jwt: secret: ${JWT_SECRET} diff --git a/src/test/java/com/select/domain/auth/controller/EmailVerificationControllerTest.java b/src/test/java/com/select/domain/auth/controller/EmailVerificationControllerTest.java new file mode 100644 index 0000000..1941644 --- /dev/null +++ b/src/test/java/com/select/domain/auth/controller/EmailVerificationControllerTest.java @@ -0,0 +1,62 @@ +package com.select.domain.auth.controller; + +import com.select.domain.auth.exception.EmailVerificationException; +import com.select.domain.auth.dto.response.EmailVerificationResponse; +import com.select.domain.auth.service.EmailVerificationService; +import com.select.global.exception.ErrorCode; +import com.select.global.exception.GlobalExceptionHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class EmailVerificationControllerTest { + private EmailVerificationService service; + private MockMvc mvc; + + @BeforeEach + void setUp() { + service = mock(EmailVerificationService.class); + mvc = MockMvcBuilders.standaloneSetup(new EmailVerificationController(service)) + .setControllerAdvice(new GlobalExceptionHandler()).build(); + } + + @Test + void normalizesBothEmailRequestsBeforeCallingService() throws Exception { + mvc.perform(post("/auth/email/send").contentType("application/json") + .content("{\"email\":\" USER@EXAMPLE.COM \"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.success").value(true)); + verify(service).sendVerificationCode("user@example.com"); + when(service.verifyCode("user@example.com", "123456")).thenReturn( + new EmailVerificationResponse("user@example.com", true, 600, "A".repeat(43))); + mvc.perform(post("/auth/email/verify").contentType("application/json") + .content("{\"email\":\" USER@EXAMPLE.COM \",\"code\":\"123456\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("회원가입 이메일 인증번호 확인 요청이 처리되었습니다.")) + .andExpect(jsonPath("$.data.email").value("user@example.com")) + .andExpect(jsonPath("$.data.verified").value(true)) + .andExpect(jsonPath("$.data.expiresIn").value(600)) + .andExpect(jsonPath("$.data.verificationToken").value("A".repeat(43))); + verify(service).verifyCode("user@example.com", "123456"); + } + + @ParameterizedTest + @EnumSource(value = ErrorCode.class, names = {"EMAIL_CODE_MISMATCH", "EMAIL_CODE_EXPIRED", + "EMAIL_ATTEMPTS_EXCEEDED", "EMAIL_RESEND_TOO_SOON", "EMAIL_NOT_VERIFIED", + "EMAIL_VERIFICATION_IN_PROGRESS", "EMAIL_VERIFICATION_UNAVAILABLE", "EMAIL_SEND_FAILED"}) + void errorResponsesFollowApiContract(ErrorCode code) throws Exception { + doThrow(new EmailVerificationException(code)).when(service).verifyCode(anyString(), anyString()); + mvc.perform(post("/auth/email/verify").contentType("application/json") + .content("{\"email\":\"user@example.com\",\"code\":\"123456\"}")) + .andExpect(status().is(code.getHttpStatus().value())) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value(code.getMessage())) + .andExpect(jsonPath("$.error.code").value(code.name())); + } +} diff --git a/src/test/java/com/select/domain/auth/service/EmailSignupIntegrationTest.java b/src/test/java/com/select/domain/auth/service/EmailSignupIntegrationTest.java new file mode 100644 index 0000000..872963f --- /dev/null +++ b/src/test/java/com/select/domain/auth/service/EmailSignupIntegrationTest.java @@ -0,0 +1,578 @@ +package com.select.domain.auth.service; + +import com.select.domain.auth.exception.EmailVerificationException; +import com.select.domain.auth.mail.EmailSender; +import com.select.domain.user.dto.request.SignupRequest; +import com.select.domain.user.exception.DuplicateEmailException; +import com.select.domain.user.repository.UserRepository; +import com.select.domain.user.service.UserService; +import com.select.global.exception.ErrorCode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; +import tools.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import com.select.global.util.EmailNormalizer; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +// H2와 실제 Redis를 사용한다. MySQL/Gmail은 사용하지 않는다. +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@EnabledIfEnvironmentVariable(named = "BABI_REDIS_TEST_PORT", matches = "[0-9]+") +class EmailSignupIntegrationTest { + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> "127.0.0.1"); + registry.add("spring.data.redis.port", () -> System.getenv("BABI_REDIS_TEST_PORT")); + } + + @LocalServerPort private int port; + @Autowired private StringRedisTemplate redis; + @Autowired private EmailVerificationService verification; + @Autowired private UserService users; + @Autowired private UserRepository repository; + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private ObjectMapper mapper; + @MockitoBean private EmailSender sender; + + private final Map tokens = new HashMap<>(); + private final List testEmails = new ArrayList<>(); + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + + @AfterEach + void removeOnlyThisTestsRedisKeys() { + for (String email : testEmails) { + redis.delete(List.of("email:verified:" + email, "email:verification:" + email, + "email:attempts:" + email, "email:resend:" + email)); + } + } + + @Test + void unverifiedAndExpiredEmailCannotSignup() throws Exception { + String email = email(); + assertApi(post("/auth/signup", signupBody(email)), 400, "EMAIL_NOT_VERIFIED"); + verifyNoInteractions(sender); + tokens.put(email, issueToken(email)); + redis.expire(key(email), Duration.ofMillis(1)); + Thread.sleep(20); + assertApi(post("/auth/signup", signupBody(email)), 400, "EMAIL_NOT_VERIFIED"); + assertFalse(repository.existsByEmail(email)); + verify(sender, times(1)).send(eq(email), anyString(), anyString()); + } + + @Test + void sendVerifySignupLoginAndDuplicateUseSameNormalizedEmail() throws Exception { + String email = email(); + AtomicReference code = new AtomicReference<>(); + doAnswer(call -> { + assertEquals(email, call.getArgument(0)); + String body = call.getArgument(2); + code.set(body.replaceAll("(?s).*인증번호는 ([0-9]{6})입니다.*", "$1")); + return null; + }).when(sender).send(anyString(), anyString(), anyString()); + String padded = " " + email.toUpperCase(java.util.Locale.ROOT) + " "; + assertEquals(200, post("/auth/email/send", Map.of("email", padded)).statusCode()); + assertApi(post("/auth/email/send", Map.of("email", email)), 429, "EMAIL_RESEND_TOO_SOON"); + String wrongCode = "000000".equals(code.get()) ? "111111" : "000000"; + assertApi(post("/auth/email/verify", Map.of("email", padded, "code", wrongCode)), 400, "EMAIL_CODE_MISMATCH"); + var verifyResponse = post("/auth/email/verify", Map.of("email", padded, "code", code.get())); + assertEquals(200, verifyResponse.statusCode()); + var data = mapper.readTree(verifyResponse.body()).path("data"); + assertEquals(email, data.path("email").asString()); + assertTrue(data.path("verified").asBoolean()); + assertEquals(600, data.path("expiresIn").asLong()); + String token = data.path("verificationToken").asString(); + tokens.put(email, token); + String stored = redis.opsForValue().get(key(email)); + assertTrue(stored.matches("proof:[0-9a-f]{64}")); + assertFalse(stored.contains(token)); + long ttl = redis.getExpire(key(email), TimeUnit.MILLISECONDS); + assertTrue(ttl > 0 && ttl <= 600000); + assertEquals(201, post("/auth/signup", signupBody(padded)).statusCode()); + assertNull(redis.opsForValue().get(key(email))); + assertTrue(repository.existsByEmail(email)); + var login = post("/auth/login", Map.of("email", padded, "password", "Test1234!")); + assertEquals(200, login.statusCode()); + assertFalse(mapper.readTree(login.body()).path("data").path("accessToken").asString().isBlank()); + assertApi(post("/auth/signup", signupBody(email)), 409, "DUPLICATE_EMAIL"); + assertApi(post("/auth/email/verify", Map.of("email", email, "code", code.get())), 400, "EMAIL_CODE_EXPIRED"); + verify(sender, times(1)).send(anyString(), anyString(), anyString()); + } + + @Test + void enforcesAttemptLimitWithoutGmail() { + String email = email(); + AtomicReference code = new AtomicReference<>(); + doAnswer(call -> { + code.set(((String) call.getArgument(2)).replaceAll("(?s).*인증번호는 ([0-9]{6})입니다.*", "$1")); + return null; + }).when(sender).send(anyString(), anyString(), anyString()); + verification.sendVerificationCode(email); + String wrong = "000000".equals(code.get()) ? "111111" : "000000"; + for (int i = 0; i < 4; i++) { + assertEquals(ErrorCode.EMAIL_CODE_MISMATCH, assertThrows(EmailVerificationException.class, + () -> verification.verifyCode(email, wrong)).getErrorCode()); + } + assertEquals(ErrorCode.EMAIL_ATTEMPTS_EXCEEDED, assertThrows(EmailVerificationException.class, + () -> verification.verifyCode(email, wrong)).getErrorCode()); + assertEquals(ErrorCode.EMAIL_ATTEMPTS_EXCEEDED, assertThrows(EmailVerificationException.class, + () -> verification.verifyCode(email, code.get())).getErrorCode()); + assertNull(redis.opsForValue().get(key(email))); + } + + @Test + void flushedSignupKeepsReservationUntilOuterRollbackAndRestoresRemainingTtl() { + String email = verifiedEmail(); + String proof = redis.opsForValue().get(key(email)); + long before = redis.getExpire(key(email), TimeUnit.MILLISECONDS); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + users.signup(signup(email)); + assertTrue(redis.opsForValue().get(key(email)).startsWith("signup:")); + assertTrue(repository.existsByEmail(email)); + status.setRollbackOnly(); + }); + assertEquals(proof, redis.opsForValue().get(key(email))); + long after = redis.getExpire(key(email), TimeUnit.MILLISECONDS); + assertTrue(after > 0 && after <= before); + assertFalse(repository.existsByEmail(email)); + users.signup(signup(email)); + assertNull(redis.opsForValue().get(key(email))); + } + + @Test + void failureAfterFlushBeforeCommitRestoresProofAndRollsBackUser() { + String email = verifiedEmail(); + String proof = redis.opsForValue().get(key(email)); + assertThrows(IllegalStateException.class, () -> new TransactionTemplate(transactionManager) + .executeWithoutResult(status -> { + users.signup(signup(email)); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void beforeCommit(boolean readOnly) { + throw new IllegalStateException("test commit failure after flush"); + } + }); + })); + assertEquals(proof, redis.opsForValue().get(key(email))); + assertFalse(repository.existsByEmail(email)); + } + + @Test + void rollbackDoesNotResurrectExpiredProof() { + String email = verifiedEmail(); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + users.signup(signup(email)); + redis.expire(key(email), Duration.ofMillis(1)); + try { Thread.sleep(20); } catch (InterruptedException e) { Thread.currentThread().interrupt(); fail(e); } + status.setRollbackOnly(); + }); + assertNull(redis.opsForValue().get(key(email))); + assertFalse(repository.existsByEmail(email)); + } + + @Test + void staleCompletionNeverDeletesOrRestoresOverNewProof() { + String email = verifiedEmail(); + String proof = redis.opsForValue().get(key(email)); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + users.signup(signup(email)); + redis.opsForValue().set(key(email), "new-proof-generation", Duration.ofSeconds(30)); + status.setRollbackOnly(); + }); + assertEquals("new-proof-generation", redis.opsForValue().get(key(email))); + redis.opsForValue().set(key(email), proof, Duration.ofSeconds(30)); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + users.signup(signup(email)); + redis.opsForValue().set(key(email), "new-proof-generation", Duration.ofSeconds(30)); + }); + assertEquals("new-proof-generation", redis.opsForValue().get(key(email))); + } + + @Test + void reservationPreventsSecondRequestWhileFirstTransactionIsOpen() throws Exception { + String email = verifiedEmail(); + CountDownLatch reserved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (var executor = Executors.newSingleThreadExecutor()) { + var first = executor.submit(() -> new TransactionTemplate(transactionManager).execute(status -> { + var response = users.signup(signup(email)); + reserved.countDown(); + try { assertTrue(release.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } + return response; + })); + try { + assertTrue(reserved.await(5, TimeUnit.SECONDS)); + assertEquals(ErrorCode.EMAIL_NOT_VERIFIED, assertThrows(EmailVerificationException.class, + () -> users.signup(new SignupRequest(email, "Test1234!", "Test1234!", "tester", "B".repeat(43)))) + .getErrorCode()); + assertEquals(ErrorCode.EMAIL_VERIFICATION_IN_PROGRESS, assertThrows(EmailVerificationException.class, + () -> users.signup(signup(email))).getErrorCode()); + } finally { release.countDown(); } + assertNotNull(first.get(5, TimeUnit.SECONDS)); + } + assertNull(redis.opsForValue().get(key(email))); + assertNotNull(repository.findByEmail(email).orElseThrow().getId()); + } + + @Test + void concurrentHttpSignupsCreateExactlyOneUser() throws Exception { + String email = verifiedEmail(); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + var a = executor.submit(() -> { start.await(); return post("/auth/signup", signupBody(email)); }); + var b = executor.submit(() -> { start.await(); return post("/auth/signup", signupBody(email)); }); + start.countDown(); + var responses = List.of(a.get(10, TimeUnit.SECONDS), b.get(10, TimeUnit.SECONDS)); + assertEquals(1, responses.stream().filter(r -> r.statusCode() == 201).count()); + assertEquals(1, responses.stream().filter(r -> r.statusCode() == 409).count()); + } + assertEquals(1, repository.findAll().stream().filter(user -> user.getEmail().equals(email)).count()); + assertNull(redis.opsForValue().get(key(email))); + } + + @Test + void duplicateEmailFailureLeavesIndependentFreshVerificationUntouched() { + String email = verifiedEmail(); + users.signup(signup(email)); + redis.delete("email:resend:" + email); + tokens.put(email, issueToken(email)); + String proof = redis.opsForValue().get(key(email)); + assertThrows(DuplicateEmailException.class, () -> users.signup(signup(email))); + assertEquals(proof, redis.opsForValue().get(key(email))); + } + + @Test + void flushFailureRestoresProofAndLeavesNoUser() { + String email = verifiedEmail(); + String proof = redis.opsForValue().get(key(email)); + long before = redis.getExpire(key(email), TimeUnit.MILLISECONDS); + assertThrows(org.springframework.dao.DataIntegrityViolationException.class, () -> users.signup( + new SignupRequest(email, "Test1234!", "Test1234!", "x".repeat(51), tokens.get(email)))); + assertFalse(repository.existsByEmail(email)); + assertEquals(proof, redis.opsForValue().get(key(email))); + long after = redis.getExpire(key(email), TimeUnit.MILLISECONDS); + assertTrue(after > 0 && after <= before); + } + + @Test + void consumedProofCannotBeReservedAgain() { + String email = verifiedEmail(); + var transaction = new TransactionTemplate(transactionManager); + transaction.executeWithoutResult(status -> verification.reserveForSignup(email, tokens.get(email))); + assertNull(redis.opsForValue().get(key(email))); + assertEquals(ErrorCode.EMAIL_NOT_VERIFIED, assertThrows(EmailVerificationException.class, + () -> transaction.executeWithoutResult(status -> verification.reserveForSignup(email, tokens.get(email)))).getErrorCode()); + } + + @Test + void failedMailReleasesOnlyItsOwnCodeAndCooldown() { + String email = email(); + doThrow(new org.springframework.mail.MailSendException("test SMTP failure")) + .when(sender).send(eq(email), anyString(), anyString()); + assertEquals(ErrorCode.EMAIL_SEND_FAILED, assertThrows(EmailVerificationException.class, + () -> verification.sendVerificationCode(email)).getErrorCode()); + assertNull(redis.opsForValue().get("email:resend:" + email)); + assertNull(redis.opsForValue().get("email:verification:" + email)); + doNothing().when(sender).send(eq(email), anyString(), anyString()); + assertDoesNotThrow(() -> verification.sendVerificationCode(email)); + } + + @Test + void missingWrongAndAnotherEmailsTokenCannotSignupOrConsumeVictimsProof() throws Exception { + String email = verifiedEmail(); + String token = tokens.get(email); + String proof = redis.opsForValue().get(key(email)); + var missing = new HashMap<>(signupBody(email)); + missing.remove("verificationToken"); + assertApi(post("/auth/signup", missing), 400, "VALIDATION_ERROR"); + var wrong = new HashMap<>(signupBody(email)); + wrong.put("verificationToken", "B".repeat(43)); + assertApi(post("/auth/signup", wrong), 400, "EMAIL_NOT_VERIFIED"); + String anotherEmail = email(); + var other = new HashMap<>(signupBody(anotherEmail)); + other.put("verificationToken", token); + assertApi(post("/auth/signup", other), 400, "EMAIL_NOT_VERIFIED"); + assertFalse(repository.existsByEmail(email)); + assertFalse(repository.existsByEmail(anotherEmail)); + assertEquals(proof, redis.opsForValue().get(key(email))); + assertEquals(201, post("/auth/signup", signupBody(email)).statusCode()); + } + + @Test + void legacyGlobalVerifiedFlagCannotAuthorizeSignup() throws Exception { + String email = email(); + redis.opsForValue().set(key(email), "1", Duration.ofMinutes(10)); + assertApi(post("/auth/signup", signupBody(email)), 400, "EMAIL_NOT_VERIFIED"); + assertFalse(repository.existsByEmail(email)); + } + + @Test + void reVerificationReplacesOldTokenAndOnlyNewTokenCanSignup() throws Exception { + String email = verifiedEmail(); + String oldToken = tokens.get(email); + String oldProof = redis.opsForValue().get(key(email)); + // 이 테스트가 만든 키만 지워 재발송 대기 시간 경과를 대신한다. + redis.delete("email:resend:" + email); + String newToken = issueToken(email); + tokens.put(email, newToken); + assertNotEquals(oldToken, newToken); + assertEquals(32, java.util.Base64.getUrlDecoder().decode(newToken).length); + assertNotEquals(oldProof, redis.opsForValue().get(key(email))); + var old = new HashMap<>(signupBody(email)); + old.put("verificationToken", oldToken); + assertApi(post("/auth/signup", old), 400, "EMAIL_NOT_VERIFIED"); + assertEquals(201, post("/auth/signup", signupBody(email)).statusCode()); + } + + @Test + void rollbackOfOldReservationDoesNotRestoreTokenAfterReVerification() throws Exception { + String email = verifiedEmail(); + String oldToken = tokens.get(email); + AtomicReference newProof = new AtomicReference<>(); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + users.signup(signup(email)); + redis.delete("email:resend:" + email); + tokens.put(email, issueToken(email)); + newProof.set(redis.opsForValue().get(key(email))); + status.setRollbackOnly(); + }); + assertEquals(newProof.get(), redis.opsForValue().get(key(email))); + var old = new HashMap<>(signupBody(email)); + old.put("verificationToken", oldToken); + assertApi(post("/auth/signup", old), 400, "EMAIL_NOT_VERIFIED"); + assertEquals(201, post("/auth/signup", signupBody(email)).statusCode()); + } + + @Test + void separateClientCannotStealVictimsVerifiedEmailAndVictimCanAutoSignup() throws Exception { + String email = email(); + AtomicReference code = new AtomicReference<>(); + doAnswer(call -> { + code.set(((String) call.getArgument(2)).replaceAll("(?s).*인증번호는 ([0-9]{6})입니다.*", "$1")); + return null; + }).when(sender).send(eq(email), anyString(), anyString()); + HttpClient victim = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + HttpClient attacker = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + assertEquals(200, post(victim, "/auth/email/send", Map.of("email", email)).statusCode()); + var verificationResponse = post(victim, "/auth/email/verify", Map.of("email", email, "code", code.get())); + assertEquals(200, verificationResponse.statusCode()); + String token = mapper.readTree(verificationResponse.body()).path("data").path("verificationToken").asString(); + var attack = new HashMap<>(signupBody(email)); + attack.put("password", "Attacker123!"); + attack.put("passwordConfirm", "Attacker123!"); + attack.remove("verificationToken"); + assertApi(post(attacker, "/auth/signup", attack), 400, "VALIDATION_ERROR"); + attack.put("verificationToken", "B".repeat(43)); + assertApi(post(attacker, "/auth/signup", attack), 400, "EMAIL_NOT_VERIFIED"); + assertFalse(repository.existsByEmail(email)); + // 프론트엔드가 인증 응답 직후 보관한 입력 정보와 토큰을 제출하는 흐름. + tokens.put(email, token); + assertEquals(201, post(victim, "/auth/signup", signupBody(email)).statusCode()); + assertEquals(200, post(victim, "/auth/login", Map.of("email", email, "password", "Test1234!")).statusCode()); + assertApi(post(attacker, "/auth/login", Map.of("email", email, "password", "Attacker123!")), + 400, "INVALID_CREDENTIALS"); + assertNull(redis.opsForValue().get(key(email))); + } + + @Test + void smtpFailurePreservesPreviousCodeAndAttemptsWithRemainingTtl() { + String email = email(); + AtomicReference code = new AtomicReference<>(); + doAnswer(call -> { + code.set(((String) call.getArgument(2)).replaceAll("(?s).*인증번호는 ([0-9]{6})입니다.*", "$1")); + return null; + }).when(sender).send(eq(email), anyString(), anyString()); + verification.sendVerificationCode(email); + String codeKey = "email:verification:" + email; + String attemptsKey = "email:attempts:" + email; + String saved = redis.opsForValue().get(codeKey); + String wrong = "000000".equals(code.get()) ? "111111" : "000000"; + assertThrows(EmailVerificationException.class, () -> verification.verifyCode(email, wrong)); + long before = redis.getExpire(codeKey, TimeUnit.MILLISECONDS); + long attemptsBefore = redis.getExpire(attemptsKey, TimeUnit.MILLISECONDS); + redis.delete("email:resend:" + email); + doThrow(new org.springframework.mail.MailSendException("test SMTP failure")) + .when(sender).send(eq(email), anyString(), anyString()); + assertEquals(ErrorCode.EMAIL_SEND_FAILED, assertThrows(EmailVerificationException.class, + () -> verification.sendVerificationCode(email)).getErrorCode()); + assertEquals(saved, redis.opsForValue().get(codeKey)); + assertEquals("1", redis.opsForValue().get(attemptsKey)); + assertTrue(redis.getExpire(codeKey, TimeUnit.MILLISECONDS) > 0); + assertTrue(redis.getExpire(codeKey, TimeUnit.MILLISECONDS) <= before); + assertTrue(redis.getExpire(attemptsKey, TimeUnit.MILLISECONDS) > 0); + assertTrue(redis.getExpire(attemptsKey, TimeUnit.MILLISECONDS) <= attemptsBefore); + assertTrue(verification.verifyCode(email, code.get()).verified()); + } + + @Test + void smtpFailureKeepsVerifiedTokenUsableForSignup() { + String email = verifiedEmail(); + String proof = redis.opsForValue().get(key(email)); + long before = redis.getExpire(key(email), TimeUnit.MILLISECONDS); + redis.delete("email:resend:" + email); + doAnswer(call -> { + assertEquals(proof, redis.opsForValue().get(key(email))); + throw new org.springframework.mail.MailSendException("test SMTP failure"); + }).when(sender).send(eq(email), anyString(), anyString()); + assertThrows(EmailVerificationException.class, () -> verification.sendVerificationCode(email)); + assertEquals(proof, redis.opsForValue().get(key(email))); + assertTrue(redis.getExpire(key(email), TimeUnit.MILLISECONDS) > 0); + assertTrue(redis.getExpire(key(email), TimeUnit.MILLISECONDS) <= before); + assertDoesNotThrow(() -> users.signup(signup(email))); + assertNull(redis.opsForValue().get(key(email))); + } + + @Test + void staleSmtpCompletionCannotOverwriteNewerResend() { + String email = email(); + AtomicReference latestCode = new AtomicReference<>(); + AtomicReference latestHash = new AtomicReference<>(); + AtomicReference latestOwner = new AtomicReference<>(); + java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { + if (calls.incrementAndGet() == 1) { + // 발송 중 60초 예약이 만료되고 다른 요청이 발송을 마친 상황. + redis.delete("email:resend:" + email); + verification.sendVerificationCode(email); + latestHash.set(redis.opsForValue().get("email:verification:" + email)); + latestOwner.set(redis.opsForValue().get("email:resend:" + email)); + } else { + latestCode.set(((String) call.getArgument(2)).replaceAll("(?s).*인증번호는 ([0-9]{6})입니다.*", "$1")); + } + return null; + }).when(sender).send(eq(email), anyString(), anyString()); + assertEquals(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE, assertThrows(EmailVerificationException.class, + () -> verification.sendVerificationCode(email)).getErrorCode()); + assertEquals(latestHash.get(), redis.opsForValue().get("email:verification:" + email)); + assertEquals(latestOwner.get(), redis.opsForValue().get("email:resend:" + email)); + assertTrue(verification.verifyCode(email, latestCode.get()).verified()); + } + + @Test + void resendCannotResetAttemptLimitOrExtendItsWindow() { + String email = email(); + AtomicReference code = new AtomicReference<>(); + doAnswer(call -> { + code.set(((String) call.getArgument(2)).replaceAll("(?s).*인증번호는 ([0-9]{6})입니다.*", "$1")); + return null; + }).when(sender).send(eq(email), anyString(), anyString()); + verification.sendVerificationCode(email); + String wrong = "000000".equals(code.get()) ? "111111" : "000000"; + for (int i = 0; i < 4; i++) { + assertEquals(ErrorCode.EMAIL_CODE_MISMATCH, assertThrows(EmailVerificationException.class, + () -> verification.verifyCode(email, wrong)).getErrorCode()); + } + String attemptsKey = "email:attempts:" + email; + long before = redis.getExpire(attemptsKey, TimeUnit.MILLISECONDS); + redis.delete("email:resend:" + email); + verification.sendVerificationCode(email); + assertEquals("4", redis.opsForValue().get(attemptsKey)); + assertTrue(redis.getExpire(attemptsKey, TimeUnit.MILLISECONDS) > 0); + assertTrue(redis.getExpire(attemptsKey, TimeUnit.MILLISECONDS) <= before); + String wrongNew = "000000".equals(code.get()) ? "111111" : "000000"; + assertEquals(ErrorCode.EMAIL_ATTEMPTS_EXCEEDED, assertThrows(EmailVerificationException.class, + () -> verification.verifyCode(email, wrongNew)).getErrorCode()); + assertEquals(ErrorCode.EMAIL_ATTEMPTS_EXCEEDED, assertThrows(EmailVerificationException.class, + () -> verification.verifyCode(email, code.get())).getErrorCode()); + assertEquals(ErrorCode.EMAIL_RESEND_TOO_SOON, assertThrows(EmailVerificationException.class, + () -> verification.sendVerificationCode(email)).getErrorCode()); + assertTrue(redis.getExpire("email:resend:" + email, TimeUnit.MILLISECONDS) > 0); + } + + @Test + void reservationRequiresExpiringProofAndKeepsRemainingTtlOnRollback() { + String email = verifiedEmail(); + String proof = redis.opsForValue().get(key(email)); + redis.persist(key(email)); + assertEquals(ErrorCode.EMAIL_NOT_VERIFIED, assertThrows(EmailVerificationException.class, + () -> users.signup(signup(email))).getErrorCode()); + assertEquals(proof, redis.opsForValue().get(key(email))); + redis.expire(key(email), Duration.ofSeconds(30)); + long before = redis.getExpire(key(email), TimeUnit.MILLISECONDS); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + verification.reserveForSignup(email, tokens.get(email)); + assertTrue(redis.opsForValue().get(key(email)).startsWith("signup:")); + long ttl = redis.getExpire(key(email), TimeUnit.MILLISECONDS); + assertTrue(ttl > 0 && ttl <= before); + status.setRollbackOnly(); + }); + assertEquals(proof, redis.opsForValue().get(key(email))); + long after = redis.getExpire(key(email), TimeUnit.MILLISECONDS); + assertTrue(after > 0 && after <= before); + assertFalse(repository.existsByEmail(email)); + } + + private String email() { + String email = "babi-test-" + UUID.randomUUID() + "@example.invalid"; + testEmails.add(email); + return email; + } + + private String verifiedEmail() { + String email = email(); + tokens.put(email, issueToken(email)); + return email; + } + + private String issueToken(String email) { + AtomicReference code = new AtomicReference<>(); + doAnswer(call -> { + code.set(((String) call.getArgument(2)).replaceAll("(?s).*인증번호는 ([0-9]{6})입니다.*", "$1")); + return null; + }).when(sender).send(eq(email), anyString(), anyString()); + verification.sendVerificationCode(email); + return verification.verifyCode(email, code.get()).verificationToken(); + } + + private String key(String email) { return "email:verified:" + email; } + private SignupRequest signup(String email) { return new SignupRequest(email, "Test1234!", "Test1234!", "tester", tokens.get(email)); } + private Map signupBody(String email) { + return Map.of("email", email, "password", "Test1234!", "passwordConfirm", "Test1234!", "nickname", "tester", + "verificationToken", tokens.getOrDefault(EmailNormalizer.normalize(email), "A".repeat(43))); + } + + private HttpResponse post(String path, Map body) throws Exception { + return post(client, path, body); + } + + private HttpResponse post(HttpClient httpClient, String path, Map body) throws Exception { + return httpClient.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)) + .timeout(Duration.ofSeconds(8)).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body))).build(), + HttpResponse.BodyHandlers.ofString()); + } + + private void assertApi(HttpResponse response, int status, String code) throws Exception { + assertEquals(status, response.statusCode(), response.body()); + assertFalse(mapper.readTree(response.body()).path("success").asBoolean()); + assertEquals(code, mapper.readTree(response.body()).path("error").path("code").asString()); + } +} diff --git a/src/test/java/com/select/domain/auth/service/EmailVerificationServiceTest.java b/src/test/java/com/select/domain/auth/service/EmailVerificationServiceTest.java new file mode 100644 index 0000000..ac4efc7 --- /dev/null +++ b/src/test/java/com/select/domain/auth/service/EmailVerificationServiceTest.java @@ -0,0 +1,221 @@ +package com.select.domain.auth.service; + +import com.select.domain.auth.exception.EmailVerificationException; +import com.select.domain.auth.mail.EmailSender; +import com.select.global.exception.ErrorCode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; +import java.util.Locale; +import java.time.Duration; +import org.springframework.data.redis.core.ValueOperations; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(OutputCaptureExtension.class) +@SuppressWarnings("unchecked") +class EmailVerificationServiceTest { + + private StringRedisTemplate redis; + private EmailVerificationService service; + + @BeforeEach + void setUp() { + redis = mock(StringRedisTemplate.class); + service = new EmailVerificationService(mock(EmailSender.class), redis, "test-only-secret"); + } + + @AfterEach + void cleanTransactionContext() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + TransactionSynchronizationManager.setActualTransactionActive(false); + } + + @ParameterizedTest + @CsvSource({"-1,EMAIL_CODE_EXPIRED", "-2,EMAIL_ATTEMPTS_EXCEEDED", "0,EMAIL_CODE_MISMATCH"}) + void translatesVerificationResults(long result, ErrorCode expected) { + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(result); + assertEquals(expected, assertThrows(EmailVerificationException.class, + () -> service.verifyCode(" USER@EXAMPLE.COM ", "123456")).getErrorCode()); + verify(redis).execute(any(RedisScript.class), eq(List.of("email:verification:user@example.com", + "email:attempts:user@example.com", "email:verified:user@example.com")), + anyString(), eq("5"), eq("600000"), eq("600000"), startsWith("proof:")); + } + + @ParameterizedTest + @CsvSource({"-1,EMAIL_NOT_VERIFIED", "-2,EMAIL_VERIFICATION_IN_PROGRESS", "0,EMAIL_VERIFICATION_UNAVAILABLE"}) + void reservationRejectsMissingBusyOrUnexpectedResult(long result, ErrorCode expected) { + beginTransaction(); + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString())).thenReturn(result); + assertEquals(expected, assertThrows(EmailVerificationException.class, + () -> service.reserveForSignup("USER@EXAMPLE.COM", "A".repeat(43))).getErrorCode()); + assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty()); + } + + @Test + void refusesReservationOutsideTransaction() { + assertThrows(IllegalStateException.class, () -> service.reserveForSignup("user@example.com", "A".repeat(43))); + verifyNoInteractions(redis); + } + + @ParameterizedTest + @CsvSource({"0,consume", "1,rollback", "2,consume"}) + void cleanupFollowsActualTransactionCompletion(int status, String action) { + beginTransaction(); + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString())).thenReturn(1L); + service.reserveForSignup(" USER@EXAMPLE.COM ", "A".repeat(43)); + // flush/메서드 반환 시점에는 인증을 정리하지 않는다. + verify(redis, never()).execute(any(RedisScript.class), anyList(), anyString(), anyString(), anyString()); + TransactionSynchronization synchronization = TransactionSynchronizationManager.getSynchronizations().getFirst(); + synchronization.afterCompletion(status); + verify(redis).execute(any(RedisScript.class), eq(List.of("email:verified:user@example.com")), + startsWith("signup:"), eq(action), matches("proof:[0-9a-f]{64}")); + } + + @ParameterizedTest + @CsvSource({"0,consume", "1,rollback"}) + void cleanupFailureLogsSafeCauseWithoutChangingTransactionOutcome(int status, String action, CapturedOutput output) { + beginTransaction(); + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString())).thenReturn(1L); + var failure = new RedisConnectionFailureException("private-command-token-secret", + new IllegalStateException("private-password-code")); + failure.addSuppressed(new IllegalArgumentException("private-suppressed-secret")); + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString(), anyString())) + .thenThrow(failure); + service.reserveForSignup("user@example.com", "A".repeat(43)); + assertDoesNotThrow(() -> TransactionSynchronizationManager.getSynchronizations().getFirst().afterCompletion(status)); + assertTrue(output.getAll().contains("cleanupId=")); + assertTrue(output.getAll().contains("action=" + action)); + assertTrue(output.getAll().contains("transactionStatus=" + status)); + assertTrue(output.getAll().contains(RedisConnectionFailureException.class.getName())); + assertTrue(output.getAll().contains(IllegalStateException.class.getName())); + assertTrue(output.getAll().contains("EmailVerificationServiceTest.java:")); + assertFalse(output.getAll().contains("private-")); + assertFalse(output.getAll().contains("user@example.com")); + assertFalse(output.getAll().contains("A".repeat(43))); + } + + @ParameterizedTest + @ValueSource(longs = {0, 2}) + void cleanupDistinguishesNormalNoOpFromUnexpectedResult(long result, CapturedOutput output) { + beginTransaction(); + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString())).thenReturn(1L); + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString(), anyString())).thenReturn(result); + service.reserveForSignup("user@example.com", "A".repeat(43)); + assertDoesNotThrow(() -> TransactionSynchronizationManager.getSynchronizations().getFirst() + .afterCompletion(TransactionSynchronization.STATUS_COMMITTED)); + assertEquals(result != 0, output.getAll().contains("정리 결과가 올바르지 않습니다")); + } + + @Test + void redisOutageBeforeSendDoesNotCallSmtp() { + var values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.setIfAbsent(anyString(), anyString(), any(Duration.class))) + .thenThrow(new RedisConnectionFailureException("test outage")); + EmailSender sender = mock(EmailSender.class); + var verification = new EmailVerificationService(sender, redis, "test-only-secret"); + assertEquals(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE, assertThrows(EmailVerificationException.class, + () -> verification.sendVerificationCode("user@example.com")).getErrorCode()); + verifyNoInteractions(sender); + } + + @Test + void redisPublicationFailureOnlyReleasesOwnCooldown() { + var values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString(), anyString())) + .thenThrow(new RedisConnectionFailureException("test outage")); + assertEquals(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE, assertThrows(EmailVerificationException.class, + () -> service.sendVerificationCode("user@example.com")).getErrorCode()); + verify(redis).execute(any(RedisScript.class), eq(List.of("email:resend:user@example.com")), anyString()); + verify(redis, never()).execute(any(RedisScript.class), eq(List.of("email:verification:user@example.com")), anyString()); + } + + @Test + void redisOutageDuringVerificationReturnsUnavailable() { + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString(), anyString(), anyString(), anyString())) + .thenThrow(new RedisConnectionFailureException("test outage")); + assertEquals(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE, assertThrows(EmailVerificationException.class, + () -> service.verifyCode("user@example.com", "123456")).getErrorCode()); + } + + @Test + void unavailableRedisRejectsSignupBeforePersistence() { + beginTransaction(); + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString())) + .thenThrow(new RedisConnectionFailureException("test outage")); + assertEquals(ErrorCode.EMAIL_VERIFICATION_UNAVAILABLE, assertThrows(EmailVerificationException.class, + () -> service.reserveForSignup("user@example.com", "A".repeat(43))).getErrorCode()); + } + + @Test + void generatedCodeAlwaysUsesSixAsciiDigitsRegardlessOfServerLocale() { + var values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true); + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString(), anyString())).thenReturn(1L); + EmailSender sender = mock(EmailSender.class); + var verification = new EmailVerificationService(sender, redis, "test-only-secret"); + Locale previous = Locale.getDefault(Locale.Category.FORMAT); + try { + Locale.setDefault(Locale.Category.FORMAT, Locale.forLanguageTag("ar")); + verification.sendVerificationCode("user@example.com"); + var body = org.mockito.ArgumentCaptor.forClass(String.class); + verify(sender).send(eq("user@example.com"), anyString(), body.capture()); + assertTrue(body.getValue().matches("(?s).*인증번호는 [0-9]{6}입니다.*")); + } finally { + Locale.setDefault(Locale.Category.FORMAT, previous); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "short", "token.with.jwt.dots", " AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}) + void rejectsMissingOrMalformedTokenEvenWithoutMvc(String token) { + assertEquals(ErrorCode.EMAIL_NOT_VERIFIED, assertThrows(EmailVerificationException.class, + () -> service.reserveForSignup("user@example.com", token)).getErrorCode()); + verifyNoInteractions(redis); + } + + @Test + void returnsRandomTokenButSendsOnlyDigestToRedis() { + when(redis.execute(any(RedisScript.class), anyList(), anyString(), anyString(), anyString(), anyString(), anyString())) + .thenReturn(1L); + var response = service.verifyCode(" USER@EXAMPLE.COM ", "123456"); + assertEquals("user@example.com", response.email()); + assertTrue(response.verified()); + assertEquals(600, response.expiresIn()); + assertTrue(response.verificationToken().matches("[A-Za-z0-9_-]{43}")); + var stored = org.mockito.ArgumentCaptor.forClass(String.class); + verify(redis).execute(any(RedisScript.class), anyList(), anyString(), anyString(), eq("600000"), + anyString(), stored.capture()); + assertTrue(stored.getValue().matches("proof:[0-9a-f]{64}")); + assertFalse(stored.getValue().contains(response.verificationToken())); + } + + private void beginTransaction() { + TransactionSynchronizationManager.initSynchronization(); + TransactionSynchronizationManager.setActualTransactionActive(true); + } +} diff --git a/src/test/java/com/select/domain/user/controller/UserControllerTest.java b/src/test/java/com/select/domain/user/controller/UserControllerTest.java index d60080f..bdeefda 100644 --- a/src/test/java/com/select/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/select/domain/user/controller/UserControllerTest.java @@ -1,5 +1,7 @@ package com.select.domain.user.controller; +import com.select.domain.auth.service.EmailVerificationService; + import com.select.domain.user.dto.request.LoginRequest; import com.select.domain.user.dto.response.LoginResponse; import com.select.domain.user.dto.response.SignupResponse; @@ -153,7 +155,7 @@ void signupWithPaddedUppercaseEmailStoresNormalizedEmail() throws Exception { .thenAnswer(invocation -> invocation.getArgument(0)); MockMvc signupMockMvc = MockMvcBuilders - .standaloneSetup(new UserController(new UserService(userRepository, passwordEncoder, mock(JwtProvider.class)))) + .standaloneSetup(new UserController(new UserService(userRepository, passwordEncoder, mock(JwtProvider.class), mock(EmailVerificationService.class)))) .setControllerAdvice(new GlobalExceptionHandler()) .build(); @@ -217,7 +219,8 @@ void signupPasswordConfirmWith21CharactersReturnsValidationErrorBeforeCallingSer "email": "test@example.com", "password": "Test1234!", "passwordConfirm": "%s", - "nickname": "tester" + "nickname": "tester", + "verificationToken": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } """.formatted("a".repeat(21)); @@ -276,7 +279,8 @@ private String signupJson(String email, String password) { "email": "%s", "password": "%s", "passwordConfirm": "%s", - "nickname": "tester" + "nickname": "tester", + "verificationToken": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } """.formatted(email, password, password); } diff --git a/src/test/java/com/select/domain/user/dto/request/SignupRequestTest.java b/src/test/java/com/select/domain/user/dto/request/SignupRequestTest.java index 56bc5ee..d08de66 100644 --- a/src/test/java/com/select/domain/user/dto/request/SignupRequestTest.java +++ b/src/test/java/com/select/domain/user/dto/request/SignupRequestTest.java @@ -34,7 +34,7 @@ private boolean validatePassword(String password) { "test@example.com", password, password, - "tester" + "tester", "A".repeat(43) ); try (var factory = Validation.buildDefaultValidatorFactory()) { @@ -42,5 +42,3 @@ private boolean validatePassword(String password) { } } } - - diff --git a/src/test/java/com/select/domain/user/service/UserEmailConstraintTest.java b/src/test/java/com/select/domain/user/service/UserEmailConstraintTest.java index a5950fd..814f336 100644 --- a/src/test/java/com/select/domain/user/service/UserEmailConstraintTest.java +++ b/src/test/java/com/select/domain/user/service/UserEmailConstraintTest.java @@ -1,5 +1,7 @@ package com.select.domain.user.service; +import com.select.domain.auth.service.EmailVerificationService; + import com.select.domain.user.dto.request.SignupRequest; import com.select.domain.user.entity.User; import com.select.domain.user.exception.DuplicateEmailException; @@ -101,10 +103,10 @@ private static Stream mysqlEmailConstraintNames() { private void assertDuplicateEmailConversion(DataIntegrityViolationException exception, String email) { UserRepository repository = mock(UserRepository.class); when(repository.saveAndFlush(any(User.class))).thenThrow(exception); - UserService service = new UserService(repository, passwordEncoder, mock(JwtProvider.class)); + UserService service = new UserService(repository, passwordEncoder, mock(JwtProvider.class), mock(EmailVerificationService.class)); assertThrows(DuplicateEmailException.class, () -> service.signup( - new SignupRequest(email, "Test1234!", "Test1234!", "tester") + new SignupRequest(email, "Test1234!", "Test1234!", "tester", "A".repeat(43)) )); } diff --git a/src/test/java/com/select/domain/user/service/UserServiceTest.java b/src/test/java/com/select/domain/user/service/UserServiceTest.java index 4c1412f..45a924b 100644 --- a/src/test/java/com/select/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/select/domain/user/service/UserServiceTest.java @@ -1,8 +1,11 @@ package com.select.domain.user.service; +import com.select.domain.auth.service.EmailVerificationService; + import com.select.domain.user.dto.request.LoginRequest; import com.select.domain.user.dto.request.SignupRequest; import com.select.domain.user.entity.User; +import org.springframework.test.util.ReflectionTestUtils; import com.select.domain.user.exception.DuplicateEmailException; import com.select.domain.user.exception.InvalidCredentialsException; import com.select.domain.user.exception.PasswordMismatchException; @@ -43,12 +46,14 @@ class UserServiceTest { private UserRepository userRepository; private PasswordEncoder passwordEncoder; private UserService userService; + private EmailVerificationService emailVerificationService; @BeforeEach void setUp() { userRepository = mock(UserRepository.class); passwordEncoder = mock(PasswordEncoder.class); - userService = new UserService(userRepository, passwordEncoder, mock(JwtProvider.class)); + emailVerificationService = mock(EmailVerificationService.class); + userService = new UserService(userRepository, passwordEncoder, mock(JwtProvider.class), emailVerificationService); } @ParameterizedTest @@ -79,7 +84,7 @@ void signUpRethrowsOtherDataIntegrityViolation(String constraintName) { @Test void signUpNormalizesUppercaseEmailBeforeSaving() { signUpSuccessfully(new SignupRequest( - "USER@EXAMPLE.COM", "Test1234!", "Test1234!", "tester" + "USER@EXAMPLE.COM", "Test1234!", "Test1234!", "tester", "A".repeat(43) )); assertSavedEmail("user@example.com"); @@ -88,7 +93,7 @@ void signUpNormalizesUppercaseEmailBeforeSaving() { @Test void signUpTrimsEmailBeforeSaving() { signUpSuccessfully(new SignupRequest( - " user@example.com ", "Test1234!", "Test1234!", "tester" + " user@example.com ", "Test1234!", "Test1234!", "tester", "A".repeat(43) )); assertSavedEmail("user@example.com"); @@ -99,7 +104,7 @@ void signUpPreventsDuplicateEmailRegardlessOfCase() { when(userRepository.existsByEmail("user@example.com")).thenReturn(true); assertThrows(DuplicateEmailException.class, () -> userService.signup(new SignupRequest( - "USER@EXAMPLE.COM", "Test1234!", "Test1234!", "tester" + "USER@EXAMPLE.COM", "Test1234!", "Test1234!", "tester", "A".repeat(43) ))); verify(userRepository).existsByEmail("user@example.com"); @@ -110,7 +115,7 @@ void signUpPreventsDuplicateEmailRegardlessOfCase() { @Test void signUpChecksPasswordMismatchBeforeEmailLookup() { assertThrows(PasswordMismatchException.class, () -> userService.signup(new SignupRequest( - "user@example.com", "Test1234!", "Different1234!", "tester" + "user@example.com", "Test1234!", "Different1234!", "tester", "A".repeat(43) ))); verifyNoInteractions(userRepository, passwordEncoder); @@ -122,7 +127,7 @@ void loginWithUnknownEmailRunsRealBCryptAndNeverIssuesToken(String password) { when(userRepository.findByEmail("missing@example.com")).thenReturn(Optional.empty()); BCryptPasswordEncoder realEncoder = spy(new BCryptPasswordEncoder()); JwtProvider jwtProvider = mock(JwtProvider.class); - UserService service = new UserService(userRepository, realEncoder, jwtProvider); + UserService service = new UserService(userRepository, realEncoder, jwtProvider, mock(EmailVerificationService.class)); assertThrows(InvalidCredentialsException.class, () -> service.login( new LoginRequest(" MISSING@EXAMPLE.COM ", password) @@ -137,6 +142,33 @@ void loginWithUnknownEmailRunsRealBCryptAndNeverIssuesToken(String password) { verifyNoInteractions(jwtProvider); } + @Test + void loginWithCorrectPasswordReturnsToken() { + User user = User.builder().email("user@example.com") + .passwordHash("valid-hash").nickname("tester").build(); + ReflectionTestUtils.setField(user, "id", 42L); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("Test1234!", "valid-hash")).thenReturn(true); + JwtProvider provider = mock(JwtProvider.class); + when(provider.createAccessToken("42")).thenReturn("access-token"); + UserService service = new UserService(userRepository, passwordEncoder, provider, mock(EmailVerificationService.class)); + + assertEquals("access-token", service.login( + new LoginRequest(" USER@EXAMPLE.COM ", "Test1234!")).accessToken()); + } + + @Test + void signupWithoutVerificationNeverHashesPasswordOrWritesUser() { + org.mockito.Mockito.doThrow(new com.select.domain.auth.exception.EmailVerificationException( + com.select.global.exception.ErrorCode.EMAIL_NOT_VERIFIED)) + .when(emailVerificationService).reserveForSignup("signup@example.com", "A".repeat(43)); + + assertThrows(com.select.domain.auth.exception.EmailVerificationException.class, + () -> userService.signup(signupRequest())); + verify(userRepository, never()).saveAndFlush(any()); + verifyNoInteractions(passwordEncoder); + } + private void signUpSuccessfully(SignupRequest request) { when(userRepository.existsByEmail(any())).thenReturn(false); when(passwordEncoder.encode(any())).thenReturn("encoded-password"); @@ -151,10 +183,11 @@ private void assertSavedEmail(String expectedEmail) { verify(userRepository).saveAndFlush(userCaptor.capture()); assertEquals(expectedEmail, userCaptor.getValue().getEmail()); verify(userRepository).existsByEmail(expectedEmail); + verify(emailVerificationService).reserveForSignup(expectedEmail, "A".repeat(43)); } private SignupRequest signupRequest() { - return new SignupRequest("signup@example.com", "Test1234!", "Test1234!", "tester"); + return new SignupRequest("signup@example.com", "Test1234!", "Test1234!", "tester", "A".repeat(43)); } private static Stream emailConstraintNames() { diff --git a/src/test/java/com/select/global/security/jwt/JwtAuthenticationIntegrationTest.java b/src/test/java/com/select/global/security/jwt/JwtAuthenticationIntegrationTest.java index 5e3cb88..91387c0 100644 --- a/src/test/java/com/select/global/security/jwt/JwtAuthenticationIntegrationTest.java +++ b/src/test/java/com/select/global/security/jwt/JwtAuthenticationIntegrationTest.java @@ -1,5 +1,8 @@ package com.select.global.security.jwt; +import com.select.domain.auth.service.EmailVerificationService; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + import com.select.domain.user.controller.UserController; import com.select.global.exception.ErrorCode; import com.select.global.security.SecurityPaths; @@ -54,6 +57,10 @@ @Import(JwtAuthenticationIntegrationTest.JwtTestConfiguration.class) class JwtAuthenticationIntegrationTest { + // 이메일 인증 자체는 별도 실제 Redis 통합 테스트에서 검증한다. + @MockitoBean + private EmailVerificationService emailVerificationService; + @LocalServerPort private int port; @@ -200,7 +207,7 @@ void signupThenLoginTokenGrantsAccessToProtectedApi() throws Exception { String email = "jwt-" + UUID.randomUUID() + "@example.com"; String signupBody = objectMapper.writeValueAsString(Map.of( "email", email, "password", "Test1234!", - "passwordConfirm", "Test1234!", "nickname", "jwt-tester" + "passwordConfirm", "Test1234!", "nickname", "jwt-tester", "verificationToken", "A".repeat(43) )); var signupResponse = client.send(request("/auth/signup") .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) @@ -244,7 +251,7 @@ void unknownEmailAndWrongPasswordKeepSame400Response(boolean registered) throws if (registered) { String signupBody = objectMapper.writeValueAsString(Map.of( "email", email, "password", "Correct1234!", - "passwordConfirm", "Correct1234!", "nickname", "login-tester" + "passwordConfirm", "Correct1234!", "nickname", "login-tester", "verificationToken", "A".repeat(43) )); var signupResponse = client.send(request("/auth/signup") .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 71523a5..cfd27e5 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -5,7 +5,12 @@ spring: jpa: hibernate: ddl-auto: create-drop - + mail: + host: localhost + username: test@babi.local +email: + verification: + secret: test-only-email-verification-secret # Ephemeral test-only secret; never reads the local or production JWT secret. jwt: secret: "${random.uuid}${random.uuid}"