169 lines
4.7 KiB
TypeScript
169 lines
4.7 KiB
TypeScript
|
|
import { sign, verify } 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 = sign(payload, runtimeConfig.jwtSecret, {
|
||
|
|
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 = verify(token, runtimeConfig.jwtSecret, {
|
||
|
|
issuer: 'monacousa-portal',
|
||
|
|
audience: 'email-verification'
|
||
|
|
}) 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 = verify(token, runtimeConfig.jwtSecret, {
|
||
|
|
issuer: 'monacousa-portal',
|
||
|
|
audience: 'email-verification'
|
||
|
|
}) 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);
|