Force mfa if enabled at login

This commit is contained in:
2026-01-16 17:50:56 +10:30
parent 4ee3b81074
commit 87c143a332
4 changed files with 215 additions and 23 deletions
+10 -4
View File
@@ -55,6 +55,11 @@ export function ComplianceBanner({ compliance }: ComplianceBannerProps) {
// Check if MFA is required based on effective_mode (if available) // Check if MFA is required based on effective_mode (if available)
const mfaRequired = isMfaRequired(compliance); const mfaRequired = isMfaRequired(compliance);
// DEBUG: Log compliance data to diagnose the error
console.log('[ComplianceBanner] compliance:', compliance);
console.log('[ComplianceBanner] missing_methods:', compliance?.missing_methods);
console.log('[ComplianceBanner] missing_methods is array:', Array.isArray(compliance?.missing_methods));
// Don't show if no compliance data or already compliant // Don't show if no compliance data or already compliant
if (!compliance || compliance.overall_status === 'compliant' || if (!compliance || compliance.overall_status === 'compliant' ||
compliance.overall_status === 'not_applicable') { compliance.overall_status === 'not_applicable') {
@@ -64,7 +69,8 @@ export function ComplianceBanner({ compliance }: ComplianceBannerProps) {
// Show banner if: // Show banner if:
// 1. MFA is required (effective_mode starts with "require_"), OR // 1. MFA is required (effective_mode starts with "require_"), OR
// 2. There are missing methods (fallback for older data without effective_mode) // 2. There are missing methods (fallback for older data without effective_mode)
if (!mfaRequired && compliance.missing_methods.length === 0) { // Guard against missing_methods being undefined
if (!mfaRequired && (!compliance.missing_methods || compliance.missing_methods.length === 0)) {
return null; return null;
} }
@@ -80,7 +86,7 @@ export function ComplianceBanner({ compliance }: ComplianceBannerProps) {
Your account requires MFA enrollment to access full features. Your account requires MFA enrollment to access full features.
Please configure MFA immediately to restore access. Please configure MFA immediately to restore access.
</p> </p>
{compliance.missing_methods.length > 0 && ( {compliance.missing_methods?.length > 0 && (
<p className="text-sm"> <p className="text-sm">
Required methods: {compliance.missing_methods.join(', ')} Required methods: {compliance.missing_methods.join(', ')}
</p> </p>
@@ -107,7 +113,7 @@ export function ComplianceBanner({ compliance }: ComplianceBannerProps) {
Time remaining: {countdown} Time remaining: {countdown}
</p> </p>
)} )}
{compliance.missing_methods.length > 0 && ( {compliance.missing_methods?.length > 0 && (
<p className="text-sm"> <p className="text-sm">
Required methods: {compliance.missing_methods.join(', ')} Required methods: {compliance.missing_methods.join(', ')}
</p> </p>
@@ -129,7 +135,7 @@ export function ComplianceBanner({ compliance }: ComplianceBannerProps) {
<p> <p>
Your organization has enabled MFA requirements. You have a grace period to configure your authentication methods. Your organization has enabled MFA requirements. You have a grace period to configure your authentication methods.
</p> </p>
{compliance.missing_methods.length > 0 && ( {compliance.missing_methods?.length > 0 && (
<p className="text-sm"> <p className="text-sm">
Required methods: {compliance.missing_methods.join(', ')} Required methods: {compliance.missing_methods.join(', ')}
</p> </p>
+54 -9
View File
@@ -4,6 +4,7 @@ import { api, User, ApiError, tokenManager, MfaComplianceSummary } from '@/lib/a
interface LoginResult { interface LoginResult {
requiresTotp: boolean; requiresTotp: boolean;
requiresWebAuthn: boolean;
requiresMfaEnrollment?: boolean; requiresMfaEnrollment?: boolean;
} }
@@ -15,6 +16,7 @@ interface AuthContextType {
requiresMfaEnrollment: boolean; requiresMfaEnrollment: boolean;
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>; login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>;
verifyTotp: (code: string, isBackupCode?: boolean) => Promise<void>; verifyTotp: (code: string, isBackupCode?: boolean) => Promise<void>;
verifyWebAuthn: () => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
refreshCompliance: () => Promise<void>; refreshCompliance: () => Promise<void>;
@@ -38,13 +40,39 @@ function persistMfaCompliance(compliance: MfaComplianceSummary | null): void {
function loadMfaCompliance(): MfaComplianceSummary | null { function loadMfaCompliance(): MfaComplianceSummary | null {
try { try {
const stored = localStorage.getItem(MFA_COMPLIANCE_KEY); const stored = localStorage.getItem(MFA_COMPLIANCE_KEY);
if (!stored) return null; if (!stored) {
console.log('[AuthContext] loadMfaCompliance: no stored data');
return null;
}
const compliance = JSON.parse(stored); const parsed = JSON.parse(stored);
console.log('[AuthContext] loadMfaCompliance: raw parsed:', parsed);
// Handle both direct format and legacy double-nested format
// Legacy format: { mfa_compliance: { ... } }
// Current format: { ... }
let compliance: Record<string, unknown>;
if (parsed.mfa_compliance && typeof parsed.mfa_compliance === 'object') {
console.log('[AuthContext] loadMfaCompliance: detected legacy double-nested format, unwrapping');
compliance = parsed.mfa_compliance as Record<string, unknown>;
} else {
compliance = parsed;
}
// Validate that the stored data has the required fields // Validate that the stored data has the required fields
if (!compliance || typeof compliance !== 'object') return null; if (!compliance || typeof compliance !== 'object') {
if (!Array.isArray(compliance.orgs)) return null; console.log('[AuthContext] loadMfaCompliance: invalid compliance object');
return null;
}
if (!Array.isArray(compliance.orgs)) {
console.log('[AuthContext] loadMfaCompliance: orgs is not an array');
return null;
}
// Validate missing_methods exists and is an array
if (!Array.isArray(compliance.missing_methods)) {
console.log('[AuthContext] loadMfaCompliance: missing_methods is not an array or missing');
}
// Check if at least one org has effective_mode (new field from API) // Check if at least one org has effective_mode (new field from API)
// If not, treat as stale data and return null to fetch fresh data // If not, treat as stale data and return null to fetch fresh data
@@ -53,11 +81,14 @@ function loadMfaCompliance(): MfaComplianceSummary | null {
); );
if (!hasEffectiveMode) { if (!hasEffectiveMode) {
console.log('[AuthContext] loadMfaCompliance: no effective_mode found, treating as stale');
return null; return null;
} }
return compliance; console.log('[AuthContext] loadMfaCompliance: loaded successfully');
} catch { return compliance as unknown as MfaComplianceSummary;
} catch (error) {
console.log('[AuthContext] loadMfaCompliance: error loading:', error);
return null; return null;
} }
} }
@@ -146,15 +177,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const response = await api.auth.login(email, password, rememberMe); const response = await api.auth.login(email, password, rememberMe);
console.log('[AuthContext] login response:', { console.log('[AuthContext] login response:', {
requires_totp: response.requires_totp, requires_totp: response.requires_totp,
requires_webauthn: response.requires_webauthn,
requires_mfa_enrollment: response.requires_mfa_enrollment, requires_mfa_enrollment: response.requires_mfa_enrollment,
hasToken: !!response.token, hasToken: !!response.token,
hasUser: !!response.user hasUser: !!response.user
}); });
// If WebAuthn is required, don't set user yet - wait for WebAuthn verification
if (response.requires_webauthn) {
console.log('[AuthContext] WebAuthn required, returning early');
return { requiresTotp: false, requiresWebAuthn: true };
}
// If TOTP is required, don't set user yet - wait for TOTP verification // If TOTP is required, don't set user yet - wait for TOTP verification
if (response.requires_totp) { if (response.requires_totp) {
console.log('[AuthContext] TOTP required, returning early'); console.log('[AuthContext] TOTP required, returning early');
return { requiresTotp: true }; return { requiresTotp: true, requiresWebAuthn: false };
} }
// If MFA enrollment is required (past deadline), set compliance state // If MFA enrollment is required (past deadline), set compliance state
@@ -171,7 +209,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
persistMfaCompliance(response.mfa_compliance); persistMfaCompliance(response.mfa_compliance);
} }
setRequiresMfaEnrollment(true); setRequiresMfaEnrollment(true);
return { requiresTotp: false, requiresMfaEnrollment: true }; return { requiresTotp: false, requiresWebAuthn: false, requiresMfaEnrollment: true };
} }
// Login complete: store token explicitly before setting user state // Login complete: store token explicitly before setting user state
@@ -194,9 +232,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setRequiresMfaEnrollment(false); setRequiresMfaEnrollment(false);
navigate('/profile'); navigate('/profile');
} }
return { requiresTotp: false }; return { requiresTotp: false, requiresWebAuthn: false };
}, [navigate]); }, [navigate]);
const verifyWebAuthn = useCallback(async () => {
// WebAuthn verification is handled directly in the LoginPage component
// This is a placeholder for consistency with the interface
console.log('[AuthContext] verifyWebAuthn called - verification handled in LoginPage');
}, []);
const verifyTotp = useCallback(async (code: string, isBackupCode = false) => { const verifyTotp = useCallback(async (code: string, isBackupCode = false) => {
const response = await api.totp.verify(code, isBackupCode); const response = await api.totp.verify(code, isBackupCode);
@@ -244,6 +288,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
requiresMfaEnrollment, requiresMfaEnrollment,
login, login,
verifyTotp, verifyTotp,
verifyWebAuthn,
logout, logout,
refreshUser, refreshUser,
refreshCompliance, refreshCompliance,
+1
View File
@@ -77,6 +77,7 @@ export interface LoginResponse {
token?: string; token?: string;
expires_at?: string; expires_at?: string;
requires_totp?: boolean; requires_totp?: boolean;
requires_webauthn?: boolean;
requires_mfa_enrollment?: boolean; requires_mfa_enrollment?: boolean;
mfa_compliance?: MfaComplianceSummary; mfa_compliance?: MfaComplianceSummary;
} }
+144 -4
View File
@@ -24,7 +24,7 @@ import {
import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard"; import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard";
import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard"; import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard";
type LoginStep = 'credentials' | 'totp' | 'passkey-email' | 'mfa-enrollment'; type LoginStep = 'credentials' | 'totp' | 'webauthn' | 'passkey-email' | 'mfa-enrollment';
export default function LoginPage() { export default function LoginPage() {
const { login, verifyTotp, refreshUser } = useAuth(); const { login, verifyTotp, refreshUser } = useAuth();
@@ -45,14 +45,16 @@ export default function LoginPage() {
try { try {
const result = await login(email, password, rememberMe); const result = await login(email, password, rememberMe);
if (result.requiresTotp) { if (result.requiresWebAuthn) {
setStep('webauthn');
} else if (result.requiresTotp) {
setStep('totp'); setStep('totp');
setTotpCode(""); setTotpCode("");
} else if (result.requiresMfaEnrollment) { } else if (result.requiresMfaEnrollment) {
// MFA enrollment required - will be handled by ProtectedLayout // MFA enrollment required - will be handled by ProtectedLayout
// Navigation happens in AuthContext // Navigation happens in AuthContext
} }
// If no TOTP or MFA enrollment required, navigation happens in AuthContext // If no TOTP, WebAuthn, or MFA enrollment required, navigation happens in AuthContext
} catch (error) { } catch (error) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error("[Gatehouse] Login failed:", error); console.error("[Gatehouse] Login failed:", error);
@@ -123,7 +125,7 @@ export default function LoginPage() {
return; return;
} }
// If we have an email from the form, use it directly // If we have an email from the credentials form or passkey-email step, use it
const emailToUse = email || passkeyEmail; const emailToUse = email || passkeyEmail;
if (!emailToUse) { if (!emailToUse) {
@@ -186,6 +188,74 @@ export default function LoginPage() {
} }
}; };
// Handle WebAuthn verification specifically for the WebAuthn step (after login response)
const handleWebAuthnVerify = async () => {
// Use the email from the credentials form
if (!email) {
toast({
variant: "destructive",
title: "Error",
description: "Email is required. Please go back and try again.",
});
handleBackToCredentials();
return;
}
setIsLoading(true);
try {
// Step 1: Get login options from server
const options = await api.webauthn.beginLogin(email) as WebAuthnLoginOptions;
// Step 2: Create assertion using browser WebAuthn API
const assertion = await createLoginAssertion(options);
// Step 3: Complete login with server
const formattedAssertion = formatLoginAssertion(assertion);
const result = await api.webauthn.completeLogin(formattedAssertion);
// Token is stored by completeLogin, refresh user and navigate
await refreshUser();
navigate('/profile');
toast({
title: "Welcome back",
description: `Signed in as ${result.user.email}`,
});
} catch (error) {
if (import.meta.env.DEV) {
console.error("[Gatehouse] WebAuthn verification failed:", error);
}
let message = "Failed to verify passkey";
if (error instanceof ApiError) {
message = error.message;
} else if (error instanceof DOMException) {
switch (error.name) {
case "NotAllowedError":
message = "Authentication was cancelled or timed out. Please try again or use your authenticator app.";
break;
case "InvalidStateError":
message = "No passkey found for this account.";
break;
default:
message = error.message || message;
}
} else if (error instanceof Error) {
message = error.message;
}
toast({
variant: "destructive",
title: "Verification failed",
description: message,
});
} finally {
setIsLoading(false);
}
};
const handlePasskeyEmailSubmit = async (e: React.FormEvent) => { const handlePasskeyEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!passkeyEmail) return; if (!passkeyEmail) return;
@@ -427,6 +497,76 @@ export default function LoginPage() {
); );
} }
// WebAuthn verification step - shows when user has WebAuthn enrolled
if (step === 'webauthn') {
return (
<div className="auth-card">
<div className="text-center mb-8">
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Fingerprint className="w-6 h-6 text-primary" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
Passkey verification
</h1>
<p className="text-muted-foreground mt-2">
Use your passkey to complete sign in
</p>
</div>
<div className="space-y-4">
<Button
onClick={handleWebAuthnVerify}
disabled={isLoading}
className="w-full"
size="lg"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Authenticating...
</>
) : (
<>
<Fingerprint className="w-5 h-5 mr-2" />
Use Passkey
</>
)}
</Button>
<div className="relative my-6">
<Separator />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-3 text-xs text-muted-foreground">
or
</span>
</div>
<Button
variant="outline"
onClick={() => {
setStep('totp');
setTotpCode("");
setUseBackupCode(false);
}}
disabled={isLoading}
className="w-full"
>
<Smartphone className="w-4 h-4 mr-2" />
Use Authenticator App
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={handleBackToCredentials}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to sign in
</Button>
</div>
</div>
);
}
// Credentials step (default) // Credentials step (default)
return ( return (
<div className="auth-card"> <div className="auth-card">