feat: refresh 토큰 완전 회전 + 재사용 탐지

- RefreshSession(패밀리) 테이블 도입 — 한 로그인=1행(currentJti/prevJti/expiresAt)
- /auth/refresh 회전형으로 교체: 현재 jti→새 jti 회전, 옛 토큰 재제출 시 재사용 탐지→패밀리 폐기(401)
- 동시 갱신 오탐 방지: 직전 jti 15초 유예 + 프론트 useApi single-flight refresh
- 로그아웃=해당 세션만 폐기, revokeAllSessions(전 기기) 준비
- tokenVersion·jwt-refresh.strategy·JwtRefreshGuard 제거(refresh는 가드 없이 서비스 직접 처리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 22:57:01 +09:00
parent df268d3b5e
commit db6a80a6f4
13 changed files with 220 additions and 102 deletions
+2 -1
View File
@@ -66,7 +66,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
| GET | `/auth/google` · `/auth/kakao` | — | 302 redirect | 소셜 동의 화면으로(전체 페이지 이동) |
| GET | `/auth/google/callback` · `/auth/kakao/callback` | — | 302 redirect | 쿠키 set 후 `FRONTEND_URL/repos` 로 |
| POST | `/auth/logout` | — | `null` | 쿠키 clear |
| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | access 쿠키 재발급 |
| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | **회전형** — access/refresh 모두 교체. 재사용/만료/누락 시 401(쿠키 제거) |
| GET | `/auth/me` | — | `User` | 부트스트랩/가드용. 미인증 401 |
`User` 응답 형태:
@@ -87,6 +87,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
- **토큰**: 원문 토큰은 메일 링크에만, DB엔 SHA-256 해시 + 만료(24h)만 저장. 재발송은 사용자 존재/상태를 노출하지 않는다.
- **소셜 로그인**(Google/Kakao): 프론트가 `/auth/{provider}` 로 전체 페이지 이동 → 콜백에서 이메일로 계정 매칭(없으면 생성, `emailVerified=true`) → 쿠키 발급 후 `/repos` 로. 키(`*_CLIENT_ID`) 미설정 시 해당 제공자 비활성.
- **계정 모델**: `provider`('local'|'google'|'kakao'), `emailVerified`. 소셜 전용 계정은 비밀번호가 없어(`passwordHash=null`) 비밀번호 로그인 시 소셜 안내. 인증 도입 이전 로컬 계정은 부팅 시 그랜드페더링(토큰 없는 로컬 계정 → 인증 완료).
- **세션/토큰 회전**: refresh 는 **회전형 + 재사용 탐지**. `refresh_sessions`(패밀리 단위, 한 로그인=1행) 에 현재 jti 를 두고, `/auth/refresh` 마다 새 jti 로 회전. 이미 회전된 옛 토큰이 다시 오면 탈취로 간주해 패밀리 폐기(전 세션 강제 로그아웃). 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight(`useApi`)로 방지. logout=해당 패밀리만 폐기. (access 토큰은 15분 만료까지 유효 — 즉시 폐기 denylist 미적용.)
- **환경변수**: `APP_API_URL`(메일 링크용 백엔드 주소), `FRONTEND_URL`(리다이렉트 대상), `GOOGLE_*`/`KAKAO_*`(OAuth 키·콜백). `.env.development.example` 참조.
---
+1 -1
View File
@@ -293,7 +293,7 @@
- **H1** OAuth 제공자 이메일 검증 미확인(계정 탈취/선점) → 전략이 `email_verified`/`is_email_verified` 를 읽어 전달, 미검증 시 403 거부.
- **H2** OAuth `state` 누락(로그인 CSRF) → 세션 없는 구조라 `CookieStateStore`(httpOnly 1회용 state 쿠키) 신설, 두 전략에 주입(새 의존성 0).
- **H3** refresh 회전/폐기 부재 → `User.tokenVersion` + refresh payload `ver` 검증, **logout 이 tokenVersion 증가로 발급 refresh 일괄 폐기**(구토큰 0 호환).
- **H3** refresh 회전/폐기 부재 → (초기) `User.tokenVersion` 폐기 → **(강화) 세션 기반 완전 회전 + 재사용 탐지로 교체**. `RefreshSession`(패밀리, 한 로그인=1행: id/currentJti/prevJti+prevAt/expiresAt) 도입. `/auth/refresh` 가 현재 jti면 새 jti로 회전, 옛 토큰 재제출이면 **재사용 탐지 → 패밀리 폐기(401)**. 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 `useApi` single-flight 로 방지. logout=패밀리 폐기, `revokeAllSessions`(전 기기) 준비. `tokenVersion`·`jwt-refresh.strategy`·`JwtRefreshGuard` 제거(refresh 는 가드 없이 서비스 직접 처리).
- **M1** 업로드 MIME 화이트리스트(`fileFilter`, html/svg 차단)+거부 400, 다운로드 `attachment`+`nosniff`(저장형 XSS 차단).
- **M2** `@Throttle` 라우트별(login 10·signup 5·resend 3 /분).
- **M3** 첨부 삭제 업로더/admin 한정.
+2 -2
View File
@@ -88,7 +88,7 @@ IDOR/교차 저장소 위조 불가, 알림 타인 접근 불가, mass-assignmen
- **H1 ✅** 각 전략이 제공자 이메일 검증 플래그를 읽어 `OAuthProfile.emailVerified` 전달, `validateOrCreateOAuthUser` 가 미검증이면 403 거부(검증 이메일일 때만 매칭/생성/미인증 로컬 승격). Google `_json.email_verified`, Kakao `kakao_account.is_email_verified`.
- **H2 ✅** 세션 없는 구조라 `CookieStateStore`(passport-oauth2 store 인터페이스) 신설 — 인가 시작 시 httpOnly 1회용 난수 state 쿠키 발급, 콜백에서 일치 검증. 두 전략에 `store` 주입(새 의존성 0). *런타임은 실제 제공자 콜백으로만 최종 확인 가능.*
- **H3 ✅** `User.tokenVersion`(int) 추가, refresh JWT payload 에 `ver` 서명, `jwt-refresh.strategy``ver===tokenVersion` 검증(구토큰은 0 호환). **logout 이 `revokeSession`(refresh 검증 후 tokenVersion 증가)** 으로 발급된 refresh 일괄 폐기. (완전 회전+재사용탐지는 후속.)
- **H3 ✅ → 완전 회전+재사용 탐지로 강화(2026-06-19)**: 초기엔 `tokenVersion` 폐기 수준이었으나, **세션(패밀리) 테이블 기반 회전형 refresh** 로 교체. `RefreshSession`(id=fid, currentJti, prevJti+prevAt, expiresAt) 한 로그인당 1행. `/auth/refresh` 가 현재 jti면 새 jti로 **회전**, 이미 회전된 옛 토큰 재제출이면 **재사용 탐지 → 패밀리 전체 폐기(401)**. 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight로 방지. logout=해당 패밀리만 폐기, `revokeAllSessions`(전 기기) 메서드 준비(비번 재설정 시 사용). `tokenVersion`/`jwt-refresh.strategy`/`JwtRefreshGuard` 제거(refresh는 가드 없이 서비스에서 직접 처리).
- **M1 ✅** 업로드 `fileFilter` MIME 화이트리스트(html/svg 등 차단) + 거부 시 400, 다운로드 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`.
- **M2 ✅** `@Throttle` 라우트별 — login 10/분, signup 5/분, resend 3/분(IP 기준). 계정 잠금/CAPTCHA·이메일별 쿨다운은 후속.
- **M3 ✅** `removeFile` 가 업로더 본인 또는 admin 만 허용(`getAttachmentOrThrow` 에 uploader 로드).
@@ -110,4 +110,4 @@ IDOR/교차 저장소 위조 불가, 알림 타인 접근 불가, mass-assignmen
**검증**: 백엔드 build/lint 0 · 20 tests. 프론트 변경 없음.
**남은 항목**: L1(에이전트 v-html, 에이전트 작업 시 함께) + §2 후속 강화(토큰 완전회전·계정잠금/CAPTCHA·M5) + §4 기능공백(비번 재설정·SMTP·파일 콘텐츠 검증). **런타임 미검증**: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요.
**남은 항목**: L1(에이전트 v-html, 에이전트 작업 시 함께) + §2 후속 강화(~~토큰 완전회전~~ **완료** / 계정잠금·CAPTCHA / M5) + §4 기능공백(비번 재설정·SMTP·파일 콘텐츠 검증). access 토큰 즉시 폐기(denylist)는 여전히 미적용(15분 잔존, 수용). **런타임 미검증**: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요(특히 회전형 refresh의 동시성·재사용 탐지 동작).