mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 06:31:14 -05:00
Fix: Implement MFA enforcement and critical bug fix
This commit is contained in:
@@ -2,6 +2,9 @@ import { ReactNode } from 'react';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AdminSidebar } from './AdminSidebar';
|
||||
import { AdminTopBar } from './AdminTopBar';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useSessionMonitor } from '@/hooks/useSessionMonitor';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -20,6 +23,8 @@ export function AdminLayout({
|
||||
lastUpdated,
|
||||
isRefreshing
|
||||
}: AdminLayoutProps) {
|
||||
const { aalWarning } = useSessionMonitor();
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={true}>
|
||||
<div className="flex min-h-screen w-full">
|
||||
@@ -34,6 +39,15 @@ export function AdminLayout({
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto bg-muted/30">
|
||||
<div className="container mx-auto px-6 py-8 max-w-7xl">
|
||||
{aalWarning && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Session Verification Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your session requires re-verification. You will be redirected to verify your identity in 30 seconds.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,31 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
|
||||
const handleRequestDeletion = async () => {
|
||||
if (!canRequestDeletion(state)) return;
|
||||
|
||||
// Phase 4: AAL2 check for security-critical operations
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session) {
|
||||
// Check if user has MFA enrolled
|
||||
const { data: factorsData } = await supabase.auth.mfa.listFactors();
|
||||
const hasMFA = factorsData?.totp?.some(f => f.status === 'verified') || false;
|
||||
|
||||
if (hasMFA) {
|
||||
const jwt = session.access_token;
|
||||
const payload = JSON.parse(atob(jwt.split('.')[1]));
|
||||
const currentAal = payload.aal || 'aal1';
|
||||
|
||||
if (currentAal !== 'aal2') {
|
||||
handleError(
|
||||
new Error('Please verify your identity with MFA first'),
|
||||
{ action: 'Request account deletion' }
|
||||
);
|
||||
sessionStorage.setItem('mfa_step_up_required', 'true');
|
||||
sessionStorage.setItem('mfa_intended_path', '/settings?tab=privacy');
|
||||
window.location.href = '/auth';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
|
||||
try {
|
||||
|
||||
@@ -95,6 +95,34 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }:
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 4: AAL2 check for security-critical operations
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session) {
|
||||
// Check if user has MFA enrolled
|
||||
const { data: factorsData } = await supabase.auth.mfa.listFactors();
|
||||
const hasMFA = factorsData?.totp?.some(f => f.status === 'verified') || false;
|
||||
|
||||
if (hasMFA) {
|
||||
const jwt = session.access_token;
|
||||
const payload = JSON.parse(atob(jwt.split('.')[1]));
|
||||
const currentAal = payload.aal || 'aal1';
|
||||
|
||||
if (currentAal !== 'aal2') {
|
||||
handleError(
|
||||
new AppError(
|
||||
'Please verify your identity with MFA first',
|
||||
'AAL2_REQUIRED'
|
||||
),
|
||||
{ action: 'Change email', userId, metadata: { step: 'aal2_check' } }
|
||||
);
|
||||
sessionStorage.setItem('mfa_step_up_required', 'true');
|
||||
sessionStorage.setItem('mfa_intended_path', '/settings?tab=security');
|
||||
window.location.href = '/auth';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Step 1: Validate email is not disposable
|
||||
|
||||
@@ -101,6 +101,30 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 4: AAL2 check for security-critical operations
|
||||
if (hasMFA) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session) {
|
||||
const jwt = session.access_token;
|
||||
const payload = JSON.parse(atob(jwt.split('.')[1]));
|
||||
const currentAal = payload.aal || 'aal1';
|
||||
|
||||
if (currentAal !== 'aal2') {
|
||||
handleError(
|
||||
new AppError(
|
||||
'Please verify your identity with MFA first',
|
||||
'AAL2_REQUIRED'
|
||||
),
|
||||
{ action: 'Change password', userId, metadata: { step: 'aal2_check' } }
|
||||
);
|
||||
sessionStorage.setItem('mfa_step_up_required', 'true');
|
||||
sessionStorage.setItem('mfa_intended_path', '/settings?tab=security');
|
||||
window.location.href = '/auth';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Step 1: Reauthenticate with current password to get a nonce
|
||||
|
||||
74
src/hooks/useSessionMonitor.ts
Normal file
74
src/hooks/useSessionMonitor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
import { useRequireMFA } from './useRequireMFA';
|
||||
import { getSessionAal } from '@/lib/authService';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* Phase 3: Session Monitoring Hook
|
||||
* Monitors AAL degradation and forces re-verification when needed
|
||||
*
|
||||
* This hook continuously checks the session's AAL level and detects
|
||||
* if it degrades from AAL2 to AAL1, which can happen after token refresh
|
||||
* or session expiry.
|
||||
*/
|
||||
export function useSessionMonitor() {
|
||||
const { aal, session, user } = useAuth();
|
||||
const { requiresMFA, isEnrolled } = useRequireMFA();
|
||||
const [aalWarning, setAalWarning] = useState(false);
|
||||
const [aalDegraded, setAalDegraded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !user || !requiresMFA || !isEnrolled) {
|
||||
setAalWarning(false);
|
||||
setAalDegraded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check AAL every 60 seconds
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const currentAal = await getSessionAal(session);
|
||||
|
||||
// If AAL degraded from AAL2 to AAL1
|
||||
if (currentAal === 'aal1' && aal === 'aal2') {
|
||||
logger.warn('AAL degradation detected', {
|
||||
userId: user.id,
|
||||
previousAal: aal,
|
||||
currentAal,
|
||||
action: 'session_monitor'
|
||||
});
|
||||
|
||||
// Show warning for 30 seconds
|
||||
setAalWarning(true);
|
||||
setAalDegraded(true);
|
||||
|
||||
// After 30 seconds, redirect to MFA step-up
|
||||
setTimeout(() => {
|
||||
logger.info('Forcing MFA step-up due to AAL degradation', {
|
||||
userId: user.id,
|
||||
action: 'session_monitor_redirect'
|
||||
});
|
||||
|
||||
sessionStorage.setItem('mfa_step_up_required', 'true');
|
||||
sessionStorage.setItem('mfa_intended_path', window.location.pathname);
|
||||
window.location.href = '/auth';
|
||||
}, 30000);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Session monitor check failed', {
|
||||
userId: user.id,
|
||||
action: 'session_monitor',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [session, aal, requiresMFA, isEnrolled, user]);
|
||||
|
||||
return {
|
||||
aalWarning,
|
||||
aalDegraded
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user