mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(security): let an operator require a second factor
Wire the previously-unused mfaEnforced flag into a real policy. An admin can require a second factor instance-wide (an AppSettings toggle in the General section) or per user (a toggle in user management); the effective requirement is the OR of the two. When required and the account has no active factor — no confirmed TOTP, no registered security key, and no primary passkey — the user is walled behind a forced-enrollment interstitial after sign-in, with only the security settings and sign-out reachable until a factor is enrolled. The gate is surfaced like the onboarding gate: a hint cookie the proxy reads without a DB round-trip, recomputed from the database on every sign-in and on /api/auth/me so a tampered cookie self-corrects. Nobody is locked out — the enrollment surface stays reachable, and toggling the policy off (or the disable-mfa CLI) is the operator's recovery path.
This commit is contained in:
1 parent
135bb04f0d
commit
c8bef61344
22 files changed
+431
-8
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
passkey: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => true,
|
||||
}));
|
||||
|
||||
const cookieStore = { set: vi.fn(), delete: vi.fn(), get: vi.fn() };
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(async () => cookieStore),
|
||||
}));
|
||||
|
||||
import { resolveMfaEnrollmentRequired } from "../mfa-enrollment";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
mfaRequired: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
|
||||
});
|
||||
|
||||
describe("resolveMfaEnrollmentRequired", () => {
|
||||
it("returns false (and skips all DB reads) for an account with confirmed TOTP", async () => {
|
||||
const required = await resolveMfaEnrollmentRequired("u1", {
|
||||
totpConfirmedAt: new Date(),
|
||||
mfaEnforced: true,
|
||||
});
|
||||
expect(required).toBe(false);
|
||||
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.webauthnMfaCredential.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when no policy applies, even for a factor-less account", async () => {
|
||||
const required = await resolveMfaEnrollmentRequired("u1", {
|
||||
totpConfirmedAt: null,
|
||||
mfaEnforced: false,
|
||||
});
|
||||
expect(required).toBe(false);
|
||||
// Policy off short-circuits before counting factors.
|
||||
expect(prisma.webauthnMfaCredential.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces enrollment under the instance policy for a factor-less account", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
mfaRequired: true,
|
||||
} as never);
|
||||
const required = await resolveMfaEnrollmentRequired("u1", {
|
||||
totpConfirmedAt: null,
|
||||
mfaEnforced: false,
|
||||
});
|
||||
expect(required).toBe(true);
|
||||
});
|
||||
|
||||
it("forces enrollment under a per-user override even with the instance policy off", async () => {
|
||||
const required = await resolveMfaEnrollmentRequired("u1", {
|
||||
totpConfirmedAt: null,
|
||||
mfaEnforced: true,
|
||||
});
|
||||
expect(required).toBe(true);
|
||||
});
|
||||
|
||||
it("lets a registered security key satisfy the policy", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
mfaRequired: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
const required = await resolveMfaEnrollmentRequired("u1", {
|
||||
totpConfirmedAt: null,
|
||||
mfaEnforced: false,
|
||||
});
|
||||
expect(required).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a primary passkey satisfy the policy", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
mfaRequired: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
const required = await resolveMfaEnrollmentRequired("u1", {
|
||||
totpConfirmedAt: null,
|
||||
mfaEnforced: false,
|
||||
});
|
||||
expect(required).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Admin-enforced MFA policy — the forced-enrollment gate.
|
||||
*
|
||||
* The effective per-user requirement is the OR of an instance-wide policy
|
||||
* (`AppSettings.mfaRequired`) and a per-user override (`User.mfaEnforced`).
|
||||
* When MFA is required AND the account has NO active second factor (no
|
||||
* confirmed TOTP and no registered security key), the user is sent through a
|
||||
* forced-enrollment interstitial after sign-in until they enrol one.
|
||||
*
|
||||
* The gate is surfaced exactly like the onboarding gate: a non-httpOnly UX-hint
|
||||
* cookie (`hl_mfa_enroll`) the proxy reads without a DB round-trip. It is a
|
||||
* server-authoritative nudge, not a cryptographic wall — every issuing auth
|
||||
* surface and `/api/auth/me` recompute it from the DB, so a locally edited
|
||||
* cookie is corrected on the next data fetch. The user can always reach
|
||||
* `/settings/security` (and the enrollment APIs) to enrol, so nobody is locked
|
||||
* out; the operator/CLI escape hatch (`scripts/disable-mfa.ts`, toggling the
|
||||
* policy off) is the recovery path of last resort.
|
||||
*/
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
/** UX-hint cookie the proxy reads to redirect into the enrollment surface. */
|
||||
export const MFA_ENROLL_COOKIE = "hl_mfa_enroll";
|
||||
const MFA_ENROLL_PENDING = "required";
|
||||
|
||||
/** The session cookie max-age, mirrored so the hint expires with the session. */
|
||||
const COOKIE_MAX_AGE_S = 30 * 24 * 60 * 60;
|
||||
|
||||
interface MfaPolicyUser {
|
||||
totpConfirmedAt: Date | null;
|
||||
mfaEnforced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether the account must enrol a second factor before using the app.
|
||||
* Cheap by construction: an account that already has TOTP short-circuits with
|
||||
* zero queries; an instance with the policy off resolves with one small read.
|
||||
*/
|
||||
export async function resolveMfaEnrollmentRequired(
|
||||
userId: string,
|
||||
user: MfaPolicyUser,
|
||||
): Promise<boolean> {
|
||||
// Already has a confirmed TOTP secret → enrolled, nothing required.
|
||||
if (user.totpConfirmedAt) return false;
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { mfaRequired: true },
|
||||
});
|
||||
const policyApplies = Boolean(settings?.mfaRequired) || user.mfaEnforced;
|
||||
if (!policyApplies) return false;
|
||||
|
||||
// Policy applies and there is no TOTP. Any of three credentials satisfies the
|
||||
// requirement: a registered security key (the MFA WebAuthn store, matching
|
||||
// `requireFreshMfa`'s either-factor rule) OR a primary passkey — a
|
||||
// passwordless passkey login is itself a phishing-resistant possession factor
|
||||
// and stamps `mfaVerifiedAt` (M-review M1), so a passkey user is "enrolled"
|
||||
// for enforcement purposes and is not nagged to add a second factor on top.
|
||||
const [webauthnKeyCount, passkeyCount] = await Promise.all([
|
||||
prisma.webauthnMfaCredential.count({ where: { userId } }),
|
||||
prisma.passkey.count({ where: { userId } }),
|
||||
]);
|
||||
return webauthnKeyCount === 0 && passkeyCount === 0;
|
||||
}
|
||||
|
||||
/** Set or clear the forced-enrollment UX-hint cookie. */
|
||||
export async function setMfaEnrollCookie(required: boolean): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
if (required) {
|
||||
cookieStore.set(MFA_ENROLL_COOKIE, MFA_ENROLL_PENDING, {
|
||||
// UX hint only — read by the proxy to redirect; the real check is the
|
||||
// server-side recompute on every /api/auth/me + auth surface.
|
||||
httpOnly: false,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "strict",
|
||||
maxAge: COOKIE_MAX_AGE_S,
|
||||
path: "/",
|
||||
});
|
||||
} else {
|
||||
cookieStore.delete(MFA_ENROLL_COOKIE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute the requirement for a user and sync the hint cookie to it. */
|
||||
export async function syncMfaEnrollCookie(
|
||||
userId: string,
|
||||
user: MfaPolicyUser,
|
||||
): Promise<void> {
|
||||
const required = await resolveMfaEnrollmentRequired(userId, user);
|
||||
await setMfaEnrollCookie(required);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
export const adminSettingsSchema = z
|
||||
.object({
|
||||
registrationEnabled: z.boolean().optional(),
|
||||
// v1.23 — instance-wide "require a second factor" policy.
|
||||
mfaRequired: z.boolean().optional(),
|
||||
defaultLocale: z.enum(["de", "en"]).optional(),
|
||||
telegramGlobal: z.boolean().optional(),
|
||||
ntfyGlobal: z.boolean().optional(),
|
||||
|
||||
Reference in new issue
Block a user