mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 04:31:13 -05:00
Implement MFA Enforcement
This commit is contained in:
24
src/components/auth/MFARequiredAlert.tsx
Normal file
24
src/components/auth/MFARequiredAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
21
src/hooks/useRequireMFA.ts
Normal file
21
src/hooks/useRequireMFA.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
Reference in New Issue
Block a user