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..f911dd56c2 --- /dev/null +++ b/clients/admin/src/components/auth/two-factor-step.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +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 { t } = useTranslation("auth"); + 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 ( + <> +
+

+ {t("twoFactor.title")} +

+

+ {t("twoFactor.subtitle")} + {email ? ( + <> + {" "} + {t("twoFactor.subtitleAs")}{" "} + {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={t("twoFactor.codePlaceholder")} + autoFocus + required + aria-invalid={error ? true : undefined} + className="h-11 font-mono text-[16px] tracking-[0.4em]" + /> +
+ + {error && ( + + )} + +
+ + +
+
+ + ); +} diff --git a/clients/admin/src/locales/en-US/auth.json b/clients/admin/src/locales/en-US/auth.json index 8264c1eeaf..63e3da5b6a 100644 --- a/clients/admin/src/locales/en-US/auth.json +++ b/clients/admin/src/locales/en-US/auth.json @@ -21,6 +21,16 @@ "login.demoButton": "Sign in with a demo account", "login.footer": "fullstackhero Administration", + "twoFactor.title": "Two-step verification", + "twoFactor.subtitle": "Enter the 6-digit code from your authenticator app to finish signing in", + "twoFactor.subtitleAs": "as", + "twoFactor.code": "Authentication code", + "twoFactor.codePlaceholder": "123456", + "twoFactor.submit": "Verify", + "twoFactor.submitting": "Verifying…", + "twoFactor.back": "Back to sign in", + "twoFactor.invalidCode": "That code didn't work. Check your authenticator app and try again.", + "demo.badge": "Dev · demo", "demo.title": "Sign in as operator.", "demo.subtitle": "Tap an account below — we'll fill the credentials and sign you in instantly.", diff --git a/clients/admin/src/locales/pt-BR/auth.json b/clients/admin/src/locales/pt-BR/auth.json index bafd0a6631..76251b7627 100644 --- a/clients/admin/src/locales/pt-BR/auth.json +++ b/clients/admin/src/locales/pt-BR/auth.json @@ -19,6 +19,15 @@ "login.submitting": "Entrando…", "login.demoButton": "Entrar com uma conta de demonstração", "login.footer": "Administração do fullstackhero", + "twoFactor.title": "Verificação em duas etapas", + "twoFactor.subtitle": "Digite o código de 6 dígitos do seu aplicativo autenticador para concluir o login", + "twoFactor.subtitleAs": "como", + "twoFactor.code": "Código de autenticação", + "twoFactor.codePlaceholder": "123456", + "twoFactor.submit": "Verificar", + "twoFactor.submitting": "Verificando…", + "twoFactor.back": "Voltar para o login", + "twoFactor.invalidCode": "Esse código não funcionou. Confira seu aplicativo autenticador e tente novamente.", "demo.badge": "Dev · demo", "demo.title": "Entrar como operador.", "demo.subtitle": "Toque em uma conta abaixo — preenchemos as credenciais e entramos na hora.", diff --git a/clients/admin/src/pages/login.tsx b/clients/admin/src/pages/login.tsx index e260782155..6037e44139 100644 --- a/clients/admin/src/pages/login.tsx +++ b/clients/admin/src/pages/login.tsx @@ -12,11 +12,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"; @@ -33,6 +35,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 { t } = useTranslation("auth"); @@ -49,6 +52,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(() => { @@ -61,25 +71,61 @@ 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 + : t("login.errorFallback"); + + 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 - : t("login.errorFallback"); - 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) ? t("twoFactor.invalidCode") : 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 }); @@ -137,160 +183,174 @@ export function LoginPage() { {/* Form card */}
-
-

- {t("login.title")} -

-

- {t("login.subtitle")} -

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

+ {t("login.title")} +

+

+ {t("login.subtitle")} +

+
- {notice && ( -
- - {notice} -
- )} + {notice && ( +
+ + {notice} +
+ )} -
- {/* Tenant */} -
- - setTenant(e.target.value)} - autoComplete="organization" - placeholder={t("login.tenantPlaceholder")} - required - aria-invalid={error ? true : undefined} - className="h-11 text-[14px]" - /> -
+ {/* Tenant */} +
+ + setTenant(e.target.value)} + autoComplete="organization" + placeholder={t("login.tenantPlaceholder")} + required + aria-invalid={error ? true : undefined} + className="h-11 text-[14px]" + /> +
- {/* Email */} -
- - setEmail(e.target.value)} - autoComplete="email" - placeholder={t("login.emailPlaceholder")} - required - aria-invalid={error ? true : undefined} - className="h-11 text-[14px]" - /> -
+ {/* Email */} +
+ + setEmail(e.target.value)} + autoComplete="email" + placeholder={t("login.emailPlaceholder")} + required + aria-invalid={error ? true : undefined} + className="h-11 text-[14px]" + /> +
- {/* Password */} -
-
- - - {t("login.forgot")} - -
-
- setPassword(e.target.value)} - autoComplete="current-password" - placeholder={t("login.passwordPlaceholder")} - required - aria-invalid={error ? true : undefined} - className="h-11 pr-11 text-[14px]" - /> - -
-
+ {/* Password */} +
+
+ + + {t("login.forgot")} + +
+
+ setPassword(e.target.value)} + autoComplete="current-password" + placeholder={t("login.passwordPlaceholder")} + 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 09a38f2da5..8c0c19a122 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) => // UTF-8 bytes, like the server emits, so non-ASCII claims hit the real decode path. btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(obj)))) 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..f911dd56c2 --- /dev/null +++ b/clients/dashboard/src/components/auth/two-factor-step.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +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 { t } = useTranslation("auth"); + 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 ( + <> +
+

+ {t("twoFactor.title")} +

+

+ {t("twoFactor.subtitle")} + {email ? ( + <> + {" "} + {t("twoFactor.subtitleAs")}{" "} + {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={t("twoFactor.codePlaceholder")} + autoFocus + required + aria-invalid={error ? true : undefined} + className="h-11 font-mono text-[16px] tracking-[0.4em]" + /> +
+ + {error && ( + + )} + +
+ + +
+
+ + ); +} diff --git a/clients/dashboard/src/locales/en-US/auth.json b/clients/dashboard/src/locales/en-US/auth.json index 7b45b57ad1..4b4fa3348e 100644 --- a/clients/dashboard/src/locales/en-US/auth.json +++ b/clients/dashboard/src/locales/en-US/auth.json @@ -20,6 +20,16 @@ "login.submitting": "Signing in…", "login.demoButton": "Sign in with a demo account", + "twoFactor.title": "Two-step verification", + "twoFactor.subtitle": "Enter the 6-digit code from your authenticator app to finish signing in", + "twoFactor.subtitleAs": "as", + "twoFactor.code": "Authentication code", + "twoFactor.codePlaceholder": "123456", + "twoFactor.submit": "Verify", + "twoFactor.submitting": "Verifying…", + "twoFactor.back": "Back to sign in", + "twoFactor.invalidCode": "That code didn't work. Check your authenticator app and try again.", + "demo.dialogTitle": "Demo accounts", "demo.dialogDescription": "Pick a demo tenant and account to sign in with.", "demo.liveDemo": "Live demo", diff --git a/clients/dashboard/src/locales/pt-BR/auth.json b/clients/dashboard/src/locales/pt-BR/auth.json index 74e1a6a6f8..224cc1f60e 100644 --- a/clients/dashboard/src/locales/pt-BR/auth.json +++ b/clients/dashboard/src/locales/pt-BR/auth.json @@ -18,6 +18,15 @@ "login.submit": "Entrar", "login.submitting": "Entrando…", "login.demoButton": "Entrar com uma conta de demonstração", + "twoFactor.title": "Verificação em duas etapas", + "twoFactor.subtitle": "Digite o código de 6 dígitos do seu aplicativo autenticador para concluir o login", + "twoFactor.subtitleAs": "como", + "twoFactor.code": "Código de autenticação", + "twoFactor.codePlaceholder": "123456", + "twoFactor.submit": "Verificar", + "twoFactor.submitting": "Verificando…", + "twoFactor.back": "Voltar para o login", + "twoFactor.invalidCode": "Esse código não funcionou. Confira seu aplicativo autenticador e tente novamente.", "demo.dialogTitle": "Contas de demonstração", "demo.dialogDescription": "Escolha uma organização e uma conta de demonstração para entrar.", "demo.liveDemo": "Demonstração ao vivo", diff --git a/clients/dashboard/src/pages/login.tsx b/clients/dashboard/src/pages/login.tsx index 87b6085446..accdb64719 100644 --- a/clients/dashboard/src/pages/login.tsx +++ b/clients/dashboard/src/pages/login.tsx @@ -3,18 +3,21 @@ import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; 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 @@ -40,6 +43,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(() => { @@ -52,25 +62,61 @@ 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 + : t("login.errorFallback"); + + 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 - : t("login.errorFallback"); - 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) ? t("twoFactor.invalidCode") : 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 }); @@ -88,158 +134,172 @@ export function LoginPage() { return ( <> -
-

- {t("login.title")} -

-

- {t("login.subtitle")} -

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

+ {t("login.title")} +

+

+ {t("login.subtitle")} +

-
- setPassword(e.target.value)} - placeholder={t("login.passwordPlaceholder")} - 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 a046d2c778..26dc9f853d 100644 --- a/clients/dashboard/tests/helpers/auth-seed.ts +++ b/clients/dashboard/tests/helpers/auth-seed.ts @@ -38,7 +38,7 @@ export const TENANT_KEY = "fsh.dashboard.tenant"; * such as "Comitê de Ética" exercise the real decode path). Signature is a * junk string — the dashboard never validates it (only the server does). */ -function fakeJwt(payload: Record): string { +export function fakeJwt(payload: Record): string { const b64url = (obj: unknown) => btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(obj)))) .replace(/=+$/, "") 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();