mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The address is what single sign-on links an existing account by, and changing it took the password or a session younger than five minutes on every account. A remembered browser skips the second factor at sign-in and still makes a fresh session, so the password alone could move the address of an account the second factor was meant to protect. On an account with a second factor only a second factor or passkey completed in the last five minutes counts now; the password is not checked. The refusal names it (second_factor_required in rejectedFields, or 401 profile.update.emailSecondFactorRequired when the address was all that changed), and the profile form opens the re-proof dialog with the factors the account holds, then saves again, no longer offering the password field. Accounts without a second factor keep the password or a recent sign-in. A token cannot change the address of such an account; the app does not change it.
453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
/**
|
|
* 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 { REPROOF_REQUIRED_CODE } from "@/lib/api-errors";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import {
|
|
checkRateLimit,
|
|
rateLimitHeaders,
|
|
refundRateLimit,
|
|
} from "@/lib/rate-limit";
|
|
import {
|
|
hasSecondFactorEnrolled,
|
|
recentProofMethods,
|
|
} from "@/lib/auth/second-factor";
|
|
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),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Give back the attempt a proof charged once it verified. The bucket exists to
|
|
* stop guessing; a proof that verified was not a guess, and without the refund
|
|
* an owner who confirms a few actions in a row (an export, a share link, a
|
|
* password change) runs the shared budget dry and is told to wait.
|
|
*/
|
|
export async function refundReproof(userId: string): Promise<void> {
|
|
await refundRateLimit(reproofBucket(userId));
|
|
}
|
|
|
|
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 { REPROOF_REQUIRED_CODE };
|
|
/** 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.
|
|
*
|
|
* What counts depends on whether the account already has a second factor.
|
|
*
|
|
* Without one (password, passkey or single sign-on only), any of:
|
|
* - the session completed a passkey sign-in or re-proof within the last five
|
|
* minutes (`Session.mfaVerifiedAt`);
|
|
* - the session re-proved a credential at `POST /api/auth/reproof` within
|
|
* the last five minutes (`Session.reproofAt`);
|
|
* - 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.
|
|
*
|
|
* With one, only a proof of possession of a factor: `Session.mfaVerifiedAt`
|
|
* within five minutes, or a TOTP code, security-key or passkey assertion in
|
|
* the body. A password is not enough and neither is a young session: signing
|
|
* in on a remembered browser skips the second factor and still creates a
|
|
* fresh session, so `createdAt` would let the password alone add a new key,
|
|
* which then satisfies every gate the second factor was protecting.
|
|
*
|
|
* 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 enrolled = await hasSecondFactorEnrolled(user);
|
|
const row = await prisma.session.findUnique({
|
|
where: { id: sessionId },
|
|
select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true },
|
|
});
|
|
const now = Date.now();
|
|
const recent = (d: Date | null | undefined) =>
|
|
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
|
|
const sessionSatisfies = enrolled
|
|
? recent(row?.mfaVerifiedAt)
|
|
: recent(row?.mfaVerifiedAt) ||
|
|
recent(row?.createdAt) ||
|
|
recent(row?.reproofAt);
|
|
if (sessionSatisfies) {
|
|
annotate({ meta: { enrollment_proof: "recent_session" } });
|
|
return null;
|
|
}
|
|
|
|
const proof = await readProof(request);
|
|
if (proof === "absent") {
|
|
annotate({
|
|
action: { name: "auth.reproof.required" },
|
|
meta: { stage, second_factor: enrolled },
|
|
});
|
|
return apiError("Confirm it is you before adding a sign-in method", 401, {
|
|
errorCode: REPROOF_REQUIRED_CODE,
|
|
methods: await recentProofMethods(user, enrolled),
|
|
});
|
|
}
|
|
if (proof === "invalid") {
|
|
return apiError("Invalid verification", 422, {
|
|
errorCode: REPROOF_REQUIRED_CODE,
|
|
});
|
|
}
|
|
// Refused before a guess is spent: a password that verified would still not
|
|
// open this gate on an account with a second factor.
|
|
if (enrolled && !isSecondFactorProof(proof.method)) {
|
|
annotate({
|
|
action: { name: "auth.reproof.method_refused" },
|
|
meta: { stage, method: proof.method },
|
|
});
|
|
return apiError("Confirm with your second factor or a passkey", 401, {
|
|
errorCode: REPROOF_REQUIRED_CODE,
|
|
methods: await recentProofMethods(user, enrolled),
|
|
});
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
await refundReproof(user.id);
|
|
annotate({ meta: { enrollment_proof: proof.method } });
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The proofs that stand for possession of a factor rather than knowledge of
|
|
* the password. On an account with a second factor these are the only ones
|
|
* that may add a credential.
|
|
*/
|
|
export function isSecondFactorProof(
|
|
method: ExistingFactorProof["method"],
|
|
): boolean {
|
|
return method === "totp" || method === "webauthn" || method === "passkey";
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
export type SensitiveChangeProof =
|
|
"ok" | "required" | "second_factor_required" | "failed" | "rate_limited";
|
|
|
|
/**
|
|
* The proof in front of changing the account's email address.
|
|
*
|
|
* The address is what single sign-on matches an existing account by, so it is
|
|
* part of how the account is reached and a stolen session must not rewrite it.
|
|
*
|
|
* On an account WITHOUT a second factor: on a cookie session, a sign-in or
|
|
* re-proof inside the last five minutes; on either transport, the current
|
|
* password alongside the change. The password draws on the shared re-proof
|
|
* budget and a wrong one is audited like every other refused proof.
|
|
*
|
|
* On an account WITH a second factor, the rule `checkCookieEnrollmentProof`
|
|
* holds for adding a credential: only possession of a factor counts, which on
|
|
* a cookie session is `Session.mfaVerifiedAt` inside five minutes (a completed
|
|
* second factor or passkey, at sign-in or at `POST /api/auth/reproof`). The
|
|
* password is not verified at all, and neither a young session nor a password
|
|
* re-proof stands in: a remembered browser skips the factor at sign-in and
|
|
* still creates a fresh session. The answer is `second_factor_required`, so
|
|
* the client opens the re-proof dialog with the factors the account holds. A
|
|
* token carries no such stamp, so a Bearer caller cannot change the address
|
|
* of such an account; the shipped app does not change it.
|
|
*/
|
|
export async function authorizeSensitiveChange(args: {
|
|
user: Pick<
|
|
User,
|
|
| "id"
|
|
| "passwordHash"
|
|
| "totpSecretEncrypted"
|
|
| "totpLastStep"
|
|
| "totpConfirmedAt"
|
|
>;
|
|
/** The session row id on a cookie request; null on a Bearer request. */
|
|
cookieSessionId: string | null;
|
|
currentPassword: string | null;
|
|
ipAddress: string | null;
|
|
stage: string;
|
|
}): Promise<SensitiveChangeProof> {
|
|
const { user, cookieSessionId, currentPassword, ipAddress, stage } = args;
|
|
|
|
const enrolled = await hasSecondFactorEnrolled(user);
|
|
|
|
if (cookieSessionId) {
|
|
const row = await prisma.session.findUnique({
|
|
where: { id: cookieSessionId },
|
|
select: { mfaVerifiedAt: true, createdAt: true, reproofAt: true },
|
|
});
|
|
const now = Date.now();
|
|
const recent = (d: Date | null | undefined) =>
|
|
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
|
|
const satisfied = enrolled
|
|
? recent(row?.mfaVerifiedAt)
|
|
: recent(row?.mfaVerifiedAt) ||
|
|
recent(row?.createdAt) ||
|
|
recent(row?.reproofAt);
|
|
if (satisfied) return "ok";
|
|
}
|
|
|
|
// Before any password is looked at: on this account a right password would
|
|
// not be enough, so checking it would only spend a guess.
|
|
if (enrolled) {
|
|
annotate({
|
|
action: { name: "auth.reproof.method_refused" },
|
|
meta: { stage, method: currentPassword ? "password" : "none" },
|
|
});
|
|
return "second_factor_required";
|
|
}
|
|
|
|
if (!currentPassword) return "required";
|
|
|
|
const rl = await checkRateLimit(
|
|
reproofBucket(user.id),
|
|
REPROOF_LIMIT,
|
|
REPROOF_WINDOW_MS,
|
|
);
|
|
if (!rl.allowed) {
|
|
await auditLog("auth.reproof.rate_limited", {
|
|
userId: user.id,
|
|
ipAddress,
|
|
details: { stage },
|
|
});
|
|
return "rate_limited";
|
|
}
|
|
|
|
const outcome = await verifyExistingFactorProof(user, {
|
|
method: "password",
|
|
password: currentPassword,
|
|
});
|
|
if (!outcome.ok) {
|
|
await recordReproofFailure(
|
|
user.id,
|
|
ipAddress,
|
|
stage,
|
|
"password",
|
|
outcome.reason,
|
|
);
|
|
return "failed";
|
|
}
|
|
await refundReproof(user.id);
|
|
return "ok";
|
|
}
|