Refactor security functions

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 19:38:36 +00:00
parent 1554254c82
commit 95972a0b22
9 changed files with 638 additions and 89 deletions

View File

@@ -5,7 +5,8 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; 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 { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2 } from 'lucide-react'; import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2 } from 'lucide-react';
@@ -16,7 +17,6 @@ import type { MFAFactor } from '@/types/auth';
export function TOTPSetup() { export function TOTPSetup() {
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const [factors, setFactors] = useState<MFAFactor[]>([]); const [factors, setFactors] = useState<MFAFactor[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -49,7 +49,11 @@ export function TOTPSetup() {
})); }));
setFactors(totpFactors); setFactors(totpFactors);
} catch (error: any) { } 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); setFactorId(data.id);
setEnrolling(true); setEnrolling(true);
} catch (error: any) { } catch (error: any) {
toast({ logger.error('Failed to start TOTP enrollment', {
title: 'Error', userId: user?.id,
description: error.message || 'Failed to start TOTP enrollment', action: 'totp_enroll_start',
variant: 'destructive' error: error.message
}); });
handleError(
new AppError(
error.message || 'Failed to start TOTP enrollment',
'TOTP_ENROLL_FAILED'
),
{ action: 'Start TOTP enrollment', userId: user?.id }
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -82,11 +93,10 @@ export function TOTPSetup() {
const verifyAndEnable = async () => { const verifyAndEnable = async () => {
if (!factorId || !verificationCode.trim()) { if (!factorId || !verificationCode.trim()) {
toast({ handleError(
title: 'Error', new AppError('Please enter the verification code', 'INVALID_INPUT'),
description: 'Please enter the verification code', { action: 'Verify TOTP', userId: user?.id, metadata: { step: 'code_entry' } }
variant: 'destructive' );
});
return; return;
} }
@@ -119,12 +129,12 @@ export function TOTPSetup() {
return; return;
} }
toast({ handleSuccess(
title: 'TOTP Enabled', 'TOTP Enabled',
description: isOAuthUser isOAuthUser
? 'Please verify with your authenticator code to continue.' ? 'Please verify with your authenticator code to continue.'
: 'Please sign in again to activate MFA protection.' : 'Please sign in again to activate MFA protection.'
}); );
if (isOAuthUser) { if (isOAuthUser) {
// Already handled above with navigate // Already handled above with navigate
@@ -137,11 +147,20 @@ export function TOTPSetup() {
}, 2000); }, 2000);
} }
} catch (error: any) { } catch (error: any) {
toast({ logger.error('TOTP verification failed', {
title: 'Error', userId: user?.id,
description: error.message || 'Invalid verification code. Please try again.', action: 'totp_verify',
variant: 'destructive' 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -153,10 +172,7 @@ export function TOTPSetup() {
const copySecret = () => { const copySecret = () => {
navigator.clipboard.writeText(secret); navigator.clipboard.writeText(secret);
toast({ handleInfo('Copied', 'Secret key copied to clipboard');
title: 'Copied',
description: 'Secret key copied to clipboard'
});
}; };
const cancelEnrollment = () => { const cancelEnrollment = () => {

View File

@@ -12,7 +12,8 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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 { supabase } from '@/integrations/supabase/client';
import { Loader2, Shield, CheckCircle2 } from 'lucide-react'; import { Loader2, Shield, CheckCircle2 } from 'lucide-react';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
@@ -33,7 +34,6 @@ interface PasswordUpdateDialogProps {
type Step = 'password' | 'mfa' | 'success'; type Step = 'password' | 'mfa' | 'success';
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) { export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
const { toast } = useToast();
const { theme } = useTheme(); const { theme } = useTheme();
const [step, setStep] = useState<Step>('password'); const [step, setStep] = useState<Step>('password');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -43,6 +43,7 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
const [captchaToken, setCaptchaToken] = useState<string>(''); const [captchaToken, setCaptchaToken] = useState<string>('');
const [captchaKey, setCaptchaKey] = useState(0); const [captchaKey, setCaptchaKey] = useState(0);
const [userId, setUserId] = useState<string>('');
const form = useForm<PasswordFormData>({ const form = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema), 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(() => { useEffect(() => {
if (open) { if (open) {
checkMFAStatus(); checkMFAStatus();
supabase.auth.getUser().then(({ data }) => {
if (data.user) setUserId(data.user.id);
});
} }
}, [open]); }, [open]);
@@ -68,17 +72,23 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
const hasVerifiedTotp = data?.totp?.some(factor => factor.status === 'verified') || false; const hasVerifiedTotp = data?.totp?.some(factor => factor.status === 'verified') || false;
setHasMFA(hasVerifiedTotp); setHasMFA(hasVerifiedTotp);
} catch (error) { } 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) => { const onSubmit = async (data: PasswordFormData) => {
if (!captchaToken) { if (!captchaToken) {
toast({ handleError(
title: 'CAPTCHA Required', new AppError('Please complete the CAPTCHA verification.', 'CAPTCHA_REQUIRED'),
description: 'Please complete the CAPTCHA verification.', {
variant: 'destructive' action: 'Change password',
}); userId,
metadata: { step: 'captcha_validation' }
}
);
return; return;
} }
@@ -97,6 +107,14 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
// Reset CAPTCHA on authentication failure // Reset CAPTCHA on authentication failure
setCaptchaToken(''); setCaptchaToken('');
setCaptchaKey(prev => prev + 1); setCaptchaKey(prev => prev + 1);
logger.error('Password authentication failed', {
userId,
action: 'password_change_auth',
error: signInError.message,
errorCode: signInError.code
});
throw signInError; throw signInError;
} }
@@ -117,18 +135,37 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
await updatePasswordWithNonce(data.newPassword, generatedNonce); await updatePasswordWithNonce(data.newPassword, generatedNonce);
} }
} catch (error: any) { } catch (error: any) {
// Handle rate limiting specifically logger.error('Password change failed', {
if (error.message?.includes('rate limit') || error.status === 429) { userId,
toast({ action: 'password_change',
title: 'Too Many Attempts', error: error instanceof Error ? error.message : String(error),
description: 'Please wait a few minutes before trying again.', errorCode: error.code,
variant: 'destructive' errorStatus: error.status
}); });
if (error.message?.includes('rate limit') || error.status === 429) {
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 { } else {
toast({ handleError(error, {
title: 'Authentication Error', action: 'Change password',
description: error.message || 'Incorrect password. Please try again.', userId,
variant: 'destructive' metadata: { step: 'authentication' }
}); });
} }
} finally { } finally {
@@ -138,11 +175,10 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
const verifyMFAAndUpdate = async () => { const verifyMFAAndUpdate = async () => {
if (totpCode.length !== 6) { if (totpCode.length !== 6) {
toast({ handleError(
title: 'Invalid Code', new AppError('Please enter a valid 6-digit code', 'INVALID_MFA_CODE'),
description: 'Please enter a valid 6-digit code', { action: 'Verify MFA', userId, metadata: { step: 'mfa_verification' } }
variant: 'destructive' );
});
return; return;
} }
@@ -153,7 +189,15 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
factorId: (await supabase.auth.mfa.listFactors()).data?.totp?.[0]?.id || '' 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({ const { error: verifyError } = await supabase.auth.mfa.verify({
factorId: challengeData.id, factorId: challengeData.id,
@@ -161,16 +205,33 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
code: totpCode 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 // TOTP verified, now update password
await updatePasswordWithNonce(newPassword, nonce); await updatePasswordWithNonce(newPassword, nonce);
} catch (error: any) { } catch (error: any) {
toast({ logger.error('MFA verification failed', {
title: 'Verification Failed', userId,
description: error.message || 'Invalid authentication code', action: 'password_change_mfa',
variant: 'destructive' 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -213,7 +274,11 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
} }
}); });
} catch (notifError) { } 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 // Don't fail the password update if notification fails
} }
} }

View File

@@ -8,6 +8,7 @@ import { handleError, handleSuccess } from '@/lib/errorHandler';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react'; import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Skeleton } from '@/components/ui/skeleton';
import { TOTPSetup } from '@/components/auth/TOTPSetup'; import { TOTPSetup } from '@/components/auth/TOTPSetup';
import { GoogleIcon } from '@/components/icons/GoogleIcon'; import { GoogleIcon } from '@/components/icons/GoogleIcon';
import { DiscordIcon } from '@/components/icons/DiscordIcon'; import { DiscordIcon } from '@/components/icons/DiscordIcon';
@@ -20,18 +21,10 @@ import {
addPasswordToAccount addPasswordToAccount
} from '@/lib/identityService'; } from '@/lib/identityService';
import type { UserIdentity, OAuthProvider } from '@/types/identity'; import type { UserIdentity, OAuthProvider } from '@/types/identity';
import type { AuthSession } from '@/types/auth';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { logger } from '@/lib/logger';
interface AuthSession { import { SessionRevokeConfirmDialog } from './SessionRevokeConfirmDialog';
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;
}
export function SecurityTab() { export function SecurityTab() {
const { user } = useAuth(); const { user } = useAuth();
@@ -44,6 +37,7 @@ export function SecurityTab() {
const [addingPassword, setAddingPassword] = useState(false); const [addingPassword, setAddingPassword] = useState(false);
const [sessions, setSessions] = useState<AuthSession[]>([]); const [sessions, setSessions] = useState<AuthSession[]>([]);
const [loadingSessions, setLoadingSessions] = useState(true); const [loadingSessions, setLoadingSessions] = useState(true);
const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null);
// Load user identities on mount // Load user identities on mount
useEffect(() => { useEffect(() => {
@@ -61,8 +55,12 @@ export function SecurityTab() {
const hasEmailProvider = fetchedIdentities.some(i => i.provider === 'email'); const hasEmailProvider = fetchedIdentities.some(i => i.provider === 'email');
setHasPassword(hasEmailProvider); setHasPassword(hasEmailProvider);
} catch (error) { } catch (error) {
console.error('Failed to load identities:', error); logger.error('Failed to load identities', {
handleError(error, { action: 'Load connected accounts' }); userId: user?.id,
action: 'load_identities',
error: error instanceof Error ? error.message : String(error)
});
handleError(error, { action: 'Load connected accounts', userId: user?.id });
} finally { } finally {
setLoadingIdentities(false); setLoadingIdentities(false);
} }
@@ -151,23 +149,55 @@ export function SecurityTab() {
const { data, error } = await supabase.rpc('get_my_sessions'); const { data, error } = await supabase.rpc('get_my_sessions');
if (error) { if (error) {
console.error('Error fetching sessions:', error); logger.error('Failed to fetch sessions', {
handleError(error, { action: 'Load sessions' }); userId: user.id,
action: 'fetch_sessions',
error: error.message
});
handleError(error, { action: 'Load sessions', userId: user.id });
} else { } else {
setSessions(data || []); setSessions((data as AuthSession[]) || []);
} }
setLoadingSessions(false); setLoadingSessions(false);
}; };
const revokeSession = async (sessionId: string) => { const initiateSessionRevoke = async (sessionId: string) => {
const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionId }); // 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) { 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 { } else {
handleSuccess('Success', 'Session revoked successfully'); handleSuccess('Success', 'Session revoked successfully');
if (sessionToRevoke.isCurrent) {
// Redirect to login after revoking current session
setTimeout(() => {
window.location.href = '/auth';
}, 1000);
} else {
fetchSessions(); fetchSessions();
} }
}
setSessionToRevoke(null);
}; };
const getDeviceIcon = (userAgent: string | null) => { const getDeviceIcon = (userAgent: string | null) => {
@@ -365,8 +395,19 @@ export function SecurityTab() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingSessions ? ( {loadingSessions ? (
<div className="flex items-center justify-center p-8"> <div className="space-y-3">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> {[1, 2, 3].map(i => (
<div key={i} className="flex items-start justify-between p-3 border rounded-lg">
<div className="flex gap-3 flex-1">
<Skeleton className="w-4 h-4 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</div>
<Skeleton className="w-20 h-8" />
</div>
))}
</div> </div>
) : sessions.length > 0 ? ( ) : sessions.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -379,7 +420,11 @@ export function SecurityTab() {
{getBrowserName(session.user_agent)} {getBrowserName(session.user_agent)}
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{session.ip && `${session.ip}`} {session.ip && (
<span title="Last 8 characters of hashed IP address for privacy">
{session.ip} {' '}
</span>
)}
Last active: {format(new Date(session.refreshed_at || session.created_at), 'PPpp')} Last active: {format(new Date(session.refreshed_at || session.created_at), 'PPpp')}
{session.aal === 'aal2' && ' • MFA'} {session.aal === 'aal2' && ' • MFA'}
</p> </p>
@@ -393,7 +438,7 @@ export function SecurityTab() {
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => revokeSession(session.id)} onClick={() => initiateSessionRevoke(session.id)}
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Revoke Revoke
@@ -409,6 +454,13 @@ export function SecurityTab() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<SessionRevokeConfirmDialog
open={!!sessionToRevoke}
onOpenChange={(open) => !open && setSessionToRevoke(null)}
onConfirm={confirmRevokeSession}
isCurrentSession={sessionToRevoke?.isCurrent ?? false}
/>
</div> </div>
</> </>
); );

View File

@@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke Session?</AlertDialogTitle>
<AlertDialogDescription>
{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.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} className="bg-destructive hover:bg-destructive/90">
{isCurrentSession ? 'Sign Out' : 'Revoke Session'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -3043,10 +3043,10 @@ export type Database = {
get_my_sessions: { get_my_sessions: {
Args: Record<PropertyKey, never> Args: Record<PropertyKey, never>
Returns: { Returns: {
aal: "aal1" | "aal2" | "aal3" aal: string
created_at: string created_at: string
id: string id: string
ip: unknown ip: string
not_after: string not_after: string
refreshed_at: string refreshed_at: string
updated_at: string updated_at: string
@@ -3084,6 +3084,10 @@ export type Database = {
Args: { ip_text: string } Args: { ip_text: string }
Returns: string Returns: string
} }
hash_session_ip: {
Args: { session_ip: unknown }
Returns: string
}
increment_blog_view_count: { increment_blog_view_count: {
Args: { post_slug: string } Args: { post_slug: string }
Returns: undefined Returns: undefined
@@ -3125,6 +3129,10 @@ export type Database = {
Args: { session_id: string } Args: { session_id: string }
Returns: undefined Returns: undefined
} }
revoke_session_with_mfa: {
Args: { target_session_id: string; target_user_id: string }
Returns: Json
}
rollback_to_version: { rollback_to_version: {
Args: { Args: {
p_changed_by: string p_changed_by: string

View File

@@ -11,6 +11,7 @@ import type {
IdentitySafetyCheck, IdentitySafetyCheck,
IdentityOperationResult IdentityOperationResult
} from '@/types/identity'; } from '@/types/identity';
import { logger } from './logger';
/** /**
* Get all identities for the current user * Get all identities for the current user
@@ -23,7 +24,10 @@ export async function getUserIdentities(): Promise<UserIdentity[]> {
return (data?.identities || []) as UserIdentity[]; return (data?.identities || []) as UserIdentity[];
} catch (error: any) { } 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 []; return [];
} }
} }
@@ -124,7 +128,11 @@ export async function disconnectIdentity(
return { success: true }; return { success: true };
} catch (error: any) { } 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 { return {
success: false, success: false,
error: error.message || 'Failed to disconnect identity' error: error.message || 'Failed to disconnect identity'
@@ -152,7 +160,11 @@ export async function connectIdentity(
return { success: true }; return { success: true };
} catch (error: any) { } 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 { return {
success: false, success: false,
error: error.message || `Failed to connect ${provider} account` error: error.message || `Failed to connect ${provider} account`
@@ -177,7 +189,10 @@ export async function addPasswordToAccount(): Promise<IdentityOperationResult> {
}; };
} }
console.log('[IdentityService] Sending password reset email'); logger.info('Initiating password setup', {
action: 'password_setup_initiated',
email: userEmail
});
// Trigger Supabase password reset email // Trigger Supabase password reset email
// User clicks link and sets password, which automatically creates email identity // User clicks link and sets password, which automatically creates email identity
@@ -189,11 +204,19 @@ export async function addPasswordToAccount(): Promise<IdentityOperationResult> {
); );
if (resetError) { 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; 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 // Log the action
await logIdentityChange(user!.id, 'password_setup_initiated', { await logIdentityChange(user!.id, 'password_setup_initiated', {
@@ -207,7 +230,10 @@ export async function addPasswordToAccount(): Promise<IdentityOperationResult> {
email: userEmail email: userEmail
}; };
} catch (error: any) { } 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 { return {
success: false, success: false,
error: error.message || 'Failed to send password reset email' error: error.message || 'Failed to send password reset email'
@@ -232,7 +258,11 @@ async function logIdentityChange(
_details: details _details: details
}); });
} catch (error) { } 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 // Don't fail the operation if audit logging fails
} }
} }

View File

@@ -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<SecurityOperation, { action: string; maxAttempts: number; windowMinutes: number }> = {
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 };
}

View File

@@ -72,3 +72,51 @@ export interface AuthServiceResponse<T = void> {
data?: T; data?: T;
error?: string; 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<string, any>;
}

View File

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