mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(auth): adding a credential beside a second factor needs that factor
On an account with a second factor, TOTP setup, security-key registration and passkey registration no longer accept the password, a password re-proof or a session younger than five minutes. A remembered browser skips the second factor at sign-in and still creates a fresh session, so the password alone could add a key that then satisfies every gate the factor protected. Only a TOTP code, a security key, a passkey or a completed second factor within five minutes opens enrollment there; the Bearer arm of TOTP setup and security-key registration asks the same of its elevation. Accounts without a second factor keep the previous rules. The refusal names the proofs the account can give, and the enrollment dialogs offer exactly those. A proof that verifies now gives its attempt back to the shared re-proof budget, including the current password on a password change.
This commit is contained in:
1 parent
66b1aef40c
commit
0692017f34
14 files changed
+519
-88
No files matched your search
@@ -16,8 +16,10 @@
|
||||
* 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
|
||||
* factor proof in the body (the step-up mint's shapes); on an account that
|
||||
* already has a second factor, only that factor or a passkey. The Bearer arm
|
||||
* carries the proof in its elevation, under the same rule: on an account with
|
||||
* a second factor the elevation must come from one. The recovery-code batch is
|
||||
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
|
||||
* setup never persists codes.
|
||||
*/
|
||||
@@ -41,7 +43,7 @@ const SETUP_RATE_LIMIT = 5;
|
||||
const SETUP_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const auth = await requireMfaManagementAuth();
|
||||
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
|
||||
const { user } = auth;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
*
|
||||
* 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.
|
||||
* the new key would satisfy every step-up gate from then on. On an account that
|
||||
* already has a second factor, both arms want that factor (or a passkey): a
|
||||
* password, or a session that skipped the factor on a remembered browser, does
|
||||
* not add a key.
|
||||
*/
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
@@ -20,7 +23,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const auth = await requireMfaManagementAuth();
|
||||
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
|
||||
const { user } = auth;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
|
||||
@@ -5,10 +5,16 @@ import {
|
||||
} from "@/lib/auth/passkey";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import {
|
||||
isSecondFactorProof,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiError,
|
||||
@@ -55,6 +61,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) return proofRequired();
|
||||
|
||||
// On an account with a second factor a password does not add a sign-in
|
||||
// credential, exactly as it does not satisfy the second factor at sign-in:
|
||||
// a passkey registered on the strength of the password alone would from then
|
||||
// on sign in without the second factor at all. Refused before a guess is
|
||||
// spent, with the proofs that would work.
|
||||
if (
|
||||
!isSecondFactorProof(parsed.data.method) &&
|
||||
(await hasSecondFactorEnrolled(user))
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.method_refused" },
|
||||
meta: { stage: "passkey_enroll", method: parsed.data.method },
|
||||
});
|
||||
return apiError("Confirm with your second factor or a passkey", 422, {
|
||||
errorCode: "auth.reproof.too_weak",
|
||||
methods: await recentProofMethods(user, true),
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -73,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
return proofRequired();
|
||||
}
|
||||
await refundReproof(user.id);
|
||||
|
||||
// A strong existing factor refreshes the session's MFA stamp. Password proof
|
||||
// deliberately clears it: registration is authorized by this single-use,
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("@/lib/auth/password", () => ({
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
@@ -77,7 +78,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import {
|
||||
requireFreshMfaIfEnrolled,
|
||||
@@ -146,6 +147,9 @@ describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
|
||||
expect(prisma.user.update).toHaveBeenCalled();
|
||||
// The right current password was not a guess: the shared re-proof
|
||||
// bucket gets its slot back.
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
throttleReproof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
@@ -72,6 +73,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
return apiError("Current password is incorrect", 401);
|
||||
}
|
||||
// The current password was right, so this attempt was not a guess. Give the
|
||||
// shared re-proof budget its slot back; otherwise a password change followed
|
||||
// by a few confirmations elsewhere runs the owner out of attempts.
|
||||
await refundReproof(user.id);
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
return apiError("New password must differ from current password", 422);
|
||||
|
||||
@@ -52,6 +52,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
REPROOF_FAILED_CODE,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
throttleReproof,
|
||||
verifyExistingFactorProof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
@@ -110,6 +111,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
await refundReproof(user.id);
|
||||
|
||||
const now = new Date();
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
|
||||
Reference in new issue
Block a user