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 { 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<MFAFactor[]>([]);
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 = () => {

View File

@@ -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<Step>('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<string>('');
const [captchaKey, setCaptchaKey] = useState(0);
const [userId, setUserId] = useState<string>('');
const form = useForm<PasswordFormData>({
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
}
}

View File

@@ -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<AuthSession[]>([]);
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() {
</CardHeader>
<CardContent>
{loadingSessions ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<div className="space-y-3">
{[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>
) : sessions.length > 0 ? (
<div className="space-y-3">
@@ -379,7 +420,11 @@ export function SecurityTab() {
{getBrowserName(session.user_agent)}
</p>
<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')}
{session.aal === 'aal2' && ' • MFA'}
</p>
@@ -393,7 +438,7 @@ export function SecurityTab() {
<Button
variant="destructive"
size="sm"
onClick={() => revokeSession(session.id)}
onClick={() => initiateSessionRevoke(session.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
Revoke
@@ -409,6 +454,13 @@ export function SecurityTab() {
</CardContent>
</Card>
</div>
<SessionRevokeConfirmDialog
open={!!sessionToRevoke}
onOpenChange={(open) => !open && setSessionToRevoke(null)}
onConfirm={confirmRevokeSession}
isCurrentSession={sessionToRevoke?.isCurrent ?? false}
/>
</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>
);
}