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` 참조.
---