Add circuit breaker pattern to email verification system
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:
2025-08-10 15:48:11 +02:00
parent c4379f0813
commit 62be77ec34
5 changed files with 816 additions and 57 deletions

View File

@@ -5,8 +5,43 @@
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="elevation-12 rounded-lg">
<v-card-text class="text-center pa-8">
<!-- Circuit Breaker - Too Many Attempts -->
<div v-if="isBlocked" class="mb-6">
<v-icon
color="warning"
size="80"
class="mb-4"
>
mdi-timer-sand
</v-icon>
<h1 class="text-h4 font-weight-bold text-warning mb-3">
Verification Temporarily Blocked
</h1>
<p class="text-body-1 text-medium-emphasis mb-4">
{{ statusMessage }}
</p>
<v-alert
type="warning"
variant="tonal"
class="mb-4 text-start"
icon="mdi-information"
>
<div class="text-body-2">
<strong>Why was this blocked?</strong>
<ul class="mt-2">
<li>Multiple failed verification attempts detected</li>
<li>This prevents server overload and potential issues</li>
<li>The block will be lifted automatically</li>
</ul>
</div>
</v-alert>
</div>
<!-- Loading State -->
<div v-if="verifying" class="mb-6">
<div v-else-if="verifying" class="mb-6">
<v-progress-circular
color="primary"
size="80"
@@ -19,9 +54,19 @@
Verifying Your Email
</h1>
<p class="text-body-1 text-medium-emphasis">
<p class="text-body-1 text-medium-emphasis" v-if="verificationState">
{{ statusMessage || 'Please wait while we verify your email address...' }}
</p>
<p class="text-body-1 text-medium-emphasis" v-else>
Please wait while we verify your email address...
</p>
<!-- Attempt Counter -->
<div v-if="verificationState && verificationState.attempts > 1" class="mt-2">
<v-chip size="small" color="primary" variant="outlined">
Attempt {{ verificationState.attempts }}/{{ verificationState.maxAttempts }}
</v-chip>
</div>
</div>
<!-- Error State -->
@@ -41,6 +86,18 @@
<p class="text-body-1 text-medium-emphasis mb-4">
{{ error }}
</p>
<!-- Circuit Breaker Status -->
<div v-if="verificationState && statusMessage" class="mb-4">
<v-alert
type="info"
variant="tonal"
class="text-start"
icon="mdi-information"
>
{{ statusMessage }}
</v-alert>
</div>
<v-alert
type="error"
@@ -54,14 +111,15 @@
<li>The verification link may have expired</li>
<li>The link may have already been used</li>
<li>The link may be malformed</li>
<li v-if="partialSuccess">Server configuration issues (contact support)</li>
</ul>
</div>
</v-alert>
</div>
<div v-if="!verifying" class="d-flex flex-column gap-3">
<div v-if="!verifying && !isBlocked" class="d-flex flex-column gap-3">
<v-btn
v-if="error"
v-if="error && canRetry"
color="primary"
size="large"
variant="elevated"
@@ -99,6 +157,32 @@
</v-btn>
</div>
<div v-else-if="isBlocked" class="d-flex flex-column gap-3">
<v-btn
color="secondary"
size="large"
variant="outlined"
block
to="/signup"
class="text-none"
>
<v-icon start>mdi-account-plus</v-icon>
Register Again
</v-btn>
<v-btn
color="outline"
size="small"
variant="text"
block
to="/"
class="text-none"
>
<v-icon start>mdi-home</v-icon>
Return to Home
</v-btn>
</div>
<!-- Additional help -->
<div class="mt-6 pt-4 border-t">
<p class="text-caption text-medium-emphasis mb-2">
@@ -126,19 +210,32 @@ definePageMeta({
middleware: 'guest'
});
import { getStaticDeviceInfo, getDeviceCssClasses, applyMobileSafariOptimizations, getMobileSafariViewportMeta } from '~/utils/static-device-detection';
import {
getVerificationState,
initVerificationState,
recordAttempt,
shouldBlockVerification,
getStatusMessage,
navigateWithFallback,
getMobileNavigationDelay,
type VerificationAttempt
} from '~/utils/verification-state';
// Get route and token immediately
const route = useRoute();
const token = route.query.token as string || '';
// Reactive state - keep minimal reactivity
const verifying = ref(true);
const verifying = ref(false);
const error = ref('');
const partialSuccess = ref(false);
// Flag to prevent multiple verification attempts
let verificationStarted = false;
let verificationComplete = false;
import { getStaticDeviceInfo, getDeviceCssClasses, applyMobileSafariOptimizations, getMobileSafariViewportMeta } from '~/utils/static-device-detection';
// Verification state management
const verificationState = ref<VerificationAttempt | null>(null);
const isBlocked = ref(false);
const canRetry = ref(true);
const statusMessage = ref('');
// Static device detection - no reactive dependencies
const deviceInfo = getStaticDeviceInfo();
@@ -158,28 +255,45 @@ useHead({
]
});
// Verify email function - make it idempotent
// Update UI state based on verification state
const updateUIState = () => {
if (!verificationState.value) return;
statusMessage.value = getStatusMessage(verificationState.value);
isBlocked.value = shouldBlockVerification(token);
canRetry.value = verificationState.value.attempts < verificationState.value.maxAttempts && !isBlocked.value;
console.log('[auth/verify] UI State updated:', {
status: verificationState.value.status,
attempts: verificationState.value.attempts,
isBlocked: isBlocked.value,
canRetry: canRetry.value
});
};
// Verify email function with circuit breaker
const verifyEmail = async () => {
// Prevent multiple simultaneous verifications
if (verificationStarted || verificationComplete) {
console.log('[auth/verify] Verification already started or complete, skipping...');
return;
}
verificationStarted = true;
if (!token) {
error.value = 'No verification token provided. Please check your email for the correct verification link.';
verifying.value = false;
verificationComplete = true;
return;
}
// Initialize or get existing verification state
verificationState.value = initVerificationState(token);
updateUIState();
// Check if verification should be blocked
if (shouldBlockVerification(token)) {
console.log('[auth/verify] Verification blocked by circuit breaker');
return;
}
console.log(`[auth/verify] Starting verification attempt ${verificationState.value.attempts + 1}/${verificationState.value.maxAttempts}`);
try {
verifying.value = true;
error.value = '';
console.log('[auth/verify] Making verification API call...');
partialSuccess.value = false;
// Call the API endpoint to verify the email
const response = await $fetch(`/api/auth/verify-email?token=${token}`, {
@@ -188,14 +302,21 @@ const verifyEmail = async () => {
console.log('[auth/verify] Email verification successful:', response);
// Extract email from response
// Record successful attempt
verificationState.value = recordAttempt(token, true);
updateUIState();
// Extract response data
const email = response?.data?.email || '';
const partialSuccess = response?.data?.partialSuccess || false;
const isPartialSuccess = response?.data?.partialSuccess || false;
const keycloakError = response?.data?.keycloakError;
if (isPartialSuccess) {
partialSuccess.value = true;
console.log('[auth/verify] Partial success - Keycloak error:', keycloakError);
}
// Mark as complete before navigation
verificationComplete = true;
// Redirect to success page with email info
// Construct redirect URL
let redirectUrl = `/auth/verify-success`;
const queryParams = [];
@@ -203,20 +324,40 @@ const verifyEmail = async () => {
queryParams.push(`email=${encodeURIComponent(email)}`);
}
if (partialSuccess) {
if (isPartialSuccess) {
queryParams.push('warning=partial');
if (keycloakError) {
queryParams.push(`error=${encodeURIComponent(keycloakError)}`);
}
}
if (queryParams.length > 0) {
redirectUrl += '?' + queryParams.join('&');
}
// Use replace to prevent back button issues
await navigateTo(redirectUrl, { replace: true });
// Use progressive navigation with mobile delay
const navigationDelay = getMobileNavigationDelay();
console.log(`[auth/verify] Navigating to success page with ${navigationDelay}ms delay`);
setTimeout(async () => {
try {
await navigateWithFallback(redirectUrl, { replace: true });
} catch (navError) {
console.error('[auth/verify] Navigation failed:', navError);
// Final fallback - direct window location
window.location.replace(redirectUrl);
}
}, navigationDelay);
} catch (err: any) {
console.error('[auth/verify] Email verification failed:', err);
// Record failed attempt
const errorMessage = err.data?.message || err.message || 'Email verification failed';
verificationState.value = recordAttempt(token, false, errorMessage);
updateUIState();
// Set error message based on status code
if (err.statusCode === 410) {
error.value = 'Verification link has expired. Please request a new verification email.';
} else if (err.statusCode === 409) {
@@ -226,19 +367,22 @@ const verifyEmail = async () => {
} else if (err.statusCode === 404) {
error.value = 'User not found. The verification token may be invalid.';
} else {
error.value = err.data?.message || err.message || 'Email verification failed. Please try again or contact support.';
error.value = errorMessage;
}
verifying.value = false;
verificationComplete = true;
}
};
// Retry verification - reset flags
const retryVerification = () => {
verificationStarted = false;
verificationComplete = false;
verifyEmail();
// Retry verification
const retryVerification = async () => {
if (!canRetry.value || isBlocked.value) {
console.log('[auth/verify] Retry blocked - canRetry:', canRetry.value, 'isBlocked:', isBlocked.value);
return;
}
console.log('[auth/verify] Retrying verification...');
await verifyEmail();
};
// Component initialization - Safari iOS reload loop prevention
@@ -250,19 +394,27 @@ onMounted(() => {
applyMobileSafariOptimizations();
console.log('[auth/verify] Mobile Safari optimizations applied');
}
// Check if token exists
if (!token) {
error.value = 'No verification token provided. Please check your email for the correct verification link.';
return;
}
// Initialize verification state
verificationState.value = initVerificationState(token, 3);
updateUIState();
// Check if verification is blocked before starting
if (shouldBlockVerification(token)) {
console.log('[auth/verify] Verification blocked by circuit breaker on mount');
return;
}
// Start verification process with a small delay to ensure stability
setTimeout(() => {
verifyEmail();
}, 100);
});
// Prevent re-verification on reactive updates
onUpdated(() => {
console.log('[auth/verify] Component updated - verification state:', {
started: verificationStarted,
complete: verificationComplete
});
}, 200);
});
</script>