mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(auth): give OIDC/SAML logins a real MFA challenge instead of a hard block (#536)
Fixes #533, found while working on #529/#531. OIDC and SAML logins hard-blocked on the MFA policy with zero check of whether the user actually enrolled TOTP, and no challenge step at all. Once an admin turned on an MFA-required policy, every SSO user was permanently locked out regardless of enrollment status. - Extract the post-auth MFA decision (challenge / enrollment-required / proceed) into a shared, unit-tested function so OIDC and SAML can't independently diverge again - An already-enrolled user now gets a real challenge (reusing the existing, auth-method-agnostic MFA completion flow) instead of being blocked - An unenrolled user under a required policy gets a distinct, correctly mapped error instead of the old generic one - Fix a real fail-open regression caught in review: a transient DB error during the enrollment-status check could have silently skipped MFA entirely for an enrolled user; now it fails closed and logs - Strip the one-time challenge token from the URL after consuming it
This commit is contained in:
@@ -106,6 +106,22 @@ export function isMfaRequiredForUser(policy: MfaPolicy, userRole: string): boole
|
||||
return false;
|
||||
}
|
||||
|
||||
// The post-authentication MFA decision, shared by every login path (local
|
||||
// password, OIDC, SAML) so a new auth method can't silently diverge from the
|
||||
// others the way OIDC/SAML once did (they blocked on policy alone, with no
|
||||
// totpEnabled check and no challenge step -- snapotter-hq/SnapOtter#533).
|
||||
export type ExternalMfaOutcome = "proceed" | "challenge" | "enrollment_required";
|
||||
|
||||
export function resolveExternalLoginMfaOutcome(
|
||||
policy: MfaPolicy,
|
||||
userRole: string,
|
||||
totpEnabled: boolean,
|
||||
): ExternalMfaOutcome {
|
||||
if (totpEnabled) return "challenge";
|
||||
if (isMfaRequiredForUser(policy, userRole)) return "enrollment_required";
|
||||
return "proceed";
|
||||
}
|
||||
|
||||
// ── MFA plugin registration ───────────────────────────────────────
|
||||
|
||||
export async function registerMfa(app: FastifyInstance): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {} from "@fastify/cookie";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import * as oidc from "openid-client";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { sharedRedis } from "../jobs/connection.js";
|
||||
import { auditFromRequest, sanitizeAuditInput } from "../lib/audit.js";
|
||||
import { resolveExternalUser, sanitizeUsername } from "../lib/external-auth-resolver.js";
|
||||
import { authAttempts } from "../lib/metrics.js";
|
||||
@@ -274,22 +277,62 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const resolvedUser = result.user;
|
||||
|
||||
let mfaRequired = false;
|
||||
// Unguarded on purpose: this read decides whether MFA gets checked at
|
||||
// all, so a DB error here must fail the login, not silently skip MFA
|
||||
// for an enrolled user. The try/catch below is scoped only to the
|
||||
// optional MFA plugin/policy lookup, same as it always was.
|
||||
let dbUser: { totpEnabled: boolean } | undefined;
|
||||
try {
|
||||
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
|
||||
const policy = await getMfaPolicy();
|
||||
mfaRequired = isMfaRequiredForUser(policy, resolvedUser.role);
|
||||
} catch {
|
||||
// MFA plugin not loaded
|
||||
}
|
||||
if (mfaRequired) {
|
||||
[dbUser] = await db
|
||||
.select({ totpEnabled: schema.users.totpEnabled })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, resolvedUser.id));
|
||||
} catch (err) {
|
||||
request.log.error(
|
||||
{ err, userId: resolvedUser.id },
|
||||
"OIDC callback: failed to read MFA enrollment status",
|
||||
);
|
||||
authAttempts.inc({ method: "oidc", result: "failure" });
|
||||
await audit("OIDC_LOGIN_FAILED", {
|
||||
userId: resolvedUser.id,
|
||||
username: resolvedUser.username,
|
||||
reason: "mfa_required",
|
||||
reason: "mfa_check_error",
|
||||
});
|
||||
return redirectToLogin(reply, "mfa_required");
|
||||
return redirectToLogin(reply, "oidc_auth_failed");
|
||||
}
|
||||
|
||||
let mfaOutcome: "proceed" | "challenge" | "enrollment_required" = "proceed";
|
||||
try {
|
||||
const { getMfaPolicy, resolveExternalLoginMfaOutcome } = await import("./mfa.js");
|
||||
const policy = await getMfaPolicy();
|
||||
mfaOutcome = resolveExternalLoginMfaOutcome(
|
||||
policy,
|
||||
resolvedUser.role,
|
||||
dbUser?.totpEnabled ?? false,
|
||||
);
|
||||
} catch {
|
||||
// MFA plugin not loaded
|
||||
}
|
||||
|
||||
if (mfaOutcome === "challenge") {
|
||||
const mfaToken = randomUUID();
|
||||
const redis = sharedRedis();
|
||||
await redis.setex(`mfa:${mfaToken}`, 300, resolvedUser.id);
|
||||
await audit("MFA_CHALLENGE_ISSUED", {
|
||||
userId: resolvedUser.id,
|
||||
username: resolvedUser.username,
|
||||
});
|
||||
return reply.redirect(`/login?mfaToken=${mfaToken}`);
|
||||
}
|
||||
|
||||
if (mfaOutcome === "enrollment_required") {
|
||||
authAttempts.inc({ method: "oidc", result: "failure" });
|
||||
await audit("OIDC_LOGIN_FAILED", {
|
||||
userId: resolvedUser.id,
|
||||
username: resolvedUser.username,
|
||||
reason: "mfa_enrollment_required",
|
||||
});
|
||||
return redirectToLogin(reply, "mfa_enrollment_required");
|
||||
}
|
||||
|
||||
// 5. Create session
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { parse as parseQs } from "node:querystring";
|
||||
import type {} from "@fastify/cookie";
|
||||
import { SAML } from "@node-saml/node-saml";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { sharedRedis } from "../jobs/connection.js";
|
||||
import { auditFromRequest } from "../lib/audit.js";
|
||||
import {
|
||||
findUniqueUsername,
|
||||
@@ -164,22 +167,62 @@ export async function registerSaml(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const resolvedUser = result.user;
|
||||
|
||||
let mfaRequired = false;
|
||||
// Unguarded on purpose: this read decides whether MFA gets checked at
|
||||
// all, so a DB error here must fail the login, not silently skip MFA
|
||||
// for an enrolled user. The try/catch below is scoped only to the
|
||||
// optional MFA plugin/policy lookup, same as it always was.
|
||||
let dbUser: { totpEnabled: boolean } | undefined;
|
||||
try {
|
||||
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
|
||||
const policy = await getMfaPolicy();
|
||||
mfaRequired = isMfaRequiredForUser(policy, resolvedUser.role);
|
||||
} catch {
|
||||
// MFA plugin not loaded
|
||||
}
|
||||
if (mfaRequired) {
|
||||
[dbUser] = await db
|
||||
.select({ totpEnabled: schema.users.totpEnabled })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, resolvedUser.id));
|
||||
} catch (err) {
|
||||
request.log.error(
|
||||
{ err, userId: resolvedUser.id },
|
||||
"SAML callback: failed to read MFA enrollment status",
|
||||
);
|
||||
authAttempts.inc({ method: "saml", result: "failure" });
|
||||
await audit("SAML_LOGIN_FAILED", {
|
||||
userId: resolvedUser.id,
|
||||
username: resolvedUser.username,
|
||||
reason: "mfa_required",
|
||||
reason: "mfa_check_error",
|
||||
});
|
||||
return redirectToLogin(reply, "mfa_required");
|
||||
return redirectToLogin(reply, "saml_auth_failed");
|
||||
}
|
||||
|
||||
let mfaOutcome: "proceed" | "challenge" | "enrollment_required" = "proceed";
|
||||
try {
|
||||
const { getMfaPolicy, resolveExternalLoginMfaOutcome } = await import("./mfa.js");
|
||||
const policy = await getMfaPolicy();
|
||||
mfaOutcome = resolveExternalLoginMfaOutcome(
|
||||
policy,
|
||||
resolvedUser.role,
|
||||
dbUser?.totpEnabled ?? false,
|
||||
);
|
||||
} catch {
|
||||
// MFA plugin not loaded
|
||||
}
|
||||
|
||||
if (mfaOutcome === "challenge") {
|
||||
const mfaToken = randomUUID();
|
||||
const redis = sharedRedis();
|
||||
await redis.setex(`mfa:${mfaToken}`, 300, resolvedUser.id);
|
||||
await audit("MFA_CHALLENGE_ISSUED", {
|
||||
userId: resolvedUser.id,
|
||||
username: resolvedUser.username,
|
||||
});
|
||||
return reply.redirect(`/login?mfaToken=${mfaToken}`);
|
||||
}
|
||||
|
||||
if (mfaOutcome === "enrollment_required") {
|
||||
authAttempts.inc({ method: "saml", result: "failure" });
|
||||
await audit("SAML_LOGIN_FAILED", {
|
||||
userId: resolvedUser.id,
|
||||
username: resolvedUser.username,
|
||||
reason: "mfa_enrollment_required",
|
||||
});
|
||||
return redirectToLogin(reply, "mfa_enrollment_required");
|
||||
}
|
||||
|
||||
// Create session (same pattern as OIDC)
|
||||
|
||||
@@ -129,7 +129,7 @@ function LanguageSelector() {
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName, ssoEnforced } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -141,6 +141,23 @@ export function LoginPage() {
|
||||
const mfaInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// A successful OIDC/SAML login for an already-enrolled user redirects
|
||||
// here with a one-time mfaToken instead of completing the session
|
||||
// directly, so the TOTP challenge can be completed the same way a local
|
||||
// login's challenge is.
|
||||
const redirectedMfaToken = searchParams.get("mfaToken");
|
||||
if (redirectedMfaToken) {
|
||||
setMfaToken(redirectedMfaToken);
|
||||
setShowMfaPrompt(true);
|
||||
setTimeout(() => mfaInputRef.current?.focus(), 100);
|
||||
// Drop it from the URL: it's a one-time credential and has no business
|
||||
// sitting in browser history or a Referer header for the rest of the
|
||||
// challenge. Also stops a later effect re-run (e.g. a locale switch)
|
||||
// from reopening the prompt after the user has moved past it.
|
||||
setSearchParams({}, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const authError = searchParams.get("error");
|
||||
if (authError) {
|
||||
const errorMessages: Record<string, string> = {
|
||||
@@ -152,10 +169,12 @@ export function LoginPage() {
|
||||
saml_auth_failed: t.auth.samlAuthFailed,
|
||||
saml_user_not_authorized: t.auth.samlUserNotAuthorized,
|
||||
saml_user_limit_reached: t.auth.samlUserLimitReached,
|
||||
mfa_enrollment_required: t.auth.mfaEnrollmentRequired,
|
||||
};
|
||||
setError(errorMessages[authError] || t.auth.oidcGenericError);
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
}, [searchParams, t]);
|
||||
}, [searchParams, setSearchParams, t]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user