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
+17 -1
View File
@@ -67,6 +67,21 @@ api.interceptors.request.use(
// 인증 만료 응답으로 판단할지 — /auth/* 자체 호출은 재시도 대상에서 제외
const isAuthEndpoint = (url?: string): boolean => (url ?? '').includes('/auth/')
// refresh 단일 실행(single-flight) — 동시 401 이 각자 refresh 를 호출하면 회전형 토큰이
// 재사용으로 오탐되어 세션이 폐기될 수 있다. 진행 중 refresh 가 있으면 그 Promise 를 공유한다.
let refreshInFlight: Promise<void> | null = null
const refreshOnce = (): Promise<void> => {
if (!refreshInFlight) {
refreshInFlight = api
.post('/auth/refresh', null, { silent: true })
.then(() => undefined)
.finally(() => {
refreshInFlight = null
})
}
return refreshInFlight
}
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 401 자동 refresh + 에러코드 매핑
api.interceptors.response.use(
(response: AxiosResponse<StandardResponse>) => {
@@ -89,10 +104,11 @@ api.interceptors.response.use(
const status: number | undefined = error?.response?.status
// access 토큰 만료(401) → refresh 1회 시도 후 원요청 재시도
// (동시 401 은 single-flight 로 묶어 refresh 토큰 회전이 한 번만 일어나게 한다)
if (status === 401 && config && !config._retry && !isAuthEndpoint(config.url)) {
config._retry = true
try {
await api.post('/auth/refresh', null, { silent: true })
await refreshOnce()
return api(config)
} catch {
// refresh 실패 → 세션 만료로 간주, 아래 일반 에러 처리로 진행