mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -05:00
Refactor security functions
This commit is contained in:
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
53
src/components/settings/SessionRevokeConfirmDialog.tsx
Normal file
53
src/components/settings/SessionRevokeConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3043,10 +3043,10 @@ export type Database = {
|
||||
get_my_sessions: {
|
||||
Args: Record<PropertyKey, never>
|
||||
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
|
||||
|
||||
@@ -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<UserIdentity[]> {
|
||||
|
||||
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<IdentityOperationResult> {
|
||||
};
|
||||
}
|
||||
|
||||
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<IdentityOperationResult> {
|
||||
);
|
||||
|
||||
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<IdentityOperationResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
92
src/lib/securityValidation.ts
Normal file
92
src/lib/securityValidation.ts
Normal 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 };
|
||||
}
|
||||
@@ -72,3 +72,51 @@ export interface AuthServiceResponse<T = void> {
|
||||
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<string, any>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user