mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11:13 -05:00
feat: Implement MFA step-up system
This commit is contained in:
@@ -7,6 +7,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
|||||||
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
|
||||||
import { AuthProvider } from "@/hooks/useAuth";
|
import { AuthProvider } from "@/hooks/useAuth";
|
||||||
import { AuthModalProvider } from "@/contexts/AuthModalContext";
|
import { AuthModalProvider } from "@/contexts/AuthModalContext";
|
||||||
|
import { MFAStepUpProvider } from "@/contexts/MFAStepUpContext";
|
||||||
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
|
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
|
||||||
import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper";
|
import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
@@ -385,9 +386,11 @@ const App = (): React.JSX.Element => (
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthModalProvider>
|
<AuthModalProvider>
|
||||||
<BrowserRouter>
|
<MFAStepUpProvider>
|
||||||
<AppContent />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<AppContent />
|
||||||
|
</BrowserRouter>
|
||||||
|
</MFAStepUpProvider>
|
||||||
</AuthModalProvider>
|
</AuthModalProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} position="bottom" />}
|
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} position="bottom" />}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { Beaker, CheckCircle, ChevronDown, Trash2, AlertTriangle } from 'lucide-
|
|||||||
import { clearTestData, getTestDataStats } from '@/lib/testDataGenerator';
|
import { clearTestData, getTestDataStats } from '@/lib/testDataGenerator';
|
||||||
import { TestDataTracker } from '@/lib/integrationTests/TestDataTracker';
|
import { TestDataTracker } from '@/lib/integrationTests/TestDataTracker';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { useMFAStepUp } from '@/contexts/MFAStepUpContext';
|
||||||
|
import { isMFACancelledError } from '@/lib/aalErrorDetection';
|
||||||
|
|
||||||
const PRESETS = {
|
const PRESETS = {
|
||||||
small: { label: 'Small', description: '~30 submissions - Quick test', counts: '5 parks, 10 rides, 3 companies, 2 models, 5 photo sets' },
|
small: { label: 'Small', description: '~30 submissions - Quick test', counts: '5 parks, 10 rides, 3 companies, 2 models, 5 photo sets' },
|
||||||
@@ -44,6 +46,7 @@ interface TestDataResults {
|
|||||||
|
|
||||||
export function TestDataGenerator(): React.JSX.Element {
|
export function TestDataGenerator(): React.JSX.Element {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { requireAAL2 } = useMFAStepUp();
|
||||||
const [preset, setPreset] = useState<'small' | 'medium' | 'large' | 'stress'>('small');
|
const [preset, setPreset] = useState<'small' | 'medium' | 'large' | 'stress'>('small');
|
||||||
const [fieldDensity, setFieldDensity] = useState<'mixed' | 'minimal' | 'standard' | 'maximum'>('mixed');
|
const [fieldDensity, setFieldDensity] = useState<'mixed' | 'minimal' | 'standard' | 'maximum'>('mixed');
|
||||||
const [entityTypes, setEntityTypes] = useState({
|
const [entityTypes, setEntityTypes] = useState({
|
||||||
@@ -168,7 +171,12 @@ export function TestDataGenerator(): React.JSX.Element {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { deleted } = await clearTestData();
|
// Wrap operation with AAL2 requirement
|
||||||
|
const { deleted } = await requireAAL2(
|
||||||
|
() => clearTestData(),
|
||||||
|
'Clearing test data requires additional verification'
|
||||||
|
);
|
||||||
|
|
||||||
await loadStats();
|
await loadStats();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -177,11 +185,14 @@ export function TestDataGenerator(): React.JSX.Element {
|
|||||||
});
|
});
|
||||||
setResults(null);
|
setResults(null);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast({
|
// Only show error if it's NOT an MFA cancellation
|
||||||
title: 'Clear Failed',
|
if (!isMFACancelledError(error)) {
|
||||||
description: getErrorMessage(error),
|
toast({
|
||||||
variant: 'destructive'
|
title: 'Clear Failed',
|
||||||
});
|
description: getErrorMessage(error),
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -191,7 +202,12 @@ export function TestDataGenerator(): React.JSX.Element {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { deleted, errors } = await TestDataTracker.bulkCleanupAllTestData();
|
// Wrap operation with AAL2 requirement
|
||||||
|
const { deleted, errors } = await requireAAL2(
|
||||||
|
() => TestDataTracker.bulkCleanupAllTestData(),
|
||||||
|
'Emergency cleanup requires additional verification'
|
||||||
|
);
|
||||||
|
|
||||||
await loadStats();
|
await loadStats();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -200,11 +216,14 @@ export function TestDataGenerator(): React.JSX.Element {
|
|||||||
});
|
});
|
||||||
setResults(null);
|
setResults(null);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast({
|
// Only show error if it's NOT an MFA cancellation
|
||||||
title: 'Emergency Cleanup Failed',
|
if (!isMFACancelledError(error)) {
|
||||||
description: getErrorMessage(error),
|
toast({
|
||||||
variant: 'destructive'
|
title: 'Emergency Cleanup Failed',
|
||||||
});
|
description: getErrorMessage(error),
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
156
src/contexts/MFAStepUpContext.tsx
Normal file
156
src/contexts/MFAStepUpContext.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* MFA Step-Up Context
|
||||||
|
*
|
||||||
|
* Provides global MFA step-up functionality that automatically
|
||||||
|
* prompts users for MFA verification when operations require AAL2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { MFAStepUpModal } from '@/components/auth/MFAStepUpModal';
|
||||||
|
import { getEnrolledFactors } from '@/lib/authService';
|
||||||
|
import { isAAL2PolicyError, getAAL2ErrorMessage, MFACancelledError } from '@/lib/aalErrorDetection';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
interface MFAStepUpContextType {
|
||||||
|
/**
|
||||||
|
* Wrap an operation that may require AAL2 verification
|
||||||
|
* If operation fails due to AAL2, prompts user for MFA and retries
|
||||||
|
*/
|
||||||
|
requireAAL2: <T>(operation: () => Promise<T>, errorMessage?: string) => Promise<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is AAL2-related
|
||||||
|
*/
|
||||||
|
isAAL2Error: (error: unknown) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MFAStepUpContext = createContext<MFAStepUpContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface MFAStepUpProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MFAStepUpProvider({ children }: MFAStepUpProviderProps) {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [factorId, setFactorId] = useState<string>('');
|
||||||
|
const [pendingOperation, setPendingOperation] = useState<(() => Promise<any>) | null>(null);
|
||||||
|
const [pendingResolve, setPendingResolve] = useState<((value: any) => void) | null>(null);
|
||||||
|
const [pendingReject, setPendingReject] = useState<((error: any) => void) | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful MFA verification
|
||||||
|
* Retries the pending operation with upgraded AAL2 session
|
||||||
|
*/
|
||||||
|
const handleMFASuccess = useCallback(async () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
|
||||||
|
if (!pendingOperation || !pendingResolve || !pendingReject) {
|
||||||
|
logger.error('No pending operation to retry after MFA success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.log('[MFAStepUp] Retrying operation after AAL2 upgrade');
|
||||||
|
const result = await pendingOperation();
|
||||||
|
pendingResolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[MFAStepUp] Operation failed after AAL2 upgrade', { error });
|
||||||
|
pendingReject(error);
|
||||||
|
} finally {
|
||||||
|
// Clean up
|
||||||
|
setPendingOperation(null);
|
||||||
|
setPendingResolve(null);
|
||||||
|
setPendingReject(null);
|
||||||
|
}
|
||||||
|
}, [pendingOperation, pendingResolve, pendingReject]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle MFA cancellation
|
||||||
|
*/
|
||||||
|
const handleMFACancel = useCallback(() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
|
||||||
|
if (pendingReject) {
|
||||||
|
pendingReject(new MFACancelledError());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
setPendingOperation(null);
|
||||||
|
setPendingResolve(null);
|
||||||
|
setPendingReject(null);
|
||||||
|
}, [pendingReject]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an operation with AAL2 requirement checking
|
||||||
|
*/
|
||||||
|
const requireAAL2 = useCallback(
|
||||||
|
async <T,>(operation: () => Promise<T>, customErrorMessage?: string): Promise<T> => {
|
||||||
|
try {
|
||||||
|
// Try the operation first
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
// Check if error is AAL2-related
|
||||||
|
if (!isAAL2PolicyError(error)) {
|
||||||
|
// Not an AAL2 error, re-throw
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('[MFAStepUp] AAL2 error detected, prompting for MFA', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get enrolled factors
|
||||||
|
const factors = await getEnrolledFactors();
|
||||||
|
|
||||||
|
if (factors.length === 0) {
|
||||||
|
logger.error('[MFAStepUp] No enrolled MFA factors found');
|
||||||
|
throw new Error('MFA is not set up for your account');
|
||||||
|
}
|
||||||
|
|
||||||
|
const factor = factors[0];
|
||||||
|
setFactorId(factor.id);
|
||||||
|
|
||||||
|
// Return a promise that resolves when MFA is verified
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
setPendingOperation(() => operation);
|
||||||
|
setPendingResolve(() => resolve);
|
||||||
|
setPendingReject(() => reject);
|
||||||
|
setModalOpen(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value: MFAStepUpContextType = {
|
||||||
|
requireAAL2,
|
||||||
|
isAAL2Error: isAAL2PolicyError,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MFAStepUpContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<MFAStepUpModal
|
||||||
|
open={modalOpen}
|
||||||
|
factorId={factorId}
|
||||||
|
onSuccess={handleMFASuccess}
|
||||||
|
onCancel={handleMFACancel}
|
||||||
|
/>
|
||||||
|
</MFAStepUpContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access MFA step-up functionality
|
||||||
|
*/
|
||||||
|
export function useMFAStepUp(): MFAStepUpContextType {
|
||||||
|
const context = useContext(MFAStepUpContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useMFAStepUp must be used within MFAStepUpProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
125
src/lib/aalErrorDetection.ts
Normal file
125
src/lib/aalErrorDetection.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* AAL2 Error Detection Utilities
|
||||||
|
*
|
||||||
|
* Detects when operations fail due to AAL2/MFA requirements
|
||||||
|
* and provides user-friendly error messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PostgrestError } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is due to AAL2/RLS policy failure
|
||||||
|
*/
|
||||||
|
export function isAAL2PolicyError(error: unknown): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
|
||||||
|
// Handle Supabase PostgrestError
|
||||||
|
if (isPostgrestError(error)) {
|
||||||
|
const code = error.code;
|
||||||
|
const message = error.message?.toLowerCase() || '';
|
||||||
|
const details = error.details?.toLowerCase() || '';
|
||||||
|
|
||||||
|
// Check for RLS policy violations
|
||||||
|
if (code === 'PGRST301' || code === '42501') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for permission denied messages
|
||||||
|
if (
|
||||||
|
message.includes('permission denied') ||
|
||||||
|
message.includes('row-level security') ||
|
||||||
|
message.includes('policy') ||
|
||||||
|
details.includes('policy')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle generic errors with 403 status
|
||||||
|
if (hasStatusCode(error) && error.status === 403) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error messages
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes('row-level security') ||
|
||||||
|
message.includes('permission denied') ||
|
||||||
|
message.includes('policy') ||
|
||||||
|
message.includes('403')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-friendly error message for AAL2 errors
|
||||||
|
*/
|
||||||
|
export function getAAL2ErrorMessage(error: unknown): string {
|
||||||
|
// Default message
|
||||||
|
const defaultMessage = 'This action requires additional security verification';
|
||||||
|
|
||||||
|
if (!error) return defaultMessage;
|
||||||
|
|
||||||
|
// Check if error mentions specific operations
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
|
||||||
|
if (message.includes('delete') || message.includes('remove')) {
|
||||||
|
return 'Deleting this data requires additional security verification';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('update') || message.includes('modify')) {
|
||||||
|
return 'Modifying this data requires additional security verification';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('insert') || message.includes('create')) {
|
||||||
|
return 'Creating this data requires additional security verification';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for PostgrestError
|
||||||
|
*/
|
||||||
|
function isPostgrestError(error: unknown): error is PostgrestError {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
'message' in error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for errors with status code
|
||||||
|
*/
|
||||||
|
function hasStatusCode(error: unknown): error is { status: number } {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'status' in error &&
|
||||||
|
typeof (error as any).status === 'number'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a user-cancellation error
|
||||||
|
*/
|
||||||
|
export class MFACancelledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('MFA verification was cancelled by user');
|
||||||
|
this.name = 'MFACancelledError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is a user cancellation
|
||||||
|
*/
|
||||||
|
export function isMFACancelledError(error: unknown): boolean {
|
||||||
|
return error instanceof MFACancelledError;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user