UNPKG

secure-2fa

Version:

A secure, developer-friendly Node.js package for email-based OTP (2FA) with strong security controls

453 lines 18.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SecureEmailOtp = void 0; const types_1 = require("../types"); const otp_generator_1 = require("./otp-generator"); const email_templates_1 = require("../templates/email-templates"); class SecureEmailOtp { constructor(dbAdapter, emailProvider, rateLimiter, serverSecret, config = {}) { this.dbAdapter = dbAdapter; this.emailProvider = emailProvider; this.rateLimiter = rateLimiter; this.otpGenerator = new otp_generator_1.OtpGenerator(serverSecret); this.events = config.events || {}; // Set default configuration this.config = { otpLength: 6, expiryMs: 2 * 60 * 1000, // 2 minutes maxRetries: 5, strictMode: true, rateLimit: { maxPerWindow: 3, windowMs: 15 * 60 * 1000, // 15 minutes }, templates: { subject: email_templates_1.EmailTemplates.getDefaultSubject(), html: email_templates_1.EmailTemplates.getDefaultHtml({}), text: email_templates_1.EmailTemplates.getDefaultText({}), senderName: 'Dynamite Lifestyle', senderEmail: 'info@dynamitelifestyle.com', }, events: {}, ...config, }; } /** * Generate and send OTP */ async generate(params) { const { email, context, requestMeta, template } = params; // Validate inputs if (!email || !context || !requestMeta) { throw new types_1.OtpError(types_1.OtpErrorCode.INVALID, 'Missing required parameters'); } // Check rate limiting const rateLimitKey = `otp:${email}:email`; // Rate limit per email, not per email+context const canProceed = await this.rateLimiter.checkLimit(rateLimitKey, this.config.rateLimit.maxPerWindow, this.config.rateLimit.windowMs); if (!canProceed) { await this.emitEvent('fail', { email, context, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.RATE_LIMITED, 'Rate limit exceeded')); throw new types_1.OtpError(types_1.OtpErrorCode.RATE_LIMITED, 'Too many OTP requests. Please try again later.'); } // Increment rate limit counter await this.rateLimiter.increment(rateLimitKey, this.config.rateLimit.windowMs); await this.emitEvent('request', { email, context, requestMeta }); // Clean up any conflicting OTPs first try { await this.dbAdapter.cleanupConflictingOtps(email, context, 'email'); } catch (cleanupError) { // Log cleanup failure but don't block OTP generation await this.emitEvent('fail', { email, context, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.DATABASE_ERROR, 'Failed to cleanup conflicting OTPs')); } // Check for existing active OTP and clean it up const existingOtp = await this.dbAdapter.findActiveOtp(email, context, 'email'); let isResent = false; if (existingOtp) { // Mark the existing OTP as used to invalidate it try { await this.dbAdapter.updateOtp(existingOtp.id, { isUsed: true, }); isResent = true; } catch (error) { // If update fails, try to delete the existing OTP try { await this.dbAdapter.deleteOtp(existingOtp.id); } catch (deleteError) { // Log cleanup failure but don't block OTP generation await this.emitEvent('fail', { email, context, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.DATABASE_ERROR, 'Failed to clean up existing OTP')); } } } // Generate new session ID (always unique) const sessionId = this.otpGenerator.generateSessionId(); // Generate new OTP const otp = this.otpGenerator.generateOtp(this.config.otpLength); // Ensure we create a valid date for expiration const expiryTimestamp = Date.now() + this.config.expiryMs; const expiresAt = new Date(expiryTimestamp); // Validate the created date if (isNaN(expiresAt.getTime())) { throw new types_1.OtpError(types_1.OtpErrorCode.INVALID, 'Failed to create valid expiration date'); } // Create OTP record with retry logic for duplicate key errors let otpRecord; let retryCount = 0; const maxRetries = 3; while (retryCount < maxRetries) { try { otpRecord = await this.dbAdapter.createOtp({ email, context, sessionId: retryCount > 0 ? this.otpGenerator.generateSessionId() : sessionId, channel: 'email', otpHash: await this.otpGenerator.hashOtp(otp), hmac: this.otpGenerator.createHmac(otp, context, sessionId), expiresAt, attempts: 0, maxAttempts: this.config.maxRetries, isUsed: false, isLocked: false, requestMeta, }); break; // Success, exit retry loop } catch (error) { retryCount++; // Check if it's a duplicate key error if (error.code === 11000 && retryCount < maxRetries) { // Log retry failure but don't block OTP generation await this.emitEvent('fail', { email, context, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.DATABASE_ERROR, 'Duplicate key error on attempt')); // Continue to next iteration with new session ID continue; } // If it's not a duplicate key error or we've exhausted retries, throw the error throw error; } } if (!otpRecord) { throw new types_1.OtpError(types_1.OtpErrorCode.DATABASE_ERROR, 'Failed to create OTP record after multiple attempts'); } // Send OTP via email try { await this.sendEmailOtp(email, otp, context, template); await this.emitEvent('send', { email, context, sessionId: otpRecord.sessionId, requestMeta }); return { sessionId: otpRecord.sessionId, expiresAt, isResent, ...(this.config.strictMode ? {} : { otp }), // Only include in non-strict mode for debugging }; } catch (error) { // Clean up the OTP record if sending failed try { await this.dbAdapter.deleteOtp(otpRecord.id); } catch (cleanupError) { // Log cleanup failure but don't block OTP generation await this.emitEvent('fail', { email, context, sessionId: otpRecord.sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.DATABASE_ERROR, 'Failed to clean up OTP record after email send failure')); } await this.emitEvent('fail', { email, context, sessionId: otpRecord.sessionId, requestMeta }, error instanceof Error ? error : new Error(String(error))); throw new types_1.OtpError(types_1.OtpErrorCode.EMAIL_SEND_FAILED, `Failed to send email: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Verify OTP */ async verify(params) { const { email, otpCode, context, sessionId, requestMeta } = params; // Validate inputs if (!email || !otpCode || !context || !sessionId || !requestMeta) { throw new types_1.OtpError(types_1.OtpErrorCode.INVALID, 'Missing required parameters'); } // Validate OTP code format (should be numeric string) if (!/^\d+$/.test(otpCode)) { throw new types_1.OtpError(types_1.OtpErrorCode.INVALID, 'Invalid OTP format. OTP must contain only digits.'); } // Find OTP record const otpRecord = await this.dbAdapter.findOtp(email, context, sessionId, 'email'); if (!otpRecord) { await this.emitEvent('fail', { email, context, sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.INVALID, 'Invalid OTP')); throw new types_1.OtpError(types_1.OtpErrorCode.INVALID, 'Invalid OTP'); } // Validate stored hash is a string if (typeof otpRecord.otpHash !== 'string' || !otpRecord.otpHash) { await this.emitEvent('fail', { email, context, sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.DATABASE_ERROR, 'Invalid OTP hash in database')); throw new types_1.OtpError(types_1.OtpErrorCode.DATABASE_ERROR, 'Invalid OTP hash in database'); } // Check if OTP is already used if (otpRecord.isUsed) { await this.emitEvent('fail', { email, context, sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.ALREADY_USED, 'OTP already used')); throw new types_1.OtpError(types_1.OtpErrorCode.ALREADY_USED, 'OTP has already been used'); } // Check if OTP is locked if (otpRecord.isLocked) { await this.emitEvent('fail', { email, context, sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.LOCKED, 'OTP is locked')); throw new types_1.OtpError(types_1.OtpErrorCode.LOCKED, 'OTP is locked due to too many failed attempts'); } // Check if OTP is expired if (otpRecord.expiresAt < new Date()) { await this.emitEvent('fail', { email, context, sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.EXPIRED, 'OTP expired')); throw new types_1.OtpError(types_1.OtpErrorCode.EXPIRED, 'OTP has expired'); } // Verify OTP hash (compare plain OTP with stored hash) let isValidHash = false; try { isValidHash = await this.otpGenerator.verifyOtpHash(otpCode, otpRecord.otpHash); } catch (error) { // Log hash verification error but don't expose details await this.emitEvent('fail', { email, context, sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.DATABASE_ERROR, 'OTP verification failed')); throw new types_1.OtpError(types_1.OtpErrorCode.DATABASE_ERROR, 'OTP verification failed'); } // Verify HMAC const isValidHmac = this.otpGenerator.verifyHmac(otpCode, context, sessionId, otpRecord.hmac); if (!isValidHash || !isValidHmac) { // Increment attempts const newAttempts = otpRecord.attempts + 1; const isLocked = newAttempts >= otpRecord.maxAttempts; await this.dbAdapter.updateOtp(otpRecord.id, { attempts: newAttempts, isLocked, }); if (isLocked) { await this.emitEvent('fail', { email, context, sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.ATTEMPTS_EXCEEDED, 'Too many failed attempts')); throw new types_1.OtpError(types_1.OtpErrorCode.ATTEMPTS_EXCEEDED, 'Too many failed attempts. OTP is now locked.'); } await this.emitEvent('fail', { email, context, sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.INVALID, 'Invalid OTP')); throw new types_1.OtpError(types_1.OtpErrorCode.INVALID, 'Invalid OTP'); } // Check request metadata in strict mode if (this.config.strictMode) { const metaMismatch = this.checkMetaMismatch(otpRecord.requestMeta, requestMeta); if (metaMismatch) { await this.emitEvent('fail', { email, context, sessionId, requestMeta }, new types_1.OtpError(types_1.OtpErrorCode.META_MISMATCH, 'Request context mismatch')); throw new types_1.OtpError(types_1.OtpErrorCode.META_MISMATCH, 'Request context mismatch'); } } // Mark OTP as used await this.dbAdapter.updateOtp(otpRecord.id, { isUsed: true, }); await this.emitEvent('verify', { email, context, sessionId, requestMeta }); return { success: true, sessionId, email, context, channel: 'email', }; } /** * Cleanup expired OTPs */ async cleanup() { await this.dbAdapter.cleanupExpiredOtps(); } /** * Health check for monitoring */ async healthCheck() { const checks = { database: false, emailProvider: false, rateLimiter: false, }; try { // Test database connection await this.dbAdapter.cleanupExpiredOtps(); checks.database = true; } catch (error) { // Database check failed } try { // Test email provider by attempting a test send (will be caught) await this.emailProvider.sendEmail({ to: 'health-check@example.com', subject: 'Health Check', text: 'Health check test' }); checks.emailProvider = true; } catch (error) { // Email provider check failed } try { // Test rate limiter await this.rateLimiter.checkLimit('health-check', 1, 60000); checks.rateLimiter = true; } catch (error) { // Rate limiter check failed } const healthyChecks = Object.values(checks).filter(Boolean).length; let status; if (healthyChecks === 3) { status = 'healthy'; } else if (healthyChecks >= 1) { status = 'degraded'; } else { status = 'unhealthy'; } return { status, checks, timestamp: new Date(), version: '1.0.1', }; } /** * Send email OTP */ async sendEmailOtp(email, otp, context, customTemplate) { const templateData = { otp, email, context, expiresIn: `${Math.floor(this.config.expiryMs / (1000 * 60))} minutes`, companyName: this.config.templates.senderName, supportEmail: this.config.templates.senderEmail, }; // Use custom template if provided, otherwise fall back to default templates const effectiveTemplate = customTemplate || this.config.templates; // Get the template content (custom or default) const htmlTemplate = effectiveTemplate.html || email_templates_1.EmailTemplates.getDefaultHtml(templateData); const textTemplate = effectiveTemplate.text || email_templates_1.EmailTemplates.getDefaultText(templateData); const subjectTemplate = effectiveTemplate.subject || email_templates_1.EmailTemplates.getDefaultSubject(); // Always render templates with actual values, whether custom or default const html = email_templates_1.EmailTemplates.renderTemplate(htmlTemplate, templateData); const text = email_templates_1.EmailTemplates.renderTemplate(textTemplate, templateData); const subject = email_templates_1.EmailTemplates.renderTemplate(subjectTemplate, templateData); const emailParams = { to: email, subject, html, text, ...(effectiveTemplate.senderEmail && { from: effectiveTemplate.senderEmail }), }; await this.emailProvider.sendEmail(emailParams); } /** * Check if request metadata matches */ checkMetaMismatch(originalMeta, currentMeta) { return (originalMeta.ip !== currentMeta.ip || originalMeta.userAgent !== currentMeta.userAgent || (originalMeta.deviceId ? originalMeta.deviceId !== currentMeta.deviceId : false) || (originalMeta.platform ? originalMeta.platform !== currentMeta.platform : false)); } /** * Emit event */ async emitEvent(type, data, error) { const event = { type, email: data.email, context: data.context, sessionId: data.sessionId || '', channel: 'email', requestMeta: data.requestMeta, timestamp: new Date(), ...(error && { error }), }; const handler = this.events[`on${type.charAt(0).toUpperCase() + type.slice(1)}`]; if (handler) { try { await handler(event); } catch (error) { // Silently handle event handler errors to prevent crashes // In production, consider logging to a proper logging service } } } } exports.SecureEmailOtp = SecureEmailOtp; //# sourceMappingURL=secure-email-otp.js.map