mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldAlert, LogOut, Loader2 } from "lucide-react";
|
||||
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SecuritySection } from "@/components/settings/security-section";
|
||||
import { useLogout } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.23 — forced second-factor enrollment interstitial.
|
||||
*
|
||||
* The proxy redirects here whenever the operator requires MFA and the account
|
||||
* has no active second factor (the `hl_mfa_enroll=required` hint cookie). It
|
||||
* reuses the security hub's enrollment cards (TOTP + security keys); the moment
|
||||
* a factor is confirmed the enrollment route clears the hint cookie and the
|
||||
* next navigation lands wherever the user was headed. A sign-out button keeps
|
||||
* the only other escape — the user is never trapped, and the operator's CLI
|
||||
* (`scripts/disable-mfa.ts`) / instance toggle is the recovery of last resort.
|
||||
*/
|
||||
export default function EnrollMfaPage() {
|
||||
const { t } = useTranslations();
|
||||
const logout = useLogout();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col gap-6 px-4 py-10">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Logo className="text-primary" size={28} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-foreground inline-flex items-center gap-2 text-lg font-semibold">
|
||||
<ShieldAlert className="text-primary h-5 w-5" aria-hidden="true" />
|
||||
{t("auth.enrollMfa.title")}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.enrollMfa.body")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SecuritySection />
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => logout.mutate()}
|
||||
disabled={logout.isPending}
|
||||
>
|
||||
{logout.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<LogOut className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.enrollMfa.signOut")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user