MFA
This commit is contained in:
@@ -191,6 +191,66 @@ const User = sequelize.define(
|
||||
defaultValue: 0,
|
||||
allowNull: true,
|
||||
},
|
||||
// Two-Factor Authentication fields
|
||||
twoFactorEnabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false,
|
||||
},
|
||||
twoFactorMethod: {
|
||||
type: DataTypes.ENUM("totp", "email"),
|
||||
allowNull: true,
|
||||
},
|
||||
totpSecret: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
totpSecretIv: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
// Email OTP fields (backup method)
|
||||
emailOtpCode: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
emailOtpExpiry: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
emailOtpAttempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
allowNull: false,
|
||||
},
|
||||
// Recovery codes
|
||||
recoveryCodesHash: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
recoveryCodesGeneratedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
// Step-up session tracking
|
||||
twoFactorVerifiedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
// Temporary secret during setup
|
||||
twoFactorSetupPendingSecret: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
twoFactorSetupPendingSecretIv: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
// TOTP replay protection
|
||||
recentTotpCodes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
hooks: {
|
||||
@@ -401,4 +461,245 @@ User.prototype.unbanUser = async function () {
|
||||
});
|
||||
};
|
||||
|
||||
// Two-Factor Authentication methods
|
||||
const TwoFactorService = require("../services/TwoFactorService");
|
||||
|
||||
// Store pending TOTP secret during setup
|
||||
User.prototype.storePendingTotpSecret = async function (
|
||||
encryptedSecret,
|
||||
encryptedSecretIv
|
||||
) {
|
||||
return this.update({
|
||||
twoFactorSetupPendingSecret: encryptedSecret,
|
||||
twoFactorSetupPendingSecretIv: encryptedSecretIv,
|
||||
});
|
||||
};
|
||||
|
||||
// Enable TOTP 2FA after verification
|
||||
User.prototype.enableTotp = async function (recoveryCodes) {
|
||||
const hashedCodes = await Promise.all(
|
||||
recoveryCodes.map((code) => bcrypt.hash(code, 12))
|
||||
);
|
||||
|
||||
// Store in structured format
|
||||
const recoveryData = {
|
||||
version: 1,
|
||||
codes: hashedCodes.map((hash) => ({
|
||||
hash,
|
||||
used: false,
|
||||
})),
|
||||
};
|
||||
|
||||
return this.update({
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: "totp",
|
||||
totpSecret: this.twoFactorSetupPendingSecret,
|
||||
totpSecretIv: this.twoFactorSetupPendingSecretIv,
|
||||
twoFactorSetupPendingSecret: null,
|
||||
twoFactorSetupPendingSecretIv: null,
|
||||
recoveryCodesHash: JSON.stringify(recoveryData),
|
||||
recoveryCodesGeneratedAt: new Date(),
|
||||
twoFactorVerifiedAt: new Date(), // Consider setup as verification
|
||||
});
|
||||
};
|
||||
|
||||
// Enable Email 2FA
|
||||
User.prototype.enableEmailTwoFactor = async function (recoveryCodes) {
|
||||
const hashedCodes = await Promise.all(
|
||||
recoveryCodes.map((code) => bcrypt.hash(code, 12))
|
||||
);
|
||||
|
||||
// Store in structured format
|
||||
const recoveryData = {
|
||||
version: 1,
|
||||
codes: hashedCodes.map((hash) => ({
|
||||
hash,
|
||||
used: false,
|
||||
})),
|
||||
};
|
||||
|
||||
return this.update({
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: "email",
|
||||
recoveryCodesHash: JSON.stringify(recoveryData),
|
||||
recoveryCodesGeneratedAt: new Date(),
|
||||
twoFactorVerifiedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
// Disable 2FA
|
||||
User.prototype.disableTwoFactor = async function () {
|
||||
return this.update({
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
totpSecret: null,
|
||||
totpSecretIv: null,
|
||||
emailOtpCode: null,
|
||||
emailOtpExpiry: null,
|
||||
emailOtpAttempts: 0,
|
||||
recoveryCodesHash: null,
|
||||
recoveryCodesGeneratedAt: null,
|
||||
twoFactorVerifiedAt: null,
|
||||
twoFactorSetupPendingSecret: null,
|
||||
twoFactorSetupPendingSecretIv: null,
|
||||
});
|
||||
};
|
||||
|
||||
// Generate and store email OTP
|
||||
User.prototype.generateEmailOtp = async function () {
|
||||
const { code, hashedCode, expiry } = TwoFactorService.generateEmailOtp();
|
||||
|
||||
await this.update({
|
||||
emailOtpCode: hashedCode,
|
||||
emailOtpExpiry: expiry,
|
||||
emailOtpAttempts: 0,
|
||||
});
|
||||
|
||||
return code; // Return plain code for sending via email
|
||||
};
|
||||
|
||||
// Verify email OTP
|
||||
User.prototype.verifyEmailOtp = function (inputCode) {
|
||||
return TwoFactorService.verifyEmailOtp(
|
||||
inputCode,
|
||||
this.emailOtpCode,
|
||||
this.emailOtpExpiry
|
||||
);
|
||||
};
|
||||
|
||||
// Increment email OTP attempts
|
||||
User.prototype.incrementEmailOtpAttempts = async function () {
|
||||
const newAttempts = (this.emailOtpAttempts || 0) + 1;
|
||||
await this.update({ emailOtpAttempts: newAttempts });
|
||||
return newAttempts;
|
||||
};
|
||||
|
||||
// Check if email OTP is locked
|
||||
User.prototype.isEmailOtpLocked = function () {
|
||||
return TwoFactorService.isEmailOtpLocked(this.emailOtpAttempts || 0);
|
||||
};
|
||||
|
||||
// Clear email OTP after successful verification
|
||||
User.prototype.clearEmailOtp = async function () {
|
||||
return this.update({
|
||||
emailOtpCode: null,
|
||||
emailOtpExpiry: null,
|
||||
emailOtpAttempts: 0,
|
||||
});
|
||||
};
|
||||
|
||||
// Check if a TOTP code was recently used (replay protection)
|
||||
User.prototype.hasUsedTotpCode = function (code) {
|
||||
const crypto = require("crypto");
|
||||
const recentCodes = JSON.parse(this.recentTotpCodes || "[]");
|
||||
const codeHash = crypto.createHash("sha256").update(code).digest("hex");
|
||||
return recentCodes.includes(codeHash);
|
||||
};
|
||||
|
||||
// Mark a TOTP code as used (replay protection)
|
||||
User.prototype.markTotpCodeUsed = async function (code) {
|
||||
const crypto = require("crypto");
|
||||
const recentCodes = JSON.parse(this.recentTotpCodes || "[]");
|
||||
const codeHash = crypto.createHash("sha256").update(code).digest("hex");
|
||||
recentCodes.unshift(codeHash);
|
||||
// Keep only last 5 codes (covers about 2.5 minutes of 30-second windows)
|
||||
await this.update({ recentTotpCodes: JSON.stringify(recentCodes.slice(0, 5)) });
|
||||
};
|
||||
|
||||
// Verify TOTP code with replay protection
|
||||
User.prototype.verifyTotpCode = function (code) {
|
||||
if (!this.totpSecret || !this.totpSecretIv) {
|
||||
return false;
|
||||
}
|
||||
// Check for replay attack
|
||||
if (this.hasUsedTotpCode(code)) {
|
||||
return false;
|
||||
}
|
||||
return TwoFactorService.verifyTotpCode(this.totpSecret, this.totpSecretIv, code);
|
||||
};
|
||||
|
||||
// Verify pending TOTP code (during setup)
|
||||
User.prototype.verifyPendingTotpCode = function (code) {
|
||||
if (!this.twoFactorSetupPendingSecret || !this.twoFactorSetupPendingSecretIv) {
|
||||
return false;
|
||||
}
|
||||
return TwoFactorService.verifyTotpCode(
|
||||
this.twoFactorSetupPendingSecret,
|
||||
this.twoFactorSetupPendingSecretIv,
|
||||
code
|
||||
);
|
||||
};
|
||||
|
||||
// Use a recovery code
|
||||
User.prototype.useRecoveryCode = async function (inputCode) {
|
||||
if (!this.recoveryCodesHash) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
const recoveryData = JSON.parse(this.recoveryCodesHash);
|
||||
const { valid, index } = await TwoFactorService.verifyRecoveryCode(
|
||||
inputCode,
|
||||
recoveryData
|
||||
);
|
||||
|
||||
if (valid) {
|
||||
// Handle both old and new format
|
||||
if (recoveryData.version) {
|
||||
// New structured format - mark as used with timestamp
|
||||
recoveryData.codes[index].used = true;
|
||||
recoveryData.codes[index].usedAt = new Date().toISOString();
|
||||
} else {
|
||||
// Legacy format - set to null
|
||||
recoveryData[index] = null;
|
||||
}
|
||||
|
||||
await this.update({
|
||||
recoveryCodesHash: JSON.stringify(recoveryData),
|
||||
twoFactorVerifiedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
valid,
|
||||
remainingCodes: TwoFactorService.getRemainingRecoveryCodesCount(recoveryData),
|
||||
};
|
||||
};
|
||||
|
||||
// Get remaining recovery codes count
|
||||
User.prototype.getRemainingRecoveryCodes = function () {
|
||||
if (!this.recoveryCodesHash) {
|
||||
return 0;
|
||||
}
|
||||
const recoveryData = JSON.parse(this.recoveryCodesHash);
|
||||
return TwoFactorService.getRemainingRecoveryCodesCount(recoveryData);
|
||||
};
|
||||
|
||||
// Regenerate recovery codes
|
||||
User.prototype.regenerateRecoveryCodes = async function () {
|
||||
const { codes, hashedCodes } = await TwoFactorService.generateRecoveryCodes();
|
||||
|
||||
// Store in structured format
|
||||
const recoveryData = {
|
||||
version: 1,
|
||||
codes: hashedCodes.map((hash) => ({
|
||||
hash,
|
||||
used: false,
|
||||
})),
|
||||
};
|
||||
|
||||
await this.update({
|
||||
recoveryCodesHash: JSON.stringify(recoveryData),
|
||||
recoveryCodesGeneratedAt: new Date(),
|
||||
});
|
||||
|
||||
return codes; // Return plain codes for display to user
|
||||
};
|
||||
|
||||
// Update step-up verification timestamp
|
||||
User.prototype.updateStepUpSession = async function () {
|
||||
return this.update({
|
||||
twoFactorVerifiedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = User;
|
||||
|
||||
Reference in New Issue
Block a user