MFA
This commit is contained in:
@@ -28,8 +28,7 @@ const csrfProtection = (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get token from header or body
|
// Get token from header or body
|
||||||
const token =
|
const token = req.headers["x-csrf-token"] || req.body.csrfToken;
|
||||||
req.headers["x-csrf-token"] || req.body.csrfToken || req.query.csrfToken;
|
|
||||||
|
|
||||||
// Get token from cookie
|
// Get token from cookie
|
||||||
const cookieToken = req.cookies && req.cookies["csrf-token"];
|
const cookieToken = req.cookies && req.cookies["csrf-token"];
|
||||||
|
|||||||
@@ -207,6 +207,57 @@ const authRateLimiters = {
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
handler: createRateLimitHandler('general'),
|
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 = {
|
module.exports = {
|
||||||
@@ -223,6 +274,12 @@ module.exports = {
|
|||||||
emailVerificationLimiter: authRateLimiters.emailVerification,
|
emailVerificationLimiter: authRateLimiters.emailVerification,
|
||||||
generalLimiter: authRateLimiters.general,
|
generalLimiter: authRateLimiters.general,
|
||||||
|
|
||||||
|
// Two-Factor Authentication rate limiters
|
||||||
|
twoFactorVerificationLimiter: authRateLimiters.twoFactorVerification,
|
||||||
|
twoFactorSetupLimiter: authRateLimiters.twoFactorSetup,
|
||||||
|
recoveryCodeLimiter: authRateLimiters.recoveryCode,
|
||||||
|
emailOtpSendLimiter: authRateLimiters.emailOtpSend,
|
||||||
|
|
||||||
// Burst protection
|
// Burst protection
|
||||||
burstProtection,
|
burstProtection,
|
||||||
|
|
||||||
|
|||||||
73
backend/middleware/stepUpAuth.js
Normal file
73
backend/middleware/stepUpAuth.js
Normal 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 };
|
||||||
@@ -345,6 +345,31 @@ const validateCoordinatesBody = [
|
|||||||
.withMessage("Longitude must be between -180 and 180"),
|
.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 = {
|
module.exports = {
|
||||||
sanitizeInput,
|
sanitizeInput,
|
||||||
handleValidationErrors,
|
handleValidationErrors,
|
||||||
@@ -359,4 +384,8 @@ module.exports = {
|
|||||||
validateFeedback,
|
validateFeedback,
|
||||||
validateCoordinatesQuery,
|
validateCoordinatesQuery,
|
||||||
validateCoordinatesBody,
|
validateCoordinatesBody,
|
||||||
|
// Two-Factor Authentication
|
||||||
|
validateTotpCode,
|
||||||
|
validateEmailOtp,
|
||||||
|
validateRecoveryCode,
|
||||||
};
|
};
|
||||||
|
|||||||
107
backend/migrations/20260115000001-add-two-factor-auth-fields.js
Normal file
107
backend/migrations/20260115000001-add-two-factor-auth-fields.js
Normal 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";'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -191,6 +191,66 @@ const User = sequelize.define(
|
|||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
allowNull: true,
|
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: {
|
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;
|
module.exports = User;
|
||||||
|
|||||||
281
backend/package-lock.json
generated
281
backend/package-lock.json
generated
@@ -30,7 +30,9 @@
|
|||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.1",
|
||||||
|
"otplib": "^13.1.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
"sequelize-cli": "^6.6.3",
|
"sequelize-cli": "^6.6.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
@@ -4588,6 +4590,74 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="
|
"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": {
|
"node_modules/@paralleldrive/cuid2": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
||||||
@@ -4620,6 +4690,15 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"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": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.34.41",
|
"version": "0.34.41",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
||||||
@@ -6100,7 +6179,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
@@ -6708,6 +6795,12 @@
|
|||||||
"node": ">=0.3.1"
|
"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": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||||
@@ -7358,7 +7451,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"locate-path": "^5.0.0",
|
"locate-path": "^5.0.0",
|
||||||
@@ -9167,7 +9259,6 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-locate": "^4.1.0"
|
"p-locate": "^4.1.0"
|
||||||
@@ -9728,6 +9819,20 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
@@ -9748,7 +9853,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-limit": "^2.2.0"
|
"p-limit": "^2.2.0"
|
||||||
@@ -9761,7 +9865,6 @@
|
|||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-try": "^2.0.0"
|
"p-try": "^2.0.0"
|
||||||
@@ -9777,7 +9880,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -9831,7 +9933,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -10004,6 +10105,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -10151,6 +10261,145 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
@@ -10282,6 +10531,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
@@ -10570,6 +10825,12 @@
|
|||||||
"node": ">= 18"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -11773,6 +12034,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/winston": {
|
||||||
"version": "3.17.0",
|
"version": "3.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
|
||||||
|
|||||||
@@ -55,7 +55,9 @@
|
|||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.1",
|
"morgan": "^1.10.1",
|
||||||
|
"otplib": "^13.1.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
"sequelize-cli": "^6.6.3",
|
"sequelize-cli": "^6.6.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
|||||||
627
backend/routes/twoFactor.js
Normal file
627
backend/routes/twoFactor.js
Normal 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;
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
|
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
|
||||||
const { authenticateToken, optionalAuth, requireAdmin } = require('../middleware/auth');
|
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 logger = require('../utils/logger');
|
||||||
const userService = require('../services/UserService');
|
const userService = require('../services/UserService');
|
||||||
|
const emailServices = require('../services/email');
|
||||||
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
const { validateS3Keys } = require('../utils/s3KeyValidator');
|
||||||
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
const { IMAGE_LIMITS } = require('../config/imageLimits');
|
||||||
const router = express.Router();
|
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
|
// Admin: Unban a user
|
||||||
router.post('/admin/:id/unban', authenticateToken, requireAdmin, async (req, res, next) => {
|
router.post('/admin/:id/unban', authenticateToken, requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const conditionCheckRoutes = require("./routes/conditionChecks");
|
|||||||
const feedbackRoutes = require("./routes/feedback");
|
const feedbackRoutes = require("./routes/feedback");
|
||||||
const uploadRoutes = require("./routes/upload");
|
const uploadRoutes = require("./routes/upload");
|
||||||
const healthRoutes = require("./routes/health");
|
const healthRoutes = require("./routes/health");
|
||||||
|
const twoFactorRoutes = require("./routes/twoFactor");
|
||||||
|
|
||||||
const emailServices = require("./services/email");
|
const emailServices = require("./services/email");
|
||||||
const s3Service = require("./services/s3Service");
|
const s3Service = require("./services/s3Service");
|
||||||
@@ -152,6 +153,7 @@ app.get("/", (req, res) => {
|
|||||||
// Public routes (no alpha access required)
|
// Public routes (no alpha access required)
|
||||||
app.use("/api/alpha", alphaRoutes);
|
app.use("/api/alpha", alphaRoutes);
|
||||||
app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration
|
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)
|
// Protected routes (require alpha access)
|
||||||
app.use("/api/users", requireAlphaAccess, userRoutes);
|
app.use("/api/users", requireAlphaAccess, userRoutes);
|
||||||
|
|||||||
305
backend/services/TwoFactorService.js
Normal file
305
backend/services/TwoFactorService.js
Normal 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;
|
||||||
@@ -167,6 +167,150 @@ class AuthEmailService {
|
|||||||
htmlContent
|
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;
|
module.exports = AuthEmailService;
|
||||||
|
|||||||
@@ -255,7 +255,7 @@
|
|||||||
<p>
|
<p>
|
||||||
<strong>Security reminder:</strong> Keep your password secure and
|
<strong>Security reminder:</strong> Keep your password secure and
|
||||||
never share it with anyone. We recommend using a strong, unique
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
232
backend/templates/emails/recoveryCodeUsedToUser.html
Normal file
232
backend/templates/emails/recoveryCodeUsedToUser.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
195
backend/templates/emails/twoFactorDisabledToUser.html
Normal file
195
backend/templates/emails/twoFactorDisabledToUser.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
195
backend/templates/emails/twoFactorEnabledToUser.html
Normal file
195
backend/templates/emails/twoFactorEnabledToUser.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
193
backend/templates/emails/twoFactorOtpToUser.html
Normal file
193
backend/templates/emails/twoFactorOtpToUser.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import { SocketProvider } from './contexts/SocketContext';
|
import { SocketProvider } from './contexts/SocketContext';
|
||||||
@@ -7,6 +7,7 @@ import Footer from './components/Footer';
|
|||||||
import AuthModal from './components/AuthModal';
|
import AuthModal from './components/AuthModal';
|
||||||
import AlphaGate from './components/AlphaGate';
|
import AlphaGate from './components/AlphaGate';
|
||||||
import FeedbackButton from './components/FeedbackButton';
|
import FeedbackButton from './components/FeedbackButton';
|
||||||
|
import { TwoFactorVerifyModal } from './components/TwoFactor';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import GoogleCallback from './pages/GoogleCallback';
|
import GoogleCallback from './pages/GoogleCallback';
|
||||||
import VerifyEmail from './pages/VerifyEmail';
|
import VerifyEmail from './pages/VerifyEmail';
|
||||||
@@ -40,6 +41,39 @@ const AppContent: React.FC = () => {
|
|||||||
const [hasAlphaAccess, setHasAlphaAccess] = useState<boolean | null>(null);
|
const [hasAlphaAccess, setHasAlphaAccess] = useState<boolean | null>(null);
|
||||||
const [checkingAccess, setCheckingAccess] = useState(true);
|
const [checkingAccess, setCheckingAccess] = useState(true);
|
||||||
|
|
||||||
|
// Step-up authentication state
|
||||||
|
const [showStepUpModal, setShowStepUpModal] = useState(false);
|
||||||
|
const [stepUpAction, setStepUpAction] = useState<string | undefined>();
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const checkAlphaAccess = async () => {
|
const checkAlphaAccess = async () => {
|
||||||
// Bypass alpha access check if feature is disabled
|
// Bypass alpha access check if feature is disabled
|
||||||
@@ -209,6 +243,15 @@ const AppContent: React.FC = () => {
|
|||||||
|
|
||||||
{/* Show feedback button for authenticated users */}
|
{/* Show feedback button for authenticated users */}
|
||||||
{user && <FeedbackButton />}
|
{user && <FeedbackButton />}
|
||||||
|
|
||||||
|
{/* Global Step-Up Authentication Modal */}
|
||||||
|
<TwoFactorVerifyModal
|
||||||
|
show={showStepUpModal}
|
||||||
|
onHide={handleStepUpClose}
|
||||||
|
onSuccess={handleStepUpSuccess}
|
||||||
|
action={stepUpAction}
|
||||||
|
methods={stepUpMethods}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PasswordStrengthMeter from "./PasswordStrengthMeter";
|
|||||||
import PasswordInput from "./PasswordInput";
|
import PasswordInput from "./PasswordInput";
|
||||||
import ForgotPasswordModal from "./ForgotPasswordModal";
|
import ForgotPasswordModal from "./ForgotPasswordModal";
|
||||||
import VerificationCodeModal from "./VerificationCodeModal";
|
import VerificationCodeModal from "./VerificationCodeModal";
|
||||||
|
import { validatePassword } from "../utils/passwordValidation";
|
||||||
|
|
||||||
interface AuthModalProps {
|
interface AuthModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -143,6 +144,15 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Password validation for signup
|
||||||
|
if (mode === "signup") {
|
||||||
|
const passwordError = validatePassword(password);
|
||||||
|
if (passwordError) {
|
||||||
|
setError(passwordError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,47 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { PASSWORD_REQUIREMENTS, COMMON_PASSWORDS } from "../utils/passwordValidation";
|
||||||
|
|
||||||
interface PasswordStrengthMeterProps {
|
interface PasswordStrengthMeterProps {
|
||||||
password: string;
|
password: string;
|
||||||
showRequirements?: boolean;
|
showRequirements?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PasswordRequirement {
|
|
||||||
regex: RegExp;
|
|
||||||
text: string;
|
|
||||||
met: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({
|
const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({
|
||||||
password,
|
password,
|
||||||
showRequirements = true,
|
showRequirements = true,
|
||||||
}) => {
|
}) => {
|
||||||
const requirements: PasswordRequirement[] = [
|
const requirements = PASSWORD_REQUIREMENTS.map((req) => ({
|
||||||
{
|
text: req.text,
|
||||||
regex: /.{8,}/,
|
met: req.regex.test(password),
|
||||||
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 getPasswordStrength = (): {
|
const getPasswordStrength = (): {
|
||||||
score: number;
|
score: number;
|
||||||
@@ -51,16 +23,8 @@ const PasswordStrengthMeter: React.FC<PasswordStrengthMeterProps> = ({
|
|||||||
if (!password) return { score: 0, label: "", color: "" };
|
if (!password) return { score: 0, label: "", color: "" };
|
||||||
|
|
||||||
const metRequirements = requirements.filter((req) => req.met).length;
|
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" };
|
return { score: 0, label: "Too Common", color: "danger" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
166
frontend/src/components/TwoFactor/RecoveryCodesDisplay.tsx
Normal file
166
frontend/src/components/TwoFactor/RecoveryCodesDisplay.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
interface RecoveryCodesDisplayProps {
|
||||||
|
codes: string[];
|
||||||
|
onAcknowledge?: () => void;
|
||||||
|
showAcknowledgeButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecoveryCodesDisplay: React.FC<RecoveryCodesDisplayProps> = ({
|
||||||
|
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(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Recovery Codes - Village Share</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: monospace; padding: 20px; }
|
||||||
|
h1 { font-size: 18px; }
|
||||||
|
.codes { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 20px; }
|
||||||
|
.code { background: #f0f0f0; padding: 10px; border-radius: 4px; text-align: center; }
|
||||||
|
.warning { color: #856404; background: #fff3cd; padding: 15px; border-radius: 4px; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Village Share Recovery Codes</h1>
|
||||||
|
<p>Save these codes in a secure location. Each code can only be used once.</p>
|
||||||
|
<div class="codes">
|
||||||
|
${codes.map((code) => `<div class="code">${code}</div>`).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Warning:</strong> These codes will not be shown again. Store them securely.
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 20px; font-size: 12px; color: #666;">Generated: ${new Date().toLocaleString()}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.print();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="alert alert-warning mb-3">
|
||||||
|
<i className="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
<strong>Important:</strong> Save these recovery codes in a secure
|
||||||
|
location. You will not be able to see them again.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row row-cols-2 g-2 mb-3">
|
||||||
|
{codes.map((code, index) => (
|
||||||
|
<div key={index} className="col">
|
||||||
|
<div
|
||||||
|
className="bg-light border rounded p-2 text-center font-monospace"
|
||||||
|
style={{ fontSize: "0.9rem", letterSpacing: "1px" }}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm flex-fill"
|
||||||
|
onClick={handleCopyAll}
|
||||||
|
>
|
||||||
|
<i className={`bi ${copied ? "bi-check" : "bi-clipboard"} me-1`}></i>
|
||||||
|
{copied ? "Copied!" : "Copy All"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm flex-fill"
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
<i className="bi bi-download me-1"></i>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm flex-fill"
|
||||||
|
onClick={handlePrint}
|
||||||
|
>
|
||||||
|
<i className="bi bi-printer me-1"></i>
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAcknowledgeButton && (
|
||||||
|
<>
|
||||||
|
<div className="form-check mb-3">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="acknowledgeRecoveryCodes"
|
||||||
|
checked={acknowledged}
|
||||||
|
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label"
|
||||||
|
htmlFor="acknowledgeRecoveryCodes"
|
||||||
|
>
|
||||||
|
I have saved my recovery codes in a secure location
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-success w-100"
|
||||||
|
disabled={!acknowledged}
|
||||||
|
onClick={onAcknowledge}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecoveryCodesDisplay;
|
||||||
240
frontend/src/components/TwoFactor/TwoFactorManagement.tsx
Normal file
240
frontend/src/components/TwoFactor/TwoFactorManagement.tsx
Normal file
@@ -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<TwoFactorStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [showSetupModal, setShowSetupModal] = useState(false);
|
||||||
|
const [showVerifyModal, setShowVerifyModal] = useState(false);
|
||||||
|
const [showRecoveryCodes, setShowRecoveryCodes] = useState(false);
|
||||||
|
const [newRecoveryCodes, setNewRecoveryCodes] = useState<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h5 className="mb-3">
|
||||||
|
<i className="bi bi-shield-lock me-2"></i>
|
||||||
|
Multi-Factor Authentication
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger mb-3">
|
||||||
|
<i className="bi bi-exclamation-circle me-2"></i>
|
||||||
|
{error}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close float-end"
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.enabled ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center mb-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<span className="badge bg-success fs-6">
|
||||||
|
<i className="bi bi-check-circle me-1"></i>
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow-1 ms-3">
|
||||||
|
<strong>
|
||||||
|
{status.method === "totp"
|
||||||
|
? "Authenticator App"
|
||||||
|
: "Email Verification"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>Recovery Codes</strong>
|
||||||
|
<div className="text-muted small">
|
||||||
|
{status.hasRecoveryCodes
|
||||||
|
? status.lowRecoveryCodes
|
||||||
|
? "Running low"
|
||||||
|
: "Available"
|
||||||
|
: "None remaining"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={handleRegenerateRecoveryCodes}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-clockwise me-1"></i>
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.lowRecoveryCodes && (
|
||||||
|
<div className="alert alert-warning mt-2 mb-0">
|
||||||
|
<i className="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
You're running low on recovery codes. Consider regenerating
|
||||||
|
new ones.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<button className="btn btn-outline-danger" onClick={handleDisable}>
|
||||||
|
<i className="bi bi-shield-x me-1"></i>
|
||||||
|
Disable MFA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowSetupModal(true)}
|
||||||
|
>
|
||||||
|
Set Up MFA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Setup Modal */}
|
||||||
|
<TwoFactorSetupModal
|
||||||
|
show={showSetupModal}
|
||||||
|
onHide={() => setShowSetupModal(false)}
|
||||||
|
onSuccess={handleSetupSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Verify Modal for disable/regenerate */}
|
||||||
|
<TwoFactorVerifyModal
|
||||||
|
show={showVerifyModal}
|
||||||
|
onHide={() => {
|
||||||
|
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 && (
|
||||||
|
<div
|
||||||
|
className="modal show d-block"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">New Recovery Codes</h5>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<RecoveryCodesDisplay
|
||||||
|
codes={newRecoveryCodes}
|
||||||
|
onAcknowledge={() => {
|
||||||
|
setShowRecoveryCodes(false);
|
||||||
|
setNewRecoveryCodes([]);
|
||||||
|
}}
|
||||||
|
showAcknowledgeButton={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoFactorManagement;
|
||||||
384
frontend/src/components/TwoFactor/TwoFactorSetupModal.tsx
Normal file
384
frontend/src/components/TwoFactor/TwoFactorSetupModal.tsx
Normal file
@@ -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<TwoFactorSetupModalProps> = ({
|
||||||
|
show,
|
||||||
|
onHide,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const [step, setStep] = useState<SetupStep>("choose");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// TOTP setup state
|
||||||
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>("");
|
||||||
|
const [verificationCode, setVerificationCode] = useState("");
|
||||||
|
|
||||||
|
// Recovery codes
|
||||||
|
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
|
||||||
|
setVerificationCode(value);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
const renderChooseMethod = () => (
|
||||||
|
<>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Set Up MFA</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={handleClose}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="d-grid gap-3">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary p-3 text-start"
|
||||||
|
onClick={handleInitTotp}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<i className="bi bi-phone fs-3 me-3"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Authenticator App</strong>
|
||||||
|
<span className="badge bg-success ms-2">Recommended</span>
|
||||||
|
<br />
|
||||||
|
<small className="text-muted">
|
||||||
|
Use Google Authenticator, Authy, or similar app
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary p-3 text-start"
|
||||||
|
onClick={handleInitEmailSetup}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<i className="bi bi-envelope fs-3 me-3"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Email Verification</strong>
|
||||||
|
<br />
|
||||||
|
<small className="text-muted">
|
||||||
|
Receive codes via email when verification is needed
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger mt-3 mb-0">
|
||||||
|
<i className="bi bi-exclamation-circle me-2"></i>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTotpQR = () => (
|
||||||
|
<>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Scan QR Code</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={handleClose}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Scan this QR code with your authenticator app to add Village Share.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
{qrCodeDataUrl && (
|
||||||
|
<img
|
||||||
|
src={qrCodeDataUrl}
|
||||||
|
alt="QR Code for authenticator app"
|
||||||
|
className="img-fluid border rounded"
|
||||||
|
style={{ maxWidth: "200px" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
onClick={() => setStep("totp-verify")}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTotpVerify = () => (
|
||||||
|
<>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Enter Verification Code</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={handleClose}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Enter the 6-digit code from your authenticator app to verify the
|
||||||
|
setup.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control form-control-lg text-center font-monospace ${
|
||||||
|
error ? "is-invalid" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="000000"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
style={{ letterSpacing: "0.5em" }}
|
||||||
|
/>
|
||||||
|
{error && <div className="invalid-feedback">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary flex-fill"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("totp-qr");
|
||||||
|
setVerificationCode("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary flex-fill"
|
||||||
|
onClick={handleVerifyTotp}
|
||||||
|
disabled={loading || verificationCode.length !== 6}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Verify"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEmailVerify = () => (
|
||||||
|
<>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Check Your Email</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={handleClose}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<i className="bi bi-envelope-check fs-1 text-primary"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted mb-3 text-center">
|
||||||
|
We've sent a 6-digit verification code to your email address. Enter it
|
||||||
|
below to complete setup.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control form-control-lg text-center font-monospace ${
|
||||||
|
error ? "is-invalid" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="000000"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
style={{ letterSpacing: "0.5em" }}
|
||||||
|
/>
|
||||||
|
{error && <div className="invalid-feedback">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary flex-fill"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("choose");
|
||||||
|
setVerificationCode("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary flex-fill"
|
||||||
|
onClick={handleVerifyEmail}
|
||||||
|
disabled={loading || verificationCode.length !== 6}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Verify"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRecoveryCodes = () => (
|
||||||
|
<>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Save Your Recovery Codes</h5>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<RecoveryCodesDisplay
|
||||||
|
codes={recoveryCodes}
|
||||||
|
onAcknowledge={handleComplete}
|
||||||
|
showAcknowledgeButton={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal show d-block"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content">
|
||||||
|
{step === "choose" && renderChooseMethod()}
|
||||||
|
{step === "totp-qr" && renderTotpQR()}
|
||||||
|
{step === "totp-verify" && renderTotpVerify()}
|
||||||
|
{step === "email-verify" && renderEmailVerify()}
|
||||||
|
{step === "recovery-codes" && renderRecoveryCodes()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoFactorSetupModal;
|
||||||
334
frontend/src/components/TwoFactor/TwoFactorVerifyModal.tsx
Normal file
334
frontend/src/components/TwoFactor/TwoFactorVerifyModal.tsx
Normal file
@@ -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<TwoFactorVerifyModalProps> = ({
|
||||||
|
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<string | null>(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<HTMLInputElement>) => {
|
||||||
|
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 = () => (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Enter the 6-digit code from your authenticator app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control form-control-lg text-center font-monospace mb-3 ${
|
||||||
|
error && activeTab === "totp" ? "is-invalid" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="000000"
|
||||||
|
value={code}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
style={{ letterSpacing: "0.5em" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEmailTab = () => (
|
||||||
|
<div className="p-3">
|
||||||
|
{!emailSent ? (
|
||||||
|
<>
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
We'll send a verification code to your email address.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
onClick={handleSendEmailOtp}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="bi bi-envelope me-2"></i>
|
||||||
|
Send Verification Code
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="alert alert-success mb-3">
|
||||||
|
<i className="bi bi-check-circle me-2"></i>
|
||||||
|
Verification code sent to your email.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control form-control-lg text-center font-monospace mb-3 ${
|
||||||
|
error && activeTab === "email" ? "is-invalid" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="000000"
|
||||||
|
value={code}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
style={{ letterSpacing: "0.5em" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-link btn-sm p-0"
|
||||||
|
onClick={handleSendEmailOtp}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRecoveryTab = () => (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Enter one of your recovery codes. Each code can only be used once.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`form-control form-control-lg text-center font-monospace mb-3 ${
|
||||||
|
error && activeTab === "recovery" ? "is-invalid" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="XXXX-XXXX"
|
||||||
|
value={code}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
maxLength={9}
|
||||||
|
autoFocus
|
||||||
|
style={{ letterSpacing: "0.2em" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal show d-block"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">
|
||||||
|
<i className="bi bi-shield-lock me-2"></i>
|
||||||
|
Verify Your Identity
|
||||||
|
</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onHide}></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body p-0">
|
||||||
|
<div className="p-3 pb-0">
|
||||||
|
<p className="text-muted mb-0">
|
||||||
|
Multi-factor authentication is required to {getActionDescription(action)}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="nav nav-tabs px-3 pt-3">
|
||||||
|
{methods.includes("totp") && (
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${activeTab === "totp" ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("totp");
|
||||||
|
setCode("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-phone me-1"></i>
|
||||||
|
App
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{methods.includes("email") && (
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${activeTab === "email" ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("email");
|
||||||
|
setCode("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-envelope me-1"></i>
|
||||||
|
Email
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{methods.includes("recovery") && (
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
className={`nav-link ${activeTab === "recovery" ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("recovery");
|
||||||
|
setCode("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-key me-1"></i>
|
||||||
|
Recovery
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{activeTab === "totp" && renderTotpTab()}
|
||||||
|
{activeTab === "email" && renderEmailTab()}
|
||||||
|
{activeTab === "recovery" && renderRecoveryTab()}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<div className="alert alert-danger mb-0">
|
||||||
|
<i className="bi bi-exclamation-circle me-2"></i>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-outline-secondary" onClick={onHide}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{(activeTab !== "email" || emailSent) && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleVerify}
|
||||||
|
disabled={loading || !isCodeValid()}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Verify"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoFactorVerifyModal;
|
||||||
4
frontend/src/components/TwoFactor/index.ts
Normal file
4
frontend/src/components/TwoFactor/index.ts
Normal file
@@ -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";
|
||||||
@@ -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 { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { userAPI, itemAPI, rentalAPI, addressAPI, conditionCheckAPI } from "../services/api";
|
import { userAPI, itemAPI, rentalAPI, addressAPI, conditionCheckAPI } from "../services/api";
|
||||||
@@ -10,6 +10,7 @@ import ReviewRenterModal from "../components/ReviewRenterModal";
|
|||||||
import ReviewDetailsModal from "../components/ReviewDetailsModal";
|
import ReviewDetailsModal from "../components/ReviewDetailsModal";
|
||||||
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
|
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
|
||||||
import Avatar from "../components/Avatar";
|
import Avatar from "../components/Avatar";
|
||||||
|
import PasswordStrengthMeter from "../components/PasswordStrengthMeter";
|
||||||
import {
|
import {
|
||||||
geocodingService,
|
geocodingService,
|
||||||
AddressComponents,
|
AddressComponents,
|
||||||
@@ -20,6 +21,8 @@ import {
|
|||||||
useAddressAutocomplete,
|
useAddressAutocomplete,
|
||||||
usStates,
|
usStates,
|
||||||
} from "../hooks/useAddressAutocomplete";
|
} from "../hooks/useAddressAutocomplete";
|
||||||
|
import { TwoFactorManagement } from "../components/TwoFactor";
|
||||||
|
import { validatePassword } from "../utils/passwordValidation";
|
||||||
|
|
||||||
const Profile: React.FC = () => {
|
const Profile: React.FC = () => {
|
||||||
const { user, updateUser, logout } = useAuth();
|
const { user, updateUser, logout } = useAuth();
|
||||||
@@ -120,6 +123,56 @@ const Profile: React.FC = () => {
|
|||||||
const [showConditionCheckViewer, setShowConditionCheckViewer] = useState(false);
|
const [showConditionCheckViewer, setShowConditionCheckViewer] = useState(false);
|
||||||
const [selectedConditionCheck, setSelectedConditionCheck] = useState<ConditionCheck | null>(null);
|
const [selectedConditionCheck, setSelectedConditionCheck] = useState<ConditionCheck | null>(null);
|
||||||
|
|
||||||
|
// Password change state
|
||||||
|
const [passwordFormData, setPasswordFormData] = useState({
|
||||||
|
currentPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
});
|
||||||
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||||
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
|
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
@@ -690,6 +743,82 @@ const Profile: React.FC = () => {
|
|||||||
// Use address autocomplete hook
|
// Use address autocomplete hook
|
||||||
const { parsePlace } = useAddressAutocomplete();
|
const { parsePlace } = useAddressAutocomplete();
|
||||||
|
|
||||||
|
// Password change handlers
|
||||||
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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
|
// Handle place selection from autocomplete
|
||||||
const handlePlaceSelect = useCallback(
|
const handlePlaceSelect = useCallback(
|
||||||
(place: PlaceDetails) => {
|
(place: PlaceDetails) => {
|
||||||
@@ -775,6 +904,15 @@ const Profile: React.FC = () => {
|
|||||||
<i className="bi bi-clock-history me-2"></i>
|
<i className="bi bi-clock-history me-2"></i>
|
||||||
Rental History
|
Rental History
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`list-group-item list-group-item-action ${
|
||||||
|
activeSection === "security" ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveSection("security")}
|
||||||
|
>
|
||||||
|
<i className="bi bi-shield-lock me-2"></i>
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="list-group-item list-group-item-action text-danger"
|
className="list-group-item list-group-item-action text-danger"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
@@ -1673,6 +1811,151 @@ const Profile: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Security Section */}
|
||||||
|
{activeSection === "security" && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-4">Security</h4>
|
||||||
|
|
||||||
|
{/* Password Change Card */}
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="mb-3">
|
||||||
|
<i className="bi bi-key me-2"></i>
|
||||||
|
Change Password
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
{passwordError && (
|
||||||
|
<div className="alert alert-danger mb-3">
|
||||||
|
<i className="bi bi-exclamation-circle me-2"></i>
|
||||||
|
{passwordError}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close float-end"
|
||||||
|
onClick={() => setPasswordError(null)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{passwordSuccess && (
|
||||||
|
<div className="alert alert-success mb-3">
|
||||||
|
<i className="bi bi-check-circle me-2"></i>
|
||||||
|
{passwordSuccess}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close float-end"
|
||||||
|
onClick={() => setPasswordSuccess(null)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handlePasswordSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="currentPassword" className="form-label">
|
||||||
|
Current Password
|
||||||
|
</label>
|
||||||
|
<div className="position-relative">
|
||||||
|
<input
|
||||||
|
type={showCurrentPassword ? "text" : "password"}
|
||||||
|
className="form-control"
|
||||||
|
id="currentPassword"
|
||||||
|
name="currentPassword"
|
||||||
|
value={passwordFormData.currentPassword}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary p-0 pe-2"
|
||||||
|
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||||
|
style={{ zIndex: 10, textDecoration: "none" }}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<i className={`bi ${showCurrentPassword ? "bi-eye" : "bi-eye-slash"}`}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="newPassword" className="form-label">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<div className="position-relative">
|
||||||
|
<input
|
||||||
|
type={showNewPassword ? "text" : "password"}
|
||||||
|
className="form-control"
|
||||||
|
id="newPassword"
|
||||||
|
name="newPassword"
|
||||||
|
value={passwordFormData.newPassword}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary p-0 pe-2"
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
style={{ zIndex: 10, textDecoration: "none" }}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<i className={`bi ${showNewPassword ? "bi-eye" : "bi-eye-slash"}`}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<PasswordStrengthMeter password={passwordFormData.newPassword} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="confirmPassword" className="form-label">
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<div className="position-relative">
|
||||||
|
<input
|
||||||
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
|
className="form-control"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={passwordFormData.confirmPassword}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary p-0 pe-2"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
style={{ zIndex: 10, textDecoration: "none" }}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<i className={`bi ${showConfirmPassword ? "bi-eye" : "bi-eye-slash"}`}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={passwordLoading || !isPasswordValid}
|
||||||
|
>
|
||||||
|
{passwordLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Changing Password...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Change Password"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multi-Factor Authentication Card */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<TwoFactorManagement />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -81,9 +81,25 @@ api.interceptors.response.use(
|
|||||||
_csrfRetry?: boolean;
|
_csrfRetry?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle CSRF token errors
|
// Handle CSRF token errors and step-up authentication
|
||||||
if (error.response?.status === 403) {
|
if (error.response?.status === 403) {
|
||||||
const errorData = error.response?.data as any;
|
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 (
|
if (
|
||||||
errorData?.code === "CSRF_TOKEN_MISMATCH" &&
|
errorData?.code === "CSRF_TOKEN_MISMATCH" &&
|
||||||
!originalRequest._csrfRetry
|
!originalRequest._csrfRetry
|
||||||
@@ -184,12 +200,40 @@ export const userAPI = {
|
|||||||
getPublicProfile: (id: string) => api.get(`/users/${id}`),
|
getPublicProfile: (id: string) => api.get(`/users/${id}`),
|
||||||
getAvailability: () => api.get("/users/availability"),
|
getAvailability: () => api.get("/users/availability"),
|
||||||
updateAvailability: (data: any) => api.put("/users/availability", data),
|
updateAvailability: (data: any) => api.put("/users/availability", data),
|
||||||
|
changePassword: (data: {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}) => api.put("/users/password", data),
|
||||||
// Admin endpoints
|
// Admin endpoints
|
||||||
adminBanUser: (id: string, reason: string) =>
|
adminBanUser: (id: string, reason: string) =>
|
||||||
api.post(`/users/admin/${id}/ban`, { reason }),
|
api.post(`/users/admin/${id}/ban`, { reason }),
|
||||||
adminUnbanUser: (id: string) => api.post(`/users/admin/${id}/unban`),
|
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 = {
|
export const addressAPI = {
|
||||||
getAddresses: () => api.get("/users/addresses"),
|
getAddresses: () => api.get("/users/addresses"),
|
||||||
createAddress: (data: any) => api.post("/users/addresses", data),
|
createAddress: (data: any) => api.post("/users/addresses", data),
|
||||||
|
|||||||
@@ -38,6 +38,34 @@ export interface User {
|
|||||||
bannedAt?: string;
|
bannedAt?: string;
|
||||||
bannedBy?: string;
|
bannedBy?: string;
|
||||||
banReason?: 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 {
|
export interface Message {
|
||||||
|
|||||||
34
frontend/src/utils/passwordValidation.ts
Normal file
34
frontend/src/utils/passwordValidation.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user