From 9e64a199175c8678a7b9c532d7c366ebfd141701 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Tue, 3 Nov 2026 22:15:22 +0530 Subject: [PATCH] fix(auth): ask for the authenticator code on sign-in when 2FA is on Both React apps let users enable two-factor authentication, but neither login page could send the code, so every later sign-in failed with 401 two_factor_required and locked the user out. When POST /token/issue answers with the ProblemDetails code Identity.TwoFactorRequired, the admin and dashboard login cards now switch to a six-digit code step (one-time-code autocomplete, numeric input) and repeat the call with twoFactorCode. A wrong code (Identity.TwoFactorInvalid) shows an inline error and keeps the user on the step; Back returns to the password step. The password is held only in component state for the retry and is cleared on back, success and unmount. The integration test exception handler now mirrors the production handler's code extension, and the 2FA login tests assert both codes, since the clients depend on them. Closes #1446 --- clients/admin/src/auth/api.ts | 32 +- clients/admin/src/auth/auth-context.tsx | 4 +- .../src/components/auth/two-factor-step.tsx | 141 +++++++ clients/admin/src/pages/login.tsx | 370 ++++++++++------- .../admin/tests/auth/two-factor-login.spec.ts | 164 ++++++++ clients/admin/tests/helpers/auth-seed.ts | 2 +- clients/dashboard/src/auth/api.ts | 32 +- clients/dashboard/src/auth/auth-context.tsx | 4 +- .../src/components/auth/two-factor-step.tsx | 141 +++++++ clients/dashboard/src/pages/login.tsx | 374 ++++++++++-------- .../tests/auth/two-factor-login.spec.ts | 166 ++++++++ clients/dashboard/tests/helpers/auth-seed.ts | 2 +- .../DetailedTestExceptionHandler.cs | 6 + .../Authentication/TwoFactorAuthTests.cs | 11 + 14 files changed, 1131 insertions(+), 318 deletions(-) create mode 100644 clients/admin/src/components/auth/two-factor-step.tsx create mode 100644 clients/admin/tests/auth/two-factor-login.spec.ts create mode 100644 clients/dashboard/src/components/auth/two-factor-step.tsx create mode 100644 clients/dashboard/tests/auth/two-factor-login.spec.ts diff --git a/clients/admin/src/auth/api.ts b/clients/admin/src/auth/api.ts index bc0489dc0b..b90704ee1b 100644 --- a/clients/admin/src/auth/api.ts +++ b/clients/admin/src/auth/api.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "@/lib/api-client"; +import { ApiRequestError, apiFetch } from "@/lib/api-client"; export type TokenResponse = { accessToken: string; @@ -11,13 +11,41 @@ export function issueToken(input: { email: string; password: string; tenant: string; + /** Authenticator (TOTP) code — sent only on the second step of a 2FA sign-in. */ + twoFactorCode?: string; }) { return apiFetch("/api/v1/identity/token/issue", { method: "POST", - body: JSON.stringify({ email: input.email, password: input.password }), + body: JSON.stringify({ + email: input.email, + password: input.password, + ...(input.twoFactorCode ? { twoFactorCode: input.twoFactorCode } : {}), + }), // X-FSH-App marks this client as the platform-admin app. Used by the // API to enforce the SuperAdmin / dashboard boundary. headers: { tenant: input.tenant, "X-FSH-App": "admin" }, skipAuth: true, }); } + +// Stable ProblemDetails `code`s the token endpoint sets for the second factor +// (the MessageKey IdentityService throws with). The detail text is localized +// under the request culture, so branch on the code, never on the prose. +const TWO_FACTOR_REQUIRED = "Identity.TwoFactorRequired"; +const TWO_FACTOR_INVALID = "Identity.TwoFactorInvalid"; + +function problemCode(error: unknown): string | undefined { + if (!(error instanceof ApiRequestError) || error.status !== 401) return undefined; + const code = error.problem?.code; + return typeof code === "string" ? code : undefined; +} + +/** The password was right, but the account has 2FA on and no code was sent. */ +export function isTwoFactorRequiredError(error: unknown): boolean { + return problemCode(error) === TWO_FACTOR_REQUIRED; +} + +/** The authenticator code was wrong or expired. */ +export function isTwoFactorInvalidError(error: unknown): boolean { + return problemCode(error) === TWO_FACTOR_INVALID; +} diff --git a/clients/admin/src/auth/auth-context.tsx b/clients/admin/src/auth/auth-context.tsx index f1457a3ea9..ca20d90f53 100644 --- a/clients/admin/src/auth/auth-context.tsx +++ b/clients/admin/src/auth/auth-context.tsx @@ -31,7 +31,7 @@ export type AuthContextValue = { * permissions request is still in flight. */ permissionsHydrated: boolean; - login: (input: { email: string; password: string; tenant: string }) => Promise; + login: (input: { email: string; password: string; tenant: string; twoFactorCode?: string }) => Promise; logout: () => void; /** Re-fetch the permission set for the signed-in user. Call after a role * assignment changes for the current user. */ @@ -162,7 +162,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const login = useCallback( - async (input: { email: string; password: string; tenant: string }) => { + async (input: { email: string; password: string; tenant: string; twoFactorCode?: string }) => { tokenStore.setTenant(input.tenant); // Stale permissions from a previous user must not leak into the new // session — clear before issuing the token so the hydration effect diff --git a/clients/admin/src/components/auth/two-factor-step.tsx b/clients/admin/src/components/auth/two-factor-step.tsx new file mode 100644 index 0000000000..8489f684c3 --- /dev/null +++ b/clients/admin/src/components/auth/two-factor-step.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, type FormEvent } from "react"; +import { AlertCircle, ArrowLeft, ArrowRight, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/cn"; + +const TWO_FACTOR_CODE_LENGTH = 6; + +type TwoFactorStepProps = { + email: string; + code: string; + onCodeChange: (code: string) => void; + onSubmit: () => void; + onBack: () => void; + submitting: boolean; + error: string | null; +}; + +/** + * Second step of the sign-in card, shown after the token call answers + * `Identity.TwoFactorRequired`. Collects the 6-digit authenticator code; the + * login page owns the credentials and re-issues the token call with it. + */ +export function TwoFactorStep({ + email, + code, + onCodeChange, + onSubmit, + onBack, + submitting, + error, +}: TwoFactorStepProps) { + const inputRef = useRef(null); + + // Put the cursor back in the field after a rejected code (the page clears it). + useEffect(() => { + if (error && code === "") inputRef.current?.focus(); + }, [error, code]); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (code.length === TWO_FACTOR_CODE_LENGTH && !submitting) onSubmit(); + }; + + return ( + <> +
+

+ Two-step verification +

+

+ Enter the 6-digit code from your authenticator app to finish signing in + {email ? ( + <> + {" "}as {email} + + ) : null} + . +

+
+ +
+
+ + onCodeChange(e.target.value.replace(/\D/g, "").slice(0, TWO_FACTOR_CODE_LENGTH))} + inputMode="numeric" + autoComplete="one-time-code" + pattern="[0-9]*" + maxLength={TWO_FACTOR_CODE_LENGTH} + placeholder="123456" + autoFocus + required + aria-invalid={error ? true : undefined} + className="h-11 font-mono text-[16px] tracking-[0.4em]" + /> +
+ + {error && ( + + )} + +
+ + +
+
+ + ); +} diff --git a/clients/admin/src/pages/login.tsx b/clients/admin/src/pages/login.tsx index 6be23f80e9..71ac34b10f 100644 --- a/clients/admin/src/pages/login.tsx +++ b/clients/admin/src/pages/login.tsx @@ -11,11 +11,13 @@ import { TimerOff, } from "lucide-react"; import { useAuth } from "@/auth/use-auth"; +import { isTwoFactorInvalidError, isTwoFactorRequiredError } from "@/auth/api"; import { consumeSignedOutReason } from "@/auth/inactivity"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { DemoAccountsDialog } from "@/components/auth/demo-accounts-dialog"; +import { TwoFactorStep } from "@/components/auth/two-factor-step"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; import { env } from "@/env"; @@ -32,6 +34,7 @@ import type { DemoAccount } from "@/pages/login.demo-accounts"; // ──────────────────────────────────────────────────────────────────────── type LocationState = { from?: { pathname: string } }; +type Credentials = { email: string; password: string; tenant: string }; export function LoginPage() { const { isAuthenticated, login } = useAuth(); @@ -47,6 +50,13 @@ export function LoginPage() { const [demoOpen, setDemoOpen] = useState(false); const [showPassword, setShowPassword] = useState(false); const [notice, setNotice] = useState(null); + // Second factor. When the token call answers Identity.TwoFactorRequired the + // credentials move here (component memory only — never storage) so the code + // step can repeat the call with them. Cleared on back, on success, and with + // the component on unmount. + const [pending, setPending] = useState(null); + const [code, setCode] = useState(""); + const [codeError, setCodeError] = useState(null); // Surface why the previous session ended (read-and-clear, one-shot). useEffect(() => { @@ -59,25 +69,65 @@ export function LoginPage() { return ; } - const performLogin = async (creds: { email: string; password: string; tenant: string }) => { + const errorMessage = (err: unknown) => + err instanceof ApiRequestError + ? err.problem?.detail ?? err.problem?.title ?? err.message + : err instanceof Error + ? err.message + : "Login failed"; + + const performLogin = async (creds: Credentials) => { setError(null); setSubmitting(true); try { await login(creds); navigate(from, { replace: true }); } catch (err) { - const message = - err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message - : err instanceof Error - ? err.message - : "Login failed"; - setError(message); + if (isTwoFactorRequiredError(err)) { + // Right password, 2FA on: hold the credentials for the retry and drop + // the password from the form field. + setPending(creds); + setPassword(""); + setShowPassword(false); + setCode(""); + setCodeError(null); + return; + } + setError(errorMessage(err)); + } finally { + setSubmitting(false); + } + }; + + // Credentials + code ride in as one argument, read when the call is made, + // not from state a pending request could see change underneath it. + const verifyCode = async (attempt: Credentials & { twoFactorCode: string }) => { + setCodeError(null); + setSubmitting(true); + try { + await login(attempt); + setPending(null); + setCode(""); + navigate(from, { replace: true }); + } catch (err) { + setCode(""); + setCodeError( + isTwoFactorInvalidError(err) + ? "That code didn't work. Check your authenticator app and try again." + : errorMessage(err), + ); } finally { setSubmitting(false); } }; + const onBackToPassword = () => { + setPending(null); + setCode(""); + setCodeError(null); + setError(null); + }; + const onSubmit = async (e: FormEvent) => { e.preventDefault(); await performLogin({ email, password, tenant }); @@ -135,160 +185,174 @@ export function LoginPage() { {/* Form card */}
-
-

- Welcome back -

-

- Sign in to your operator account -

-
+ {pending ? ( + void verifyCode({ ...pending, twoFactorCode: code })} + onBack={onBackToPassword} + submitting={submitting} + error={codeError} + /> + ) : ( + <> +
+

+ Welcome back +

+

+ Sign in to your operator account +

+
- {notice && ( -
- - {notice} -
- )} + {notice && ( +
+ + {notice} +
+ )} -
- {/* Tenant */} -
- - setTenant(e.target.value)} - autoComplete="organization" - placeholder="root" - required - aria-invalid={error ? true : undefined} - className="h-11 text-[14px]" - /> -
+ {/* Tenant */} +
+ + setTenant(e.target.value)} + autoComplete="organization" + placeholder="root" + required + aria-invalid={error ? true : undefined} + className="h-11 text-[14px]" + /> +
- {/* Email */} -
- - setEmail(e.target.value)} - autoComplete="email" - placeholder="operator@root.example" - required - aria-invalid={error ? true : undefined} - className="h-11 text-[14px]" - /> -
+ {/* Email */} +
+ + setEmail(e.target.value)} + autoComplete="email" + placeholder="operator@root.example" + required + aria-invalid={error ? true : undefined} + className="h-11 text-[14px]" + /> +
- {/* Password */} -
-
- - - Forgot? - -
-
- setPassword(e.target.value)} - autoComplete="current-password" - placeholder="Enter your password" - required - aria-invalid={error ? true : undefined} - className="h-11 pr-11 text-[14px]" - /> - -
-
+ {/* Password */} +
+
+ + + Forgot? + +
+
+ setPassword(e.target.value)} + autoComplete="current-password" + placeholder="Enter your password" + required + aria-invalid={error ? true : undefined} + className="h-11 pr-11 text-[14px]" + /> + +
+
- {error && ( - )} - > - - {error} -
- )} -
- -
- +
+ +
+ - {/* Demo accounts — dev-only, same dashed-button pattern as dashboard. */} - {import.meta.env.DEV && ( -
- -
+ {/* Demo accounts — dev-only, same dashed-button pattern as dashboard. */} + {import.meta.env.DEV && ( +
+ +
+ )} + )}
diff --git a/clients/admin/tests/auth/two-factor-login.spec.ts b/clients/admin/tests/auth/two-factor-login.spec.ts new file mode 100644 index 0000000000..034128bbad --- /dev/null +++ b/clients/admin/tests/auth/two-factor-login.spec.ts @@ -0,0 +1,164 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; +import { fakeJwt } from "../helpers/auth-seed"; +import { installAdminShellMocks } from "../helpers/shell-mocks"; + +// Two-step sign-in (#1446). When the account has 2FA on, POST /token/issue +// without a code answers 401 with ProblemDetails `code: Identity.TwoFactorRequired`; +// the login card swaps to a code step and repeats the call with `twoFactorCode`. +// A wrong code answers `code: Identity.TwoFactorInvalid`. + +const VALID_CODE = "123456"; +const EMAIL = "operator@root.example"; +const PASSWORD = "Password123!"; + +function tokenResponse() { + const now = Math.floor(Date.now() / 1000); + return { + accessToken: fakeJwt({ + sub: "u-2fa", + email: EMAIL, + given_name: "Root", + family_name: "Operator", + tenant: "root", + exp: now + 3600, + iat: now, + }), + refreshToken: "refresh", + accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), + refreshTokenExpiresAt: new Date(Date.now() + 7_200_000).toISOString(), + }; +} + +function problem(route: Route, code: string, detail: string) { + return route.fulfill({ + status: 401, + headers: { "Content-Type": "application/problem+json" }, + body: JSON.stringify({ + type: "https://httpstatuses.io/401", + title: "Unauthorized", + status: 401, + detail, + code, + }), + }); +} + +type IssueBody = { email: string; password: string; twoFactorCode?: string }; + +/** + * Mock the token endpoint as the API behaves for a user with (or without) + * 2FA enabled, recording every body the page posts. + */ +async function mockTokenIssue(page: Page, opts: { twoFactorEnabled: boolean }) { + const bodies: IssueBody[] = []; + const tenants: (string | undefined)[] = []; + await page.route("**/api/v1/identity/token/issue", async (route) => { + const body = JSON.parse(route.request().postData() ?? "{}") as IssueBody; + bodies.push(body); + tenants.push(route.request().headers().tenant); + if (!opts.twoFactorEnabled) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(tokenResponse()) }); + return; + } + if (!body.twoFactorCode) { + await problem(route, "Identity.TwoFactorRequired", "two_factor_required: An authenticator code is required to complete sign-in."); + return; + } + if (body.twoFactorCode !== VALID_CODE) { + await problem(route, "Identity.TwoFactorInvalid", "two_factor_invalid: The authenticator code is invalid or expired."); + return; + } + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(tokenResponse()) }); + }); + return Object.assign(bodies, { tenants }); +} + +test.beforeEach(async ({ page }) => { + // After sign-in the operator shell boots on "/". Answer anything it fetches with + // an empty 200 so no stray 401 logs the fresh session out mid-test. + await page.route("**/api/v1/**", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await installAdminShellMocks(page); +}); + +async function submitPassword(page: Page) { + await page.goto("/login"); + await page.getByLabel("Tenant").fill("root"); + await page.getByLabel("Email").fill(EMAIL); + await page.getByLabel("Password", { exact: true }).fill(PASSWORD); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); +} + +test.describe("login — two-factor step", () => { + test("asks for the code, then signs in with it", async ({ page }) => { + const bodies = await mockTokenIssue(page, { twoFactorEnabled: true }); + await submitPassword(page); + + await expect(page.getByRole("heading", { name: /two-step verification/i })).toBeVisible(); + const codeInput = page.getByLabel("Authentication code"); + await expect(codeInput).toBeFocused(); + await expect(codeInput).toHaveAttribute("autocomplete", "one-time-code"); + await expect(codeInput).toHaveAttribute("inputmode", "numeric"); + // No error surfaces for the expected 2FA challenge. + await expect(page.getByRole("alert")).toHaveCount(0); + + const verify = page.getByRole("button", { name: /^verify$/i }); + await expect(verify).toBeDisabled(); + await codeInput.fill(VALID_CODE); + await verify.click(); + + await expect(page).not.toHaveURL(/\/login/); + expect(bodies).toHaveLength(2); + expect(bodies[0]).toEqual({ email: EMAIL, password: PASSWORD }); + expect(bodies[1]).toEqual({ email: EMAIL, password: PASSWORD, twoFactorCode: VALID_CODE }); + // The tenant chosen on the password step rides the retry too. + expect(bodies.tenants).toEqual(["root", "root"]); + + // The password never lands in browser storage. + const stored = await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage })); + expect(stored).not.toContain(PASSWORD); + }); + + test("a wrong code shows an error and stays on the code step", async ({ page }) => { + const bodies = await mockTokenIssue(page, { twoFactorEnabled: true }); + await submitPassword(page); + + const codeInput = page.getByLabel("Authentication code"); + await codeInput.fill("000000"); + await page.getByRole("button", { name: /^verify$/i }).click(); + + await expect(page.getByRole("alert")).toContainText(/that code didn't work/i); + await expect(page.getByRole("heading", { name: /two-step verification/i })).toBeVisible(); + await expect(page).toHaveURL(/\/login/); + await expect(codeInput).toHaveValue(""); + expect(bodies[1]).toMatchObject({ twoFactorCode: "000000" }); + + // A second attempt with the right code still works from here. + await codeInput.fill(VALID_CODE); + await page.getByRole("button", { name: /^verify$/i }).click(); + await expect(page).not.toHaveURL(/\/login/); + }); + + test("back returns to the password step with the password cleared", async ({ page }) => { + await mockTokenIssue(page, { twoFactorEnabled: true }); + await submitPassword(page); + + await expect(page.getByLabel("Authentication code")).toBeVisible(); + await page.getByRole("button", { name: /back to sign in/i }).click(); + + await expect(page.getByRole("heading", { name: /welcome back/i })).toBeVisible(); + await expect(page.getByLabel("Email")).toHaveValue(EMAIL); + await expect(page.getByLabel("Password", { exact: true })).toHaveValue(""); + await expect(page.getByLabel("Authentication code")).toHaveCount(0); + }); + + test("a user without 2FA still signs in in one step", async ({ page }) => { + const bodies = await mockTokenIssue(page, { twoFactorEnabled: false }); + await submitPassword(page); + + await expect(page).not.toHaveURL(/\/login/); + expect(bodies).toHaveLength(1); + expect(bodies[0]).toEqual({ email: EMAIL, password: PASSWORD }); + }); +}); diff --git a/clients/admin/tests/helpers/auth-seed.ts b/clients/admin/tests/helpers/auth-seed.ts index c631a344ac..42a581e5d4 100644 --- a/clients/admin/tests/helpers/auth-seed.ts +++ b/clients/admin/tests/helpers/auth-seed.ts @@ -14,7 +14,7 @@ export type SeededUser = { permissions?: string[]; }; -function fakeJwt(payload: Record): string { +export function fakeJwt(payload: Record): string { const b64url = (obj: unknown) => btoa(JSON.stringify(obj)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); return [b64url({ alg: "HS256", typ: "JWT" }), b64url(payload), "sig"].join("."); diff --git a/clients/dashboard/src/auth/api.ts b/clients/dashboard/src/auth/api.ts index 1ed1c1e64f..469d754a9f 100644 --- a/clients/dashboard/src/auth/api.ts +++ b/clients/dashboard/src/auth/api.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "@/lib/api-client"; +import { ApiRequestError, apiFetch } from "@/lib/api-client"; export type TokenResponse = { accessToken: string; @@ -11,10 +11,16 @@ export function issueToken(input: { email: string; password: string; tenant: string; + /** Authenticator (TOTP) code — sent only on the second step of a 2FA sign-in. */ + twoFactorCode?: string; }) { return apiFetch("/api/v1/identity/token/issue", { method: "POST", - body: JSON.stringify({ email: input.email, password: input.password }), + body: JSON.stringify({ + email: input.email, + password: input.password, + ...(input.twoFactorCode ? { twoFactorCode: input.twoFactorCode } : {}), + }), // X-FSH-App tells the API this credential request originated from the // tenant dashboard. The server uses it to enforce the SuperAdmin / app // boundary — a root-tenant login submitted with X-FSH-App=dashboard is @@ -23,3 +29,25 @@ export function issueToken(input: { skipAuth: true, }); } + +// Stable ProblemDetails `code`s the token endpoint sets for the second factor +// (the MessageKey IdentityService throws with). The detail text is localized +// under the request culture, so branch on the code, never on the prose. +const TWO_FACTOR_REQUIRED = "Identity.TwoFactorRequired"; +const TWO_FACTOR_INVALID = "Identity.TwoFactorInvalid"; + +function problemCode(error: unknown): string | undefined { + if (!(error instanceof ApiRequestError) || error.status !== 401) return undefined; + const code = error.problem?.code; + return typeof code === "string" ? code : undefined; +} + +/** The password was right, but the account has 2FA on and no code was sent. */ +export function isTwoFactorRequiredError(error: unknown): boolean { + return problemCode(error) === TWO_FACTOR_REQUIRED; +} + +/** The authenticator code was wrong or expired. */ +export function isTwoFactorInvalidError(error: unknown): boolean { + return problemCode(error) === TWO_FACTOR_INVALID; +} diff --git a/clients/dashboard/src/auth/auth-context.tsx b/clients/dashboard/src/auth/auth-context.tsx index 2909c62703..a3c2099252 100644 --- a/clients/dashboard/src/auth/auth-context.tsx +++ b/clients/dashboard/src/auth/auth-context.tsx @@ -41,7 +41,7 @@ export type AuthContextValue = { permissionsHydrated: boolean; /** Truthy iff the current access token carries act_sub (impersonation mode). */ impersonation: ImpersonationInfo | null; - login: (input: { email: string; password: string; tenant: string }) => Promise; + login: (input: { email: string; password: string; tenant: string; twoFactorCode?: string }) => Promise; logout: () => void; /** Re-fetch the permission set for the signed-in user (e.g. after a role change). */ refreshPermissions: () => Promise; @@ -216,7 +216,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const login = useCallback( - async (input: { email: string; password: string; tenant: string }) => { + async (input: { email: string; password: string; tenant: string; twoFactorCode?: string }) => { tokenStore.setTenant(input.tenant); // Stale permissions from a previous user must not leak into the new // session — clear before issuing the token so the hydration effect diff --git a/clients/dashboard/src/components/auth/two-factor-step.tsx b/clients/dashboard/src/components/auth/two-factor-step.tsx new file mode 100644 index 0000000000..8489f684c3 --- /dev/null +++ b/clients/dashboard/src/components/auth/two-factor-step.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, type FormEvent } from "react"; +import { AlertCircle, ArrowLeft, ArrowRight, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/cn"; + +const TWO_FACTOR_CODE_LENGTH = 6; + +type TwoFactorStepProps = { + email: string; + code: string; + onCodeChange: (code: string) => void; + onSubmit: () => void; + onBack: () => void; + submitting: boolean; + error: string | null; +}; + +/** + * Second step of the sign-in card, shown after the token call answers + * `Identity.TwoFactorRequired`. Collects the 6-digit authenticator code; the + * login page owns the credentials and re-issues the token call with it. + */ +export function TwoFactorStep({ + email, + code, + onCodeChange, + onSubmit, + onBack, + submitting, + error, +}: TwoFactorStepProps) { + const inputRef = useRef(null); + + // Put the cursor back in the field after a rejected code (the page clears it). + useEffect(() => { + if (error && code === "") inputRef.current?.focus(); + }, [error, code]); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (code.length === TWO_FACTOR_CODE_LENGTH && !submitting) onSubmit(); + }; + + return ( + <> +
+

+ Two-step verification +

+

+ Enter the 6-digit code from your authenticator app to finish signing in + {email ? ( + <> + {" "}as {email} + + ) : null} + . +

+
+ +
+
+ + onCodeChange(e.target.value.replace(/\D/g, "").slice(0, TWO_FACTOR_CODE_LENGTH))} + inputMode="numeric" + autoComplete="one-time-code" + pattern="[0-9]*" + maxLength={TWO_FACTOR_CODE_LENGTH} + placeholder="123456" + autoFocus + required + aria-invalid={error ? true : undefined} + className="h-11 font-mono text-[16px] tracking-[0.4em]" + /> +
+ + {error && ( + + )} + +
+ + +
+
+ + ); +} diff --git a/clients/dashboard/src/pages/login.tsx b/clients/dashboard/src/pages/login.tsx index 92f8d04ae8..6255915c76 100644 --- a/clients/dashboard/src/pages/login.tsx +++ b/clients/dashboard/src/pages/login.tsx @@ -2,18 +2,21 @@ import { useEffect, useState, type FormEvent } from "react"; import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"; import { AlertCircle, ArrowRight, Eye, EyeOff, Loader2, Sparkles, TimerOff } from "lucide-react"; import { useAuth } from "@/auth/use-auth"; +import { isTwoFactorInvalidError, isTwoFactorRequiredError } from "@/auth/api"; import { consumeSignedOutReason } from "@/auth/inactivity"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { AuthShell } from "@/components/auth/auth-shell"; import { DemoAccountsDialog } from "@/components/auth/demo-accounts-dialog"; +import { TwoFactorStep } from "@/components/auth/two-factor-step"; import { ApiRequestError } from "@/lib/api-client"; import { cn } from "@/lib/cn"; import { env } from "@/env"; import type { DemoAccount } from "@/pages/login.demo-accounts"; type LocationState = { from?: { pathname: string } }; +type Credentials = { email: string; password: string; tenant: string }; // ──────────────────────────────────────────────────────────────────────── // Login — dentalOS "welcome back" card on rose+saffron atmospheric orbs @@ -38,6 +41,13 @@ export function LoginPage() { const [demoOpen, setDemoOpen] = useState(false); const [showPassword, setShowPassword] = useState(false); const [notice, setNotice] = useState(null); + // Second factor. When the token call answers Identity.TwoFactorRequired the + // credentials move here (component memory only — never storage) so the code + // step can repeat the call with them. Cleared on back, on success, and with + // the component on unmount. + const [pending, setPending] = useState(null); + const [code, setCode] = useState(""); + const [codeError, setCodeError] = useState(null); // Surface why the previous session ended (read-and-clear, one-shot). useEffect(() => { @@ -50,25 +60,65 @@ export function LoginPage() { return ; } - const performLogin = async (creds: { email: string; password: string; tenant: string }) => { + const errorMessage = (err: unknown) => + err instanceof ApiRequestError + ? err.problem?.detail ?? err.problem?.title ?? err.message + : err instanceof Error + ? err.message + : "Login failed"; + + const performLogin = async (creds: Credentials) => { setError(null); setSubmitting(true); try { await login(creds); navigate(from, { replace: true }); } catch (err) { - const message = - err instanceof ApiRequestError - ? err.problem?.detail ?? err.problem?.title ?? err.message - : err instanceof Error - ? err.message - : "Login failed"; - setError(message); + if (isTwoFactorRequiredError(err)) { + // Right password, 2FA on: hold the credentials for the retry and drop + // the password from the form field. + setPending(creds); + setPassword(""); + setShowPassword(false); + setCode(""); + setCodeError(null); + return; + } + setError(errorMessage(err)); } finally { setSubmitting(false); } }; + // Credentials + code ride in as one argument, read when the call is made, + // not from state a pending request could see change underneath it. + const verifyCode = async (attempt: Credentials & { twoFactorCode: string }) => { + setCodeError(null); + setSubmitting(true); + try { + await login(attempt); + setPending(null); + setCode(""); + navigate(from, { replace: true }); + } catch (err) { + setCode(""); + setCodeError( + isTwoFactorInvalidError(err) + ? "That code didn't work. Check your authenticator app and try again." + : errorMessage(err), + ); + } finally { + setSubmitting(false); + } + }; + + const onBackToPassword = () => { + setPending(null); + setCode(""); + setCodeError(null); + setError(null); + }; + const onSubmit = async (e: FormEvent) => { e.preventDefault(); await performLogin({ email, password, tenant }); @@ -86,158 +136,172 @@ export function LoginPage() { return ( <> -
-

- Welcome back -

-

- Sign in to your account -

-
- - {notice && ( -
- - {notice} -
- )} - -
- {/* Tenant — FSH stays multi-tenant, so this leads the form. */} -
- - setTenant(e.target.value)} - placeholder="root" - autoComplete="organization" - required - aria-invalid={error ? true : undefined} - className="h-11 text-[14px]" - /> -
- -
- - setEmail(e.target.value)} - placeholder="name@example.com" - autoComplete="email" - required - aria-invalid={error ? true : undefined} - className="h-11 text-[14px]" - /> -
- -
-
- - - Forgot? - + {pending ? ( + void verifyCode({ ...pending, twoFactorCode: code })} + onBack={onBackToPassword} + submitting={submitting} + error={codeError} + /> + ) : ( + <> +
+

+ Welcome back +

+

+ Sign in to your account +

-
- setPassword(e.target.value)} - placeholder="Enter your password" - autoComplete="current-password" - required - aria-invalid={error ? true : undefined} - className="h-11 pr-11 text-[14px]" - /> - -
-
- - {error && ( - - )} + + {notice} +
+ )} -
- +
+ + + {error && ( + )} - - -
- - {/* Demo accounts — runtime-gated (staging on, prod off). */} - {env.demoMode && ( -
- -
+ +
+ +
+ + + {/* Demo accounts — runtime-gated (staging on, prod off). */} + {env.demoMode && ( +
+ +
+ )} + )}
diff --git a/clients/dashboard/tests/auth/two-factor-login.spec.ts b/clients/dashboard/tests/auth/two-factor-login.spec.ts new file mode 100644 index 0000000000..8c45feb1d9 --- /dev/null +++ b/clients/dashboard/tests/auth/two-factor-login.spec.ts @@ -0,0 +1,166 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; +import { fakeJwt } from "../helpers/auth-seed"; +import { installShellMocks } from "../helpers/shell-mocks"; + +// Two-step sign-in (#1446). When the account has 2FA on, POST /token/issue +// without a code answers 401 with ProblemDetails `code: Identity.TwoFactorRequired`; +// the login card swaps to a code step and repeats the call with `twoFactorCode`. +// A wrong code answers `code: Identity.TwoFactorInvalid`. + +const VALID_CODE = "123456"; +const EMAIL = "alice@acme.com"; +const PASSWORD = "Password123!"; + +function tokenResponse() { + const now = Math.floor(Date.now() / 1000); + return { + accessToken: fakeJwt({ + sub: "u-2fa", + email: EMAIL, + given_name: "Alice", + family_name: "Nguyen", + tenant: "acme", + exp: now + 3600, + iat: now, + }), + refreshToken: "refresh", + accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), + refreshTokenExpiresAt: new Date(Date.now() + 7_200_000).toISOString(), + }; +} + +function problem(route: Route, code: string, detail: string) { + return route.fulfill({ + status: 401, + headers: { "Content-Type": "application/problem+json" }, + body: JSON.stringify({ + type: "https://httpstatuses.io/401", + title: "Unauthorized", + status: 401, + detail, + code, + }), + }); +} + +type IssueBody = { email: string; password: string; twoFactorCode?: string }; + +/** + * Mock the token endpoint as the API behaves for a user with (or without) + * 2FA enabled, recording every body the page posts. + */ +async function mockTokenIssue(page: Page, opts: { twoFactorEnabled: boolean }) { + const bodies: IssueBody[] = []; + await page.route("**/api/v1/identity/token/issue", async (route) => { + const body = JSON.parse(route.request().postData() ?? "{}") as IssueBody; + bodies.push(body); + if (!opts.twoFactorEnabled) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(tokenResponse()) }); + return; + } + if (!body.twoFactorCode) { + await problem(route, "Identity.TwoFactorRequired", "two_factor_required: An authenticator code is required to complete sign-in."); + return; + } + if (body.twoFactorCode !== VALID_CODE) { + await problem(route, "Identity.TwoFactorInvalid", "two_factor_invalid: The authenticator code is invalid or expired."); + return; + } + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(tokenResponse()) }); + }); + return bodies; +} + +test.beforeEach(async ({ page }) => { + await page.route("**/config.json", (route) => + route.fulfill({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiBase: "", defaultTenant: "acme", demoMode: false }), + }), + ); + // After sign-in the app shell boots on "/". Answer anything it fetches with + // an empty 200 so no stray 401 logs the fresh session out mid-test. + await page.route("**/api/v1/**", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await installShellMocks(page); +}); + +async function submitPassword(page: Page) { + await page.goto("/login"); + await page.getByLabel("Email").fill(EMAIL); + await page.getByLabel("Password", { exact: true }).fill(PASSWORD); + await page.getByRole("button", { name: /^sign in$/i }).click(); +} + +test.describe("login — two-factor step", () => { + test("asks for the code, then signs in with it", async ({ page }) => { + const bodies = await mockTokenIssue(page, { twoFactorEnabled: true }); + await submitPassword(page); + + await expect(page.getByRole("heading", { name: /two-step verification/i })).toBeVisible(); + const codeInput = page.getByLabel("Authentication code"); + await expect(codeInput).toBeFocused(); + await expect(codeInput).toHaveAttribute("autocomplete", "one-time-code"); + await expect(codeInput).toHaveAttribute("inputmode", "numeric"); + // No error surfaces for the expected 2FA challenge. + await expect(page.getByRole("alert")).toHaveCount(0); + + const verify = page.getByRole("button", { name: /^verify$/i }); + await expect(verify).toBeDisabled(); + await codeInput.fill(VALID_CODE); + await verify.click(); + + await expect(page).not.toHaveURL(/\/login/); + expect(bodies).toHaveLength(2); + expect(bodies[0]).toEqual({ email: EMAIL, password: PASSWORD }); + expect(bodies[1]).toEqual({ email: EMAIL, password: PASSWORD, twoFactorCode: VALID_CODE }); + + // The password never lands in browser storage. + const stored = await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage })); + expect(stored).not.toContain(PASSWORD); + }); + + test("a wrong code shows an error and stays on the code step", async ({ page }) => { + const bodies = await mockTokenIssue(page, { twoFactorEnabled: true }); + await submitPassword(page); + + const codeInput = page.getByLabel("Authentication code"); + await codeInput.fill("000000"); + await page.getByRole("button", { name: /^verify$/i }).click(); + + await expect(page.getByRole("alert")).toContainText(/that code didn't work/i); + await expect(page.getByRole("heading", { name: /two-step verification/i })).toBeVisible(); + await expect(page).toHaveURL(/\/login/); + await expect(codeInput).toHaveValue(""); + expect(bodies[1]).toMatchObject({ twoFactorCode: "000000" }); + + // A second attempt with the right code still works from here. + await codeInput.fill(VALID_CODE); + await page.getByRole("button", { name: /^verify$/i }).click(); + await expect(page).not.toHaveURL(/\/login/); + }); + + test("back returns to the password step with the password cleared", async ({ page }) => { + await mockTokenIssue(page, { twoFactorEnabled: true }); + await submitPassword(page); + + await expect(page.getByLabel("Authentication code")).toBeVisible(); + await page.getByRole("button", { name: /back to sign in/i }).click(); + + await expect(page.getByRole("heading", { name: /welcome back/i })).toBeVisible(); + await expect(page.getByLabel("Email")).toHaveValue(EMAIL); + await expect(page.getByLabel("Password", { exact: true })).toHaveValue(""); + await expect(page.getByLabel("Authentication code")).toHaveCount(0); + }); + + test("a user without 2FA still signs in in one step", async ({ page }) => { + const bodies = await mockTokenIssue(page, { twoFactorEnabled: false }); + await submitPassword(page); + + await expect(page).not.toHaveURL(/\/login/); + expect(bodies).toHaveLength(1); + expect(bodies[0]).toEqual({ email: EMAIL, password: PASSWORD }); + }); +}); diff --git a/clients/dashboard/tests/helpers/auth-seed.ts b/clients/dashboard/tests/helpers/auth-seed.ts index 3eaf4bd166..2a62982ba9 100644 --- a/clients/dashboard/tests/helpers/auth-seed.ts +++ b/clients/dashboard/tests/helpers/auth-seed.ts @@ -36,7 +36,7 @@ const TENANT_KEY = "fsh.dashboard.tenant"; * base64url-encoded JSON. Signature is a junk string — the dashboard * never validates it (only the server does), so this is safe. */ -function fakeJwt(payload: Record): string { +export function fakeJwt(payload: Record): string { const b64url = (obj: unknown) => btoa(JSON.stringify(obj)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); return [b64url({ alg: "HS256", typ: "JWT" }), b64url(payload), "sig"].join("."); diff --git a/src/Tests/Integration.Tests/Infrastructure/DetailedTestExceptionHandler.cs b/src/Tests/Integration.Tests/Infrastructure/DetailedTestExceptionHandler.cs index 6c7207dc01..c09b688125 100644 --- a/src/Tests/Integration.Tests/Infrastructure/DetailedTestExceptionHandler.cs +++ b/src/Tests/Integration.Tests/Infrastructure/DetailedTestExceptionHandler.cs @@ -43,6 +43,12 @@ public async ValueTask TryHandleAsync( problemDetails.Title = customEx.GetType().Name; problemDetails.Detail = customEx.Message; + // Mirror GlobalExceptionHandler's machine-readable code, which clients branch on. + if (customEx.MessageKey is not null) + { + problemDetails.Extensions["code"] = customEx.MessageKey; + } + if (customEx.ErrorMessages is { Count: > 0 }) { problemDetails.Extensions["errors"] = customEx.ErrorMessages; diff --git a/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs b/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs index 5ece6d65ba..f5b0970bb2 100644 --- a/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs +++ b/src/Tests/Integration.Tests/Tests/Authentication/TwoFactorAuthTests.cs @@ -71,6 +71,10 @@ public async Task Login_Should_Return401_When_TwoFactorEnabled_And_NoCodeProvide var response = await LoginAsync(email, password); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + + // Both React login pages branch on this code to show the authenticator step, + // so it is a client contract: the detail text is localized, the code is not. + (await ReadProblemCodeAsync(response)).ShouldBe("Identity.TwoFactorRequired"); } [Fact] @@ -81,6 +85,7 @@ public async Task Login_Should_Return401_When_TwoFactorEnabled_And_WrongCodeProv var response = await LoginAsync(email, password, "000000"); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + (await ReadProblemCodeAsync(response)).ShouldBe("Identity.TwoFactorInvalid"); } [Fact] @@ -222,6 +227,12 @@ private async Task WithRootTenantScopeAsync(Func, Task> act await action(userManager); } + private static async Task ReadProblemCodeAsync(HttpResponseMessage response) + { + using var problem = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return problem.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null; + } + private static async Task DeserializeAsync(HttpResponseMessage response) { var json = await response.Content.ReadAsStringAsync();