mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(auth): require a fresh proof before a second factor is added
A browser session on its own could start TOTP enrollment or register a security key. Combined with a pending secret counting as a factor, that let a stolen session give itself a second factor, register a passkey with it, and keep access after the owner changed the password. TOTP setup and security-key registration now pass on the cookie path only when the session signed in or completed a second factor in the last five minutes, or when the body carries a proof in the step-up shape (password, a confirmed authenticator code, or a passkey or security-key assertion). The settings cards ask for that proof when the server answers auth.reproof.required and retry the same call with it. The Bearer path is unchanged: its elevation already is the proof. Every route that checks a password or a factor as a re-proof now uses one verifier and one per-account budget of five attempts per fifteen minutes, shared with the step-up mint, and records each refused proof as auth.mfa.failed. Passkey enrollment and password change had neither the limit (passkey enrollment) nor the audit row (both). A structural test freezes the set of credential-verifying routes and requires a throttle and an audit call in each.
This commit is contained in:
1 parent
e1718e3c30
commit
7b5bc130d8
27 files changed
+1763
-584
No files matched your search
+463
-414
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -5584,6 +5584,7 @@
|
||||
"passkeyReauth": {
|
||||
"title": "Bestätige deine Identität",
|
||||
"description": "Bestätige vor dem Hinzufügen eines neuen Passkeys eine vorhandene Anmeldemethode.",
|
||||
"factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
|
||||
"methodLabel": "Bestätigungsmethode",
|
||||
"methods": {
|
||||
"password": "Passwort",
|
||||
@@ -5594,7 +5595,8 @@
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"authenticatorCode": "Authenticator-Code",
|
||||
"authenticatorPlaceholder": "Sechsstelliger Authenticator-Code",
|
||||
"verifyAndAdd": "Bestätigen und hinzufügen"
|
||||
"verifyAndAdd": "Bestätigen und hinzufügen",
|
||||
"verifyAndContinue": "Bestätigen und fortfahren"
|
||||
},
|
||||
"passkeyName": "Name",
|
||||
"noPasskeys": "Keine Passkeys registriert.",
|
||||
|
||||
+3
-1
@@ -5584,6 +5584,7 @@
|
||||
"passkeyReauth": {
|
||||
"title": "Confirm it's you",
|
||||
"description": "Verify an existing sign-in method before adding a new passkey.",
|
||||
"factorDescription": "Verify an existing sign-in method before adding a second factor.",
|
||||
"methodLabel": "Verification method",
|
||||
"methods": {
|
||||
"password": "Password",
|
||||
@@ -5594,7 +5595,8 @@
|
||||
"currentPassword": "Current password",
|
||||
"authenticatorCode": "Authenticator code",
|
||||
"authenticatorPlaceholder": "6-digit authenticator code",
|
||||
"verifyAndAdd": "Verify and add"
|
||||
"verifyAndAdd": "Verify and add",
|
||||
"verifyAndContinue": "Verify and continue"
|
||||
},
|
||||
"passkeyName": "Name",
|
||||
"noPasskeys": "No passkeys registered.",
|
||||
|
||||
+3
-1
@@ -5584,6 +5584,7 @@
|
||||
"passkeyReauth": {
|
||||
"title": "Confirma tu identidad",
|
||||
"description": "Verifica un método de inicio de sesión existente antes de añadir una nueva clave de acceso.",
|
||||
"factorDescription": "Verifica un método de inicio de sesión existente antes de añadir un segundo factor.",
|
||||
"methodLabel": "Método de verificación",
|
||||
"methods": {
|
||||
"password": "Contraseña",
|
||||
@@ -5594,7 +5595,8 @@
|
||||
"currentPassword": "Contraseña actual",
|
||||
"authenticatorCode": "Código del autenticador",
|
||||
"authenticatorPlaceholder": "Código de seis dígitos del autenticador",
|
||||
"verifyAndAdd": "Verificar y añadir"
|
||||
"verifyAndAdd": "Verificar y añadir",
|
||||
"verifyAndContinue": "Verificar y continuar"
|
||||
},
|
||||
"passkeyName": "Nombre",
|
||||
"noPasskeys": "No hay claves de acceso registradas.",
|
||||
|
||||
+3
-1
@@ -5584,6 +5584,7 @@
|
||||
"passkeyReauth": {
|
||||
"title": "Confirmez votre identité",
|
||||
"description": "Vérifiez une méthode de connexion existante avant d’ajouter une nouvelle clé d’accès.",
|
||||
"factorDescription": "Vérifiez une méthode de connexion existante avant d'ajouter un second facteur.",
|
||||
"methodLabel": "Méthode de vérification",
|
||||
"methods": {
|
||||
"password": "Mot de passe",
|
||||
@@ -5594,7 +5595,8 @@
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"authenticatorCode": "Code d’authentification",
|
||||
"authenticatorPlaceholder": "Code d’authentification à 6 chiffres",
|
||||
"verifyAndAdd": "Vérifier et ajouter"
|
||||
"verifyAndAdd": "Vérifier et ajouter",
|
||||
"verifyAndContinue": "Vérifier et continuer"
|
||||
},
|
||||
"passkeyName": "Nom",
|
||||
"noPasskeys": "Aucune clé d’accès enregistrée.",
|
||||
|
||||
+3
-1
@@ -5584,6 +5584,7 @@
|
||||
"passkeyReauth": {
|
||||
"title": "Conferma la tua identità",
|
||||
"description": "Verifica un metodo di accesso esistente prima di aggiungere una nuova passkey.",
|
||||
"factorDescription": "Verifica un metodo di accesso esistente prima di aggiungere un secondo fattore.",
|
||||
"methodLabel": "Metodo di verifica",
|
||||
"methods": {
|
||||
"password": "Password",
|
||||
@@ -5594,7 +5595,8 @@
|
||||
"currentPassword": "Password attuale",
|
||||
"authenticatorCode": "Codice dell’app di autenticazione",
|
||||
"authenticatorPlaceholder": "Codice a 6 cifre dell’app di autenticazione",
|
||||
"verifyAndAdd": "Verifica e aggiungi"
|
||||
"verifyAndAdd": "Verifica e aggiungi",
|
||||
"verifyAndContinue": "Verifica e continua"
|
||||
},
|
||||
"passkeyName": "Nome",
|
||||
"noPasskeys": "Nessuna passkey registrata.",
|
||||
|
||||
+3
-1
@@ -5584,6 +5584,7 @@
|
||||
"passkeyReauth": {
|
||||
"title": "본인 확인",
|
||||
"description": "새 패스키를 추가하기 전에 기존 로그인 수단으로 확인해 주세요.",
|
||||
"factorDescription": "두 번째 인증 수단을 추가하기 전에 기존 로그인 방법을 확인하세요.",
|
||||
"methodLabel": "확인 방법",
|
||||
"methods": {
|
||||
"password": "비밀번호",
|
||||
@@ -5594,7 +5595,8 @@
|
||||
"currentPassword": "현재 비밀번호",
|
||||
"authenticatorCode": "인증 앱 코드",
|
||||
"authenticatorPlaceholder": "6자리 인증 앱 코드",
|
||||
"verifyAndAdd": "확인 후 추가"
|
||||
"verifyAndAdd": "확인 후 추가",
|
||||
"verifyAndContinue": "확인하고 계속"
|
||||
},
|
||||
"passkeyName": "이름",
|
||||
"noPasskeys": "등록된 패스키가 없어요.",
|
||||
|
||||
+3
-1
@@ -5584,6 +5584,7 @@
|
||||
"passkeyReauth": {
|
||||
"title": "Potwierdź swoją tożsamość",
|
||||
"description": "Przed dodaniem nowego klucza dostępu zweryfikuj jedną z istniejących metod logowania.",
|
||||
"factorDescription": "Potwierdź istniejącą metodę logowania, zanim dodasz drugi składnik.",
|
||||
"methodLabel": "Metoda weryfikacji",
|
||||
"methods": {
|
||||
"password": "Hasło",
|
||||
@@ -5594,7 +5595,8 @@
|
||||
"currentPassword": "Aktualne hasło",
|
||||
"authenticatorCode": "Kod z aplikacji uwierzytelniającej",
|
||||
"authenticatorPlaceholder": "6-cyfrowy kod z aplikacji uwierzytelniającej",
|
||||
"verifyAndAdd": "Zweryfikuj i dodaj"
|
||||
"verifyAndAdd": "Zweryfikuj i dodaj",
|
||||
"verifyAndContinue": "Potwierdź i kontynuuj"
|
||||
},
|
||||
"passkeyName": "Nazwa",
|
||||
"noPasskeys": "Brak zarejestrowanych kluczy dostępu.",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Every route that checks a password or a second factor is a guessing oracle,
|
||||
* and every one must be throttled and must record a refused guess.
|
||||
*
|
||||
* The passkey-enrollment re-proof verified a password and a TOTP code with
|
||||
* neither: a stolen session could guess there as fast as it could send
|
||||
* requests, and nothing in the audit log would show it. Behavioural tests pin
|
||||
* each route that was fixed; this freezes the SET, so a new route that starts
|
||||
* verifying a credential has to be listed here, and the listing asks the two
|
||||
* questions at the moment it is added.
|
||||
*
|
||||
* Matching is on the verifier's name anywhere in the file, not on an import
|
||||
* shape, so an aliased import still counts. It remains a tripwire: it proves a
|
||||
* throttle and an audit call are present in the file, not that they sit on the
|
||||
* right branch — the per-route tests do that.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const APP = join(process.cwd(), "src", "app");
|
||||
|
||||
const VERIFIER =
|
||||
/\b(verifyPassword|verifyPasswordOrDummy|verifyMfaFactor|verifyTotp|verifyMfaAuthentication|verifyAuthentication|verifyExistingFactorProof|checkCookieEnrollmentProof)\b/;
|
||||
|
||||
const THROTTLE =
|
||||
/\b(checkRateLimit|checkAuthSurfaceRateLimit|throttleReproof|checkCookieEnrollmentProof|reserveChallengeAttempt)\s*\(/;
|
||||
|
||||
const AUDIT =
|
||||
/\b(auditLog|recordReproofFailure|checkCookieEnrollmentProof)\s*\(/;
|
||||
|
||||
/**
|
||||
* Every file under src/app that verifies a credential. Adding one means
|
||||
* answering, in its own tests, how a wrong guess is limited and recorded.
|
||||
*/
|
||||
const EXPECTED = [
|
||||
"api/auth/login/route.ts",
|
||||
"api/auth/me/mfa/disable/route.ts",
|
||||
"api/auth/me/mfa/totp/confirm/route.ts",
|
||||
"api/auth/me/mfa/totp/setup/route.ts",
|
||||
"api/auth/me/mfa/webauthn/register/options/route.ts",
|
||||
"api/auth/mfa/verify/route.ts",
|
||||
"api/auth/mfa/webauthn/verify/route.ts",
|
||||
"api/auth/passkey/login-verify/route.ts",
|
||||
"api/auth/passkey/register-options/route.ts",
|
||||
"api/auth/password/route.ts",
|
||||
"api/auth/step-up/route.ts",
|
||||
];
|
||||
|
||||
function verifyingFiles(): string[] {
|
||||
return walkSourceFiles(APP, { floor: 500 })
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !/\.test\.tsx?$/.test(p))
|
||||
.filter((p) => VERIFIER.test(readFileSync(join(APP, p), "utf8")));
|
||||
}
|
||||
|
||||
describe("credential-verifying routes", () => {
|
||||
const found = verifyingFiles();
|
||||
|
||||
it("finds the verifying routes at all", () => {
|
||||
// An empty match agrees with nothing and would pass on a tree never read.
|
||||
expect(found.length).toBeGreaterThanOrEqual(EXPECTED.length);
|
||||
});
|
||||
|
||||
it("is exactly the listed set", () => {
|
||||
expect(found).toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it.each(EXPECTED)("%s is throttled and audits refusals", (rel) => {
|
||||
const src = readFileSync(join(APP, rel), "utf8");
|
||||
expect(src).toMatch(THROTTLE);
|
||||
expect(src).toMatch(AUDIT);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,14 @@
|
||||
*
|
||||
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
|
||||
* presenting a single-use step-up elevation minted against a re-proved factor.
|
||||
* A token on its own can still never enrol MFA. The recovery-code batch is
|
||||
* A token on its own can still never enrol MFA.
|
||||
*
|
||||
* A cookie session on its own cannot either. Enrolling hands the caller a
|
||||
* factor that later satisfies every step-up gate, so a stolen session must not
|
||||
* be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof`
|
||||
* — a sign-in or second factor inside the last five minutes, or a password /
|
||||
* factor proof in the body (the step-up mint's shapes). The Bearer arm already
|
||||
* carries that proof in its elevation. The recovery-code batch is
|
||||
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
|
||||
* setup never persists codes.
|
||||
*/
|
||||
@@ -26,6 +33,7 @@ import { prisma } from "@/lib/db";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
|
||||
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -57,6 +65,17 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
throw new HttpError(409, "A second factor is already active");
|
||||
}
|
||||
|
||||
if (auth.transport === "cookie") {
|
||||
const refusal = await checkCookieEnrollmentProof({
|
||||
user,
|
||||
sessionId: auth.session.id,
|
||||
request: req,
|
||||
ipAddress: getClientIp(req),
|
||||
stage: "totp_enroll",
|
||||
});
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
|
||||
// Rate limit and the already-active check have passed; the write is next.
|
||||
await auth.commitElevation();
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/existing-factor-proof", () => ({
|
||||
checkCookieEnrollmentProof: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST as REGISTER_OPTIONS } from "../options/route";
|
||||
import { POST as REGISTER_VERIFY } from "../verify/route";
|
||||
import { requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
createMfaRegistrationOptions,
|
||||
verifyMfaRegistration,
|
||||
@@ -63,8 +68,16 @@ beforeEach(() => {
|
||||
commitElevation: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(checkCookieEnrollmentProof).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function optionsRequest(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/auth/me/mfa/webauthn/register/options",
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
|
||||
it("returns ceremony options + challenge id", async () => {
|
||||
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
|
||||
@@ -72,7 +85,7 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
|
||||
challengeId: "ch-1",
|
||||
} as never);
|
||||
|
||||
const res = await REGISTER_OPTIONS();
|
||||
const res = await REGISTER_OPTIONS(optionsRequest());
|
||||
const body = (await res.json()) as {
|
||||
data: { options: unknown; challengeId: string };
|
||||
};
|
||||
@@ -86,6 +99,40 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("security-key enrollment needs a fresh proof on the cookie arm", () => {
|
||||
it("stops before any challenge exists when the session cannot show one", async () => {
|
||||
vi.mocked(checkCookieEnrollmentProof).mockResolvedValue(
|
||||
new Response(null, { status: 401 }),
|
||||
);
|
||||
const res = await REGISTER_OPTIONS(optionsRequest());
|
||||
expect(res.status).toBe(401);
|
||||
expect(createMfaRegistrationOptions).not.toHaveBeenCalled();
|
||||
expect(checkCookieEnrollmentProof).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-1",
|
||||
stage: "security_key_enroll",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the Bearer arm to its elevation", async () => {
|
||||
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
|
||||
transport: "bearer",
|
||||
user: USER,
|
||||
apiTokenId: "tok-1",
|
||||
accessTokenHash: "h",
|
||||
commitElevation: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
|
||||
options: {},
|
||||
challengeId: "ch-2",
|
||||
} as never);
|
||||
const res = await REGISTER_OPTIONS(optionsRequest());
|
||||
expect(res.status).toBe(200);
|
||||
expect(checkCookieEnrollmentProof).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/mfa/webauthn/register/verify", () => {
|
||||
const credential = {
|
||||
id: "cred-1",
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
* session or a Bearer token presenting a single-use step-up elevation; a token
|
||||
* on its own can never enrol MFA. Returns the SimpleWebAuthn creation options +
|
||||
* the server-issued challenge id to present back at /register/verify.
|
||||
*
|
||||
* The cookie arm additionally passes `checkCookieEnrollmentProof`: a session
|
||||
* alone cannot add a security key, for the same reason it cannot enroll TOTP —
|
||||
* the new key would satisfy every step-up gate from then on.
|
||||
*/
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const auth = await requireMfaManagementAuth();
|
||||
const { user } = auth;
|
||||
|
||||
@@ -31,6 +36,17 @@ export const POST = apiHandler(async () => {
|
||||
return res;
|
||||
}
|
||||
|
||||
if (auth.transport === "cookie") {
|
||||
const refusal = await checkCookieEnrollmentProof({
|
||||
user,
|
||||
sessionId: auth.session.id,
|
||||
request: req,
|
||||
ipAddress: getClientIp(req),
|
||||
stage: "security_key_enroll",
|
||||
});
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
|
||||
await auth.commitElevation();
|
||||
|
||||
const { options, challengeId } = await createMfaRegistrationOptions(
|
||||
|
||||
@@ -2,16 +2,20 @@ import { NextRequest } from "next/server";
|
||||
import {
|
||||
createAuthenticationOptions,
|
||||
createRegistrationOptions,
|
||||
verifyAuthentication,
|
||||
} from "@/lib/auth/passkey";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import {
|
||||
createMfaAuthenticationOptions,
|
||||
verifyMfaAuthentication,
|
||||
} from "@/lib/auth/mfa/webauthn";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
recordReproofFailure,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { stepUpMintSchema } from "@/lib/validations/step-up";
|
||||
@@ -51,50 +55,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) return proofRequired();
|
||||
|
||||
let proved = false;
|
||||
// Every proof counts against the account's shared re-proof bucket and every
|
||||
// refusal is audited. Without both, a stolen session could guess the account
|
||||
// password or a TOTP code here as fast as it could send requests.
|
||||
const ip = getClientIp(request);
|
||||
const limited = await throttleReproof(user.id, ip, "passkey_enroll");
|
||||
if (limited) return limited;
|
||||
|
||||
if (parsed.data.method === "password") {
|
||||
proved = Boolean(
|
||||
user.passwordHash &&
|
||||
(await verifyPassword(user.passwordHash, parsed.data.password)),
|
||||
const outcome = await verifyExistingFactorProof(user, parsed.data);
|
||||
if (!outcome.ok) {
|
||||
await recordReproofFailure(
|
||||
user.id,
|
||||
ip,
|
||||
"passkey_enroll",
|
||||
parsed.data.method,
|
||||
outcome.reason,
|
||||
);
|
||||
} else if (parsed.data.method === "totp") {
|
||||
const result = await verifyMfaFactor(user, "totp", parsed.data.code);
|
||||
proved = result.ok;
|
||||
} else {
|
||||
const expectedType =
|
||||
parsed.data.method === "passkey"
|
||||
? "authentication"
|
||||
: "mfa_authentication";
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: parsed.data.challengeId },
|
||||
select: { userId: true, type: true },
|
||||
});
|
||||
|
||||
if (challenge?.userId === user.id && challenge.type === expectedType) {
|
||||
try {
|
||||
if (parsed.data.method === "passkey") {
|
||||
const result = await verifyAuthentication(
|
||||
parsed.data.challengeId,
|
||||
parsed.data.credential,
|
||||
);
|
||||
proved =
|
||||
result.verification.verified && result.passkey.userId === user.id;
|
||||
} else {
|
||||
proved = await verifyMfaAuthentication(
|
||||
parsed.data.challengeId,
|
||||
user.id,
|
||||
parsed.data.credential,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
proved = false;
|
||||
}
|
||||
}
|
||||
return proofRequired();
|
||||
}
|
||||
|
||||
if (!proved) return proofRequired();
|
||||
|
||||
// A strong existing factor refreshes the session's MFA stamp. Password proof
|
||||
// deliberately clears it: registration is authorized by this single-use,
|
||||
// session-bound challenge but cannot upgrade password-only authentication.
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
recordReproofFailure,
|
||||
throttleReproof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfaIfEnrolled,
|
||||
@@ -35,14 +38,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// contract. Mirrors the step-up on MFA-disable + recovery-code regen.
|
||||
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`auth:password:${user.id}`,
|
||||
5,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429);
|
||||
}
|
||||
// The current-password check below is a guessing oracle, so it draws on the
|
||||
// account's shared re-proof budget: a guess spent here is one the step-up
|
||||
// mint and the enrollment proofs cannot spend again.
|
||||
const ip = getClientIp(request);
|
||||
const limited = await throttleReproof(user.id, ip, "password_change");
|
||||
if (limited) return limited;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
@@ -62,6 +63,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
|
||||
if (!currentValid) {
|
||||
await recordReproofFailure(
|
||||
user.id,
|
||||
ip,
|
||||
"password_change",
|
||||
"password",
|
||||
"bad_password",
|
||||
);
|
||||
return apiError("Current password is incorrect", 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,16 +40,17 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import {
|
||||
REPROOF_LIMIT,
|
||||
REPROOF_WINDOW_MS,
|
||||
reproofBucket,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
mintStepUpElevation,
|
||||
isFreshFactorMethod,
|
||||
@@ -65,8 +66,8 @@ export const dynamic = "force-dynamic";
|
||||
* against a user-supplied string — the same 5-per-15-minutes the password-change
|
||||
* route uses, for the same reason.
|
||||
*/
|
||||
const MINT_LIMIT = 5;
|
||||
const MINT_WINDOW_MS = 15 * 60 * 1000;
|
||||
const MINT_LIMIT = REPROOF_LIMIT;
|
||||
const MINT_WINDOW_MS = REPROOF_WINDOW_MS;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireBearerAuth();
|
||||
@@ -91,8 +92,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// The account's shared re-proof bucket: a guess spent here is a guess the
|
||||
// web enrollment surfaces cannot spend again, and the other way round.
|
||||
const userRl = await checkRateLimit(
|
||||
`auth:step-up:${user.id}`,
|
||||
reproofBucket(user.id),
|
||||
MINT_LIMIT,
|
||||
MINT_WINDOW_MS,
|
||||
);
|
||||
@@ -122,78 +125,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const method: StepUpMethod = parsed.data.method;
|
||||
let proved = false;
|
||||
// The shared verifier: the same challenge ownership and ceremony checks, and
|
||||
// the same confirmed-secret rule for TOTP, as every other re-proof surface.
|
||||
// The replay guard and the accepted-step burn behave exactly as they do at
|
||||
// login and at MFA-disable, so a code spent here cannot be replayed there.
|
||||
const outcome = await verifyExistingFactorProof(user, parsed.data);
|
||||
const proved = outcome.ok;
|
||||
// Audit-only detail. Never reaches the response — see the file header.
|
||||
let failure = "invalid";
|
||||
|
||||
/**
|
||||
* Resolve a WebAuthn challenge and confirm it belongs to THIS account before
|
||||
* any verification runs. Both ceremony helpers resolve a challenge by id
|
||||
* alone, so without this a challenge minted for another account could be
|
||||
* carried into the ceremony.
|
||||
*/
|
||||
const challengeBelongsToCaller = async (id: string): Promise<boolean> => {
|
||||
const row = await prisma.authChallenge.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
return Boolean(row && row.userId === user.id);
|
||||
};
|
||||
|
||||
if (parsed.data.method === "password") {
|
||||
if (!user.passwordHash) {
|
||||
// An SSO-provisioned account has no password to re-prove. Same refusal as
|
||||
// a wrong one: the account's credential shape is not a token holder's to
|
||||
// enumerate. `/api/auth/step-up/options` is the honest discovery path —
|
||||
// it 409s when there is no credential of the requested kind either.
|
||||
failure = "no_password";
|
||||
} else {
|
||||
proved = await verifyPassword(user.passwordHash, parsed.data.password);
|
||||
if (!proved) failure = "bad_password";
|
||||
}
|
||||
} else if (parsed.data.method === "totp") {
|
||||
// The shared factor verifier, so the replay guard and the accepted-step
|
||||
// burn behave exactly as they do at login and at MFA-disable. A code spent
|
||||
// here cannot be replayed there.
|
||||
const result = await verifyMfaFactor(user, "totp", parsed.data.code);
|
||||
proved = result.ok;
|
||||
if (!proved) failure = result.replay ? "totp_replay" : "bad_totp";
|
||||
} else if (parsed.data.method === "webauthn") {
|
||||
if (!(await challengeBelongsToCaller(parsed.data.challengeId))) {
|
||||
failure = "foreign_challenge";
|
||||
} else {
|
||||
try {
|
||||
// Scoped to the caller's own second-factor credentials by the verifier.
|
||||
proved = await verifyMfaAuthentication(
|
||||
parsed.data.challengeId,
|
||||
user.id,
|
||||
parsed.data.credential,
|
||||
);
|
||||
if (!proved) failure = "bad_assertion";
|
||||
} catch {
|
||||
// A malformed / expired challenge is a failed attempt, not a 500.
|
||||
failure = "bad_assertion";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!(await challengeBelongsToCaller(parsed.data.challengeId))) {
|
||||
failure = "foreign_challenge";
|
||||
} else {
|
||||
try {
|
||||
const result = await verifyAuthentication(
|
||||
parsed.data.challengeId,
|
||||
parsed.data.credential,
|
||||
);
|
||||
// Both halves matter: a verified assertion against SOMEONE ELSE'S
|
||||
// passkey proves possession of a factor, just not of this account's.
|
||||
proved =
|
||||
result.verification.verified && result.passkey.userId === user.id;
|
||||
if (!proved) failure = "bad_assertion";
|
||||
} catch {
|
||||
failure = "bad_assertion";
|
||||
}
|
||||
}
|
||||
}
|
||||
const failure = outcome.ok ? "" : outcome.reason;
|
||||
|
||||
if (!proved) {
|
||||
await auditLog("auth.stepup.mint.failed", {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* TOTP and security-key enrollment ask for a fresh proof when the server says
|
||||
* so (401 `auth.reproof.required`), and retry the SAME enrollment call with it.
|
||||
*
|
||||
* Source-oriented like the passkey reauth test next door: the repository keeps
|
||||
* component tests free of a browser DOM harness. The behavioural half — that
|
||||
* the server refuses without the proof — is pinned against Postgres in
|
||||
* `tests/integration/credential-enrollment-proof.test.ts`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { isReproofRequired } from "../existing-factor-reauth-dialog";
|
||||
|
||||
const read = (file: string) =>
|
||||
readFileSync(resolve(__dirname, "..", file), "utf8");
|
||||
|
||||
describe("isReproofRequired", () => {
|
||||
it("recognises only the re-proof refusal", () => {
|
||||
expect(
|
||||
isReproofRequired(
|
||||
new ApiError("x", 401, { errorCode: "auth.reproof.required" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReproofRequired(
|
||||
new ApiError("x", 401, { errorCode: "auth.reproof.failed" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReproofRequired(
|
||||
new ApiError("x", 401, { errorCode: "auth.stepup.required" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isReproofRequired(new Error("x"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["totp-card.tsx", '"/api/auth/me/mfa/totp/setup"', "beginSetup"],
|
||||
[
|
||||
"security-keys-card.tsx",
|
||||
'"/api/auth/me/mfa/webauthn/register/options"',
|
||||
"add",
|
||||
],
|
||||
])("%s", (file, endpoint, mutation) => {
|
||||
const source = read(file);
|
||||
|
||||
it("sends the collected proof on the enrollment call itself", () => {
|
||||
expect(source).toContain(endpoint);
|
||||
expect(source).toMatch(
|
||||
new RegExp(`${endpoint.replace(/[/.]/g, "\\$&")},\\s*proof\\)`),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the re-proof dialog on the server's cue and retries with the proof", () => {
|
||||
expect(source).toContain("isReproofRequired(err)");
|
||||
expect(source).toContain("setReauthOpen(true)");
|
||||
expect(source).toContain(`onProof={(proof) => ${mutation}.mutate(proof)}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn";
|
||||
|
||||
/** The body the enrollment routes accept as a fresh existing-factor proof. */
|
||||
export type ExistingFactorProof =
|
||||
| { method: "password"; password: string }
|
||||
| { method: "totp"; code: string }
|
||||
| {
|
||||
method: "passkey" | "webauthn";
|
||||
challengeId: string;
|
||||
credential: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* The server answers an enrollment it will not take on the session alone with
|
||||
* 401 `auth.reproof.required`. That is the cue to ask for a proof and retry,
|
||||
* not an error to show.
|
||||
*/
|
||||
export function isReproofRequired(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof ApiError &&
|
||||
err.status === 401 &&
|
||||
err.meta?.errorCode === "auth.reproof.required"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a fresh proof of a credential the account already holds, before a
|
||||
* new factor is added. A passkey or security-key assertion begins at the
|
||||
* cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues
|
||||
* an assertion challenge and nothing else; the resulting proof is handed to
|
||||
* `onProof`, which retries the enrollment call with it.
|
||||
*/
|
||||
export function ExistingFactorReauthDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
methods,
|
||||
pending,
|
||||
error,
|
||||
onProof,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
methods: ReauthMethod[];
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onProof: (proof: ExistingFactorProof) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [method, setMethod] = useState<ReauthMethod>(methods[0] ?? "password");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [assertionError, setAssertionError] = useState<string | null>(null);
|
||||
const [asserting, setAsserting] = useState(false);
|
||||
const busy = pending || asserting;
|
||||
const shown = assertionError ?? error;
|
||||
|
||||
async function submit() {
|
||||
setAssertionError(null);
|
||||
if (method === "password") {
|
||||
onProof({ method, password: secret });
|
||||
return;
|
||||
}
|
||||
if (method === "totp") {
|
||||
onProof({ method, code: secret });
|
||||
return;
|
||||
}
|
||||
setAsserting(true);
|
||||
try {
|
||||
const webauthn = await import("@simplewebauthn/browser");
|
||||
const begun = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startAuthentication
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", { method });
|
||||
const credential = await webauthn.startAuthentication({
|
||||
optionsJSON: begun.options,
|
||||
});
|
||||
onProof({ method, challengeId: begun.challengeId, credential });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setAssertionError(err.message);
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setAssertionError(t(key, params));
|
||||
}
|
||||
} finally {
|
||||
setAsserting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labels: Record<ReauthMethod, string> = {
|
||||
password: t("settings.passkeyReauth.methods.password"),
|
||||
totp: t("settings.passkeyReauth.methods.totp"),
|
||||
passkey: t("settings.passkeyReauth.methods.passkey"),
|
||||
webauthn: t("settings.passkeyReauth.methods.webauthn"),
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (busy) return;
|
||||
if (!next) {
|
||||
setSecret("");
|
||||
setAssertionError(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent data-testid="factor-reauth-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.passkeyReauth.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.passkeyReauth.factorDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{methods.length > 1 && (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2"
|
||||
role="group"
|
||||
aria-label={t("settings.passkeyReauth.methodLabel")}
|
||||
>
|
||||
{methods.map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
type="button"
|
||||
variant={method === m ? "secondary" : "outline"}
|
||||
aria-pressed={method === m}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMethod(m);
|
||||
setSecret("");
|
||||
setAssertionError(null);
|
||||
}}
|
||||
>
|
||||
{labels[m]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{method === "password" && (
|
||||
<Input
|
||||
type="password"
|
||||
data-testid="factor-reauth-password"
|
||||
autoComplete="current-password"
|
||||
value={secret}
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
placeholder={t("settings.passkeyReauth.currentPassword")}
|
||||
aria-label={t("settings.passkeyReauth.currentPassword")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{method === "totp" && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={secret}
|
||||
onChange={(event) =>
|
||||
setSecret(event.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
placeholder={t("settings.passkeyReauth.authenticatorPlaceholder")}
|
||||
aria-label={t("settings.passkeyReauth.authenticatorCode")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{shown && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{shown}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="factor-reauth-submit"
|
||||
disabled={
|
||||
busy ||
|
||||
((method === "password" || method === "totp") &&
|
||||
secret.length === 0)
|
||||
}
|
||||
aria-busy={busy || undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.passkeyReauth.verifyAndContinue")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,10 @@ export function SecuritySection() {
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
<SecurityKeysCard
|
||||
keys={status.webauthn}
|
||||
totpEnabled={status.totp.enabled}
|
||||
/>
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,12 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
export interface WebauthnKeyInfo {
|
||||
id: string;
|
||||
@@ -74,20 +80,38 @@ export function describeStepUp(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
export function SecurityKeysCard({
|
||||
keys,
|
||||
totpEnabled = false,
|
||||
}: {
|
||||
keys: WebauthnKeyInfo[];
|
||||
totpEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
// Adding a key right after signing in goes straight through; later, the
|
||||
// server asks for a fresh proof first (401 `auth.reproof.required`) and the
|
||||
// options call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
const reauthMethods: ReauthMethod[] = [
|
||||
"password",
|
||||
...(totpEnabled ? (["totp"] as const) : []),
|
||||
"passkey",
|
||||
...(keys.length > 0 ? (["webauthn"] as const) : []),
|
||||
];
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (proof?: ExistingFactorProof) => {
|
||||
const { startRegistration } = await import("@simplewebauthn/browser");
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startRegistration>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/me/mfa/webauthn/register/options");
|
||||
}>("/api/auth/me/mfa/webauthn/register/options", proof);
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
await apiPost("/api/auth/me/mfa/webauthn/register/verify", {
|
||||
challengeId,
|
||||
@@ -96,14 +120,28 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setReauthOpen(false);
|
||||
setReauthError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
return;
|
||||
}
|
||||
let message: string;
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message || t("settings.security.keys.addFailed"));
|
||||
message = err.message || t("settings.security.keys.addFailed");
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
message = t(key, params);
|
||||
}
|
||||
if (proof) {
|
||||
setReauthError(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -294,7 +332,7 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => add.mutate()}
|
||||
onClick={() => add.mutate(undefined)}
|
||||
disabled={add.isPending}
|
||||
>
|
||||
{add.isPending ? (
|
||||
@@ -305,6 +343,17 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
|
||||
{t("settings.security.keys.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<ExistingFactorReauthDialog
|
||||
open={reauthOpen}
|
||||
onOpenChange={(open) => {
|
||||
setReauthOpen(open);
|
||||
if (!open) setReauthError(null);
|
||||
}}
|
||||
methods={reauthMethods}
|
||||
pending={add.isPending}
|
||||
error={reauthError}
|
||||
onProof={(proof) => add.mutate(proof)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
|
||||
@@ -33,6 +33,11 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
interface SetupData {
|
||||
otpauthUri: string;
|
||||
@@ -118,12 +123,20 @@ export function TotpCard({
|
||||
setError(null);
|
||||
}
|
||||
|
||||
// Enrollment right after signing in goes straight through; later, the server
|
||||
// asks for a fresh proof first (401 `auth.reproof.required`), and the setup
|
||||
// call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
|
||||
const beginSetup = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup");
|
||||
mutationFn: async (proof?: ExistingFactorProof) => {
|
||||
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup", proof);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
setError(null);
|
||||
setReauthOpen(false);
|
||||
setReauthError(null);
|
||||
setSetup(data);
|
||||
try {
|
||||
const QRCode = (await import("qrcode")).default;
|
||||
@@ -133,14 +146,21 @@ export function TotpCard({
|
||||
setQrDataUrl(null);
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.totp.setupFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
return;
|
||||
}
|
||||
const message = describeError(
|
||||
err,
|
||||
t("settings.security.totp.setupFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
);
|
||||
if (proof) setReauthError(message);
|
||||
else setError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const confirm = useMutation({
|
||||
@@ -241,7 +261,7 @@ export function TotpCard({
|
||||
type="button"
|
||||
data-testid="totp-setup-start"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => beginSetup.mutate()}
|
||||
onClick={() => beginSetup.mutate(undefined)}
|
||||
disabled={beginSetup.isPending}
|
||||
>
|
||||
{beginSetup.isPending ? (
|
||||
@@ -252,6 +272,17 @@ export function TotpCard({
|
||||
{t("settings.security.totp.setUp")}
|
||||
</Button>
|
||||
)}
|
||||
<ExistingFactorReauthDialog
|
||||
open={reauthOpen}
|
||||
onOpenChange={(open) => {
|
||||
setReauthOpen(open);
|
||||
if (!open) setReauthError(null);
|
||||
}}
|
||||
methods={["password", "passkey", "webauthn"]}
|
||||
pending={beginSetup.isPending}
|
||||
error={reauthError}
|
||||
onProof={(proof) => beginSetup.mutate(proof)}
|
||||
/>
|
||||
|
||||
{/* ── Enable wizard ── */}
|
||||
{setup && (
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Re-proving a credential the account already holds.
|
||||
*
|
||||
* Several surfaces accept a fresh proof in the request body before they act:
|
||||
* the Bearer step-up mint, the primary-passkey enrollment, TOTP enrollment and
|
||||
* security-key enrollment. They used to verify it each in their own way, and
|
||||
* the copies drifted — one checked who owned a WebAuthn challenge but not its
|
||||
* type, one had no rate limit and wrote no audit row for a wrong password, so
|
||||
* a stolen session could guess the password or a TOTP code there without
|
||||
* limit and without trace. This module is the one verifier and the one
|
||||
* throttle, so every surface refuses, counts and records a failed proof the
|
||||
* same way.
|
||||
*
|
||||
* The throttle is ONE bucket per account shared by every surface: five proofs
|
||||
* in fifteen minutes, the ceiling the step-up mint has used since it shipped
|
||||
* (same key, so its counter carries straight over). A shared bucket means a
|
||||
* guesser cannot multiply the budget by rotating between routes.
|
||||
*/
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
|
||||
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
|
||||
import {
|
||||
stepUpMintSchema,
|
||||
type StepUpMintRequest,
|
||||
} from "@/lib/validations/step-up";
|
||||
|
||||
export type ExistingFactorProof = StepUpMintRequest;
|
||||
|
||||
export const REPROOF_LIMIT = 5;
|
||||
export const REPROOF_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/** The per-account bucket every re-proof surface draws from. */
|
||||
export function reproofBucket(userId: string): string {
|
||||
return `auth:step-up:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge one attempt to the account's re-proof bucket. Returns the 429 to send
|
||||
* when the bucket is empty (and writes the audit row), or null to proceed.
|
||||
* Call it only when a proof is actually about to be verified, so a request that
|
||||
* needs no proof does not spend one.
|
||||
*/
|
||||
export async function throttleReproof(
|
||||
userId: string,
|
||||
ipAddress: string | null,
|
||||
stage: string,
|
||||
): Promise<Response | null> {
|
||||
const rl = await checkRateLimit(
|
||||
reproofBucket(userId),
|
||||
REPROOF_LIMIT,
|
||||
REPROOF_WINDOW_MS,
|
||||
);
|
||||
if (rl.allowed) return null;
|
||||
await auditLog("auth.reproof.rate_limited", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: { stage },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.rate_limited" },
|
||||
meta: { stage },
|
||||
});
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
export type ProofOutcome = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Verify a proof against the account. Never throws for a bad proof: a
|
||||
* malformed or expired WebAuthn challenge is a failed attempt, not a 500.
|
||||
*
|
||||
* A WebAuthn challenge must belong to this account AND be of the ceremony the
|
||||
* method names, because both ceremony helpers resolve a challenge by id alone.
|
||||
* A TOTP code is accepted only from a confirmed secret (`verifyMfaFactor`).
|
||||
*/
|
||||
export async function verifyExistingFactorProof(
|
||||
user: Pick<
|
||||
User,
|
||||
| "id"
|
||||
| "passwordHash"
|
||||
| "totpSecretEncrypted"
|
||||
| "totpLastStep"
|
||||
| "totpConfirmedAt"
|
||||
>,
|
||||
proof: ExistingFactorProof,
|
||||
): Promise<ProofOutcome> {
|
||||
if (proof.method === "password") {
|
||||
if (!user.passwordHash) return { ok: false, reason: "no_password" };
|
||||
const ok = await verifyPassword(user.passwordHash, proof.password);
|
||||
return ok ? { ok } : { ok: false, reason: "bad_password" };
|
||||
}
|
||||
|
||||
if (proof.method === "totp") {
|
||||
const result = await verifyMfaFactor(user, "totp", proof.code);
|
||||
if (result.ok) return { ok: true };
|
||||
return { ok: false, reason: result.replay ? "totp_replay" : "bad_totp" };
|
||||
}
|
||||
|
||||
const expectedType =
|
||||
proof.method === "passkey" ? "authentication" : "mfa_authentication";
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: proof.challengeId },
|
||||
select: { userId: true, type: true },
|
||||
});
|
||||
if (!challenge || challenge.userId !== user.id) {
|
||||
return { ok: false, reason: "foreign_challenge" };
|
||||
}
|
||||
if (challenge.type !== expectedType) {
|
||||
return { ok: false, reason: "wrong_ceremony" };
|
||||
}
|
||||
|
||||
try {
|
||||
if (proof.method === "passkey") {
|
||||
const result = await verifyAuthentication(
|
||||
proof.challengeId,
|
||||
proof.credential,
|
||||
);
|
||||
// Both halves matter: a verified assertion against SOMEONE ELSE'S passkey
|
||||
// proves possession of a factor, just not of this account's.
|
||||
const ok =
|
||||
result.verification.verified && result.passkey.userId === user.id;
|
||||
return ok ? { ok } : { ok: false, reason: "bad_assertion" };
|
||||
}
|
||||
const ok = await verifyMfaAuthentication(
|
||||
proof.challengeId,
|
||||
user.id,
|
||||
proof.credential,
|
||||
);
|
||||
return ok ? { ok } : { ok: false, reason: "bad_assertion" };
|
||||
} catch {
|
||||
return { ok: false, reason: "bad_assertion" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a refused proof. The machine reason goes to the audit row and the
|
||||
* wide event; the wire carries none of it.
|
||||
*/
|
||||
export async function recordReproofFailure(
|
||||
userId: string,
|
||||
ipAddress: string | null,
|
||||
stage: string,
|
||||
method: ExistingFactorProof["method"],
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: { stage, method, reason },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.failed" },
|
||||
meta: { stage, method, reason },
|
||||
});
|
||||
}
|
||||
|
||||
/** No proof was presented, and the session carries none recent enough. */
|
||||
export const REPROOF_REQUIRED_CODE = "auth.reproof.required";
|
||||
/** A proof was presented and did not verify. The session is untouched. */
|
||||
export const REPROOF_FAILED_CODE = "auth.reproof.failed";
|
||||
|
||||
/**
|
||||
* How recent a sign-in or a second-factor stamp must be to stand in for a
|
||||
* proof in the body. The step-up window, so a fresh login is exactly as good
|
||||
* here as it is on the fresh-factor routes.
|
||||
*/
|
||||
export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The cookie-path gate in front of ADDING a sign-in credential or second
|
||||
* factor to an account (TOTP enrollment, security-key enrollment).
|
||||
*
|
||||
* A live session on its own is not enough. If it were, whoever held a stolen
|
||||
* session could enroll their own authenticator, and from then on hold a factor
|
||||
* that satisfies every step-up gate, registers a passkey and survives the
|
||||
* owner changing the password. So the caller must show that the person at the
|
||||
* keyboard can authenticate as the account owner right now, in one of three
|
||||
* ways:
|
||||
*
|
||||
* - the session completed a second factor or passkey sign-in within the last
|
||||
* five minutes (`Session.mfaVerifiedAt`);
|
||||
* - the session itself was created by a sign-in within the last five
|
||||
* minutes (`Session.createdAt`, which no later write touches) — this is
|
||||
* also what lets an SSO-only account with nothing else to re-prove enroll
|
||||
* right after signing in;
|
||||
* - the body carries a proof: `{ method: "password", password }`,
|
||||
* `{ method: "totp", code }` or a passkey / security-key assertion, the
|
||||
* same shapes the step-up mint accepts.
|
||||
*
|
||||
* Returns the refusal to send, or null to proceed. Proofs in the body are
|
||||
* throttled and audited through the shared re-proof bucket.
|
||||
*/
|
||||
export async function checkCookieEnrollmentProof(args: {
|
||||
user: User;
|
||||
sessionId: string;
|
||||
request: Request;
|
||||
ipAddress: string | null;
|
||||
stage: string;
|
||||
}): Promise<Response | null> {
|
||||
const { user, sessionId, request, ipAddress, stage } = args;
|
||||
|
||||
const row = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { mfaVerifiedAt: true, createdAt: true },
|
||||
});
|
||||
const now = Date.now();
|
||||
const recent = (d: Date | null | undefined) =>
|
||||
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
|
||||
if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) {
|
||||
annotate({ meta: { enrollment_proof: "recent_session" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
const proof = await readProof(request);
|
||||
if (proof === "absent") {
|
||||
annotate({ action: { name: "auth.reproof.required" }, meta: { stage } });
|
||||
return apiError("Confirm it is you before adding a sign-in method", 401, {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
});
|
||||
}
|
||||
if (proof === "invalid") {
|
||||
return apiError("Invalid verification", 422, {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
});
|
||||
}
|
||||
|
||||
const limited = await throttleReproof(user.id, ipAddress, stage);
|
||||
if (limited) return limited;
|
||||
|
||||
const outcome = await verifyExistingFactorProof(user, proof);
|
||||
if (!outcome.ok) {
|
||||
await recordReproofFailure(
|
||||
user.id,
|
||||
ipAddress,
|
||||
stage,
|
||||
proof.method,
|
||||
outcome.reason,
|
||||
);
|
||||
return apiError("Verification failed", 401, {
|
||||
errorCode: REPROOF_FAILED_CODE,
|
||||
});
|
||||
}
|
||||
annotate({ meta: { enrollment_proof: proof.method } });
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readProof(
|
||||
request: Request,
|
||||
): Promise<ExistingFactorProof | "absent" | "invalid"> {
|
||||
if (!request.headers.get("content-type")?.includes("application/json")) {
|
||||
return "absent";
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await request.text();
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
if (raw.trim() === "") return "absent";
|
||||
if (raw.length > 64 * 1024) return "invalid";
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
if (
|
||||
body === null ||
|
||||
(typeof body === "object" && Object.keys(body).length === 0)
|
||||
) {
|
||||
return "absent";
|
||||
}
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
return parsed.success ? parsed.data : "invalid";
|
||||
}
|
||||
@@ -60,6 +60,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"auth.refresh.invalid",
|
||||
"auth.refresh.reuse",
|
||||
"auth.refresh.revoked",
|
||||
"auth.reproof.failed",
|
||||
"auth.reproof.required",
|
||||
"auth.scope.insufficient",
|
||||
"auth.stepup.required",
|
||||
"auth.token.expired",
|
||||
|
||||
@@ -315,6 +315,17 @@ const successFlagResponse = z
|
||||
const MFA_MANAGEMENT_AUTH_NOTE =
|
||||
" Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.";
|
||||
|
||||
const ENROLLMENT_PROOF_NOTE =
|
||||
"\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint.";
|
||||
|
||||
const enrollmentProofResponses = {
|
||||
"401": {
|
||||
description:
|
||||
"No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── v1.30.34 step-up elevation (Bearer transport) ────────────────────
|
||||
|
||||
const stepUpOptionsResponse = z
|
||||
@@ -805,7 +816,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" +
|
||||
"WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" +
|
||||
"The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" +
|
||||
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" +
|
||||
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per source address and per account (5 / 15 min); the per-account budget is shared with the web's credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a code from a pending secret is refused like a wrong one.\n\n" +
|
||||
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" +
|
||||
"The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.",
|
||||
requestBody: {
|
||||
@@ -830,8 +841,13 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Begin TOTP enrollment (cookie session or step-up elevation)",
|
||||
description:
|
||||
"Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
"Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm, and a pending secret is not a factor anywhere: a code from it is refused by every route that verifies one." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE +
|
||||
ENROLLMENT_PROOF_NOTE,
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: stepUpMintSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Pending secret created.",
|
||||
@@ -846,6 +862,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...enrollmentProofResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -953,7 +970,12 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Begin registering a security key as a second factor",
|
||||
description:
|
||||
"Returns SimpleWebAuthn creation options + a challenge id." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
MFA_MANAGEMENT_AUTH_NOTE +
|
||||
ENROLLMENT_PROOF_NOTE,
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: { "application/json": { schema: stepUpMintSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Registration options issued.",
|
||||
@@ -967,6 +989,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...enrollmentProofResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1922,7 +1945,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
'**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' +
|
||||
"**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued.\n\n" +
|
||||
"Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" +
|
||||
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller.",
|
||||
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" +
|
||||
"Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
|
||||
@@ -55,6 +55,8 @@ export const stepUpMintSchema = z
|
||||
"Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes (disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web stamps a session second-factor-verified.",
|
||||
});
|
||||
|
||||
export type StepUpMintRequest = z.infer<typeof stepUpMintSchema>;
|
||||
|
||||
/** Which assertion ceremony to begin at `POST /api/auth/step-up/options`. */
|
||||
export const stepUpOptionsSchema = z
|
||||
.object({
|
||||
|
||||
@@ -214,6 +214,30 @@ describe("POST /api/auth/password (real Postgres)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("audits a wrong current password and shares the re-proof ceiling", async () => {
|
||||
const { userId } = await seedUserWithSession();
|
||||
const prisma = getPrismaClient();
|
||||
const { POST } = await import("@/app/api/auth/password/route");
|
||||
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
currentPassword: `WrongPasswordValue!${i}`,
|
||||
newPassword: STRONG_NEW,
|
||||
confirmPassword: STRONG_NEW,
|
||||
}),
|
||||
);
|
||||
statuses.push(res.status);
|
||||
}
|
||||
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
|
||||
expect(
|
||||
await prisma.auditLog.count({
|
||||
where: { userId, action: "auth.mfa.failed" },
|
||||
}),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it("returns 422 when the new password matches the current password", async () => {
|
||||
await seedUserWithSession();
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Adding a factor to an account needs a fresh proof, not just a live session.
|
||||
*
|
||||
* The chain this closes: a stolen browser session starts TOTP enrollment, reads
|
||||
* a code from the secret it was just handed, and presents that code as "an
|
||||
* existing second factor" — to mint a step-up, to register its own passkey, to
|
||||
* pass the fresh-factor gates. Two rules break it, and both are pinned here
|
||||
* against real Postgres:
|
||||
*
|
||||
* 1. a code from a secret that was never confirmed is not a factor anywhere;
|
||||
* 2. the cookie arm of TOTP and security-key enrollment wants a sign-in or a
|
||||
* second factor from the last five minutes, or a proof in the body.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-enrollment-proof-32-bytes-minimum-1234567890";
|
||||
|
||||
const { hashToken } = await import("@/lib/auth/hmac");
|
||||
const { hashPassword } = await import("@/lib/auth/password");
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const USER_ID = "user-enroll-proof";
|
||||
const PASSWORD = "Correct horse battery staple!42";
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
function post(path: string, body?: unknown): NextRequest {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const name of ["authorization", "x-step-up"]) {
|
||||
const value = headerJar.get(name);
|
||||
if (value) headers[name] = value;
|
||||
}
|
||||
if (body !== undefined) headers["content-type"] = "application/json";
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
|
||||
async function totpSetup(body?: unknown) {
|
||||
const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/me/mfa/totp/setup", body),
|
||||
);
|
||||
}
|
||||
|
||||
async function securityKeyOptions(body?: unknown) {
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/me/mfa/webauthn/register/options/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/me/mfa/webauthn/register/options", body),
|
||||
);
|
||||
}
|
||||
|
||||
async function stepUpMint(body: unknown) {
|
||||
const { POST } = await import("@/app/api/auth/step-up/route");
|
||||
return (POST as unknown as RouteFn)(post("/api/auth/step-up", body));
|
||||
}
|
||||
|
||||
async function startSession(opts: {
|
||||
createdAgoMs: number;
|
||||
mfaVerifiedAgoMs?: number;
|
||||
}): Promise<string> {
|
||||
const now = Date.now();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
createdAt: new Date(now - opts.createdAgoMs),
|
||||
expiresAt: new Date(now + HOUR),
|
||||
mfaVerifiedAt:
|
||||
opts.mfaVerifiedAgoMs === undefined
|
||||
? null
|
||||
: new Date(now - opts.mfaVerifiedAgoMs),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session.id;
|
||||
}
|
||||
|
||||
async function seedPendingTotp(): Promise<string> {
|
||||
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const secret = generateTotpSecret();
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: USER_ID },
|
||||
data: {
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
totpConfirmedAt: null,
|
||||
totpLastStep: null,
|
||||
},
|
||||
});
|
||||
return secret;
|
||||
}
|
||||
|
||||
function codeFor(secret: string): string {
|
||||
return new OTPAuth.TOTP({
|
||||
issuer: "HealthLog",
|
||||
label: "HealthLog",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
}).generate({ timestamp: Date.now() });
|
||||
}
|
||||
|
||||
async function errorCode(res: Response): Promise<string | undefined> {
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
return body.meta?.errorCode;
|
||||
}
|
||||
|
||||
async function storedSecret(): Promise<string | null> {
|
||||
const user = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: USER_ID },
|
||||
select: { totpSecretEncrypted: true },
|
||||
});
|
||||
return user.totpSecretEncrypted;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
process.env.APP_URL = "http://localhost:3000";
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "enroll-user",
|
||||
email: "enroll@example.test",
|
||||
role: "USER",
|
||||
passwordHash: await hashPassword(PASSWORD),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("TOTP enrollment on the cookie arm", () => {
|
||||
it("refuses a session that is neither fresh nor carrying a proof", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await totpSetup();
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.required");
|
||||
expect(await storedSecret()).toBeNull();
|
||||
});
|
||||
|
||||
it("enrolls with the account password in the body", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await totpSetup({ method: "password", password: PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedSecret()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("enrolls without a body right after signing in", async () => {
|
||||
await startSession({ createdAgoMs: 30_000 });
|
||||
const res = await totpSetup();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("enrolls without a body after a recent second factor", async () => {
|
||||
await startSession({ createdAgoMs: HOUR, mfaVerifiedAgoMs: 60_000 });
|
||||
const res = await totpSetup();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses and audits a wrong password", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await totpSetup({ method: "password", password: "nope" });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.failed");
|
||||
expect(await storedSecret()).toBeNull();
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: USER_ID, action: "auth.mfa.failed" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("does not take a code from its own pending secret as the proof", async () => {
|
||||
const secret = await seedPendingTotp();
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await totpSetup({ method: "totp", code: codeFor(secret) });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.failed");
|
||||
});
|
||||
|
||||
it("cannot be guessed at past the shared re-proof ceiling", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
statuses.push(
|
||||
(await totpSetup({ method: "password", password: `guess-${i}` }))
|
||||
.status,
|
||||
);
|
||||
}
|
||||
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security-key enrollment on the cookie arm", () => {
|
||||
it("refuses a stale session without a proof and creates no challenge", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await securityKeyOptions();
|
||||
expect(res.status).toBe(401);
|
||||
expect(await errorCode(res)).toBe("auth.reproof.required");
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("issues options once the password is re-proved", async () => {
|
||||
await startSession({ createdAgoMs: HOUR });
|
||||
const res = await securityKeyOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pending TOTP secret on the Bearer arm", () => {
|
||||
it("cannot mint a step-up elevation", async () => {
|
||||
const secret = await seedPendingTotp();
|
||||
const raw = `hlk_enrollproof${"0".repeat(64 - "enrollproof".length)}`;
|
||||
await getPrismaClient().apiToken.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "phone",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
|
||||
const res = await stepUpMint({ method: "totp", code: codeFor(secret) });
|
||||
expect(res.status).toBe(401);
|
||||
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -359,6 +359,61 @@ describe("passkey register-options existing-factor proof (real Postgres)", () =>
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("refuses a code from a TOTP secret that was never confirmed", async () => {
|
||||
const secret = await seedTotp();
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: USER_ID },
|
||||
data: { totpConfirmedAt: null },
|
||||
});
|
||||
|
||||
const response = await registerOptions({
|
||||
method: "totp",
|
||||
code: currentTotpCode(secret),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(0);
|
||||
const session = await getPrismaClient().session.findFirstOrThrow({
|
||||
where: { userId: USER_ID },
|
||||
select: { mfaVerifiedAt: true },
|
||||
});
|
||||
expect(session.mfaVerifiedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("audits every wrong proof and stops guessing after five", async () => {
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const response = await registerOptions({
|
||||
method: "password",
|
||||
password: `wrong-guess-${i}`,
|
||||
});
|
||||
statuses.push(response.status);
|
||||
}
|
||||
|
||||
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
|
||||
const failed = await getPrismaClient().auditLog.findMany({
|
||||
where: { userId: USER_ID, action: "auth.mfa.failed" },
|
||||
select: { details: true },
|
||||
});
|
||||
expect(failed).toHaveLength(5);
|
||||
const details = failed[0].details;
|
||||
expect(
|
||||
typeof details === "string" ? JSON.parse(details) : details,
|
||||
).toMatchObject({
|
||||
stage: "passkey_enroll",
|
||||
method: "password",
|
||||
reason: "bad_password",
|
||||
});
|
||||
|
||||
// The bucket is spent, so even the right password waits it out.
|
||||
const correct = await registerOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(correct.status).toBe(429);
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wildcard", ["*"]],
|
||||
["narrow", ["medication:ingest"]],
|
||||
|
||||
Reference in new issue
Block a user