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

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

@@ -0,0 +1,627 @@
const express = require("express");
const { User } = require("../models");
const TwoFactorService = require("../services/TwoFactorService");
const emailServices = require("../services/email");
const logger = require("../utils/logger");
const { authenticateToken } = require("../middleware/auth");
const { requireStepUpAuth } = require("../middleware/stepUpAuth");
const { csrfProtection } = require("../middleware/csrf");
const {
sanitizeInput,
validateTotpCode,
validateEmailOtp,
validateRecoveryCode,
} = require("../middleware/validation");
const {
twoFactorVerificationLimiter,
twoFactorSetupLimiter,
recoveryCodeLimiter,
emailOtpSendLimiter,
} = require("../middleware/rateLimiter");
const router = express.Router();
// Helper for structured security audit logging
const auditLog = (req, action, userId, details = {}) => {
logger.info({
type: 'security_audit',
action,
userId,
ip: req.ip,
userAgent: req.get('User-Agent'),
...details,
});
};
// All routes require authentication
router.use(authenticateToken);
// ============================================
// SETUP ENDPOINTS
// ============================================
/**
* POST /api/2fa/setup/totp/init
* Initialize TOTP setup - generate secret and QR code
*/
router.post(
"/setup/totp/init",
twoFactorSetupLimiter,
csrfProtection,
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({
error: "Multi-factor authentication is already enabled",
});
}
// Generate TOTP secret and QR code
const { qrCodeDataUrl, encryptedSecret, encryptedSecretIv } =
await TwoFactorService.generateTotpSecret(user.email);
// Store pending secret for verification
await user.storePendingTotpSecret(encryptedSecret, encryptedSecretIv);
auditLog(req, '2fa.setup.initiated', user.id, { method: 'totp' });
res.json({
qrCodeDataUrl,
message: "Scan the QR code with your authenticator app",
});
} catch (error) {
logger.error("TOTP setup init error:", error);
res.status(500).json({ error: "Failed to initialize TOTP setup" });
}
}
);
/**
* POST /api/2fa/setup/totp/verify
* Verify TOTP code and enable 2FA
*/
router.post(
"/setup/totp/verify",
twoFactorSetupLimiter,
csrfProtection,
sanitizeInput,
validateTotpCode,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({
error: "Multi-factor authentication is already enabled",
});
}
if (!user.twoFactorSetupPendingSecret) {
return res.status(400).json({
error: "No pending TOTP setup. Please start the setup process again.",
});
}
// Verify the code against the pending secret
const isValid = user.verifyPendingTotpCode(code);
if (!isValid) {
return res.status(400).json({
error: "Invalid verification code. Please try again.",
});
}
// Generate recovery codes
const { codes: recoveryCodes } =
await TwoFactorService.generateRecoveryCodes();
// Enable TOTP
await user.enableTotp(recoveryCodes);
// Send confirmation email
try {
await emailServices.auth.sendTwoFactorEnabledEmail(user);
} catch (emailError) {
logger.error("Failed to send 2FA enabled email:", emailError);
// Don't fail the request if email fails
}
auditLog(req, '2fa.setup.completed', user.id, { method: 'totp' });
res.json({
message: "Multi-factor authentication enabled successfully",
recoveryCodes,
warning:
"Save these recovery codes in a secure location. You will not be able to see them again.",
});
} catch (error) {
logger.error("TOTP setup verify error:", error);
res.status(500).json({ error: "Failed to enable multi-factor authentication" });
}
}
);
/**
* POST /api/2fa/setup/email/init
* Initialize email 2FA setup - send verification code
*/
router.post(
"/setup/email/init",
twoFactorSetupLimiter,
emailOtpSendLimiter,
csrfProtection,
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({
error: "Multi-factor authentication is already enabled",
});
}
// Generate and send email OTP
const otpCode = await user.generateEmailOtp();
try {
await emailServices.auth.sendTwoFactorOtpEmail(user, otpCode);
} catch (emailError) {
logger.error("Failed to send 2FA setup OTP email:", emailError);
return res.status(500).json({ error: "Failed to send verification email" });
}
auditLog(req, '2fa.setup.initiated', user.id, { method: 'email' });
res.json({
message: "Verification code sent to your email",
});
} catch (error) {
logger.error("Email 2FA setup init error:", error);
res.status(500).json({ error: "Failed to initialize email 2FA setup" });
}
}
);
/**
* POST /api/2fa/setup/email/verify
* Verify email OTP and enable email 2FA
*/
router.post(
"/setup/email/verify",
twoFactorSetupLimiter,
csrfProtection,
sanitizeInput,
validateEmailOtp,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({
error: "Multi-factor authentication is already enabled",
});
}
if (user.isEmailOtpLocked()) {
return res.status(429).json({
error: "Too many failed attempts. Please request a new code.",
});
}
// Verify the OTP
const isValid = user.verifyEmailOtp(code);
if (!isValid) {
await user.incrementEmailOtpAttempts();
return res.status(400).json({
error: "Invalid or expired verification code",
});
}
// Generate recovery codes
const { codes: recoveryCodes } =
await TwoFactorService.generateRecoveryCodes();
// Enable email 2FA
await user.enableEmailTwoFactor(recoveryCodes);
await user.clearEmailOtp();
// Send confirmation email
try {
await emailServices.auth.sendTwoFactorEnabledEmail(user);
} catch (emailError) {
logger.error("Failed to send 2FA enabled email:", emailError);
}
auditLog(req, '2fa.setup.completed', user.id, { method: 'email' });
res.json({
message: "Multi-factor authentication enabled successfully",
recoveryCodes,
warning:
"Save these recovery codes in a secure location. You will not be able to see them again.",
});
} catch (error) {
logger.error("Email 2FA setup verify error:", error);
res.status(500).json({ error: "Failed to enable multi-factor authentication" });
}
}
);
// ============================================
// VERIFICATION ENDPOINTS (Step-up auth)
// ============================================
/**
* POST /api/2fa/verify/totp
* Verify TOTP code for step-up authentication
*/
router.post(
"/verify/totp",
twoFactorVerificationLimiter,
csrfProtection,
sanitizeInput,
validateTotpCode,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled || user.twoFactorMethod !== "totp") {
logger.warn(`2FA verify failed for user ${user.id}: TOTP not enabled or wrong method`);
return res.status(400).json({
error: "Verification failed",
});
}
const isValid = user.verifyTotpCode(code);
if (!isValid) {
auditLog(req, '2fa.verify.failed', user.id, { method: 'totp' });
return res.status(400).json({
error: "Invalid verification code",
});
}
// Mark code as used for replay protection
await user.markTotpCodeUsed(code);
// Update step-up session
await user.updateStepUpSession();
auditLog(req, '2fa.verify.success', user.id, { method: 'totp' });
res.json({
message: "Verification successful",
verified: true,
});
} catch (error) {
logger.error("TOTP verification error:", error);
res.status(500).json({ error: "Verification failed" });
}
}
);
/**
* POST /api/2fa/verify/email/send
* Send email OTP for step-up authentication
*/
router.post(
"/verify/email/send",
emailOtpSendLimiter,
csrfProtection,
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`2FA verify failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Verification failed",
});
}
// Generate and send email OTP
const otpCode = await user.generateEmailOtp();
try {
await emailServices.auth.sendTwoFactorOtpEmail(user, otpCode);
} catch (emailError) {
logger.error("Failed to send 2FA OTP email:", emailError);
return res.status(500).json({ error: "Failed to send verification email" });
}
auditLog(req, '2fa.otp.sent', user.id, { method: 'email' });
res.json({
message: "Verification code sent to your email",
});
} catch (error) {
logger.error("Email OTP send error:", error);
res.status(500).json({ error: "Failed to send verification code" });
}
}
);
/**
* POST /api/2fa/verify/email
* Verify email OTP for step-up authentication
*/
router.post(
"/verify/email",
twoFactorVerificationLimiter,
csrfProtection,
sanitizeInput,
validateEmailOtp,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`2FA verify failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Verification failed",
});
}
if (user.isEmailOtpLocked()) {
return res.status(429).json({
error: "Too many failed attempts. Please request a new code.",
});
}
const isValid = user.verifyEmailOtp(code);
if (!isValid) {
await user.incrementEmailOtpAttempts();
auditLog(req, '2fa.verify.failed', user.id, { method: 'email' });
return res.status(400).json({
error: "Invalid or expired verification code",
});
}
// Update step-up session and clear OTP
await user.updateStepUpSession();
await user.clearEmailOtp();
auditLog(req, '2fa.verify.success', user.id, { method: 'email' });
res.json({
message: "Verification successful",
verified: true,
});
} catch (error) {
logger.error("Email OTP verification error:", error);
res.status(500).json({ error: "Verification failed" });
}
}
);
/**
* POST /api/2fa/verify/recovery
* Use recovery code for step-up authentication
*/
router.post(
"/verify/recovery",
recoveryCodeLimiter,
csrfProtection,
sanitizeInput,
validateRecoveryCode,
async (req, res) => {
try {
const { code } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`2FA verify failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Verification failed",
});
}
const { valid, remainingCodes } = await user.useRecoveryCode(code);
if (!valid) {
auditLog(req, '2fa.verify.failed', user.id, { method: 'recovery' });
return res.status(400).json({
error: "Invalid recovery code",
});
}
// Send alert email about recovery code usage
try {
await emailServices.auth.sendRecoveryCodeUsedEmail(user, remainingCodes);
} catch (emailError) {
logger.error("Failed to send recovery code used email:", emailError);
}
auditLog(req, '2fa.recovery.used', user.id, { lowCodes: remainingCodes <= 2 });
res.json({
message: "Verification successful",
verified: true,
remainingCodes,
warning:
remainingCodes <= 2
? "You are running low on recovery codes. Please generate new ones."
: null,
});
} catch (error) {
logger.error("Recovery code verification error:", error);
res.status(500).json({ error: "Verification failed" });
}
}
);
// ============================================
// MANAGEMENT ENDPOINTS
// ============================================
/**
* GET /api/2fa/status
* Get current 2FA status for the user
*/
router.get("/status", async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({
enabled: user.twoFactorEnabled,
method: user.twoFactorMethod,
hasRecoveryCodes: user.getRemainingRecoveryCodes() > 0,
lowRecoveryCodes: user.getRemainingRecoveryCodes() <= 2,
});
} catch (error) {
logger.error("2FA status error:", error);
res.status(500).json({ error: "Failed to get 2FA status" });
}
});
/**
* POST /api/2fa/disable
* Disable 2FA (requires step-up authentication)
*/
router.post(
"/disable",
csrfProtection,
requireStepUpAuth("2fa_disable"),
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`2FA disable failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Operation failed",
});
}
await user.disableTwoFactor();
// Send notification email
try {
await emailServices.auth.sendTwoFactorDisabledEmail(user);
} catch (emailError) {
logger.error("Failed to send 2FA disabled email:", emailError);
}
auditLog(req, '2fa.disabled', user.id);
res.json({
message: "Multi-factor authentication has been disabled",
});
} catch (error) {
logger.error("2FA disable error:", error);
res.status(500).json({ error: "Failed to disable multi-factor authentication" });
}
}
);
/**
* POST /api/2fa/recovery/regenerate
* Generate new recovery codes (requires step-up authentication)
*/
router.post(
"/recovery/regenerate",
csrfProtection,
requireStepUpAuth("recovery_regenerate"),
async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (!user.twoFactorEnabled) {
logger.warn(`Recovery regenerate failed for user ${user.id}: 2FA not enabled`);
return res.status(400).json({
error: "Operation failed",
});
}
const recoveryCodes = await user.regenerateRecoveryCodes();
auditLog(req, '2fa.recovery.regenerated', user.id);
res.json({
recoveryCodes,
warning:
"Save these recovery codes in a secure location. Your previous codes are now invalid.",
});
} catch (error) {
logger.error("Recovery code regeneration error:", error);
res.status(500).json({ error: "Failed to regenerate recovery codes" });
}
}
);
/**
* GET /api/2fa/recovery/remaining
* Get recovery codes status (not exact count for security)
*/
router.get("/recovery/remaining", async (req, res) => {
try {
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const remaining = user.getRemainingRecoveryCodes();
res.json({
hasRecoveryCodes: remaining > 0,
lowRecoveryCodes: remaining <= 2,
});
} catch (error) {
logger.error("Recovery codes remaining error:", error);
res.status(500).json({ error: "Failed to get recovery codes status" });
}
});
module.exports = router;

View File

@@ -1,9 +1,12 @@
const express = require('express');
const { User, UserAddress } = require('../models'); // Import from models/index.js to get models with associations
const { authenticateToken, optionalAuth, requireAdmin } = require('../middleware/auth');
const { validateCoordinatesBody, handleValidationErrors } = require('../middleware/validation');
const { validateCoordinatesBody, validatePasswordChange, handleValidationErrors, sanitizeInput } = require('../middleware/validation');
const { requireStepUpAuth } = require('../middleware/stepUpAuth');
const { csrfProtection } = require('../middleware/csrf');
const logger = require('../utils/logger');
const userService = require('../services/UserService');
const emailServices = require('../services/email');
const { validateS3Keys } = require('../utils/s3KeyValidator');
const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router();
@@ -362,6 +365,58 @@ router.post('/admin/:id/ban', authenticateToken, requireAdmin, async (req, res,
}
});
// Change password (requires step-up auth if 2FA is enabled)
router.put('/password', authenticateToken, csrfProtection, requireStepUpAuth('password_change'), sanitizeInput, validatePasswordChange, async (req, res, next) => {
try {
const { currentPassword, newPassword } = req.body;
const user = await User.findByPk(req.user.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Google OAuth users can't change password
if (user.authProvider === 'google' && !user.password) {
return res.status(400).json({
error: 'Cannot change password for accounts linked with Google'
});
}
// Verify current password
const isValid = await user.comparePassword(currentPassword);
if (!isValid) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
// Update password (this increments jwtVersion to invalidate other sessions)
await user.resetPassword(newPassword);
// Send password changed notification
try {
await emailServices.auth.sendPasswordChangedEmail(user);
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Failed to send password changed email', {
error: emailError.message,
userId: req.user.id
});
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info('Password changed successfully', { userId: req.user.id });
res.json({ message: 'Password changed successfully' });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error('Password change failed', {
error: error.message,
stack: error.stack,
userId: req.user.id
});
next(error);
}
});
// Admin: Unban a user
router.post('/admin/:id/unban', authenticateToken, requireAdmin, async (req, res, next) => {
try {