From a0d341c4e0f1cc93dbed4cbcc4866177c63d9aeb Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Oct 2025 19:50:29 +0000 Subject: [PATCH] Fix: Implement MFA removal protection --- src/components/auth/MFARemovalDialog.tsx | 72 ++++++----- src/components/auth/TOTPSetup.tsx | 14 ++- src/lib/securityValidation.ts | 2 +- supabase/functions/mfa-unenroll/index.ts | 146 +++++++++++++++++++++++ 4 files changed, 201 insertions(+), 33 deletions(-) create mode 100644 supabase/functions/mfa-unenroll/index.ts diff --git a/src/components/auth/MFARemovalDialog.tsx b/src/components/auth/MFARemovalDialog.tsx index ecf7ee95..f0192f45 100644 --- a/src/components/auth/MFARemovalDialog.tsx +++ b/src/components/auth/MFARemovalDialog.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; import { getErrorMessage } from '@/lib/errorHandler'; +import { useRequireMFA } from '@/hooks/useRequireMFA'; import { AlertDialog, AlertDialogAction, @@ -26,11 +27,29 @@ interface MFARemovalDialogProps { } export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MFARemovalDialogProps) { + const { requiresMFA } = useRequireMFA(); const [step, setStep] = useState<'password' | 'totp' | 'confirm'>('password'); const [password, setPassword] = useState(''); const [totpCode, setTotpCode] = useState(''); const [loading, setLoading] = useState(false); + // Phase 1: Check AAL2 requirement on dialog open + useEffect(() => { + if (open) { + const checkAalLevel = async () => { + const { data: { session } } = await supabase.auth.getSession(); + const currentAal = (session as any)?.aal || 'aal1'; + + if (currentAal !== 'aal2') { + toast.error('Please verify your identity with MFA before making security changes'); + onOpenChange(false); + } + }; + + checkAalLevel(); + } + }, [open, onOpenChange]); + const handleClose = () => { setStep('password'); setPassword(''); @@ -93,6 +112,14 @@ export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MF if (verifyError) throw verifyError; + // Phase 1: Verify session is at AAL2 after TOTP verification + const { data: { session } } = await supabase.auth.getSession(); + const currentAal = (session as any)?.aal || 'aal1'; + + if (currentAal !== 'aal2') { + throw new Error('Session must be at AAL2 to remove MFA'); + } + toast.success('TOTP code verified'); setStep('confirm'); } catch (error) { @@ -104,39 +131,22 @@ export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MF }; const handleMFARemoval = async () => { + // Phase 2: Check if user's role requires MFA + if (requiresMFA) { + toast.error('Your role requires two-factor authentication and it cannot be disabled'); + handleClose(); + return; + } + setLoading(true); try { - // Unenroll the factor - const { error } = await supabase.auth.mfa.unenroll({ factorId }); + // Phase 3: Call edge function instead of direct unenroll + const { data, error } = await supabase.functions.invoke('mfa-unenroll', { + body: { factorId } + }); + if (error) throw error; - - // Log the action - const { data: { user } } = await supabase.auth.getUser(); - if (user) { - await supabase.from('admin_audit_log').insert({ - admin_user_id: user.id, - target_user_id: user.id, - action: 'mfa_disabled', - details: { - factor_id: factorId, - timestamp: new Date().toISOString(), - user_agent: navigator.userAgent - } - }); - - // Trigger email notification - await supabase.functions.invoke('trigger-notification', { - body: { - userId: user.id, - workflowId: 'security-alert', - payload: { - action: 'MFA Disabled', - message: 'Two-factor authentication has been disabled on your account.', - timestamp: new Date().toISOString() - } - } - }).catch(err => console.error('Notification error:', err)); - } + if (data?.error) throw new Error(data.error); toast.success('Two-factor authentication has been disabled'); handleClose(); diff --git a/src/components/auth/TOTPSetup.tsx b/src/components/auth/TOTPSetup.tsx index 138e76d2..0c5f3269 100644 --- a/src/components/auth/TOTPSetup.tsx +++ b/src/components/auth/TOTPSetup.tsx @@ -8,8 +8,9 @@ import { Badge } from '@/components/ui/badge'; import { handleError, handleSuccess, handleInfo, AppError, getErrorMessage } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; import { useAuth } from '@/hooks/useAuth'; +import { useRequireMFA } from '@/hooks/useRequireMFA'; import { supabase } from '@/integrations/supabase/client'; -import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2 } from 'lucide-react'; +import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2, AlertTriangle } from 'lucide-react'; import { MFARemovalDialog } from './MFARemovalDialog'; import { setStepUpRequired, getAuthMethod } from '@/lib/sessionFlags'; import { useNavigate } from 'react-router-dom'; @@ -17,6 +18,7 @@ import type { MFAFactor } from '@/types/auth'; export function TOTPSetup() { const { user } = useAuth(); + const { requiresMFA } = useRequireMFA(); const navigate = useNavigate(); const [factors, setFactors] = useState([]); const [loading, setLoading] = useState(false); @@ -274,6 +276,16 @@ export function TOTPSetup() { + {/* Phase 2: Warning for role-required users */} + {requiresMFA && ( + + + + Your role requires MFA. You cannot disable two-factor authentication. + + + )} +
diff --git a/src/lib/securityValidation.ts b/src/lib/securityValidation.ts index 97f83dda..c917a276 100644 --- a/src/lib/securityValidation.ts +++ b/src/lib/securityValidation.ts @@ -85,7 +85,7 @@ export function getRateLimitParams(operation: SecurityOperation): { identity_connect: { action: 'identity_connect', maxAttempts: 5, windowMinutes: 60 }, session_revoke: { action: 'session_revoke', maxAttempts: 10, windowMinutes: 60 }, mfa_enroll: { action: 'mfa_enroll', maxAttempts: 3, windowMinutes: 60 }, - mfa_unenroll: { action: 'mfa_unenroll', maxAttempts: 2, windowMinutes: 60 }, + mfa_unenroll: { action: 'mfa_unenroll', maxAttempts: 2, windowMinutes: 1440 }, // Phase 4: 2 per day }; return limits[operation] || { action: operation, maxAttempts: 5, windowMinutes: 60 }; diff --git a/supabase/functions/mfa-unenroll/index.ts b/supabase/functions/mfa-unenroll/index.ts new file mode 100644 index 00000000..68c74ae8 --- /dev/null +++ b/supabase/functions/mfa-unenroll/index.ts @@ -0,0 +1,146 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +Deno.serve(async (req) => { + // Handle CORS preflight requests + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + // Create Supabase client with user's auth token + const supabaseClient = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_ANON_KEY')!, + { global: { headers: { Authorization: req.headers.get('Authorization')! } } } + ); + + // Get authenticated user + const { data: { user }, error: userError } = await supabaseClient.auth.getUser(); + if (userError || !user) { + console.error('Authentication failed:', userError); + return new Response( + JSON.stringify({ error: 'Unauthorized' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + console.log('[mfa-unenroll] Processing request for user:', user.id); + + // Phase 1: Check AAL level + const { data: { session } } = await supabaseClient.auth.getSession(); + const aal = session?.aal || 'aal1'; + + if (aal !== 'aal2') { + console.warn('[mfa-unenroll] AAL2 required, current:', aal); + return new Response( + JSON.stringify({ error: 'AAL2 required to remove MFA' }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Phase 2: Check if user's role requires MFA + const { data: roles } = await supabaseClient + .from('user_roles') + .select('role') + .eq('user_id', user.id); + + const requiresMFA = roles?.some(r => ['admin', 'moderator', 'superuser'].includes(r.role)); + + if (requiresMFA) { + console.warn('[mfa-unenroll] Role requires MFA, blocking removal'); + return new Response( + JSON.stringify({ error: 'Your role requires MFA and it cannot be disabled' }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Phase 4: Check rate limit (2 attempts per 24 hours) + const { data: recentAttempts } = await supabaseClient + .from('admin_audit_log') + .select('created_at') + .eq('admin_user_id', user.id) + .eq('action', 'mfa_disabled') + .gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); + + if (recentAttempts && recentAttempts.length >= 2) { + console.warn('[mfa-unenroll] Rate limit exceeded:', recentAttempts.length, 'attempts'); + return new Response( + JSON.stringify({ error: 'Rate limit exceeded. Try again in 24 hours.' }), + { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Get factor ID from request + const { factorId } = await req.json(); + if (!factorId) { + return new Response( + JSON.stringify({ error: 'Factor ID required' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Phase 3: Proceed with unenrollment + const { error: unenrollError } = await supabaseClient.auth.mfa.unenroll({ factorId }); + + if (unenrollError) { + console.error('[mfa-unenroll] Unenroll failed:', unenrollError); + return new Response( + JSON.stringify({ error: unenrollError.message }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Audit log the action + const { error: auditError } = await supabaseClient.from('admin_audit_log').insert({ + admin_user_id: user.id, + target_user_id: user.id, + action: 'mfa_disabled', + details: { + factorId, + timestamp: new Date().toISOString(), + user_agent: req.headers.get('user-agent') || 'unknown', + aal_level: aal + } + }); + + if (auditError) { + console.error('[mfa-unenroll] Audit log failed:', auditError); + } + + // Send security notification + try { + await supabaseClient.functions.invoke('trigger-notification', { + body: { + userId: user.id, + workflowId: 'security-alert', + payload: { + action: 'MFA Disabled', + message: 'Two-factor authentication has been disabled on your account.', + timestamp: new Date().toISOString() + } + } + }); + } catch (notifError) { + console.error('[mfa-unenroll] Notification failed:', notifError); + } + + console.log('[mfa-unenroll] MFA successfully disabled for user:', user.id); + + return new Response( + JSON.stringify({ success: true }), + { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + + } catch (error) { + console.error('[mfa-unenroll] Unexpected error:', error); + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } +});