From 8a36c71edb5cd78d14079d57f6ec8d0d9ffc8d95 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Oct 2025 19:43:43 +0000 Subject: [PATCH] Fix: Implement MFA enforcement and critical bug fix --- src/components/layout/AdminLayout.tsx | 14 + .../settings/AccountDeletionDialog.tsx | 25 ++ src/components/settings/EmailChangeDialog.tsx | 28 ++ .../settings/PasswordUpdateDialog.tsx | 24 ++ src/hooks/useSessionMonitor.ts | 74 ++++ .../process-selective-approval/index.ts | 33 ++ ...8_41a145a6-a126-4132-86a5-0f8295639976.sql | 349 ++++++++++++++++++ 7 files changed, 547 insertions(+) create mode 100644 src/hooks/useSessionMonitor.ts create mode 100644 supabase/migrations/20251017194148_41a145a6-a126-4132-86a5-0f8295639976.sql diff --git a/src/components/layout/AdminLayout.tsx b/src/components/layout/AdminLayout.tsx index 9ee44bcd..e2732e71 100644 --- a/src/components/layout/AdminLayout.tsx +++ b/src/components/layout/AdminLayout.tsx @@ -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 (
@@ -34,6 +39,15 @@ export function AdminLayout({ />
+ {aalWarning && ( + + + Session Verification Required + + Your session requires re-verification. You will be redirected to verify your identity in 30 seconds. + + + )} {children}
diff --git a/src/components/settings/AccountDeletionDialog.tsx b/src/components/settings/AccountDeletionDialog.tsx index b78a419c..6ec0ef1b 100644 --- a/src/components/settings/AccountDeletionDialog.tsx +++ b/src/components/settings/AccountDeletionDialog.tsx @@ -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 { diff --git a/src/components/settings/EmailChangeDialog.tsx b/src/components/settings/EmailChangeDialog.tsx index 178446b4..fe08c795 100644 --- a/src/components/settings/EmailChangeDialog.tsx +++ b/src/components/settings/EmailChangeDialog.tsx @@ -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 diff --git a/src/components/settings/PasswordUpdateDialog.tsx b/src/components/settings/PasswordUpdateDialog.tsx index 858519f7..1514eae2 100644 --- a/src/components/settings/PasswordUpdateDialog.tsx +++ b/src/components/settings/PasswordUpdateDialog.tsx @@ -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 diff --git a/src/hooks/useSessionMonitor.ts b/src/hooks/useSessionMonitor.ts new file mode 100644 index 00000000..bae810f0 --- /dev/null +++ b/src/hooks/useSessionMonitor.ts @@ -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 + }; +} diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index 333a56bb..ee8b08a3 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -115,6 +115,39 @@ serve(async (req) => { ); } + // Phase 2: AAL2 Enforcement - Check if user has MFA enrolled and requires AAL2 + const { data: { session } } = await supabaseAuth.auth.getSession(); + if (!session) { + return new Response( + JSON.stringify({ error: 'No active session found.' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Check if user has MFA enrolled + const { data: factorsData } = await supabaseAuth.auth.mfa.listFactors(); + const hasMFA = factorsData?.totp?.some(f => f.status === 'verified') || false; + + // Parse JWT to get AAL level + const jwt = session.access_token; + const payload = JSON.parse(atob(jwt.split('.')[1])); + const aal = payload.aal || 'aal1'; + + // Enforce AAL2 if MFA is enrolled + if (hasMFA && aal !== 'aal2') { + console.error('AAL2 required but session is at AAL1', { userId: authenticatedUserId }); + return new Response( + JSON.stringify({ + error: 'MFA verification required', + code: 'AAL2_REQUIRED', + message: 'Your role requires two-factor authentication. Please verify your identity to continue.' + }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + console.log('AAL2 check passed', { userId: authenticatedUserId, hasMFA, aal }); + const { itemIds, submissionId }: ApprovalRequest = await req.json(); // UUID validation regex diff --git a/supabase/migrations/20251017194148_41a145a6-a126-4132-86a5-0f8295639976.sql b/supabase/migrations/20251017194148_41a145a6-a126-4132-86a5-0f8295639976.sql new file mode 100644 index 00000000..0d0350bb --- /dev/null +++ b/supabase/migrations/20251017194148_41a145a6-a126-4132-86a5-0f8295639976.sql @@ -0,0 +1,349 @@ +-- Phase 0: Fix critical bug - Users can't create submissions +-- Add missing INSERT policy for submission_items + +CREATE POLICY "Users can insert their own submission items" +ON public.submission_items +FOR INSERT +TO authenticated +WITH CHECK ( + EXISTS ( + SELECT 1 FROM content_submissions cs + WHERE cs.id = submission_items.submission_id + AND cs.user_id = auth.uid() + ) +); + +-- Phase 1: Complete Database-Level AAL2 Enforcement +-- Add AAL2 checks to all moderator-accessible tables + +-- 1.1 Submission Review Tables + +-- park_submissions +DROP POLICY IF EXISTS "Moderators can update park submissions" ON public.park_submissions; +CREATE POLICY "Moderators can update park submissions" +ON public.park_submissions +FOR UPDATE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can delete park submissions" ON public.park_submissions; +CREATE POLICY "Moderators can delete park submissions" +ON public.park_submissions +FOR DELETE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can view all park submissions" ON public.park_submissions; +CREATE POLICY "Moderators can view all park submissions" +ON public.park_submissions +FOR SELECT +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +-- ride_submissions +DROP POLICY IF EXISTS "Moderators can update ride submissions" ON public.ride_submissions; +CREATE POLICY "Moderators can update ride submissions" +ON public.ride_submissions +FOR UPDATE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can delete ride submissions" ON public.ride_submissions; +CREATE POLICY "Moderators can delete ride submissions" +ON public.ride_submissions +FOR DELETE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can view all ride submissions" ON public.ride_submissions; +CREATE POLICY "Moderators can view all ride submissions" +ON public.ride_submissions +FOR SELECT +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +-- company_submissions +DROP POLICY IF EXISTS "Moderators can update company submissions" ON public.company_submissions; +CREATE POLICY "Moderators can update company submissions" +ON public.company_submissions +FOR UPDATE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can delete company submissions" ON public.company_submissions; +CREATE POLICY "Moderators can delete company submissions" +ON public.company_submissions +FOR DELETE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can view all company submissions" ON public.company_submissions; +CREATE POLICY "Moderators can view all company submissions" +ON public.company_submissions +FOR SELECT +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +-- photo_submissions +DROP POLICY IF EXISTS "Moderators can update photo submissions" ON public.photo_submissions; +CREATE POLICY "Moderators can update photo submissions" +ON public.photo_submissions +FOR UPDATE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can delete photo submissions" ON public.photo_submissions; +CREATE POLICY "Moderators can delete photo submissions" +ON public.photo_submissions +FOR DELETE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can view all photo submissions" ON public.photo_submissions; +CREATE POLICY "Moderators can view all photo submissions" +ON public.photo_submissions +FOR SELECT +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +-- photo_submission_items +DROP POLICY IF EXISTS "Moderators can update photo submission items" ON public.photo_submission_items; +CREATE POLICY "Moderators can update photo submission items" +ON public.photo_submission_items +FOR UPDATE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can delete photo submission items" ON public.photo_submission_items; +CREATE POLICY "Moderators can delete photo submission items" +ON public.photo_submission_items +FOR DELETE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can view all photo submission items" ON public.photo_submission_items; +CREATE POLICY "Moderators can view all photo submission items" +ON public.photo_submission_items +FOR SELECT +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +-- 1.2 User Management Tables + +-- profiles (moderator banning) +DROP POLICY IF EXISTS "Moderators can update profiles for banning" ON public.profiles; +CREATE POLICY "Moderators can update profiles for banning" +ON public.profiles +FOR UPDATE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +-- 1.3 Reports & Moderation + +-- reports +DROP POLICY IF EXISTS "Moderators can update reports" ON public.reports; +CREATE POLICY "Moderators can update reports" +ON public.reports +FOR UPDATE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can delete reports" ON public.reports; +CREATE POLICY "Moderators can delete reports" +ON public.reports +FOR DELETE +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +DROP POLICY IF EXISTS "Moderators can view all reports" ON public.reports; +CREATE POLICY "Moderators can view all reports" +ON public.reports +FOR SELECT +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +-- 1.4 Blog Management + +-- blog_posts (admin/superuser only) +DROP POLICY IF EXISTS "Admins and superusers can manage blog posts" ON public.blog_posts; +CREATE POLICY "Admins and superusers can manage blog posts" +ON public.blog_posts +FOR ALL +TO authenticated +USING ( + (has_role(auth.uid(), 'admin'::app_role) OR has_role(auth.uid(), 'superuser'::app_role)) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +-- 1.5 Admin Audit & Settings + +-- admin_audit_log (already has AAL2, verify it's correct) +DROP POLICY IF EXISTS "Admins can insert audit log with MFA" ON public.admin_audit_log; +CREATE POLICY "Admins can insert audit log with MFA" +ON public.admin_audit_log +FOR INSERT +TO authenticated +WITH CHECK ( + is_moderator(auth.uid()) AND has_aal2() +); + +DROP POLICY IF EXISTS "Admins can view audit log" ON public.admin_audit_log; +CREATE POLICY "Admins can view audit log" +ON public.admin_audit_log +FOR SELECT +TO authenticated +USING ( + is_moderator(auth.uid()) AND ( + NOT EXISTS ( + SELECT 1 FROM auth.mfa_factors + WHERE user_id = auth.uid() AND status = 'verified' + ) OR has_aal2() + ) +); + +-- admin_settings (superuser only) +DROP POLICY IF EXISTS "Superusers can manage settings with MFA" ON public.admin_settings; +CREATE POLICY "Superusers can manage settings with MFA" +ON public.admin_settings +FOR ALL +TO authenticated +USING ( + is_superuser(auth.uid()) AND has_aal2() +); \ No newline at end of file