Files
rentall-app/backend/services/TwoFactorService.js
2026-01-21 19:00:55 -05:00

306 lines
8.7 KiB
JavaScript

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;
const EMAIL_OTP_EXPIRY_MINUTES = parseInt(
process.env.TWO_FACTOR_EMAIL_OTP_EXPIRY_MINUTES,
10,
);
const STEP_UP_VALIDITY_MINUTES = parseInt(
process.env.TWO_FACTOR_STEP_UP_VALIDITY_MINUTES,
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;