mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user