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:
2026-10-31 17:25:53 +09:00
parent f9e3b1c57a
commit c31442e639
55 changed files with 8475 additions and 115 deletions
@@ -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,
}
}
+5
View File
@@ -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) {
+35 -1
View File
@@ -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'))
+17 -8
View File
@@ -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 }
}