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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user