mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:51: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 { 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 = () => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
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: {
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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