716 lines
18 KiB
JavaScript
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;
|