Files
jackiettran 5d3c124d3e text changes
2026-01-21 19:20:07 -05:00

716 lines
18 KiB
JavaScript

const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const bcrypt = require("bcryptjs");
const User = sequelize.define(
"User",
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
email: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
validate: {
isEmail: true,
},
},
password: {
type: DataTypes.STRING,
allowNull: true,
},
firstName: {
type: DataTypes.STRING,
allowNull: false,
},
lastName: {
type: DataTypes.STRING,
allowNull: false,
},
phone: {
type: DataTypes.STRING,
allowNull: true,
},
authProvider: {
type: DataTypes.ENUM("local", "google"),
defaultValue: "local",
},
providerId: {
type: DataTypes.STRING,
allowNull: true,
},
address1: {
type: DataTypes.STRING,
},
address2: {
type: DataTypes.STRING,
},
city: {
type: DataTypes.STRING,
},
state: {
type: DataTypes.STRING,
},
zipCode: {
type: DataTypes.STRING,
},
country: {
type: DataTypes.STRING,
},
imageFilename: {
type: DataTypes.TEXT,
},
isVerified: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
verificationToken: {
type: DataTypes.STRING,
allowNull: true,
},
verificationTokenExpiry: {
type: DataTypes.DATE,
allowNull: true,
},
verifiedAt: {
type: DataTypes.DATE,
allowNull: true,
},
passwordResetToken: {
type: DataTypes.STRING,
allowNull: true,
},
passwordResetTokenExpiry: {
type: DataTypes.DATE,
allowNull: true,
},
defaultAvailableAfter: {
type: DataTypes.STRING,
defaultValue: "00:00",
},
defaultAvailableBefore: {
type: DataTypes.STRING,
defaultValue: "23:00",
},
defaultSpecifyTimesPerDay: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
defaultWeeklyTimes: {
type: DataTypes.JSONB,
defaultValue: {
sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "00:00", availableBefore: "23:00" },
},
},
stripeConnectedAccountId: {
type: DataTypes.STRING,
allowNull: true,
},
stripePayoutsEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: true,
},
stripeCustomerId: {
type: DataTypes.STRING,
allowNull: true,
},
stripeRequirementsCurrentlyDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeRequirementsPastDue: {
type: DataTypes.JSON,
defaultValue: [],
allowNull: true,
},
stripeDisabledReason: {
type: DataTypes.STRING,
allowNull: true,
},
stripeRequirementsLastUpdated: {
type: DataTypes.DATE,
allowNull: true,
},
loginAttempts: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
lockUntil: {
type: DataTypes.DATE,
allowNull: true,
},
jwtVersion: {
type: DataTypes.INTEGER,
defaultValue: 0,
allowNull: false,
},
role: {
type: DataTypes.ENUM("user", "admin"),
defaultValue: "user",
allowNull: false,
},
isBanned: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
},
bannedAt: {
type: DataTypes.DATE,
allowNull: true,
},
bannedBy: {
type: DataTypes.UUID,
allowNull: true,
},
banReason: {
type: DataTypes.TEXT,
allowNull: true,
},
itemRequestNotificationRadius: {
type: DataTypes.INTEGER,
defaultValue: 10,
allowNull: true,
validate: {
min: 1,
max: 100,
},
},
verificationAttempts: {
type: DataTypes.INTEGER,
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: {
beforeCreate: async (user) => {
if (user.password) {
user.password = await bcrypt.hash(user.password, 12);
}
},
beforeUpdate: async (user) => {
if (user.changed("password") && user.password) {
user.password = await bcrypt.hash(user.password, 12);
}
},
},
},
);
User.prototype.comparePassword = async function (password) {
if (!this.password) {
return false;
}
return bcrypt.compare(password, this.password);
};
// Account lockout constants
const MAX_LOGIN_ATTEMPTS = 10;
const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours
// Check if account is locked
User.prototype.isLocked = function () {
return !!(this.lockUntil && this.lockUntil > Date.now());
};
// Increment login attempts and lock account if necessary
User.prototype.incLoginAttempts = async function () {
// If we have a previous lock that has expired, restart at 1
if (this.lockUntil && this.lockUntil < Date.now()) {
return this.update({
loginAttempts: 1,
lockUntil: null,
});
}
const updates = { loginAttempts: this.loginAttempts + 1 };
// Lock account after max attempts
if (this.loginAttempts + 1 >= MAX_LOGIN_ATTEMPTS && !this.isLocked()) {
updates.lockUntil = Date.now() + LOCK_TIME;
}
return this.update(updates);
};
// Reset login attempts after successful login
User.prototype.resetLoginAttempts = async function () {
return this.update({
loginAttempts: 0,
lockUntil: null,
});
};
// Email verification methods
// Maximum verification attempts before requiring a new code
const MAX_VERIFICATION_ATTEMPTS = 5;
User.prototype.generateVerificationToken = async function () {
const crypto = require("crypto");
// Generate 6-digit numeric code (100000-999999)
const code = crypto.randomInt(100000, 999999).toString();
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
return this.update({
verificationToken: code,
verificationTokenExpiry: expiry,
verificationAttempts: 0, // Reset attempts on new code
});
};
User.prototype.isVerificationTokenValid = function (token) {
const crypto = require("crypto");
if (!this.verificationToken || !this.verificationTokenExpiry) {
return false;
}
// Check if token is expired
if (new Date() > new Date(this.verificationTokenExpiry)) {
return false;
}
// Validate 6-digit format
if (!/^\d{6}$/.test(token)) {
return false;
}
// Use timing-safe comparison to prevent timing attacks
try {
const inputBuffer = Buffer.from(token);
const storedBuffer = Buffer.from(this.verificationToken);
if (inputBuffer.length !== storedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(inputBuffer, storedBuffer);
} catch {
return false;
}
};
// Check if too many verification attempts
User.prototype.isVerificationLocked = function () {
return (this.verificationAttempts || 0) >= MAX_VERIFICATION_ATTEMPTS;
};
// Increment verification attempts
User.prototype.incrementVerificationAttempts = async function () {
const newAttempts = (this.verificationAttempts || 0) + 1;
await this.update({ verificationAttempts: newAttempts });
return newAttempts;
};
User.prototype.verifyEmail = async function () {
return this.update({
isVerified: true,
verifiedAt: new Date(),
verificationToken: null,
verificationTokenExpiry: null,
verificationAttempts: 0,
});
};
// Password reset methods
User.prototype.generatePasswordResetToken = async function () {
const crypto = require("crypto");
// Generate random token for email URL
const token = crypto.randomBytes(32).toString("hex");
// Hash token before storing in database (SHA-256)
const hashedToken = crypto.createHash("sha256").update(token).digest("hex");
const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await this.update({
passwordResetToken: hashedToken,
passwordResetTokenExpiry: expiry,
});
// Return plain token for email URL (not stored in DB)
return token;
};
User.prototype.isPasswordResetTokenValid = function (token) {
if (!this.passwordResetToken || !this.passwordResetTokenExpiry) {
return false;
}
// Check if token is expired first
if (new Date() > new Date(this.passwordResetTokenExpiry)) {
return false;
}
const crypto = require("crypto");
// Hash the incoming token to compare with stored hash
const hashedToken = crypto.createHash("sha256").update(token).digest("hex");
// Use timing-safe comparison to prevent timing attacks
const storedTokenBuffer = Buffer.from(this.passwordResetToken, "hex");
const hashedTokenBuffer = Buffer.from(hashedToken, "hex");
// Ensure buffers are same length for timingSafeEqual
if (storedTokenBuffer.length !== hashedTokenBuffer.length) {
return false;
}
return crypto.timingSafeEqual(storedTokenBuffer, hashedTokenBuffer);
};
User.prototype.resetPassword = async function (newPassword) {
return this.update({
password: newPassword,
passwordResetToken: null,
passwordResetTokenExpiry: null,
// Increment JWT version to invalidate all existing sessions
jwtVersion: this.jwtVersion + 1,
});
};
// Ban user method - sets ban fields and invalidates all sessions
User.prototype.banUser = async function (adminId, reason) {
return this.update({
isBanned: true,
bannedAt: new Date(),
bannedBy: adminId,
banReason: reason,
// Increment JWT version to immediately invalidate all sessions
jwtVersion: this.jwtVersion + 1,
});
};
// Unban user method - clears ban fields
User.prototype.unbanUser = async function () {
return this.update({
isBanned: false,
bannedAt: null,
bannedBy: null,
banReason: null,
// We don't increment jwtVersion on unban - user will need to log in fresh
});
};
// 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;