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
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
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:
+
+
+
+ Change your password
+ Re-enable multi-factor authentication
+ Contact our support team
+
+
+
+ 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
+
+
+
+
+
+
+
+
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:
+
+
+
+ Changing your password
+ Updating your email address
+ Requesting payouts
+ Disabling multi-factor authentication
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
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) => (
+
+ ))}
+
+
+
+
+
+ {copied ? "Copied!" : "Copy All"}
+
+
+
+ Download
+
+
+
+ Print
+
+
+
+ {showAcknowledgeButton && (
+ <>
+
+ setAcknowledged(e.target.checked)}
+ />
+
+ I have saved my recovery codes in a secure location
+
+
+
+
+ Continue
+
+ >
+ )}
+
+ );
+};
+
+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 (
+
+ );
+ }
+
+ return (
+
+
+
+ Multi-Factor Authentication
+
+
+ {error && (
+
+
+ {error}
+ setError(null)}
+ >
+
+ )}
+
+ {status?.enabled ? (
+
+
+
+
+
+
+ Enabled
+
+
+
+
+ {status.method === "totp"
+ ? "Authenticator App"
+ : "Email Verification"}
+
+
+
+
+
+
+
+
+
+
Recovery Codes
+
+ {status.hasRecoveryCodes
+ ? status.lowRecoveryCodes
+ ? "Running low"
+ : "Available"
+ : "None remaining"}
+
+
+
+
+ Regenerate
+
+
+
+ {status.lowRecoveryCodes && (
+
+
+ You're running low on recovery codes. Consider regenerating
+ new ones.
+
+ )}
+
+
+
+
+
+
+ Disable MFA
+
+
+
+ ) : (
+
+
+
+ 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.
+
+
+
setShowSetupModal(true)}
+ >
+ Set Up MFA
+
+
+
+ )}
+
+ {/* 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
+
+
+
+
+
+
+
+
+ Authenticator App
+ Recommended
+
+
+ Use Google Authenticator, Authy, or similar app
+
+
+
+
+
+
+
+
+
+ Email Verification
+
+
+ Receive codes via email when verification is needed
+
+
+
+
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ >
+ );
+
+ const renderTotpQR = () => (
+ <>
+
+
Scan QR Code
+
+
+
+
+ Scan this QR code with your authenticator app to add Village Share.
+
+
+
+ {qrCodeDataUrl && (
+
+ )}
+
+
+
setStep("totp-verify")}
+ >
+ Continue
+
+
+ >
+ );
+
+ const renderTotpVerify = () => (
+ <>
+
+
Enter Verification Code
+
+
+
+
+ Enter the 6-digit code from your authenticator app to verify the
+ setup.
+
+
+
+
+
+ {
+ setStep("totp-qr");
+ setVerificationCode("");
+ setError(null);
+ }}
+ >
+ Back
+
+
+ {loading ? (
+ <>
+
+ Verifying...
+ >
+ ) : (
+ "Verify"
+ )}
+
+
+
+ >
+ );
+
+ const renderEmailVerify = () => (
+ <>
+
+
Check Your Email
+
+
+
+
+
+
+
+
+ We've sent a 6-digit verification code to your email address. Enter it
+ below to complete setup.
+
+
+
+
+
+ {
+ setStep("choose");
+ setVerificationCode("");
+ setError(null);
+ }}
+ >
+ Back
+
+
+ {loading ? (
+ <>
+
+ Verifying...
+ >
+ ) : (
+ "Verify"
+ )}
+
+
+
+ >
+ );
+
+ 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.
+
+
+ {loading ? (
+ <>
+
+ Sending...
+ >
+ ) : (
+ <>
+
+ Send Verification Code
+ >
+ )}
+
+ >
+ ) : (
+ <>
+
+
+ Verification code sent to your email.
+
+
+
+
+
+ Resend code
+
+ >
+ )}
+
+ );
+
+ 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") && (
+
+ {
+ setActiveTab("totp");
+ setCode("");
+ setError(null);
+ }}
+ >
+
+ App
+
+
+ )}
+ {methods.includes("email") && (
+
+ {
+ setActiveTab("email");
+ setCode("");
+ setError(null);
+ }}
+ >
+
+ Email
+
+
+ )}
+ {methods.includes("recovery") && (
+
+ {
+ setActiveTab("recovery");
+ setCode("");
+ setError(null);
+ }}
+ >
+
+ Recovery
+
+
+ )}
+
+
+ {activeTab === "totp" && renderTotpTab()}
+ {activeTab === "email" && renderEmailTab()}
+ {activeTab === "recovery" && renderRecoveryTab()}
+
+ {error && (
+
+ )}
+
+
+
+ Cancel
+
+ {(activeTab !== "email" || emailSent) && (
+
+ {loading ? (
+ <>
+
+ Verifying...
+ >
+ ) : (
+ "Verify"
+ )}
+
+ )}
+
+
+
+
+ );
+};
+
+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
+ setActiveSection("security")}
+ >
+
+ Security
+
{
)}
+
+ {/* Security Section */}
+ {activeSection === "security" && (
+
+
Security
+
+ {/* Password Change Card */}
+
+
+
+
+ Change Password
+
+
+ {passwordError && (
+
+
+ {passwordError}
+ setPasswordError(null)}
+ >
+
+ )}
+
+ {passwordSuccess && (
+
+
+ {passwordSuccess}
+ setPasswordSuccess(null)}
+ >
+
+ )}
+
+
+
+
+
+ {/* 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;
+}