feat: Implement MFA Challenge Support

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 13:39:05 +00:00
parent 3e520e1520
commit 121f7c533a
3 changed files with 198 additions and 3 deletions

View File

@@ -11,6 +11,7 @@ import { useToast } from '@/hooks/use-toast';
import { TurnstileCaptcha } from './TurnstileCaptcha'; import { TurnstileCaptcha } from './TurnstileCaptcha';
import { notificationService } from '@/lib/notificationService'; import { notificationService } from '@/lib/notificationService';
import { useCaptchaBypass } from '@/hooks/useCaptchaBypass'; import { useCaptchaBypass } from '@/hooks/useCaptchaBypass';
import { MFAChallenge } from './MFAChallenge';
interface AuthModalProps { interface AuthModalProps {
open: boolean; open: boolean;
@@ -27,6 +28,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
const [captchaKey, setCaptchaKey] = useState(0); const [captchaKey, setCaptchaKey] = useState(0);
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null); const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0); const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: '', email: '',
password: '', password: '',
@@ -71,9 +73,20 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
signInOptions.options = { captchaToken: tokenToUse }; signInOptions.options = { captchaToken: tokenToUse };
} }
const { error } = await supabase.auth.signInWithPassword(signInOptions); const { data, error } = await supabase.auth.signInWithPassword(signInOptions);
if (error) throw error; if (error) throw error;
// Check if MFA is required (user exists but no session)
if (data.user && !data.session) {
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return;
}
}
toast({ toast({
title: "Welcome back!", title: "Welcome back!",
description: "You've been signed in successfully." description: "You've been signed in successfully."
@@ -95,6 +108,16 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
} }
}; };
const handleMfaSuccess = () => {
setMfaFactorId(null);
onOpenChange(false);
};
const handleMfaCancel = () => {
setMfaFactorId(null);
setSignInCaptchaKey(prev => prev + 1);
};
const handleSignUp = async (e: React.FormEvent) => { const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@@ -261,7 +284,15 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
</TabsList> </TabsList>
<TabsContent value="signin" className="space-y-4 mt-4"> <TabsContent value="signin" className="space-y-4 mt-4">
<form onSubmit={handleSignIn} className="space-y-4"> {mfaFactorId ? (
<MFAChallenge
factorId={mfaFactorId}
onSuccess={handleMfaSuccess}
onCancel={handleMfaCancel}
/>
) : (
<>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="modal-signin-email">Email</Label> <Label htmlFor="modal-signin-email">Email</Label>
<div className="relative"> <div className="relative">
@@ -374,6 +405,8 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
</Button> </Button>
</div> </div>
</div> </div>
</>
)}
</TabsContent> </TabsContent>
<TabsContent value="signup" className="space-y-3 sm:space-y-4 mt-4"> <TabsContent value="signup" className="space-y-3 sm:space-y-4 mt-4">

View File

@@ -0,0 +1,124 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
import { Shield } from 'lucide-react';
interface MFAChallengeProps {
factorId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) {
const { toast } = useToast();
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [challengeId, setChallengeId] = useState<string | null>(null);
// Create MFA challenge on mount
useEffect(() => {
const createChallenge = async () => {
try {
const { data, error } = await supabase.auth.mfa.challenge({ factorId });
if (error) throw error;
setChallengeId(data.id);
} catch (error: any) {
toast({
variant: "destructive",
title: "MFA Challenge Failed",
description: error.message
});
onCancel();
}
};
createChallenge();
}, [factorId, onCancel, toast]);
const handleVerify = async () => {
if (code.length !== 6 || !challengeId) return;
setLoading(true);
try {
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId,
code
});
if (error) throw error;
if (data) {
toast({
title: "Welcome back!",
description: "MFA verification successful."
});
onSuccess();
}
} catch (error: any) {
toast({
variant: "destructive",
title: "Verification Failed",
description: error.message || "Invalid code. Please try again."
});
setCode('');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-primary">
<Shield className="w-5 h-5" />
<h3 className="font-semibold">Two-Factor Authentication</h3>
</div>
<p className="text-sm text-muted-foreground">
Enter the 6-digit code from your authenticator app
</p>
<div className="space-y-2">
<Label htmlFor="mfa-code">Authentication Code</Label>
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={code}
onChange={setCode}
onComplete={handleVerify}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={onCancel}
className="flex-1"
disabled={loading}
>
Cancel
</Button>
<Button
onClick={handleVerify}
className="flex-1"
disabled={code.length !== 6 || loading}
>
{loading ? "Verifying..." : "Verify"}
</Button>
</div>
</div>
);
}

View File

@@ -15,6 +15,8 @@ import { useToast } from '@/hooks/use-toast';
import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha'; import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha';
import { notificationService } from '@/lib/notificationService'; import { notificationService } from '@/lib/notificationService';
import { StorageWarning } from '@/components/auth/StorageWarning'; import { StorageWarning } from '@/components/auth/StorageWarning';
import { MFAChallenge } from '@/components/auth/MFAChallenge';
export default function Auth() { export default function Auth() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -28,6 +30,7 @@ export default function Auth() {
const [captchaKey, setCaptchaKey] = useState(0); const [captchaKey, setCaptchaKey] = useState(0);
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null); const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0); const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: '', email: '',
password: '', password: '',
@@ -89,6 +92,18 @@ export default function Auth() {
if (error) throw error; if (error) throw error;
// Check if MFA is required (user exists but no session)
if (data.user && !data.session) {
console.log('[Auth] MFA required');
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return;
}
}
console.log('[Auth] Sign in successful', { console.log('[Auth] Sign in successful', {
user: data.user?.email, user: data.user?.email,
session: !!data.session, session: !!data.session,
@@ -139,6 +154,19 @@ export default function Auth() {
setLoading(false); setLoading(false);
} }
}; };
const handleMfaSuccess = () => {
setMfaFactorId(null);
toast({
title: "Welcome back!",
description: "You've been signed in successfully."
});
};
const handleMfaCancel = () => {
setMfaFactorId(null);
setSignInCaptchaKey(prev => prev + 1);
};
const handleSignUp = async (e: React.FormEvent) => { const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@@ -324,7 +352,15 @@ export default function Auth() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSignIn} className="space-y-4"> {mfaFactorId ? (
<MFAChallenge
factorId={mfaFactorId}
onSuccess={handleMfaSuccess}
onCancel={handleMfaCancel}
/>
) : (
<>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="signin-email">Email</Label> <Label htmlFor="signin-email">Email</Label>
<div className="relative"> <div className="relative">
@@ -419,6 +455,8 @@ export default function Auth() {
</Button> </Button>
</div> </div>
</div> </div>
</>
)}
</CardContent> </CardContent>
</TabsContent> </TabsContent>