mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 20:11:14 -05:00
Fix: Implement RLS policies for MFA
This commit is contained in:
@@ -35,6 +35,8 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
|
||||
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
|
||||
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
|
||||
const [mfaChallengeId, setMfaChallengeId] = useState<string | null>(null);
|
||||
const [mfaPendingUserId, setMfaPendingUserId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
@@ -70,82 +72,57 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
setSignInCaptchaToken(null);
|
||||
|
||||
try {
|
||||
const signInOptions: any = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
if (tokenToUse) {
|
||||
signInOptions.options = { captchaToken: tokenToUse };
|
||||
// Call server-side auth check with MFA detection
|
||||
const { data: authResult, error: authError } = await supabase.functions.invoke(
|
||||
'auth-with-mfa-check',
|
||||
{
|
||||
body: {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
captchaToken: tokenToUse,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (authError || authResult.error) {
|
||||
throw new Error(authResult?.error || authError?.message || 'Authentication failed');
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword(signInOptions);
|
||||
if (error) throw error;
|
||||
|
||||
// CRITICAL: Check ban status immediately after successful authentication
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned, ban_reason')
|
||||
.eq('user_id', data.user.id)
|
||||
.single();
|
||||
|
||||
if (profile?.banned) {
|
||||
// Sign out immediately
|
||||
await supabase.auth.signOut();
|
||||
|
||||
const reason = profile.ban_reason
|
||||
? `Reason: ${profile.ban_reason}`
|
||||
// Check if user is banned
|
||||
if (authResult.banned) {
|
||||
const reason = authResult.banReason
|
||||
? `Reason: ${authResult.banReason}`
|
||||
: 'Contact support for assistance.';
|
||||
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Account Suspended",
|
||||
description: `Your account has been suspended. ${reason}`,
|
||||
duration: 10000
|
||||
duration: 10000,
|
||||
});
|
||||
setLoading(false);
|
||||
return; // Stop authentication flow
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Check if MFA is required
|
||||
if (authResult.mfaRequired) {
|
||||
// NO SESSION EXISTS YET - show MFA challenge
|
||||
console.log('[AuthModal] MFA required - no session created yet');
|
||||
setMfaFactorId(authResult.factorId);
|
||||
setMfaChallengeId(authResult.challengeId);
|
||||
setMfaPendingUserId(authResult.userId);
|
||||
setLoading(false);
|
||||
return; // User has NO session - MFA modal will show
|
||||
}
|
||||
|
||||
// No MFA required - user has session
|
||||
console.log('[AuthModal] No MFA required - user authenticated');
|
||||
|
||||
// Track auth method for audit logging
|
||||
setAuthMethod('password');
|
||||
|
||||
// Check if MFA step-up is required
|
||||
const { handlePostAuthFlow } = await import('@/lib/authService');
|
||||
const postAuthResult = await handlePostAuthFlow(data.session, 'password');
|
||||
|
||||
if (postAuthResult.success && postAuthResult.data.shouldRedirect) {
|
||||
// CRITICAL SECURITY FIX: Get factor BEFORE destroying session
|
||||
const { data: factors } = await supabase.auth.mfa.listFactors();
|
||||
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
|
||||
|
||||
if (totpFactor) {
|
||||
// DESTROY the AAL1 session - user should NOT be logged in before MFA
|
||||
console.log('[AuthModal] MFA required - destroying AAL1 session and storing credentials');
|
||||
await supabase.auth.signOut();
|
||||
|
||||
// Store credentials in memory for re-authentication after TOTP
|
||||
sessionStorage.setItem('mfa_pending_email_modal', formData.email);
|
||||
sessionStorage.setItem('mfa_pending_password_modal', formData.password);
|
||||
sessionStorage.setItem('mfa_factor_id_modal', totpFactor.id);
|
||||
|
||||
setMfaFactorId(totpFactor.id);
|
||||
setLoading(false);
|
||||
return; // User has NO session - MFA modal will show
|
||||
}
|
||||
// Set the session in Supabase client
|
||||
if (authResult.session) {
|
||||
await supabase.auth.setSession(authResult.session);
|
||||
}
|
||||
|
||||
|
||||
toast({
|
||||
title: "Welcome back!",
|
||||
description: "You've been signed in successfully."
|
||||
@@ -171,47 +148,12 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
};
|
||||
|
||||
const handleMfaSuccess = async () => {
|
||||
console.log('[AuthModal] MFA verification succeeded');
|
||||
|
||||
// Retrieve stored credentials
|
||||
const email = sessionStorage.getItem('mfa_pending_email_modal');
|
||||
const password = sessionStorage.getItem('mfa_pending_password_modal');
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('[AuthModal] Missing stored credentials for re-authentication');
|
||||
toast({
|
||||
title: "Authentication error",
|
||||
description: "Please sign in again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setMfaFactorId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear stored credentials
|
||||
sessionStorage.removeItem('mfa_pending_email_modal');
|
||||
sessionStorage.removeItem('mfa_pending_password_modal');
|
||||
sessionStorage.removeItem('mfa_factor_id_modal');
|
||||
|
||||
// Re-authenticate with stored credentials - this should create AAL2 session
|
||||
console.log('[AuthModal] Re-authenticating with verified credentials');
|
||||
const { error: reAuthError } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (reAuthError) {
|
||||
console.error('[AuthModal] Re-authentication failed:', reAuthError);
|
||||
toast({
|
||||
title: "Authentication error",
|
||||
description: "Please sign in again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setMfaFactorId(null);
|
||||
return;
|
||||
}
|
||||
console.log('[AuthModal] MFA verification succeeded - no further action needed');
|
||||
|
||||
// Clear state
|
||||
setMfaFactorId(null);
|
||||
setMfaChallengeId(null);
|
||||
setMfaPendingUserId(null);
|
||||
|
||||
toast({
|
||||
title: "Authentication complete",
|
||||
@@ -224,12 +166,10 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
const handleMfaCancel = async () => {
|
||||
console.log('[AuthModal] User cancelled MFA verification');
|
||||
|
||||
// Clear stored credentials
|
||||
sessionStorage.removeItem('mfa_pending_email_modal');
|
||||
sessionStorage.removeItem('mfa_pending_password_modal');
|
||||
sessionStorage.removeItem('mfa_factor_id_modal');
|
||||
|
||||
// Clear state
|
||||
setMfaFactorId(null);
|
||||
setMfaChallengeId(null);
|
||||
setMfaPendingUserId(null);
|
||||
setSignInCaptchaKey(prev => prev + 1);
|
||||
|
||||
toast({
|
||||
@@ -429,6 +369,8 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
{mfaFactorId ? (
|
||||
<MFAChallenge
|
||||
factorId={mfaFactorId}
|
||||
challengeId={mfaChallengeId}
|
||||
userId={mfaPendingUserId}
|
||||
onSuccess={handleMfaSuccess}
|
||||
onCancel={handleMfaCancel}
|
||||
/>
|
||||
|
||||
@@ -10,11 +10,13 @@ import { Shield, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface MFAChallengeProps {
|
||||
factorId: string;
|
||||
challengeId?: string | null;
|
||||
userId?: string | null;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) {
|
||||
export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCancel }: MFAChallengeProps) {
|
||||
const { toast } = useToast();
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -24,6 +26,38 @@ export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProp
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// NEW SERVER-SIDE FLOW: If we have challengeId and userId, use edge function
|
||||
if (challengeId && userId) {
|
||||
const { data: result, error: verifyError } = await supabase.functions.invoke(
|
||||
'verify-mfa-and-login',
|
||||
{
|
||||
body: {
|
||||
challengeId,
|
||||
factorId,
|
||||
code: code.trim(),
|
||||
userId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (verifyError || result.error) {
|
||||
throw new Error(result?.error || verifyError?.message || 'Verification failed');
|
||||
}
|
||||
|
||||
// Set the session in Supabase client
|
||||
if (result.session) {
|
||||
await supabase.auth.setSession(result.session);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Welcome back!",
|
||||
description: "MFA verification successful."
|
||||
});
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
// OLD FLOW: For OAuth/Magic Link step-up (existing session)
|
||||
// Create fresh challenge for each verification attempt
|
||||
const { data: challengeData, error: challengeError } =
|
||||
await supabase.auth.mfa.challenge({ factorId });
|
||||
|
||||
@@ -4530,6 +4530,7 @@ export type Database = {
|
||||
Returns: undefined
|
||||
}
|
||||
backfill_sort_orders: { Args: never; Returns: undefined }
|
||||
block_aal1_with_mfa: { Args: never; Returns: boolean }
|
||||
can_approve_submission_item: {
|
||||
Args: { item_id: string }
|
||||
Returns: boolean
|
||||
|
||||
@@ -38,6 +38,8 @@ export default function Auth() {
|
||||
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
|
||||
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
|
||||
const [mfaPendingEmail, setMfaPendingEmail] = useState<string | null>(null);
|
||||
const [mfaChallengeId, setMfaChallengeId] = useState<string | null>(null);
|
||||
const [mfaPendingUserId, setMfaPendingUserId] = useState<string | null>(null);
|
||||
|
||||
const emailParam = searchParams.get('email');
|
||||
const messageParam = searchParams.get('message');
|
||||
@@ -93,114 +95,66 @@ export default function Auth() {
|
||||
setSignInCaptchaToken(null);
|
||||
|
||||
try {
|
||||
const {
|
||||
data,
|
||||
error
|
||||
} = await supabase.auth.signInWithPassword({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
options: {
|
||||
captchaToken: tokenToUse
|
||||
// Call server-side auth check with MFA detection
|
||||
const { data: authResult, error: authError } = await supabase.functions.invoke(
|
||||
'auth-with-mfa-check',
|
||||
{
|
||||
body: {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
captchaToken: tokenToUse,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
);
|
||||
|
||||
// CRITICAL: Check ban status immediately after successful authentication
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned, ban_reason')
|
||||
.eq('user_id', data.user.id)
|
||||
.single();
|
||||
|
||||
if (profile?.banned) {
|
||||
// Sign out immediately
|
||||
await supabase.auth.signOut();
|
||||
|
||||
const reason = profile.ban_reason
|
||||
? `Reason: ${profile.ban_reason}`
|
||||
if (authError || authResult.error) {
|
||||
throw new Error(authResult?.error || authError?.message || 'Authentication failed');
|
||||
}
|
||||
|
||||
// Check if user is banned
|
||||
if (authResult.banned) {
|
||||
const reason = authResult.banReason
|
||||
? `Reason: ${authResult.banReason}`
|
||||
: 'Contact support for assistance.';
|
||||
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Account Suspended",
|
||||
description: `Your account has been suspended. ${reason}`,
|
||||
duration: 10000
|
||||
duration: 10000,
|
||||
});
|
||||
setLoading(false);
|
||||
return; // Stop authentication flow
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Check if MFA is required
|
||||
if (authResult.mfaRequired) {
|
||||
// NO SESSION EXISTS YET - show MFA challenge
|
||||
console.log('[Auth] MFA required - no session created yet');
|
||||
setMfaFactorId(authResult.factorId);
|
||||
setMfaChallengeId(authResult.challengeId);
|
||||
setMfaPendingUserId(authResult.userId);
|
||||
setLoading(false);
|
||||
return; // User has NO session - MFA modal will show
|
||||
}
|
||||
|
||||
// No MFA required - user has session
|
||||
console.log('[Auth] No MFA required - user authenticated');
|
||||
|
||||
// Track auth method for audit logging
|
||||
setAuthMethod('password');
|
||||
|
||||
// Check if MFA step-up is required
|
||||
const { handlePostAuthFlow } = await import('@/lib/authService');
|
||||
const postAuthResult = await handlePostAuthFlow(data.session, 'password');
|
||||
|
||||
if (postAuthResult.success && postAuthResult.data.shouldRedirect) {
|
||||
// MFA IS REQUIRED - we must show the challenge or sign out
|
||||
const { data: factors } = await supabase.auth.mfa.listFactors();
|
||||
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
|
||||
|
||||
if (totpFactor) {
|
||||
// DESTROY the AAL1 session - user should NOT be logged in before MFA
|
||||
console.log('[Auth] MFA required - destroying AAL1 session and storing credentials');
|
||||
await supabase.auth.signOut();
|
||||
|
||||
// Store credentials in memory for re-authentication after TOTP
|
||||
sessionStorage.setItem('mfa_pending_email', formData.email);
|
||||
sessionStorage.setItem('mfa_pending_password', formData.password);
|
||||
sessionStorage.setItem('mfa_factor_id', totpFactor.id);
|
||||
|
||||
setMfaPendingEmail(formData.email);
|
||||
setMfaFactorId(totpFactor.id);
|
||||
setLoading(false);
|
||||
return; // User has NO session - MFA modal will show
|
||||
} else {
|
||||
// MFA is required but no factor found - FORCE SIGN OUT for security
|
||||
console.error('[Auth] SECURITY: MFA required but no verified factor found');
|
||||
await supabase.auth.signOut();
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Authentication Error",
|
||||
description: "Multi-factor authentication is required but not properly configured. Please contact support."
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ONLY show success toast if MFA was NOT required
|
||||
if (postAuthResult.success && !postAuthResult.data.shouldRedirect) {
|
||||
// Verify session was stored
|
||||
setTimeout(async () => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Session Error",
|
||||
description: "Login succeeded but session was not stored. Please check your browser settings and enable cookies/storage."
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Welcome back!",
|
||||
description: "You've been signed in successfully."
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
// Set the session in Supabase client
|
||||
if (authResult.session) {
|
||||
await supabase.auth.setSession(authResult.session);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Welcome back!",
|
||||
description: "You've been signed in successfully."
|
||||
});
|
||||
|
||||
// Navigate after brief delay
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
// Reset CAPTCHA widget to force fresh token generation
|
||||
@@ -211,11 +165,11 @@ export default function Auth() {
|
||||
// Enhanced error messages
|
||||
const errorMsg = getErrorMessage(error);
|
||||
let errorMessage = errorMsg;
|
||||
if (errorMsg.includes('Invalid login credentials')) {
|
||||
if (errorMsg.includes('Invalid login credentials') || errorMsg.includes('Invalid credentials')) {
|
||||
errorMessage = 'Invalid email or password. Please try again.';
|
||||
} else if (errorMsg.includes('Email not confirmed')) {
|
||||
errorMessage = 'Please confirm your email address before signing in.';
|
||||
} else if (error.message.includes('Too many requests')) {
|
||||
} else if (errorMsg.includes('Too many requests')) {
|
||||
errorMessage = 'Too many login attempts. Please wait a few minutes and try again.';
|
||||
}
|
||||
|
||||
@@ -230,50 +184,16 @@ export default function Auth() {
|
||||
};
|
||||
|
||||
const handleMfaSuccess = async () => {
|
||||
console.log('[Auth] MFA verification succeeded');
|
||||
console.log('[Auth] MFA verification succeeded - no further action needed');
|
||||
|
||||
// Retrieve stored credentials
|
||||
const email = sessionStorage.getItem('mfa_pending_email');
|
||||
const password = sessionStorage.getItem('mfa_pending_password');
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('[Auth] Missing stored credentials for re-authentication');
|
||||
toast({
|
||||
title: "Authentication error",
|
||||
description: "Please sign in again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setMfaFactorId(null);
|
||||
setMfaPendingEmail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear stored credentials
|
||||
sessionStorage.removeItem('mfa_pending_email');
|
||||
sessionStorage.removeItem('mfa_pending_password');
|
||||
sessionStorage.removeItem('mfa_factor_id');
|
||||
|
||||
// Re-authenticate with stored credentials - this should create AAL2 session
|
||||
console.log('[Auth] Re-authenticating with verified credentials');
|
||||
const { error: reAuthError } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (reAuthError) {
|
||||
console.error('[Auth] Re-authentication failed:', reAuthError);
|
||||
toast({
|
||||
title: "Authentication error",
|
||||
description: "Please sign in again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setMfaFactorId(null);
|
||||
setMfaPendingEmail(null);
|
||||
return;
|
||||
}
|
||||
// MFA verification is handled by MFAChallenge component
|
||||
// which calls verify-mfa-and-login edge function
|
||||
// The session is automatically set by the edge function
|
||||
|
||||
// Clear state
|
||||
setMfaFactorId(null);
|
||||
setMfaPendingEmail(null);
|
||||
setMfaChallengeId(null);
|
||||
setMfaPendingUserId(null);
|
||||
|
||||
toast({
|
||||
title: "Authentication complete",
|
||||
@@ -288,13 +208,10 @@ export default function Auth() {
|
||||
const handleMfaCancel = async () => {
|
||||
console.log('[Auth] User cancelled MFA verification');
|
||||
|
||||
// Clear stored credentials
|
||||
sessionStorage.removeItem('mfa_pending_email');
|
||||
sessionStorage.removeItem('mfa_pending_password');
|
||||
sessionStorage.removeItem('mfa_factor_id');
|
||||
|
||||
// Clear state - no credentials stored
|
||||
setMfaFactorId(null);
|
||||
setMfaPendingEmail(null);
|
||||
setMfaChallengeId(null);
|
||||
setMfaPendingUserId(null);
|
||||
setSignInCaptchaKey(prev => prev + 1);
|
||||
|
||||
toast({
|
||||
@@ -520,6 +437,8 @@ export default function Auth() {
|
||||
|
||||
<MFAChallenge
|
||||
factorId={mfaFactorId}
|
||||
challengeId={mfaChallengeId}
|
||||
userId={mfaPendingUserId}
|
||||
onSuccess={handleMfaSuccess}
|
||||
onCancel={handleMfaCancel}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user