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:
@@ -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 실패 → 세션 만료로 간주, 아래 일반 에러 처리로 진행
|
||||
|
||||
Reference in New Issue
Block a user