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;