This commit is contained in:
jackiettran
2026-01-16 18:04:39 -05:00
parent 63385e049c
commit cf97dffbfb
31 changed files with 4405 additions and 56 deletions

View File

@@ -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"];

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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";'
);
},
};

View File

@@ -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,
});
},
};

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",

627
backend/routes/twoFactor.js Normal file
View File

@@ -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;

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -255,7 +255,7 @@
<p>
<strong>Security reminder:</strong> 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.
</p>
</div>

View File

@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Recovery Code Used</title>
<style>
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #fd7e14 0%, #e55300 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #ffecd2;
font-size: 14px;
margin-top: 8px;
}
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Security Notice</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Recovery Code Used</h1>
<div class="info-box">
<p>
A recovery code was just used to verify your identity on your
Village Share account.
</p>
</div>
<p>
Recovery codes are one-time use codes that allow you to access your
account when you don't have access to your authenticator app.
</p>
<div
style="
text-align: center;
margin: 30px 0;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
"
>
<p style="margin: 0 0 10px 0; color: #6c757d; font-size: 14px">
Remaining recovery codes:
</p>
<span
style="
font-size: 48px;
font-weight: 700;
color: {{remainingCodesColor}};
"
>{{remainingCodes}}</span
>
<span style="font-size: 24px; color: #6c757d"> / 10</span>
</div>
{{#if lowCodesWarning}}
<div class="alert-box">
<p>
<strong>Warning:</strong> You're running low on recovery codes! We
strongly recommend generating new recovery codes from your account
settings to avoid being locked out.
</p>
</div>
{{/if}}
<div class="warning-box">
<p>
<strong>Didn't use a recovery code?</strong> If you didn't initiate
this action, someone may have access to your recovery codes. Please
secure your account immediately by:
</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px">
<li>Changing your password</li>
<li>Generating new recovery codes</li>
<li>Contacting our support team</li>
</ul>
</div>
<p>
<strong>Timestamp:</strong> {{timestamp}}
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security notification. You received this message because a
recovery code was used on your account.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Multi-Factor Authentication Disabled</title>
<style>
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 14px;
margin-top: 8px;
}
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Security Alert</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Multi-Factor Authentication Disabled</h1>
<div class="alert-box">
<p>
<strong>Important:</strong> Multi-factor authentication has been
disabled on your Village Share account.
</p>
</div>
<p>
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.
</p>
<div class="warning-box">
<p>
<strong>We recommend keeping 2FA enabled</strong> to protect your
account, especially if you have payment methods saved or are
receiving payouts from rentals.
</p>
</div>
<p>
<strong>Didn't disable this?</strong> If you didn't make this change,
your account may have been compromised. Please take the following
steps immediately:
</p>
<ol style="color: #6c757d; margin: 16px 0; padding-left: 20px">
<li>Change your password</li>
<li>Re-enable multi-factor authentication</li>
<li>Contact our support team</li>
</ol>
<p>
<strong>Timestamp:</strong> {{timestamp}}
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security notification. You received this message because
multi-factor authentication was disabled on your account.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Multi-Factor Authentication Enabled</title>
<style>
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
font-size: 14px;
margin-top: 8px;
}
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 14px;
}
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Security Update</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Multi-Factor Authentication Enabled</h1>
<div class="success-box">
<p>
<strong>Great news!</strong> Multi-factor authentication has been
successfully enabled on your Village Share account.
</p>
</div>
<p>
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:
</p>
<ul style="color: #6c757d; margin: 16px 0; padding-left: 20px">
<li>Changing your password</li>
<li>Updating your email address</li>
<li>Requesting payouts</li>
<li>Disabling multi-factor authentication</li>
</ul>
<div class="info-box">
<p>
<strong>Recovery Codes:</strong> 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.
</p>
</div>
<p>
<strong>Didn't enable this?</strong> If you didn't make this change,
please contact our support team immediately and secure your account.
</p>
<p>
<strong>Timestamp:</strong> {{timestamp}}
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security notification. You received this message because
multi-factor authentication was enabled on your account.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Your Verification Code</title>
<style>
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e0d4f7;
font-size: 14px;
margin-top: 8px;
}
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Security Verification</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Your Verification Code</h1>
<p>
You requested a verification code to complete a secure action on your
Village Share account.
</p>
<div style="text-align: center; margin: 30px 0">
<p style="margin-bottom: 10px; color: #6c757d; font-size: 14px">
Your verification code is:
</p>
<div
style="
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12px;
padding: 20px 40px;
display: inline-block;
border: 2px dashed #667eea;
"
>
<span
style="
font-size: 36px;
font-weight: 700;
letter-spacing: 8px;
color: #667eea;
font-family: 'Courier New', monospace;
"
>{{otpCode}}</span
>
</div>
<p style="margin-top: 10px; font-size: 14px; color: #6c757d">
Enter this code to verify your identity
</p>
</div>
<div class="warning-box">
<p>
<strong>This code will expire in 10 minutes.</strong> If you didn't
request this code, please secure your account immediately.
</p>
</div>
<p>
<strong>Didn't request this code?</strong> If you didn't initiate this
request, someone may be trying to access your account. We recommend
changing your password immediately.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security email sent to protect your account. Never share
this code with anyone.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>