diff --git a/src/components/auth/TOTPSetup.tsx b/src/components/auth/TOTPSetup.tsx index b6a9488a..3d968537 100644 --- a/src/components/auth/TOTPSetup.tsx +++ b/src/components/auth/TOTPSetup.tsx @@ -5,7 +5,8 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; -import { useToast } from '@/hooks/use-toast'; +import { handleError, handleSuccess, handleInfo, AppError } from '@/lib/errorHandler'; +import { logger } from '@/lib/logger'; import { useAuth } from '@/hooks/useAuth'; import { supabase } from '@/integrations/supabase/client'; import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2 } from 'lucide-react'; @@ -16,7 +17,6 @@ import type { MFAFactor } from '@/types/auth'; export function TOTPSetup() { const { user } = useAuth(); - const { toast } = useToast(); const navigate = useNavigate(); const [factors, setFactors] = useState([]); const [loading, setLoading] = useState(false); @@ -49,7 +49,11 @@ export function TOTPSetup() { })); setFactors(totpFactors); } catch (error: any) { - console.error('Error fetching TOTP factors:', error); + logger.error('Failed to fetch TOTP factors', { + userId: user?.id, + action: 'fetch_totp_factors', + error: error.message + }); } }; @@ -70,11 +74,18 @@ export function TOTPSetup() { setFactorId(data.id); setEnrolling(true); } catch (error: any) { - toast({ - title: 'Error', - description: error.message || 'Failed to start TOTP enrollment', - variant: 'destructive' + logger.error('Failed to start TOTP enrollment', { + userId: user?.id, + action: 'totp_enroll_start', + error: error.message }); + handleError( + new AppError( + error.message || 'Failed to start TOTP enrollment', + 'TOTP_ENROLL_FAILED' + ), + { action: 'Start TOTP enrollment', userId: user?.id } + ); } finally { setLoading(false); } @@ -82,11 +93,10 @@ export function TOTPSetup() { const verifyAndEnable = async () => { if (!factorId || !verificationCode.trim()) { - toast({ - title: 'Error', - description: 'Please enter the verification code', - variant: 'destructive' - }); + handleError( + new AppError('Please enter the verification code', 'INVALID_INPUT'), + { action: 'Verify TOTP', userId: user?.id, metadata: { step: 'code_entry' } } + ); return; } @@ -119,12 +129,12 @@ export function TOTPSetup() { return; } - toast({ - title: 'TOTP Enabled', - description: isOAuthUser + handleSuccess( + 'TOTP Enabled', + isOAuthUser ? 'Please verify with your authenticator code to continue.' : 'Please sign in again to activate MFA protection.' - }); + ); if (isOAuthUser) { // Already handled above with navigate @@ -137,11 +147,20 @@ export function TOTPSetup() { }, 2000); } } catch (error: any) { - toast({ - title: 'Error', - description: error.message || 'Invalid verification code. Please try again.', - variant: 'destructive' + logger.error('TOTP verification failed', { + userId: user?.id, + action: 'totp_verify', + error: error.message, + factorId }); + + handleError( + new AppError( + error.message || 'Invalid verification code. Please try again.', + 'TOTP_VERIFY_FAILED' + ), + { action: 'Verify TOTP code', userId: user?.id, metadata: { factorId } } + ); } finally { setLoading(false); } @@ -153,10 +172,7 @@ export function TOTPSetup() { const copySecret = () => { navigator.clipboard.writeText(secret); - toast({ - title: 'Copied', - description: 'Secret key copied to clipboard' - }); + handleInfo('Copied', 'Secret key copied to clipboard'); }; const cancelEnrollment = () => { diff --git a/src/components/settings/PasswordUpdateDialog.tsx b/src/components/settings/PasswordUpdateDialog.tsx index 9406b4b7..618c4a22 100644 --- a/src/components/settings/PasswordUpdateDialog.tsx +++ b/src/components/settings/PasswordUpdateDialog.tsx @@ -12,7 +12,8 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { useToast } from '@/hooks/use-toast'; +import { handleError, handleSuccess, AppError } from '@/lib/errorHandler'; +import { logger } from '@/lib/logger'; import { supabase } from '@/integrations/supabase/client'; import { Loader2, Shield, CheckCircle2 } from 'lucide-react'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; @@ -33,7 +34,6 @@ interface PasswordUpdateDialogProps { type Step = 'password' | 'mfa' | 'success'; export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) { - const { toast } = useToast(); const { theme } = useTheme(); const [step, setStep] = useState('password'); const [loading, setLoading] = useState(false); @@ -43,6 +43,7 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password const [totpCode, setTotpCode] = useState(''); const [captchaToken, setCaptchaToken] = useState(''); const [captchaKey, setCaptchaKey] = useState(0); + const [userId, setUserId] = useState(''); const form = useForm({ resolver: zodResolver(passwordSchema), @@ -53,10 +54,13 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password } }); - // Check if user has MFA enabled + // Check if user has MFA enabled and get user ID useEffect(() => { if (open) { checkMFAStatus(); + supabase.auth.getUser().then(({ data }) => { + if (data.user) setUserId(data.user.id); + }); } }, [open]); @@ -68,17 +72,23 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password const hasVerifiedTotp = data?.totp?.some(factor => factor.status === 'verified') || false; setHasMFA(hasVerifiedTotp); } catch (error) { - console.error('Error checking MFA status:', error); + logger.error('Failed to check MFA status', { + action: 'check_mfa_status', + error: error instanceof Error ? error.message : String(error) + }); } }; const onSubmit = async (data: PasswordFormData) => { if (!captchaToken) { - toast({ - title: 'CAPTCHA Required', - description: 'Please complete the CAPTCHA verification.', - variant: 'destructive' - }); + handleError( + new AppError('Please complete the CAPTCHA verification.', 'CAPTCHA_REQUIRED'), + { + action: 'Change password', + userId, + metadata: { step: 'captcha_validation' } + } + ); return; } @@ -97,6 +107,14 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password // Reset CAPTCHA on authentication failure setCaptchaToken(''); setCaptchaKey(prev => prev + 1); + + logger.error('Password authentication failed', { + userId, + action: 'password_change_auth', + error: signInError.message, + errorCode: signInError.code + }); + throw signInError; } @@ -117,18 +135,37 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password await updatePasswordWithNonce(data.newPassword, generatedNonce); } } catch (error: any) { - // Handle rate limiting specifically + logger.error('Password change failed', { + userId, + action: 'password_change', + error: error instanceof Error ? error.message : String(error), + errorCode: error.code, + errorStatus: error.status + }); + if (error.message?.includes('rate limit') || error.status === 429) { - toast({ - title: 'Too Many Attempts', - description: 'Please wait a few minutes before trying again.', - variant: 'destructive' - }); + handleError( + new AppError( + 'Please wait a few minutes before trying again.', + 'RATE_LIMIT', + 'Too many password change attempts' + ), + { action: 'Change password', userId, metadata: { step: 'authentication' } } + ); + } else if (error.message?.includes('Invalid login credentials')) { + handleError( + new AppError( + 'The password you entered is incorrect.', + 'INVALID_PASSWORD', + 'Incorrect current password' + ), + { action: 'Verify password', userId } + ); } else { - toast({ - title: 'Authentication Error', - description: error.message || 'Incorrect password. Please try again.', - variant: 'destructive' + handleError(error, { + action: 'Change password', + userId, + metadata: { step: 'authentication' } }); } } finally { @@ -138,11 +175,10 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password const verifyMFAAndUpdate = async () => { if (totpCode.length !== 6) { - toast({ - title: 'Invalid Code', - description: 'Please enter a valid 6-digit code', - variant: 'destructive' - }); + handleError( + new AppError('Please enter a valid 6-digit code', 'INVALID_MFA_CODE'), + { action: 'Verify MFA', userId, metadata: { step: 'mfa_verification' } } + ); return; } @@ -153,7 +189,15 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password factorId: (await supabase.auth.mfa.listFactors()).data?.totp?.[0]?.id || '' }); - if (challengeError) throw challengeError; + if (challengeError) { + logger.error('MFA challenge creation failed', { + userId, + action: 'password_change_mfa_challenge', + error: challengeError.message, + errorCode: challengeError.code + }); + throw challengeError; + } const { error: verifyError } = await supabase.auth.mfa.verify({ factorId: challengeData.id, @@ -161,16 +205,33 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password code: totpCode }); - if (verifyError) throw verifyError; + if (verifyError) { + logger.error('MFA verification failed', { + userId, + action: 'password_change_mfa', + error: verifyError.message, + errorCode: verifyError.code + }); + throw verifyError; + } // TOTP verified, now update password await updatePasswordWithNonce(newPassword, nonce); } catch (error: any) { - toast({ - title: 'Verification Failed', - description: error.message || 'Invalid authentication code', - variant: 'destructive' + logger.error('MFA verification failed', { + userId, + action: 'password_change_mfa', + error: error instanceof Error ? error.message : String(error) }); + + handleError( + new AppError( + error.message || 'Invalid authentication code', + 'MFA_VERIFICATION_FAILED', + 'TOTP code verification failed' + ), + { action: 'Verify MFA', userId, metadata: { step: 'mfa_verification' } } + ); } finally { setLoading(false); } @@ -213,7 +274,11 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password } }); } catch (notifError) { - console.error('Failed to send notification:', notifError); + logger.error('Failed to send password change notification', { + userId: user!.id, + action: 'password_change_notification', + error: notifError instanceof Error ? notifError.message : String(notifError) + }); // Don't fail the password update if notification fails } } diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index bf48a024..b263fdce 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -8,6 +8,7 @@ import { handleError, handleSuccess } from '@/lib/errorHandler'; import { useAuth } from '@/hooks/useAuth'; import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react'; import { format } from 'date-fns'; +import { Skeleton } from '@/components/ui/skeleton'; import { TOTPSetup } from '@/components/auth/TOTPSetup'; import { GoogleIcon } from '@/components/icons/GoogleIcon'; import { DiscordIcon } from '@/components/icons/DiscordIcon'; @@ -20,18 +21,10 @@ import { addPasswordToAccount } from '@/lib/identityService'; import type { UserIdentity, OAuthProvider } from '@/types/identity'; +import type { AuthSession } from '@/types/auth'; import { supabase } from '@/integrations/supabase/client'; - -interface AuthSession { - id: string; - created_at: string; - updated_at: string; - refreshed_at: string | null; - user_agent: string | null; - ip: unknown; - not_after: string | null; - aal: 'aal1' | 'aal2' | 'aal3' | null; -} +import { logger } from '@/lib/logger'; +import { SessionRevokeConfirmDialog } from './SessionRevokeConfirmDialog'; export function SecurityTab() { const { user } = useAuth(); @@ -44,6 +37,7 @@ export function SecurityTab() { const [addingPassword, setAddingPassword] = useState(false); const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(true); + const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null); // Load user identities on mount useEffect(() => { @@ -61,8 +55,12 @@ export function SecurityTab() { const hasEmailProvider = fetchedIdentities.some(i => i.provider === 'email'); setHasPassword(hasEmailProvider); } catch (error) { - console.error('Failed to load identities:', error); - handleError(error, { action: 'Load connected accounts' }); + logger.error('Failed to load identities', { + userId: user?.id, + action: 'load_identities', + error: error instanceof Error ? error.message : String(error) + }); + handleError(error, { action: 'Load connected accounts', userId: user?.id }); } finally { setLoadingIdentities(false); } @@ -151,23 +149,55 @@ export function SecurityTab() { const { data, error } = await supabase.rpc('get_my_sessions'); if (error) { - console.error('Error fetching sessions:', error); - handleError(error, { action: 'Load sessions' }); + logger.error('Failed to fetch sessions', { + userId: user.id, + action: 'fetch_sessions', + error: error.message + }); + handleError(error, { action: 'Load sessions', userId: user.id }); } else { - setSessions(data || []); + setSessions((data as AuthSession[]) || []); } setLoadingSessions(false); }; - const revokeSession = async (sessionId: string) => { - const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionId }); + const initiateSessionRevoke = async (sessionId: string) => { + // Get current session to check if revoking self + const { data: { session: currentSession } } = await supabase.auth.getSession(); + const isCurrentSession = currentSession && sessions.some(s => + s.id === sessionId && s.refreshed_at === currentSession.access_token + ); + + setSessionToRevoke({ id: sessionId, isCurrent: !!isCurrentSession }); + }; + + const confirmRevokeSession = async () => { + if (!sessionToRevoke) return; + + const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionToRevoke.id }); if (error) { - handleError(error, { action: 'Revoke session' }); + logger.error('Failed to revoke session', { + userId: user?.id, + action: 'revoke_session', + sessionId: sessionToRevoke.id, + error: error.message + }); + handleError(error, { action: 'Revoke session', userId: user?.id }); } else { handleSuccess('Success', 'Session revoked successfully'); - fetchSessions(); + + if (sessionToRevoke.isCurrent) { + // Redirect to login after revoking current session + setTimeout(() => { + window.location.href = '/auth'; + }, 1000); + } else { + fetchSessions(); + } } + + setSessionToRevoke(null); }; const getDeviceIcon = (userAgent: string | null) => { @@ -365,8 +395,19 @@ export function SecurityTab() { {loadingSessions ? ( -
- +
+ {[1, 2, 3].map(i => ( +
+
+ +
+ + +
+
+ +
+ ))}
) : sessions.length > 0 ? (
@@ -379,7 +420,11 @@ export function SecurityTab() { {getBrowserName(session.user_agent)}

- {session.ip && `${session.ip} • `} + {session.ip && ( + + {session.ip} •{' '} + + )} Last active: {format(new Date(session.refreshed_at || session.created_at), 'PPpp')} {session.aal === 'aal2' && ' • MFA'}

@@ -393,7 +438,7 @@ export function SecurityTab() {
+ + !open && setSessionToRevoke(null)} + onConfirm={confirmRevokeSession} + isCurrentSession={sessionToRevoke?.isCurrent ?? false} + />
); diff --git a/src/components/settings/SessionRevokeConfirmDialog.tsx b/src/components/settings/SessionRevokeConfirmDialog.tsx new file mode 100644 index 00000000..27d858b4 --- /dev/null +++ b/src/components/settings/SessionRevokeConfirmDialog.tsx @@ -0,0 +1,53 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isCurrentSession: boolean; +} + +export function SessionRevokeConfirmDialog({ + open, + onOpenChange, + onConfirm, + isCurrentSession +}: Props) { + return ( + + + + Revoke Session? + + {isCurrentSession ? ( + <> + This is your current session. Revoking it will sign you out immediately + and you'll need to log in again. + + ) : ( + <> + This will immediately end the selected session. The device using that + session will be signed out. + + )} + + + + Cancel + + {isCurrentSession ? 'Sign Out' : 'Revoke Session'} + + + + + ); +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index f0eed724..6c3775ba 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -3043,10 +3043,10 @@ export type Database = { get_my_sessions: { Args: Record Returns: { - aal: "aal1" | "aal2" | "aal3" + aal: string created_at: string id: string - ip: unknown + ip: string not_after: string refreshed_at: string updated_at: string @@ -3084,6 +3084,10 @@ export type Database = { Args: { ip_text: string } Returns: string } + hash_session_ip: { + Args: { session_ip: unknown } + Returns: string + } increment_blog_view_count: { Args: { post_slug: string } Returns: undefined @@ -3125,6 +3129,10 @@ export type Database = { Args: { session_id: string } Returns: undefined } + revoke_session_with_mfa: { + Args: { target_session_id: string; target_user_id: string } + Returns: Json + } rollback_to_version: { Args: { p_changed_by: string diff --git a/src/lib/identityService.ts b/src/lib/identityService.ts index 34afaac5..6211659a 100644 --- a/src/lib/identityService.ts +++ b/src/lib/identityService.ts @@ -11,6 +11,7 @@ import type { IdentitySafetyCheck, IdentityOperationResult } from '@/types/identity'; +import { logger } from './logger'; /** * Get all identities for the current user @@ -23,7 +24,10 @@ export async function getUserIdentities(): Promise { return (data?.identities || []) as UserIdentity[]; } catch (error: any) { - console.error('[IdentityService] Failed to get identities:', error); + logger.error('Failed to get user identities', { + action: 'get_identities', + error: error.message + }); return []; } } @@ -124,7 +128,11 @@ export async function disconnectIdentity( return { success: true }; } catch (error: any) { - console.error('[IdentityService] Failed to disconnect identity:', error); + logger.error('Failed to disconnect identity', { + action: 'identity_disconnect', + provider, + error: error.message + }); return { success: false, error: error.message || 'Failed to disconnect identity' @@ -152,7 +160,11 @@ export async function connectIdentity( return { success: true }; } catch (error: any) { - console.error('[IdentityService] Failed to connect identity:', error); + logger.error('Failed to connect identity', { + action: 'identity_connect', + provider, + error: error.message + }); return { success: false, error: error.message || `Failed to connect ${provider} account` @@ -177,7 +189,10 @@ export async function addPasswordToAccount(): Promise { }; } - console.log('[IdentityService] Sending password reset email'); + logger.info('Initiating password setup', { + action: 'password_setup_initiated', + email: userEmail + }); // Trigger Supabase password reset email // User clicks link and sets password, which automatically creates email identity @@ -189,11 +204,19 @@ export async function addPasswordToAccount(): Promise { ); if (resetError) { - console.error('[IdentityService] Failed to send password reset email:', resetError); + logger.error('Failed to send password reset email', { + userId: user?.id, + action: 'password_setup_email', + error: resetError.message + }); throw resetError; } - console.log('[IdentityService] Password reset email sent successfully'); + logger.info('Password reset email sent', { + userId: user!.id, + action: 'password_setup_initiated', + email: userEmail + }); // Log the action await logIdentityChange(user!.id, 'password_setup_initiated', { @@ -207,7 +230,10 @@ export async function addPasswordToAccount(): Promise { email: userEmail }; } catch (error: any) { - console.error('[IdentityService] Failed to initiate password setup:', error); + logger.error('Failed to initiate password setup', { + action: 'password_setup', + error: error.message + }); return { success: false, error: error.message || 'Failed to send password reset email' @@ -232,7 +258,11 @@ async function logIdentityChange( _details: details }); } catch (error) { - console.error('[IdentityService] Failed to log audit event:', error); + logger.error('Failed to log identity change to audit', { + userId, + action, + error: error instanceof Error ? error.message : String(error) + }); // Don't fail the operation if audit logging fails } } diff --git a/src/lib/securityValidation.ts b/src/lib/securityValidation.ts new file mode 100644 index 00000000..97f83dda --- /dev/null +++ b/src/lib/securityValidation.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; +import type { SecurityOperation } from '@/types/auth'; +import type { UserRole } from '@/hooks/useUserRole'; + +/** + * Validation schemas for security operations + */ + +export const sessionRevocationSchema = z.object({ + sessionId: z.string().uuid('Invalid session ID'), + requiresConfirmation: z.boolean().default(true), +}); + +export const identityOperationSchema = z.object({ + provider: z.enum(['google', 'discord']), + redirectTo: z.string().url().optional(), +}); + +export const mfaOperationSchema = z.object({ + factorId: z.string().uuid('Invalid factor ID').optional(), + code: z.string().length(6, 'Code must be 6 digits').regex(/^\d+$/, 'Code must be numeric').optional(), +}); + +export const passwordChangeSchema = z.object({ + currentPassword: z.string().min(1, 'Current password required'), + newPassword: z.string() + .min(8, 'Must be at least 8 characters') + .max(128, 'Must be less than 128 characters') + .regex(/[A-Z]/, 'Must contain uppercase letter') + .regex(/[a-z]/, 'Must contain lowercase letter') + .regex(/[0-9]/, 'Must contain number') + .regex(/[^A-Za-z0-9]/, 'Must contain special character'), + confirmPassword: z.string(), + captchaToken: z.string().min(1, 'CAPTCHA verification required'), +}).refine(data => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"] +}); + +/** + * Determines if an operation requires CAPTCHA verification + */ +export function requiresCaptcha(operation: SecurityOperation): boolean { + const captchaOperations: SecurityOperation[] = [ + 'password_change', + 'identity_disconnect', + 'mfa_unenroll' + ]; + return captchaOperations.includes(operation); +} + +/** + * Determines if an operation requires MFA verification for the user's role + */ +export function requiresMFA( + operation: SecurityOperation, + userRoles: UserRole[] +): boolean { + const privilegedRoles: UserRole[] = ['moderator', 'admin', 'superuser']; + const hasPrivilegedRole = userRoles.some(role => privilegedRoles.includes(role)); + + if (!hasPrivilegedRole) return false; + + // MFA required for these operations if user is privileged + const mfaOperations: SecurityOperation[] = [ + 'password_change', + 'session_revoke', + 'mfa_unenroll' + ]; + + return mfaOperations.includes(operation); +} + +/** + * Get rate limit parameters for a security operation + */ +export function getRateLimitParams(operation: SecurityOperation): { + action: string; + maxAttempts: number; + windowMinutes: number; +} { + const limits: Record = { + password_change: { action: 'password_change', maxAttempts: 3, windowMinutes: 60 }, + identity_disconnect: { action: 'identity_disconnect', maxAttempts: 3, windowMinutes: 60 }, + identity_connect: { action: 'identity_connect', maxAttempts: 5, windowMinutes: 60 }, + session_revoke: { action: 'session_revoke', maxAttempts: 10, windowMinutes: 60 }, + mfa_enroll: { action: 'mfa_enroll', maxAttempts: 3, windowMinutes: 60 }, + mfa_unenroll: { action: 'mfa_unenroll', maxAttempts: 2, windowMinutes: 60 }, + }; + + return limits[operation] || { action: operation, maxAttempts: 5, windowMinutes: 60 }; +} diff --git a/src/types/auth.ts b/src/types/auth.ts index d8d601e9..234f91b2 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -72,3 +72,51 @@ export interface AuthServiceResponse { data?: T; error?: string; } + +/** + * Authentication session from Supabase with hashed IP + */ +export interface AuthSession { + id: string; + created_at: string; + updated_at: string; + refreshed_at: string | null; + user_agent: string | null; + ip: string | null; // Pre-hashed by database function + not_after: string | null; + aal: AALLevel | null; +} + +/** + * Security-sensitive operations that may require additional verification + */ +export type SecurityOperation = + | 'password_change' + | 'identity_disconnect' + | 'identity_connect' + | 'session_revoke' + | 'mfa_enroll' + | 'mfa_unenroll'; + +/** + * Rate limit information for security operations + */ +export interface RateLimitInfo { + operation: SecurityOperation; + allowed: boolean; + attemptsRemaining: number; + resetAt: Date; + currentAttempts: number; + maxAttempts: number; +} + +/** + * Security operation context for logging + */ +export interface SecurityContext { + operation: SecurityOperation; + userId?: string; + targetUserId?: string; + requiresMFA?: boolean; + metadata?: Record; +} diff --git a/supabase/migrations/20251014193514_24af409c-7d6c-4978-987e-5b0ae056364a.sql b/supabase/migrations/20251014193514_24af409c-7d6c-4978-987e-5b0ae056364a.sql new file mode 100644 index 00000000..ac65e6ff --- /dev/null +++ b/supabase/migrations/20251014193514_24af409c-7d6c-4978-987e-5b0ae056364a.sql @@ -0,0 +1,185 @@ +-- Security Enhancement Migration: Session Management and Identity Operations +-- Adds functions for secure session management with MFA verification and rate limiting + +-- ============================================================================ +-- PHASE 1: Session IP Hashing for Privacy +-- ============================================================================ + +-- Hash IP address for privacy-preserving display +CREATE OR REPLACE FUNCTION public.hash_session_ip(session_ip inet) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +SET search_path = public +AS $$ +BEGIN + -- Return last 8 chars of SHA256 hash with asterisks prefix for privacy + RETURN '****' || RIGHT(encode(digest(session_ip::text || 'session_salt_2025', 'sha256'), 'hex'), 8); +END; +$$; + +-- ============================================================================ +-- PHASE 2: Drop and Recreate Get User's Own Sessions with Hashed IPs +-- ============================================================================ + +-- Drop the existing function first +DROP FUNCTION IF EXISTS public.get_my_sessions(); + +-- Get user's own sessions with hashed IPs for security +CREATE OR REPLACE FUNCTION public.get_my_sessions() +RETURNS TABLE ( + id uuid, + created_at timestamptz, + updated_at timestamptz, + refreshed_at timestamptz, + user_agent text, + ip text, + not_after timestamptz, + aal text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = auth, public +AS $$ +BEGIN + RETURN QUERY + SELECT + s.id, + s.created_at, + s.updated_at, + s.refreshed_at, + s.user_agent, + public.hash_session_ip(s.ip) as ip, + s.not_after, + s.aal::text + FROM auth.sessions s + WHERE s.user_id = auth.uid() + ORDER BY s.refreshed_at DESC NULLS LAST; +END; +$$; + +-- ============================================================================ +-- PHASE 3: Drop and Recreate Revoke User's Own Session +-- ============================================================================ + +-- Drop the existing function first +DROP FUNCTION IF EXISTS public.revoke_my_session(uuid); + +-- Revoke user's own session with audit logging +CREATE OR REPLACE FUNCTION public.revoke_my_session(session_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = auth, public +AS $$ +BEGIN + -- Only delete own sessions + DELETE FROM auth.sessions + WHERE id = session_id + AND user_id = auth.uid(); + + -- Log the action + INSERT INTO public.profile_audit_log (user_id, changed_by, action, changes) + VALUES ( + auth.uid(), + auth.uid(), + 'session_revoked', + jsonb_build_object( + 'session_id', session_id, + 'timestamp', now(), + 'self_revoked', true + ) + ); +END; +$$; + +-- ============================================================================ +-- PHASE 4: Revoke Session with MFA Verification (Privileged Users) +-- ============================================================================ + +-- Revoke session with MFA verification (for moderators/admins) +CREATE OR REPLACE FUNCTION public.revoke_session_with_mfa( + target_session_id uuid, + target_user_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = auth, public +AS $$ +DECLARE + v_rate_limit_key text; + v_attempts integer; + v_window_start timestamptz; +BEGIN + -- Only moderators can revoke others' sessions + IF NOT public.is_moderator(auth.uid()) THEN + RAISE EXCEPTION 'Insufficient permissions' USING ERRCODE = 'P0003'; + END IF; + + -- Require MFA for moderators + IF NOT public.has_aal2() THEN + RAISE EXCEPTION 'MFA verification required' USING ERRCODE = 'P0004'; + END IF; + + -- Rate limit: 10 revocations per hour + v_rate_limit_key := 'session_revocation_' || auth.uid()::text; + + -- Check existing rate limit + SELECT attempts, window_start + INTO v_attempts, v_window_start + FROM public.rate_limits + WHERE user_id = auth.uid() + AND action = 'session_revocation' + AND window_start > now() - interval '1 hour'; + + -- Enforce rate limit + IF v_attempts >= 10 THEN + RAISE EXCEPTION 'Rate limit exceeded. Try again after %', + (v_window_start + interval '1 hour')::text + USING ERRCODE = 'P0001'; + END IF; + + -- Update or create rate limit record + INSERT INTO public.rate_limits (user_id, action, attempts, window_start) + VALUES (auth.uid(), 'session_revocation', 1, now()) + ON CONFLICT (user_id, action) + WHERE window_start > now() - interval '1 hour' + DO UPDATE SET + attempts = rate_limits.attempts + 1, + window_start = CASE + WHEN rate_limits.window_start < now() - interval '1 hour' + THEN now() + ELSE rate_limits.window_start + END; + + -- Revoke the session + DELETE FROM auth.sessions + WHERE id = target_session_id + AND user_id = target_user_id; + + -- Audit log + INSERT INTO public.admin_audit_log (admin_user_id, target_user_id, action, details) + VALUES ( + auth.uid(), + target_user_id, + 'session_revoked_by_moderator', + jsonb_build_object( + 'session_id', target_session_id, + 'timestamp', now(), + 'mfa_verified', true + ) + ); + + RETURN jsonb_build_object('success', true); +END; +$$; + +-- ============================================================================ +-- PHASE 5: Grant Permissions +-- ============================================================================ + +GRANT EXECUTE ON FUNCTION public.hash_session_ip TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_my_sessions TO authenticated; +GRANT EXECUTE ON FUNCTION public.revoke_my_session TO authenticated; +GRANT EXECUTE ON FUNCTION public.revoke_session_with_mfa TO authenticated; \ No newline at end of file