generated from ttipo/project-base
feat(frontend): 12단계 계정 보안 화면 — 로그인 코드 단계 · 2단계 인증 · 앱 비밀번호 · 로그인 기록
로그인은 비밀번호가 맞고 2단계를 켠 직원만 코드 단계로 간다. 설정 › 계정에 2단계 인증 · 앱 비밀번호 · 로그인 기록을 더하고, 비밀번호 화면에 2단계를 켠 직원의 코드 칸을 둔다. 비밀(코드 단계의 비밀번호 · 2단계 키 · 앱 비밀번호 값)은 화면 메모리에만 두고, 세션이 바뀌면 그 창을 모두 닫는다 — 12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다. 비밀이 드는 요청은 Pinia 액션 밖에서 보낸다(devtools 기록). QR 은 uqr 0.1.3(MIT · 의존성 없음 — 2단계 인증 화면 청크에만)으로 SVG 를 그린다(v-html 없음). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useApi } from './useApi'
|
||||
import { AUTH_SESSION_LIST_LIMIT, TWO_FACTOR_CHANGE_TIMEOUT_MS } from '@/shared/constants/account-security'
|
||||
import type {
|
||||
AppPassword,
|
||||
AuthSession,
|
||||
CreatedAppPassword,
|
||||
EndedSessions,
|
||||
LoginEvent,
|
||||
LoginEventQuery,
|
||||
TwoFactorActivationPayload,
|
||||
TwoFactorDeactivationPayload,
|
||||
TwoFactorEnrollment,
|
||||
TwoFactorStatus,
|
||||
} from '@/shared/types/account-security'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
|
||||
/** 호출부가 정할 수 있는 요청 옵션 — 화면이 오류를 직접 그릴 때 silent, 취소가 필요하면 signal */
|
||||
export type AccountSecurityRequestOptions = Pick<AxiosRequestConfig, 'silent' | 'signal'>
|
||||
|
||||
/** 경로에 넣는 id 는 항상 인코딩한다 */
|
||||
const segment = (value: string) => encodeURIComponent(value)
|
||||
|
||||
// 계정 보안 API Composable — docs/mail/api-contract.md 5절 "계정 보안 — 12단계" (모두 로그인 필요)
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 각 호출의 응답 타입(R)을 data 타입으로 지정한다.
|
||||
// 비밀(2단계 인증 키 · otpauth 주소 · 앱 비밀번호 값)이 든 응답은 부르는 쪽이 화면 메모리에만 두고 창을 닫으면 버린다 — 여기서는 남기지 않는다.
|
||||
// 로그인 · 비밀번호 바꾸기에 더한 칸(otpCode)은 useAuth 가 보낸다
|
||||
export function useAccountSecurity() {
|
||||
const api = useApi()
|
||||
|
||||
/** 2단계 인증 상태 — 엔진이 기준이다 */
|
||||
function getTwoFactor(options?: AccountSecurityRequestOptions): Promise<TwoFactorStatus> {
|
||||
return api.get<TwoFactorStatus, TwoFactorStatus>('/two-factor', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록 시작 (꺼져 있을 때만) — 새 키 · otpauth 주소 · 이때까지 켜야 하는 시각(10분).
|
||||
* 직원마다 기다리는 등록은 하나다 — 새로 시작하면 앞의 것은 버린다. 이미 켜져 있으면 409 AUTH_008
|
||||
*/
|
||||
function startTwoFactorEnrollment(options?: AccountSecurityRequestOptions): Promise<TwoFactorEnrollment> {
|
||||
return api.post<TwoFactorEnrollment, TwoFactorEnrollment>('/two-factor/enrollments', null, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 켜기 — 코드를 등록의 키로 먼저 확인한 뒤 엔진에 쓴다. 코드 틀림 400 AUTH_006 · 지금 비밀번호 틀림 400 AUTH_005 ·
|
||||
* 등록이 없음 · 10분 지남 · 그사이 바뀜 409 AUTH_008. 서버가 엔진을 여러 번 부르므로 한도를 길게 둔다
|
||||
*/
|
||||
function activateTwoFactor(
|
||||
payload: TwoFactorActivationPayload,
|
||||
options?: AccountSecurityRequestOptions,
|
||||
): Promise<TwoFactorStatus> {
|
||||
return api.post<TwoFactorStatus, TwoFactorStatus>('/two-factor/activation', payload, {
|
||||
timeout: TWO_FACTOR_CHANGE_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 끄기 — 비밀번호를 먼저 확인하고(AUTH_005) 코드를 본다(AUTH_006). 이미 꺼져 있으면 200 (같은 결과) */
|
||||
function deactivateTwoFactor(
|
||||
payload: TwoFactorDeactivationPayload,
|
||||
options?: AccountSecurityRequestOptions,
|
||||
): Promise<TwoFactorStatus> {
|
||||
return api.post<TwoFactorStatus, TwoFactorStatus>('/two-factor/deactivation', payload, {
|
||||
timeout: TWO_FACTOR_CHANGE_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 앱 비밀번호 — 한 쪽(엔진 상한이 작다), 최근에 만든 것이 위 */
|
||||
function listAppPasswords(options?: AccountSecurityRequestOptions): Promise<PaginatedResult<AppPassword>> {
|
||||
return api.get<PaginatedResult<AppPassword>, PaginatedResult<AppPassword>>('/app-passwords', options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 만들기 — 값(password)은 이 응답에만 온다. 상한 409 AUTH_009, 이름이 비었거나 40자를 넘거나 이미 있는 이름이면 400 VAL_001
|
||||
*/
|
||||
function createAppPassword(name: string, options?: AccountSecurityRequestOptions): Promise<CreatedAppPassword> {
|
||||
return api.post<CreatedAppPassword, CreatedAppPassword>('/app-passwords', { name }, options)
|
||||
}
|
||||
|
||||
/** 지우기 — 이미 없어도 200 */
|
||||
function deleteAppPassword(id: string, options?: AccountSecurityRequestOptions): Promise<null> {
|
||||
return api.delete<null, null>(`/app-passwords/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 로그인된 브라우저 — 지금 세션이 맨 앞, 그다음 최근 활동 차례 (서버 최대 100 을 한 번에) */
|
||||
function listAuthSessions(options?: AccountSecurityRequestOptions): Promise<PaginatedResult<AuthSession>> {
|
||||
return api.get<PaginatedResult<AuthSession>, PaginatedResult<AuthSession>>('/auth/sessions', {
|
||||
params: { limit: AUTH_SESSION_LIST_LIMIT },
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/** 세션 하나 끊기 — 지금 세션은 400 VAL_001(로그아웃을 쓴다), 남의 것 · 없는 것 · 끝난 것은 404 RES_001 */
|
||||
function endAuthSession(id: string, options?: AccountSecurityRequestOptions): Promise<null> {
|
||||
return api.delete<null, null>(`/auth/sessions/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 이 요청의 세션만 남기고 모두 끊는다 */
|
||||
function endOtherAuthSessions(options?: AccountSecurityRequestOptions): Promise<EndedSessions> {
|
||||
return api.delete<EndedSessions, EndedSessions>('/auth/sessions', options)
|
||||
}
|
||||
|
||||
/** 로그인 기록 — 최근 차례 · 커서 페이지. isSuccess=false 면 실패만 */
|
||||
function listLoginEvents(
|
||||
query: LoginEventQuery,
|
||||
options?: AccountSecurityRequestOptions,
|
||||
): Promise<PaginatedResult<LoginEvent>> {
|
||||
return api.get<PaginatedResult<LoginEvent>, PaginatedResult<LoginEvent>>('/login-events', {
|
||||
params: query,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getTwoFactor,
|
||||
startTwoFactorEnrollment,
|
||||
activateTwoFactor,
|
||||
deactivateTwoFactor,
|
||||
listAppPasswords,
|
||||
createAppPassword,
|
||||
deleteAppPassword,
|
||||
listAuthSessions,
|
||||
endAuthSession,
|
||||
endOtherAuthSessions,
|
||||
listLoginEvents,
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,11 @@ describe('에러 코드 사전', () => {
|
||||
'FILE_005', // 보관 한도 — 그 파일 줄에 사전 문구 + [대용량 첨부 보기]
|
||||
'FILE_006', // 서버 저장 공간 — 그 파일 줄에 사전 문구, 멈추고 [다시 시도]
|
||||
'MAIL_016', // 대용량 파일을 쓸 수 없음 — 쓰기 화면이 파일마다 상태를 다시 묻는다
|
||||
// 웹메일 12단계 (계정 보안) — 화면이 넷 모두로 분기한다 (계약 5절 "에러 코드")
|
||||
'AUTH_006', // 2단계 인증 코드가 틀림 · 다시 씀 — 로그인 코드 단계 · 켜기 · 끄기 창 · 비밀번호 화면이 코드 칸 아래에 보이고 칸을 비운다
|
||||
'AUTH_007', // 2단계 인증을 켠 직원의 요청에 코드가 없음 — 비밀번호 화면이 2단계 상태를 다시 받고 코드 칸을 보인다
|
||||
'AUTH_008', // 등록이 없음 · 시간이 지남 · 그사이 바뀜 — 켜기 창이 처음부터 다시 한다
|
||||
'AUTH_009', // 앱 비밀번호 상한 — 만들기 창이 칸 아래에 보이고 목록을 다시 받는다
|
||||
]
|
||||
|
||||
for (const code of usedByClient) {
|
||||
|
||||
@@ -96,12 +96,46 @@ describe('useAuth', () => {
|
||||
|
||||
await expect(
|
||||
useAuth().login({ email: USER.email, password: 'secret' }),
|
||||
).resolves.toEqual(USER)
|
||||
).resolves.toEqual({ status: 'signedIn', user: USER })
|
||||
|
||||
expect(requests[0]?.data).toEqual({ email: USER.email, password: 'secret' })
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('응답의 status(12단계)는 스토어에 두지 않는다 — signedIn 이면 사용자 칸만', async () => {
|
||||
installMockApi(() => ok({ status: 'signedIn', ...USER }))
|
||||
|
||||
await expect(useAuth().login({ email: USER.email, password: 'secret' })).resolves.toEqual({
|
||||
status: 'signedIn',
|
||||
user: USER,
|
||||
})
|
||||
expect(useAuthStore().user).toEqual(USER)
|
||||
})
|
||||
|
||||
it('2단계 인증을 켠 직원(twoFactorRequired — 쿠키 없음)이면 세션을 열지 않고 그대로 돌려준다 (계약 5절)', async () => {
|
||||
installMockApi(() => ok({ status: 'twoFactorRequired', email: null, name: null }))
|
||||
const authStore = useAuthStore()
|
||||
const generation = authStore.generation
|
||||
|
||||
await expect(useAuth().login({ email: USER.email, password: 'secret' })).resolves.toEqual({
|
||||
status: 'twoFactorRequired',
|
||||
})
|
||||
expect(authStore.isLoggedIn).toBe(false)
|
||||
expect(authStore.generation).toBe(generation)
|
||||
})
|
||||
|
||||
it('코드 단계는 비밀번호와 코드를 함께 싣고, 부르는 쪽이 고른 코드의 알림을 끈다', async () => {
|
||||
const requests = installMockApi(() => fail(429, 'REQ_001'))
|
||||
|
||||
const error: unknown = await useAuth()
|
||||
.login({ email: USER.email, password: 'secret', otpCode: '123456' }, { silent: ['AUTH_004', 'AUTH_006', 'REQ_001'] })
|
||||
.catch((reason: unknown) => reason)
|
||||
|
||||
expect(requests[0]?.data).toEqual({ email: USER.email, password: 'secret', otpCode: '123456' })
|
||||
expect(isApiError(error) && error.code).toBe('REQ_001')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('AUTH_004 는 전역 알림 없이 던진다 — 로그인 폼이 안내한다', async () => {
|
||||
installMockApi(() => fail(401, 'AUTH_004'))
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ 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, LoginPayload } from '@/shared/types/auth'
|
||||
import type { AuthUser, ChangePasswordPayload, LoginOutcome, LoginPayload, LoginResponse } from '@/shared/types/auth'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
export type { LoginPayload } from '@/shared/types/auth'
|
||||
export type { LoginOutcome, LoginPayload } from '@/shared/types/auth'
|
||||
|
||||
/** 로그인 요청의 알림 끄기 — 첫 단계는 AUTH_004 만(폼이 안내한다), 코드 단계는 코드 틀림 · 시도 과다도 화면이 직접 보인다 */
|
||||
export type LoginRequestOptions = Pick<AxiosRequestConfig, 'silent'>
|
||||
|
||||
// 인증 API Composable — 쿠키 기반 (docs/mail/api-contract.md 1절)
|
||||
// 토큰은 HttpOnly 쿠키로만 오가므로 여기서는 응답의 사용자(email·name)만 다룬다.
|
||||
@@ -18,21 +21,26 @@ export function useAuth() {
|
||||
|
||||
/**
|
||||
* 로그인. 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
|
||||
* AUTH_004(자격 증명 불일치)는 로그인 폼이 직접 안내하므로 그 코드만 전역 알림을 끈다.
|
||||
* AUTH_004(자격 증명 불일치)는 로그인 폼이 직접 안내하므로 기본으로 그 코드만 전역 알림을 끈다.
|
||||
* 2단계 인증을 켠 직원이고 비밀번호가 맞으면 쿠키 없이 twoFactorRequired 가 온다(12단계 — 계약 5절) — 세션을 열지 않고 그대로 돌려준다.
|
||||
* 화면은 코드를 받아 비밀번호와 함께 다시 부른다(payload.otpCode)
|
||||
*/
|
||||
async function login(payload: LoginPayload): Promise<AuthUser> {
|
||||
async function login(payload: LoginPayload, options: LoginRequestOptions = {}): Promise<LoginOutcome> {
|
||||
loading.value = true
|
||||
try {
|
||||
// 이 탭에서 진행 중인 갱신이 있으면 끝난 뒤에 보낸다 (계약 1절) — 로그아웃 전에 시작한 갱신의 늦은 응답이
|
||||
// 새 로그인 쿠키를 덮거나, 그 뒤의 정리용 로그아웃이 새 세션을 끊지 않게 한다
|
||||
await settleRefresh()
|
||||
// 인터셉터가 { success, data } 를 언래핑하므로 응답 타입(R)을 data 타입으로 지정한다
|
||||
const user = await api.post<AuthUser, AuthUser>('/auth/login', payload, {
|
||||
const response = await api.post<LoginResponse, LoginResponse>('/auth/login', payload, {
|
||||
silent: ['AUTH_004'],
|
||||
...options,
|
||||
})
|
||||
// 새 세션 — 세대가 바뀌어 그 전에 시작한 일의 늦은 결과는 쓰이지 않는다
|
||||
if (response.status === 'twoFactorRequired') return { status: 'twoFactorRequired' }
|
||||
// 새 세션 — 세대가 바뀌어 그 전에 시작한 일의 늦은 결과는 쓰이지 않는다. 스토어에는 사용자 칸만 둔다
|
||||
const user: AuthUser = { email: response.email, name: response.name }
|
||||
authStore.startSession(user)
|
||||
return user
|
||||
return { status: 'signedIn', user }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -88,7 +96,8 @@ export function useAuth() {
|
||||
/**
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password" — 4단계). 실패하면 ApiError 를 그대로 던진다 — 호출부가 code 로 분기한다.
|
||||
* 지금 비밀번호가 틀리면 400 AUTH_005(세션은 그대로). 401 이 아니므로 토큰이 만료됐을 때만 갱신 뒤 같은 요청을 다시 보낸다.
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 — 이 탭의 세션은 이어진다.
|
||||
* 바뀌면 서버가 이 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊고 앱 비밀번호도 모두 지운다 — 이 탭의 세션은 이어진다.
|
||||
* 2단계 인증을 켠 직원은 otpCode 가 필수다(12단계 — 없으면 400 AUTH_007, 틀리면 400 AUTH_006).
|
||||
* 시간 한도는 30초다 (PASSWORD_CHANGE_TIMEOUT_MS — 서버가 엔진을 두 번 부르고 세션을 끊는 시간까지 기다린다)
|
||||
*/
|
||||
async function changePassword(
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import type { Clock, ClockTimer } from './useClock'
|
||||
import { TWO_FACTOR_LOGIN_WINDOW_MS } from '@/shared/constants/account-security'
|
||||
|
||||
/** 코드 단계에서 다시 보낼 자격 — 서버는 비밀번호를 들고 있지 않아 코드와 함께 한 번 더 싣는다 */
|
||||
export interface LoginChallengeCredentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 코드 단계 (12단계 — 계약 5절 "로그인" 클라이언트 · 디자인 README "로그인 — 코드 단계").
|
||||
* 확인한 비밀번호는 화면 메모리에만 둔다 — 반응형 상태에 넣지 않는다(개발 도구 · 화면에 드러나지 않게). 반응형은 단계 · 주소 · 시간 지남뿐이다.
|
||||
* 지우는 때: 성공 · [다른 계정으로 로그인] · 비밀번호가 틀림(AUTH_004 — 첫 단계로) · 화면을 떠날 때(unmount) · 10분이 지났을 때.
|
||||
* 10분이 지나면 비밀번호만 먼저 지우고 화면은 그대로 둔다 — [확인]을 누를 때 「다시 로그인해 주세요」와 함께 첫 단계로 간다(부르는 쪽).
|
||||
* 10분은 타이머 하나에 기대지 않는다 — 절전 · 뒤에 깔린 탭에서는 타이머가 늦게 돈다. 꺼낼 때(credentials) 시계로 한 번 더 잰다.
|
||||
* 화면이 사라진 뒤에는 시작하지 않는다(start 가 false) — 떠난 뒤 늦게 온 twoFactorRequired 가 비밀번호를 붙잡지 않게.
|
||||
* 시계는 주입받는다(useClock) — 테스트는 손으로 돌리는 시계로 10분을 넘긴다
|
||||
*/
|
||||
export function useLoginChallenge(clock: Clock) {
|
||||
/** 코드 단계를 보이는 중 */
|
||||
const active = ref<boolean>(false)
|
||||
/** 비밀번호를 확인한 주소 — 코드 단계에 보이고, 첫 단계로 돌아갈 때 주소 칸에 남긴다 */
|
||||
const email = ref<string>('')
|
||||
/** 10분이 지나 비밀번호를 지웠다 */
|
||||
const expired = ref<boolean>(false)
|
||||
|
||||
let password: string | null = null
|
||||
/** 코드 단계를 시작한 시각 (clock.now) */
|
||||
let startedAt = 0
|
||||
let timer: ClockTimer | null = null
|
||||
/** 화면이 사라졌다 — 더는 시작하지 않는다 */
|
||||
let disposed = false
|
||||
|
||||
function stopTimer() {
|
||||
if (timer === null) return
|
||||
clock.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
/** 10분이 지났다 — 비밀번호를 지운다 (화면은 그대로 — [확인] 때 첫 단계로) */
|
||||
function expire() {
|
||||
stopTimer()
|
||||
password = null
|
||||
expired.value = true
|
||||
}
|
||||
|
||||
/** 비밀번호가 맞았고 코드가 필요하다 — 코드 단계로. 화면이 이미 사라졌으면 시작하지 않고 false */
|
||||
function start(nextEmail: string, nextPassword: string): boolean {
|
||||
if (disposed) return false
|
||||
stopTimer()
|
||||
email.value = nextEmail
|
||||
password = nextPassword
|
||||
startedAt = clock.now()
|
||||
expired.value = false
|
||||
active.value = true
|
||||
timer = clock.setTimeout(() => {
|
||||
timer = null
|
||||
expire()
|
||||
}, TWO_FACTOR_LOGIN_WINDOW_MS)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 코드와 함께 다시 보낼 자격 — 시간이 지났거나(시계로 다시 잰다) 끝냈으면 null */
|
||||
function credentials(): LoginChallengeCredentials | null {
|
||||
if (!active.value || password === null) return null
|
||||
if (clock.now() - startedAt >= TWO_FACTOR_LOGIN_WINDOW_MS) {
|
||||
expire()
|
||||
return null
|
||||
}
|
||||
return { email: email.value, password }
|
||||
}
|
||||
|
||||
/** 코드 단계를 끝내고 비밀번호를 지운다 */
|
||||
function clear() {
|
||||
stopTimer()
|
||||
password = null
|
||||
active.value = false
|
||||
expired.value = false
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
clear()
|
||||
})
|
||||
|
||||
return { active, email, expired, start, credentials, clear }
|
||||
}
|
||||
Reference in New Issue
Block a user