MFA
This commit is contained in:
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
Reference in New Issue
Block a user