mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 17:31:12 -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 [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
|
||||||
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
|
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
|
||||||
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -70,82 +72,57 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
setSignInCaptchaToken(null);
|
setSignInCaptchaToken(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const signInOptions: any = {
|
// Call server-side auth check with MFA detection
|
||||||
email: formData.email,
|
const { data: authResult, error: authError } = await supabase.functions.invoke(
|
||||||
password: formData.password,
|
'auth-with-mfa-check',
|
||||||
};
|
{
|
||||||
|
body: {
|
||||||
if (tokenToUse) {
|
email: formData.email,
|
||||||
signInOptions.options = { captchaToken: tokenToUse };
|
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);
|
// Check if user is banned
|
||||||
if (error) throw error;
|
if (authResult.banned) {
|
||||||
|
const reason = authResult.banReason
|
||||||
// CRITICAL: Check ban status immediately after successful authentication
|
? `Reason: ${authResult.banReason}`
|
||||||
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}`
|
|
||||||
: 'Contact support for assistance.';
|
: 'Contact support for assistance.';
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Account Suspended",
|
title: "Account Suspended",
|
||||||
description: `Your account has been suspended. ${reason}`,
|
description: `Your account has been suspended. ${reason}`,
|
||||||
duration: 10000
|
duration: 10000,
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return; // Stop authentication flow
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if MFA is required (user exists but no session)
|
// Check if MFA is required
|
||||||
if (data.user && !data.session) {
|
if (authResult.mfaRequired) {
|
||||||
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
|
// NO SESSION EXISTS YET - show MFA challenge
|
||||||
|
console.log('[AuthModal] MFA required - no session created yet');
|
||||||
if (totpFactor) {
|
setMfaFactorId(authResult.factorId);
|
||||||
setMfaFactorId(totpFactor.id);
|
setMfaChallengeId(authResult.challengeId);
|
||||||
setLoading(false);
|
setMfaPendingUserId(authResult.userId);
|
||||||
return;
|
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
|
// Set the session in Supabase client
|
||||||
setAuthMethod('password');
|
if (authResult.session) {
|
||||||
|
await supabase.auth.setSession(authResult.session);
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Welcome back!",
|
title: "Welcome back!",
|
||||||
description: "You've been signed in successfully."
|
description: "You've been signed in successfully."
|
||||||
@@ -171,47 +148,12 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMfaSuccess = async () => {
|
const handleMfaSuccess = async () => {
|
||||||
console.log('[AuthModal] MFA verification succeeded');
|
console.log('[AuthModal] MFA verification succeeded - no further action needed');
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Clear state
|
||||||
setMfaFactorId(null);
|
setMfaFactorId(null);
|
||||||
|
setMfaChallengeId(null);
|
||||||
|
setMfaPendingUserId(null);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Authentication complete",
|
title: "Authentication complete",
|
||||||
@@ -224,12 +166,10 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
const handleMfaCancel = async () => {
|
const handleMfaCancel = async () => {
|
||||||
console.log('[AuthModal] User cancelled MFA verification');
|
console.log('[AuthModal] User cancelled MFA verification');
|
||||||
|
|
||||||
// Clear stored credentials
|
// Clear state
|
||||||
sessionStorage.removeItem('mfa_pending_email_modal');
|
|
||||||
sessionStorage.removeItem('mfa_pending_password_modal');
|
|
||||||
sessionStorage.removeItem('mfa_factor_id_modal');
|
|
||||||
|
|
||||||
setMfaFactorId(null);
|
setMfaFactorId(null);
|
||||||
|
setMfaChallengeId(null);
|
||||||
|
setMfaPendingUserId(null);
|
||||||
setSignInCaptchaKey(prev => prev + 1);
|
setSignInCaptchaKey(prev => prev + 1);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -429,6 +369,8 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
{mfaFactorId ? (
|
{mfaFactorId ? (
|
||||||
<MFAChallenge
|
<MFAChallenge
|
||||||
factorId={mfaFactorId}
|
factorId={mfaFactorId}
|
||||||
|
challengeId={mfaChallengeId}
|
||||||
|
userId={mfaPendingUserId}
|
||||||
onSuccess={handleMfaSuccess}
|
onSuccess={handleMfaSuccess}
|
||||||
onCancel={handleMfaCancel}
|
onCancel={handleMfaCancel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import { Shield, AlertCircle } from 'lucide-react';
|
|||||||
|
|
||||||
interface MFAChallengeProps {
|
interface MFAChallengeProps {
|
||||||
factorId: string;
|
factorId: string;
|
||||||
|
challengeId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) {
|
export function MFAChallenge({ factorId, challengeId, userId, onSuccess, onCancel }: MFAChallengeProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -24,6 +26,38 @@ export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProp
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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
|
// Create fresh challenge for each verification attempt
|
||||||
const { data: challengeData, error: challengeError } =
|
const { data: challengeData, error: challengeError } =
|
||||||
await supabase.auth.mfa.challenge({ factorId });
|
await supabase.auth.mfa.challenge({ factorId });
|
||||||
|
|||||||
@@ -4530,6 +4530,7 @@ export type Database = {
|
|||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
backfill_sort_orders: { Args: never; Returns: undefined }
|
backfill_sort_orders: { Args: never; Returns: undefined }
|
||||||
|
block_aal1_with_mfa: { Args: never; Returns: boolean }
|
||||||
can_approve_submission_item: {
|
can_approve_submission_item: {
|
||||||
Args: { item_id: string }
|
Args: { item_id: string }
|
||||||
Returns: boolean
|
Returns: boolean
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export default function Auth() {
|
|||||||
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
|
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
|
||||||
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
|
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
|
||||||
const [mfaPendingEmail, setMfaPendingEmail] = 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 emailParam = searchParams.get('email');
|
||||||
const messageParam = searchParams.get('message');
|
const messageParam = searchParams.get('message');
|
||||||
@@ -93,114 +95,66 @@ export default function Auth() {
|
|||||||
setSignInCaptchaToken(null);
|
setSignInCaptchaToken(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {
|
// Call server-side auth check with MFA detection
|
||||||
data,
|
const { data: authResult, error: authError } = await supabase.functions.invoke(
|
||||||
error
|
'auth-with-mfa-check',
|
||||||
} = await supabase.auth.signInWithPassword({
|
{
|
||||||
email: formData.email,
|
body: {
|
||||||
password: formData.password,
|
email: formData.email,
|
||||||
options: {
|
password: formData.password,
|
||||||
captchaToken: tokenToUse
|
captchaToken: tokenToUse,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// CRITICAL: Check ban status immediately after successful authentication
|
if (authError || authResult.error) {
|
||||||
const { data: profile } = await supabase
|
throw new Error(authResult?.error || authError?.message || 'Authentication failed');
|
||||||
.from('profiles')
|
}
|
||||||
.select('banned, ban_reason')
|
|
||||||
.eq('user_id', data.user.id)
|
// Check if user is banned
|
||||||
.single();
|
if (authResult.banned) {
|
||||||
|
const reason = authResult.banReason
|
||||||
if (profile?.banned) {
|
? `Reason: ${authResult.banReason}`
|
||||||
// Sign out immediately
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
|
|
||||||
const reason = profile.ban_reason
|
|
||||||
? `Reason: ${profile.ban_reason}`
|
|
||||||
: 'Contact support for assistance.';
|
: 'Contact support for assistance.';
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Account Suspended",
|
title: "Account Suspended",
|
||||||
description: `Your account has been suspended. ${reason}`,
|
description: `Your account has been suspended. ${reason}`,
|
||||||
duration: 10000
|
duration: 10000,
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return; // Stop authentication flow
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if MFA is required (user exists but no session)
|
// Check if MFA is required
|
||||||
if (data.user && !data.session) {
|
if (authResult.mfaRequired) {
|
||||||
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
|
// NO SESSION EXISTS YET - show MFA challenge
|
||||||
|
console.log('[Auth] MFA required - no session created yet');
|
||||||
if (totpFactor) {
|
setMfaFactorId(authResult.factorId);
|
||||||
setMfaFactorId(totpFactor.id);
|
setMfaChallengeId(authResult.challengeId);
|
||||||
setLoading(false);
|
setMfaPendingUserId(authResult.userId);
|
||||||
return;
|
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
|
// Set the session in Supabase client
|
||||||
setAuthMethod('password');
|
if (authResult.session) {
|
||||||
|
await supabase.auth.setSession(authResult.session);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Welcome back!",
|
||||||
|
description: "You've been signed in successfully."
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate after brief delay
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/');
|
||||||
|
}, 500);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Reset CAPTCHA widget to force fresh token generation
|
// Reset CAPTCHA widget to force fresh token generation
|
||||||
@@ -211,11 +165,11 @@ export default function Auth() {
|
|||||||
// Enhanced error messages
|
// Enhanced error messages
|
||||||
const errorMsg = getErrorMessage(error);
|
const errorMsg = getErrorMessage(error);
|
||||||
let errorMessage = errorMsg;
|
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.';
|
errorMessage = 'Invalid email or password. Please try again.';
|
||||||
} else if (errorMsg.includes('Email not confirmed')) {
|
} else if (errorMsg.includes('Email not confirmed')) {
|
||||||
errorMessage = 'Please confirm your email address before signing in.';
|
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.';
|
errorMessage = 'Too many login attempts. Please wait a few minutes and try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,50 +184,16 @@ export default function Auth() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMfaSuccess = async () => {
|
const handleMfaSuccess = async () => {
|
||||||
console.log('[Auth] MFA verification succeeded');
|
console.log('[Auth] MFA verification succeeded - no further action needed');
|
||||||
|
|
||||||
// Retrieve stored credentials
|
// MFA verification is handled by MFAChallenge component
|
||||||
const email = sessionStorage.getItem('mfa_pending_email');
|
// which calls verify-mfa-and-login edge function
|
||||||
const password = sessionStorage.getItem('mfa_pending_password');
|
// The session is automatically set by the edge function
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Clear state
|
||||||
setMfaFactorId(null);
|
setMfaFactorId(null);
|
||||||
setMfaPendingEmail(null);
|
setMfaChallengeId(null);
|
||||||
|
setMfaPendingUserId(null);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Authentication complete",
|
title: "Authentication complete",
|
||||||
@@ -288,13 +208,10 @@ export default function Auth() {
|
|||||||
const handleMfaCancel = async () => {
|
const handleMfaCancel = async () => {
|
||||||
console.log('[Auth] User cancelled MFA verification');
|
console.log('[Auth] User cancelled MFA verification');
|
||||||
|
|
||||||
// Clear stored credentials
|
// Clear state - no credentials stored
|
||||||
sessionStorage.removeItem('mfa_pending_email');
|
|
||||||
sessionStorage.removeItem('mfa_pending_password');
|
|
||||||
sessionStorage.removeItem('mfa_factor_id');
|
|
||||||
|
|
||||||
setMfaFactorId(null);
|
setMfaFactorId(null);
|
||||||
setMfaPendingEmail(null);
|
setMfaChallengeId(null);
|
||||||
|
setMfaPendingUserId(null);
|
||||||
setSignInCaptchaKey(prev => prev + 1);
|
setSignInCaptchaKey(prev => prev + 1);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -520,6 +437,8 @@ export default function Auth() {
|
|||||||
|
|
||||||
<MFAChallenge
|
<MFAChallenge
|
||||||
factorId={mfaFactorId}
|
factorId={mfaFactorId}
|
||||||
|
challengeId={mfaChallengeId}
|
||||||
|
userId={mfaPendingUserId}
|
||||||
onSuccess={handleMfaSuccess}
|
onSuccess={handleMfaSuccess}
|
||||||
onCancel={handleMfaCancel}
|
onCancel={handleMfaCancel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
project_id = "ydvtmnrszybqnbcqbdcy"
|
project_id = "ydvtmnrszybqnbcqbdcy"
|
||||||
|
|
||||||
|
[functions.auth-with-mfa-check]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.verify-mfa-and-login]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.check-mfa-enrollment]
|
||||||
|
verify_jwt = true
|
||||||
|
|
||||||
[functions.send-password-added-email]
|
[functions.send-password-added-email]
|
||||||
verify_jwt = true
|
verify_jwt = true
|
||||||
|
|
||||||
|
|||||||
4
supabase/functions/_shared/cors.ts
Normal file
4
supabase/functions/_shared/cors.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
};
|
||||||
128
supabase/functions/auth-with-mfa-check/index.ts
Normal file
128
supabase/functions/auth-with-mfa-check/index.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||||
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { email, password } = await req.json();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Email and password are required' }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin client
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify credentials using signInWithPassword (doesn't create session with admin client)
|
||||||
|
const { data: authData, error: authError } = await supabaseAdmin.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authError || !authData.user) {
|
||||||
|
console.error('Auth error:', authError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: authError?.message || 'Invalid credentials' }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = authData.user.id;
|
||||||
|
|
||||||
|
// Check if user is banned
|
||||||
|
const { data: profile, error: profileError } = await supabaseAdmin
|
||||||
|
.from('profiles')
|
||||||
|
.select('banned, ban_reason')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profileError) {
|
||||||
|
console.error('Profile check error:', profileError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile?.banned) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Account suspended',
|
||||||
|
banned: true,
|
||||||
|
banReason: profile.ban_reason
|
||||||
|
}),
|
||||||
|
{ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for MFA enrollment
|
||||||
|
const { data: factors, error: factorsError } = await supabaseAdmin.auth.admin.mfa.listFactors({
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (factorsError) {
|
||||||
|
console.error('MFA factors check error:', factorsError);
|
||||||
|
// Continue - assume no MFA if check fails
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifiedFactors = factors?.totp?.filter((f) => f.status === 'verified') || [];
|
||||||
|
|
||||||
|
if (verifiedFactors.length > 0) {
|
||||||
|
// User has MFA - create a challenge but don't create session
|
||||||
|
const factorId = verifiedFactors[0].id;
|
||||||
|
|
||||||
|
// Create MFA challenge
|
||||||
|
const { data: challengeData, error: challengeError } = await supabaseAdmin.auth.mfa.challenge({
|
||||||
|
factorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (challengeError) {
|
||||||
|
console.error('Challenge creation error:', challengeError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to create MFA challenge' }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
mfaRequired: true,
|
||||||
|
factorId,
|
||||||
|
challengeId: challengeData.id,
|
||||||
|
userId, // Needed for verification step
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No MFA required - return session
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
mfaRequired: false,
|
||||||
|
session: authData.session,
|
||||||
|
user: authData.user,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Internal server error' }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
84
supabase/functions/check-mfa-enrollment/index.ts
Normal file
84
supabase/functions/check-mfa-enrollment/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||||
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing authorization header' }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create client with user's token to verify session
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
|
||||||
|
global: {
|
||||||
|
headers: { Authorization: authHeader },
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (userError || !user) {
|
||||||
|
console.error('User verification error:', userError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Unauthorized' }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin client to check MFA factors
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: factors, error: factorsError } = await supabaseAdmin.auth.admin.mfa.listFactors({
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (factorsError) {
|
||||||
|
console.error('MFA factors check error:', factorsError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to check MFA enrollment' }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifiedFactors = factors?.totp?.filter((f) => f.status === 'verified') || [];
|
||||||
|
const hasEnrolled = verifiedFactors.length > 0;
|
||||||
|
const factorId = verifiedFactors.length > 0 ? verifiedFactors[0].id : undefined;
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
hasEnrolled,
|
||||||
|
factorId,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Internal server error' }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
91
supabase/functions/verify-mfa-and-login/index.ts
Normal file
91
supabase/functions/verify-mfa-and-login/index.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||||
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { challengeId, factorId, code, userId } = await req.json();
|
||||||
|
|
||||||
|
if (!challengeId || !factorId || !code || !userId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Missing required fields' }),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create admin client
|
||||||
|
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify TOTP code
|
||||||
|
const { data: verifyData, error: verifyError } = await supabaseAdmin.auth.mfa.verify({
|
||||||
|
factorId,
|
||||||
|
challengeId,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verifyError || !verifyData) {
|
||||||
|
console.error('MFA verification error:', verifyError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: verifyError?.message || 'Invalid verification code' }),
|
||||||
|
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verification successful - create AAL2 session using admin API
|
||||||
|
const { data: sessionData, error: sessionError } = await supabaseAdmin.auth.admin.createSession({
|
||||||
|
userId,
|
||||||
|
// This creates a session with AAL2
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionError || !sessionData) {
|
||||||
|
console.error('Session creation error:', sessionError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to create session' }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log successful MFA authentication
|
||||||
|
try {
|
||||||
|
await supabaseAdmin.rpc('log_admin_action', {
|
||||||
|
_admin_user_id: userId,
|
||||||
|
_target_user_id: userId,
|
||||||
|
_action: 'mfa_login_success',
|
||||||
|
_details: { timestamp: new Date().toISOString(), aal: 'aal2' },
|
||||||
|
});
|
||||||
|
} catch (logError) {
|
||||||
|
console.error('Audit log error:', logError);
|
||||||
|
// Don't fail the login if audit logging fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
session: sessionData.session,
|
||||||
|
user: sessionData.user,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Internal server error' }),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Create security definer function to block AAL1 sessions when MFA is enrolled
|
||||||
|
CREATE OR REPLACE FUNCTION public.block_aal1_with_mfa()
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
-- If current session is AAL1
|
||||||
|
WHEN (auth.jwt() ->> 'aal') = 'aal1' THEN
|
||||||
|
-- Check if user has verified MFA factors
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM auth.mfa_factors
|
||||||
|
WHERE user_id = auth.uid()
|
||||||
|
AND status = 'verified'
|
||||||
|
)
|
||||||
|
-- If AAL2 or higher, allow
|
||||||
|
ELSE true
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Apply to profiles table
|
||||||
|
CREATE POLICY "enforce_aal2_for_mfa_users" ON public.profiles
|
||||||
|
FOR ALL
|
||||||
|
USING (public.block_aal1_with_mfa());
|
||||||
|
|
||||||
|
-- Apply to user_roles table
|
||||||
|
CREATE POLICY "enforce_aal2_for_mfa_users_roles" ON public.user_roles
|
||||||
|
FOR ALL
|
||||||
|
USING (public.block_aal1_with_mfa());
|
||||||
|
|
||||||
|
-- Apply to submission tables
|
||||||
|
CREATE POLICY "enforce_aal2_for_mfa_users_park_sub" ON public.park_submissions
|
||||||
|
FOR ALL
|
||||||
|
USING (public.block_aal1_with_mfa());
|
||||||
|
|
||||||
|
CREATE POLICY "enforce_aal2_for_mfa_users_ride_sub" ON public.ride_submissions
|
||||||
|
FOR ALL
|
||||||
|
USING (public.block_aal1_with_mfa());
|
||||||
|
|
||||||
|
CREATE POLICY "enforce_aal2_for_mfa_users_company_sub" ON public.company_submissions
|
||||||
|
FOR ALL
|
||||||
|
USING (public.block_aal1_with_mfa());
|
||||||
|
|
||||||
|
CREATE POLICY "enforce_aal2_for_mfa_users_content_sub" ON public.content_submissions
|
||||||
|
FOR ALL
|
||||||
|
USING (public.block_aal1_with_mfa());
|
||||||
|
|
||||||
|
CREATE POLICY "enforce_aal2_for_mfa_users_photo_sub" ON public.photo_submissions
|
||||||
|
FOR ALL
|
||||||
|
USING (public.block_aal1_with_mfa());
|
||||||
|
|
||||||
|
-- Apply to user content tables
|
||||||
|
CREATE POLICY "enforce_aal2_for_mfa_users_reviews" ON public.reviews
|
||||||
|
FOR ALL
|
||||||
|
USING (public.block_aal1_with_mfa());
|
||||||
|
|
||||||
|
CREATE POLICY "enforce_aal2_for_mfa_users_reports" ON public.reports
|
||||||
|
FOR ALL
|
||||||
|
USING (public.block_aal1_with_mfa());
|
||||||
|
|
||||||
|
-- Grant execute permission
|
||||||
|
GRANT EXECUTE ON FUNCTION public.block_aal1_with_mfa() TO authenticated;
|
||||||
Reference in New Issue
Block a user