From cf97dffbfbfb6cd7798f9d8669c9e6782e5ba0df Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:04:39 -0500 Subject: [PATCH] MFA --- backend/middleware/csrf.js | 3 +- backend/middleware/rateLimiter.js | 57 ++ backend/middleware/stepUpAuth.js | 73 ++ backend/middleware/validation.js | 29 + ...260115000001-add-two-factor-auth-fields.js | 107 +++ ...0260116000001-mfa-security-enhancements.js | 32 + backend/models/User.js | 301 +++++++++ backend/package-lock.json | 281 +++++++- backend/package.json | 2 + backend/routes/twoFactor.js | 627 ++++++++++++++++++ backend/routes/users.js | 57 +- backend/server.js | 2 + backend/services/TwoFactorService.js | 305 +++++++++ .../services/email/domain/AuthEmailService.js | 144 ++++ .../emails/passwordChangedToUser.html | 2 +- .../emails/recoveryCodeUsedToUser.html | 232 +++++++ .../emails/twoFactorDisabledToUser.html | 195 ++++++ .../emails/twoFactorEnabledToUser.html | 195 ++++++ .../templates/emails/twoFactorOtpToUser.html | 193 ++++++ frontend/src/App.tsx | 45 +- frontend/src/components/AuthModal.tsx | 10 + .../src/components/PasswordStrengthMeter.tsx | 48 +- .../TwoFactor/RecoveryCodesDisplay.tsx | 166 +++++ .../TwoFactor/TwoFactorManagement.tsx | 240 +++++++ .../TwoFactor/TwoFactorSetupModal.tsx | 384 +++++++++++ .../TwoFactor/TwoFactorVerifyModal.tsx | 334 ++++++++++ frontend/src/components/TwoFactor/index.ts | 4 + frontend/src/pages/Profile.tsx | 285 +++++++- frontend/src/services/api.ts | 46 +- frontend/src/types/index.ts | 28 + frontend/src/utils/passwordValidation.ts | 34 + 31 files changed, 4405 insertions(+), 56 deletions(-) create mode 100644 backend/middleware/stepUpAuth.js create mode 100644 backend/migrations/20260115000001-add-two-factor-auth-fields.js create mode 100644 backend/migrations/20260116000001-mfa-security-enhancements.js create mode 100644 backend/routes/twoFactor.js create mode 100644 backend/services/TwoFactorService.js create mode 100644 backend/templates/emails/recoveryCodeUsedToUser.html create mode 100644 backend/templates/emails/twoFactorDisabledToUser.html create mode 100644 backend/templates/emails/twoFactorEnabledToUser.html create mode 100644 backend/templates/emails/twoFactorOtpToUser.html create mode 100644 frontend/src/components/TwoFactor/RecoveryCodesDisplay.tsx create mode 100644 frontend/src/components/TwoFactor/TwoFactorManagement.tsx create mode 100644 frontend/src/components/TwoFactor/TwoFactorSetupModal.tsx create mode 100644 frontend/src/components/TwoFactor/TwoFactorVerifyModal.tsx create mode 100644 frontend/src/components/TwoFactor/index.ts create mode 100644 frontend/src/utils/passwordValidation.ts diff --git a/backend/middleware/csrf.js b/backend/middleware/csrf.js index 53e3bcd..a7f9572 100644 --- a/backend/middleware/csrf.js +++ b/backend/middleware/csrf.js @@ -28,8 +28,7 @@ const csrfProtection = (req, res, next) => { } // Get token from header or body - const token = - req.headers["x-csrf-token"] || req.body.csrfToken || req.query.csrfToken; + const token = req.headers["x-csrf-token"] || req.body.csrfToken; // Get token from cookie const cookieToken = req.cookies && req.cookies["csrf-token"]; diff --git a/backend/middleware/rateLimiter.js b/backend/middleware/rateLimiter.js index 6329fc4..10fef9c 100644 --- a/backend/middleware/rateLimiter.js +++ b/backend/middleware/rateLimiter.js @@ -207,6 +207,57 @@ const authRateLimiters = { legacyHeaders: false, handler: createRateLimitHandler('general'), }), + + // Two-Factor Authentication rate limiters + twoFactorVerification: rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // 10 verification attempts per 15 minutes + message: { + error: "Too many verification attempts. Please try again later.", + retryAfter: 900, + }, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: true, + handler: createRateLimitHandler('twoFactorVerification'), + }), + + twoFactorSetup: rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 5, // 5 setup attempts per hour + message: { + error: "Too many setup attempts. Please try again later.", + retryAfter: 3600, + }, + standardHeaders: true, + legacyHeaders: false, + handler: createRateLimitHandler('twoFactorSetup'), + }), + + recoveryCode: rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 3, // 3 recovery code attempts per 15 minutes + message: { + error: "Too many recovery code attempts. Please try again later.", + retryAfter: 900, + }, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: false, // Count all attempts for security + handler: createRateLimitHandler('recoveryCode'), + }), + + emailOtpSend: rateLimit({ + windowMs: 10 * 60 * 1000, // 10 minutes + max: 2, // 2 OTP sends per 10 minutes + message: { + error: "Please wait before requesting another code.", + retryAfter: 600, + }, + standardHeaders: true, + legacyHeaders: false, + handler: createRateLimitHandler('emailOtpSend'), + }), }; module.exports = { @@ -223,6 +274,12 @@ module.exports = { emailVerificationLimiter: authRateLimiters.emailVerification, generalLimiter: authRateLimiters.general, + // Two-Factor Authentication rate limiters + twoFactorVerificationLimiter: authRateLimiters.twoFactorVerification, + twoFactorSetupLimiter: authRateLimiters.twoFactorSetup, + recoveryCodeLimiter: authRateLimiters.recoveryCode, + emailOtpSendLimiter: authRateLimiters.emailOtpSend, + // Burst protection burstProtection, diff --git a/backend/middleware/stepUpAuth.js b/backend/middleware/stepUpAuth.js new file mode 100644 index 0000000..e441c19 --- /dev/null +++ b/backend/middleware/stepUpAuth.js @@ -0,0 +1,73 @@ +const TwoFactorService = require("../services/TwoFactorService"); +const logger = require("../utils/logger"); + +/** + * Middleware to require step-up authentication for sensitive actions. + * Only applies to users who have 2FA enabled. + * + * @param {string} action - The sensitive action being protected + * @returns {Function} Express middleware function + */ +const requireStepUpAuth = (action) => { + return async (req, res, next) => { + try { + // If user doesn't have 2FA enabled, skip step-up requirement + if (!req.user.twoFactorEnabled) { + return next(); + } + + // Check if user has a valid step-up session (within 5 minutes) + const isValid = TwoFactorService.validateStepUpSession(req.user); + + if (!isValid) { + logger.info( + `Step-up authentication required for user ${req.user.id}, action: ${action}` + ); + + return res.status(403).json({ + error: "Multi-factor authentication required", + code: "STEP_UP_REQUIRED", + action: action, + methods: getTwoFactorMethods(req.user), + }); + } + + next(); + } catch (error) { + logger.error("Step-up auth middleware error:", error); + return res.status(500).json({ + error: "An error occurred during authentication", + }); + } + }; +}; + +/** + * Get available 2FA methods for a user + * @param {Object} user - User object + * @returns {string[]} Array of available methods + */ +function getTwoFactorMethods(user) { + const methods = []; + + // Primary method is always available + if (user.twoFactorMethod === "totp") { + methods.push("totp"); + } + + // Email is always available as a backup method + methods.push("email"); + + // Recovery codes are available if any remain + if (user.recoveryCodesHash) { + const recoveryData = JSON.parse(user.recoveryCodesHash); + const remaining = TwoFactorService.getRemainingRecoveryCodesCount(recoveryData); + if (remaining > 0) { + methods.push("recovery"); + } + } + + return methods; +} + +module.exports = { requireStepUpAuth }; diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js index b2ca0b2..541b55e 100644 --- a/backend/middleware/validation.js +++ b/backend/middleware/validation.js @@ -345,6 +345,31 @@ const validateCoordinatesBody = [ .withMessage("Longitude must be between -180 and 180"), ]; +// Two-Factor Authentication validation +const validateTotpCode = [ + body("code") + .trim() + .matches(/^\d{6}$/) + .withMessage("TOTP code must be exactly 6 digits"), + handleValidationErrors, +]; + +const validateEmailOtp = [ + body("code") + .trim() + .matches(/^\d{6}$/) + .withMessage("Email OTP must be exactly 6 digits"), + handleValidationErrors, +]; + +const validateRecoveryCode = [ + body("code") + .trim() + .matches(/^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/i) + .withMessage("Recovery code must be in format XXXX-XXXX"), + handleValidationErrors, +]; + module.exports = { sanitizeInput, handleValidationErrors, @@ -359,4 +384,8 @@ module.exports = { validateFeedback, validateCoordinatesQuery, validateCoordinatesBody, + // Two-Factor Authentication + validateTotpCode, + validateEmailOtp, + validateRecoveryCode, }; diff --git a/backend/migrations/20260115000001-add-two-factor-auth-fields.js b/backend/migrations/20260115000001-add-two-factor-auth-fields.js new file mode 100644 index 0000000..f5fe153 --- /dev/null +++ b/backend/migrations/20260115000001-add-two-factor-auth-fields.js @@ -0,0 +1,107 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add TOTP configuration fields + await queryInterface.addColumn("Users", "twoFactorEnabled", { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false, + }); + + await queryInterface.addColumn("Users", "twoFactorMethod", { + type: Sequelize.ENUM("totp", "email"), + allowNull: true, + }); + + await queryInterface.addColumn("Users", "totpSecret", { + type: Sequelize.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("Users", "totpSecretIv", { + type: Sequelize.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("Users", "twoFactorEnabledAt", { + type: Sequelize.DATE, + allowNull: true, + }); + + // Add Email OTP fields (backup method) + await queryInterface.addColumn("Users", "emailOtpCode", { + type: Sequelize.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("Users", "emailOtpExpiry", { + type: Sequelize.DATE, + allowNull: true, + }); + + await queryInterface.addColumn("Users", "emailOtpAttempts", { + type: Sequelize.INTEGER, + defaultValue: 0, + allowNull: false, + }); + + // Add Recovery Codes fields + await queryInterface.addColumn("Users", "recoveryCodesHash", { + type: Sequelize.TEXT, + allowNull: true, + }); + + await queryInterface.addColumn("Users", "recoveryCodesGeneratedAt", { + type: Sequelize.DATE, + allowNull: true, + }); + + await queryInterface.addColumn("Users", "recoveryCodesUsedCount", { + type: Sequelize.INTEGER, + defaultValue: 0, + allowNull: false, + }); + + // Add Step-up session tracking + await queryInterface.addColumn("Users", "twoFactorVerifiedAt", { + type: Sequelize.DATE, + allowNull: true, + }); + + // Add temporary secret storage during setup + await queryInterface.addColumn("Users", "twoFactorSetupPendingSecret", { + type: Sequelize.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("Users", "twoFactorSetupPendingSecretIv", { + type: Sequelize.STRING, + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + // Remove all 2FA fields in reverse order + await queryInterface.removeColumn("Users", "twoFactorSetupPendingSecretIv"); + await queryInterface.removeColumn("Users", "twoFactorSetupPendingSecret"); + await queryInterface.removeColumn("Users", "twoFactorVerifiedAt"); + await queryInterface.removeColumn("Users", "recoveryCodesUsedCount"); + await queryInterface.removeColumn("Users", "recoveryCodesGeneratedAt"); + await queryInterface.removeColumn("Users", "recoveryCodesHash"); + await queryInterface.removeColumn("Users", "emailOtpAttempts"); + await queryInterface.removeColumn("Users", "emailOtpExpiry"); + await queryInterface.removeColumn("Users", "emailOtpCode"); + await queryInterface.removeColumn("Users", "twoFactorEnabledAt"); + await queryInterface.removeColumn("Users", "totpSecretIv"); + await queryInterface.removeColumn("Users", "totpSecret"); + await queryInterface.removeColumn("Users", "twoFactorMethod"); + await queryInterface.removeColumn("Users", "twoFactorEnabled"); + + // Remove the ENUM type + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_Users_twoFactorMethod";' + ); + }, +}; diff --git a/backend/migrations/20260116000001-mfa-security-enhancements.js b/backend/migrations/20260116000001-mfa-security-enhancements.js new file mode 100644 index 0000000..0fe5c9b --- /dev/null +++ b/backend/migrations/20260116000001-mfa-security-enhancements.js @@ -0,0 +1,32 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add recentTotpCodes field for TOTP replay protection + await queryInterface.addColumn("Users", "recentTotpCodes", { + type: Sequelize.TEXT, + allowNull: true, + comment: "JSON array of hashed recently used TOTP codes for replay protection", + }); + + // Remove deprecated columns (if they exist) + await queryInterface.removeColumn("Users", "twoFactorEnabledAt").catch(() => {}); + await queryInterface.removeColumn("Users", "recoveryCodesUsedCount").catch(() => {}); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn("Users", "recentTotpCodes"); + + // Re-add deprecated columns for rollback + await queryInterface.addColumn("Users", "twoFactorEnabledAt", { + type: Sequelize.DATE, + allowNull: true, + }); + await queryInterface.addColumn("Users", "recoveryCodesUsedCount", { + type: Sequelize.INTEGER, + defaultValue: 0, + allowNull: false, + }); + }, +}; diff --git a/backend/models/User.js b/backend/models/User.js index c9fdb4c..4c32c19 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -191,6 +191,66 @@ const User = sequelize.define( defaultValue: 0, allowNull: true, }, + // Two-Factor Authentication fields + twoFactorEnabled: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + twoFactorMethod: { + type: DataTypes.ENUM("totp", "email"), + allowNull: true, + }, + totpSecret: { + type: DataTypes.STRING, + allowNull: true, + }, + totpSecretIv: { + type: DataTypes.STRING, + allowNull: true, + }, + // Email OTP fields (backup method) + emailOtpCode: { + type: DataTypes.STRING, + allowNull: true, + }, + emailOtpExpiry: { + type: DataTypes.DATE, + allowNull: true, + }, + emailOtpAttempts: { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false, + }, + // Recovery codes + recoveryCodesHash: { + type: DataTypes.TEXT, + allowNull: true, + }, + recoveryCodesGeneratedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + // Step-up session tracking + twoFactorVerifiedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + // Temporary secret during setup + twoFactorSetupPendingSecret: { + type: DataTypes.STRING, + allowNull: true, + }, + twoFactorSetupPendingSecretIv: { + type: DataTypes.STRING, + allowNull: true, + }, + // TOTP replay protection + recentTotpCodes: { + type: DataTypes.TEXT, + allowNull: true, + }, }, { hooks: { @@ -401,4 +461,245 @@ User.prototype.unbanUser = async function () { }); }; +// Two-Factor Authentication methods +const TwoFactorService = require("../services/TwoFactorService"); + +// Store pending TOTP secret during setup +User.prototype.storePendingTotpSecret = async function ( + encryptedSecret, + encryptedSecretIv +) { + return this.update({ + twoFactorSetupPendingSecret: encryptedSecret, + twoFactorSetupPendingSecretIv: encryptedSecretIv, + }); +}; + +// Enable TOTP 2FA after verification +User.prototype.enableTotp = async function (recoveryCodes) { + const hashedCodes = await Promise.all( + recoveryCodes.map((code) => bcrypt.hash(code, 12)) + ); + + // Store in structured format + const recoveryData = { + version: 1, + codes: hashedCodes.map((hash) => ({ + hash, + used: false, + })), + }; + + return this.update({ + twoFactorEnabled: true, + twoFactorMethod: "totp", + totpSecret: this.twoFactorSetupPendingSecret, + totpSecretIv: this.twoFactorSetupPendingSecretIv, + twoFactorSetupPendingSecret: null, + twoFactorSetupPendingSecretIv: null, + recoveryCodesHash: JSON.stringify(recoveryData), + recoveryCodesGeneratedAt: new Date(), + twoFactorVerifiedAt: new Date(), // Consider setup as verification + }); +}; + +// Enable Email 2FA +User.prototype.enableEmailTwoFactor = async function (recoveryCodes) { + const hashedCodes = await Promise.all( + recoveryCodes.map((code) => bcrypt.hash(code, 12)) + ); + + // Store in structured format + const recoveryData = { + version: 1, + codes: hashedCodes.map((hash) => ({ + hash, + used: false, + })), + }; + + return this.update({ + twoFactorEnabled: true, + twoFactorMethod: "email", + recoveryCodesHash: JSON.stringify(recoveryData), + recoveryCodesGeneratedAt: new Date(), + twoFactorVerifiedAt: new Date(), + }); +}; + +// Disable 2FA +User.prototype.disableTwoFactor = async function () { + return this.update({ + twoFactorEnabled: false, + twoFactorMethod: null, + totpSecret: null, + totpSecretIv: null, + emailOtpCode: null, + emailOtpExpiry: null, + emailOtpAttempts: 0, + recoveryCodesHash: null, + recoveryCodesGeneratedAt: null, + twoFactorVerifiedAt: null, + twoFactorSetupPendingSecret: null, + twoFactorSetupPendingSecretIv: null, + }); +}; + +// Generate and store email OTP +User.prototype.generateEmailOtp = async function () { + const { code, hashedCode, expiry } = TwoFactorService.generateEmailOtp(); + + await this.update({ + emailOtpCode: hashedCode, + emailOtpExpiry: expiry, + emailOtpAttempts: 0, + }); + + return code; // Return plain code for sending via email +}; + +// Verify email OTP +User.prototype.verifyEmailOtp = function (inputCode) { + return TwoFactorService.verifyEmailOtp( + inputCode, + this.emailOtpCode, + this.emailOtpExpiry + ); +}; + +// Increment email OTP attempts +User.prototype.incrementEmailOtpAttempts = async function () { + const newAttempts = (this.emailOtpAttempts || 0) + 1; + await this.update({ emailOtpAttempts: newAttempts }); + return newAttempts; +}; + +// Check if email OTP is locked +User.prototype.isEmailOtpLocked = function () { + return TwoFactorService.isEmailOtpLocked(this.emailOtpAttempts || 0); +}; + +// Clear email OTP after successful verification +User.prototype.clearEmailOtp = async function () { + return this.update({ + emailOtpCode: null, + emailOtpExpiry: null, + emailOtpAttempts: 0, + }); +}; + +// Check if a TOTP code was recently used (replay protection) +User.prototype.hasUsedTotpCode = function (code) { + const crypto = require("crypto"); + const recentCodes = JSON.parse(this.recentTotpCodes || "[]"); + const codeHash = crypto.createHash("sha256").update(code).digest("hex"); + return recentCodes.includes(codeHash); +}; + +// Mark a TOTP code as used (replay protection) +User.prototype.markTotpCodeUsed = async function (code) { + const crypto = require("crypto"); + const recentCodes = JSON.parse(this.recentTotpCodes || "[]"); + const codeHash = crypto.createHash("sha256").update(code).digest("hex"); + recentCodes.unshift(codeHash); + // Keep only last 5 codes (covers about 2.5 minutes of 30-second windows) + await this.update({ recentTotpCodes: JSON.stringify(recentCodes.slice(0, 5)) }); +}; + +// Verify TOTP code with replay protection +User.prototype.verifyTotpCode = function (code) { + if (!this.totpSecret || !this.totpSecretIv) { + return false; + } + // Check for replay attack + if (this.hasUsedTotpCode(code)) { + return false; + } + return TwoFactorService.verifyTotpCode(this.totpSecret, this.totpSecretIv, code); +}; + +// Verify pending TOTP code (during setup) +User.prototype.verifyPendingTotpCode = function (code) { + if (!this.twoFactorSetupPendingSecret || !this.twoFactorSetupPendingSecretIv) { + return false; + } + return TwoFactorService.verifyTotpCode( + this.twoFactorSetupPendingSecret, + this.twoFactorSetupPendingSecretIv, + code + ); +}; + +// Use a recovery code +User.prototype.useRecoveryCode = async function (inputCode) { + if (!this.recoveryCodesHash) { + return { valid: false }; + } + + const recoveryData = JSON.parse(this.recoveryCodesHash); + const { valid, index } = await TwoFactorService.verifyRecoveryCode( + inputCode, + recoveryData + ); + + if (valid) { + // Handle both old and new format + if (recoveryData.version) { + // New structured format - mark as used with timestamp + recoveryData.codes[index].used = true; + recoveryData.codes[index].usedAt = new Date().toISOString(); + } else { + // Legacy format - set to null + recoveryData[index] = null; + } + + await this.update({ + recoveryCodesHash: JSON.stringify(recoveryData), + twoFactorVerifiedAt: new Date(), + }); + } + + return { + valid, + remainingCodes: TwoFactorService.getRemainingRecoveryCodesCount(recoveryData), + }; +}; + +// Get remaining recovery codes count +User.prototype.getRemainingRecoveryCodes = function () { + if (!this.recoveryCodesHash) { + return 0; + } + const recoveryData = JSON.parse(this.recoveryCodesHash); + return TwoFactorService.getRemainingRecoveryCodesCount(recoveryData); +}; + +// Regenerate recovery codes +User.prototype.regenerateRecoveryCodes = async function () { + const { codes, hashedCodes } = await TwoFactorService.generateRecoveryCodes(); + + // Store in structured format + const recoveryData = { + version: 1, + codes: hashedCodes.map((hash) => ({ + hash, + used: false, + })), + }; + + await this.update({ + recoveryCodesHash: JSON.stringify(recoveryData), + recoveryCodesGeneratedAt: new Date(), + }); + + return codes; // Return plain codes for display to user +}; + +// Update step-up verification timestamp +User.prototype.updateStepUpSession = async function () { + return this.update({ + twoFactorVerifiedAt: new Date(), + }); +}; + module.exports = User; diff --git a/backend/package-lock.json b/backend/package-lock.json index 5714016..3e18e26 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -30,7 +30,9 @@ "jsdom": "^27.0.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", + "otplib": "^13.1.1", "pg": "^8.16.3", + "qrcode": "^1.5.4", "sequelize": "^6.37.7", "sequelize-cli": "^6.6.3", "socket.io": "^4.8.1", @@ -4588,6 +4590,74 @@ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" }, + "node_modules/@otplib/core": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.1.1.tgz", + "integrity": "sha512-K7167w5T5fBtI7FCTrcTyqHPNEKIvyBHFACvJGXci60V30Rt4VDsRHWw/LYtOZRbUqJbPH9orn4N10dYRVi3bw==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.1.1.tgz", + "integrity": "sha512-2Zht/w3kb9Cv/BPNWC/KvFspztFae38BlteW4KQOn1U+jBj02EMpO3V75OaLGFyz3ZCWzYdLoTRhMkVDCVklDw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1", + "@otplib/uri": "13.1.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.1.1.tgz", + "integrity": "sha512-PAKmU60DOQyfwSP6VDcwP5wRO3GYCC8UgH0+J2wY2XI5OetHDPv27wNjBibE1iO7NMwCnUf1pv0rsTVE5TR9ew==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.1.1.tgz", + "integrity": "sha512-MbgmPWGzhlUG+Jq4sX1UHgdNVEcXqinZy9GUtu2iotHRya815oBVwkiviM/y2qorxuCOIMNTAArD6jKlVv/qiQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.1.1" + } + }, + "node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/totp": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.1.1.tgz", + "integrity": "sha512-Bbr8C3fLQeUIiAU5sBltPIvPTsGOCmTBln2TjU8wt36nnWMK1p7SsV+VeAk4VYEsGCc4W/ds4M0Pbx4gJMt88A==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1", + "@otplib/hotp": "13.1.1", + "@otplib/uri": "13.1.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.1.1.tgz", + "integrity": "sha512-UMdz/41JIKPLw6/VeExc3MJaCHZYEZXlF5WVyhtWT5nfzQKAcgp6HI8Tar0lDN+lXXKCjbGwlyCKOWCMAISNXg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -4620,6 +4690,15 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -6100,7 +6179,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6620,6 +6698,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -6708,6 +6795,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", @@ -7358,7 +7451,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -9167,7 +9259,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -9728,6 +9819,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/otplib": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.1.1.tgz", + "integrity": "sha512-cjNU7ENJPwqNK7Qesl6P357B0WB4XmbptHCsGzy14jYcRmQyNwkvl0eT0nzUX0c1djrkOFVIHtNp3Px1vDIpfw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1", + "@otplib/hotp": "13.1.1", + "@otplib/plugin-base32-scure": "13.1.1", + "@otplib/plugin-crypto-noble": "13.1.1", + "@otplib/totp": "13.1.1", + "@otplib/uri": "13.1.1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9748,7 +9853,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -9761,7 +9865,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -9777,7 +9880,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9831,7 +9933,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10004,6 +10105,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -10151,6 +10261,145 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -10282,6 +10531,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -10570,6 +10825,12 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -11773,6 +12034,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/winston": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", diff --git a/backend/package.json b/backend/package.json index fb70621..bb62114 100644 --- a/backend/package.json +++ b/backend/package.json @@ -55,7 +55,9 @@ "jsdom": "^27.0.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", + "otplib": "^13.1.1", "pg": "^8.16.3", + "qrcode": "^1.5.4", "sequelize": "^6.37.7", "sequelize-cli": "^6.6.3", "socket.io": "^4.8.1", diff --git a/backend/routes/twoFactor.js b/backend/routes/twoFactor.js new file mode 100644 index 0000000..5b4a56e --- /dev/null +++ b/backend/routes/twoFactor.js @@ -0,0 +1,627 @@ +const express = require("express"); +const { User } = require("../models"); +const TwoFactorService = require("../services/TwoFactorService"); +const emailServices = require("../services/email"); +const logger = require("../utils/logger"); +const { authenticateToken } = require("../middleware/auth"); +const { requireStepUpAuth } = require("../middleware/stepUpAuth"); +const { csrfProtection } = require("../middleware/csrf"); +const { + sanitizeInput, + validateTotpCode, + validateEmailOtp, + validateRecoveryCode, +} = require("../middleware/validation"); +const { + twoFactorVerificationLimiter, + twoFactorSetupLimiter, + recoveryCodeLimiter, + emailOtpSendLimiter, +} = require("../middleware/rateLimiter"); + +const router = express.Router(); + +// Helper for structured security audit logging +const auditLog = (req, action, userId, details = {}) => { + logger.info({ + type: 'security_audit', + action, + userId, + ip: req.ip, + userAgent: req.get('User-Agent'), + ...details, + }); +}; + +// All routes require authentication +router.use(authenticateToken); + +// ============================================ +// SETUP ENDPOINTS +// ============================================ + +/** + * POST /api/2fa/setup/totp/init + * Initialize TOTP setup - generate secret and QR code + */ +router.post( + "/setup/totp/init", + twoFactorSetupLimiter, + csrfProtection, + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (user.twoFactorEnabled) { + return res.status(400).json({ + error: "Multi-factor authentication is already enabled", + }); + } + + // Generate TOTP secret and QR code + const { qrCodeDataUrl, encryptedSecret, encryptedSecretIv } = + await TwoFactorService.generateTotpSecret(user.email); + + // Store pending secret for verification + await user.storePendingTotpSecret(encryptedSecret, encryptedSecretIv); + + auditLog(req, '2fa.setup.initiated', user.id, { method: 'totp' }); + + res.json({ + qrCodeDataUrl, + message: "Scan the QR code with your authenticator app", + }); + } catch (error) { + logger.error("TOTP setup init error:", error); + res.status(500).json({ error: "Failed to initialize TOTP setup" }); + } + } +); + +/** + * POST /api/2fa/setup/totp/verify + * Verify TOTP code and enable 2FA + */ +router.post( + "/setup/totp/verify", + twoFactorSetupLimiter, + csrfProtection, + sanitizeInput, + validateTotpCode, + async (req, res) => { + try { + const { code } = req.body; + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (user.twoFactorEnabled) { + return res.status(400).json({ + error: "Multi-factor authentication is already enabled", + }); + } + + if (!user.twoFactorSetupPendingSecret) { + return res.status(400).json({ + error: "No pending TOTP setup. Please start the setup process again.", + }); + } + + // Verify the code against the pending secret + const isValid = user.verifyPendingTotpCode(code); + + if (!isValid) { + return res.status(400).json({ + error: "Invalid verification code. Please try again.", + }); + } + + // Generate recovery codes + const { codes: recoveryCodes } = + await TwoFactorService.generateRecoveryCodes(); + + // Enable TOTP + await user.enableTotp(recoveryCodes); + + // Send confirmation email + try { + await emailServices.auth.sendTwoFactorEnabledEmail(user); + } catch (emailError) { + logger.error("Failed to send 2FA enabled email:", emailError); + // Don't fail the request if email fails + } + + auditLog(req, '2fa.setup.completed', user.id, { method: 'totp' }); + + res.json({ + message: "Multi-factor authentication enabled successfully", + recoveryCodes, + warning: + "Save these recovery codes in a secure location. You will not be able to see them again.", + }); + } catch (error) { + logger.error("TOTP setup verify error:", error); + res.status(500).json({ error: "Failed to enable multi-factor authentication" }); + } + } +); + +/** + * POST /api/2fa/setup/email/init + * Initialize email 2FA setup - send verification code + */ +router.post( + "/setup/email/init", + twoFactorSetupLimiter, + emailOtpSendLimiter, + csrfProtection, + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (user.twoFactorEnabled) { + return res.status(400).json({ + error: "Multi-factor authentication is already enabled", + }); + } + + // Generate and send email OTP + const otpCode = await user.generateEmailOtp(); + + try { + await emailServices.auth.sendTwoFactorOtpEmail(user, otpCode); + } catch (emailError) { + logger.error("Failed to send 2FA setup OTP email:", emailError); + return res.status(500).json({ error: "Failed to send verification email" }); + } + + auditLog(req, '2fa.setup.initiated', user.id, { method: 'email' }); + + res.json({ + message: "Verification code sent to your email", + }); + } catch (error) { + logger.error("Email 2FA setup init error:", error); + res.status(500).json({ error: "Failed to initialize email 2FA setup" }); + } + } +); + +/** + * POST /api/2fa/setup/email/verify + * Verify email OTP and enable email 2FA + */ +router.post( + "/setup/email/verify", + twoFactorSetupLimiter, + csrfProtection, + sanitizeInput, + validateEmailOtp, + async (req, res) => { + try { + const { code } = req.body; + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (user.twoFactorEnabled) { + return res.status(400).json({ + error: "Multi-factor authentication is already enabled", + }); + } + + if (user.isEmailOtpLocked()) { + return res.status(429).json({ + error: "Too many failed attempts. Please request a new code.", + }); + } + + // Verify the OTP + const isValid = user.verifyEmailOtp(code); + + if (!isValid) { + await user.incrementEmailOtpAttempts(); + return res.status(400).json({ + error: "Invalid or expired verification code", + }); + } + + // Generate recovery codes + const { codes: recoveryCodes } = + await TwoFactorService.generateRecoveryCodes(); + + // Enable email 2FA + await user.enableEmailTwoFactor(recoveryCodes); + await user.clearEmailOtp(); + + // Send confirmation email + try { + await emailServices.auth.sendTwoFactorEnabledEmail(user); + } catch (emailError) { + logger.error("Failed to send 2FA enabled email:", emailError); + } + + auditLog(req, '2fa.setup.completed', user.id, { method: 'email' }); + + res.json({ + message: "Multi-factor authentication enabled successfully", + recoveryCodes, + warning: + "Save these recovery codes in a secure location. You will not be able to see them again.", + }); + } catch (error) { + logger.error("Email 2FA setup verify error:", error); + res.status(500).json({ error: "Failed to enable multi-factor authentication" }); + } + } +); + +// ============================================ +// VERIFICATION ENDPOINTS (Step-up auth) +// ============================================ + +/** + * POST /api/2fa/verify/totp + * Verify TOTP code for step-up authentication + */ +router.post( + "/verify/totp", + twoFactorVerificationLimiter, + csrfProtection, + sanitizeInput, + validateTotpCode, + async (req, res) => { + try { + const { code } = req.body; + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (!user.twoFactorEnabled || user.twoFactorMethod !== "totp") { + logger.warn(`2FA verify failed for user ${user.id}: TOTP not enabled or wrong method`); + return res.status(400).json({ + error: "Verification failed", + }); + } + + const isValid = user.verifyTotpCode(code); + + if (!isValid) { + auditLog(req, '2fa.verify.failed', user.id, { method: 'totp' }); + return res.status(400).json({ + error: "Invalid verification code", + }); + } + + // Mark code as used for replay protection + await user.markTotpCodeUsed(code); + + // Update step-up session + await user.updateStepUpSession(); + + auditLog(req, '2fa.verify.success', user.id, { method: 'totp' }); + + res.json({ + message: "Verification successful", + verified: true, + }); + } catch (error) { + logger.error("TOTP verification error:", error); + res.status(500).json({ error: "Verification failed" }); + } + } +); + +/** + * POST /api/2fa/verify/email/send + * Send email OTP for step-up authentication + */ +router.post( + "/verify/email/send", + emailOtpSendLimiter, + csrfProtection, + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (!user.twoFactorEnabled) { + logger.warn(`2FA verify failed for user ${user.id}: 2FA not enabled`); + return res.status(400).json({ + error: "Verification failed", + }); + } + + // Generate and send email OTP + const otpCode = await user.generateEmailOtp(); + + try { + await emailServices.auth.sendTwoFactorOtpEmail(user, otpCode); + } catch (emailError) { + logger.error("Failed to send 2FA OTP email:", emailError); + return res.status(500).json({ error: "Failed to send verification email" }); + } + + auditLog(req, '2fa.otp.sent', user.id, { method: 'email' }); + + res.json({ + message: "Verification code sent to your email", + }); + } catch (error) { + logger.error("Email OTP send error:", error); + res.status(500).json({ error: "Failed to send verification code" }); + } + } +); + +/** + * POST /api/2fa/verify/email + * Verify email OTP for step-up authentication + */ +router.post( + "/verify/email", + twoFactorVerificationLimiter, + csrfProtection, + sanitizeInput, + validateEmailOtp, + async (req, res) => { + try { + const { code } = req.body; + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (!user.twoFactorEnabled) { + logger.warn(`2FA verify failed for user ${user.id}: 2FA not enabled`); + return res.status(400).json({ + error: "Verification failed", + }); + } + + if (user.isEmailOtpLocked()) { + return res.status(429).json({ + error: "Too many failed attempts. Please request a new code.", + }); + } + + const isValid = user.verifyEmailOtp(code); + + if (!isValid) { + await user.incrementEmailOtpAttempts(); + auditLog(req, '2fa.verify.failed', user.id, { method: 'email' }); + return res.status(400).json({ + error: "Invalid or expired verification code", + }); + } + + // Update step-up session and clear OTP + await user.updateStepUpSession(); + await user.clearEmailOtp(); + + auditLog(req, '2fa.verify.success', user.id, { method: 'email' }); + + res.json({ + message: "Verification successful", + verified: true, + }); + } catch (error) { + logger.error("Email OTP verification error:", error); + res.status(500).json({ error: "Verification failed" }); + } + } +); + +/** + * POST /api/2fa/verify/recovery + * Use recovery code for step-up authentication + */ +router.post( + "/verify/recovery", + recoveryCodeLimiter, + csrfProtection, + sanitizeInput, + validateRecoveryCode, + async (req, res) => { + try { + const { code } = req.body; + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (!user.twoFactorEnabled) { + logger.warn(`2FA verify failed for user ${user.id}: 2FA not enabled`); + return res.status(400).json({ + error: "Verification failed", + }); + } + + const { valid, remainingCodes } = await user.useRecoveryCode(code); + + if (!valid) { + auditLog(req, '2fa.verify.failed', user.id, { method: 'recovery' }); + return res.status(400).json({ + error: "Invalid recovery code", + }); + } + + // Send alert email about recovery code usage + try { + await emailServices.auth.sendRecoveryCodeUsedEmail(user, remainingCodes); + } catch (emailError) { + logger.error("Failed to send recovery code used email:", emailError); + } + + auditLog(req, '2fa.recovery.used', user.id, { lowCodes: remainingCodes <= 2 }); + + res.json({ + message: "Verification successful", + verified: true, + remainingCodes, + warning: + remainingCodes <= 2 + ? "You are running low on recovery codes. Please generate new ones." + : null, + }); + } catch (error) { + logger.error("Recovery code verification error:", error); + res.status(500).json({ error: "Verification failed" }); + } + } +); + +// ============================================ +// MANAGEMENT ENDPOINTS +// ============================================ + +/** + * GET /api/2fa/status + * Get current 2FA status for the user + */ +router.get("/status", async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + res.json({ + enabled: user.twoFactorEnabled, + method: user.twoFactorMethod, + hasRecoveryCodes: user.getRemainingRecoveryCodes() > 0, + lowRecoveryCodes: user.getRemainingRecoveryCodes() <= 2, + }); + } catch (error) { + logger.error("2FA status error:", error); + res.status(500).json({ error: "Failed to get 2FA status" }); + } +}); + +/** + * POST /api/2fa/disable + * Disable 2FA (requires step-up authentication) + */ +router.post( + "/disable", + csrfProtection, + requireStepUpAuth("2fa_disable"), + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (!user.twoFactorEnabled) { + logger.warn(`2FA disable failed for user ${user.id}: 2FA not enabled`); + return res.status(400).json({ + error: "Operation failed", + }); + } + + await user.disableTwoFactor(); + + // Send notification email + try { + await emailServices.auth.sendTwoFactorDisabledEmail(user); + } catch (emailError) { + logger.error("Failed to send 2FA disabled email:", emailError); + } + + auditLog(req, '2fa.disabled', user.id); + + res.json({ + message: "Multi-factor authentication has been disabled", + }); + } catch (error) { + logger.error("2FA disable error:", error); + res.status(500).json({ error: "Failed to disable multi-factor authentication" }); + } + } +); + +/** + * POST /api/2fa/recovery/regenerate + * Generate new recovery codes (requires step-up authentication) + */ +router.post( + "/recovery/regenerate", + csrfProtection, + requireStepUpAuth("recovery_regenerate"), + async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (!user.twoFactorEnabled) { + logger.warn(`Recovery regenerate failed for user ${user.id}: 2FA not enabled`); + return res.status(400).json({ + error: "Operation failed", + }); + } + + const recoveryCodes = await user.regenerateRecoveryCodes(); + + auditLog(req, '2fa.recovery.regenerated', user.id); + + res.json({ + recoveryCodes, + warning: + "Save these recovery codes in a secure location. Your previous codes are now invalid.", + }); + } catch (error) { + logger.error("Recovery code regeneration error:", error); + res.status(500).json({ error: "Failed to regenerate recovery codes" }); + } + } +); + +/** + * GET /api/2fa/recovery/remaining + * Get recovery codes status (not exact count for security) + */ +router.get("/recovery/remaining", async (req, res) => { + try { + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const remaining = user.getRemainingRecoveryCodes(); + res.json({ + hasRecoveryCodes: remaining > 0, + lowRecoveryCodes: remaining <= 2, + }); + } catch (error) { + logger.error("Recovery codes remaining error:", error); + res.status(500).json({ error: "Failed to get recovery codes status" }); + } +}); + +module.exports = router; diff --git a/backend/routes/users.js b/backend/routes/users.js index aece226..11a570c 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,9 +1,12 @@ const express = require('express'); const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations const { authenticateToken, optionalAuth, requireAdmin } = require('../middleware/auth'); -const { validateCoordinatesBody, handleValidationErrors } = require('../middleware/validation'); +const { validateCoordinatesBody, validatePasswordChange, handleValidationErrors, sanitizeInput } = require('../middleware/validation'); +const { requireStepUpAuth } = require('../middleware/stepUpAuth'); +const { csrfProtection } = require('../middleware/csrf'); const logger = require('../utils/logger'); const userService = require('../services/UserService'); +const emailServices = require('../services/email'); const { validateS3Keys } = require('../utils/s3KeyValidator'); const { IMAGE_LIMITS } = require('../config/imageLimits'); const router = express.Router(); @@ -362,6 +365,58 @@ router.post('/admin/:id/ban', authenticateToken, requireAdmin, async (req, res, } }); +// Change password (requires step-up auth if 2FA is enabled) +router.put('/password', authenticateToken, csrfProtection, requireStepUpAuth('password_change'), sanitizeInput, validatePasswordChange, async (req, res, next) => { + try { + const { currentPassword, newPassword } = req.body; + const user = await User.findByPk(req.user.id); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Google OAuth users can't change password + if (user.authProvider === 'google' && !user.password) { + return res.status(400).json({ + error: 'Cannot change password for accounts linked with Google' + }); + } + + // Verify current password + const isValid = await user.comparePassword(currentPassword); + if (!isValid) { + return res.status(400).json({ error: 'Current password is incorrect' }); + } + + // Update password (this increments jwtVersion to invalidate other sessions) + await user.resetPassword(newPassword); + + // Send password changed notification + try { + await emailServices.auth.sendPasswordChangedEmail(user); + } catch (emailError) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error('Failed to send password changed email', { + error: emailError.message, + userId: req.user.id + }); + } + + const reqLogger = logger.withRequestId(req.id); + reqLogger.info('Password changed successfully', { userId: req.user.id }); + + res.json({ message: 'Password changed successfully' }); + } catch (error) { + const reqLogger = logger.withRequestId(req.id); + reqLogger.error('Password change failed', { + error: error.message, + stack: error.stack, + userId: req.user.id + }); + next(error); + } +}); + // Admin: Unban a user router.post('/admin/:id/unban', authenticateToken, requireAdmin, async (req, res, next) => { try { diff --git a/backend/server.js b/backend/server.js index 1fd3894..59c4233 100644 --- a/backend/server.js +++ b/backend/server.js @@ -31,6 +31,7 @@ const conditionCheckRoutes = require("./routes/conditionChecks"); const feedbackRoutes = require("./routes/feedback"); const uploadRoutes = require("./routes/upload"); const healthRoutes = require("./routes/health"); +const twoFactorRoutes = require("./routes/twoFactor"); const emailServices = require("./services/email"); const s3Service = require("./services/s3Service"); @@ -152,6 +153,7 @@ app.get("/", (req, res) => { // Public routes (no alpha access required) app.use("/api/alpha", alphaRoutes); app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration +app.use("/api/2fa", twoFactorRoutes); // 2FA routes require authentication (handled in router) // Protected routes (require alpha access) app.use("/api/users", requireAlphaAccess, userRoutes); diff --git a/backend/services/TwoFactorService.js b/backend/services/TwoFactorService.js new file mode 100644 index 0000000..83c93b4 --- /dev/null +++ b/backend/services/TwoFactorService.js @@ -0,0 +1,305 @@ +const crypto = require("crypto"); +const { authenticator } = require("otplib"); +const QRCode = require("qrcode"); +const bcrypt = require("bcryptjs"); +const logger = require("../utils/logger"); + +// Configuration +const TOTP_ISSUER = process.env.TOTP_ISSUER || "VillageShare"; +const EMAIL_OTP_EXPIRY_MINUTES = parseInt( + process.env.TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES || "10", + 10 +); +const STEP_UP_VALIDITY_MINUTES = parseInt( + process.env.TWO_FACTOR_STEP_UP_VALIDITY_MINUTES || "5", + 10 +); +const MAX_EMAIL_OTP_ATTEMPTS = 3; +const RECOVERY_CODE_COUNT = 10; +const BCRYPT_ROUNDS = 12; + +// Characters for recovery codes (excludes confusing chars: 0, O, 1, I, L) +const RECOVERY_CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + +class TwoFactorService { + /** + * Generate a new TOTP secret and QR code for setup + * @param {string} email - User's email address + * @returns {Promise<{qrCodeDataUrl: string, encryptedSecret: string, encryptedSecretIv: string}>} + */ + static async generateTotpSecret(email) { + const secret = authenticator.generateSecret(); + const otpAuthUrl = authenticator.keyuri(email, TOTP_ISSUER, secret); + + // Generate QR code as data URL + const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl); + + // Encrypt the secret for storage + const { encrypted, iv } = this._encryptSecret(secret); + + return { + qrCodeDataUrl, + encryptedSecret: encrypted, + encryptedSecretIv: iv, + }; + } + + /** + * Verify a TOTP code against a user's secret + * @param {string} encryptedSecret - Encrypted TOTP secret + * @param {string} iv - Initialization vector for decryption + * @param {string} code - 6-digit TOTP code to verify + * @returns {boolean} + */ + static verifyTotpCode(encryptedSecret, iv, code) { + try { + // Validate code format + if (!/^\d{6}$/.test(code)) { + return false; + } + + // Decrypt the secret + const secret = this._decryptSecret(encryptedSecret, iv); + + // Verify with window 0 (only current 30-second period) to prevent replay attacks + return authenticator.verify({ token: code, secret, window: 0 }); + } catch (error) { + logger.error("TOTP verification error:", error); + return false; + } + } + + /** + * Generate a 6-digit email OTP code + * @returns {{code: string, hashedCode: string, expiry: Date}} + */ + static generateEmailOtp() { + // Generate 6-digit numeric code + const code = crypto.randomInt(100000, 999999).toString(); + + // Hash the code for storage (SHA-256) + const hashedCode = crypto.createHash("sha256").update(code).digest("hex"); + + // Calculate expiry + const expiry = new Date(Date.now() + EMAIL_OTP_EXPIRY_MINUTES * 60 * 1000); + + return { code, hashedCode, expiry }; + } + + /** + * Verify an email OTP code using timing-safe comparison + * @param {string} inputCode - Code entered by user + * @param {string} storedHash - Hashed code stored in database + * @param {Date} expiry - Expiry timestamp + * @returns {boolean} + */ + static verifyEmailOtp(inputCode, storedHash, expiry) { + try { + // Validate code format + if (!/^\d{6}$/.test(inputCode)) { + return false; + } + + // Check expiry + if (!expiry || new Date() > new Date(expiry)) { + return false; + } + + // Hash the input code + const inputHash = crypto + .createHash("sha256") + .update(inputCode) + .digest("hex"); + + // Timing-safe comparison + const inputBuffer = Buffer.from(inputHash, "hex"); + const storedBuffer = Buffer.from(storedHash, "hex"); + + if (inputBuffer.length !== storedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(inputBuffer, storedBuffer); + } catch (error) { + logger.error("Email OTP verification error:", error); + return false; + } + } + + /** + * Generate recovery codes (10 codes in XXXX-XXXX format) + * @returns {Promise<{codes: string[], hashedCodes: string[]}>} + */ + static async generateRecoveryCodes() { + const codes = []; + const hashedCodes = []; + + for (let i = 0; i < RECOVERY_CODE_COUNT; i++) { + // Generate code in XXXX-XXXX format + let code = ""; + for (let j = 0; j < 8; j++) { + if (j === 4) code += "-"; + code += + RECOVERY_CODE_CHARS[crypto.randomInt(RECOVERY_CODE_CHARS.length)]; + } + codes.push(code); + + // Hash the code for storage + const hashedCode = await bcrypt.hash(code, BCRYPT_ROUNDS); + hashedCodes.push(hashedCode); + } + + return { codes, hashedCodes }; + } + + /** + * Verify a recovery code and return the index if valid + * @param {string} inputCode - Recovery code entered by user + * @param {Object} recoveryData - Recovery codes data (structured format) + * @returns {Promise<{valid: boolean, index: number}>} + */ + static async verifyRecoveryCode(inputCode, recoveryData) { + // Normalize input (uppercase, ensure format) + const normalizedCode = inputCode.toUpperCase().trim(); + + // Validate format + if (!/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalizedCode)) { + return { valid: false, index: -1 }; + } + + // Handle both old format (array) and new format (structured object) + const codes = recoveryData.version + ? recoveryData.codes + : recoveryData.map((hash, i) => ({ + hash, + used: hash === null, + index: i, + })); + + // Check each code + for (let i = 0; i < codes.length; i++) { + const codeEntry = codes[i]; + + // Skip already used codes + if (codeEntry.used || !codeEntry.hash) continue; + + const isMatch = await bcrypt.compare(normalizedCode, codeEntry.hash); + if (isMatch) { + return { valid: true, index: i }; + } + } + + return { valid: false, index: -1 }; + } + + /** + * Validate if a step-up session is still valid + * @param {Object} user - User object with twoFactorVerifiedAt field + * @param {number} maxAgeMinutes - Maximum age in minutes (default: 15) + * @returns {boolean} + */ + static validateStepUpSession(user, maxAgeMinutes = STEP_UP_VALIDITY_MINUTES) { + if (!user.twoFactorVerifiedAt) { + return false; + } + + const verifiedAt = new Date(user.twoFactorVerifiedAt); + const maxAge = maxAgeMinutes * 60 * 1000; + const now = Date.now(); + + return now - verifiedAt.getTime() < maxAge; + } + + /** + * Get the count of remaining recovery codes + * @param {Object|Array} recoveryData - Recovery codes data (structured or legacy format) + * @returns {number} + */ + static getRemainingRecoveryCodesCount(recoveryData) { + if (!recoveryData) { + return 0; + } + + // Handle new structured format + if (recoveryData.version) { + return recoveryData.codes.filter((code) => !code.used).length; + } + + // Handle legacy array format + if (Array.isArray(recoveryData)) { + return recoveryData.filter((code) => code !== null && code !== "").length; + } + + return 0; + } + + /** + * Encrypt a TOTP secret using AES-256-GCM + * @param {string} secret - Plain text secret + * @returns {{encrypted: string, iv: string}} + * @private + */ + static _encryptSecret(secret) { + const encryptionKey = process.env.TOTP_ENCRYPTION_KEY; + if (!encryptionKey || encryptionKey.length !== 64) { + throw new Error( + "TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)" + ); + } + + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + "aes-256-gcm", + Buffer.from(encryptionKey, "hex"), + iv + ); + + let encrypted = cipher.update(secret, "utf8", "hex"); + encrypted += cipher.final("hex"); + const authTag = cipher.getAuthTag().toString("hex"); + + return { + encrypted: encrypted + ":" + authTag, + iv: iv.toString("hex"), + }; + } + + /** + * Decrypt a TOTP secret using AES-256-GCM + * @param {string} encryptedData - Encrypted data with auth tag + * @param {string} iv - Initialization vector (hex) + * @returns {string} - Decrypted secret + * @private + */ + static _decryptSecret(encryptedData, iv) { + const encryptionKey = process.env.TOTP_ENCRYPTION_KEY; + if (!encryptionKey || encryptionKey.length !== 64) { + throw new Error( + "TOTP_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)" + ); + } + + const [ciphertext, authTag] = encryptedData.split(":"); + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + Buffer.from(encryptionKey, "hex"), + Buffer.from(iv, "hex") + ); + decipher.setAuthTag(Buffer.from(authTag, "hex")); + + let decrypted = decipher.update(ciphertext, "hex", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; + } + + /** + * Check if email OTP attempts are locked + * @param {number} attempts - Current attempt count + * @returns {boolean} + */ + static isEmailOtpLocked(attempts) { + return attempts >= MAX_EMAIL_OTP_ATTEMPTS; + } +} + +module.exports = TwoFactorService; diff --git a/backend/services/email/domain/AuthEmailService.js b/backend/services/email/domain/AuthEmailService.js index 4f0b64d..79635db 100644 --- a/backend/services/email/domain/AuthEmailService.js +++ b/backend/services/email/domain/AuthEmailService.js @@ -167,6 +167,150 @@ class AuthEmailService { htmlContent ); } + + /** + * Send two-factor authentication OTP email + * @param {Object} user - User object + * @param {string} user.firstName - User's first name + * @param {string} user.email - User's email address + * @param {string} otpCode - 6-digit OTP code + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendTwoFactorOtpEmail(user, otpCode) { + if (!this.initialized) { + await this.initialize(); + } + + const variables = { + recipientName: user.firstName || "there", + otpCode: otpCode, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "twoFactorOtpToUser", + variables + ); + + return await this.emailClient.sendEmail( + user.email, + "Your Verification Code - Village Share", + htmlContent + ); + } + + /** + * Send two-factor authentication enabled confirmation email + * @param {Object} user - User object + * @param {string} user.firstName - User's first name + * @param {string} user.email - User's email address + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendTwoFactorEnabledEmail(user) { + if (!this.initialized) { + await this.initialize(); + } + + const timestamp = new Date().toLocaleString("en-US", { + dateStyle: "long", + timeStyle: "short", + }); + + const variables = { + recipientName: user.firstName || "there", + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "twoFactorEnabledToUser", + variables + ); + + return await this.emailClient.sendEmail( + user.email, + "Multi-Factor Authentication Enabled - Village Share", + htmlContent + ); + } + + /** + * Send two-factor authentication disabled notification email + * @param {Object} user - User object + * @param {string} user.firstName - User's first name + * @param {string} user.email - User's email address + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendTwoFactorDisabledEmail(user) { + if (!this.initialized) { + await this.initialize(); + } + + const timestamp = new Date().toLocaleString("en-US", { + dateStyle: "long", + timeStyle: "short", + }); + + const variables = { + recipientName: user.firstName || "there", + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "twoFactorDisabledToUser", + variables + ); + + return await this.emailClient.sendEmail( + user.email, + "Multi-Factor Authentication Disabled - Village Share", + htmlContent + ); + } + + /** + * Send recovery code used notification email + * @param {Object} user - User object + * @param {string} user.firstName - User's first name + * @param {string} user.email - User's email address + * @param {number} remainingCodes - Number of remaining recovery codes + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendRecoveryCodeUsedEmail(user, remainingCodes) { + if (!this.initialized) { + await this.initialize(); + } + + const timestamp = new Date().toLocaleString("en-US", { + dateStyle: "long", + timeStyle: "short", + }); + + // Determine color based on remaining codes + let remainingCodesColor = "#28a745"; // Green + if (remainingCodes <= 2) { + remainingCodesColor = "#dc3545"; // Red + } else if (remainingCodes <= 5) { + remainingCodesColor = "#fd7e14"; // Orange + } + + const variables = { + recipientName: user.firstName || "there", + remainingCodes: remainingCodes, + remainingCodesColor: remainingCodesColor, + lowCodesWarning: remainingCodes <= 2, + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "recoveryCodeUsedToUser", + variables + ); + + return await this.emailClient.sendEmail( + user.email, + "Recovery Code Used - Village Share", + htmlContent + ); + } } module.exports = AuthEmailService; diff --git a/backend/templates/emails/passwordChangedToUser.html b/backend/templates/emails/passwordChangedToUser.html index d9f17e9..6a3bd47 100644 --- a/backend/templates/emails/passwordChangedToUser.html +++ b/backend/templates/emails/passwordChangedToUser.html @@ -255,7 +255,7 @@

Security reminder: Keep your password secure and never share it with anyone. We recommend using a strong, unique - password and enabling two-factor authentication when available. + password and enabling multi-factor authentication when available.

diff --git a/backend/templates/emails/recoveryCodeUsedToUser.html b/backend/templates/emails/recoveryCodeUsedToUser.html new file mode 100644 index 0000000..f34c864 --- /dev/null +++ b/backend/templates/emails/recoveryCodeUsedToUser.html @@ -0,0 +1,232 @@ + + + + + + + Recovery Code Used + + + +
+
+ +
Security Notice
+
+ +
+

Hi {{recipientName}},

+ +

Recovery Code Used

+ +
+

+ A recovery code was just used to verify your identity on your + Village Share account. +

+
+ +

+ Recovery codes are one-time use codes that allow you to access your + account when you don't have access to your authenticator app. +

+ +
+

+ Remaining recovery codes: +

+ {{remainingCodes}} + / 10 +
+ + {{#if lowCodesWarning}} +
+

+ Warning: You're running low on recovery codes! We + strongly recommend generating new recovery codes from your account + settings to avoid being locked out. +

+
+ {{/if}} + +
+

+ Didn't use a recovery code? If you didn't initiate + this action, someone may have access to your recovery codes. Please + secure your account immediately by: +

+
    +
  • Changing your password
  • +
  • Generating new recovery codes
  • +
  • Contacting our support team
  • +
+
+ +

+ Timestamp: {{timestamp}} +

+
+ + +
+ + diff --git a/backend/templates/emails/twoFactorDisabledToUser.html b/backend/templates/emails/twoFactorDisabledToUser.html new file mode 100644 index 0000000..d3a99d2 --- /dev/null +++ b/backend/templates/emails/twoFactorDisabledToUser.html @@ -0,0 +1,195 @@ + + + + + + + Multi-Factor Authentication Disabled + + + +
+
+ +
Security Alert
+
+ +
+

Hi {{recipientName}},

+ +

Multi-Factor Authentication Disabled

+ +
+

+ Important: Multi-factor authentication has been + disabled on your Village Share account. +

+
+ +

+ Your account no longer requires multi-factor verification for sensitive + actions. While this may be more convenient, your account is now less + protected against unauthorized access. +

+ +
+

+ We recommend keeping 2FA enabled to protect your + account, especially if you have payment methods saved or are + receiving payouts from rentals. +

+
+ +

+ Didn't disable this? If you didn't make this change, + your account may have been compromised. Please take the following + steps immediately: +

+ +
    +
  1. Change your password
  2. +
  3. Re-enable multi-factor authentication
  4. +
  5. Contact our support team
  6. +
+ +

+ Timestamp: {{timestamp}} +

+
+ + +
+ + diff --git a/backend/templates/emails/twoFactorEnabledToUser.html b/backend/templates/emails/twoFactorEnabledToUser.html new file mode 100644 index 0000000..6023bd7 --- /dev/null +++ b/backend/templates/emails/twoFactorEnabledToUser.html @@ -0,0 +1,195 @@ + + + + + + + Multi-Factor Authentication Enabled + + + +
+
+ +
Security Update
+
+ +
+

Hi {{recipientName}},

+ +

Multi-Factor Authentication Enabled

+ +
+

+ Great news! Multi-factor authentication has been + successfully enabled on your Village Share account. +

+
+ +

+ Your account is now more secure. From now on, you'll need to verify + your identity using your authenticator app or email when performing + sensitive actions like: +

+ + + +
+

+ Recovery Codes: Make sure you've saved your + recovery codes in a secure location. These codes can be used to + access your account if you lose access to your authenticator app. +

+
+ +

+ Didn't enable this? If you didn't make this change, + please contact our support team immediately and secure your account. +

+ +

+ Timestamp: {{timestamp}} +

+
+ + +
+ + diff --git a/backend/templates/emails/twoFactorOtpToUser.html b/backend/templates/emails/twoFactorOtpToUser.html new file mode 100644 index 0000000..0ba36cb --- /dev/null +++ b/backend/templates/emails/twoFactorOtpToUser.html @@ -0,0 +1,193 @@ + + + + + + + Your Verification Code + + + +
+
+ +
Security Verification
+
+ +
+

Hi {{recipientName}},

+ +

Your Verification Code

+ +

+ You requested a verification code to complete a secure action on your + Village Share account. +

+ +
+

+ Your verification code is: +

+
+ {{otpCode}} +
+

+ Enter this code to verify your identity +

+
+ +
+

+ This code will expire in 10 minutes. If you didn't + request this code, please secure your account immediately. +

+
+ +

+ Didn't request this code? If you didn't initiate this + request, someone may be trying to access your account. We recommend + changing your password immediately. +

+
+ + +
+ + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd159f7..b2d35fb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import { SocketProvider } from './contexts/SocketContext'; @@ -7,6 +7,7 @@ import Footer from './components/Footer'; import AuthModal from './components/AuthModal'; import AlphaGate from './components/AlphaGate'; import FeedbackButton from './components/FeedbackButton'; +import { TwoFactorVerifyModal } from './components/TwoFactor'; import Home from './pages/Home'; import GoogleCallback from './pages/GoogleCallback'; import VerifyEmail from './pages/VerifyEmail'; @@ -40,6 +41,39 @@ const AppContent: React.FC = () => { const [hasAlphaAccess, setHasAlphaAccess] = useState(null); const [checkingAccess, setCheckingAccess] = useState(true); + // Step-up authentication state + const [showStepUpModal, setShowStepUpModal] = useState(false); + const [stepUpAction, setStepUpAction] = useState(); + const [stepUpMethods, setStepUpMethods] = useState<("totp" | "email" | "recovery")[]>([]); + + // Listen for step-up authentication required events + useEffect(() => { + const handleStepUpRequired = (event: CustomEvent) => { + const { action, methods } = event.detail; + setStepUpAction(action); + setStepUpMethods(methods || ["totp", "email", "recovery"]); + setShowStepUpModal(true); + }; + + window.addEventListener("stepUpRequired", handleStepUpRequired as EventListener); + + return () => { + window.removeEventListener("stepUpRequired", handleStepUpRequired as EventListener); + }; + }, []); + + const handleStepUpSuccess = useCallback(() => { + setShowStepUpModal(false); + setStepUpAction(undefined); + // Dispatch event so pending actions can auto-retry + window.dispatchEvent(new CustomEvent("stepUpSuccess")); + }, []); + + const handleStepUpClose = useCallback(() => { + setShowStepUpModal(false); + setStepUpAction(undefined); + }, []); + useEffect(() => { const checkAlphaAccess = async () => { // Bypass alpha access check if feature is disabled @@ -209,6 +243,15 @@ const AppContent: React.FC = () => { {/* Show feedback button for authenticated users */} {user && } + + {/* Global Step-Up Authentication Modal */} + ); }; diff --git a/frontend/src/components/AuthModal.tsx b/frontend/src/components/AuthModal.tsx index cf68d95..1ac40da 100644 --- a/frontend/src/components/AuthModal.tsx +++ b/frontend/src/components/AuthModal.tsx @@ -4,6 +4,7 @@ import PasswordStrengthMeter from "./PasswordStrengthMeter"; import PasswordInput from "./PasswordInput"; import ForgotPasswordModal from "./ForgotPasswordModal"; import VerificationCodeModal from "./VerificationCodeModal"; +import { validatePassword } from "../utils/passwordValidation"; interface AuthModalProps { show: boolean; @@ -143,6 +144,15 @@ const AuthModal: React.FC = ({ return; } + // Password validation for signup + if (mode === "signup") { + const passwordError = validatePassword(password); + if (passwordError) { + setError(passwordError); + return; + } + } + setLoading(true); try { diff --git a/frontend/src/components/PasswordStrengthMeter.tsx b/frontend/src/components/PasswordStrengthMeter.tsx index e5ba2aa..f7aeeaa 100644 --- a/frontend/src/components/PasswordStrengthMeter.tsx +++ b/frontend/src/components/PasswordStrengthMeter.tsx @@ -1,47 +1,19 @@ import React from "react"; +import { PASSWORD_REQUIREMENTS, COMMON_PASSWORDS } from "../utils/passwordValidation"; interface PasswordStrengthMeterProps { password: string; showRequirements?: boolean; } -interface PasswordRequirement { - regex: RegExp; - text: string; - met: boolean; -} - const PasswordStrengthMeter: React.FC = ({ password, showRequirements = true, }) => { - const requirements: PasswordRequirement[] = [ - { - regex: /.{8,}/, - text: "At least 8 characters", - met: /.{8,}/.test(password), - }, - { - regex: /[a-z]/, - text: "One lowercase letter", - met: /[a-z]/.test(password), - }, - { - regex: /[A-Z]/, - text: "One uppercase letter", - met: /[A-Z]/.test(password), - }, - { - regex: /\d/, - text: "One number", - met: /\d/.test(password), - }, - { - regex: /[-@$!%*?&#^]/, - text: "One special character (-@$!%*?&#^)", - met: /[-@$!%*?&#^]/.test(password), - }, - ]; + const requirements = PASSWORD_REQUIREMENTS.map((req) => ({ + text: req.text, + met: req.regex.test(password), + })); const getPasswordStrength = (): { score: number; @@ -51,16 +23,8 @@ const PasswordStrengthMeter: React.FC = ({ if (!password) return { score: 0, label: "", color: "" }; const metRequirements = requirements.filter((req) => req.met).length; - const hasCommonPassword = [ - "password", - "123456", - "123456789", - "qwerty", - "abc123", - "password123", - ].includes(password.toLowerCase()); - if (hasCommonPassword) { + if (COMMON_PASSWORDS.includes(password.toLowerCase())) { return { score: 0, label: "Too Common", color: "danger" }; } diff --git a/frontend/src/components/TwoFactor/RecoveryCodesDisplay.tsx b/frontend/src/components/TwoFactor/RecoveryCodesDisplay.tsx new file mode 100644 index 0000000..e9e3c8d --- /dev/null +++ b/frontend/src/components/TwoFactor/RecoveryCodesDisplay.tsx @@ -0,0 +1,166 @@ +import React, { useState } from "react"; + +interface RecoveryCodesDisplayProps { + codes: string[]; + onAcknowledge?: () => void; + showAcknowledgeButton?: boolean; +} + +const RecoveryCodesDisplay: React.FC = ({ + codes, + onAcknowledge, + showAcknowledgeButton = true, +}) => { + const [acknowledged, setAcknowledged] = useState(false); + const [copied, setCopied] = useState(false); + + const handleCopyAll = async () => { + const codesText = codes.join("\n"); + try { + await navigator.clipboard.writeText(codesText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy codes:", err); + } + }; + + const handleDownload = () => { + if ( + !confirm( + "Warning: This will create an unencrypted file on your device. " + + "Consider using a password manager instead. Continue?" + ) + ) { + return; + } + const codesText = `Village Share Recovery Codes\n${"=".repeat(30)}\n\nSave these codes in a secure location.\nEach code can only be used once.\n\n${codes.join("\n")}\n\nGenerated: ${new Date().toLocaleString()}`; + const blob = new Blob([codesText], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "village-share-recovery-codes.txt"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handlePrint = () => { + if ( + !confirm( + "Warning: Printed documents can be easily compromised. " + + "Consider using a password manager instead. Continue?" + ) + ) { + return; + } + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(` + + + Recovery Codes - Village Share + + + +

Village Share Recovery Codes

+

Save these codes in a secure location. Each code can only be used once.

+
+ ${codes.map((code) => `
${code}
`).join("")} +
+
+ Warning: These codes will not be shown again. Store them securely. +
+

Generated: ${new Date().toLocaleString()}

+ + + `); + printWindow.document.close(); + printWindow.print(); + } + }; + + return ( +
+
+ + Important: Save these recovery codes in a secure + location. You will not be able to see them again. +
+ +
+ {codes.map((code, index) => ( +
+
+ {code} +
+
+ ))} +
+ +
+ + + +
+ + {showAcknowledgeButton && ( + <> +
+ setAcknowledged(e.target.checked)} + /> + +
+ + + + )} +
+ ); +}; + +export default RecoveryCodesDisplay; diff --git a/frontend/src/components/TwoFactor/TwoFactorManagement.tsx b/frontend/src/components/TwoFactor/TwoFactorManagement.tsx new file mode 100644 index 0000000..5bba3d7 --- /dev/null +++ b/frontend/src/components/TwoFactor/TwoFactorManagement.tsx @@ -0,0 +1,240 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { twoFactorAPI } from "../../services/api"; +import { TwoFactorStatus } from "../../types"; +import TwoFactorSetupModal from "./TwoFactorSetupModal"; +import TwoFactorVerifyModal from "./TwoFactorVerifyModal"; +import RecoveryCodesDisplay from "./RecoveryCodesDisplay"; + +const TwoFactorManagement: React.FC = () => { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Modal states + const [showSetupModal, setShowSetupModal] = useState(false); + const [showVerifyModal, setShowVerifyModal] = useState(false); + const [showRecoveryCodes, setShowRecoveryCodes] = useState(false); + const [newRecoveryCodes, setNewRecoveryCodes] = useState([]); + const [pendingAction, setPendingAction] = useState< + "disable" | "regenerate" | null + >(null); + + const fetchStatus = useCallback(async () => { + try { + const response = await twoFactorAPI.getStatus(); + setStatus(response.data); + } catch (err: any) { + setError(err.response?.data?.error || "Failed to load 2FA status"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + const handleSetupSuccess = () => { + fetchStatus(); + }; + + const handleDisable = () => { + setPendingAction("disable"); + setShowVerifyModal(true); + }; + + const handleRegenerateRecoveryCodes = () => { + setPendingAction("regenerate"); + setShowVerifyModal(true); + }; + + const handleVerifySuccess = async () => { + setShowVerifyModal(false); + + if (pendingAction === "disable") { + try { + await twoFactorAPI.disable(); + fetchStatus(); + } catch (err: any) { + setError(err.response?.data?.error || "Failed to disable 2FA"); + } + } else if (pendingAction === "regenerate") { + try { + const response = await twoFactorAPI.regenerateRecoveryCodes(); + setNewRecoveryCodes(response.data.recoveryCodes); + setShowRecoveryCodes(true); + fetchStatus(); + } catch (err: any) { + setError( + err.response?.data?.error || "Failed to regenerate recovery codes" + ); + } + } + + setPendingAction(null); + }; + + if (loading) { + return ( +
+
+ Loading... +
+
+ ); + } + + return ( +
+
+ + Multi-Factor Authentication +
+ + {error && ( +
+ + {error} + +
+ )} + + {status?.enabled ? ( +
+
+
+
+ + + Enabled + +
+
+ + {status.method === "totp" + ? "Authenticator App" + : "Email Verification"} + +
+
+ +
+ +
+
+
+ Recovery Codes +
+ {status.hasRecoveryCodes + ? status.lowRecoveryCodes + ? "Running low" + : "Available" + : "None remaining"} +
+
+ +
+ + {status.lowRecoveryCodes && ( +
+ + You're running low on recovery codes. Consider regenerating + new ones. +
+ )} +
+ +
+ + +
+
+ ) : ( +
+
+

+ Multi-Factor Authentication (MFA) adds an extra layer of security + to your account. When enabled, you'll need to do an additional + verficiation step when performing sensitive actions like changing + your password. +

+ + +
+
+ )} + + {/* Setup Modal */} + setShowSetupModal(false)} + onSuccess={handleSetupSuccess} + /> + + {/* Verify Modal for disable/regenerate */} + { + setShowVerifyModal(false); + setPendingAction(null); + }} + onSuccess={handleVerifySuccess} + action={ + pendingAction === "disable" ? "2fa_disable" : "recovery_regenerate" + } + methods={ + status?.method === "totp" + ? ["totp", "email", "recovery"] + : ["email", "recovery"] + } + /> + + {/* Recovery Codes Display Modal */} + {showRecoveryCodes && ( +
+
+
+
+
New Recovery Codes
+
+
+ { + setShowRecoveryCodes(false); + setNewRecoveryCodes([]); + }} + showAcknowledgeButton={true} + /> +
+
+
+
+ )} +
+ ); +}; + +export default TwoFactorManagement; diff --git a/frontend/src/components/TwoFactor/TwoFactorSetupModal.tsx b/frontend/src/components/TwoFactor/TwoFactorSetupModal.tsx new file mode 100644 index 0000000..ffe39ae --- /dev/null +++ b/frontend/src/components/TwoFactor/TwoFactorSetupModal.tsx @@ -0,0 +1,384 @@ +import React, { useState } from "react"; +import { twoFactorAPI } from "../../services/api"; +import RecoveryCodesDisplay from "./RecoveryCodesDisplay"; + +interface TwoFactorSetupModalProps { + show: boolean; + onHide: () => void; + onSuccess: () => void; +} + +type SetupStep = + | "choose" + | "totp-qr" + | "totp-verify" + | "email-verify" + | "recovery-codes"; + +const TwoFactorSetupModal: React.FC = ({ + show, + onHide, + onSuccess, +}) => { + const [step, setStep] = useState("choose"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // TOTP setup state + const [qrCodeDataUrl, setQrCodeDataUrl] = useState(""); + const [verificationCode, setVerificationCode] = useState(""); + + // Recovery codes + const [recoveryCodes, setRecoveryCodes] = useState([]); + + const resetState = () => { + setStep("choose"); + setLoading(false); + setError(null); + setQrCodeDataUrl(""); + setVerificationCode(""); + setRecoveryCodes([]); + }; + + const handleClose = () => { + resetState(); + onHide(); + }; + + const handleInitTotp = async () => { + setLoading(true); + setError(null); + try { + const response = await twoFactorAPI.initTotpSetup(); + setQrCodeDataUrl(response.data.qrCodeDataUrl); + setStep("totp-qr"); + } catch (err: any) { + setError(err.response?.data?.error || "Failed to initialize TOTP setup"); + } finally { + setLoading(false); + } + }; + + const handleVerifyTotp = async () => { + if (verificationCode.length !== 6) { + setError("Please enter a 6-digit code"); + return; + } + + setLoading(true); + setError(null); + try { + const response = await twoFactorAPI.verifyTotpSetup(verificationCode); + setRecoveryCodes(response.data.recoveryCodes); + setStep("recovery-codes"); + } catch (err: any) { + setError(err.response?.data?.error || "Invalid verification code"); + } finally { + setLoading(false); + } + }; + + const handleInitEmailSetup = async () => { + setLoading(true); + setError(null); + try { + await twoFactorAPI.initEmailSetup(); + setStep("email-verify"); + } catch (err: any) { + setError( + err.response?.data?.error || "Failed to send verification email" + ); + } finally { + setLoading(false); + } + }; + + const handleVerifyEmail = async () => { + if (verificationCode.length !== 6) { + setError("Please enter a 6-digit code"); + return; + } + + setLoading(true); + setError(null); + try { + const response = await twoFactorAPI.verifyEmailSetup(verificationCode); + setRecoveryCodes(response.data.recoveryCodes); + setStep("recovery-codes"); + } catch (err: any) { + setError(err.response?.data?.error || "Invalid verification code"); + } finally { + setLoading(false); + } + }; + + const handleComplete = () => { + handleClose(); + onSuccess(); + }; + + const handleCodeChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/\D/g, "").slice(0, 6); + setVerificationCode(value); + setError(null); + }; + + if (!show) return null; + + const renderChooseMethod = () => ( + <> +
+
Set Up MFA
+ +
+
+
+ + + +
+ + {error && ( +
+ + {error} +
+ )} +
+ + ); + + const renderTotpQR = () => ( + <> +
+
Scan QR Code
+ +
+
+

+ Scan this QR code with your authenticator app to add Village Share. +

+ +
+ {qrCodeDataUrl && ( + QR Code for authenticator app + )} +
+ + +
+ + ); + + const renderTotpVerify = () => ( + <> +
+
Enter Verification Code
+ +
+
+

+ Enter the 6-digit code from your authenticator app to verify the + setup. +

+ +
+ + {error &&
{error}
} +
+ +
+ + +
+
+ + ); + + const renderEmailVerify = () => ( + <> +
+
Check Your Email
+ +
+
+
+ +
+ +

+ We've sent a 6-digit verification code to your email address. Enter it + below to complete setup. +

+ +
+ + {error &&
{error}
} +
+ +
+ + +
+
+ + ); + + const renderRecoveryCodes = () => ( + <> +
+
Save Your Recovery Codes
+
+
+ +
+ + ); + + return ( +
+
+
+ {step === "choose" && renderChooseMethod()} + {step === "totp-qr" && renderTotpQR()} + {step === "totp-verify" && renderTotpVerify()} + {step === "email-verify" && renderEmailVerify()} + {step === "recovery-codes" && renderRecoveryCodes()} +
+
+
+ ); +}; + +export default TwoFactorSetupModal; diff --git a/frontend/src/components/TwoFactor/TwoFactorVerifyModal.tsx b/frontend/src/components/TwoFactor/TwoFactorVerifyModal.tsx new file mode 100644 index 0000000..d0df991 --- /dev/null +++ b/frontend/src/components/TwoFactor/TwoFactorVerifyModal.tsx @@ -0,0 +1,334 @@ +import React, { useState, useEffect } from "react"; +import { twoFactorAPI } from "../../services/api"; + +interface TwoFactorVerifyModalProps { + show: boolean; + onHide: () => void; + onSuccess: () => void; + action?: string; + methods?: ("totp" | "email" | "recovery")[]; +} + +const TwoFactorVerifyModal: React.FC = ({ + show, + onHide, + onSuccess, + action, + methods = ["totp", "email", "recovery"], +}) => { + const [activeTab, setActiveTab] = useState<"totp" | "email" | "recovery">( + methods[0] || "totp" + ); + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [emailSent, setEmailSent] = useState(false); + + // Reset state when modal opens/closes + useEffect(() => { + if (show) { + setCode(""); + setError(null); + setEmailSent(false); + setActiveTab(methods[0] || "totp"); + } + }, [show, methods]); + + const getActionDescription = (actionType?: string) => { + switch (actionType) { + case "password_change": + return "change your password"; + case "email_change": + return "change your email address"; + case "payout": + return "request a payout"; + case "account_delete": + return "delete your account"; + case "2fa_disable": + return "disable multi-factor authentication"; + case "recovery_regenerate": + return "regenerate recovery codes"; + default: + return "complete this action"; + } + }; + + const handleCodeChange = (e: React.ChangeEvent) => { + let value = e.target.value; + + if (activeTab === "recovery") { + // Format recovery code: XXXX-XXXX + value = value.toUpperCase().replace(/[^A-Z0-9-]/g, ""); + if (value.length > 4 && !value.includes("-")) { + value = value.slice(0, 4) + "-" + value.slice(4); + } + value = value.slice(0, 9); + } else { + // TOTP and email: 6 digits only + value = value.replace(/\D/g, "").slice(0, 6); + } + + setCode(value); + setError(null); + }; + + const handleSendEmailOtp = async () => { + setLoading(true); + setError(null); + try { + await twoFactorAPI.requestEmailOtp(); + setEmailSent(true); + } catch (err: any) { + setError(err.response?.data?.error || "Failed to send verification code"); + } finally { + setLoading(false); + } + }; + + const handleVerify = async () => { + setLoading(true); + setError(null); + + try { + let response; + + switch (activeTab) { + case "totp": + response = await twoFactorAPI.verifyTotp(code); + break; + case "email": + response = await twoFactorAPI.verifyEmailOtp(code); + break; + case "recovery": + response = await twoFactorAPI.verifyRecoveryCode(code); + break; + } + + onSuccess(); + onHide(); + } catch (err: any) { + setError(err.response?.data?.error || "Verification failed"); + } finally { + setLoading(false); + } + }; + + const isCodeValid = () => { + if (activeTab === "recovery") { + return /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code); + } + return code.length === 6; + }; + + if (!show) return null; + + const renderTotpTab = () => ( +
+

+ Enter the 6-digit code from your authenticator app. +

+ + +
+ ); + + const renderEmailTab = () => ( +
+ {!emailSent ? ( + <> +

+ We'll send a verification code to your email address. +

+ + + ) : ( + <> +
+ + Verification code sent to your email. +
+ + + + + + )} +
+ ); + + const renderRecoveryTab = () => ( +
+

+ Enter one of your recovery codes. Each code can only be used once. +

+ + +
+ ); + + return ( +
+
+
+
+
+ + Verify Your Identity +
+ +
+
+
+

+ Multi-factor authentication is required to {getActionDescription(action)}. +

+
+ +
    + {methods.includes("totp") && ( +
  • + +
  • + )} + {methods.includes("email") && ( +
  • + +
  • + )} + {methods.includes("recovery") && ( +
  • + +
  • + )} +
+ + {activeTab === "totp" && renderTotpTab()} + {activeTab === "email" && renderEmailTab()} + {activeTab === "recovery" && renderRecoveryTab()} + + {error && ( +
+
+ + {error} +
+
+ )} +
+
+ + {(activeTab !== "email" || emailSent) && ( + + )} +
+
+
+
+ ); +}; + +export default TwoFactorVerifyModal; diff --git a/frontend/src/components/TwoFactor/index.ts b/frontend/src/components/TwoFactor/index.ts new file mode 100644 index 0000000..8cff72c --- /dev/null +++ b/frontend/src/components/TwoFactor/index.ts @@ -0,0 +1,4 @@ +export { default as TwoFactorSetupModal } from "./TwoFactorSetupModal"; +export { default as TwoFactorVerifyModal } from "./TwoFactorVerifyModal"; +export { default as TwoFactorManagement } from "./TwoFactorManagement"; +export { default as RecoveryCodesDisplay } from "./RecoveryCodesDisplay"; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index dbcb835..71fd061 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../contexts/AuthContext"; import { userAPI, itemAPI, rentalAPI, addressAPI, conditionCheckAPI } from "../services/api"; @@ -10,6 +10,7 @@ import ReviewRenterModal from "../components/ReviewRenterModal"; import ReviewDetailsModal from "../components/ReviewDetailsModal"; import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal"; import Avatar from "../components/Avatar"; +import PasswordStrengthMeter from "../components/PasswordStrengthMeter"; import { geocodingService, AddressComponents, @@ -20,6 +21,8 @@ import { useAddressAutocomplete, usStates, } from "../hooks/useAddressAutocomplete"; +import { TwoFactorManagement } from "../components/TwoFactor"; +import { validatePassword } from "../utils/passwordValidation"; const Profile: React.FC = () => { const { user, updateUser, logout } = useAuth(); @@ -120,6 +123,56 @@ const Profile: React.FC = () => { const [showConditionCheckViewer, setShowConditionCheckViewer] = useState(false); const [selectedConditionCheck, setSelectedConditionCheck] = useState(null); + // Password change state + const [passwordFormData, setPasswordFormData] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + const [passwordLoading, setPasswordLoading] = useState(false); + const [passwordError, setPasswordError] = useState(null); + const [passwordSuccess, setPasswordSuccess] = useState(null); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [pendingPasswordChange, setPendingPasswordChange] = useState(false); + + // Listen for step-up auth success to retry pending password change + useEffect(() => { + const handleStepUpSuccess = () => { + if (pendingPasswordChange) { + setPendingPasswordChange(false); + // Retry the password change after successful step-up auth + const retryPasswordChange = async () => { + setPasswordLoading(true); + try { + await userAPI.changePassword(passwordFormData); + setPasswordSuccess("Password changed successfully"); + setPasswordFormData({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + } catch (err: any) { + const errorMessage = + err.response?.data?.error || + err.response?.data?.message || + "Failed to change password"; + setPasswordError(errorMessage); + } finally { + setPasswordLoading(false); + } + }; + retryPasswordChange(); + } + }; + + window.addEventListener("stepUpSuccess", handleStepUpSuccess); + return () => { + window.removeEventListener("stepUpSuccess", handleStepUpSuccess); + }; + }, [pendingPasswordChange, passwordFormData]); + useEffect(() => { fetchProfile(); fetchStats(); @@ -690,6 +743,82 @@ const Profile: React.FC = () => { // Use address autocomplete hook const { parsePlace } = useAddressAutocomplete(); + // Password change handlers + const handlePasswordChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setPasswordFormData((prev) => ({ ...prev, [name]: value })); + setPasswordError(null); + }; + + const isPasswordValid = useMemo(() => { + const { currentPassword, newPassword, confirmPassword } = passwordFormData; + const commonPasswords = ["password", "123456", "123456789", "qwerty", "abc123", "password123"]; + + return ( + currentPassword.length > 0 && + newPassword.length >= 8 && + /[a-z]/.test(newPassword) && + /[A-Z]/.test(newPassword) && + /\d/.test(newPassword) && + /[-@$!%*?&#^]/.test(newPassword) && + newPassword === confirmPassword && + newPassword !== currentPassword && + !commonPasswords.includes(newPassword.toLowerCase()) + ); + }, [passwordFormData]); + + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setPasswordError(null); + setPasswordSuccess(null); + + const newPassword = passwordFormData.newPassword; + + // Validate passwords match + if (newPassword !== passwordFormData.confirmPassword) { + setPasswordError("New passwords do not match"); + return; + } + + // Validate new password is different from current + if (newPassword === passwordFormData.currentPassword) { + setPasswordError("New password must be different from current password"); + return; + } + + // Validate password strength + const passwordError = validatePassword(newPassword); + if (passwordError) { + setPasswordError(passwordError); + return; + } + + setPasswordLoading(true); + + try { + await userAPI.changePassword(passwordFormData); + setPasswordSuccess("Password changed successfully"); + setPasswordFormData({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + } catch (err: any) { + // If step-up authentication is required, mark as pending and let modal handle it + if (err.response?.data?.code === "STEP_UP_REQUIRED") { + setPendingPasswordChange(true); + return; + } + const errorMessage = + err.response?.data?.error || + err.response?.data?.message || + "Failed to change password"; + setPasswordError(errorMessage); + } finally { + setPasswordLoading(false); + } + }; + // Handle place selection from autocomplete const handlePlaceSelect = useCallback( (place: PlaceDetails) => { @@ -775,6 +904,15 @@ const Profile: React.FC = () => { Rental History + + + )} + + {passwordSuccess && ( +
+ + {passwordSuccess} + +
+ )} + +
+
+ +
+ + +
+
+ +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+ + +
+ + + + {/* Multi-Factor Authentication Card */} +
+
+ +
+
+ + )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7240b8c..f5632c1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -81,9 +81,25 @@ api.interceptors.response.use( _csrfRetry?: boolean; }; - // Handle CSRF token errors + // Handle CSRF token errors and step-up authentication if (error.response?.status === 403) { const errorData = error.response?.data as any; + + // Handle step-up authentication required + if (errorData?.code === "STEP_UP_REQUIRED") { + // Emit custom event for step-up auth modal + window.dispatchEvent( + new CustomEvent("stepUpRequired", { + detail: { + action: errorData.action, + methods: errorData.methods, + originalRequest, + }, + }) + ); + return Promise.reject(error); + } + if ( errorData?.code === "CSRF_TOKEN_MISMATCH" && !originalRequest._csrfRetry @@ -184,12 +200,40 @@ export const userAPI = { getPublicProfile: (id: string) => api.get(`/users/${id}`), getAvailability: () => api.get("/users/availability"), updateAvailability: (data: any) => api.put("/users/availability", data), + changePassword: (data: { + currentPassword: string; + newPassword: string; + confirmPassword: string; + }) => api.put("/users/password", data), // Admin endpoints adminBanUser: (id: string, reason: string) => api.post(`/users/admin/${id}/ban`, { reason }), adminUnbanUser: (id: string) => api.post(`/users/admin/${id}/unban`), }; +export const twoFactorAPI = { + // Setup endpoints + initTotpSetup: () => api.post("/2fa/setup/totp/init"), + verifyTotpSetup: (code: string) => + api.post("/2fa/setup/totp/verify", { code }), + initEmailSetup: () => api.post("/2fa/setup/email/init"), + verifyEmailSetup: (code: string) => + api.post("/2fa/setup/email/verify", { code }), + + // Verification endpoints (step-up auth) + verifyTotp: (code: string) => api.post("/2fa/verify/totp", { code }), + requestEmailOtp: () => api.post("/2fa/verify/email/send"), + verifyEmailOtp: (code: string) => api.post("/2fa/verify/email", { code }), + verifyRecoveryCode: (code: string) => + api.post("/2fa/verify/recovery", { code }), + + // Management endpoints + getStatus: () => api.get("/2fa/status"), + disable: () => api.post("/2fa/disable"), + regenerateRecoveryCodes: () => api.post("/2fa/recovery/regenerate"), + getRemainingRecoveryCodes: () => api.get("/2fa/recovery/remaining"), +}; + export const addressAPI = { getAddresses: () => api.get("/users/addresses"), createAddress: (data: any) => api.post("/users/addresses", data), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e26cab5..c8a076f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -38,6 +38,34 @@ export interface User { bannedAt?: string; bannedBy?: string; banReason?: string; + // Two-Factor Authentication fields + twoFactorEnabled?: boolean; + twoFactorMethod?: "totp" | "email"; +} + +export interface TwoFactorStatus { + enabled: boolean; + method?: "totp" | "email"; + hasRecoveryCodes: boolean; + lowRecoveryCodes: boolean; +} + +export interface TwoFactorSetupResponse { + qrCodeDataUrl: string; + message: string; +} + +export interface TwoFactorVerifyResponse { + message: string; + recoveryCodes: string[]; + warning: string; +} + +export interface StepUpRequiredError { + error: string; + code: "STEP_UP_REQUIRED"; + action: string; + methods: ("totp" | "email" | "recovery")[]; } export interface Message { diff --git a/frontend/src/utils/passwordValidation.ts b/frontend/src/utils/passwordValidation.ts new file mode 100644 index 0000000..ed06786 --- /dev/null +++ b/frontend/src/utils/passwordValidation.ts @@ -0,0 +1,34 @@ +export const PASSWORD_REQUIREMENTS = [ + { regex: /.{8,}/, text: "At least 8 characters", message: "Password must be at least 8 characters long" }, + { regex: /[a-z]/, text: "One lowercase letter", message: "Password must contain at least one lowercase letter" }, + { regex: /[A-Z]/, text: "One uppercase letter", message: "Password must contain at least one uppercase letter" }, + { regex: /\d/, text: "One number", message: "Password must contain at least one number" }, + { regex: /[-@$!%*?&#^]/, text: "One special character (-@$!%*?&#^)", message: "Password must contain at least one special character (-@$!%*?&#^)" }, +]; + +export const COMMON_PASSWORDS = [ + "password", + "123456", + "123456789", + "qwerty", + "abc123", + "password123", +]; + +/** + * Validates password against all requirements + * @returns error message if invalid, null if valid + */ +export function validatePassword(password: string): string | null { + for (const req of PASSWORD_REQUIREMENTS) { + if (!req.regex.test(password)) { + return req.message; + } + } + + if (COMMON_PASSWORDS.includes(password.toLowerCase())) { + return "This password is too common. Please choose a stronger password"; + } + + return null; +}