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

+3
View File
@@ -29,6 +29,7 @@ export const GET = apiHandler(async () => {
return apiSuccess({
registrationEnabled: settings?.registrationEnabled ?? true,
mfaRequired: settings?.mfaRequired ?? false,
defaultLocale: settings?.defaultLocale ?? "de",
telegramGlobal: settings?.telegramGlobal ?? true,
ntfyGlobal: settings?.ntfyGlobal ?? true,
@@ -79,6 +80,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Boolean fields — direct mapping
const booleanFields = [
"registrationEnabled",
"mfaRequired",
"telegramGlobal",
"ntfyGlobal",
"webPushGlobal",
@@ -216,6 +218,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiSuccess({
registrationEnabled: settings.registrationEnabled,
mfaRequired: settings.mfaRequired,
defaultLocale: settings.defaultLocale,
telegramGlobal: settings.telegramGlobal,
ntfyGlobal: settings.ntfyGlobal,
+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,
})),
);
@@ -30,6 +30,7 @@ import {
persistRecoveryCodes,
} from "@/lib/auth/mfa/recovery-codes";
import { totpConfirmSchema } from "@/lib/validations/mfa";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
export const dynamic = "force-dynamic";
@@ -113,6 +114,10 @@ export const POST = apiHandler(async (req: Request) => {
});
});
// v1.23 — the account now has an active second factor, so any
// admin-enforced forced-enrollment redirect must clear immediately.
await setMfaEnrollCookie(false);
await auditLog("auth.mfa.enabled", {
userId: user.id,
ipAddress: getClientIp(req),
@@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/mfa-enrollment", () => ({
setMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
@@ -18,6 +18,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
export const dynamic = "force-dynamic";
@@ -59,6 +60,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
});
// v1.23 — a registered security key satisfies an admin-enforced MFA policy,
// so clear any forced-enrollment redirect immediately.
await setMfaEnrollCookie(false);
await auditLog("auth.mfa.webauthn.register", {
userId: user.id,
ipAddress: getClientIp(request),
+10 -1
View File
@@ -2,6 +2,7 @@ import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
@@ -26,8 +27,16 @@ export const GET = apiHandler(async () => {
// cycle-tracking gate; no row is forced, a NULL toggle derives from
// gender) is a Postgres round-trip. Running them sequentially added
// the cookie hop to every /me — and /me sits on every app boot path.
const [, cycleProfile, modules, moduleAvailability] = await Promise.all([
const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
// corrected here; an account that enrols (or has the policy lifted) loses
// the redirect on the next /me read.
syncMfaEnrollCookie(user.id, {
totpConfirmedAt: user.totpConfirmedAt,
mfaEnforced: user.mfaEnforced,
}),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },