Add circuit breaker pattern to email verification system
All checks were successful
Build And Push Image / docker (push) Successful in 2m53s
All checks were successful
Build And Push Image / docker (push) Successful in 2m53s
Implement rate limiting and attempt tracking to prevent verification abuse and infinite reload loops. Add temporary blocking with clear user feedback, enhanced error states, and retry logic. Includes new verification state utilities and improved UI components for better user experience during blocked states.
This commit is contained in:
300
utils/verification-state.ts
Normal file
300
utils/verification-state.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Client-side verification state management with circuit breaker pattern
|
||||
* Prevents endless reload loops on mobile browsers
|
||||
*/
|
||||
|
||||
export interface VerificationAttempt {
|
||||
token: string;
|
||||
attempts: number;
|
||||
lastAttempt: number;
|
||||
maxAttempts: number;
|
||||
status: 'pending' | 'success' | 'failed' | 'blocked';
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'email_verification_state';
|
||||
const MAX_ATTEMPTS_DEFAULT = 3;
|
||||
const ATTEMPT_WINDOW = 5 * 60 * 1000; // 5 minutes
|
||||
const CIRCUIT_BREAKER_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/**
|
||||
* Get verification state for a token
|
||||
*/
|
||||
export function getVerificationState(token: string): VerificationAttempt | null {
|
||||
if (typeof window === 'undefined' || !token) return null;
|
||||
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`${STORAGE_KEY}_${token.substring(0, 10)}`);
|
||||
if (!stored) return null;
|
||||
|
||||
const state = JSON.parse(stored) as VerificationAttempt;
|
||||
|
||||
// Check if circuit breaker timeout has passed
|
||||
const now = Date.now();
|
||||
if (state.status === 'blocked' && (now - state.lastAttempt) > CIRCUIT_BREAKER_TIMEOUT) {
|
||||
console.log('[verification-state] Circuit breaker timeout passed, resetting state');
|
||||
clearVerificationState(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch (error) {
|
||||
console.warn('[verification-state] Failed to parse stored state:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize or update verification state
|
||||
*/
|
||||
export function initVerificationState(token: string, maxAttempts: number = MAX_ATTEMPTS_DEFAULT): VerificationAttempt {
|
||||
if (typeof window === 'undefined' || !token) {
|
||||
throw new Error('Cannot initialize verification state: no window or token');
|
||||
}
|
||||
|
||||
const existing = getVerificationState(token);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const state: VerificationAttempt = {
|
||||
token,
|
||||
attempts: 0,
|
||||
lastAttempt: 0,
|
||||
maxAttempts,
|
||||
status: 'pending',
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(`${STORAGE_KEY}_${token.substring(0, 10)}`, JSON.stringify(state));
|
||||
console.log('[verification-state] Initialized verification state for token');
|
||||
return state;
|
||||
} catch (error) {
|
||||
console.error('[verification-state] Failed to save state:', error);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a verification attempt
|
||||
*/
|
||||
export function recordAttempt(token: string, success: boolean = false, error?: string): VerificationAttempt {
|
||||
if (typeof window === 'undefined' || !token) {
|
||||
throw new Error('Cannot record attempt: no window or token');
|
||||
}
|
||||
|
||||
const state = getVerificationState(token) || initVerificationState(token);
|
||||
const now = Date.now();
|
||||
|
||||
// Check if we're within the attempt window
|
||||
if (state.lastAttempt > 0 && (now - state.lastAttempt) > ATTEMPT_WINDOW) {
|
||||
console.log('[verification-state] Attempt window expired, resetting counter');
|
||||
state.attempts = 0;
|
||||
state.errors = [];
|
||||
}
|
||||
|
||||
state.attempts++;
|
||||
state.lastAttempt = now;
|
||||
|
||||
if (success) {
|
||||
state.status = 'success';
|
||||
console.log('[verification-state] Verification successful, clearing state');
|
||||
// Don't clear immediately - let the navigation complete first
|
||||
setTimeout(() => clearVerificationState(token), 1000);
|
||||
} else {
|
||||
if (error) {
|
||||
state.errors.push(error);
|
||||
}
|
||||
|
||||
if (state.attempts >= state.maxAttempts) {
|
||||
state.status = 'blocked';
|
||||
console.log(`[verification-state] Maximum attempts (${state.maxAttempts}) reached, blocking further attempts`);
|
||||
} else {
|
||||
state.status = 'failed';
|
||||
console.log(`[verification-state] Attempt ${state.attempts}/${state.maxAttempts} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(`${STORAGE_KEY}_${token.substring(0, 10)}`, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.error('[verification-state] Failed to update state:', error);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if verification should be blocked
|
||||
*/
|
||||
export function shouldBlockVerification(token: string): boolean {
|
||||
if (typeof window === 'undefined' || !token) return false;
|
||||
|
||||
const state = getVerificationState(token);
|
||||
if (!state) return false;
|
||||
|
||||
if (state.status === 'blocked') {
|
||||
const timeRemaining = CIRCUIT_BREAKER_TIMEOUT - (Date.now() - state.lastAttempt);
|
||||
if (timeRemaining > 0) {
|
||||
console.log(`[verification-state] Verification blocked for ${Math.ceil(timeRemaining / 1000 / 60)} more minutes`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return state.status === 'success' || (state.attempts >= state.maxAttempts && state.status !== 'pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear verification state for a token
|
||||
*/
|
||||
export function clearVerificationState(token: string): void {
|
||||
if (typeof window === 'undefined' || !token) return;
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(`${STORAGE_KEY}_${token.substring(0, 10)}`);
|
||||
console.log('[verification-state] Cleared verification state');
|
||||
} catch (error) {
|
||||
console.warn('[verification-state] Failed to clear state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly status message
|
||||
*/
|
||||
export function getStatusMessage(state: VerificationAttempt | null): string {
|
||||
if (!state) return '';
|
||||
|
||||
switch (state.status) {
|
||||
case 'pending':
|
||||
return '';
|
||||
case 'success':
|
||||
return 'Email verified successfully!';
|
||||
case 'failed':
|
||||
if (state.attempts === 1) {
|
||||
return 'Verification failed. Retrying...';
|
||||
}
|
||||
return `Verification failed (${state.attempts}/${state.maxAttempts} attempts). ${state.maxAttempts - state.attempts} attempts remaining.`;
|
||||
case 'blocked':
|
||||
const timeRemaining = Math.ceil((CIRCUIT_BREAKER_TIMEOUT - (Date.now() - state.lastAttempt)) / 1000 / 60);
|
||||
return `Too many failed attempts. Please wait ${timeRemaining} minutes before trying again, or contact support.`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Progressive navigation with fallbacks for mobile browsers
|
||||
*/
|
||||
export async function navigateWithFallback(url: string, options: { replace?: boolean } = {}): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
console.log(`[verification-state] Attempting navigation to: ${url}`);
|
||||
|
||||
try {
|
||||
// Method 1: Use Nuxt navigateTo
|
||||
if (typeof navigateTo === 'function') {
|
||||
console.log('[verification-state] Using navigateTo');
|
||||
await navigateTo(url, options);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[verification-state] navigateTo failed:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Method 2: Use Vue Router (if available)
|
||||
const nuxtApp = (window as any)?.$nuxt;
|
||||
if (nuxtApp?.$router) {
|
||||
console.log('[verification-state] Using Vue Router');
|
||||
if (options.replace) {
|
||||
await nuxtApp.$router.replace(url);
|
||||
} else {
|
||||
await nuxtApp.$router.push(url);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[verification-state] Vue Router failed:', error);
|
||||
}
|
||||
|
||||
// Method 3: Direct window.location (mobile fallback)
|
||||
console.log('[verification-state] Using window.location fallback');
|
||||
if (options.replace) {
|
||||
window.location.replace(url);
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-specific delay before navigation to ensure stability
|
||||
*/
|
||||
export function getMobileNavigationDelay(): number {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
|
||||
// Detect mobile browsers
|
||||
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
const isSafari = /Safari/i.test(navigator.userAgent) && !/Chrome/i.test(navigator.userAgent);
|
||||
|
||||
if (isMobile && isSafari) {
|
||||
return 500; // Extra delay for Safari on iOS
|
||||
} else if (isMobile) {
|
||||
return 300; // Standard mobile delay
|
||||
}
|
||||
|
||||
return 100; // Minimal delay for desktop
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all expired verification states
|
||||
*/
|
||||
export function cleanupExpiredStates(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (!key?.startsWith(STORAGE_KEY)) continue;
|
||||
|
||||
try {
|
||||
const stored = sessionStorage.getItem(key);
|
||||
if (!stored) continue;
|
||||
|
||||
const state = JSON.parse(stored) as VerificationAttempt;
|
||||
|
||||
// Remove states older than circuit breaker timeout
|
||||
if ((now - state.lastAttempt) > CIRCUIT_BREAKER_TIMEOUT) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
} catch (error) {
|
||||
// Remove invalid stored data
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach(key => {
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
if (keysToRemove.length > 0) {
|
||||
console.log(`[verification-state] Cleaned up ${keysToRemove.length} expired verification states`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[verification-state] Failed to cleanup expired states:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-cleanup on page load
|
||||
if (typeof window !== 'undefined') {
|
||||
// Clean up immediately
|
||||
cleanupExpiredStates();
|
||||
|
||||
// Clean up periodically
|
||||
setInterval(cleanupExpiredStates, 5 * 60 * 1000); // Every 5 minutes
|
||||
}
|
||||
Reference in New Issue
Block a user