mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(auth): close the MFA policy lockout and add self-service enrollment (#531)
Fixes #529 (opened investigating #515). Setting MFA policy to "required"/"admins only" saved regardless of whether the mfa enterprise feature was licensed, and there was no enrollment UI at all, so any instance that flipped the toggle locked every unenrolled user out with no way back in. The login page and Settings save also both collapsed the resulting error into a generic message, hiding the real reason. - Reject saving mfaPolicy to admins_only/required server-side unless mfa is licensed - Surface the specific server error on login and on a failed settings save instead of a generic fallback - Add a self-service two-factor authentication enrollment flow (QR code, manual entry, recovery codes, verify, disable) so a licensed admin can actually satisfy the policy before it's enforced - Fix a pending-enrollment dead end, silent error swallowing in verify/disable, and a silent clipboard-copy failure on the recovery codes screen - Add the integration test that actually proves the fix: a real login attempt returns 403 MFA_ENROLLMENT_REQUIRED
This commit is contained in:
@@ -590,6 +590,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
email: user.email ?? null,
|
||||
hasLocalPassword: !!user.passwordHash,
|
||||
hasOidcLink: !!user.externalId,
|
||||
totpEnabled: user.totpEnabled,
|
||||
},
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -145,14 +145,11 @@ export async function registerMfa(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's already a pending (unverified) enrollment
|
||||
if (dbUser.totpSecret && !dbUser.totpEnabled) {
|
||||
return reply.status(409).send({
|
||||
error:
|
||||
"MFA enrollment already pending. Complete verification first or contact an admin to reset.",
|
||||
code: "MFA_ENROLLMENT_PENDING",
|
||||
});
|
||||
}
|
||||
// A pending (unverified) enrollment from an earlier attempt the user
|
||||
// canceled or abandoned is intentionally overwritten below rather than
|
||||
// blocked: it was never activated, so nothing depends on it, and
|
||||
// starting fresh is strictly safer than leaving a stale, possibly
|
||||
// already-screenshotted QR code valid.
|
||||
|
||||
// Generate TOTP secret
|
||||
const totp = createTotp(user.username);
|
||||
|
||||
@@ -139,6 +139,25 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Enforcing MFA requires a way to actually enroll, which is gated behind
|
||||
// the "mfa" enterprise feature. Letting this save through on an unlicensed
|
||||
// instance creates a login rule nobody can satisfy (snapotter-hq/SnapOtter#515).
|
||||
if (key === "mfaPolicy" && (strValue === "admins_only" || strValue === "required")) {
|
||||
let mfaLicensed = false;
|
||||
try {
|
||||
const { isFeatureEnabled } = await import("@snapotter/enterprise");
|
||||
mfaLicensed = isFeatureEnabled("mfa");
|
||||
} catch {
|
||||
// Enterprise package not available
|
||||
}
|
||||
if (!mfaLicensed) {
|
||||
return reply.status(403).send({
|
||||
error: "MFA requires an enterprise license",
|
||||
code: "FEATURE_NOT_LICENSED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({ key, strValue });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user