generated from ttipo/project-base
feat(frontend): 13단계 전환 준비 화면 — 새 비밀번호 · 2단계 켜기 단계 · 유예 줄 · 메일 프로그램 연결 · 30일 안내 · 문의 줄
- 제한된 세션: restriction 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로, AUTH_010 은 알림 없이 me 를 다시 받아 옮긴다. 제한된 동안은 나가기 확인을 묻지 않고(확인에서 이동을 취소하면 메일 화면에 갇혔다 — 리뷰) 실시간 알림을 열지 않는다. - 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분 · 세션이 끝나면 지운다. 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다. - 유예 노란 줄(시작일 전날까지), 설정 › 2단계 인증의 필수 안내, 설정 › 메일 프로그램 연결(펼침 줄 · 새 Outlook 은 권하지 않음), 휴지통 · 스팸메일함 30일 줄, 로그인 · 코드 단계의 문의 줄. 검증: lint · type-check · 단위 2,354 · 실사용 시험 89/89. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,8 @@ describe('에러 코드 사전', () => {
|
||||
'AUTH_007', // 2단계 인증을 켠 직원의 요청에 코드가 없음 — 비밀번호 화면이 2단계 상태를 다시 받고 코드 칸을 보인다
|
||||
'AUTH_008', // 등록이 없음 · 시간이 지남 · 그사이 바뀜 — 켜기 창이 처음부터 다시 한다
|
||||
'AUTH_009', // 앱 비밀번호 상한 — 만들기 창이 칸 아래에 보이고 목록을 다시 받는다
|
||||
// 웹메일 13단계 (전환 준비)
|
||||
'AUTH_010', // 제한된 세션 — 알림 없이 me 로 제한을 다시 받아 로그인 틀의 그 단계로 (옛 토큰이면 갱신 뒤 한 번 다시 보낸다)
|
||||
]
|
||||
|
||||
for (const code of usedByClient) {
|
||||
@@ -255,6 +257,162 @@ describe('useApi 인터셉터', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useApi 403 AUTH_010 — 제한된 세션 (13단계 · 계약 6절)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
clock += 60_000
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const lines = (requests: { method: string; url: string }[]) => requests.map((request) => `${request.method} ${request.url}`)
|
||||
|
||||
it('알림 없이 me 로 제한을 다시 받아 둔다 — 원래 요청은 다시 보내지 않고 AUTH_010 으로 끝낸다, 확인한 차례가 오른다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/auth/me' ? ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }) : fail(403, 'AUTH_010'),
|
||||
)
|
||||
|
||||
const error = await rejectionOf(api.get('/mailboxes'))
|
||||
|
||||
expect(error.code).toBe('AUTH_010')
|
||||
expect(lines(requests)).toEqual(['GET /mailboxes', 'GET /auth/me'])
|
||||
expect(authStore.restriction).toBe('twoFactorSetup')
|
||||
expect(authStore.isLoggedIn).toBe(true)
|
||||
expect(authStore.restrictionChecks).toBe(1)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('같은 제한이 다시 확인돼도 확인한 차례는 오른다 — 앞 이동이 막혀 보호 화면에 남았으면 화면이 다시 옮긴다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
|
||||
installMockApi(({ url }) =>
|
||||
url === '/auth/me' ? ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }) : fail(403, 'AUTH_010'),
|
||||
)
|
||||
|
||||
await rejectionOf(api.post('/messages', {}))
|
||||
await rejectionOf(api.post('/messages', {}))
|
||||
|
||||
expect(authStore.restrictionChecks).toBe(2)
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('me 를 받지 못하면(장애) 제한을 확인하지 못한 것 — AUTH_010 사전 문구로 알린다 (silent 요청은 알리지 않는다)', async () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
installMockApi(({ url }) => (url === '/auth/me' ? fail(503, 'SYS_001') : fail(403, 'AUTH_010')))
|
||||
|
||||
await rejectionOf(api.get('/mail-state', { silent: true }))
|
||||
expect(toastMessages()).toEqual([])
|
||||
|
||||
await rejectionOf(api.post('/messages', {}))
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_010])
|
||||
expect(useAuthStore().restrictionChecks).toBe(0)
|
||||
})
|
||||
|
||||
it('모르는 제한(새 서버)도 제한이다 — 갱신 · 다시 보내기를 하지 않는다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/auth/me' ? ok({ ...USER, restriction: 'brandNew', twoFactorDeadline: null }) : fail(403, 'AUTH_010'),
|
||||
)
|
||||
|
||||
await rejectionOf(api.get('/mailboxes'))
|
||||
|
||||
expect(lines(requests)).toEqual(['GET /mailboxes', 'GET /auth/me'])
|
||||
expect(authStore.restriction).toBe('unknown')
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('silent 를 주지 않은 요청이어도 알림을 띄우지 않는다 — 화면이 그 단계로 간다', async () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
installMockApi(({ url }) =>
|
||||
url === '/auth/me' ? ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }) : fail(403, 'AUTH_010'),
|
||||
)
|
||||
|
||||
await rejectionOf(api.post('/messages', {}))
|
||||
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('me 가 제한 없음이면 옛 토큰이다 — 갱신으로 새 토큰을 받고 원래 요청을 한 번 다시 보낸다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
let refreshed = false
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/me') return ok({ ...USER, restriction: null, twoFactorDeadline: null })
|
||||
if (url === '/auth/refresh') {
|
||||
refreshed = true
|
||||
return ok({ ...USER, restriction: null, twoFactorDeadline: null })
|
||||
}
|
||||
return refreshed ? ok({ items: [] }) : fail(403, 'AUTH_010')
|
||||
})
|
||||
|
||||
await expect(api.get('/mailboxes')).resolves.toEqual({ items: [] })
|
||||
|
||||
expect(lines(requests)).toEqual(['GET /mailboxes', 'GET /auth/me', 'POST /auth/refresh', 'GET /mailboxes'])
|
||||
expect(authStore.restriction).toBeNull()
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('다시 보낸 요청이 또 AUTH_010 이면 더 묻지 않고 끝낸다 (한 번만) — 확인하지 못했으니 사전 문구로 알린다', async () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
const requests = installMockApi(({ url }) => {
|
||||
if (url === '/auth/me' || url === '/auth/refresh') return ok({ ...USER, restriction: null, twoFactorDeadline: null })
|
||||
return fail(403, 'AUTH_010')
|
||||
})
|
||||
|
||||
const error = await rejectionOf(api.get('/mailboxes'))
|
||||
|
||||
expect(error.code).toBe('AUTH_010')
|
||||
expect(lines(requests)).toEqual(['GET /mailboxes', 'GET /auth/me', 'POST /auth/refresh', 'GET /mailboxes'])
|
||||
expect(toastMessages()).toEqual([ERROR_CODE_MESSAGES.AUTH_010])
|
||||
})
|
||||
|
||||
it('동시에 여러 요청이 AUTH_010 을 받아도 me 는 한 번만 묻는다', async () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
let releaseMe: (result: MockResult) => void = () => undefined
|
||||
const meGate = new Promise<MockResult>((resolve) => {
|
||||
releaseMe = resolve
|
||||
})
|
||||
const requests = installMockApi(({ url }) => (url === '/auth/me' ? meGate : fail(403, 'AUTH_010')))
|
||||
|
||||
const first = rejectionOf(api.get('/mailboxes'))
|
||||
const second = rejectionOf(api.get('/quota'))
|
||||
await flushPromises()
|
||||
releaseMe(ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }))
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(requests.filter((request) => request.url === '/auth/me')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('묻는 사이 로그아웃했으면 me 의 결과를 쓰지 않는다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
let releaseMe: (result: MockResult) => void = () => undefined
|
||||
const meGate = new Promise<MockResult>((resolve) => {
|
||||
releaseMe = resolve
|
||||
})
|
||||
installMockApi(({ url }) => (url === '/auth/me' ? meGate : fail(403, 'AUTH_010')))
|
||||
|
||||
const pending = rejectionOf(api.get('/mailboxes'))
|
||||
await flushPromises()
|
||||
authStore.clearAuth()
|
||||
releaseMe(ok({ ...USER, restriction: 'passwordChange', twoFactorDeadline: null }))
|
||||
await pending
|
||||
|
||||
expect(authStore.isLoggedIn).toBe(false)
|
||||
expect(authStore.restriction).toBeNull()
|
||||
expect(toastMessages()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useApi 401 처리 (계약 1절)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -345,10 +503,27 @@ describe('useApi 401 처리 (계약 1절)', () => {
|
||||
const authStore = useAuthStore()
|
||||
installMockApi(() => ({ status: 200, data: { success: true, data: USER } }))
|
||||
|
||||
await expect(refreshSession()).resolves.toEqual({ ok: true, user: USER })
|
||||
await expect(refreshSession()).resolves.toEqual({
|
||||
ok: true,
|
||||
user: USER,
|
||||
session: { restriction: null, twoFactorDeadline: null },
|
||||
})
|
||||
expect(authStore.user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('갱신 응답의 제한 · 유예 마감(13단계)을 스토어에 둔다 — 시작일이 지나면 갱신 때 제한이 붙는다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER, { restriction: null, twoFactorDeadline: '2026-11-30T15:00:00.000Z' })
|
||||
installMockApi(() => ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }))
|
||||
|
||||
const outcome = await refreshSession()
|
||||
|
||||
expect(outcome).toEqual({ ok: true, user: USER, session: { restriction: 'twoFactorSetup', twoFactorDeadline: null } })
|
||||
expect(authStore.user).toEqual(USER)
|
||||
expect(authStore.restriction).toBe('twoFactorSetup')
|
||||
expect(authStore.twoFactorDeadline).toBeNull()
|
||||
})
|
||||
|
||||
it('동시에 여러 요청이 401 을 받아도 갱신 요청은 한 번만 보낸다', async () => {
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
let refreshed = false
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
FALLBACK_ERROR_CODE,
|
||||
type ErrorCode,
|
||||
} from '@/shared/constants/error-codes.generated'
|
||||
import type { AuthUser } from '@/shared/types/auth'
|
||||
import type { AuthSessionResponse, AuthSessionState, AuthUser } from '@/shared/types/auth'
|
||||
import { joinUrl, resolveApiBaseUrl } from '@/shared/utils/api-url'
|
||||
import { sessionStateOf, sessionUserOf } from '@/shared/utils/auth-session'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
|
||||
@@ -28,6 +29,8 @@ declare module 'axios' {
|
||||
_authRetried?: boolean
|
||||
/** 내부용 — 요청을 보낼 때 로그인 세션이 있었는지 (401 을 세션 만료로 볼지 가른다) */
|
||||
_sentWithSession?: boolean
|
||||
/** 내부용 — 403 AUTH_010 뒤 제한을 다시 맞추고 이미 한 번 다시 보낸 요청인지 (한 번만 다시 보낸다 — 13단계) */
|
||||
_restrictionRetried?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +60,12 @@ const API_TIMEOUT_MS = 10000
|
||||
/** 동일 코드의 알림이 짧은 시간에 중복 노출되는 것을 막는 간격 */
|
||||
const DUPLICATE_ALERT_WINDOW_MS = 3000
|
||||
|
||||
/**
|
||||
* 제한된 세션(13단계 — 계약 6절)으로 다른 API 를 불렀다. me 로 제한을 다시 받아 확인되면 알리지 않는다 — 화면이 로그인 틀의 그 단계로 간다
|
||||
* (App 의 useSessionRestriction — 옮기지 못하면 거기서 사전 문구로 알린다). 확인하지 못했으면 사전 문구로 알린다
|
||||
*/
|
||||
const RESTRICTED_CODE: ErrorCode = 'AUTH_010'
|
||||
|
||||
/** 계약 1절 — 401 이어도 /auth/refresh 로 재시도하지 않는 인증 엔드포인트 */
|
||||
const AUTH_ENDPOINTS = ['/auth/login', '/auth/refresh', '/auth/me', '/auth/logout'] as const
|
||||
const REFRESH_ENDPOINT = '/auth/refresh'
|
||||
@@ -177,22 +186,26 @@ function isSilenced(code: ErrorCode, silent: boolean | readonly ErrorCode[] | un
|
||||
return silent.includes(code)
|
||||
}
|
||||
|
||||
/** 에러 코드를 확정하고 알림까지 처리한 뒤 정규화된 에러를 만든다. */
|
||||
/**
|
||||
* 에러 코드를 확정하고 알림까지 처리한 뒤 정규화된 에러를 만든다.
|
||||
* notify 가 false 면 알리지 않는다 — 화면이 다른 길로 안내할 때 (제한을 확인한 AUTH_010 — 로그인 틀의 그 단계로 간다)
|
||||
*/
|
||||
function handle(
|
||||
rawCode: string,
|
||||
traceId: string | undefined,
|
||||
status: number | undefined,
|
||||
config: InternalAxiosRequestConfig | undefined,
|
||||
notify = true,
|
||||
): ApiError {
|
||||
const authStore = useAuthStore()
|
||||
const sentWithSession = config?._sentWithSession ?? authStore.isLoggedIn
|
||||
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE, sentWithSession)
|
||||
const message = getErrorMessage(code)
|
||||
// 로그아웃한 뒤에 돌아온, 로그인 중에 보낸 요청의 401 — 사용자가 끝낸 세션이라 알리지 않는다
|
||||
// 로그아웃한 뒤에 돌아온, 로그인 중에 보낸 요청의 401 · 403 AUTH_010 — 사용자가 끝낸 세션이라 알리지 않는다
|
||||
// (로그인 화면에 "로그인이 필요한 서비스입니다" 가 뜨지 않게)
|
||||
const endedBySignOut = code === 'AUTH_001' && sentWithSession && authStore.signedOut
|
||||
const endedBySignOut = (code === 'AUTH_001' || code === RESTRICTED_CODE) && sentWithSession && authStore.signedOut
|
||||
|
||||
if (!endedBySignOut && !isSilenced(code, config?.silent)) showErrorUI(code, message, traceId)
|
||||
if (notify && !endedBySignOut && !isSilenced(code, config?.silent)) showErrorUI(code, message, traceId)
|
||||
|
||||
return { code, message, traceId, status }
|
||||
}
|
||||
@@ -225,7 +238,7 @@ export function isSessionEnd(code: ErrorCode): boolean {
|
||||
return code === 'AUTH_001' || code === 'AUTH_002'
|
||||
}
|
||||
|
||||
export type RefreshOutcome = { ok: true; user: AuthUser } | { ok: false; error: ApiError }
|
||||
export type RefreshOutcome = { ok: true; user: AuthUser; session: AuthSessionState } | { ok: false; error: ApiError }
|
||||
|
||||
let refreshInFlight: Promise<RefreshOutcome> | null = null
|
||||
|
||||
@@ -257,15 +270,18 @@ export function refreshSession(): Promise<RefreshOutcome> {
|
||||
const startedAt = useAuthStore().generation
|
||||
refreshInFlight = api
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 응답 타입(R)을 data 타입으로 지정한다
|
||||
.post<AuthUser, AuthUser>(REFRESH_ENDPOINT, null, { silent: true })
|
||||
.then(async (user): Promise<RefreshOutcome> => {
|
||||
.post<AuthSessionResponse, AuthSessionResponse>(REFRESH_ENDPOINT, null, { silent: true })
|
||||
.then(async (response): Promise<RefreshOutcome> => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.generation !== startedAt || authStore.signedOut) {
|
||||
await clearCookiesOfLateRefresh()
|
||||
return { ok: false, error: { code: 'AUTH_001', message: getErrorMessage('AUTH_001') } }
|
||||
}
|
||||
authStore.markAuthenticated(user)
|
||||
return { ok: true, user }
|
||||
// 새 토큰의 제한 · 유예 마감도 함께 바꾼다 (13단계 — 시작일이 지나면 갱신 때 제한이 붙는다)
|
||||
const user = sessionUserOf(response)
|
||||
const session = sessionStateOf(response)
|
||||
authStore.markAuthenticated(user, session)
|
||||
return { ok: true, user, session }
|
||||
})
|
||||
.catch((error: unknown): RefreshOutcome => ({ ok: false, error: toApiError(error) }))
|
||||
.finally(() => {
|
||||
@@ -275,6 +291,79 @@ export function refreshSession(): Promise<RefreshOutcome> {
|
||||
return refreshInFlight
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금 세션 — me 를 알림 없이 묻는다(제한 · 유예 마감은 서버 세션 표의 지금 값). 13단계 — 제한을 다시 맞출 때 · 갱신이 장애로 실패했을 때.
|
||||
* 세션 없이 보낸 요청으로 표시한다 — me 의 401 이 곧바로 세션 만료(AUTH_002)로 승격되지 않게 (갱신해 본 뒤에 정한다)
|
||||
*/
|
||||
export function fetchCurrentSession(): Promise<AuthSessionResponse> {
|
||||
return api.get<AuthSessionResponse, AuthSessionResponse>(ME_ENDPOINT, { silent: true, _sentWithSession: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* 403 AUTH_010 뒤 제한을 다시 맞춘 결과 —
|
||||
* renewed: 옛 토큰이었다(제한 없이 새 토큰을 받음 — 원래 요청을 한 번 다시 보내도 된다) ·
|
||||
* restricted: 제한을 확인했다(화면이 그 단계로 간다 — 알리지 않는다) · stale: 그사이 세션이 바뀌었다 · unknown: 확인하지 못했다(알린다)
|
||||
*/
|
||||
export type RestrictionCheck = 'renewed' | 'restricted' | 'stale' | 'unknown'
|
||||
|
||||
let restrictionCheck: Promise<RestrictionCheck> | null = null
|
||||
|
||||
/**
|
||||
* 403 AUTH_010 을 받았다 — 알림 없이 me 로 세션의 제한을 다시 받는다 (계약 6절 "제한된 세션").
|
||||
* - 제한이 있으면 스토어에 두고 확인한 차례를 올린다(noteRestrictionCheck) → 보호 화면이면 App 의 useSessionRestriction 이 로그인 틀의 그 단계로 보낸다.
|
||||
* 같은 제한이 다시 와도 차례가 올라 다시 옮긴다(앞 이동이 막혔을 때). 모르는 제한(unknown)도 제한이다 — 갱신 · 다시 보내기를 하지 않는다
|
||||
* - 제한이 없다는데 AUTH_010 이면 이 액세스 토큰이 제한을 풀기 전에 받은 옛것이다 — 갱신으로 새 토큰을 받는다 (renewed)
|
||||
* - me 가 AUTH_001(액세스 토큰 만료)이면 갱신해 본다 (같은 규칙). 그 밖의 장애는 unknown
|
||||
* 동시에 여러 요청이 AUTH_010 을 받아도 한 번만 묻는다. 그사이 세션이 바뀌었으면(로그아웃 · 다른 계정) 결과를 쓰지 않는다 (stale)
|
||||
*/
|
||||
export function recheckRestriction(): Promise<RestrictionCheck> {
|
||||
if (!restrictionCheck) {
|
||||
const startedAt = useAuthStore().generation
|
||||
const sessionChanged = () => {
|
||||
const authStore = useAuthStore()
|
||||
return authStore.generation !== startedAt || authStore.signedOut
|
||||
}
|
||||
restrictionCheck = (async (): Promise<RestrictionCheck> => {
|
||||
try {
|
||||
const response = await fetchCurrentSession()
|
||||
if (sessionChanged()) return 'stale'
|
||||
const session = sessionStateOf(response)
|
||||
if (session.restriction !== null) {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(sessionUserOf(response), session)
|
||||
authStore.noteRestrictionCheck()
|
||||
return 'restricted'
|
||||
}
|
||||
} catch (error) {
|
||||
if (sessionChanged()) return 'stale'
|
||||
if (!isApiError(error) || error.code !== 'AUTH_001') return 'unknown'
|
||||
}
|
||||
const outcome = await refreshSession()
|
||||
if (sessionChanged()) return 'stale'
|
||||
// 갱신이 세션 끝(401)이면 세션 만료 흐름(대화상자)이 맡는다
|
||||
if (!outcome.ok) return isSessionEnd(outcome.error.code) ? 'stale' : 'unknown'
|
||||
if (outcome.session.restriction !== null) {
|
||||
useAuthStore().noteRestrictionCheck()
|
||||
return 'restricted'
|
||||
}
|
||||
return 'renewed'
|
||||
})().finally(() => {
|
||||
restrictionCheck = null
|
||||
})
|
||||
}
|
||||
return restrictionCheck
|
||||
}
|
||||
|
||||
/** 로그인 중에 보낸 요청의 403 AUTH_010 — 제한을 다시 맞출 대상인가 (인증 엔드포인트 · 로그아웃 뒤의 응답은 아니다) */
|
||||
function isRestrictedResponse(code: string, config: InternalAxiosRequestConfig): boolean {
|
||||
return (
|
||||
code === RESTRICTED_CODE &&
|
||||
config._sentWithSession === true &&
|
||||
!isAuthEndpoint(config.url) &&
|
||||
!useAuthStore().signedOut
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 탭에서 진행 중인 갱신(과 그 뒤의 정리용 로그아웃)이 끝날 때까지 기다린다 — 로그인 직전에 부른다.
|
||||
* 늦게 온 갱신 응답의 Set-Cookie 가 새 로그인 쿠키를 덮거나, 정리용 로그아웃이 새 세션을 끊지 않게 한다.
|
||||
@@ -376,6 +465,14 @@ api.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
// 제한된 세션(403 AUTH_010 — 13단계) — 제한을 다시 맞춘다. 옛 토큰이었으면 새 토큰으로 한 번만 다시 보낸다.
|
||||
// 제한을 확인했으면 알리지 않는다(화면이 그 단계로 간다), 확인하지 못했거나 다시 보낸 요청도 막히면 사전 문구로 알린다
|
||||
if (config && isRestrictedResponse(code, config)) {
|
||||
const check: RestrictionCheck = config._restrictionRetried ? 'unknown' : await recheckRestriction()
|
||||
if (check === 'renewed') return api.request({ ...config, _restrictionRetried: true })
|
||||
return Promise.reject(handle(code, payload?.error?.traceId, status, config, check === 'unknown'))
|
||||
}
|
||||
|
||||
return Promise.reject(handle(code, payload?.error?.traceId, status, config))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,6 +20,10 @@ import { useAuth } from './useAuth'
|
||||
import { AUTH_CHANNEL_NAME, closeLogoutChannel } from './useLogoutBroadcast'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
/** 제한 · 유예가 없는 세션 (13단계) */
|
||||
const UNRESTRICTED = { restriction: null, twoFactorDeadline: null }
|
||||
/** 2단계 필수의 시작일 2026-12-01 0시(KST) */
|
||||
const DEADLINE = '2026-11-30T15:00:00.000Z'
|
||||
let clock = Date.UTC(2026, 9, 26, 2, 0, 0)
|
||||
|
||||
function requestLine(request: { method: string; url: string }) {
|
||||
@@ -55,6 +59,27 @@ describe('useAuth', () => {
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('제한된 세션(13단계)이면 me 의 restriction · twoFactorDeadline 을 둔다 — 새로고침 · 다른 탭도 그 단계로', async () => {
|
||||
installMockApi(() => ok({ ...USER, restriction: 'twoFactorSetup', twoFactorDeadline: null }))
|
||||
|
||||
await useAuth().restoreSession()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
expect(authStore.user).toEqual(USER)
|
||||
expect(authStore.restriction).toBe('twoFactorSetup')
|
||||
})
|
||||
|
||||
it('복원이 리프레시로 끝나도 그 응답의 제한 · 유예 마감을 둔다', async () => {
|
||||
installMockApi(({ url }) =>
|
||||
url === '/auth/me' ? fail(401, 'AUTH_001') : ok({ ...USER, restriction: null, twoFactorDeadline: DEADLINE }),
|
||||
)
|
||||
|
||||
await useAuth().restoreSession()
|
||||
|
||||
expect(useAuthStore().restriction).toBeNull()
|
||||
expect(useAuthStore().twoFactorDeadline).toBe(DEADLINE)
|
||||
})
|
||||
|
||||
it('/auth/me 가 AUTH_001 이면 알림 없이 리프레시로 한 번 더 시도한다 (액세스 토큰만 만료된 경우)', async () => {
|
||||
const requests = installMockApi(({ url }) =>
|
||||
url === '/auth/me' ? fail(401, 'AUTH_001') : ok(USER),
|
||||
@@ -96,7 +121,7 @@ describe('useAuth', () => {
|
||||
|
||||
await expect(
|
||||
useAuth().login({ email: USER.email, password: 'secret' }),
|
||||
).resolves.toEqual({ status: 'signedIn', user: USER })
|
||||
).resolves.toEqual({ status: 'signedIn', user: USER, session: UNRESTRICTED })
|
||||
|
||||
expect(requests[0]?.data).toEqual({ email: USER.email, password: 'secret' })
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
@@ -108,10 +133,37 @@ describe('useAuth', () => {
|
||||
await expect(useAuth().login({ email: USER.email, password: 'secret' })).resolves.toEqual({
|
||||
status: 'signedIn',
|
||||
user: USER,
|
||||
session: UNRESTRICTED,
|
||||
})
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('제한된 세션(13단계 — 계약 6절)도 로그인한다 — 제한 · 유예 마감은 스토어에 두고 결과로도 돌려준다 (사용자 칸에는 넣지 않는다)', async () => {
|
||||
installMockApi(() =>
|
||||
ok({ status: 'signedIn', ...USER, restriction: 'passwordChange', twoFactorDeadline: DEADLINE }),
|
||||
)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
await expect(useAuth().login({ email: USER.email, password: 'secret' })).resolves.toEqual({
|
||||
status: 'signedIn',
|
||||
user: USER,
|
||||
session: { restriction: 'passwordChange', twoFactorDeadline: DEADLINE },
|
||||
})
|
||||
expect(authStore.user).toEqual(USER)
|
||||
expect(authStore.isLoggedIn).toBe(true)
|
||||
expect(authStore.restriction).toBe('passwordChange')
|
||||
expect(authStore.twoFactorDeadline).toBe(DEADLINE)
|
||||
})
|
||||
|
||||
it('모르는 제한 이름은 제한이 있는 것(unknown) · 읽을 수 없는 마감은 없는 것으로 읽는다', async () => {
|
||||
installMockApi(() => ok({ status: 'signedIn', ...USER, restriction: 'somethingNew', twoFactorDeadline: 'soon' }))
|
||||
|
||||
await useAuth().login({ email: USER.email, password: 'secret' })
|
||||
|
||||
expect(useAuthStore().restriction).toBe('unknown')
|
||||
expect(useAuthStore().twoFactorDeadline).toBeNull()
|
||||
})
|
||||
|
||||
it('2단계 인증을 켠 직원(twoFactorRequired — 쿠키 없음)이면 세션을 열지 않고 그대로 돌려준다 (계약 5절)', async () => {
|
||||
installMockApi(() => ok({ status: 'twoFactorRequired', email: null, name: null }))
|
||||
const authStore = useAuthStore()
|
||||
@@ -296,7 +348,7 @@ describe('useAuth', () => {
|
||||
installMockApi(({ url }) => (url === '/auth/refresh' ? ok(USER) : fail(401, 'AUTH_001')))
|
||||
const outcome = await useAuth().refresh()
|
||||
|
||||
expect(outcome).toEqual({ ok: true, user: USER })
|
||||
expect(outcome).toEqual({ ok: true, user: USER, session: UNRESTRICTED })
|
||||
expect(authStore.signedOut).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,15 @@ import { ref } from 'vue'
|
||||
import { isApiError, refreshSession, settleRefresh, useApi } from './useApi'
|
||||
import { announceLogout } from './useLogoutBroadcast'
|
||||
import { PASSWORD_CHANGE_TIMEOUT_MS } from '@/shared/constants/account'
|
||||
import type { AuthUser, ChangePasswordPayload, LoginOutcome, LoginPayload, LoginResponse } from '@/shared/types/auth'
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
ChangePasswordPayload,
|
||||
LoginOutcome,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
SupportContact,
|
||||
} from '@/shared/types/auth'
|
||||
import { sessionStateOf, sessionUserOf } from '@/shared/utils/auth-session'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
export type { LoginOutcome, LoginPayload } from '@/shared/types/auth'
|
||||
@@ -23,7 +31,8 @@ export function useAuth() {
|
||||
* 로그인. 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
|
||||
* AUTH_004(자격 증명 불일치)는 로그인 폼이 직접 안내하므로 기본으로 그 코드만 전역 알림을 끈다.
|
||||
* 2단계 인증을 켠 직원이고 비밀번호가 맞으면 쿠키 없이 twoFactorRequired 가 온다(12단계 — 계약 5절) — 세션을 열지 않고 그대로 돌려준다.
|
||||
* 화면은 코드를 받아 비밀번호와 함께 다시 부른다(payload.otpCode)
|
||||
* 화면은 코드를 받아 비밀번호와 함께 다시 부른다(payload.otpCode).
|
||||
* 로그인한 세션에 제한(새 비밀번호 · 2단계 켜기 — 13단계 계약 6절)이 붙어 올 수 있다 — 스토어에 두고 결과로도 돌려준다
|
||||
*/
|
||||
async function login(payload: LoginPayload, options: LoginRequestOptions = {}): Promise<LoginOutcome> {
|
||||
loading.value = true
|
||||
@@ -37,10 +46,11 @@ export function useAuth() {
|
||||
...options,
|
||||
})
|
||||
if (response.status === 'twoFactorRequired') return { status: 'twoFactorRequired' }
|
||||
// 새 세션 — 세대가 바뀌어 그 전에 시작한 일의 늦은 결과는 쓰이지 않는다. 스토어에는 사용자 칸만 둔다
|
||||
const user: AuthUser = { email: response.email, name: response.name }
|
||||
authStore.startSession(user)
|
||||
return { status: 'signedIn', user }
|
||||
// 새 세션 — 세대가 바뀌어 그 전에 시작한 일의 늦은 결과는 쓰이지 않는다. 스토어에는 사용자 칸과 제한 · 유예 마감만 둔다
|
||||
const user = sessionUserOf(response)
|
||||
const session = sessionStateOf(response)
|
||||
authStore.startSession(user, session)
|
||||
return { status: 'signedIn', user, session }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -67,9 +77,9 @@ export function useAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 현재 세션의 사용자. 로그인 전이면 AUTH_001 — 정상 상태이므로 알림을 띄우지 않는다 (계약 1절) */
|
||||
function me(): Promise<AuthUser> {
|
||||
return api.get<AuthUser, AuthUser>('/auth/me', { silent: ['AUTH_001'] })
|
||||
/** 현재 세션의 사용자(+ 제한 · 유예 마감 — 13단계). 로그인 전이면 AUTH_001 — 정상 상태이므로 알림을 띄우지 않는다 (계약 1절) */
|
||||
function me(): Promise<AuthSessionResponse> {
|
||||
return api.get<AuthSessionResponse, AuthSessionResponse>('/auth/me', { silent: ['AUTH_001'] })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,14 +88,14 @@ export function useAuth() {
|
||||
* 그러면 /auth/refresh 를 알림 없이 한 번 부르고, 성공하면 복원 완료 · 실패하면 로그아웃 상태로 시작한다.
|
||||
* (/auth/me 는 인터셉터의 자동 재시도 대상이 아니라서 여기서 명시적으로 부른다)
|
||||
*/
|
||||
async function fetchSessionUser(): Promise<AuthUser | null> {
|
||||
async function fetchSessionUser(): Promise<AuthSessionResponse | null> {
|
||||
try {
|
||||
return await me()
|
||||
} catch (error) {
|
||||
if (!isApiError(error) || error.code !== 'AUTH_001') return null
|
||||
}
|
||||
const outcome = await refreshSession()
|
||||
return outcome.ok ? outcome.user : null
|
||||
return outcome.ok ? { ...outcome.user, ...outcome.session } : null
|
||||
}
|
||||
|
||||
/** 앱 시작 시 한 번 세션을 복원한다 (여러 번 불러도 조회는 한 번) */
|
||||
@@ -107,5 +117,13 @@ export function useAuth() {
|
||||
await api.put('/auth/password', payload, { timeout: PASSWORD_CHANGE_TIMEOUT_MS, ...options })
|
||||
}
|
||||
|
||||
return { loading, login, logout, me, refresh: refreshSession, restoreSession, changePassword }
|
||||
/**
|
||||
* 문의 연락처 (13단계 — 계약 6절 "문의 연락처", 로그인 없음). 정하지 않았으면 text 가 null.
|
||||
* 받지 못해도 화면은 지금 글 그대로라 알리지 않는다. 세션 없이 보낸 요청으로 표시한다 — 로그인 중 401 을 세션 만료로 승격하지 않게
|
||||
*/
|
||||
function getSupportContact(): Promise<SupportContact> {
|
||||
return api.get<SupportContact, SupportContact>('/support-contact', { silent: true, _sentWithSession: false })
|
||||
}
|
||||
|
||||
return { loading, login, logout, me, refresh: refreshSession, restoreSession, changePassword, getSupportContact }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
// [계속 고치기]면 창을 그대로 두고(stayed — 브라우저가 닫았으면 다시 띄운다), [닫기]면 close
|
||||
// - 창을 연 채 다른 화면으로: 이동 가드가 답을 기다린다(asking 'route' — [나가기]면 그대로 간다, [계속 고치기]면 이동을 취소한다)
|
||||
// - 탭 닫기 · 새로고침: 바꾼 것이 있는 동안 beforeunload (브라우저 확인창 — 보내는 중에도)
|
||||
// - 묻지 않는 때: 바꾼 것이 없다 · 보내는 중(저장 · 지우기 — 결과는 알림으로 알린다) · 세션이 끝났다 (서명 · 부재중 설정과 같다).
|
||||
// - 묻지 않는 때: 바꾼 것이 없다 · 보내는 중(저장 · 지우기 — 결과는 알림으로 알린다) · 세션이 끝났거나 제한이 붙었다(13단계) (서명 · 부재중 설정과 같다).
|
||||
// 묻는 중에 그렇게 되면 확인 창을 닫는다 — 닫기 확인은 창을 그대로 두고, 이동 확인은 그 이동을 취소한다(useLeaveConfirm)
|
||||
// - 닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면(뒤로 가기) 이동 확인이 이어받는다 — 같은 확인 창에 글만 바뀐다
|
||||
|
||||
@@ -32,11 +32,11 @@ export function useDialogLeaveConfirm(options: DialogLeaveConfirmOptions) {
|
||||
const closeConfirming = ref<boolean>(false)
|
||||
|
||||
/** 지금 닫거나 떠나면 잃는 것이 있다 — 이때만 묻는다 */
|
||||
const guarded = () => options.changed() && !options.sending() && authStore.isLoggedIn
|
||||
const guarded = () => options.changed() && !options.sending() && authStore.canUseApp
|
||||
|
||||
const route = useLeaveConfirm({
|
||||
active: guarded,
|
||||
unloadActive: () => options.changed() && authStore.isLoggedIn,
|
||||
unloadActive: () => options.changed() && authStore.canUseApp,
|
||||
})
|
||||
|
||||
const asking = computed<DialogLeaveAsking>(() => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import type { Clock, ClockTimer } from './useClock'
|
||||
import { RESTRICTED_SECRET_WINDOW_MS } from '@/shared/constants/session-restriction'
|
||||
|
||||
/**
|
||||
* 제한된 로그인 단계의 비밀번호 (13단계 — 계약 6절 "비밀번호 바꾸기 강제" · "2단계 인증 필수" 클라이언트).
|
||||
* 로그인 때 넣은 비밀번호(새 비밀번호를 정한 뒤에는 그것)를 새 비밀번호 단계의 「임시 비밀번호」 · 2단계 켜기의 「지금 비밀번호」로 다시 쓴다.
|
||||
* 반응형 상태에 넣지 않는다(개발 도구 · 화면에 드러나지 않게) — 반응형은 "있는가"(available)뿐이다. 12단계 useLoginChallenge 와 같은 결.
|
||||
* 지우는 때: 마지막 단계를 마침 · [다른 계정으로 로그인] · 화면을 떠날 때(unmount) · 10분이 지났을 때 · 서버가 그 비밀번호를 거절했을 때(AUTH_005).
|
||||
* 10분은 타이머 하나에 기대지 않는다 — 절전 · 뒤에 깔린 탭에서는 타이머가 늦게 돈다. 꺼낼 때(peek) 시계로 한 번 더 잰다.
|
||||
* 화면이 사라진 뒤에는 기억하지 않는다(remember 가 false) — 떠난 뒤 늦게 온 로그인 응답이 비밀번호를 붙잡지 않게.
|
||||
* 시계는 주입받는다(useClock) — 테스트는 손으로 돌리는 시계로 10분을 넘긴다
|
||||
*/
|
||||
export function useLoginSecret(clock: Clock) {
|
||||
/** 화면 메모리에 비밀번호가 있다 — 없으면 그 단계가 칸을 보인다 */
|
||||
const available = ref<boolean>(false)
|
||||
|
||||
let secret: string | null = null
|
||||
/** 기억한 시각 (clock.now) */
|
||||
let rememberedAt = 0
|
||||
let timer: ClockTimer | null = null
|
||||
let disposed = false
|
||||
|
||||
function stopTimer() {
|
||||
if (timer === null) return
|
||||
clock.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
/** 비밀번호를 지운다 */
|
||||
function forget() {
|
||||
stopTimer()
|
||||
secret = null
|
||||
available.value = false
|
||||
}
|
||||
|
||||
/** 서버가 방금 확인한 비밀번호를 10분 동안 기억한다. 화면이 이미 사라졌으면 기억하지 않고 false */
|
||||
function remember(value: string): boolean {
|
||||
if (disposed) return false
|
||||
stopTimer()
|
||||
secret = value
|
||||
rememberedAt = clock.now()
|
||||
available.value = true
|
||||
timer = clock.setTimeout(() => {
|
||||
timer = null
|
||||
forget()
|
||||
}, RESTRICTED_SECRET_WINDOW_MS)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 기억한 비밀번호 — 없거나 10분이 지났으면(시계로 다시 잰다) null */
|
||||
function peek(): string | null {
|
||||
if (secret === null) return null
|
||||
if (clock.now() - rememberedAt >= RESTRICTED_SECRET_WINDOW_MS) {
|
||||
forget()
|
||||
return null
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
forget()
|
||||
})
|
||||
|
||||
return { available, remember, peek, forget }
|
||||
}
|
||||
|
||||
export type LoginSecret = ReturnType<typeof useLoginSecret>
|
||||
@@ -469,6 +469,41 @@ describe('useMailRealtime (실시간 연결)', () => {
|
||||
})
|
||||
|
||||
describe('401 · 세션', () => {
|
||||
it('제한된 세션(13단계 — 새 비밀번호 · 2단계 켜기 전)에는 열지 않는다 — 제한이 풀리면 연다', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
|
||||
const realtime = useMailRealtimeStore(pinia)
|
||||
const scope = effectScope()
|
||||
scope.run(() => useMailRealtime({ clock, random: () => 0.5, fetch: server.fetch, isUserViewing: () => false }))
|
||||
tabs.push({ scope, pinia, realtime, auth, statuses: [] })
|
||||
await settle()
|
||||
|
||||
expect(server.connections).toHaveLength(0)
|
||||
expect(realtime.status).toBe('off')
|
||||
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
|
||||
|
||||
auth.markAuthenticated(USER, { restriction: null, twoFactorDeadline: null })
|
||||
await settle()
|
||||
|
||||
expect(server.connections).toHaveLength(1)
|
||||
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(true)
|
||||
})
|
||||
|
||||
it('연결한 뒤 제한이 붙으면(갱신 때 2단계 필수의 시작일이 지남) 닫는다', async () => {
|
||||
const tab = openTab()
|
||||
await settle()
|
||||
expect(server.connections).toHaveLength(1)
|
||||
|
||||
tab.auth.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
|
||||
await settle()
|
||||
|
||||
expect(server.last().aborted).toBe(true)
|
||||
expect(tab.realtime.status).toBe('off')
|
||||
expect(locks.isHeld(mailEventsLockName(USER.email))).toBe(false)
|
||||
})
|
||||
|
||||
it('401 이면 세션을 한 번 갱신하고 바로 다시 잇는다', async () => {
|
||||
server.reply({ kind: 'status', status: 401 })
|
||||
const tab = openTab()
|
||||
|
||||
@@ -55,6 +55,7 @@ import { useMailRealtimeStore, type MailRealtimeStatus } from '@/stores/mail-rea
|
||||
// (탭마다 알림 · 그 탭의 쓰기 화면이 그 파일을 "위험 — 보낼 수 없음"으로 바꾼다)
|
||||
//
|
||||
// 로그아웃 · 세션 만료 · 다른 계정 로그인(authStore.generation) 때 잠금을 놓고 연결을 닫는다.
|
||||
// 제한된 세션(13단계 — 새 비밀번호 · 2단계 켜기를 마치기 전, 계약 6절)에는 열지 않는다 — 제한이 풀리면 연다.
|
||||
|
||||
export interface NewMailContext {
|
||||
/** 사용자가 이 탭을 보고 있는지 — 보이고(visibilityState) 초점이 있을 때(hasFocus) */
|
||||
@@ -531,7 +532,7 @@ export function useMailRealtime(options: MailRealtimeOptions = {}): void {
|
||||
session?.stop()
|
||||
session = null
|
||||
const email = authStore.user?.email
|
||||
if (!email) {
|
||||
if (!email || authStore.restriction !== null) {
|
||||
realtimeStore.setStatus('off')
|
||||
return
|
||||
}
|
||||
@@ -548,8 +549,13 @@ export function useMailRealtime(options: MailRealtimeOptions = {}): void {
|
||||
}
|
||||
|
||||
// 로그인 · 세션 복원으로 시작하고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 그만둔다(새 세션이면 다시 시작).
|
||||
// 토큰 갱신(같은 세션)으로는 다시 시작하지 않는다 — 세대와 주소가 그대로다 (값마다 비교하도록 배열 소스로 둔다)
|
||||
watch([() => authStore.generation, () => authStore.user?.email ?? null], restart, { immediate: true })
|
||||
// 토큰 갱신(같은 세션)으로는 다시 시작하지 않는다 — 세대와 주소가 그대로다 (값마다 비교하도록 배열 소스로 둔다).
|
||||
// 제한이 붙거나 풀리면 다시 본다 — 제한된 동안은 닫아 둔다 (13단계)
|
||||
watch(
|
||||
[() => authStore.generation, () => authStore.user?.email ?? null, () => authStore.restriction],
|
||||
restart,
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
session?.stop()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises } from '@/test-utils/mock-api'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { useSessionRestriction } from './useSessionRestriction'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' }
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
useSessionRestriction()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
describe('useSessionRestriction (13단계 — 계약 6절 "제한된 세션")', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountAt(path: string) {
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(Host, { global: { plugins: [pinia, router] } })
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
it('보호 화면에 있는 동안 제한이 생기면(갱신 · AUTH_010 뒤 me) 로그인 틀로 바꿔 끼운다 — 돌아올 곳은 지금 화면', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
const { router } = await mountAt('/mail/inbox/m1?q=1')
|
||||
|
||||
authStore.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/mail/inbox/m1?q=1')
|
||||
})
|
||||
|
||||
it('가드가 이동을 막아 보호 화면에 남으면 AUTH_010 사전 문구로 알리고, 다음 AUTH_010(같은 제한의 재확인)에서 다시 옮긴다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
const { router } = await mountAt('/mail/compose')
|
||||
let blocked = true
|
||||
router.beforeEach((to) => !(blocked && to.name === 'login'))
|
||||
|
||||
authStore.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.AUTH_010])
|
||||
|
||||
// 같은 제한이 다시 확인됐다 — 값은 그대로지만 확인한 차례가 오른다 (useApi.recheckRestriction)
|
||||
blocked = false
|
||||
authStore.markAuthenticated(USER, { restriction: 'twoFactorSetup', twoFactorDeadline: null })
|
||||
authStore.noteRestrictionCheck()
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/mail/compose')
|
||||
})
|
||||
|
||||
it('보호 화면이 아니면(로그인 · 공개 화면) 옮기지 않는다', async () => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.markAuthenticated(USER)
|
||||
const { router } = await mountAt('/s/token-abc')
|
||||
|
||||
authStore.markAuthenticated(USER, { restriction: 'passwordChange', twoFactorDeadline: null })
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('public-share')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { watch } from 'vue'
|
||||
import { isNavigationFailure, NavigationFailureType, useRouter } from 'vue-router'
|
||||
|
||||
import { getErrorMessage, notifyApiError } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
/**
|
||||
* 세션에 제한이 생기면 보호 화면을 떠나 로그인 틀의 그 단계로 간다 (13단계 — 계약 6절 "제한된 세션").
|
||||
* 앱 최상위(App.vue)에서 한 번 부른다. 제한은 로그인할 때만 붙는 것이 아니다 — 2단계 인증 필수의 시작일이 지나면
|
||||
* 이미 로그인한 세션도 다음 갱신 때 붙고, 403 AUTH_010 을 받으면 useApi 가 me 로 다시 받아 둔다.
|
||||
* - 제한이 바뀔 때뿐 아니라 서버가 제한을 다시 확인해 줄 때마다(authStore.restrictionChecks) 옮긴다 — 앞 이동이 막혀
|
||||
* 보호 화면에 남았어도 다음 AUTH_010 에서 다시 옮긴다
|
||||
* - 나가기 확인은 제한된 세션에서 묻지 않는다(authStore.canUseApp). 그래도 다른 가드가 이동을 막아 보호 화면에 남으면
|
||||
* AUTH_010 사전 문구로 알린다 — 그 상태의 [보내기] 등이 조용히 끝나지 않게
|
||||
* 처음 들어올 때 · 주소를 바꿔 들어올 때는 라우터 가드가 같은 규칙으로 막는다 (router/index.ts).
|
||||
* 돌아올 곳(redirect)은 지금 화면 — 단계를 마치면 로그인 화면이 그곳으로 보낸다
|
||||
*/
|
||||
export function useSessionRestriction(): void {
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function leaveProtectedRoute() {
|
||||
const current = router.currentRoute.value
|
||||
if (!current.meta.requiresAuth) return
|
||||
const failure = await router.replace({ name: 'login', query: { redirect: current.fullPath } })
|
||||
// 가드가 이동을 막았고 아직 제한된 채 보호 화면이다 — 알린다 (더 새 이동에 밀려 취소된 것은 그 이동이 맡는다)
|
||||
const blocked = isNavigationFailure(failure, NavigationFailureType.aborted)
|
||||
if (blocked && authStore.restriction !== null && router.currentRoute.value.meta.requiresAuth) {
|
||||
notifyApiError({ code: 'AUTH_010', message: getErrorMessage('AUTH_010') })
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => authStore.restriction, () => authStore.restrictionChecks], ([restriction]) => {
|
||||
if (restriction !== null) void leaveProtectedRoute()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user