Skip to content

[Feat] 인증 도메인 (AUTH-1~4, 6~8) - #18

Open
DGAZA-max wants to merge 14 commits into
devfrom
feat/auth-login
Open

[Feat] 인증 도메인 (AUTH-1~4, 6~8)#18
DGAZA-max wants to merge 14 commits into
devfrom
feat/auth-login

Conversation

@DGAZA-max

Copy link
Copy Markdown
Member

💡 개요

  • 인증 도메인을 구현합니다. 회원가입부터 비밀번호 찾기까지, 토큰을 발급하고 회수하는 한 사이클이 전부 들어 있습니다.
  • 머지되면 X-Dev-User-Id 헤더 우회를 실제 JWT로 바꿀 수 있습니다. 다른 도메인에서는 @AuthenticationPrincipal AuthenticatedUser로 현재 사용자를 꺼내 쓰시면 됩니다.
  • 양이 많아 죄송합니다. 대신 커밋 13개를 순서대로 읽으면 되도록 잘라뒀습니다. 각 커밋은 그 시점의 코드만으로 말이 되고(예: 로그아웃이 없는 커밋에는 블랙리스트 검사도 없습니다), 왜 그렇게 했는지를 커밋 메시지에 적어뒀습니다. 파일 단위로 보시면 훨씬 어렵습니다.
  • chore/jdk-21 위에 쌓여 있습니다. 그쪽이 머지되면 base가 dev로 자동 변경됩니다.

🛠️ 작업 내용

  • AUTH-1 회원가입 — 가입 즉시 로그인 상태로 진입, 인증 메일 발송
  • AUTH-2 로그인 — 계정 열거 방지(응답·응답시간 동일)
  • AUTH-3 토큰 재발급 — RTR + Redis 화이트리스트 + 재사용 감지(전 기기 무효화)
  • AUTH-4 로그아웃 — access 블랙리스트 + refresh 폐기 + 쿠키 삭제
  • AUTH-6 이메일 인증 — 토큰 해시 저장, TTL 24시간, 1회용, 재발송
  • AUTH-7 비밀번호 찾기 — TTL 30분, 1회용, 재설정 시 refresh 전체 무효화
  • AUTH-8 브루트포스 잠금 — 계정+IP 기준 5회, 지수 백오프 1→15분
  • 메일 발송 추상화 — 로컬은 콘솔 로그, 운영은 Resend (정책상 배포 후 SES 전환)
  • 메일 유발 요청 호출 제한 — 재설정 요청 주소당 3회/시간, 재발송 계정당 3회/시간
  • users 엔티티 — 인증에 필요한 최소 필드만 선점
  • 에러코드 AUTH001 ~ AUTH010 (다음 기능은 AUTH011부터)
  • 테스트 30개 — Testcontainers로 실제 MySQL·Redis를 띄운 통합 테스트

엔드포인트

메서드 경로 인증
POST /api/v1/auth/signup 공개
POST /api/v1/auth/login 공개
POST /api/v1/auth/reissue 공개
POST /api/v1/auth/logout 공개
POST /api/v1/auth/email/verify 공개
POST /api/v1/auth/email/verification 필요
POST /api/v1/auth/password/reset-request 공개
POST /api/v1/auth/password/reset 공개

토큰 전달 규칙 (프런트 연동 시 참고)

  • access: 응답 바디 → 프런트가 메모리에만 보관 (localStorage 금지)
  • refresh: httpOnly + Secure + SameSite=Lax 쿠키. 경로를 /api/v1/auth로 좁혀 일반 데이터 요청에는 실리지 않습니다
  • access 30분 / refresh 3일 (정책)

💬 리뷰 포인트

1. users 엔티티 필드 선정 — @이중현

인증에 필요한 최소 필드만 먼저 만들었습니다. USR-1~3에 당장 필요한 필드가 빠졌는지만 봐주세요.

  • 넣은 것: id · public_id(ULID) · email · password_hash · nickname · role · email_verified · status · trust_score · last_login_at · deleted_at
  • 일부러 뺀 것: phone · previous_nickname · nickname_changed_at — USR 범위라 중현님이 추가하시는 게 맞다고 봤습니다
  • reported_flag는 넣었습니다 — 신뢰도 TRS-3(하한 보호 해제)이 쓰는 값이라 제 도메인입니다

지금 ddl-auto=create-drop이라 필드 추가 비용은 없습니다. 편하게 말씀해 주세요.

2. 불친절해 보이는 응답은 대부분 의도한 것입니다

  • 로그인에서 계정 없음과 비밀번호 틀림이 같은 코드·같은 메시지입니다. 다르면 응답만 보고 가입 여부를 알 수 있습니다. 같은 이유로 계정이 없어도 더미 해시와 BCrypt 비교를 한 번 수행합니다 — 건너뛰면 응답 시간(수십 ms)으로 구분됩니다
  • 재설정 요청도 가입 여부와 무관하게 항상 같은 200입니다. "이 계정은 소셜 로그인입니다" 안내는 화면이 아니라 메일 본문으로 나갑니다
  • 토큰 오류도 만료·이미 사용됨·존재하지 않음을 구분하지 않습니다

메시지를 친절하게 바꾸자는 의견 주시기 전에 한 번 물어봐 주시면 감사하겠습니다.

3. 로컬에서 직접 돌려보실 때

app.mail.provider=log가 기본이라 인증 링크가 콘솔에 그대로 찍힙니다. 메일 계정 없이 가입 → 인증 → 비밀번호 재설정까지 끝까지 확인하실 수 있습니다.

✅ 체크리스트

  • deferred 이슈 영향 없음 / 갱신함
    • OAuth(AUTH-5)는 카카오 앱키 발급 대기 중입니다. 나오는 대로 바로 올립니다
    • 자체 점검에서 나온 2건은 논의가 필요해 이번 PR에 넣지 않았습니다
      • 비밀번호 변경 후 access 토큰이 만료까지(최대 30분) 살아 있습니다. refresh는 즉시 전부 끊깁니다. 막으려면 요청마다 Redis 읽기가 하나 늘어납니다
      • MAIL_PROVIDER를 배포에서 빠뜨리면 기본값 log로 조용히 기동돼 메일이 로그로만 나갑니다. 필수값으로 올릴지 결정이 필요합니다

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3fc31acd-8466-43f2-a156-2d8a5d34630c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

DGAZA-max and others added 14 commits August 31, 2026 17:24
빌드 툴체인·Dockerfile·CI를 17에서 21로 함께 올린다. 세 곳이 어긋나면 로컬만 통과하고
CI나 이미지 빌드에서 깨지므로 한 커밋으로 묶는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
인증이 users 없이는 한 줄도 못 나가므로 인증 담당자가 먼저 만든다. 최종 소유는 유저 도메인이라 패키지도 domain/user에 둔다.

인증·신뢰도에 필요한 최소 필드만 넣었다. phone·previous_nickname·nickname_changed_at은 USR 착수 시 유저 도메인이 추가한다. MVP 동안 스키마 진실은 엔티티이고 로컬이 create-drop이라 필드 추가 비용은 없다.

- 이메일 중복 검사는 소프트 삭제 행까지 포함한다. 정책이 동일 이메일 재가입 1차 불허이고 DB의 email UNIQUE도 삭제 행을 점유하므로, 사전 검사 범위가 다르면 검사만 통과하고 INSERT에서 터진다.
- 소프트 삭제에 전역 필터(@SQLRestriction)를 걸지 않는다. 공통 규칙이 명시적 where이고, 관리자는 삭제본도 조회해야 해서 전역 필터를 걸면 그쪽이 막힌다.
- public_id는 ULID다. 내부 PK를 노출하면 행 개수·증가 속도가 새고, UUIDv4는 정렬성이 없어 인덱스가 흩어진다. 생성 하나만 필요해서 라이브러리 대신 26자 인코딩만 직접 넣었다.
- reported_flag는 신뢰도 조건부 하한(TRS-3)이 쓰는 값이라 함께 넣었다. 정책에 300 재돌파 시 보호 복원 규칙이 있어 의미는 '이력'이 아니라 '지금 보호가 풀려 있는가'다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
SecurityConfig에 자리만 예약돼 있던 JWT 필터를 실제로 채운다. 토큰 발급·검증은 '누가 로그인했나'를 모르는 기계적 작업이라 global/security에 둔다 — 필터가 domain/auth를 참조하면 global에서 domain으로 향하는 역방향 의존이 생긴다.

- access·refresh를 모두 JWT로 발급하되 typ 클레임으로 구분한다. 구분이 없으면 refresh를 Authorization 헤더에 그대로 넣어 3일짜리 access처럼 쓸 수 있다.
- refresh를 불투명 랜덤 문자열이 아니라 JWT로 둔다. 쿠키만 받았을 때 sub에서 userId를 꺼내야 화이트리스트 키를 찾을 수 있고, 그게 없으면 정책이 요구하는 '재사용 감지 시 유저 전체 무효화'가 불가능하다.
- 해석 실패를 예외가 아니라 Optional로 돌려준다. 호출자가 할 일이 '인증하지 않고 통과' 하나뿐이라 예외로 만들면 필터마다 try-catch가 반복된다. 최종 거절은 authorizeHttpRequests와 EntryPoint가 한다.
- 토큰 문자열은 로그에 남기지 않는다. 유효한 토큰이 로그 수집기로 흘러가면 로그를 읽는 사람이 그대로 남의 세션을 쓸 수 있다.
- JWT 필터를 dev 필터보다 먼저 등록한다. 같은 앵커에 addFilterBefore를 여러 번 부르면 등록 순서대로 실행되므로, 순서가 바뀌면 X-Dev-User-Id 헤더가 실제 토큰보다 앞서 principal을 채운다. dev 필터에는 이미 인증된 요청을 건너뛰는 분기를 넣었다.
- app.jwt.secret에 기본값을 두지 않고 RequiredPropertyGuard에 등록한다. 기본값이 있으면 배포에서 JWT_SECRET을 빠뜨렸을 때 개발용 키로 조용히 기동해 누구나 토큰을 위조할 수 있다. 로컬 기본값은 application-local.yml에만 둔다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
정책이 refresh 화이트리스트를 Redis로 확정해 refresh_tokens 테이블은 만들지 않는다.

화이트리스트를 유저별 Hash 하나(field=jti)로 둔다. 정책이 '재사용 감지 시 유저 전체 무효화'를 요구하는데, 키를 userId:jti로 흩어놓으면 전체 삭제에 SCAN이 필요해 운영에서 느리고 위험하다. Hash면 DEL 한 번이면 끝난다.

만료는 키 TTL이 아니라 value의 expiresAt으로 판정한다. Redis TTL은 키 단위이지 필드 단위가 아니라, 한 유저의 모든 기기 토큰이 TTL을 공유한다 — 새 기기에서 로그인할 때마다 EXPIRE가 갱신돼 3일 전 발급 토큰이 영영 안 죽고 '3일' 정책이 실제로는 지켜지지 않는다. 필드 단위 TTL(HEXPIRE)은 Redis 7.4+ 기능인데 docker-compose가 7.2라 쓸 수 없다. 검증마다 어차피 value를 읽으므로 추가 비용은 없고, 키 TTL(4일)은 고아 키 방지 안전망으로만 둔다.

실패 처리는 방향이 다르다. 저장·조회는 fail-closed다 — 화이트리스트는 '있어야 통과'라 조회를 못 했을 때 통과시킬 방법이 원리적으로 없고, 저장에 실패했는데 로그인을 성공시키면 화이트리스트에 없는 refresh가 발급돼 다음 재발급에서 재사용 공격으로 오판된다. 삭제만 예외를 삼킨다.

로그인 실패 카운터는 계정+IP 조합으로 센다. 계정만 세면 공격자가 남의 계정을 일부러 틀려 그 사람을 잠글 수 있고, IP만 세면 같은 공유망 사용자들이 서로를 잠근다. 5회부터 지수 백오프(1·2·4·8·최대 15분)로 잠그며 fail-open이다 — 막으면 정상 사용자도 로그인을 못 하는데 통과시켜도 공격자는 비밀번호를 맞혀야 한다. 계정 식별자는 해시로 키에 넣는다(키 목록만 훑어도 가입 이메일이 드러나면 안 된다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
SecurityConfig에 경로만 예약돼 있던 signup·login을 실제 엔드포인트로 채운다.

토큰 전달은 정책 '쿠키 전달'을 따른다. access는 응답 바디로 주고 프런트가 메모리에만 보관하며(localStorage 금지), refresh는 httpOnly+Secure+SameSite=Lax 쿠키로만 나간다. 쿠키 경로를 /api/v1/auth로 좁혀 일반 데이터 요청에는 아예 실리지 않게 했다 — refresh를 바디에도 실으면 httpOnly를 둔 이유가 통째로 사라진다.

로그인은 비밀번호 대조를 먼저 하고 계정 상태(제재·탈퇴)는 그다음에 본다. 순서를 뒤집으면 비밀번호를 모르는 사람도 '이 계정은 정지됐다'를 알아낼 수 있다.

계정 열거 방지는 응답만으로 부족하다. 계정이 없을 때 즉시 실패시키면 BCrypt 비교(수십 ms)가 빠져 응답 시간만으로 가입 여부가 드러나므로, 계정이 없거나 OAuth 전용이어도 더미 해시와 정확히 한 번 비교한다.

X-Forwarded-For는 신뢰하지 않는다. 클라이언트가 마음대로 넣을 수 있어 헤더만 바꿔가며 브루트포스 잠금을 무한히 회피할 수 있다. ALB 뒤에서 실제 IP가 필요해지면 신뢰 프록시 설정을 함께 넣어야 한다.

에러 코드 접두어는 요구사항 명세서 ID를 그대로 쓴다. 도메인이 14개라 첫 글자 한 자로는 겹치고(Auth-Admin, Payment-Price, Trust-Transaction, Report-Review, Common-Chat), 한 번 응답에 나간 코드는 변경·재사용이 금지되므로 지금 정해둔다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
발급만 있고 회수가 없으면 반쪽이라 같은 PR에서 이어 붙인다.

재발급은 RTR이다. 서명이 통과해도 화이트리스트에 없으면 이미 한 번 쓰인 토큰이라는 뜻이다 — 정상 사용자라면 재발급 때 직전 토큰이 폐기되고 새 토큰을 받았을 테니, 옛 토큰이 다시 오는 것은 탈취본이 돌아다닌다는 신호다. 그래서 그 유저의 모든 기기를 끊는다(정책).

로그아웃은 access를 블랙리스트에 넣고 refresh를 화이트리스트에서 지운다. 인증을 요구하지 않는 이유는 access가 만료된 뒤에도 로그아웃은 돼야 하기 때문이다.

블랙리스트 조회는 fail-open이다(정책 개정). 차단해서 얻는 보안은 장애 지속 시간 동안뿐인데 — 복구되면 다시 조회돼 차단되고, Redis 데이터가 유실됐다면 fail-closed도 못 막는다 — 그 대가로 같은 시간 동안 정상 사용자 전원이 401이 되고 silent refresh도 401이라 재로그인조차 막힌다. 노출은 '이미 탈취된 토큰'에 한정되고 창은 장애 시간과 같다. 서명·만료 검증은 Redis와 무관한 로컬 연산이라 그대로 수행되며, 건너뛰는 것은 '이 토큰이 로그아웃됐나' 조회 하나뿐이다.

반대로 쓰기는 실패해도 로그아웃을 진행시킨다. 쓰기가 이미 실패한 마당에 로그아웃을 거절하면 보안은 하나도 못 얻으면서 사용자만 막고, 쿠키가 남아 공용 PC에서 다음 사용자가 silent refresh로 남의 계정에 로그인되는 더 나쁜 결과가 된다. 쿠키 삭제는 컨트롤러가 응답 헤더로 항상 수행한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
Testcontainers(MySQL·Redis)로 실제 요청을 통과시켜 대외 계약과 보안 가정을 고정한다.

고정하는 것:
- refresh가 응답 바디에 실리지 않고 httpOnly·Secure·path=/api/v1/auth 쿠키로만 나가는 것
- 계정 없음과 비밀번호 불일치가 같은 코드·같은 메시지를 주는 것(계정 열거 방지)
- RTR 회전 후 직전 refresh를 다시 쓰면 거부되고 그 유저의 새 토큰까지 함께 끊기는 것
- 로그아웃 뒤 서명이 살아 있는 access가 블랙리스트로 차단되는 것
- refresh를 Authorization 헤더에 넣어도 인증되지 않는 것(typ 구분)
- 토큰이 하나도 없어도 로그아웃이 200이고 쿠키가 지워지는 것

보호 경로 확인에는 기존 테스트 전용 프로브(/__test/me)를 쓴다. principal까지 함께 검증된다.

Boot 4는 Jackson 3라 ObjectMapper를 tools.jackson.databind에서 주입받는다. com.fasterxml.jackson.databind.ObjectMapper로 쓰면 빈이 없어 컨텍스트가 뜨지 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
LoginAttemptStore는 코드가 들어왔지만 동작을 고정하는 테스트가 없었다. 잠금 임계값과
검사 순서는 눈으로 읽어서는 틀린 걸 알아채기 어려운 부분이라 테스트로 못박는다.

- 5회까지는 AUTH003, 6회부터 AUTH006 (임계값이 조용히 바뀌는 것을 막는다)
- 잠긴 뒤에는 올바른 비밀번호도 429. 잠금 검사가 비밀번호 대조보다 앞에 있어야 하며,
  순서가 뒤집히면 잠긴 계정도 대입을 계속할 수 있어 잠금이 아무것도 막지 못한다
- 로그인 성공 시 카운터 초기화. 빠지면 오타가 며칠치 누적돼 정상 사용자가 잠긴다
- 잠금은 계정+IP 단위라 같은 IP의 다른 계정은 영향받지 않는다. 키에서 계정이 빠지면
  공유 IP(회사·학교) 사용자가 통째로 막힌다

잠금 키가 hash(email)+IP라 테스트마다 이메일이 다르면 서로 간섭하지 않는다. 그래서
별도 클래스로 떼지 않고 여기에 두어 컨테이너 기동을 한 번으로 유지했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
이메일 인증·비밀번호 찾기가 붙기 전에 발송 경로부터 만든다. 두 기능의 실제 로직은
토큰 발급·해시 저장·TTL·1회용 검증이고 발송은 마지막 한 줄이라, 인터페이스로 갈라두면
공급자가 정해지지 않아도 기능을 끝까지 만들고 테스트할 수 있다.

- MailSender: send(to, subject, body) 하나뿐인 인터페이스
- LoggingMailSender: 기본 구현. 콘솔에 링크를 찍어 메일 계정 없이 흐름을 확인한다
- ResendMailSender: app.mail.provider=resend 일 때만 등록. 키가 비면 기동 시점에 실패한다
- MailProperties: provider · from · api-key · base-url

정책 확정대로 MVP는 Resend(도메인 없이 발송 가능·리드타임 0), AWS 배포 후 SES로 전환한다.
발송 실패를 예외로 올리지 않는 이유는 ResendMailSender 주석에 적었다 — 메일 게이트웨이가
잠깐 죽었다고 가입 트랜잭션을 되돌리면 사용자는 계정조차 못 만든다.

api-key는 개인 Resend 계정 키라 기본값을 빈 문자열로 두고 .env에서만 주입한다.
LoggingMailSender만 수신자 주소를 찍는다 — 로컬 전용이고, 운영 구현은 주소·본문을
로그에 남기지 않는다(개인정보 + 재설정 링크 유출 방지).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
가입 시 email_verified=false로 만들고 인증 토큰을 메일로 보낸다. 이 경로가 필요한
이유는 OAuth 자동연결 조건이 email_verified=true인데(정책), 로컬 가입에서 그걸 true로
만들 방법이 없으면 자동연결이 영영 불가능하거나 미검증 이메일을 자동연결하는
계정 탈취 벡터가 생기기 때문이다.

- POST /api/v1/auth/email/verify   (공개) 메일 링크의 토큰으로 인증 완료
- POST /api/v1/auth/email/verification (인증 필요) 인증 메일 재발송
- AUTH008 INVALID_EMAIL_VERIFICATION_TOKEN (400)

설계 판단 세 가지:

1. 토큰은 원문이 아니라 SHA-256 해시로 저장한다. DB를 읽을 수 있는 사람이 남의 인증
   링크를 그대로 쓸 수 있으면 안 된다 - 값을 아는 쪽(메일을 받은 본인)만 통과한다.
2. verify는 공개, 재발송은 인증 필요로 갈랐다. 메일 링크를 누르는 브라우저가 로그인
   상태라는 보장이 없어 verify는 열어야 하고, 재발송을 이메일만 받아 열어두면 응답으로
   가입 여부를 확인할 수 있는 데다 남의 주소로 메일을 대신 쏘는 발송기가 된다.
3. 토큰을 쿼리가 아니라 본문으로 받는다. 쿼리에 실으면 액세스 로그·프록시 로그·Referer에
   토큰이 남아 로그를 볼 수 있는 사람이 링크를 그대로 재현할 수 있다. 메일 링크는
   프런트엔드 화면을 가리키고, 그 화면이 토큰을 본문에 담아 이 API를 부른다.

만료·이미 사용됨·존재하지 않음은 한 코드로 묶었다. 구분해 주면 "이 토큰은 있는데
만료됐다"가 새어 추측에 힌트가 된다.

토큰 엔티티가 MutableEntity를 상속하는 이유는 used_at을 채우는 UPDATE가 실제로
있기 때문이다. ERD에는 이 테이블에 updated_at이 없어 ERD 쪽을 맞춰야 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
메일로 받은 링크로 새 비밀번호를 설정한다. 링크 TTL 30분·1회용.

- POST /api/v1/auth/password/reset-request  재설정 링크 요청
- POST /api/v1/auth/password/reset          새 비밀번호 설정
- AUTH009 INVALID_PASSWORD_RESET_TOKEN (400)

이메일 인증 토큰(24시간)보다 수명을 훨씬 짧게 둔 이유는 이 링크 하나로 비밀번호를 바꿔
계정을 통째로 가져갈 수 있기 때문이다. 메일함이 열려 있는 시간을 최소화한다.

reset-request는 <b>계정이 없어도, OAuth 전용이어도 똑같은 200</b>을 준다. 응답이
달라지는 순간 이 API는 로그인 없이 쓸 수 있는 가입 여부 조회기가 된다. "이 계정은 소셜
로그인입니다"라는 안내는 화면이 아니라 메일 본문으로 보낸다 - 그 사실 자체가 열거
정보라서, 진짜 주인만 읽을 수 있는 경로로 옮긴 것이다(정책).

재설정에 성공하면 그 유저의 refresh를 전부 무효화한다(정책). 비밀번호를 바꾸는 상황은
대개 계정을 빼앗겼을 때인데, 공격자가 이미 다른 기기에서 로그인해 있으면 비밀번호만
바꿔봐야 그 세션이 그대로 살아 있다.

새 비밀번호 길이 제약은 회원가입과 같게 맞췄다. 여기만 느슨하면 재설정을 통해 정책보다
약한 비밀번호가 들어온다. 상한 64자는 BCrypt가 72바이트 초과분을 조용히 잘라내기 때문이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
토큰이 SHA-256 해시로만 저장돼 DB에서 원문을 꺼낼 수 없다. 그래서 실제 사용자와 같은
경로로 검증한다 - RecordingMailSender로 발송된 메일 본문을 잡고, 링크의 토큰을 그대로
API에 넣는다. 저장 방식이 해시가 아니게 바뀌면 이 테스트는 여전히 통과하지만, 반대로
링크가 실제로 동작하는지는 이 방식이 아니면 확인할 수 없다.

이메일 인증:
- 가입 시 메일이 나가고 링크의 토큰으로 인증이 완료된다
- 토큰은 1회용
- 이미 쓴 토큰과 아예 없는 토큰의 응답이 코드·메시지까지 동일하다
- 재발송은 로그인해야 쓸 수 있고(비로그인 401), 인증이 끝난 계정에는 새 링크를 만들지 않는다

비밀번호 찾기:
- 재설정 후 새 비밀번호로만 로그인된다
- 재설정하면 기존 refresh가 전부 끊긴다
- 토큰은 1회용이고, 두 번째 시도가 실제로 무시됐는지 로그인까지 확인한다
- 가입 여부와 무관하게 응답이 같고, 없는 계정에는 메일이 나가지 않는다
- 새 비밀번호 8자 미만은 걸러지고, 그때 토큰은 아직 살아 있다

마지막 항목이 필요한 이유: 검증 실패가 토큰을 태우면 오타 한 번에 링크를 다시 받아야
한다. 토큰 소모가 비밀번호 검증보다 뒤에 있어야 한다는 순서를 고정한다.

컨테이너를 AuthFlowTest와 나눠 쓰는 대신 클래스를 분리했다. 여기는 메일 캡처 픽스처가
필요하고 @beforeeach로 우편함을 비워야 해서 관심사가 다르다. 기동이 한 번 더 늘지만
전체 스위트는 3분 30초로 여전히 짧다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
자체 점검에서 나온 세 가지를 고친다.

1. AuthController가 UserRepository를 직접 들고 있었다. 이 프로젝트에서 컨트롤러가
   리포지토리를 주입받는 유일한 자리였다. 재발송 API의 핵심 제약이 "본인에게만"인데
   그 규칙이 서비스 밖에 있었다 - EmailVerificationService.resendTo(userId)로 옮긴다.

2. 만료 판정에 테스트가 없었다. isUsable에서 expiresAt 비교가 통째로 빠져도 기존
   테스트는 전부 통과한다. 이미 지난 expiresAt을 가진 행을 직접 넣어 두 흐름 모두
   거부되는지, 그리고 재설정 쪽은 비밀번호가 실제로 그대로인지까지 확인한다.

3. 가입 여부 노출 테스트의 단언이 사실상 비어 있었다. ApiResponse가
   @JsonInclude(NON_NULL)이라 성공 응답에는 data 필드 자체가 없고, 두 MissingNode를
   비교하고 있어 무엇을 바꿔도 통과했다. 본문 전체 비교로 바꾼다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
비밀번호 재설정 요청은 로그인 없이 호출할 수 있고 호출 한 번이 곧 메일 한 통인데
아무 제한이 없었다. 남의 주소만 알면 그 사람 메일함을 폭격할 수 있고, 더 나쁘게는
공급자 발송 쿼터를 태워 다른 모든 사용자의 가입·재설정 메일까지 함께 멈춘다.
로그인은 LoginAttemptStore가 막고 있는데 이 경로만 열려 있었다.

- 재설정 요청: 주소당 3회/시간 + IP당 20회/시간
- 인증 메일 재발송: 계정당 3회/시간
- AUTH010 TOO_MANY_MAIL_REQUESTS (429)

주소 기준과 IP 기준을 함께 본다. 주소만 세면 주소를 바꿔가며 쿼터를 태울 수 있고,
IP만 세면 한 사람의 메일함을 여러 IP에서 폭격하는 것을 못 막는다. IP 한도를 넉넉히
둔 것은 회사·학교 NAT 때문이다.

<b>제한 검사는 계정 조회보다 앞에 있다.</b> 존재하는 주소만 세면 429가 나오는지
여부로 가입 여부를 알 수 있어, 응답을 똑같이 맞춰둔 열거 방지가 그대로 무너진다.
테스트로 이 순서를 고정했다.

Redis 장애 시에는 통과시킨다(fail-open) - 인증 계열 공통 방침이고, 여기서 막으면
Redis가 죽은 동안 비밀번호를 잃어버린 사용자가 복구 수단을 통째로 잃는다.

카운터는 RedisConfig에 이미 등록된 rate_limit.lua 싱글턴 빈을 그대로 쓴다(해당 빈
주석의 지시). 한도는 스크립트가 아니라 호출 측이 갖는다.

테스트는 @beforeeach에서 제한 키를 비운다. 키가 IP를 포함하는데 MockMvc 요청은 전부
같은 IP라, 비우지 않으면 실행 순서에 따라 뒤쪽 테스트만 429로 깨진다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVPfbxuEbFinAZwi4FrQpU
Base automatically changed from chore/jdk-21 to dev August 31, 2026 08:39

@RootToApex RootToApex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 1. 재사용 공격 대응이 조용히 실패한다 (High)

AuthCommandService.java (reissue) · RefreshTokenStore.java (revokeAll)

log.warn("refresh 재사용 감지 - 해당 유저의 모든 세션을 무효화한다. userId={}", claims.userId());
refreshTokenStore.revokeAll(claims.userId());
throw new BusinessException(AuthErrorCode.INVALID_TOKEN);

revokeAll이 내부에서 예외를 삼키고 경고만 남깁니다.

public void revokeAll(Long userId) {
    try { redisTemplate.delete(key(userId)); }
    catch (RuntimeException e) { log.warn("refresh 전체 무효화 실패 - reason={}", ...); }
}

재현 — Redis가 일시적으로 응답하지 않는 동안 탈취된 옛 refresh가 들어오는 경우

  1. 화이트리스트 조회 실패 → entry == null → 재사용으로 판정
  2. revokeAll 호출 → 삭제 실패 → 경고 로그만 남음
  3. 호출자에겐 INVALID_TOKEN 하나만 반환

결과적으로 공격자의 나머지 기기 토큰은 그대로 살아 있는데, 로그를 보지 않는 한 무효화된 것으로 보입니다.

클래스 주석의 근거는 "삭제만 예외를 삼킨다 — 로그아웃은 쓰기가 실패해도 진행돼야 하기 때문"인데, 이 호출은 로그아웃이 아니라 공격 대응입니다. 정책도 두 경로를 나눠서 적고 있습니다.

  • 로그아웃(쓰기)은 별개다: Redis 쓰기 실패와 무관하게 (…) 200을 반환한다
  • 화이트리스트 저장(로그인)·조회(재발급)는 fail-closed 유지

재사용 감지는 화이트리스트 계열이라 fail-closed 쪽에 가깝다고 봅니다. revokeAll(userId, boolean failClosed)로 나누거나 공격 대응 전용 메서드를 따로 두는 건 어떨까요?

@RootToApex RootToApex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 2. public_id 생성기가 둘이 된다 (Medium)

global/util/PublicId.java ↔ PR #16의 global/util/PublicIdGenerator.java

같은 일을 하는 클래스가 둘입니다. 먼저 머지되는 쪽이 남고 뒤엣것이 충돌을 풀어야 합니다.

구현을 검산했는데 경화님 것이 정확합니다 — Crockford Base32 32자(I·L·O·U 제외), 48비트 타임스탬프 10자 + 40비트씩 랜덤 16자 = 26자.

의존성이 0개인 쪽이 낫다고 봅니다. 다만 지금 제 것을 지우면 PublicId가 아직 머지되지 않아 #16 빌드가 깨집니다. #18이 먼저 머지되면 제가 #16에서 PublicIdGeneratorulid-creator 의존성을 걷어내겠습니다. 제 것이 먼저 머지되면 머지 직후 정리 PR을 따로 내겠습니다.

한 가지만 남깁니다 — static 메서드라 테스트에서 값을 고정할 수 없습니다. 지금은 필요 없지만 "특정 public_id로 저장된 상태"를 단정해야 하는 테스트가 생기면 그때 인터페이스로 감싸야 합니다.

@RootToApex RootToApex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 3. 탈퇴한 계정의 닉네임이 영구 점유된다 (Low)

UserRepository.java (existsByNickname)

삭제 행까지 포함해 확인하므로 탈퇴자의 닉네임은 다시 쓸 수 없습니다. 정책상 "닉네임 마스킹은 표시 레벨"이라 DB에는 원래 값이 남고요.

주석에 "USR-3 소유라 여기서 정하지 않는다"고 하셨는데, 제 담당이라 제가 정하겠습니다. 다만 이메일과 성격이 다르다는 점만 남깁니다.

  • 이메일 재가입 불허 → 신뢰도 세탁 방지가 목적이라 영구 점유가 맞습니다
  • 닉네임 UNIQUE → 사칭 방지가 목적이라, 탈퇴 후 일정 기간이 지나면 풀어주는 것도 성립합니다

정하면 공유하겠습니다. 이 PR을 막지는 않습니다.

@RootToApex RootToApex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지적 1번(재사용 대응 fail-open)만 정리되면 승인하겠습니다. 2·3번은 제가 처리하거나 제 도메인 결정이라 이 PR을 막지 않습니다.

SecurityConfig를 저와 함께 고쳤습니다 — 경화님은 /api/v1/auth/* 추가와 JWT 필터 등록, 저는 #16에서 매물 경로를 /api/v1/listings/api/listings로 맞췄습니다(API 명세서·이미 머지된 시세 조회가 v1 없는 경로라서요). auth 경로의 v1은 소유가 아니라 손대지 않았습니다. v1을 남길지 뺄지는 정해야 할 것 같습니다.

@DGAZA-max DGAZA-max self-assigned this Aug 31, 2026
@DGAZA-max DGAZA-max added the feat A new feature label Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants