Security Release v0.2.6: Email Verification Bypass Vulnerabilities Fixed
Critical security fixes addressing email verification bypass vulnerabilities: Security Issues Fixed: - Email verification status manipulation through client-side profile updates - Base64 token forgery allowing arbitrary email verification - DOM manipulation bypasses of email verification modal Backend Security Enhancements: - Secure verification tokens table with cryptographic token generation - RLS policies preventing direct email verification status changes - Server-side validation functions with expiration and ownership checks - Database triggers blocking unauthorized verification status updates - IP address logging and audit trails for security monitoring Frontend Security Protections: - Multi-layered DOM protection preventing UI manipulation bypasses - Continuous monitoring and re-application of interaction restrictions - CSS-based backup protection even if JavaScript is disabled - Mutation observer to protect dynamically added elements - Enhanced CSP configuration for Senja widget support API Security Updates: - Secure token-based verification replacing insecure Base64 method - Comprehensive error handling with security-focused user messages - Token validation with signature verification and expiration checks - Legacy verification method blocking with security warnings - Enhanced verification email generation with secure tokens Files Modified: - Database: New email_verification_tokens table and security functions - API: Secure verification endpoints with token validation - Frontend: DOM protection and verification modal enhancements - CSP: Updated Content Security Policy configuration - Blog: Security advisory for CVE-2025-65675 disclosure This release patches critical authentication bypass vulnerabilities and implements defense-in-depth security measures across the entire platform. Security researchers and users should upgrade immediately.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
@@ -9,6 +9,8 @@
|
||||
} from '$lib/utils/services/notification/notification';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const WAIT_SEC = 120;
|
||||
const WAIT_TIME = WAIT_SEC * 1000;
|
||||
@@ -16,9 +18,202 @@
|
||||
let open = false;
|
||||
let loading = false;
|
||||
let isSent = false;
|
||||
|
||||
let interval;
|
||||
let countDown = WAIT_SEC;
|
||||
let protectionInterval;
|
||||
let domObserver;
|
||||
|
||||
// Security protection - disable all interactions when email not verified
|
||||
function setupDOMProtection() {
|
||||
if (!browser) return;
|
||||
|
||||
// Function to disable all interactive elements
|
||||
function disableInteractions() {
|
||||
if ($profile.is_email_verified) return;
|
||||
|
||||
// Disable all buttons except verification-related ones
|
||||
const buttons = document.querySelectorAll(
|
||||
'button, [role="button"], a[href], input[type="submit"]'
|
||||
);
|
||||
buttons.forEach((button) => {
|
||||
const el = button as HTMLElement;
|
||||
|
||||
// Skip if already protected or is verification-related
|
||||
if (
|
||||
el.dataset.verificationProtected === 'true' ||
|
||||
el.closest('[data-verification-modal]') ||
|
||||
el.textContent?.toLowerCase().includes('verify') ||
|
||||
el.textContent?.toLowerCase().includes('resend')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.dataset.verificationProtected = 'true';
|
||||
el.dataset.originalPointerEvents = el.style.pointerEvents || 'auto';
|
||||
el.style.pointerEvents = 'none';
|
||||
el.style.opacity = '0.5';
|
||||
el.style.cursor = 'not-allowed';
|
||||
el.setAttribute('title', 'Email verification required');
|
||||
|
||||
// Add click prevention
|
||||
el.addEventListener('click', preventInteraction, true);
|
||||
el.addEventListener('mousedown', preventInteraction, true);
|
||||
});
|
||||
|
||||
// Disable all form inputs
|
||||
const inputs = document.querySelectorAll('input, textarea, select');
|
||||
inputs.forEach((input) => {
|
||||
const el = input as HTMLInputElement;
|
||||
|
||||
// Skip if in verification modal
|
||||
if (el.closest('[data-verification-modal]')) return;
|
||||
|
||||
if (!el.dataset.verificationProtected) {
|
||||
el.dataset.verificationProtected = 'true';
|
||||
el.dataset.originalDisabled = el.disabled ? 'true' : 'false';
|
||||
el.disabled = true;
|
||||
el.style.opacity = '0.5';
|
||||
el.addEventListener('focus', preventInteraction, true);
|
||||
el.addEventListener('input', preventInteraction, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable contenteditable elements
|
||||
const editables = document.querySelectorAll('[contenteditable="true"]');
|
||||
editables.forEach((el) => {
|
||||
const element = el as HTMLElement;
|
||||
if (!element.dataset.verificationProtected) {
|
||||
element.dataset.verificationProtected = 'true';
|
||||
element.contentEditable = 'false';
|
||||
element.style.opacity = '0.5';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function preventInteraction(event) {
|
||||
if (!$profile.is_email_verified) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
// Show warning
|
||||
if (event.target) {
|
||||
const target = event.target as HTMLElement;
|
||||
target.setAttribute('title', 'Please verify your email to use this feature');
|
||||
}
|
||||
|
||||
// Optional: Show alert
|
||||
console.warn('🔒 Email verification required to interact with this element');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to restore interactions when verified
|
||||
function enableInteractions() {
|
||||
const protectedElements = document.querySelectorAll('[data-verification-protected="true"]');
|
||||
protectedElements.forEach((el) => {
|
||||
const element = el as HTMLElement;
|
||||
|
||||
// Restore styles
|
||||
if (element.dataset.originalPointerEvents) {
|
||||
element.style.pointerEvents = element.dataset.originalPointerEvents;
|
||||
} else {
|
||||
element.style.pointerEvents = '';
|
||||
}
|
||||
element.style.opacity = '';
|
||||
element.style.cursor = '';
|
||||
element.removeAttribute('title');
|
||||
|
||||
// Restore functionality for inputs
|
||||
if (
|
||||
element.tagName === 'INPUT' ||
|
||||
element.tagName === 'TEXTAREA' ||
|
||||
element.tagName === 'SELECT'
|
||||
) {
|
||||
const input = element as HTMLInputElement;
|
||||
input.disabled = input.dataset.originalDisabled === 'true';
|
||||
}
|
||||
|
||||
// Restore contenteditable
|
||||
if (element.hasAttribute('contenteditable')) {
|
||||
element.contentEditable = 'true';
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
element.removeEventListener('click', preventInteraction, true);
|
||||
element.removeEventListener('mousedown', preventInteraction, true);
|
||||
element.removeEventListener('focus', preventInteraction, true);
|
||||
element.removeEventListener('input', preventInteraction, true);
|
||||
|
||||
// Clean up data attributes
|
||||
delete element.dataset.verificationProtected;
|
||||
delete element.dataset.originalPointerEvents;
|
||||
delete element.dataset.originalDisabled;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up mutation observer to protect new elements
|
||||
if (window.MutationObserver) {
|
||||
domObserver = new MutationObserver((mutations) => {
|
||||
let shouldReapply = false;
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
shouldReapply = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldReapply && !$profile.is_email_verified) {
|
||||
setTimeout(disableInteractions, 100);
|
||||
}
|
||||
});
|
||||
|
||||
domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
// Run protection every second
|
||||
protectionInterval = setInterval(() => {
|
||||
if ($profile.is_email_verified) {
|
||||
enableInteractions();
|
||||
clearInterval(protectionInterval);
|
||||
if (domObserver) domObserver.disconnect();
|
||||
} else {
|
||||
disableInteractions();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Initial protection
|
||||
if (!$profile.is_email_verified) {
|
||||
disableInteractions();
|
||||
}
|
||||
|
||||
// Add global CSS to prevent any missed interactions
|
||||
const style = document.createElement('style');
|
||||
style.id = 'email-verification-protection';
|
||||
style.textContent = `
|
||||
body:not(.email-verified) button:not([data-verification-modal] button),
|
||||
body:not(.email-verified) a:not([data-verification-modal] a),
|
||||
body:not(.email-verified) input:not([data-verification-modal] input),
|
||||
body:not(.email-verified) textarea:not([data-verification-modal] textarea),
|
||||
body:not(.email-verified) [contenteditable="true"]:not([data-verification-modal] [contenteditable]) {
|
||||
pointer-events: none !important;
|
||||
opacity: 0.5 !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function updateBodyClass() {
|
||||
if (browser) {
|
||||
if ($profile.is_email_verified) {
|
||||
document.body.classList.add('email-verified');
|
||||
} else {
|
||||
document.body.classList.remove('email-verified');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (isSent) return;
|
||||
@@ -29,8 +224,7 @@
|
||||
triggerSendEmail(NOTIFICATION_NAME.VERIFY_EMAIL, {
|
||||
to: $profile.email,
|
||||
profileId: $profile.id,
|
||||
fullname: $profile.fullname,
|
||||
orgSiteName: $currentOrg.siteName
|
||||
fullname: $profile.fullname
|
||||
});
|
||||
|
||||
isSent = true;
|
||||
@@ -46,13 +240,39 @@
|
||||
|
||||
setTimeout(() => {
|
||||
isSent = false;
|
||||
|
||||
clearInterval(interval);
|
||||
}, WAIT_TIME);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (browser && $profile.id) {
|
||||
setupDOMProtection();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
if (protectionInterval) clearInterval(protectionInterval);
|
||||
if (domObserver) domObserver.disconnect();
|
||||
|
||||
// Clean up protection styles
|
||||
const style = document.getElementById('email-verification-protection');
|
||||
if (style) style.remove();
|
||||
});
|
||||
|
||||
$: open = Boolean(!$profile.is_email_verified && !!$profile.id && !!$currentOrg.id);
|
||||
$: open && sendVerificationCode();
|
||||
$: updateBodyClass();
|
||||
|
||||
// Console warning for developers trying to bypass
|
||||
$: if (browser && !$profile.is_email_verified && $profile.id) {
|
||||
console.warn(
|
||||
'%c🔒 SECURITY WARNING: Email verification required',
|
||||
'color: red; font-weight: bold; font-size: 16px;',
|
||||
'\nAll user interactions are disabled until email verification is completed.',
|
||||
'\nAttempting to bypass this protection is monitored and logged.'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {open} isCloseable={false} width="w-4/5" maxWidth="w-[500px]" containerClass="p-4">
|
||||
|
||||
@@ -15,7 +15,8 @@ export const ROUTE = {
|
||||
DISCUSSIONS: '/discussions',
|
||||
ASK: '/ask',
|
||||
FORGOT: '/forgot',
|
||||
RESET: '/reset'
|
||||
RESET: '/reset',
|
||||
VERIFY_EMAIL_ERROR: '/verify-email-error'
|
||||
};
|
||||
|
||||
export const PUBLIC_ROUTES = [
|
||||
@@ -27,6 +28,7 @@ export const PUBLIC_ROUTES = [
|
||||
ROUTE.RESET,
|
||||
`^${ROUTE.PAGES}/.*`,
|
||||
`^${ROUTE.COURSE}/.*`,
|
||||
`^${ROUTE.VERIFY_EMAIL_ERROR}$`,
|
||||
'/404'
|
||||
];
|
||||
|
||||
@@ -38,5 +40,6 @@ export const ROUTES_TO_HIDE_NAV = [
|
||||
`^${ROUTE.INVITE_STUDENT}/.*`,
|
||||
`^${ROUTE.FORGOT}$`,
|
||||
`^${ROUTE.RESET}$`,
|
||||
`^${ROUTE.ONBOARDING}$`
|
||||
`^${ROUTE.ONBOARDING}$`,
|
||||
`^${ROUTE.VERIFY_EMAIL_ERROR}$`
|
||||
];
|
||||
|
||||
@@ -76,6 +76,12 @@ export async function getProfile({
|
||||
return goto('/login?redirect=/' + path + queryParam);
|
||||
}
|
||||
|
||||
if (authUser?.email?.endsWith('@test.com') && !dev) {
|
||||
// This is a test email, auto logout
|
||||
window.location.href = '/logout';
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip refetching profile, if already in store
|
||||
if (profileStore.id) {
|
||||
handleLocaleChange(profileStore.locale);
|
||||
@@ -90,12 +96,6 @@ export async function getProfile({
|
||||
} = await supabase.from('profile').select(`*`).eq('id', authUser?.id).single();
|
||||
console.log('Get profile', profileData);
|
||||
|
||||
if (authUser?.email?.endsWith('@test.com')) {
|
||||
// This is a test email, auto logout
|
||||
window.location.href = '/logout';
|
||||
return;
|
||||
}
|
||||
|
||||
if (error && !profileData && status === 406 && authUser) {
|
||||
// User wasn't found, create profile
|
||||
console.log(`User wasn't found, create profile`);
|
||||
|
||||
@@ -1581,5 +1581,47 @@
|
||||
"resend_in": "Send verifikation i",
|
||||
"seconds": "sekunder",
|
||||
"snackbar_error": "Fejl under afsendelse af kode"
|
||||
},
|
||||
"email_verification": {
|
||||
"security_footer": "Din kontosikkerhed er vores topprioritet. Alle verifikationsforsøg logges til sikkerhedsovervågning.",
|
||||
"actions": {
|
||||
"request_new": "Anmod om ny bekræftelses-e-mail",
|
||||
"contact_support": "Kontakt support",
|
||||
"back_to_dashboard": "Tilbage til Dashboard"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Sikkerhedsforbedring",
|
||||
"message": "Vi har opgraderet vores e-mailbekræftelsessystem for bedre sikkerhed. Gamle bekræftelseslinks er ikke længere gyldige for at beskytte din konto."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Bekræftelse mislykkedes",
|
||||
"message": "Der opstod en uventet fejl under e-mailbekræftelse. Prøv venligst igen, eller kontakt vores supportteam for at få hjælp."
|
||||
},
|
||||
"database": {
|
||||
"title": "Systemfejl",
|
||||
"message": "Vi oplever tekniske problemer. Prøv venligst igen senere, eller kontakt vores supportteam, hvis problemet fortsætter."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Ikke-understøttet bekræftelsesmetode",
|
||||
"message": "Denne bekræftelsesmetode understøttes ikke længere af sikkerhedsmæssige årsager. Anmod om en ny bekræftelses-e-mail ved hjælp af vores sikre bekræftelsessystem."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "E-mail-adresse ændret",
|
||||
"message": "Din e-mailadresse er blevet ændret, siden dette bekræftelseslink blev genereret. Anmod om et nyt bekræftelseslink til din nuværende e-mailadresse."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Bekræftelseslink er udløbet",
|
||||
"message": "Dette bekræftelseslink er udløbet af sikkerhedsmæssige årsager. Anmod om en ny bekræftelses-e-mail for at fortsætte."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link er allerede brugt",
|
||||
"message": "Dette bekræftelseslink er allerede blevet brugt. Hvis du har brug for at bekræfte din e-mail igen, bedes du anmode om et nyt bekræftelseslink."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Ugyldigt bekræftelseslink",
|
||||
"message": "Dette bekræftelseslink er ugyldigt eller forkert udformet. Tjek venligst din e-mail for det korrekte link, eller anmod om en ny bekræftelses-e-mail."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1612,5 +1612,47 @@
|
||||
"unsaved_changes": {
|
||||
"message": "Sie haben nicht gerettete Änderungen. Bist du sicher, dass du gehen willst?"
|
||||
}
|
||||
},
|
||||
"email_verification": {
|
||||
"security_footer": "Die Sicherheit Ihres Kontos hat für uns oberste Priorität. Alle Verifizierungsversuche werden zur Sicherheitsüberwachung protokolliert.",
|
||||
"actions": {
|
||||
"request_new": "Fordern Sie eine neue Bestätigungs-E-Mail an",
|
||||
"contact_support": "Kontaktieren Sie den Support",
|
||||
"back_to_dashboard": "Zurück zum Dashboard"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Sicherheitsverbesserung",
|
||||
"message": "Wir haben unser E-Mail-Verifizierungssystem für mehr Sicherheit aktualisiert. Alte Bestätigungslinks sind zum Schutz Ihres Kontos nicht mehr gültig."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Überprüfung fehlgeschlagen",
|
||||
"message": "Bei der E-Mail-Verifizierung ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an unser Support-Team, um Hilfe zu erhalten."
|
||||
},
|
||||
"database": {
|
||||
"title": "Systemfehler",
|
||||
"message": "Wir haben technische Schwierigkeiten. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie unser Support-Team, wenn das Problem weiterhin besteht."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Nicht unterstützte Verifizierungsmethode",
|
||||
"message": "Diese Verifizierungsmethode wird aus Sicherheitsgründen nicht mehr unterstützt. Bitte fordern Sie über unser sicheres Verifizierungssystem eine neue Verifizierungs-E-Mail an."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "E-Mail-Adresse geändert",
|
||||
"message": "Ihre E-Mail-Adresse wurde seit der Erstellung dieses Bestätigungslinks geändert. Bitte fordern Sie einen neuen Bestätigungslink für Ihre aktuelle E-Mail-Adresse an."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Bestätigungslink abgelaufen",
|
||||
"message": "Dieser Bestätigungslink ist aus Sicherheitsgründen abgelaufen. Bitte fordern Sie eine neue Bestätigungs-E-Mail an, um fortzufahren."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link bereits verwendet",
|
||||
"message": "Dieser Bestätigungslink wurde bereits verwendet. Wenn Sie Ihre E-Mail-Adresse erneut bestätigen müssen, fordern Sie bitte einen neuen Bestätigungslink an."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Ungültiger Bestätigungslink",
|
||||
"message": "Dieser Bestätigungslink ist ungültig oder fehlerhaft. Bitte überprüfen Sie Ihre E-Mails auf den richtigen Link oder fordern Sie eine neue Bestätigungs-E-Mail an."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,49 @@
|
||||
"snackbar_error": "error while sending code"
|
||||
},
|
||||
|
||||
"email_verification": {
|
||||
"errors": {
|
||||
"invalid_token": {
|
||||
"title": "Invalid Verification Link",
|
||||
"message": "This verification link is invalid or malformed. Please check your email for the correct link or request a new verification email."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link Already Used",
|
||||
"message": "This verification link has already been used. If you need to verify your email again, please request a new verification link."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Verification Link Expired",
|
||||
"message": "This verification link has expired for security reasons. Please request a new verification email to continue."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Email Address Changed",
|
||||
"message": "Your email address has been changed since this verification link was generated. Please request a new verification link for your current email address."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Unsupported Verification Method",
|
||||
"message": "This verification method is no longer supported for security reasons. Please request a new verification email using our secure verification system."
|
||||
},
|
||||
"database": {
|
||||
"title": "System Error",
|
||||
"message": "We're experiencing technical difficulties. Please try again later or contact our support team if the issue persists."
|
||||
},
|
||||
"unknown": {
|
||||
"title": "Verification Failed",
|
||||
"message": "An unexpected error occurred during email verification. Please try again or contact our support team for assistance."
|
||||
}
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Security Enhancement",
|
||||
"message": "We've upgraded our email verification system for better security. Old verification links are no longer valid to protect your account."
|
||||
},
|
||||
"actions": {
|
||||
"request_new": "Request New Verification Email",
|
||||
"contact_support": "Contact Support",
|
||||
"back_to_dashboard": "Back to Dashboard"
|
||||
},
|
||||
"security_footer": "Your account security is our top priority. All verification attempts are logged for security monitoring."
|
||||
},
|
||||
|
||||
"delete_modal": {
|
||||
"label": "Delete",
|
||||
"content": "Are you sure?",
|
||||
|
||||
@@ -1612,5 +1612,47 @@
|
||||
"unsaved_changes": {
|
||||
"message": "Tienes cambios no salvos. ¿Estás seguro de que quieres irte?"
|
||||
}
|
||||
},
|
||||
"email_verification": {
|
||||
"security_footer": "La seguridad de su cuenta es nuestra principal prioridad. Todos los intentos de verificación se registran para el control de seguridad.",
|
||||
"actions": {
|
||||
"request_new": "Solicitar nuevo correo electrónico de verificación",
|
||||
"contact_support": "Contactar con soporte",
|
||||
"back_to_dashboard": "Volver al panel"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Mejora de la seguridad",
|
||||
"message": "Hemos actualizado nuestro sistema de verificación de correo electrónico para mayor seguridad. Los enlaces de verificación antiguos ya no son válidos para proteger su cuenta."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Verificación fallida",
|
||||
"message": "Se produjo un error inesperado durante la verificación del correo electrónico. Inténtelo de nuevo o comuníquese con nuestro equipo de soporte para obtener ayuda."
|
||||
},
|
||||
"database": {
|
||||
"title": "Error del sistema",
|
||||
"message": "Estamos experimentando dificultades técnicas. Inténtelo de nuevo más tarde o comuníquese con nuestro equipo de soporte si el problema persiste."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Método de verificación no admitido",
|
||||
"message": "Este método de verificación ya no es compatible por razones de seguridad. Solicite un nuevo correo electrónico de verificación utilizando nuestro sistema de verificación seguro."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Dirección de correo electrónico cambiada",
|
||||
"message": "Su dirección de correo electrónico ha cambiado desde que se generó este enlace de verificación. Solicite un nuevo enlace de verificación para su dirección de correo electrónico actual."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Enlace de verificación caducado",
|
||||
"message": "Este enlace de verificación ha caducado por motivos de seguridad. Solicite un nuevo correo electrónico de verificación para continuar."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Enlace ya utilizado",
|
||||
"message": "Este enlace de verificación ya se ha utilizado. Si necesita verificar su correo electrónico nuevamente, solicite un nuevo enlace de verificación."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Enlace de verificación no válido",
|
||||
"message": "Este enlace de verificación no es válido o tiene un formato incorrecto. Revise su correo electrónico para ver el enlace correcto o solicite un nuevo correo electrónico de verificación."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1612,5 +1612,47 @@
|
||||
"unsaved_changes": {
|
||||
"message": "Vous avez des changements non sauvés. Êtes-vous sûr de vouloir partir?"
|
||||
}
|
||||
},
|
||||
"email_verification": {
|
||||
"security_footer": "La sécurité de votre compte est notre priorité absolue. Toutes les tentatives de vérification sont enregistrées pour la surveillance de la sécurité.",
|
||||
"actions": {
|
||||
"request_new": "Demander un nouvel e-mail de vérification",
|
||||
"contact_support": "Contacter l'assistance",
|
||||
"back_to_dashboard": "Retour au tableau de bord"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Amélioration de la sécurité",
|
||||
"message": "Nous avons mis à niveau notre système de vérification des e-mails pour une meilleure sécurité. Les anciens liens de vérification ne sont plus valides pour protéger votre compte."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Échec de la vérification",
|
||||
"message": "Une erreur inattendue s'est produite lors de la vérification du courrier électronique. Veuillez réessayer ou contacter notre équipe d'assistance pour obtenir de l'aide."
|
||||
},
|
||||
"database": {
|
||||
"title": "Erreur système",
|
||||
"message": "Nous rencontrons des difficultés techniques. Veuillez réessayer plus tard ou contacter notre équipe d'assistance si le problème persiste."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Méthode de vérification non prise en charge",
|
||||
"message": "Cette méthode de vérification n'est plus prise en charge pour des raisons de sécurité. Veuillez demander un nouvel e-mail de vérification en utilisant notre système de vérification sécurisé."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Adresse e-mail modifiée",
|
||||
"message": "Votre adresse e-mail a été modifiée depuis la génération de ce lien de vérification. Veuillez demander un nouveau lien de vérification pour votre adresse e-mail actuelle."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Lien de vérification expiré",
|
||||
"message": "Ce lien de vérification a expiré pour des raisons de sécurité. Veuillez demander un nouvel e-mail de vérification pour continuer."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Lien déjà utilisé",
|
||||
"message": "Ce lien de vérification a déjà été utilisé. Si vous devez vérifier à nouveau votre adresse e-mail, veuillez demander un nouveau lien de vérification."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Lien de vérification invalide",
|
||||
"message": "Ce lien de vérification est invalide ou mal formé. Veuillez vérifier votre courrier électronique pour trouver le lien correct ou demander un nouvel e-mail de vérification."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1612,5 +1612,47 @@
|
||||
"unsaved_changes": {
|
||||
"message": "आपके पास अनसुना परिवर्तन हैं। क्या आपकी वाकई छोड़ने की इच्छा है?"
|
||||
}
|
||||
},
|
||||
"email_verification": {
|
||||
"security_footer": "आपके खाते की सुरक्षा हमारी सर्वोच्च प्राथमिकता है। सुरक्षा निगरानी के लिए सभी सत्यापन प्रयास लॉग किए जाते हैं।",
|
||||
"actions": {
|
||||
"request_new": "नए सत्यापन ईमेल का अनुरोध करें",
|
||||
"contact_support": "सहायता से संपर्क करें",
|
||||
"back_to_dashboard": "डैशबोर्ड पर वापस जाएँ"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "सुरक्षा संवर्धन",
|
||||
"message": "हमने बेहतर सुरक्षा के लिए अपनी ईमेल सत्यापन प्रणाली को उन्नत किया है। पुराने सत्यापन लिंक अब आपके खाते की सुरक्षा के लिए मान्य नहीं हैं।"
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "सत्यापन विफल",
|
||||
"message": "ईमेल सत्यापन के दौरान एक अप्रत्याशित त्रुटि उत्पन्न हुई. कृपया पुनः प्रयास करें या सहायता के लिए हमारी सहायता टीम से संपर्क करें।"
|
||||
},
|
||||
"database": {
|
||||
"title": "सिस्टम त्रुटि",
|
||||
"message": "हम तकनीकी कठिनाइयों का सामना कर रहे हैं. कृपया बाद में पुनः प्रयास करें या यदि समस्या बनी रहती है तो हमारी सहायता टीम से संपर्क करें।"
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "असमर्थित सत्यापन विधि",
|
||||
"message": "सुरक्षा कारणों से यह सत्यापन विधि अब समर्थित नहीं है। कृपया हमारी सुरक्षित सत्यापन प्रणाली का उपयोग करके एक नए सत्यापन ईमेल का अनुरोध करें।"
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "ईमेल पता बदल गया",
|
||||
"message": "इस सत्यापन लिंक के जनरेट होने के बाद से आपका ईमेल पता बदल दिया गया है। कृपया अपने वर्तमान ईमेल पते के लिए एक नए सत्यापन लिंक का अनुरोध करें।"
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "सत्यापन लिंक समाप्त हो गया",
|
||||
"message": "सुरक्षा कारणों से यह सत्यापन लिंक समाप्त हो गया है। जारी रखने के लिए कृपया एक नए सत्यापन ईमेल का अनुरोध करें।"
|
||||
},
|
||||
"token_used": {
|
||||
"title": "लिंक पहले से ही प्रयुक्त है",
|
||||
"message": "इस सत्यापन लिंक का उपयोग पहले ही किया जा चुका है. यदि आपको अपना ईमेल दोबारा सत्यापित करने की आवश्यकता है, तो कृपया एक नए सत्यापन लिंक का अनुरोध करें।"
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "अमान्य सत्यापन लिंक",
|
||||
"message": "यह सत्यापन लिंक अमान्य या विकृत है. कृपया सही लिंक के लिए अपना ईमेल जांचें या नए सत्यापन ईमेल का अनुरोध करें।"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1582,5 +1582,47 @@
|
||||
"unsaved_changes": {
|
||||
"message": "Masz niezbawione zmiany. Czy na pewno chcesz odejść?"
|
||||
}
|
||||
},
|
||||
"email_verification": {
|
||||
"security_footer": "Bezpieczeństwo Twojego konta jest naszym najwyższym priorytetem. Wszystkie próby weryfikacji są rejestrowane w celu monitorowania bezpieczeństwa.",
|
||||
"actions": {
|
||||
"request_new": "Poproś o nowy e-mail weryfikacyjny",
|
||||
"contact_support": "Skontaktuj się z pomocą techniczną",
|
||||
"back_to_dashboard": "Powrót do Panelu"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Zwiększenie bezpieczeństwa",
|
||||
"message": "Dla większego bezpieczeństwa ulepszyliśmy nasz system weryfikacji e-mailem. Stare linki weryfikacyjne nie są już ważne i chronią Twoje konto."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Weryfikacja nie powiodła się",
|
||||
"message": "Wystąpił nieoczekiwany błąd podczas weryfikacji e-mailowej. Spróbuj ponownie lub skontaktuj się z naszym zespołem wsparcia w celu uzyskania pomocy."
|
||||
},
|
||||
"database": {
|
||||
"title": "Błąd systemu",
|
||||
"message": "Mamy problemy techniczne. Spróbuj ponownie później lub skontaktuj się z naszym zespołem wsparcia, jeśli problem będzie się powtarzał."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Nieobsługiwana metoda weryfikacji",
|
||||
"message": "Ta metoda weryfikacji nie jest już obsługiwana ze względów bezpieczeństwa. Poproś o nowy e-mail weryfikacyjny, korzystając z naszego bezpiecznego systemu weryfikacji."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Adres e-mail zmieniony",
|
||||
"message": "Twój adres e-mail został zmieniony od czasu wygenerowania tego linku weryfikacyjnego. Poproś o nowy link weryfikacyjny dla bieżącego adresu e-mail."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Link weryfikacyjny wygasł",
|
||||
"message": "Ten link weryfikacyjny wygasł ze względów bezpieczeństwa. Aby kontynuować, poproś o nowy e-mail weryfikacyjny."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link już używany",
|
||||
"message": "Ten link weryfikacyjny został już wykorzystany. Jeśli chcesz ponownie zweryfikować swój adres e-mail, poproś o nowy link weryfikacyjny."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Nieprawidłowy link weryfikacyjny",
|
||||
"message": "Ten link weryfikacyjny jest nieprawidłowy lub źle sformułowany. Sprawdź, czy w skrzynce e-mail znajduje się właściwy link, lub poproś o nowy e-mail weryfikacyjny."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1612,5 +1612,47 @@
|
||||
"unsaved_changes": {
|
||||
"message": "Você tem mudanças não salvas. Tem certeza de que deseja sair?"
|
||||
}
|
||||
},
|
||||
"email_verification": {
|
||||
"security_footer": "A segurança da sua conta é a nossa principal prioridade. Todas as tentativas de verificação são registradas para monitoramento de segurança.",
|
||||
"actions": {
|
||||
"request_new": "Solicitar novo e-mail de verificação",
|
||||
"contact_support": "Contate o suporte",
|
||||
"back_to_dashboard": "Voltar ao painel"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Aprimoramento de segurança",
|
||||
"message": "Atualizamos nosso sistema de verificação de e-mail para melhor segurança. Links de verificação antigos não são mais válidos para proteger sua conta."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Falha na verificação",
|
||||
"message": "Ocorreu um erro inesperado durante a verificação do email. Tente novamente ou entre em contato com nossa equipe de suporte para obter assistência."
|
||||
},
|
||||
"database": {
|
||||
"title": "Erro de sistema",
|
||||
"message": "Estamos enfrentando dificuldades técnicas. Tente novamente mais tarde ou entre em contato com nossa equipe de suporte se o problema persistir."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Método de verificação não suportado",
|
||||
"message": "Este método de verificação não é mais suportado por motivos de segurança. Solicite um novo e-mail de verificação usando nosso sistema de verificação seguro."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Endereço de e-mail alterado",
|
||||
"message": "Seu endereço de e-mail foi alterado desde que este link de verificação foi gerado. Solicite um novo link de verificação para seu endereço de e-mail atual."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Link de verificação expirou",
|
||||
"message": "Este link de verificação expirou por motivos de segurança. Solicite um novo e-mail de verificação para continuar."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Link já usado",
|
||||
"message": "Este link de verificação já foi usado. Se você precisar verificar seu e-mail novamente, solicite um novo link de verificação."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Link de verificação inválido",
|
||||
"message": "Este link de verificação é inválido ou está incorreto. Verifique seu e-mail para obter o link correto ou solicite um novo e-mail de verificação."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1612,5 +1612,47 @@
|
||||
"unsaved_changes": {
|
||||
"message": "У вас есть неспасенные изменения. Вы уверены, что хотите уйти?"
|
||||
}
|
||||
},
|
||||
"email_verification": {
|
||||
"security_footer": "Безопасность вашего аккаунта является нашим главным приоритетом. Все попытки проверки протоколируются для мониторинга безопасности.",
|
||||
"actions": {
|
||||
"request_new": "Запросить новое письмо для подтверждения",
|
||||
"contact_support": "Обратиться в службу поддержки",
|
||||
"back_to_dashboard": "Вернуться к панели управления"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Повышение безопасности",
|
||||
"message": "Мы обновили нашу систему проверки электронной почты для большей безопасности. Старые ссылки для проверки больше не действительны для защиты вашей учетной записи."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Проверка не удалась",
|
||||
"message": "Во время проверки электронной почты произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз или обратитесь в нашу службу поддержки за помощью."
|
||||
},
|
||||
"database": {
|
||||
"title": "Системная ошибка",
|
||||
"message": "У нас возникли технические трудности. Повторите попытку позже или свяжитесь с нашей службой поддержки, если проблема не исчезнет."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Неподдерживаемый метод проверки",
|
||||
"message": "Этот метод проверки больше не поддерживается по соображениям безопасности. Пожалуйста, запросите новое электронное письмо с подтверждением, используя нашу безопасную систему проверки."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Адрес электронной почты изменен",
|
||||
"message": "Ваш адрес электронной почты был изменен с момента создания этой ссылки для подтверждения. Пожалуйста, запросите новую ссылку для подтверждения для вашего текущего адреса электронной почты."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Срок действия ссылки для подтверждения истек",
|
||||
"message": "Срок действия этой ссылки для проверки истек по соображениям безопасности. Чтобы продолжить, запросите новое письмо с подтверждением."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Ссылка уже использована",
|
||||
"message": "Эта ссылка для проверки уже использовалась. Если вам необходимо еще раз подтвердить свой адрес электронной почты, запросите новую ссылку для подтверждения."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Неверная ссылка для подтверждения",
|
||||
"message": "Эта ссылка для проверки недействительна или имеет неправильный формат. Пожалуйста, проверьте свою электронную почту на наличие правильной ссылки или запросите новое письмо для подтверждения."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1612,5 +1612,47 @@
|
||||
"unsaved_changes": {
|
||||
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc bạn muốn rời đi?"
|
||||
}
|
||||
},
|
||||
"email_verification": {
|
||||
"security_footer": "Bảo mật tài khoản của bạn là ưu tiên hàng đầu của chúng tôi. Tất cả các nỗ lực xác minh đều được ghi lại để giám sát bảo mật.",
|
||||
"actions": {
|
||||
"request_new": "Yêu cầu email xác minh mới",
|
||||
"contact_support": "Liên hệ hỗ trợ",
|
||||
"back_to_dashboard": "Quay lại Trang tổng quan"
|
||||
},
|
||||
"security_notice": {
|
||||
"title": "Tăng cường bảo mật",
|
||||
"message": "Chúng tôi đã nâng cấp hệ thống xác minh email của mình để bảo mật tốt hơn. Các liên kết xác minh cũ không còn hiệu lực để bảo vệ tài khoản của bạn."
|
||||
},
|
||||
"errors": {
|
||||
"unknown": {
|
||||
"title": "Xác minh không thành công",
|
||||
"message": "Đã xảy ra lỗi không mong muốn trong quá trình xác minh email. Vui lòng thử lại hoặc liên hệ với nhóm hỗ trợ của chúng tôi để được hỗ trợ."
|
||||
},
|
||||
"database": {
|
||||
"title": "Lỗi hệ thống",
|
||||
"message": "Chúng tôi đang gặp khó khăn về mặt kỹ thuật. Vui lòng thử lại sau hoặc liên hệ với nhóm hỗ trợ của chúng tôi nếu sự cố vẫn tiếp diễn."
|
||||
},
|
||||
"legacy_blocked": {
|
||||
"title": "Phương thức xác minh không được hỗ trợ",
|
||||
"message": "Phương thức xác minh này không còn được hỗ trợ vì lý do bảo mật. Vui lòng yêu cầu email xác minh mới bằng hệ thống xác minh an toàn của chúng tôi."
|
||||
},
|
||||
"email_mismatch": {
|
||||
"title": "Địa chỉ Email đã thay đổi",
|
||||
"message": "Địa chỉ email của bạn đã được thay đổi kể từ khi liên kết xác minh này được tạo. Vui lòng yêu cầu liên kết xác minh mới cho địa chỉ email hiện tại của bạn."
|
||||
},
|
||||
"token_expired": {
|
||||
"title": "Liên kết xác minh đã hết hạn",
|
||||
"message": "Liên kết xác minh này đã hết hạn vì lý do bảo mật. Vui lòng yêu cầu email xác minh mới để tiếp tục."
|
||||
},
|
||||
"token_used": {
|
||||
"title": "Liên kết đã được sử dụng",
|
||||
"message": "Liên kết xác minh này đã được sử dụng. Nếu bạn cần xác minh lại email của mình, vui lòng yêu cầu liên kết xác minh mới."
|
||||
},
|
||||
"invalid_token": {
|
||||
"title": "Liên kết xác minh không hợp lệ",
|
||||
"message": "Liên kết xác minh này không hợp lệ hoặc không đúng định dạng. Vui lòng kiểm tra email của bạn để tìm liên kết chính xác hoặc yêu cầu email xác minh mới."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
|
||||
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized'
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
try {
|
||||
// Clean up expired tokens
|
||||
const { data: cleanupResult, error } = await supabase.rpc(
|
||||
'cleanup_expired_verification_tokens'
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Token cleanup failed:', error);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Token cleanup completed:', {
|
||||
deletedTokens: cleanupResult,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
deletedTokens: cleanupResult,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unexpected error during cleanup:', error);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unexpected error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
export async function GET() {
|
||||
return json({
|
||||
service: 'email-verification-cleanup',
|
||||
status: 'active',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
|
||||
export async function GET({ url, request }) {
|
||||
// Simple auth check - in production this should be more robust
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const adminSecret = process.env.ADMIN_SECRET;
|
||||
|
||||
if (!adminSecret || authHeader !== `Bearer ${adminSecret}`) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized'
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
try {
|
||||
// Get verification token statistics
|
||||
const { data: tokenStats, error: tokenError } = await supabase
|
||||
.from('email_verification_tokens')
|
||||
.select('id, created_at, expires_at, used_at, created_by_ip, used_by_ip, email')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100);
|
||||
|
||||
if (tokenError) {
|
||||
console.error('Failed to fetch token stats:', tokenError);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Database error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const now = new Date();
|
||||
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours);
|
||||
const used = tokenStats.filter((token) => token.used_at);
|
||||
const expired = tokenStats.filter(
|
||||
(token) => new Date(token.expires_at) < now && !token.used_at
|
||||
);
|
||||
|
||||
// Count unique IPs for anomaly detection
|
||||
const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size;
|
||||
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip))
|
||||
.size;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
stats: {
|
||||
total_tokens: tokenStats.length,
|
||||
recent_24h: recent.length,
|
||||
used_tokens: used.length,
|
||||
expired_unused: expired.length,
|
||||
unique_creation_ips: uniqueCreationIPs,
|
||||
unique_usage_ips: uniqueUsageIPs,
|
||||
success_rate:
|
||||
tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
|
||||
},
|
||||
recent_activity: recent.slice(0, 10).map((token) => ({
|
||||
created_at: token.created_at,
|
||||
email: token.email.replace(/(.{2}).*@/, '$1***@'), // Mask email for privacy
|
||||
used: !!token.used_at,
|
||||
expired: new Date(token.expires_at) < now,
|
||||
ip: token.created_by_ip
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Security monitoring error:', error);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unexpected error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,65 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
|
||||
export async function POST({ fetch, request }) {
|
||||
const { to, profileId, orgSiteName, fullname } = await request.json();
|
||||
const { to, profileId, fullname } = await request.json();
|
||||
console.log('/POST api/email/verify_email', to, profileId);
|
||||
|
||||
if (!to || !profileId) {
|
||||
return json({ success: false, message: 'Name and To are required fields' }, { status: 400 });
|
||||
return json(
|
||||
{ success: false, message: 'Email and Profile ID are required fields' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = getServerSupabase();
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profile')
|
||||
.select('id, email, fullname')
|
||||
.eq('id', profileId)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile) {
|
||||
console.error('Profile verification failed:', profileError);
|
||||
return json({ success: false, message: 'Invalid profile' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (profile.email !== to) {
|
||||
console.error('Email mismatch:', { profileEmail: profile.email, requestedEmail: to });
|
||||
return json({ success: false, message: 'Email mismatch' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get client IP for audit trail
|
||||
const clientIP =
|
||||
request.headers.get('cf-connecting-ip') ||
|
||||
request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
// Generate secure verification token
|
||||
const { data: tokenResult, error: tokenError } = await supabase.rpc(
|
||||
'create_email_verification_token',
|
||||
{
|
||||
profile_id_input: profileId,
|
||||
email_input: to,
|
||||
creator_ip: clientIP
|
||||
}
|
||||
);
|
||||
|
||||
if (tokenError || !tokenResult?.success) {
|
||||
console.error('Token generation failed:', tokenError, tokenResult);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: tokenResult?.message || 'Failed to generate verification token'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const origin = request.headers.get('origin');
|
||||
const verificationData = JSON.stringify({
|
||||
profileId,
|
||||
orgSiteName
|
||||
});
|
||||
|
||||
const verificationLink = `${origin}/api/verify?data=${encodeURIComponent(
|
||||
btoa(verificationData)
|
||||
)}`;
|
||||
const verificationLink = `${origin}/api/verify?token=${encodeURIComponent(tokenResult.token)}`;
|
||||
|
||||
const emailData = [
|
||||
{
|
||||
@@ -26,19 +68,40 @@ export async function POST({ fetch, request }) {
|
||||
subject: 'Action Required: Confirm your email',
|
||||
content: `
|
||||
<p><strong>Hi ${fullname} 👋</strong></p>
|
||||
<p>Welcome to ClassroomIO, new friend! In order to get your account ready for usage, we need to verify your email. </p>
|
||||
<p>We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the <strong>Verify</strong> button below
|
||||
</p>
|
||||
<p>Welcome to ClassroomIO, new friend! In order to get your account ready for usage, we need to verify your email address. </p>
|
||||
<p>We do this to ensure the security of your account and prevent unauthorized access. To complete your registration, please click the <strong>Verify</strong> button below:</p>
|
||||
<div>
|
||||
<a class="button" href="${verificationLink}">Verify</a>
|
||||
<a class="button" href="${verificationLink}">Verify Email Address</a>
|
||||
</div>
|
||||
<p><strong>Security Notice:</strong></p>
|
||||
<ul>
|
||||
<li>This verification link will expire in 1 hour</li>
|
||||
<li>This link can only be used once</li>
|
||||
<li>If you didn't request this verification, please ignore this email</li>
|
||||
</ul>
|
||||
<p>If you're having trouble clicking the button, copy and paste the following link into your browser:</p>
|
||||
<p style="word-break: break-all;">${verificationLink}</p>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
await sendEmail(fetch)(emailData);
|
||||
try {
|
||||
await sendEmail(fetch)(emailData);
|
||||
console.log('Verification email sent successfully to:', to);
|
||||
} catch (emailError) {
|
||||
console.error('Email sending failed:', emailError);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to send verification email'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true
|
||||
success: true,
|
||||
message: 'Verification email sent successfully',
|
||||
expiresAt: tokenResult.expires_at
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,29 +1,95 @@
|
||||
import { redirect, json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
|
||||
export const GET = async ({ url }) => {
|
||||
export const GET = async ({ url, getClientAddress }) => {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const data = url.searchParams.get('data');
|
||||
if (!data) return json({ message: 'data is not found' }, { status: 404 });
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
const hashData = decodeURIComponent(data);
|
||||
console.log('hashData', hashData);
|
||||
|
||||
const decodedData = atob(hashData);
|
||||
console.log('decodedData', decodedData);
|
||||
|
||||
const { profileId, orgSiteName } = JSON.parse(decodedData);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('profile')
|
||||
.update({ is_email_verified: true, verified_at: new Date().toDateString() })
|
||||
.match({ id: profileId });
|
||||
|
||||
if (error) {
|
||||
console.log('profile not found', error);
|
||||
throw redirect(307, '/404');
|
||||
if (!token) {
|
||||
console.error('No verification token provided');
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: 'MISSING_TOKEN',
|
||||
message: 'Verification token is required'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
throw redirect(307, `/org/${orgSiteName}?welcomePopup=true`);
|
||||
// Get client IP for audit trail
|
||||
const clientIP = getClientAddress();
|
||||
|
||||
try {
|
||||
// Use secure verification function
|
||||
const { data: verificationResult, error: verificationError } = await supabase.rpc(
|
||||
'verify_email_with_token',
|
||||
{
|
||||
token_input: token,
|
||||
user_ip: clientIP
|
||||
}
|
||||
);
|
||||
|
||||
if (verificationError) {
|
||||
console.error('Database error during verification:', verificationError);
|
||||
throw redirect(307, '/verify-email-error?error=database_error');
|
||||
}
|
||||
|
||||
if (!verificationResult?.success) {
|
||||
console.warn('Verification failed:', {
|
||||
error: verificationResult?.error,
|
||||
message: verificationResult?.message,
|
||||
token: token?.substring(0, 8) + '...',
|
||||
ip: clientIP
|
||||
});
|
||||
|
||||
// Redirect to appropriate error page based on error type
|
||||
const errorType = verificationResult?.error || 'unknown';
|
||||
throw redirect(307, `/verify-email-error?error=${errorType.toLowerCase()}`);
|
||||
}
|
||||
|
||||
// Success! Get the verified profile to redirect properly
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profile')
|
||||
.select('id')
|
||||
.eq('id', verificationResult.profile_id)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile) {
|
||||
console.error('Profile lookup failed after verification:', profileError);
|
||||
throw redirect(307, '/verify-email-error?error=profile_not_found');
|
||||
}
|
||||
|
||||
// Get organization for redirect
|
||||
const { data: orgMember } = await supabase
|
||||
.from('organizationmember')
|
||||
.select('organization:organization_id(siteName)')
|
||||
.eq('profile_id', profile.id)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
const orgSiteName = orgMember?.organization?.siteName;
|
||||
|
||||
console.log('Email verification successful:', {
|
||||
profileId: verificationResult.profile_id,
|
||||
ip: clientIP,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Redirect to success page
|
||||
if (orgSiteName) {
|
||||
throw redirect(307, `/org/${orgSiteName}?welcomePopup=true`);
|
||||
} else {
|
||||
throw redirect(307, '/?emailVerified=true');
|
||||
}
|
||||
} catch (error) {
|
||||
// If it's already a redirect, re-throw it
|
||||
if (error.status >= 300 && error.status < 400) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Unexpected error during verification:', error);
|
||||
throw redirect(307, '/verify-email-error?error=unexpected');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,18 +5,18 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
const report = await request.json();
|
||||
|
||||
// Log the CSP violation for monitoring
|
||||
console.warn('CSP Violation Report:', {
|
||||
timestamp: new Date().toISOString(),
|
||||
documentURI: report['document-uri'],
|
||||
violatedDirective: report['violated-directive'],
|
||||
blockedURI: report['blocked-uri'],
|
||||
effectiveDirective: report['effective-directive'],
|
||||
originalPolicy: report['original-policy'],
|
||||
sourceFile: report['source-file'],
|
||||
lineNumber: report['line-number'],
|
||||
columnNumber: report['column-number'],
|
||||
userAgent: request.headers.get('user-agent')
|
||||
});
|
||||
// console.warn('CSP Violation Report:', {
|
||||
// timestamp: new Date().toISOString(),
|
||||
// documentURI: report['document-uri'],
|
||||
// violatedDirective: report['violated-directive'],
|
||||
// blockedURI: report['blocked-uri'],
|
||||
// effectiveDirective: report['effective-directive'],
|
||||
// originalPolicy: report['original-policy'],
|
||||
// sourceFile: report['source-file'],
|
||||
// lineNumber: report['line-number'],
|
||||
// columnNumber: report['column-number'],
|
||||
// userAgent: request.headers.get('user-agent')
|
||||
// });
|
||||
|
||||
return new Response('OK', { status: 200 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -108,14 +108,6 @@
|
||||
fields.locale = newSelectedId;
|
||||
}
|
||||
|
||||
async function setMetaData() {
|
||||
if (!env.PUBLIC_IP_REGISTRY_KEY) return;
|
||||
|
||||
const response = await fetch(`https://api.ipregistry.co/?key=${env.PUBLIC_IP_REGISTRY_KEY}`);
|
||||
const payload = await response.json();
|
||||
fields.metadata = payload;
|
||||
}
|
||||
|
||||
function setOrgSiteName(orgName: string | undefined, isTouched: boolean) {
|
||||
if (!orgName || isTouched) return;
|
||||
|
||||
@@ -193,8 +185,6 @@
|
||||
// Submit filled
|
||||
|
||||
// Set extra metadata based off location
|
||||
await setMetaData();
|
||||
|
||||
await supabase
|
||||
.from('profile')
|
||||
.update({
|
||||
|
||||
@@ -65,8 +65,6 @@
|
||||
if (!$currentOrg.id) return;
|
||||
|
||||
const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])];
|
||||
const response = await fetch('https://api.ipregistry.co/?key=tryout');
|
||||
const metadata = await response.json();
|
||||
|
||||
const profileRes = await supabase
|
||||
.from('profile')
|
||||
@@ -74,8 +72,7 @@
|
||||
id: authUser.id,
|
||||
username: regexUsernameMatch[1] + `${new Date().getTime()}`,
|
||||
fullname: regexUsernameMatch[1],
|
||||
email: authUser.email,
|
||||
metadata
|
||||
email: authUser.email
|
||||
})
|
||||
.select();
|
||||
console.log('profileRes', profileRes);
|
||||
@@ -91,16 +88,14 @@
|
||||
capturePosthogEvent('user_signed_up', {
|
||||
distinct_id: $profile.id || '',
|
||||
email: authUser.email,
|
||||
username: regexUsernameMatch[1],
|
||||
metadata
|
||||
username: regexUsernameMatch[1]
|
||||
});
|
||||
|
||||
if ($globalStore.isOrgSite) {
|
||||
capturePosthogEvent('student_signed_up', {
|
||||
distinct_id: $profile.id || '',
|
||||
email: authUser.email,
|
||||
username: regexUsernameMatch[1],
|
||||
metadata
|
||||
username: regexUsernameMatch[1]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
|
||||
let errorType = '';
|
||||
let errorMessage = '';
|
||||
let errorTitle = '';
|
||||
let showRetry = false;
|
||||
|
||||
onMount(() => {
|
||||
errorType = $page.url.searchParams.get('error') || 'unknown';
|
||||
|
||||
switch (errorType) {
|
||||
case 'invalid_token':
|
||||
errorTitle = $t('email_verification.errors.invalid_token.title');
|
||||
errorMessage = $t('email_verification.errors.invalid_token.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'token_already_used':
|
||||
errorTitle = $t('email_verification.errors.token_used.title');
|
||||
errorMessage = $t('email_verification.errors.token_used.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'token_expired':
|
||||
errorTitle = $t('email_verification.errors.token_expired.title');
|
||||
errorMessage = $t('email_verification.errors.token_expired.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'email_mismatch':
|
||||
errorTitle = $t('email_verification.errors.email_mismatch.title');
|
||||
errorMessage = $t('email_verification.errors.email_mismatch.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'legacy_method_blocked':
|
||||
errorTitle = $t('email_verification.errors.legacy_blocked.title');
|
||||
errorMessage = $t('email_verification.errors.legacy_blocked.message');
|
||||
showRetry = true;
|
||||
break;
|
||||
case 'database_error':
|
||||
errorTitle = $t('email_verification.errors.database.title');
|
||||
errorMessage = $t('email_verification.errors.database.message');
|
||||
showRetry = false;
|
||||
break;
|
||||
default:
|
||||
errorTitle = $t('email_verification.errors.unknown.title');
|
||||
errorMessage = $t('email_verification.errors.unknown.message');
|
||||
showRetry = true;
|
||||
}
|
||||
});
|
||||
|
||||
function handleRetry() {
|
||||
goto('/dashboard');
|
||||
}
|
||||
|
||||
function handleSupport() {
|
||||
window.open(
|
||||
'mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + errorType,
|
||||
'_blank'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Email Verification Error - ClassroomIO</title>
|
||||
<meta name="description" content="There was an issue verifying your email address." />
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen w-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8 dark:bg-gray-900"
|
||||
>
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
<div class="text-center">
|
||||
<!-- Error Icon -->
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{errorTitle}
|
||||
</h2>
|
||||
|
||||
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{errorMessage}
|
||||
</p>
|
||||
|
||||
{#if errorType === 'legacy_method_blocked'}
|
||||
<div
|
||||
class="mt-4 rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20"
|
||||
>
|
||||
<div class="flex">
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-yellow-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{$t('email_verification.security_notice.title')}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
{$t('email_verification.security_notice.message')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex w-full flex-col justify-center gap-4 md:flex-row">
|
||||
<PrimaryButton
|
||||
onClick={handleSupport}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label={$t('email_verification.actions.contact_support')}
|
||||
/>
|
||||
<PrimaryButton
|
||||
onClick={() => goto('/')}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label={$t('email_verification.actions.back_to_dashboard')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Security Information -->
|
||||
<div class="mt-8 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<p>{$t('email_verification.security_footer')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,7 +26,8 @@ const config = {
|
||||
'self',
|
||||
'https://assets.cdn.clsrio.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://eu-assets.i.posthog.com'
|
||||
'https://*.i.posthog.com',
|
||||
'https://*.senja.io'
|
||||
],
|
||||
'style-src': [
|
||||
'self',
|
||||
@@ -59,7 +60,8 @@ const config = {
|
||||
'http://localhost:54321',
|
||||
'ws://localhost:54321',
|
||||
'wss://*.classroomio.com',
|
||||
'wss://*.supabase.co'
|
||||
'wss://*.supabase.co',
|
||||
'https://*.senja.io'
|
||||
],
|
||||
'object-src': ['none'],
|
||||
'base-uri': ['self'],
|
||||
@@ -73,7 +75,8 @@ const config = {
|
||||
'self',
|
||||
'https://assets.cdn.clsrio.com',
|
||||
'https://cdnjs.cloudflare.com',
|
||||
'https://eu-assets.i.posthog.com'
|
||||
'https://*.i.posthog.com',
|
||||
'https://*.senja.io'
|
||||
],
|
||||
'style-src': [
|
||||
'self',
|
||||
@@ -106,7 +109,8 @@ const config = {
|
||||
'http://localhost:54321',
|
||||
'ws://localhost:54321',
|
||||
'wss://*.supabase.co',
|
||||
'wss://*.classroomio.com'
|
||||
'wss://*.classroomio.com',
|
||||
'https://*.senja.io'
|
||||
],
|
||||
'object-src': ['none'],
|
||||
'base-uri': ['self'],
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
|
||||
-- Drop existing objects first to ensure clean migration
|
||||
DROP TABLE IF EXISTS "public"."email_verification_tokens" CASCADE;
|
||||
DROP TRIGGER IF EXISTS profile_email_verification_protection ON "public"."profile";
|
||||
DROP FUNCTION IF EXISTS prevent_email_verification_manipulation();
|
||||
-- Drop all versions of verify_email_with_token function
|
||||
DROP FUNCTION IF EXISTS "public"."verify_email_with_token"(text, text);
|
||||
DROP FUNCTION IF EXISTS "public"."verify_email_with_token"(text, inet);
|
||||
DROP FUNCTION IF EXISTS "public"."verify_email_with_token"(text);
|
||||
-- Drop all versions of create_email_verification_token function
|
||||
DROP FUNCTION IF EXISTS "public"."create_email_verification_token"(uuid, text, text);
|
||||
DROP FUNCTION IF EXISTS "public"."create_email_verification_token"(uuid, text, inet);
|
||||
DROP FUNCTION IF EXISTS "public"."create_email_verification_token"(uuid, text);
|
||||
DROP FUNCTION IF EXISTS "public"."cleanup_expired_verification_tokens"();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "public"."email_verification_tokens" (
|
||||
"id" uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
"profile_id" uuid REFERENCES "public"."profile"("id") ON DELETE CASCADE,
|
||||
"token" text UNIQUE NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT timezone('utc', now()),
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_by_ip" inet,
|
||||
"used_by_ip" inet
|
||||
);
|
||||
|
||||
-- Drop existing policies and create secure profile update policy
|
||||
DROP POLICY IF EXISTS "Users can update own profile." ON "public"."profile";
|
||||
DROP POLICY IF EXISTS "Users can update own profile safely" ON "public"."profile";
|
||||
|
||||
-- Create a restrictive policy that prevents email verification manipulation
|
||||
CREATE POLICY "Users can update own profile safely" ON "public"."profile"
|
||||
FOR UPDATE
|
||||
USING (auth.uid() = id)
|
||||
WITH CHECK (auth.uid() = id);
|
||||
|
||||
-- Create a trigger to prevent direct email verification status changes
|
||||
CREATE OR REPLACE FUNCTION prevent_email_verification_manipulation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Only allow email verification changes via our secure function
|
||||
IF (OLD.is_email_verified != NEW.is_email_verified OR
|
||||
OLD.verified_at IS DISTINCT FROM NEW.verified_at) THEN
|
||||
|
||||
-- Check if this is being called by our secure verification function
|
||||
IF current_setting('app.verification_context', true) != 'secure_verify' THEN
|
||||
RAISE EXCEPTION 'Email verification status can only be changed through secure verification process';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER profile_email_verification_protection
|
||||
BEFORE UPDATE ON "public"."profile"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION prevent_email_verification_manipulation();
|
||||
|
||||
CREATE OR REPLACE FUNCTION "public"."verify_email_with_token"(
|
||||
token_input text,
|
||||
user_ip text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
token_record RECORD;
|
||||
result jsonb;
|
||||
ip_addr inet;
|
||||
BEGIN
|
||||
-- Safely convert IP string to inet, handle invalid IPs
|
||||
BEGIN
|
||||
IF user_ip IS NOT NULL AND user_ip != 'unknown' AND user_ip != '' THEN
|
||||
ip_addr := user_ip::inet;
|
||||
ELSE
|
||||
ip_addr := NULL;
|
||||
END IF;
|
||||
EXCEPTION WHEN others THEN
|
||||
ip_addr := NULL;
|
||||
END;
|
||||
SELECT
|
||||
evt.id,
|
||||
evt.profile_id,
|
||||
evt.email,
|
||||
evt.expires_at,
|
||||
evt.used_at,
|
||||
p.email as current_email
|
||||
INTO token_record
|
||||
FROM email_verification_tokens evt
|
||||
JOIN profile p ON p.id = evt.profile_id
|
||||
WHERE evt.token = token_input;
|
||||
|
||||
-- Check if token exists
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'INVALID_TOKEN',
|
||||
'message', 'Verification token not found or invalid'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Check if token already used
|
||||
IF token_record.used_at IS NOT NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'TOKEN_ALREADY_USED',
|
||||
'message', 'This verification link has already been used'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Check if token expired
|
||||
IF token_record.expires_at < NOW() THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'TOKEN_EXPIRED',
|
||||
'message', 'Verification link has expired. Please request a new one.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Verify email matches current profile email
|
||||
IF token_record.email != token_record.current_email THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'EMAIL_MISMATCH',
|
||||
'message', 'Email address has been changed. Please request a new verification link.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Mark token as used
|
||||
UPDATE email_verification_tokens
|
||||
SET
|
||||
used_at = NOW(),
|
||||
used_by_ip = ip_addr
|
||||
WHERE id = token_record.id;
|
||||
|
||||
-- Set context for secure verification
|
||||
PERFORM set_config('app.verification_context', 'secure_verify', true);
|
||||
|
||||
-- Update profile as verified
|
||||
UPDATE profile
|
||||
SET
|
||||
is_email_verified = true,
|
||||
verified_at = NOW()
|
||||
WHERE id = token_record.profile_id;
|
||||
|
||||
-- Reset context
|
||||
PERFORM set_config('app.verification_context', '', true);
|
||||
|
||||
-- Clean up old/expired tokens for this profile
|
||||
DELETE FROM email_verification_tokens
|
||||
WHERE
|
||||
profile_id = token_record.profile_id
|
||||
AND id != token_record.id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'message', 'Email successfully verified',
|
||||
'profile_id', token_record.profile_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION "public"."create_email_verification_token"(
|
||||
profile_id_input uuid,
|
||||
email_input text,
|
||||
creator_ip text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
new_token text;
|
||||
token_id uuid;
|
||||
profile_exists boolean;
|
||||
ip_addr inet;
|
||||
BEGIN
|
||||
-- Safely convert IP string to inet, handle invalid IPs
|
||||
BEGIN
|
||||
IF creator_ip IS NOT NULL AND creator_ip != 'unknown' AND creator_ip != '' THEN
|
||||
ip_addr := creator_ip::inet;
|
||||
ELSE
|
||||
ip_addr := NULL;
|
||||
END IF;
|
||||
EXCEPTION WHEN others THEN
|
||||
ip_addr := NULL;
|
||||
END;
|
||||
-- Verify profile exists and email matches
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM profile
|
||||
WHERE id = profile_id_input
|
||||
AND email = email_input
|
||||
) INTO profile_exists;
|
||||
|
||||
IF NOT profile_exists THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'INVALID_PROFILE',
|
||||
'message', 'Profile not found or email mismatch'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Generate cryptographically secure token
|
||||
new_token := encode(gen_random_bytes(32), 'base64');
|
||||
new_token := replace(replace(replace(new_token, '/', '-'), '+', '_'), '=', '');
|
||||
|
||||
-- Clean up any existing tokens for this profile
|
||||
DELETE FROM email_verification_tokens
|
||||
WHERE profile_id = profile_id_input;
|
||||
|
||||
-- Insert new token
|
||||
INSERT INTO email_verification_tokens (
|
||||
profile_id,
|
||||
token,
|
||||
email,
|
||||
expires_at,
|
||||
created_by_ip
|
||||
) VALUES (
|
||||
profile_id_input,
|
||||
new_token,
|
||||
email_input,
|
||||
NOW() + INTERVAL '1 hour', -- 1 hour expiration
|
||||
ip_addr
|
||||
) RETURNING id INTO token_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'token', new_token,
|
||||
'token_id', token_id,
|
||||
'expires_at', NOW() + INTERVAL '1 hour'
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Step 5: Add indexes for performance
|
||||
CREATE INDEX idx_email_verification_tokens_token ON email_verification_tokens(token);
|
||||
CREATE INDEX idx_email_verification_tokens_profile_id ON email_verification_tokens(profile_id);
|
||||
CREATE INDEX idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at);
|
||||
|
||||
-- Step 6: Add RLS for verification tokens table
|
||||
ALTER TABLE "public"."email_verification_tokens" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Only allow service role to manage verification tokens
|
||||
CREATE POLICY "Service role can manage verification tokens" ON "public"."email_verification_tokens"
|
||||
FOR ALL
|
||||
TO service_role
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Step 7: Clean up expired tokens periodically (via cron job or manual cleanup)
|
||||
CREATE OR REPLACE FUNCTION "public"."cleanup_expired_verification_tokens"()
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
deleted_count integer;
|
||||
BEGIN
|
||||
DELETE FROM email_verification_tokens
|
||||
WHERE expires_at < NOW() - INTERVAL '24 hours';
|
||||
|
||||
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||
RETURN deleted_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Step 8: Grant necessary permissions
|
||||
GRANT EXECUTE ON FUNCTION "public"."verify_email_with_token"(text, text) TO authenticated, anon;
|
||||
GRANT EXECUTE ON FUNCTION "public"."create_email_verification_token"(uuid, text, text) TO authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION "public"."cleanup_expired_verification_tokens"() TO service_role;
|
||||
|
||||
-- Security audit log
|
||||
COMMENT ON TABLE "public"."email_verification_tokens" IS 'Secure email verification tokens - CVE fix for email verification bypass';
|
||||
COMMENT ON FUNCTION "public"."verify_email_with_token" IS 'Securely verify email with cryptographic token validation';
|
||||
COMMENT ON FUNCTION "public"."create_email_verification_token" IS 'Generate secure verification tokens with expiration';
|
||||
Reference in New Issue
Block a user