mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 08:11:13 -05:00
Fix: Implement MFA removal protection
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getErrorMessage } from '@/lib/errorHandler';
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { useRequireMFA } from '@/hooks/useRequireMFA';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -26,11 +27,29 @@ interface MFARemovalDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MFARemovalDialogProps) {
|
export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MFARemovalDialogProps) {
|
||||||
|
const { requiresMFA } = useRequireMFA();
|
||||||
const [step, setStep] = useState<'password' | 'totp' | 'confirm'>('password');
|
const [step, setStep] = useState<'password' | 'totp' | 'confirm'>('password');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
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 = () => {
|
const handleClose = () => {
|
||||||
setStep('password');
|
setStep('password');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
@@ -93,6 +112,14 @@ export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MF
|
|||||||
|
|
||||||
if (verifyError) throw verifyError;
|
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');
|
toast.success('TOTP code verified');
|
||||||
setStep('confirm');
|
setStep('confirm');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -104,39 +131,22 @@ export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MF
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMFARemoval = async () => {
|
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);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Unenroll the factor
|
// Phase 3: Call edge function instead of direct unenroll
|
||||||
const { error } = await supabase.auth.mfa.unenroll({ factorId });
|
const { data, error } = await supabase.functions.invoke('mfa-unenroll', {
|
||||||
if (error) throw error;
|
body: { factorId }
|
||||||
|
|
||||||
// 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
|
if (error) throw error;
|
||||||
await supabase.functions.invoke('trigger-notification', {
|
if (data?.error) throw new Error(data.error);
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Two-factor authentication has been disabled');
|
toast.success('Two-factor authentication has been disabled');
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { handleError, handleSuccess, handleInfo, AppError, getErrorMessage } from '@/lib/errorHandler';
|
import { handleError, handleSuccess, handleInfo, AppError, getErrorMessage } from '@/lib/errorHandler';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useRequireMFA } from '@/hooks/useRequireMFA';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
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 { MFARemovalDialog } from './MFARemovalDialog';
|
||||||
import { setStepUpRequired, getAuthMethod } from '@/lib/sessionFlags';
|
import { setStepUpRequired, getAuthMethod } from '@/lib/sessionFlags';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -17,6 +18,7 @@ import type { MFAFactor } from '@/types/auth';
|
|||||||
|
|
||||||
export function TOTPSetup() {
|
export function TOTPSetup() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { requiresMFA } = useRequireMFA();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [factors, setFactors] = useState<MFAFactor[]>([]);
|
const [factors, setFactors] = useState<MFAFactor[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -274,6 +276,16 @@ export function TOTPSetup() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
{/* Phase 2: Warning for role-required users */}
|
||||||
|
{requiresMFA && (
|
||||||
|
<Alert variant="default" className="border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<AlertDescription className="text-amber-800 dark:text-amber-200">
|
||||||
|
Your role requires MFA. You cannot disable two-factor authentication.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-green-50 dark:bg-green-950 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-green-50 dark:bg-green-950 rounded-full flex items-center justify-center">
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function getRateLimitParams(operation: SecurityOperation): {
|
|||||||
identity_connect: { action: 'identity_connect', maxAttempts: 5, windowMinutes: 60 },
|
identity_connect: { action: 'identity_connect', maxAttempts: 5, windowMinutes: 60 },
|
||||||
session_revoke: { action: 'session_revoke', maxAttempts: 10, windowMinutes: 60 },
|
session_revoke: { action: 'session_revoke', maxAttempts: 10, windowMinutes: 60 },
|
||||||
mfa_enroll: { action: 'mfa_enroll', maxAttempts: 3, 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 };
|
return limits[operation] || { action: operation, maxAttempts: 5, windowMinutes: 60 };
|
||||||
|
|||||||
146
supabase/functions/mfa-unenroll/index.ts
Normal file
146
supabase/functions/mfa-unenroll/index.ts
Normal file
@@ -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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user