From ee1264b66bc1474ce77fd77c57d6e630e0bdfecf Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 21 Jun 2026 22:42:41 +0200 Subject: [PATCH] M4-T08: add two-step login and TOTP settings panel (qrcode.react) --- docs/design/m4-login-hardening.md | 2 +- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/api/schema.d.ts | 270 +++++++++++++++++ frontend/src/auth/totp.ts | 77 +++++ frontend/src/pages/ConfigPage.tsx | 6 + frontend/src/pages/LoginPage.test.tsx | 179 ++++++++++- frontend/src/pages/LoginPage.tsx | 207 ++++++++++--- frontend/src/pages/TotpSettings.test.tsx | 344 ++++++++++++++++++++++ frontend/src/pages/TotpSettings.tsx | 360 +++++++++++++++++++++++ 10 files changed, 1405 insertions(+), 51 deletions(-) create mode 100644 frontend/src/auth/totp.ts create mode 100644 frontend/src/pages/TotpSettings.test.tsx create mode 100644 frontend/src/pages/TotpSettings.tsx diff --git a/docs/design/m4-login-hardening.md b/docs/design/m4-login-hardening.md index 8725f88..41adc3a 100644 --- a/docs/design/m4-login-hardening.md +++ b/docs/design/m4-login-hardening.md @@ -235,7 +235,7 @@ Phase B(TOTP,可选配) - **Reviewer checklist**: 逃生不依赖已存凭据;只动 auth 行。 ### M4-T08 — 前端:两步登录 + 设置页 TOTP -- **Status**: `todo` · **Depends**: M4-T05, M4-T06 +- **Status**: `done` · **Depends**: M4-T05, M4-T06 - **Files**: `modify frontend/src/pages/LoginPage.tsx`;`create frontend/src/pages/.../TotpSettings.tsx`(或并入 ConfigPage)、`frontend/src/auth/totp.ts`;`modify frontend/package.json`(+`qrcode.react`,lock 同步);`create` 对应测试 - **Steps**: 登录页:401+`totp_required`→第二屏 6 位码(也接受恢复码);429→"稍后再试"(读 `Retry-After`)。设置页:未启用→启用流程(二维码由 `otpauth` URI 渲染 + 恢复码一次性展示 + 输码确认);已启用→停用。 - **Out of scope / 不要碰**: 不改后端;不碰防爆破逻辑。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6aa9396..ceafa9d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "leaflet.heat": "^0.2.0", "leaflet.markercluster": "^1.5.3", "openapi-fetch": "^0.17.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-feather": "^2.0.10", @@ -5661,6 +5662,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 25731b0..792d5d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "leaflet.heat": "^0.2.0", "leaflet.markercluster": "^1.5.3", "openapi-fetch": "^0.17.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-feather": "^2.0.10", diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 32b6c4a..a78f6e4 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -245,6 +245,7 @@ export interface paths { * * On success, sets an HttpOnly session cookie and returns the session user + CSRF token. * On failure, returns 401 with no cookie set. + * Repeated failures trigger exponential back-off (429 + Retry-After). * No X-CSRF-Token required (unauthenticated endpoint). */ post: operations["post_login_api_auth_login_post"]; @@ -300,6 +301,112 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/totp/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Totp Setup + * @description Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes. + * + * The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays + * False until the user confirms with POST /api/auth/totp/enable). + * + * Recovery codes are returned here as plaintext exactly once; their Argon2 hashes + * are persisted immediately so enable only needs to flip the enabled flag. + * + * Repeating this call replaces any prior pending secret and regenerates codes. + * + * Requires: session cookie + X-CSRF-Token. + */ + post: operations["post_totp_setup_api_auth_totp_setup_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/totp/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Totp Enable + * @description Enable TOTP by confirming with the current 6-digit code from the authenticator app. + * + * Requires a prior call to POST /api/auth/totp/setup (so that a pending secret + * exists). On success, totp_enabled becomes True. + * + * Returns 400 if the code is wrong or there is no pending secret. + * Requires: session cookie + X-CSRF-Token. + */ + post: operations["post_totp_enable_api_auth_totp_enable_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/totp/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Totp Disable + * @description Disable TOTP. The caller must provide exactly one of: + * - ``password``: the user's current login password, OR + * - ``code``: the current 6-digit TOTP code. + * + * On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted. + * Returns 400 if neither credential matches or neither is provided. + * Requires: session cookie + X-CSRF-Token. + */ + post: operations["post_totp_disable_api_auth_totp_disable_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/totp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Totp Status + * @description Return the current TOTP status for the authenticated user. + * + * Response contains only ``{"enabled": bool}``. + * Secret and recovery codes are NEVER returned here. + * Requires: session cookie only (no CSRF — read-only). + */ + get: operations["get_totp_status_api_auth_totp_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/homeassistant/publish": { parameters: { query?: never; @@ -510,6 +617,8 @@ export interface components { username: string; /** Password */ password: string; + /** Totp Code */ + totp_code?: string | null; }; /** PasswordChangeRequest */ PasswordChangeRequest: { @@ -647,6 +756,50 @@ export interface components { /** Status */ status: string; }; + /** + * TotpDisableRequest + * @description Disable TOTP by proving identity. + * + * Exactly one of ``password`` or ``code`` must be provided. + */ + TotpDisableRequest: { + /** Password */ + password?: string | null; + /** Code */ + code?: string | null; + }; + /** + * TotpEnableRequest + * @description The user confirms setup by providing the 6-digit TOTP code. + */ + TotpEnableRequest: { + /** Code */ + code: string; + }; + /** + * TotpSetupResponse + * @description Returned once after a setup call. + * + * ``secret`` and ``recovery_codes`` are **one-time plaintext values**. + * They are never returned again by any subsequent API call. + * The frontend must display and instruct the user to save them before confirming. + */ + TotpSetupResponse: { + /** Secret */ + secret: string; + /** Otpauth Uri */ + otpauth_uri: string; + /** Recovery Codes */ + recovery_codes: string[]; + }; + /** + * TotpStatusResponse + * @description Minimal status response — never exposes secret or recovery codes. + */ + TotpStatusResponse: { + /** Enabled */ + enabled: boolean; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -1143,6 +1296,123 @@ export interface operations { }; }; }; + post_totp_setup_api_auth_totp_setup_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TotpSetupResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + post_totp_enable_api_auth_totp_enable_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TotpEnableRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + post_totp_disable_api_auth_totp_disable_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TotpDisableRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_totp_status_api_auth_totp_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TotpStatusResponse"]; + }; + }; + }; + }; publish_from_homeassistant_homeassistant_publish_post: { parameters: { query?: never; diff --git a/frontend/src/auth/totp.ts b/frontend/src/auth/totp.ts new file mode 100644 index 0000000..0ea2a96 --- /dev/null +++ b/frontend/src/auth/totp.ts @@ -0,0 +1,77 @@ +/** + * TOTP API helpers (M4-T08). + * + * All calls go through the typed apiClient (openapi-fetch + generated schema). + * CSRF tokens are injected automatically by the client middleware for write + * endpoints; GET /api/auth/totp needs no CSRF (read-only). + * + * Security notes: + * - The TotpSetupResponse contains secret + recovery_codes in plaintext. + * These are kept only in component state; callers must NOT write them to + * localStorage, sessionStorage, or log them. + * - After the setup phase is over the data leaves memory when the component + * re-renders (one-time display only). + */ + +import apiClient, { ApiError } from '../api/client' +import type { components } from '../api/schema.d.ts' + +export type TotpSetupResponse = components['schemas']['TotpSetupResponse'] +export type TotpStatusResponse = components['schemas']['TotpStatusResponse'] + +// --------------------------------------------------------------------------- +// getTotpStatus — GET /api/auth/totp +// --------------------------------------------------------------------------- + +/** Return the current TOTP enabled state for the authenticated user. */ +export async function getTotpStatus(): Promise { + const res = await apiClient.GET('/api/auth/totp') + if (!res.data) { + throw new ApiError(res.response.status, null) + } + return res.data +} + +// --------------------------------------------------------------------------- +// setupTotp — POST /api/auth/totp/setup +// --------------------------------------------------------------------------- + +/** + * Generate a new pending TOTP secret + recovery codes. + * Returns the one-time plaintext values; caller is responsible for displaying + * them exactly once and never persisting them outside component state. + */ +export async function setupTotp(): Promise { + const res = await apiClient.POST('/api/auth/totp/setup') + if (!res.data) { + throw new ApiError(res.response.status, null) + } + return res.data +} + +// --------------------------------------------------------------------------- +// enableTotp — POST /api/auth/totp/enable +// --------------------------------------------------------------------------- + +/** Confirm TOTP setup with the current 6-digit code from the authenticator app. */ +export async function enableTotp(code: string): Promise { + // The endpoint returns 204 on success; openapi-fetch yields { data: undefined }. + await apiClient.POST('/api/auth/totp/enable', { body: { code } }) +} + +// --------------------------------------------------------------------------- +// disableTotp — POST /api/auth/totp/disable +// --------------------------------------------------------------------------- + +/** + * Disable TOTP. Exactly one of password or code must be provided. + * On success the backend clears the secret and all recovery codes. + */ +export async function disableTotp(opts: { + password?: string | null + code?: string | null +}): Promise { + await apiClient.POST('/api/auth/totp/disable', { + body: { password: opts.password ?? null, code: opts.code ?? null }, + }) +} diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index 760bf33..cef7943 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -35,6 +35,7 @@ import { } from '@mantine/core' import apiClient, { ApiError } from '../api/client' import type { components } from '../api/schema.d.ts' +import { TotpSettings } from './TotpSettings' // --------------------------------------------------------------------------- // Types @@ -393,6 +394,11 @@ export function ConfigPage() { )} + + {/* TOTP two-factor auth management */} + + + ) } diff --git a/frontend/src/pages/LoginPage.test.tsx b/frontend/src/pages/LoginPage.test.tsx index aa871b9..35c488f 100644 --- a/frontend/src/pages/LoginPage.test.tsx +++ b/frontend/src/pages/LoginPage.test.tsx @@ -1,16 +1,21 @@ /** - * Tests for LoginPage (M2-T07). + * Tests for LoginPage (M2-T07, extended M4-T08). * * Strategy: vi.mock the apiClient module so we can control POST /api/auth/login * responses without a real server. We also mock useSession so tests can control * the authentication state. * * Coverage: - * 1. Renders the login form. - * 2. Successful login → invalidates session query + navigates. + * 1. Renders the login form (step 1). + * 2. Successful login (step 1) → invalidates session query + navigates. * 3. 401 bad credentials → shows inline error, does not navigate. * 4. Already-authenticated users visiting /login → redirected to '/'. * 5. Unexpected error → shows generic error message. + * 6. (M4-T08) 401 with totp_required → switches to step 2 TOTP form. + * 7. (M4-T08) Step 2 submit with correct code → success + navigate. + * 8. (M4-T08) Step 2 submit with wrong code → shows error, stays on step 2. + * 9. (M4-T08) Step 2 "Back to login" button → returns to step 1. + * 10. (M4-T08) 429 response → shows "too many attempts" message. */ import { describe, it, expect, vi, beforeEach } from 'vitest' @@ -86,6 +91,10 @@ describe('LoginPage', () => { mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null }) }) + // ------------------------------------------------------------------------- + // 1. Renders the form + // ------------------------------------------------------------------------- + it('renders the login form with username and password fields', () => { renderLogin() expect(screen.getByTestId('login-form')).toBeInTheDocument() @@ -99,8 +108,11 @@ describe('LoginPage', () => { expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument() }) + // ------------------------------------------------------------------------- + // 2. Successful login + // ------------------------------------------------------------------------- + it('navigates to "/" on successful login', async () => { - // Simulate a successful POST /api/auth/login response mockPost.mockResolvedValueOnce({ data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' }, response: { status: 200, ok: true }, @@ -130,10 +142,14 @@ describe('LoginPage', () => { }) }) + // ------------------------------------------------------------------------- + // 3. 401 bad credentials + // ------------------------------------------------------------------------- + it('shows inline error on 401 and does NOT navigate', async () => { - // Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } } mockPost.mockResolvedValueOnce({ data: undefined, + error: { detail: 'invalid username or password' }, response: { status: 401, ok: false }, }) @@ -154,6 +170,7 @@ describe('LoginPage', () => { it('does not include the password in the error message', async () => { mockPost.mockResolvedValueOnce({ data: undefined, + error: { detail: 'invalid username or password' }, response: { status: 401, ok: false }, }) @@ -167,6 +184,10 @@ describe('LoginPage', () => { expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password') }) + // ------------------------------------------------------------------------- + // 5. Unexpected error + // ------------------------------------------------------------------------- + it('shows generic error on unexpected network failure', async () => { mockPost.mockRejectedValueOnce(new Error('Network error')) @@ -180,6 +201,10 @@ describe('LoginPage', () => { expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i) }) + // ------------------------------------------------------------------------- + // 4. Already authenticated + // ------------------------------------------------------------------------- + it('redirects already-authenticated users to "/"', async () => { mockUseSession.mockReturnValue({ status: 'authenticated', @@ -192,4 +217,148 @@ describe('LoginPage', () => { expect(screen.getByTestId('home-page')).toBeInTheDocument() }) }) + + // ------------------------------------------------------------------------- + // 6. (M4-T08) 401 with totp_required → step 2 + // ------------------------------------------------------------------------- + + it('switches to TOTP step on 401 with totp_required=true', async () => { + mockPost.mockResolvedValueOnce({ + data: undefined, + error: { detail: { totp_required: true } }, + response: { status: 401, ok: false }, + }) + + renderLogin() + fillAndSubmit('admin', 'correct-password') + + await waitFor(() => { + expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() + }) + + // Step 1 form should be gone + expect(screen.queryByTestId('login-form')).not.toBeInTheDocument() + // TOTP code input should be visible + expect(screen.getByTestId('totp-code-input')).toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 7. (M4-T08) Step 2 correct code → success + // ------------------------------------------------------------------------- + + it('completes login on correct TOTP code (step 2)', async () => { + // Step 1: trigger totp_required + mockPost.mockResolvedValueOnce({ + data: undefined, + error: { detail: { totp_required: true } }, + response: { status: 401, ok: false }, + }) + // Step 2: success + mockPost.mockResolvedValueOnce({ + data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok456' }, + response: { status: 200, ok: true }, + }) + + renderLogin() + fillAndSubmit('admin', 'correct-password') + + await waitFor(() => { + expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() + }) + + // Enter the TOTP code and submit step 2 + fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '123456' } }) + fireEvent.submit(screen.getByTestId('login-totp-form')) + + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument() + }) + + // Check step 2 request included totp_code + expect(mockPost).toHaveBeenCalledWith('/api/auth/login', { + body: { username: 'admin', password: 'correct-password', totp_code: '123456' }, + }) + }) + + // ------------------------------------------------------------------------- + // 8. (M4-T08) Step 2 wrong code → error, stays on step 2 + // ------------------------------------------------------------------------- + + it('shows error on wrong TOTP code and stays on step 2', async () => { + // Step 1: totp_required + mockPost.mockResolvedValueOnce({ + data: undefined, + error: { detail: { totp_required: true } }, + response: { status: 401, ok: false }, + }) + // Step 2: wrong code → 401 + mockPost.mockResolvedValueOnce({ + data: undefined, + error: { detail: 'invalid totp code' }, + response: { status: 401, ok: false }, + }) + + renderLogin() + fillAndSubmit('admin', 'correct-password') + + await waitFor(() => { + expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() + }) + + fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '000000' } }) + fireEvent.submit(screen.getByTestId('login-totp-form')) + + await waitFor(() => { + expect(screen.getByTestId('login-error')).toBeInTheDocument() + }) + + // Should still be on TOTP form + expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() + expect(screen.getByTestId('login-error')).toHaveTextContent(/incorrect verification code/i) + }) + + // ------------------------------------------------------------------------- + // 9. (M4-T08) Step 2 "Back to login" → returns to step 1 + // ------------------------------------------------------------------------- + + it('returns to step 1 when "Back to login" is clicked', async () => { + mockPost.mockResolvedValueOnce({ + data: undefined, + error: { detail: { totp_required: true } }, + response: { status: 401, ok: false }, + }) + + renderLogin() + fillAndSubmit('admin', 'correct-password') + + await waitFor(() => { + expect(screen.getByTestId('login-totp-form')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('totp-back')) + + await waitFor(() => { + expect(screen.getByTestId('login-form')).toBeInTheDocument() + }) + + expect(screen.queryByTestId('login-totp-form')).not.toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 10. (M4-T08) 429 → "too many attempts" message + // ------------------------------------------------------------------------- + + it('shows "too many attempts" message on 429', async () => { + const { ApiError } = await import('../api/client') + mockPost.mockRejectedValueOnce(new ApiError(429, { detail: 'Too Many Requests' })) + + renderLogin() + fillAndSubmit('admin', 'password') + + await waitFor(() => { + expect(screen.getByTestId('login-error')).toBeInTheDocument() + }) + + expect(screen.getByTestId('login-error')).toHaveTextContent(/too many login attempts/i) + }) }) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 14afdd5..7167c3e 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,13 +1,21 @@ /** - * LoginPage — real login form (M2-T07). + * LoginPage — real login form (M2-T07, extended M4-T08). * * Behaviours: - * - Renders a Mantine form with username + password fields. + * - Renders a Mantine form with username + password fields (step 1). * - On submit → POST /api/auth/login via apiClient (no CSRF needed; unauthenticated endpoint). * - On success → invalidate ['session'] so SessionProvider re-fetches, then navigate to the * originally-requested route (from location.state.from) or fall back to '/'. - * - On 401 (bad credentials) → show an inline error without leaking the password. + * - On 401 with totp_required=true → switch to step 2 (TOTP code entry). + * - On step 2 submit → POST /api/auth/login with {username, password, totp_code}. + * - On 401 plain (bad credentials / wrong TOTP code) → show inline error. + * - On 429 (rate-limited) → show "too many attempts, please try again later". * - Already-authenticated users visiting /login → redirect to '/'. + * + * Security: + * - Password is retained in state for the step-2 re-submission but is NEVER + * written to localStorage, sessionStorage, or console. + * - totp_code / recovery codes are NEVER logged or persisted. */ import { useState } from 'react' @@ -23,9 +31,10 @@ import { Alert, Stack, Center, + Text, } from '@mantine/core' import { useSession } from '../auth/SessionProvider' -import apiClient from '../api/client' +import apiClient, { ApiError } from '../api/client' import { setCsrfToken } from '../api/csrf' // --------------------------------------------------------------------------- @@ -36,6 +45,9 @@ interface LocationState { from?: { pathname: string } } +/** The two UI states for the login flow. */ +type LoginStep = 'credentials' | 'totp' + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -48,6 +60,8 @@ export function LoginPage() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') + const [totpCode, setTotpCode] = useState('') + const [step, setStep] = useState('credentials') const [error, setError] = useState(null) const [loading, setLoading] = useState(false) @@ -57,7 +71,11 @@ export function LoginPage() { return } - async function handleSubmit(e: React.FormEvent) { + // --------------------------------------------------------------------------- + // handleCredentialsSubmit — step 1 + // --------------------------------------------------------------------------- + + async function handleCredentialsSubmit(e: React.FormEvent) { e.preventDefault() setError(null) setLoading(true) @@ -68,31 +86,84 @@ export function LoginPage() { }) if (res.response.status === 401 || !res.data) { - // Bad credentials — do not leak the password in the message. + // Check if the backend wants a TOTP code. + // openapi-fetch puts 401 body in res.error (not res.data). + // The shape is { detail: { totp_required: true } } for TOTP-required, + // or { detail: "invalid username or password" } for bad credentials. + const detail = (res.error as { detail?: unknown } | undefined)?.detail + if (detail && typeof detail === 'object' && (detail as { totp_required?: boolean }).totp_required === true) { + // Transition to step 2 — TOTP entry. Password stays in state for re-send. + setStep('totp') + setTotpCode('') + return + } + // Ordinary bad credentials. setError('Incorrect username or password.') return } - // Success: store the CSRF token returned by login (same shape as session response). + // Success: store the CSRF token returned by login. if (res.data.csrf_token) { setCsrfToken(res.data.csrf_token) } - // Refresh session state: invalidate the ['session'] query so SessionProvider - // picks up the new authenticated state (which may include force_password_change). + // Refresh session state. await queryClient.invalidateQueries({ queryKey: ['session'] }) - - // Navigate to the originally-requested route or home. const from = (location.state as LocationState)?.from?.pathname ?? '/' navigate(from, { replace: true }) - } catch { - // Any unexpected error (network, 5xx, etc.) - setError('Login failed. Please try again.') + } catch (err) { + if (err instanceof ApiError && err.status === 429) { + setError('Too many login attempts. Please try again later.') + } else { + setError('Login failed. Please try again.') + } } finally { setLoading(false) } } + // --------------------------------------------------------------------------- + // handleTotpSubmit — step 2 + // --------------------------------------------------------------------------- + + async function handleTotpSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + setLoading(true) + + try { + const res = await apiClient.POST('/api/auth/login', { + body: { username, password, totp_code: totpCode }, + }) + + if (res.response.status === 401 || !res.data) { + // Wrong code (or expired) — stay on step 2 and show error. + setError('Incorrect verification code. Please try again.') + return + } + + // Success. + if (res.data.csrf_token) { + setCsrfToken(res.data.csrf_token) + } + await queryClient.invalidateQueries({ queryKey: ['session'] }) + const from = (location.state as LocationState)?.from?.pathname ?? '/' + navigate(from, { replace: true }) + } catch (err) { + if (err instanceof ApiError && err.status === 429) { + setError('Too many login attempts. Please try again later.') + } else { + setError('Login failed. Please try again.') + } + } finally { + setLoading(false) + } + } + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + return (
@@ -107,39 +178,85 @@ export function LoginPage() { )} -
- - setUsername(e.currentTarget.value)} - required - autoComplete="username" - data-testid="username-input" - /> + {step === 'credentials' && ( + + + setUsername(e.currentTarget.value)} + required + autoComplete="username" + data-testid="username-input" + /> - setPassword(e.currentTarget.value)} - required - autoComplete="current-password" - data-testid="password-input" - /> + setPassword(e.currentTarget.value)} + required + autoComplete="current-password" + data-testid="password-input" + /> - - - + +
+ + )} + + {step === 'totp' && ( +
+ + + Enter the 6-digit code from your authenticator app, or a recovery code. + + + setTotpCode(e.currentTarget.value)} + required + autoComplete="one-time-code" + inputMode="numeric" + data-testid="totp-code-input" + /> + + + + + +
+ )}
diff --git a/frontend/src/pages/TotpSettings.test.tsx b/frontend/src/pages/TotpSettings.test.tsx new file mode 100644 index 0000000..1c8f24d --- /dev/null +++ b/frontend/src/pages/TotpSettings.test.tsx @@ -0,0 +1,344 @@ +/** + * Tests for TotpSettings (M4-T08). + * + * Strategy: vi.mock the auth/totp module so we can control API responses + * without a real server. We also mock the api/client module for ApiError. + * + * Coverage: + * 1. Shows loading spinner while fetching TOTP status. + * 2. Shows error when TOTP status fetch fails. + * 3. Shows "Disabled" badge and enable button when TOTP is disabled. + * 4. Enable flow: clicking setup → shows QR code + recovery codes + confirm form. + * 5. Confirm with correct code → TOTP enabled (status refetched). + * 6. Confirm with wrong code → shows error, stays on confirm form. + * 7. Shows "Enabled" badge and disable form when TOTP is enabled. + * 8. Disable with password → TOTP disabled. + * 9. Disable with TOTP code → TOTP disabled. + * 10. Disable without providing password or code → shows validation error. + * 11. Setup error shows error message. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor, fireEvent } from '@testing-library/react' +import { renderWithProviders } from '../test-utils' +import { TotpSettings } from './TotpSettings' + +// --------------------------------------------------------------------------- +// Mock auth/totp +// --------------------------------------------------------------------------- + +const mockGetTotpStatus = vi.fn() +const mockSetupTotp = vi.fn() +const mockEnableTotp = vi.fn() +const mockDisableTotp = vi.fn() + +vi.mock('../auth/totp', () => ({ + getTotpStatus: (...args: unknown[]) => mockGetTotpStatus(...args), + setupTotp: (...args: unknown[]) => mockSetupTotp(...args), + enableTotp: (...args: unknown[]) => mockEnableTotp(...args), + disableTotp: (...args: unknown[]) => mockDisableTotp(...args), +})) + +// --------------------------------------------------------------------------- +// Mock api/client (for ApiError) +// --------------------------------------------------------------------------- + +vi.mock('../api/client', () => ({ + default: { + GET: vi.fn(), + POST: vi.fn(), + PUT: vi.fn(), + }, + ApiError: class ApiError extends Error { + status: number + body: unknown + constructor(status: number, body: unknown) { + super(`API error ${status}`) + this.name = 'ApiError' + this.status = status + this.body = body + } + }, + registerLoginRedirect: vi.fn(), +})) + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +const MOCK_SETUP_DATA = { + secret: 'JBSWY3DPEHPK3PXP', + otpauth_uri: 'otpauth://totp/TestApp:admin?secret=JBSWY3DPEHPK3PXP&issuer=TestApp', + recovery_codes: ['abcd-1234', 'efgh-5678', 'ijkl-9012'], +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderTotp() { + return renderWithProviders(, { initialPath: '/config' }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('TotpSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ------------------------------------------------------------------------- + // 1. Loading state + // ------------------------------------------------------------------------- + + it('shows loading spinner while fetching TOTP status', async () => { + // Never resolves during this test + mockGetTotpStatus.mockReturnValue(new Promise(() => {})) + renderTotp() + await waitFor(() => { + expect(screen.getByTestId('totp-status-loading')).toBeInTheDocument() + }) + }) + + // ------------------------------------------------------------------------- + // 2. Error state + // ------------------------------------------------------------------------- + + it('shows error when TOTP status fetch fails', async () => { + mockGetTotpStatus.mockRejectedValue(new Error('Network error')) + renderTotp() + await waitFor(() => { + expect(screen.getByTestId('totp-status-error')).toBeInTheDocument() + }) + }) + + // ------------------------------------------------------------------------- + // 3. Disabled state + // ------------------------------------------------------------------------- + + it('shows Disabled badge and enable button when TOTP is disabled', async () => { + mockGetTotpStatus.mockResolvedValue({ enabled: false }) + renderTotp() + + await waitFor(() => { + expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument() + }) + + expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i) + expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 4. Enable flow: setup shows QR + recovery codes + confirm form + // ------------------------------------------------------------------------- + + it('shows QR code, secret, and recovery codes after clicking setup', async () => { + mockGetTotpStatus.mockResolvedValue({ enabled: false }) + mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA) + + renderTotp() + + await waitFor(() => { + expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('totp-setup-button')) + + await waitFor(() => { + expect(screen.getByTestId('totp-qr-code')).toBeInTheDocument() + }) + + expect(screen.getByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP') + expect(screen.getByTestId('totp-recovery-codes')).toBeInTheDocument() + expect(screen.getByTestId('totp-recovery-codes')).toHaveTextContent('abcd-1234') + expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument() + expect(screen.getByTestId('totp-confirm-input')).toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 5. Confirm with correct code → enabled + // ------------------------------------------------------------------------- + + it('enables TOTP after confirming with correct code', async () => { + mockGetTotpStatus + .mockResolvedValueOnce({ enabled: false }) + .mockResolvedValueOnce({ enabled: true }) + mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA) + mockEnableTotp.mockResolvedValue(undefined) + + renderTotp() + + await waitFor(() => { + expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('totp-setup-button')) + + await waitFor(() => { + expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument() + }) + + fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '123456' } }) + fireEvent.submit(screen.getByTestId('totp-confirm-form')) + + await waitFor(() => { + expect(mockEnableTotp).toHaveBeenCalledWith('123456') + }) + + // After enable the status refetches → shows enabled + await waitFor(() => { + expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i) + }) + }) + + // ------------------------------------------------------------------------- + // 6. Confirm with wrong code → error + // ------------------------------------------------------------------------- + + it('shows error on wrong TOTP code during enable', async () => { + mockGetTotpStatus.mockResolvedValue({ enabled: false }) + mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA) + + const { ApiError } = await import('../api/client') + mockEnableTotp.mockRejectedValue(new ApiError(400, { detail: 'invalid code' })) + + renderTotp() + + await waitFor(() => { + expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('totp-setup-button')) + + await waitFor(() => { + expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument() + }) + + fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '000000' } }) + fireEvent.submit(screen.getByTestId('totp-confirm-form')) + + await waitFor(() => { + expect(screen.getByTestId('totp-confirm-error')).toBeInTheDocument() + }) + + // Should still show confirm form (not enabled) + expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 7. Enabled state + // ------------------------------------------------------------------------- + + it('shows Enabled badge and disable form when TOTP is enabled', async () => { + mockGetTotpStatus.mockResolvedValue({ enabled: true }) + renderTotp() + + await waitFor(() => { + expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument() + }) + + expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i) + expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument() + expect(screen.getByTestId('totp-disable-button')).toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 8. Disable with password + // ------------------------------------------------------------------------- + + it('disables TOTP when correct password is provided', async () => { + mockGetTotpStatus + .mockResolvedValueOnce({ enabled: true }) + .mockResolvedValueOnce({ enabled: false }) + mockDisableTotp.mockResolvedValue(undefined) + + renderTotp() + + await waitFor(() => { + expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument() + }) + + fireEvent.change(screen.getByTestId('totp-disable-password'), { + target: { value: 'my-password' }, + }) + fireEvent.submit(screen.getByTestId('totp-disable-form')) + + await waitFor(() => { + expect(mockDisableTotp).toHaveBeenCalledWith({ password: 'my-password', code: null }) + }) + + await waitFor(() => { + expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i) + }) + }) + + // ------------------------------------------------------------------------- + // 9. Disable with TOTP code + // ------------------------------------------------------------------------- + + it('disables TOTP when correct TOTP code is provided', async () => { + mockGetTotpStatus + .mockResolvedValueOnce({ enabled: true }) + .mockResolvedValueOnce({ enabled: false }) + mockDisableTotp.mockResolvedValue(undefined) + + renderTotp() + + await waitFor(() => { + expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument() + }) + + fireEvent.change(screen.getByTestId('totp-disable-code'), { target: { value: '654321' } }) + fireEvent.submit(screen.getByTestId('totp-disable-form')) + + await waitFor(() => { + expect(mockDisableTotp).toHaveBeenCalledWith({ password: null, code: '654321' }) + }) + }) + + // ------------------------------------------------------------------------- + // 10. Disable without credentials → validation error + // ------------------------------------------------------------------------- + + it('shows validation error when disable submitted without password or code', async () => { + mockGetTotpStatus.mockResolvedValue({ enabled: true }) + + renderTotp() + + await waitFor(() => { + expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument() + }) + + // Submit without filling in anything + fireEvent.submit(screen.getByTestId('totp-disable-form')) + + await waitFor(() => { + expect(screen.getByTestId('totp-disable-error')).toBeInTheDocument() + }) + + expect(mockDisableTotp).not.toHaveBeenCalled() + }) + + // ------------------------------------------------------------------------- + // 11. Setup error + // ------------------------------------------------------------------------- + + it('shows error when TOTP setup request fails', async () => { + mockGetTotpStatus.mockResolvedValue({ enabled: false }) + mockSetupTotp.mockRejectedValue(new Error('Network error')) + + renderTotp() + + await waitFor(() => { + expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('totp-setup-button')) + + await waitFor(() => { + expect(screen.getByTestId('totp-enable-error')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/pages/TotpSettings.tsx b/frontend/src/pages/TotpSettings.tsx new file mode 100644 index 0000000..dd355aa --- /dev/null +++ b/frontend/src/pages/TotpSettings.tsx @@ -0,0 +1,360 @@ +/** + * TotpSettings — TOTP two-factor auth management panel (M4-T08). + * + * Intended to be embedded in the settings/config area of the app. + * Handles the full TOTP lifecycle: + * - Status probe : GET /api/auth/totp + * - Enable flow : setup → show QR code + one-time recovery codes → confirm with code + * - Disable flow : prompt for current password or TOTP code → disable + * + * Security notes: + * - secret and recovery_codes from TotpSetupResponse are held ONLY in component + * state; they are NEVER written to localStorage, sessionStorage, or console. + * - Recovery codes are displayed exactly once (during the setup flow); after the + * user confirms with a code and TOTP is enabled the data is discarded. + * - Passwords typed in the disable form are NEVER logged or persisted. + */ + +import { useState, useCallback } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + Paper, + Title, + Text, + Button, + Alert, + Stack, + Group, + TextInput, + PasswordInput, + Divider, + Badge, + Loader, + Center, + Code, + List, +} from '@mantine/core' +import { QRCodeSVG } from 'qrcode.react' +import { getTotpStatus, setupTotp, enableTotp, disableTotp } from '../auth/totp' +import { ApiError } from '../api/client' +import type { TotpSetupResponse } from '../auth/totp' + +// --------------------------------------------------------------------------- +// Sub-panel: Enable flow +// --------------------------------------------------------------------------- + +interface EnablePanelProps { + onEnabled: () => void +} + +function EnablePanel({ onEnabled }: EnablePanelProps) { + const [setupData, setSetupData] = useState(null) + const [confirmCode, setConfirmCode] = useState('') + const [status, setStatus] = useState<'idle' | 'setup-loading' | 'confirm' | 'confirming' | 'error'>('idle') + const [errorMsg, setErrorMsg] = useState(null) + + async function handleSetup() { + setStatus('setup-loading') + setErrorMsg(null) + try { + const data = await setupTotp() + // data contains one-time secret + recovery_codes — keep only in state. + setSetupData(data) + setStatus('confirm') + } catch { + setErrorMsg('Failed to start TOTP setup. Please try again.') + setStatus('error') + } + } + + async function handleEnable(e: React.FormEvent) { + e.preventDefault() + setErrorMsg(null) + setStatus('confirming') + try { + await enableTotp(confirmCode) + // Discard secret + recovery codes from memory now that TOTP is enabled. + setSetupData(null) + setConfirmCode('') + onEnabled() + } catch (err) { + let msg = 'Failed to enable TOTP. Check the code and try again.' + if (err instanceof ApiError && err.status === 400) { + msg = 'Incorrect code or no pending setup. Please start the setup again.' + } + setErrorMsg(msg) + setStatus('confirm') + } + } + + // --------------------------------------------------------------------------- + // Render: idle — show "Start Setup" button + // --------------------------------------------------------------------------- + + if (status === 'idle' || status === 'error') { + return ( + + + Two-factor authentication is not enabled. Enable it to require a verification code + each time you sign in. + + {errorMsg && ( + + {errorMsg} + + )} + + + ) + } + + // --------------------------------------------------------------------------- + // Render: setup-loading + // --------------------------------------------------------------------------- + + if (status === 'setup-loading') { + return ( +
+ +
+ ) + } + + // --------------------------------------------------------------------------- + // Render: confirm — show QR code, secret, recovery codes, confirmation form + // --------------------------------------------------------------------------- + + if ((status === 'confirm' || status === 'confirming') && setupData) { + return ( + + + Save your recovery codes now — they will only be shown once. + + + + 1. Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy): + + +
+ +
+ + + Or enter the key manually: + + + {setupData.secret} + + + + + + 2. Save these recovery codes in a secure place. Each code can be used once + if you lose access to your authenticator: + + + + {setupData.recovery_codes.map((code) => ( + + {code} + + ))} + + + + + + + 3. Enter the 6-digit code from your authenticator app to confirm setup: + + + {errorMsg && ( + + {errorMsg} + + )} + +
+ + setConfirmCode(e.currentTarget.value)} + required + autoComplete="one-time-code" + inputMode="numeric" + data-testid="totp-confirm-input" + /> + + +
+
+ ) + } + + return null +} + +// --------------------------------------------------------------------------- +// Sub-panel: Disable flow +// --------------------------------------------------------------------------- + +interface DisablePanelProps { + onDisabled: () => void +} + +function DisablePanel({ onDisabled }: DisablePanelProps) { + const [disablePassword, setDisablePassword] = useState('') + const [disableCode, setDisableCode] = useState('') + const [errorMsg, setErrorMsg] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + async function handleDisable(e: React.FormEvent) { + e.preventDefault() + setErrorMsg(null) + + // At least one of password or code must be provided. + if (!disablePassword && !disableCode) { + setErrorMsg('Please provide your current password or a TOTP code.') + return + } + + setIsSubmitting(true) + try { + await disableTotp({ + password: disablePassword || null, + code: disableCode || null, + }) + setDisablePassword('') + setDisableCode('') + onDisabled() + } catch (err) { + let msg = 'Failed to disable TOTP. Please try again.' + if (err instanceof ApiError && err.status === 400) { + msg = 'Incorrect password or code. Please try again.' + } + setErrorMsg(msg) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + Two-factor authentication is enabled. To disable it, enter your current password + or a valid verification code. + + + {errorMsg && ( + + {errorMsg} + + )} + +
+ + setDisablePassword(e.currentTarget.value)} + autoComplete="current-password" + data-testid="totp-disable-password" + /> + + + — or — + + + setDisableCode(e.currentTarget.value)} + autoComplete="one-time-code" + inputMode="numeric" + data-testid="totp-disable-code" + /> + + + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// TotpSettings — main exported component +// --------------------------------------------------------------------------- + +export function TotpSettings() { + const queryClient = useQueryClient() + + const { + data: totpStatus, + isLoading, + isError, + } = useQuery({ + queryKey: ['totp-status'], + queryFn: getTotpStatus, + // No automatic retries — the test QueryClient is configured with retry: false. + // Network errors are shown immediately via the error state. + retry: false, + }) + + // Invalidate the TOTP status query to refetch after enable/disable. + const refetchStatus = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: ['totp-status'] }) + }, [queryClient]) + + return ( + + + Two-Factor Authentication (TOTP) + {totpStatus && ( + + {totpStatus.enabled ? 'Enabled' : 'Disabled'} + + )} + + + {isLoading && ( +
+ +
+ )} + + {isError && ( + + Failed to load TOTP status. Please refresh the page. + + )} + + {totpStatus && !totpStatus.enabled && } + + {totpStatus && totpStatus.enabled && } +
+ ) +}