monacousa-portal/server/utils/email-tokens.ts

169 lines
4.7 KiB
TypeScript

import jwt from 'jsonwebtoken';
export interface EmailVerificationTokenPayload {
userId: string;
email: string;
purpose: 'email-verification';
iat: number;
}
// In-memory token storage for validation (in production, consider Redis)
const activeTokens = new Map<string, EmailVerificationTokenPayload>();
/**
* Generate a secure JWT token for email verification
*/
export async function generateEmailVerificationToken(userId: string, email: string): Promise<string> {
const runtimeConfig = useRuntimeConfig();
if (!runtimeConfig.jwtSecret) {
throw new Error('JWT secret not configured');
}
const payload: EmailVerificationTokenPayload = {
userId,
email: email.toLowerCase().trim(),
purpose: 'email-verification',
iat: Date.now()
};
const token = jwt.sign(payload, runtimeConfig.jwtSecret as string, {
expiresIn: '24h',
issuer: 'monacousa-portal',
audience: 'email-verification'
});
// Store token metadata for additional validation
activeTokens.set(token, payload);
// Clean up expired tokens periodically
setTimeout(() => {
activeTokens.delete(token);
}, 24 * 60 * 60 * 1000); // 24 hours
console.log('[email-tokens] Generated verification token for user:', userId, 'email:', email);
return token;
}
/**
* Verify and decode an email verification token
*/
export async function verifyEmailToken(token: string): Promise<{ userId: string; email: string }> {
const runtimeConfig = useRuntimeConfig();
if (!runtimeConfig.jwtSecret) {
throw new Error('JWT secret not configured');
}
if (!token) {
throw new Error('Token is required');
}
try {
// Verify JWT signature and expiration
const decoded = jwt.verify(token, runtimeConfig.jwtSecret as string, {
issuer: 'monacousa-portal',
audience: 'email-verification'
}) as any as EmailVerificationTokenPayload;
// Validate token purpose
if (decoded.purpose !== 'email-verification') {
throw new Error('Invalid token purpose');
}
// Check if token exists in our active tokens (prevents replay attacks)
const storedPayload = activeTokens.get(token);
if (!storedPayload) {
throw new Error('Token not found or already used');
}
// Validate payload consistency
if (storedPayload.userId !== decoded.userId || storedPayload.email !== decoded.email) {
throw new Error('Token payload mismatch');
}
// Remove token after successful verification (single use)
activeTokens.delete(token);
console.log('[email-tokens] Successfully verified token for user:', decoded.userId, 'email:', decoded.email);
return {
userId: decoded.userId,
email: decoded.email
};
} catch (error: any) {
console.error('[email-tokens] Token verification failed:', error.message);
// Provide user-friendly error messages
if (error.name === 'TokenExpiredError') {
throw new Error('Verification link has expired. Please request a new one.');
} else if (error.name === 'JsonWebTokenError') {
throw new Error('Invalid verification link.');
} else {
throw new Error(error.message || 'Token verification failed');
}
}
}
/**
* Check if a token is still valid without consuming it
*/
export async function isTokenValid(token: string): Promise<boolean> {
try {
const runtimeConfig = useRuntimeConfig();
if (!runtimeConfig.jwtSecret || !token) {
return false;
}
const decoded = jwt.verify(token, runtimeConfig.jwtSecret as string, {
issuer: 'monacousa-portal',
audience: 'email-verification'
}) as any as EmailVerificationTokenPayload;
return decoded.purpose === 'email-verification' && activeTokens.has(token);
} catch (error) {
return false;
}
}
/**
* Clean up expired tokens from memory
*/
export function cleanupExpiredTokens(): void {
const now = Date.now();
const expirationTime = 24 * 60 * 60 * 1000; // 24 hours
for (const [token, payload] of activeTokens.entries()) {
if (now - payload.iat > expirationTime) {
activeTokens.delete(token);
}
}
console.log('[email-tokens] Cleaned up expired tokens. Active tokens:', activeTokens.size);
}
/**
* Get statistics about active tokens
*/
export function getTokenStats(): { activeTokens: number; oldestToken: number | null } {
const now = Date.now();
let oldestToken: number | null = null;
for (const payload of activeTokens.values()) {
if (oldestToken === null || payload.iat < oldestToken) {
oldestToken = payload.iat;
}
}
return {
activeTokens: activeTokens.size,
oldestToken: oldestToken ? Math.floor((now - oldestToken) / 1000 / 60) : null // minutes ago
};
}
// Periodic cleanup of expired tokens (every hour)
setInterval(cleanupExpiredTokens, 60 * 60 * 1000);