feat: Refresh Token 회전·로그아웃과 안전한 데모 인증 기반 구현 - #35
Merged
Conversation
공통 오류 계약을 ApiErrorCode interface로 분리하고 로그인 오류를 AuthErrorCode로 이동했습니다. 외부 오류 code와 HTTP 응답은 그대로 유지합니다.
Refresh Token을 일회성으로 사용하고 같은 token family 안에서 새 토큰으로 교체합니다. 재사용·만료·폐기·비활성 계정은 동일한 401 응답으로 처리하며, 재사용 탐지 시 해당 token family 전체를 폐기하도록 구성했습니다.
Refresh Token cookie가 없거나 유효하지 않아도 로그아웃은 동일한 204로 응답합니다. 알려진 토큰은 같은 token family 전체를 폐기하고 브라우저 쿠키를 즉시 만료시켜 반복 호출에도 안전하게 처리합니다.
CSRF 보호가 비활성화된 MVP에서는 Refresh Token 쿠키의 SameSite=None 설정을 허용하지 않습니다. Strict 또는 Lax만 사용하도록 제한하고, 삭제 쿠키가 발급 쿠키와 동일한 보안 속성을 유지하는지 검증했습니다.
정상 회전, 이전 토큰 재사용, token family 전체 폐기, 계정·사업장 비활성화, 로그아웃 멱등성과 로그인 family 격리를 통합 테스트합니다. 누락·변조·알 수 없는 토큰은 동일한 401과 삭제 쿠키를 반환하며, 로그아웃 뒤 기존 Access Token은 만료 전까지 유효한 정책도 명시적으로 검증합니다.
OpenAPI와 README에 cookie-only 재발급, 멱등 로그아웃, INVALID_REFRESH_TOKEN 응답과 쿠키 삭제 계약을 반영합니다. SameSite 정책, Client single-flight, 로그아웃 후 기존 Access Token의 최대 유효 시간도 ADR과 초보자용 흐름에 함께 설명했습니다.
Refresh Token 검증 실패 응답에도 삭제 쿠키와 함께 Cache-Control: no-store, Pragma: no-cache를 반환합니다. 브라우저나 중간 캐시가 인증 오류와 토큰 관련 응답을 저장하지 않도록 실제 응답과 OpenAPI 계약을 함께 검증했습니다.
동일 Refresh Token 요청 두 개가 실제 Repository 조회 시점에 겹치도록 조정하고 PostgreSQL family lock 동작을 검증합니다. 정확히 한 요청만 회전하며, 다른 요청의 재사용 탐지 뒤 교체 토큰까지 포함한 family 전체가 폐기되는지 CI에서 확인합니다.
Refresh Token 생성 바이트 수와 입력 검증 형식을 하나의 정책으로 묶어 서로 다른 값으로 변경되는 위험을 줄입니다. URL-safe Base64 무패딩 형식의 허용·거부 경계를 단위 테스트로 고정했습니다.
로그인 성공·실패, 토큰 회전·거부·재사용, family 폐기와 로그아웃을 typed event로 정의하고 안전한 식별자만 기록합니다. 현재는 request_id가 포함된 진단 로그 adapter를 사용하며, append-only 영구 저장은 감사 모듈 소유 이슈 #11에서 이 port에 연결합니다.
명시적으로 활성화한 환경에서만 데모 사업장과 ADMIN 계정을 Flyway 이후 한 번 생성합니다. 비밀번호 기본값을 두지 않고 BCrypt hash만 저장하며, 기존 계정을 덮어쓰지 않는 멱등 동작과 Secret 비노출을 테스트·README·환경변수 예시에 반영했습니다.
26 tasks
이미 생성된 데모 ADMIN이 있어도 연결된 Company가 실제로 존재하고 활성 상태인지 다시 확인합니다. 비활성 사업장을 정상 Seed로 오인하지 않도록 회귀 테스트를 추가했습니다.
동시 요청이 lock을 기다리기 전에 시각을 잡아 새 교체 토큰보다 이른 revoked_at을 기록하던 PostgreSQL 제약 위반을 수정합니다. Refresh와 Logout 모두 family 조회·잠금 이후에 현재 시각을 읽도록 바꾸고 호출 순서 회귀 테스트를 추가했습니다.
hywznn
marked this pull request as ready for review
July 22, 2026 10:49
Contributor
Author
|
로그인 기능을 최대한 고도화 해봤습니다 한번 나중에 어디다가 써먹어보려구여 |
This was referenced Jul 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
왜 필요한가요?
Closes #4
PR #33에서 구현한 로그인·JWT·사업장 권한 기반 위에 Access Token 재발급과 로그아웃을 완성합니다. Refresh Token 재사용·동시 요청·쿠키 보안 정책을 명시하지 않으면 탈취된 토큰이나 중복 재발급이 새 로그인 상태를 계속 만들 수 있고, 데모 환경의 초기 계정도 안전하게 준비하기 어렵습니다.
무엇이 바뀌나요?
POST /api/v1/auth/refresh: HttpOnly 쿠키 전용 Refresh Token rotationPOST /api/v1/auth/logout: token family 폐기와 멱등204401 INVALID_REFRESH_TOKEN반환SameSite=None을 금지하고Strict·Lax만 허용Cache-Control: no-store반환AuthAuditPort정의.env.example에 canonical path, cookie 계약, single-flight, JWT 만료 정책과 Seed 방법 반영어떻게 검증했나요?
./gradlew clean test./gradlew build로컬 전체 테스트와 build가 통과했습니다. 로컬에는 PostgreSQL/Docker가 없어 PostgreSQL 전용 테스트는 skip됩니다. GitHub Actions의 실제 PostgreSQL 환경에서 동일 토큰 동시 요청이 정확히
200 1건 + 401 1건인지 검증했고 통과했습니다.보안·개인정보
user_id + company_id를 함께 검증합니다.API·DB·운영 영향
credentials: "include"를 사용해야 합니다.refresh_token제약과 index를 사용합니다..env.example에 추가했으며 실제 Seed 비밀번호는 포함하지 않았습니다.DEMO_SEED_ENABLED=false를 유지하면 됩니다. 새 migration이 없어 DB rollback은 필요하지 않습니다.응답 예시
{ "access_token": "<redacted-jwt>", "token_type": "Bearer", "expires_in_seconds": 900, "expires_at": "2026-07-22T01:15:00Z" }새 Refresh Token은 위 JSON에 포함되지 않고
Set-Cookie로만 전달됩니다.