M4-T08: add two-step login and TOTP settings panel (qrcode.react)
This commit is contained in:
@@ -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 / 不要碰**: 不改后端;不碰防爆破逻辑。
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+270
@@ -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;
|
||||
|
||||
@@ -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<TotpStatusResponse> {
|
||||
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<TotpSetupResponse> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
await apiClient.POST('/api/auth/totp/disable', {
|
||||
body: { password: opts.password ?? null, code: opts.code ?? null },
|
||||
})
|
||||
}
|
||||
@@ -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() {
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* TOTP two-factor auth management */}
|
||||
<Stack mt="xl">
|
||||
<TotpSettings />
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<LoginStep>('credentials')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -57,7 +71,11 @@ export function LoginPage() {
|
||||
return <Navigate to={from} replace />
|
||||
}
|
||||
|
||||
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 (
|
||||
<Center mih="100vh">
|
||||
<Container size="xs" w="100%">
|
||||
@@ -107,39 +178,85 @@ export function LoginPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} data-testid="login-form">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
data-testid="username-input"
|
||||
/>
|
||||
{step === 'credentials' && (
|
||||
<form onSubmit={handleCredentialsSubmit} data-testid="login-form">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Enter your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
data-testid="username-input"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
data-testid="password-input"
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
data-testid="password-input"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
mt="sm"
|
||||
data-testid="login-submit"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
mt="sm"
|
||||
data-testid="login-submit"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'totp' && (
|
||||
<form onSubmit={handleTotpSubmit} data-testid="login-totp-form">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Enter the 6-digit code from your authenticator app, or a recovery code.
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="Verification Code"
|
||||
placeholder="6-digit code or recovery code"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.currentTarget.value)}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
data-testid="totp-code-input"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
mt="sm"
|
||||
data-testid="totp-submit"
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStep('credentials')
|
||||
setError(null)
|
||||
setTotpCode('')
|
||||
}}
|
||||
data-testid="totp-back"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</Center>
|
||||
|
||||
@@ -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(<TotpSettings />, { 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<TotpSetupResponse | null>(null)
|
||||
const [confirmCode, setConfirmCode] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'setup-loading' | 'confirm' | 'confirming' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(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 (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
Two-factor authentication is not enabled. Enable it to require a verification code
|
||||
each time you sign in.
|
||||
</Text>
|
||||
{errorMsg && (
|
||||
<Alert color="red" data-testid="totp-enable-error">
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
<Button onClick={handleSetup} data-testid="totp-setup-button">
|
||||
Enable Two-Factor Authentication
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: setup-loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (status === 'setup-loading') {
|
||||
return (
|
||||
<Center>
|
||||
<Loader data-testid="totp-setup-loading" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: confirm — show QR code, secret, recovery codes, confirmation form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if ((status === 'confirm' || status === 'confirming') && setupData) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" data-testid="totp-setup-warning">
|
||||
Save your recovery codes now — they will only be shown once.
|
||||
</Alert>
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
1. Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy):
|
||||
</Text>
|
||||
|
||||
<Center data-testid="totp-qr-code">
|
||||
<QRCodeSVG value={setupData.otpauth_uri} size={180} />
|
||||
</Center>
|
||||
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Or enter the key manually:
|
||||
</Text>
|
||||
<Code block data-testid="totp-secret">
|
||||
{setupData.secret}
|
||||
</Code>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
2. Save these recovery codes in a secure place. Each code can be used once
|
||||
if you lose access to your authenticator:
|
||||
</Text>
|
||||
<Paper withBorder p="sm" data-testid="totp-recovery-codes">
|
||||
<List spacing="xs" size="sm">
|
||||
{setupData.recovery_codes.map((code) => (
|
||||
<List.Item key={code}>
|
||||
<Code>{code}</Code>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
3. Enter the 6-digit code from your authenticator app to confirm setup:
|
||||
</Text>
|
||||
|
||||
{errorMsg && (
|
||||
<Alert color="red" data-testid="totp-confirm-error">
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleEnable} data-testid="totp-confirm-form">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Verification Code"
|
||||
placeholder="6-digit code"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.currentTarget.value)}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
data-testid="totp-confirm-input"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={status === 'confirming'}
|
||||
data-testid="totp-confirm-button"
|
||||
>
|
||||
Confirm and Enable
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" data-testid="totp-enabled-notice">
|
||||
Two-factor authentication is enabled. To disable it, enter your current password
|
||||
or a valid verification code.
|
||||
</Alert>
|
||||
|
||||
{errorMsg && (
|
||||
<Alert color="red" data-testid="totp-disable-error">
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDisable} data-testid="totp-disable-form">
|
||||
<Stack gap="sm">
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
placeholder="Enter your password (or use a TOTP code below)"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.currentTarget.value)}
|
||||
autoComplete="current-password"
|
||||
data-testid="totp-disable-password"
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
— or —
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="TOTP Code"
|
||||
placeholder="6-digit code from your authenticator"
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.currentTarget.value)}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
data-testid="totp-disable-code"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="red"
|
||||
variant="outline"
|
||||
loading={isSubmitting}
|
||||
data-testid="totp-disable-button"
|
||||
>
|
||||
Disable Two-Factor Authentication
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<Paper withBorder p="md" radius="md" data-testid="totp-settings">
|
||||
<Group justify="space-between" mb="md" wrap="nowrap">
|
||||
<Title order={4}>Two-Factor Authentication (TOTP)</Title>
|
||||
{totpStatus && (
|
||||
<Badge
|
||||
color={totpStatus.enabled ? 'green' : 'gray'}
|
||||
variant="outline"
|
||||
data-testid="totp-status-badge"
|
||||
>
|
||||
{totpStatus.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isLoading && (
|
||||
<Center>
|
||||
<Loader data-testid="totp-status-loading" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert color="red" data-testid="totp-status-error">
|
||||
Failed to load TOTP status. Please refresh the page.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{totpStatus && !totpStatus.enabled && <EnablePanel onEnabled={refetchStatus} />}
|
||||
|
||||
{totpStatus && totpStatus.enabled && <DisablePanel onDisabled={refetchStatus} />}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user