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:
Marc-André Bombeck committed 2026-06-27 21:49:53 +02:00
1 parent 135bb04f0d
commit c8bef61344
22 files changed
+431 -8

No files matched your search

+7
View File
@@ -16,6 +16,9 @@ const updateUserSchema = z.object({
username: z.string().min(3).max(30).optional(),
email: z.string().email().nullable().optional(),
role: z.enum(["ADMIN", "USER"]).optional(),
// v1.23 — per-user "require a second factor" override. Effective requirement
// is the OR of this and the instance-wide AppSettings.mfaRequired policy.
mfaEnforced: z.boolean().optional(),
});
export const PUT = apiHandler(
@@ -64,6 +67,9 @@ export const PUT = apiHandler(
}),
...(parsed.data.email !== undefined && { email: parsed.data.email }),
...(parsed.data.role !== undefined && { role: parsed.data.role }),
...(parsed.data.mfaEnforced !== undefined && {
mfaEnforced: parsed.data.mfaEnforced,
}),
},
select: {
id: true,
@@ -71,6 +77,7 @@ export const PUT = apiHandler(
email: true,
role: true,
createdAt: true,
mfaEnforced: true,
},
});
@@ -111,6 +111,7 @@ describe("GET /api/admin/users", () => {
email: true,
role: true,
createdAt: true,
mfaEnforced: true,
_count: { select: { passkeys: true } },
});
expect(args.select).not.toHaveProperty("passwordHash");
+4
View File
@@ -16,6 +16,7 @@ export const GET = apiHandler(async () => {
email: true,
role: true,
createdAt: true,
mfaEnforced: true,
_count: { select: { passkeys: true } },
},
orderBy: { createdAt: "asc" },
@@ -28,6 +29,9 @@ export const GET = apiHandler(async () => {
email: u.email,
role: u.role,
createdAt: u.createdAt,
// v1.23 — per-user "require a second factor" override. The effective
// requirement is the OR of this and the instance-wide policy.
mfaEnforced: u.mfaEnforced,
passkeyCount: u._count.passkeys,
})),
);