From 7aa219efe5d30385edd302552700fdac8d41669d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:45:59 +0000 Subject: [PATCH] Implement MFA Enforcement --- src/components/auth/MFARequiredAlert.tsx | 24 +++++ src/components/auth/TOTPSetup.tsx | 14 ++- src/hooks/useAuth.tsx | 11 +++ src/hooks/useRequireMFA.ts | 21 ++++ src/integrations/supabase/types.ts | 4 + src/pages/AdminDashboard.tsx | 14 ++- src/pages/AdminModeration.tsx | 14 ++- src/pages/AdminReports.tsx | 14 ++- src/pages/AdminUsers.tsx | 14 ++- ...6_adbf79c0-f924-4285-b0fa-ab2e54ca5e65.sql | 98 +++++++++++++++++++ 10 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 src/components/auth/MFARequiredAlert.tsx create mode 100644 src/hooks/useRequireMFA.ts create mode 100644 supabase/migrations/20251014134306_adbf79c0-f924-4285-b0fa-ab2e54ca5e65.sql diff --git a/src/components/auth/MFARequiredAlert.tsx b/src/components/auth/MFARequiredAlert.tsx new file mode 100644 index 00000000..8268e590 --- /dev/null +++ b/src/components/auth/MFARequiredAlert.tsx @@ -0,0 +1,24 @@ +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Shield } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +export function MFARequiredAlert() { + const navigate = useNavigate(); + + return ( + + + Two-Factor Authentication Required + +

Your role requires two-factor authentication to access this area.

+ +
+
+ ); +} diff --git a/src/components/auth/TOTPSetup.tsx b/src/components/auth/TOTPSetup.tsx index 619292ed..1d369d0a 100644 --- a/src/components/auth/TOTPSetup.tsx +++ b/src/components/auth/TOTPSetup.tsx @@ -113,16 +113,14 @@ export function TOTPSetup() { toast({ title: 'TOTP Enabled', - description: 'Two-factor authentication has been successfully enabled for your account.' + description: 'Please sign in again to activate MFA protection.' }); - // Reset state and refresh factors - setEnrolling(false); - setQrCode(''); - setSecret(''); - setFactorId(''); - setVerificationCode(''); - fetchTOTPFactors(); + // Force sign out to get new session with AAL2 + setTimeout(async () => { + await supabase.auth.signOut(); + window.location.href = '/auth'; + }, 2000); } catch (error: any) { toast({ title: 'Error', diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index a2348456..f13a48a6 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -8,6 +8,7 @@ import { authLog, authWarn, authError } from '@/lib/authLogger'; interface AuthContextType { user: User | null; session: Session | null; + aal: 'aal1' | 'aal2' | null; loading: boolean; pendingEmail: string | null; sessionError: string | null; @@ -21,6 +22,7 @@ const AuthContext = createContext(undefined); function AuthProviderComponent({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [session, setSession] = useState(null); + const [aal, setAal] = useState<'aal1' | 'aal2' | null>(null); const [loading, setLoading] = useState(true); const [pendingEmail, setPendingEmail] = useState(null); const [sessionError, setSessionError] = useState(null); @@ -84,17 +86,22 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { authLog('[Auth] SIGNED_IN - user authenticated'); setSession(session); setUser(session.user); + const userAal = (session.user as any).aal as 'aal1' | 'aal2' | undefined; + setAal(userAal || 'aal1'); setLoading(false); } else if (event === 'INITIAL_SESSION') { if (session?.user) { authLog('[Auth] INITIAL_SESSION - user exists'); setSession(session); setUser(session.user); + const userAal = (session.user as any).aal as 'aal1' | 'aal2' | undefined; + setAal(userAal || 'aal1'); setLoading(false); } else { authLog('[Auth] INITIAL_SESSION - no user'); setSession(null); setUser(null); + setAal(null); setLoading(false); return; } @@ -102,11 +109,14 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { authLog('[Auth] SIGNED_OUT - clearing state'); setSession(null); setUser(null); + setAal(null); setLoading(false); return; } else { setSession(session); setUser(session?.user ?? null); + const userAal = session?.user ? ((session.user as any).aal as 'aal1' | 'aal2' | undefined) : null; + setAal(userAal || null); } // Detect confirmed email change: email changed AND no longer pending @@ -214,6 +224,7 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { const value = { user, session, + aal, loading, pendingEmail, sessionError, diff --git a/src/hooks/useRequireMFA.ts b/src/hooks/useRequireMFA.ts new file mode 100644 index 00000000..db592b35 --- /dev/null +++ b/src/hooks/useRequireMFA.ts @@ -0,0 +1,21 @@ +import { useAuth } from './useAuth'; +import { useUserRole } from './useUserRole'; + +export function useRequireMFA() { + const { aal } = useAuth(); + const { isModerator, isAdmin, loading } = useUserRole(); + + // MFA is required for moderators and admins + const requiresMFA = isModerator() || isAdmin(); + + // User has MFA if they have AAL2 + const hasMFA = aal === 'aal2'; + + return { + requiresMFA, + hasMFA, + needsEnrollment: requiresMFA && !hasMFA, + aal, + loading, + }; +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index dd8e0573..256a3762 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -3012,6 +3012,10 @@ export type Database = { Args: { _user_id: string } Returns: Json } + has_aal2: { + Args: Record + Returns: boolean + } has_pending_dependents: { Args: { item_id: string } Returns: boolean diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index e1c75a1f..76cfb367 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom'; import { FileText, Flag, AlertCircle, Activity, ShieldAlert } from 'lucide-react'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; +import { useRequireMFA } from '@/hooks/useRequireMFA'; +import { MFARequiredAlert } from '@/components/auth/MFARequiredAlert'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; @@ -20,6 +22,7 @@ import { QueueSkeleton } from '@/components/moderation/QueueSkeleton'; export default function AdminDashboard() { const { user, loading: authLoading } = useAuth(); const { isModerator, loading: roleLoading } = useUserRole(); + const { needsEnrollment, loading: mfaLoading } = useRequireMFA(); const navigate = useNavigate(); const [isRefreshing, setIsRefreshing] = useState(false); const [activeTab, setActiveTab] = useState('moderation'); @@ -110,7 +113,7 @@ export default function AdminDashboard() { } }, [user, authLoading, roleLoading, navigate, isModerator]); - if (authLoading || roleLoading) { + if (authLoading || roleLoading || mfaLoading) { return (
@@ -149,6 +152,15 @@ export default function AdminDashboard() { if (!user || !isModerator()) { return null; } + + // MFA enforcement + if (needsEnrollment) { + return ( + + + + ); + } const statCards = [ { diff --git a/src/pages/AdminModeration.tsx b/src/pages/AdminModeration.tsx index a4853112..41e504e6 100644 --- a/src/pages/AdminModeration.tsx +++ b/src/pages/AdminModeration.tsx @@ -2,6 +2,8 @@ import { useRef, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; +import { useRequireMFA } from '@/hooks/useRequireMFA'; +import { MFARequiredAlert } from '@/components/auth/MFARequiredAlert'; import { AdminLayout } from '@/components/layout/AdminLayout'; import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue'; import { QueueSkeleton } from '@/components/moderation/QueueSkeleton'; @@ -11,6 +13,7 @@ import { useModerationStats } from '@/hooks/useModerationStats'; export default function AdminModeration() { const { user, loading: authLoading } = useAuth(); const { isModerator, loading: roleLoading } = useUserRole(); + const { needsEnrollment, loading: mfaLoading } = useRequireMFA(); const navigate = useNavigate(); const moderationQueueRef = useRef(null); @@ -46,7 +49,7 @@ export default function AdminModeration() { } }, [user, authLoading, roleLoading, navigate, isModerator]); - if (authLoading || roleLoading) { + if (authLoading || roleLoading || mfaLoading) { return ( + + + ); + } return ( (null); @@ -47,7 +50,7 @@ export default function AdminReports() { } }, [user, authLoading, roleLoading, navigate, isModerator]); - if (authLoading || roleLoading) { + if (authLoading || roleLoading || mfaLoading) { return ( + + + ); + } return ( { @@ -26,7 +29,7 @@ export default function AdminUsers() { } }, [user, authLoading, roleLoading, navigate, isModerator]); - if (authLoading || roleLoading) { + if (authLoading || roleLoading || mfaLoading) { return (
@@ -59,6 +62,15 @@ export default function AdminUsers() { if (!user || !isModerator()) { return null; } + + // MFA enforcement + if (needsEnrollment) { + return ( + + + + ); + } return ( diff --git a/supabase/migrations/20251014134306_adbf79c0-f924-4285-b0fa-ab2e54ca5e65.sql b/supabase/migrations/20251014134306_adbf79c0-f924-4285-b0fa-ab2e54ca5e65.sql new file mode 100644 index 00000000..3c36e6c8 --- /dev/null +++ b/supabase/migrations/20251014134306_adbf79c0-f924-4285-b0fa-ab2e54ca5e65.sql @@ -0,0 +1,98 @@ +-- Create helper function to check AAL2 (Authenticator Assurance Level 2) +CREATE OR REPLACE FUNCTION public.has_aal2() +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT COALESCE((auth.jwt()->>'aal')::text = 'aal2', false); +$$; + +-- Update admin_settings policies to require MFA +DROP POLICY IF EXISTS "Superusers can manage settings" ON public.admin_settings; +CREATE POLICY "Superusers can manage settings with MFA" +ON public.admin_settings +FOR ALL +USING ( + is_superuser(auth.uid()) + AND public.has_aal2() +); + +-- Update user_roles policies to require MFA for role management +DROP POLICY IF EXISTS "Admins can insert user roles" ON public.user_roles; +CREATE POLICY "Admins can insert user roles with MFA" +ON public.user_roles +FOR INSERT +WITH CHECK ( + (has_role(auth.uid(), 'admin'::app_role) OR is_superuser(auth.uid())) + AND public.has_aal2() +); + +DROP POLICY IF EXISTS "Admins can delete user roles" ON public.user_roles; +CREATE POLICY "Admins can delete user roles with MFA" +ON public.user_roles +FOR DELETE +USING ( + (has_role(auth.uid(), 'admin'::app_role) OR is_superuser(auth.uid())) + AND public.has_aal2() +); + +-- Update content_submissions moderation policies to require MFA +DROP POLICY IF EXISTS "Moderators can update content submissions" ON public.content_submissions; +CREATE POLICY "Moderators can update submissions with MFA" +ON public.content_submissions +FOR UPDATE +USING ( + is_moderator(auth.uid()) + AND public.has_aal2() +); + +DROP POLICY IF EXISTS "Moderators can delete content submissions" ON public.content_submissions; +CREATE POLICY "Moderators can delete submissions with MFA" +ON public.content_submissions +FOR DELETE +USING ( + is_moderator(auth.uid()) + AND public.has_aal2() +); + +-- Update submission_items policies to require MFA +DROP POLICY IF EXISTS "Moderators can update submission items" ON public.submission_items; +CREATE POLICY "Moderators can update submission items with MFA" +ON public.submission_items +FOR UPDATE +USING ( + is_moderator(auth.uid()) + AND public.has_aal2() +); + +-- Update reports policies to require MFA +DROP POLICY IF EXISTS "Moderators can update reports" ON public.reports; +CREATE POLICY "Moderators can update reports with MFA" +ON public.reports +FOR UPDATE +USING ( + is_moderator(auth.uid()) + AND public.has_aal2() +); + +-- Update admin_audit_log policies to require MFA +DROP POLICY IF EXISTS "Admins can insert audit log" ON public.admin_audit_log; +CREATE POLICY "Admins can insert audit log with MFA" +ON public.admin_audit_log +FOR INSERT +WITH CHECK ( + is_moderator(auth.uid()) + AND public.has_aal2() +); + +-- Update profiles policies for sensitive operations +DROP POLICY IF EXISTS "Admins can update any profile" ON public.profiles; +CREATE POLICY "Admins can update any profile with MFA" +ON public.profiles +FOR UPDATE +USING ( + (auth.uid() = user_id) OR + ((has_role(auth.uid(), 'admin'::app_role) OR is_superuser(auth.uid())) AND public.has_aal2()) +); \ No newline at end of file