Add email verification system for user registration
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s

- Add SMTP configuration UI in admin panel with test functionality
- Implement email verification workflow with tokens and templates
- Add verification success/expired pages for user feedback
- Include nodemailer, handlebars, and JWT dependencies
- Create API endpoints for email config, testing, and verification
This commit is contained in:
2025-08-08 22:51:14 +02:00
parent 7b72d7a565
commit 4ec05e29dc
20 changed files with 2501 additions and 227 deletions

View File

@@ -0,0 +1,137 @@
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { email } = body;
if (!email || typeof email !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'Email is required'
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid email format'
});
}
console.log('[send-verification-email] Processing request for email:', email);
// Check if user exists in Keycloak
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
const keycloak = createKeycloakAdminClient();
let existingUsers;
try {
existingUsers = await keycloak.findUserByEmail(email.toLowerCase().trim());
} catch (error: any) {
console.error('[send-verification-email] Failed to search users:', error.message);
throw createError({
statusCode: 500,
statusMessage: 'Failed to verify account status'
});
}
if (!existingUsers || existingUsers.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'No account found with this email address'
});
}
const user = existingUsers[0];
// Check if user is already verified
if (user.emailVerified) {
throw createError({
statusCode: 400,
statusMessage: 'This email address is already verified'
});
}
// Rate limiting: check if we recently sent an email to this address
const rateLimitKey = `verification_email_${email.toLowerCase()}`;
// Simple in-memory rate limiting (in production, use Redis)
const globalCache = globalThis as any;
if (!globalCache.verificationEmailCache) {
globalCache.verificationEmailCache = new Map();
}
const lastSent = globalCache.verificationEmailCache.get(rateLimitKey);
const cooldownPeriod = 2 * 60 * 1000; // 2 minutes
if (lastSent && Date.now() - lastSent < cooldownPeriod) {
throw createError({
statusCode: 429,
statusMessage: 'Please wait a few minutes before requesting another verification email'
});
}
// Generate verification token
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
const verificationToken = await generateEmailVerificationToken(user.id, email);
// Get configuration
const config = useRuntimeConfig();
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
// Send verification email
const { getEmailService } = await import('~/server/utils/email');
const emailService = getEmailService();
try {
await emailService.sendWelcomeEmail(email, {
firstName: user.firstName || '',
lastName: user.lastName || '',
verificationLink,
memberId: user.id
});
console.log('[send-verification-email] Successfully sent verification email to:', email);
// Update rate limiting cache
globalCache.verificationEmailCache.set(rateLimitKey, Date.now());
// Clean up old rate limit entries periodically
if (Math.random() < 0.1) { // 10% chance
const now = Date.now();
for (const [key, timestamp] of globalCache.verificationEmailCache.entries()) {
if (now - timestamp > cooldownPeriod * 2) {
globalCache.verificationEmailCache.delete(key);
}
}
}
return {
success: true,
message: 'Verification email sent successfully'
};
} catch (emailError: any) {
console.error('[send-verification-email] Failed to send email:', emailError.message);
throw createError({
statusCode: 500,
statusMessage: 'Failed to send verification email. Please try again later.'
});
}
} catch (error: any) {
console.error('[send-verification-email] Request failed:', error.message);
// Re-throw HTTP errors
if (error.statusCode) {
throw error;
}
// Handle unexpected errors
throw createError({
statusCode: 500,
statusMessage: 'An unexpected error occurred. Please try again.'
});
}
});

View File

@@ -0,0 +1,59 @@
export default defineEventHandler(async (event) => {
try {
const { token } = getQuery(event);
if (!token || typeof token !== 'string') {
throw createError({
statusCode: 400,
statusMessage: 'Verification token is required'
});
}
console.log('[verify-email] Processing verification token...');
// Verify the token
const { verifyEmailToken } = await import('~/server/utils/email-tokens');
const { userId, email } = await verifyEmailToken(token);
// Update user verification status in Keycloak
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
const keycloak = createKeycloakAdminClient();
try {
await keycloak.updateUserProfile(userId, {
emailVerified: true,
attributes: {
lastLoginDate: new Date().toISOString()
}
});
console.log('[verify-email] Successfully verified user:', userId, 'email:', email);
// Redirect to success page
return sendRedirect(event, '/auth/verify-success?email=' + encodeURIComponent(email), 302);
} catch (keycloakError: any) {
console.error('[verify-email] Keycloak update failed:', keycloakError.message);
// Even if Keycloak update fails, consider verification successful if token was valid
// This prevents user frustration due to backend issues
return sendRedirect(event, '/auth/verify-success?email=' + encodeURIComponent(email) + '&warning=partial', 302);
}
} catch (error: any) {
console.error('[verify-email] Verification failed:', error.message);
// Handle different error types with appropriate redirects
if (error.message?.includes('expired')) {
return sendRedirect(event, '/auth/verify-expired', 302);
} else if (error.message?.includes('already used') || error.message?.includes('not found')) {
return sendRedirect(event, '/auth/verify-expired?reason=used', 302);
} else {
// For other errors, show a generic error page
throw createError({
statusCode: 400,
statusMessage: error.message || 'Invalid verification link'
});
}
}
});