Implement MFA Enforcement

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 13:45:59 +00:00
parent 121f7c533a
commit 7aa219efe5
10 changed files with 216 additions and 12 deletions

View File

@@ -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 (
<Alert variant="destructive" className="my-4">
<Shield className="h-4 w-4" />
<AlertTitle>Two-Factor Authentication Required</AlertTitle>
<AlertDescription className="mt-2 space-y-3">
<p>Your role requires two-factor authentication to access this area.</p>
<Button
onClick={() => navigate('/settings?tab=security')}
size="sm"
>
Set up MFA
</Button>
</AlertDescription>
</Alert>
);
}

View File

@@ -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',

View File

@@ -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<AuthContextType | undefined>(undefined);
function AuthProviderComponent({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [aal, setAal] = useState<'aal1' | 'aal2' | null>(null);
const [loading, setLoading] = useState(true);
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
const [sessionError, setSessionError] = useState<string | null>(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,

View File

@@ -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,
};
}

View File

@@ -3012,6 +3012,10 @@ export type Database = {
Args: { _user_id: string }
Returns: Json
}
has_aal2: {
Args: Record<PropertyKey, never>
Returns: boolean
}
has_pending_dependents: {
Args: { item_id: string }
Returns: boolean

View File

@@ -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 (
<AdminLayout>
<div className="space-y-6">
@@ -149,6 +152,15 @@ export default function AdminDashboard() {
if (!user || !isModerator()) {
return null;
}
// MFA enforcement
if (needsEnrollment) {
return (
<AdminLayout>
<MFARequiredAlert />
</AdminLayout>
);
}
const statCards = [
{

View File

@@ -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<ModerationQueueRef>(null);
@@ -46,7 +49,7 @@ export default function AdminModeration() {
}
}, [user, authLoading, roleLoading, navigate, isModerator]);
if (authLoading || roleLoading) {
if (authLoading || roleLoading || mfaLoading) {
return (
<AdminLayout
onRefresh={handleRefresh}
@@ -71,6 +74,15 @@ export default function AdminModeration() {
if (!user || !isModerator()) {
return null;
}
// MFA enforcement
if (needsEnrollment) {
return (
<AdminLayout>
<MFARequiredAlert />
</AdminLayout>
);
}
return (
<AdminLayout

View File

@@ -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 { ReportsQueue, ReportsQueueRef } from '@/components/moderation/ReportsQueue';
import { QueueSkeleton } from '@/components/moderation/QueueSkeleton';
@@ -11,6 +13,7 @@ import { useModerationStats } from '@/hooks/useModerationStats';
export default function AdminReports() {
const { user, loading: authLoading } = useAuth();
const { isModerator, loading: roleLoading } = useUserRole();
const { needsEnrollment, loading: mfaLoading } = useRequireMFA();
const navigate = useNavigate();
const reportsQueueRef = useRef<ReportsQueueRef>(null);
@@ -47,7 +50,7 @@ export default function AdminReports() {
}
}, [user, authLoading, roleLoading, navigate, isModerator]);
if (authLoading || roleLoading) {
if (authLoading || roleLoading || mfaLoading) {
return (
<AdminLayout
onRefresh={handleRefresh}
@@ -72,6 +75,15 @@ export default function AdminReports() {
if (!user || !isModerator()) {
return null;
}
// MFA enforcement
if (needsEnrollment) {
return (
<AdminLayout>
<MFARequiredAlert />
</AdminLayout>
);
}
return (
<AdminLayout

View File

@@ -2,6 +2,8 @@ import { useEffect } 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 { UserManagement } from '@/components/admin/UserManagement';
import { Skeleton } from '@/components/ui/skeleton';
@@ -10,6 +12,7 @@ import { Card, CardContent } from '@/components/ui/card';
export default function AdminUsers() {
const { user, loading: authLoading } = useAuth();
const { isModerator, loading: roleLoading } = useUserRole();
const { needsEnrollment, loading: mfaLoading } = useRequireMFA();
const navigate = useNavigate();
useEffect(() => {
@@ -26,7 +29,7 @@ export default function AdminUsers() {
}
}, [user, authLoading, roleLoading, navigate, isModerator]);
if (authLoading || roleLoading) {
if (authLoading || roleLoading || mfaLoading) {
return (
<AdminLayout>
<div className="space-y-6">
@@ -59,6 +62,15 @@ export default function AdminUsers() {
if (!user || !isModerator()) {
return null;
}
// MFA enforcement
if (needsEnrollment) {
return (
<AdminLayout>
<MFARequiredAlert />
</AdminLayout>
);
}
return (
<AdminLayout>

View File

@@ -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())
);