feat: Implement secure MFA removal

This commit is contained in:
gpt-engineer-app[bot]
2025-10-01 15:06:17 +00:00
parent d6efa2d39f
commit 05ef5acee9
2 changed files with 265 additions and 54 deletions

View File

@@ -0,0 +1,245 @@
import { useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Shield, AlertTriangle } from 'lucide-react';
interface MFARemovalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
factorId: string;
onSuccess: () => void;
}
export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MFARemovalDialogProps) {
const [step, setStep] = useState<'password' | 'totp' | 'confirm'>('password');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [loading, setLoading] = useState(false);
const handleClose = () => {
setStep('password');
setPassword('');
setTotpCode('');
setLoading(false);
onOpenChange(false);
};
const handlePasswordVerification = async () => {
if (!password.trim()) {
toast.error('Please enter your password');
return;
}
setLoading(true);
try {
// Get current user email
const { data: { user } } = await supabase.auth.getUser();
if (!user?.email) throw new Error('User email not found');
// Re-authenticate with password
const { error } = await supabase.auth.signInWithPassword({
email: user.email,
password: password
});
if (error) throw error;
toast.success('Password verified');
setStep('totp');
} catch (error: any) {
console.error('Password verification failed:', error);
toast.error('Invalid password. Please try again.');
} finally {
setLoading(false);
}
};
const handleTOTPVerification = async () => {
if (!totpCode.trim() || totpCode.length !== 6) {
toast.error('Please enter a valid 6-digit code');
return;
}
setLoading(true);
try {
// Create challenge
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
factorId
});
if (challengeError) throw challengeError;
// Verify TOTP code
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code: totpCode.trim()
});
if (verifyError) throw verifyError;
toast.success('TOTP code verified');
setStep('confirm');
} catch (error: any) {
console.error('TOTP verification failed:', error);
toast.error('Invalid code. Please try again.');
} finally {
setLoading(false);
}
};
const handleMFARemoval = async () => {
setLoading(true);
try {
// Unenroll the factor
const { error } = await supabase.auth.mfa.unenroll({ 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));
}
toast.success('Two-factor authentication has been disabled');
handleClose();
onSuccess();
} catch (error: any) {
console.error('MFA removal failed:', error);
toast.error('Failed to disable MFA. Please try again.');
} finally {
setLoading(false);
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-destructive" />
Disable Two-Factor Authentication
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Disabling MFA will make your account less secure. You'll need to verify your identity first.
</AlertDescription>
</Alert>
{step === 'password' && (
<div className="space-y-3">
<p className="text-sm">Step 1 of 3: Enter your password to continue</p>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handlePasswordVerification()}
placeholder="Enter your password"
disabled={loading}
/>
</div>
</div>
)}
{step === 'totp' && (
<div className="space-y-3">
<p className="text-sm">Step 2 of 3: Enter your current TOTP code</p>
<div className="space-y-2">
<Label htmlFor="totp">Verification Code</Label>
<Input
id="totp"
type="text"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
onKeyDown={(e) => e.key === 'Enter' && handleTOTPVerification()}
placeholder="000000"
maxLength={6}
disabled={loading}
className="text-center text-2xl tracking-widest"
/>
</div>
</div>
)}
{step === 'confirm' && (
<div className="space-y-3">
<p className="text-sm font-semibold">Step 3 of 3: Final confirmation</p>
<p className="text-sm text-muted-foreground">
Are you sure you want to disable two-factor authentication? This will:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>Remove TOTP protection from your account</li>
<li>Send a security notification email</li>
<li>Log this action in your security history</li>
</ul>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleClose} disabled={loading}>
Cancel
</AlertDialogCancel>
{step === 'password' && (
<Button onClick={handlePasswordVerification} disabled={loading}>
{loading ? 'Verifying...' : 'Continue'}
</Button>
)}
{step === 'totp' && (
<Button onClick={handleTOTPVerification} disabled={loading}>
{loading ? 'Verifying...' : 'Continue'}
</Button>
)}
{step === 'confirm' && (
<AlertDialogAction onClick={handleMFARemoval} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{loading ? 'Disabling...' : 'Disable MFA'}
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -5,11 +5,11 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2, AlertCircle } from 'lucide-react';
import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2 } from 'lucide-react';
import { MFARemovalDialog } from './MFARemovalDialog';
interface TOTPFactor {
id: string;
@@ -30,6 +30,7 @@ export function TOTPSetup() {
const [factorId, setFactorId] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [showRemovalDialog, setShowRemovalDialog] = useState(false);
useEffect(() => {
fetchTOTPFactors();
@@ -133,30 +134,8 @@ export function TOTPSetup() {
}
};
const unenrollFactor = async (factorId: string) => {
setLoading(true);
try {
const { error } = await supabase.auth.mfa.unenroll({
factorId
});
if (error) throw error;
toast({
title: 'TOTP Disabled',
description: 'Two-factor authentication has been disabled for your account.'
});
fetchTOTPFactors();
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to disable TOTP',
variant: 'destructive'
});
} finally {
setLoading(false);
}
const handleRemovalSuccess = async () => {
await fetchTOTPFactors();
};
const copySecret = () => {
@@ -281,36 +260,23 @@ export function TOTPSetup() {
<Badge variant="secondary" className="bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300">
Active
</Badge>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
<Button
variant="outline"
size="sm"
onClick={() => setShowRemovalDialog(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Disable
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-destructive" />
Disable Two-Factor Authentication
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to disable two-factor authentication? This will make your account less secure.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => unenrollFactor(activeFactor.id)}
className="bg-destructive hover:bg-destructive/90"
>
Disable TOTP
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<MFARemovalDialog
open={showRemovalDialog}
onOpenChange={setShowRemovalDialog}
factorId={activeFactor.id}
onSuccess={handleRemovalSuccess}
/>
</div>
) : (
<div className="flex items-center justify-between p-4 border rounded-lg">