UNPKG

lad

Version:

Lad is the best Node.js framework. Made by a former Express TC and Koa team member.

383 lines (334 loc) 11 kB
const Boom = require('@hapi/boom'); const _ = require('lodash'); const captainHook = require('captain-hook'); const cryptoRandomString = require('crypto-random-string'); const isSANB = require('is-string-and-not-blank'); const dayjs = require('dayjs'); const relativeTime = require('dayjs/plugin/relativeTime'); const updateLocale = require('dayjs/plugin/updateLocale'); const mongoose = require('mongoose'); const mongooseCommonPlugin = require('mongoose-common-plugin'); const mongooseOmitCommonFields = require('mongoose-omit-common-fields'); const passportLocalMongoose = require('passport-local-mongoose'); const validator = require('validator'); const { authenticator } = require('otplib'); const { boolean } = require('boolean'); // <https://github.com/Automattic/mongoose/issues/5534> mongoose.Error.messages = require('@ladjs/mongoose-error-messages'); const logger = require('../../helpers/logger'); const config = require('../../config'); const i18n = require('../../helpers/i18n'); if (config.passportLocalMongoose.usernameField !== 'email') throw new Error( 'User model and @ladjs/passport requires that the usernameField is email' ); const options = { length: 10, type: 'numeric' }; const { fields } = config.passport; const omitExtraFields = [ ..._.without(mongooseOmitCommonFields.underscored.keys, 'email'), config.userFields.apiToken, config.userFields.resetTokenExpiresAt, config.userFields.resetToken, config.userFields.changeEmailTokenExpiresAt, config.userFields.changeEmailToken, config.userFields.changeEmailNewAddress, config.userFields.hasSetPassword, config.userFields.hasVerifiedEmail, config.userFields.verificationPinExpiresAt, config.userFields.verificationPin, config.userFields.verificationPinSentAt, config.userFields.welcomeEmailSentAt, config.userFields.otpRecoveryKeys, config.userFields.pendingRecovery, config.userFields.accountUpdates, config.userFields.twoFactorReminderSentAt, fields.otpEnabled, fields.otpToken ]; // set relative threshold for messages dayjs.extend(updateLocale, { thresholds: [{ l: 'ss', r: 5 }] }); dayjs.extend(relativeTime); const User = new mongoose.Schema({ // group permissions group: { type: String, default: 'user', enum: ['admin', 'user'], lowercase: true, trim: true }, email: { type: String, required: true, index: true, trim: true, lowercase: true, unique: true, validate: (value) => validator.isEmail(value) } }); // additional variable based properties to add to the schema const object = {}; // two factor auth reminders object[config.userFields.twoFactorReminderSentAt] = Date; object[config.userFields.fullEmail] = { type: String, required: true, trim: true }; // api token for basic auth object[config.userFields.apiToken] = { type: String, required: true, lowercase: true, trim: true, unique: true, index: true }; object[config.userFields.otpRecoveryKeys] = Array; // password reset object[config.userFields.resetTokenExpiresAt] = Date; object[config.userFields.resetToken] = String; // email change object[config.userFields.changeEmailTokenExpiresAt] = Date; object[config.userFields.changeEmailToken] = String; object[config.userFields.changeEmailNewAddress] = String; // welcome email object[config.userFields.welcomeEmailSentAt] = Date; // account verification object[config.userFields.hasSetPassword] = { type: Boolean, default: false // manually set to true during web/API signup }; object[config.userFields.hasVerifiedEmail] = { type: Boolean, default: true // manually set to false during web/API signup }; object[config.userFields.verificationPinExpiresAt] = Date; object[config.userFields.verificationPinSentAt] = Date; object[config.userFields.verificationPin] = { type: String, trim: true, validate: (value) => isSANB(value) && value.replace(/\D/g, '').length === 6 }; object[config.userFields.pendingRecovery] = { type: Boolean, default: false }; object[config.userFields.pendingRecovery] = { type: Boolean, default: false }; // list of account updates that are batched every 1 min. object[config.userFields.accountUpdates] = Array; object[config.userFields.accountUpdates] = Array; // shared field names with @ladjs/passport for consistency object[fields.displayName] = { type: String, required: true, trim: true, maxlength: 70 }; object[fields.givenName] = { type: String, trim: true, maxlength: 35 }; object[fields.familyName] = { type: String, trim: true, maxlength: 35 }; object[fields.avatarURL] = { type: String, trim: true, validate: (value) => validator.isURL(value) }; // google object[fields.googleProfileID] = { type: String, index: true }; object[fields.googleAccessToken] = String; object[fields.googleRefreshToken] = String; // github object[fields.githubProfileID] = { type: String, index: true }; object[fields.githubAccessToken] = String; object[fields.githubRefreshToken] = String; object[fields.otpEnabled] = { type: Boolean, default: false }; object[fields.otpToken] = String; // shared field names with @ladjs/i18n and email-templates object[config.lastLocaleField] = { type: String, default: i18n.config.defaultLocale }; // finally add the fields User.add(object); User.plugin(captainHook); User.virtual(config.userFields.verificationPinHasExpired).get(function () { return boolean( !this[config.userFields.verificationPinExpiresAt] || new Date(this[config.userFields.verificationPinExpiresAt]).getTime() < Date.now() ); }); User.pre('validate', async function (next) { try { // create api token if doesn't exist if (!isSANB(this[config.userFields.apiToken])) this[config.userFields.apiToken] = await cryptoRandomString.async({ length: 24 }); // set the user's display name to their email address // but if they have a name or surname set then use that this[fields.displayName] = this.email; if (isSANB(this[fields.givenName]) || isSANB(this[fields.familyName])) { this[fields.displayName] = `${this[fields.givenName] || ''} ${ this[fields.familyName] || '' }`; } // set the user's full email address (incl display name) this[config.userFields.fullEmail] = this[fields.displayName] && this[fields.displayName] !== this.email ? `${this[fields.displayName]} <${this.email}>` : this.email; // if otp authentication values no longer valid // then disable it completely if ( !Array.isArray(this[config.userFields.otpRecoveryKeys]) || !this[config.userFields.otpRecoveryKeys] || this[config.userFields.otpRecoveryKeys].length === 0 || !this[config.passport.fields.otpToken] ) this[fields.otpEnabled] = false; if ( !Array.isArray(this[config.userFields.otpRecoveryKeys]) || this[config.userFields.otpRecoveryKeys].length === 0 ) this[config.userFields.otpRecoveryKeys] = await Promise.all( new Array(10).fill().map(() => cryptoRandomString.async(options)) ); if (!this[config.passport.fields.otpToken]) this[config.passport.fields.otpToken] = authenticator.generateSecret(); next(); } catch (err) { next(err); } }); // // NOTE: you should not call this method directly // instead you should use the helper located at // `../helpers/send-verification-email.js` // User.methods.sendVerificationEmail = async function (ctx, reset = false) { if ( this[config.userFields.hasVerifiedEmail] && boolean(!this[config.userFields.pendingRecovery]) ) return this; if (reset) { this[config.userFields.verificationPinExpiresAt] = this[`__${config.userFields.verificationPinExpiresAt}`]; this[config.userFields.verificationPinSentAt] = this[`__${config.userFields.verificationPinSentAt}`]; this[config.userFields.verificationPin] = this[`__${config.userFields.verificationPin}`]; await this.save(); return this; } // store old values in case we have to reset this[`__${config.userFields.verificationPinExpiresAt}`] = this[config.userFields.verificationPinExpiresAt]; this[`__${config.userFields.verificationPinSentAt}`] = this[config.userFields.verificationPinSentAt]; this[`__${config.userFields.verificationPin}`] = this[config.userFields.verificationPin]; const diff = this[config.userFields.verificationPinExpiresAt] && this[config.userFields.verificationPinSentAt] ? Date.now() - new Date(this[config.userFields.verificationPinSentAt]).getTime() : false; const sendNewEmail = this[config.userFields.verificationPinHasExpired] || (diff && diff >= config.verificationPinEmailIntervalMs); // ensure the user waited as long as necessary to send a new pin email if (!sendNewEmail) { const message = i18n.api.t( { phrase: config.i18n.phrases.EMAIL_VERIFICATION_INTERVAL, locale: this[config.lastLocaleField] }, dayjs .duration(config.verificationPinEmailIntervalMs - diff) .locale(this[config.lastLocaleField]) .humanize() ); if (ctx) { const err = Boom.badRequest(message); err.no_translate = true; throw err; } const err = new Error(message); err.no_translate = true; throw err; } if (this[config.userFields.verificationPinHasExpired]) { this[config.userFields.verificationPinExpiresAt] = new Date( Date.now() + config.verificationPinTimeoutMs ); this[config.userFields.verificationPin] = await cryptoRandomString.async( config.verificationPin ); } this[config.userFields.verificationPinSentAt] = new Date(); await this.save(); return this; }; User.plugin(mongooseCommonPlugin, { object: 'user', omitCommonFields: false, omitExtraFields, mongooseHidden: { virtuals: { [config.userFields.verificationPinHasExpired]: 'hide' } } }); User.plugin(passportLocalMongoose, config.passportLocalMongoose); User.post('init', (doc) => { for (const field of config.accountUpdateFields) { const fieldName = _.get(config, field); doc[`__${fieldName}`] = doc[fieldName]; } }); User.pre('save', function (next) { // filter by allowed field updates (otp enabled, profile updates, etc) for (const field of config.accountUpdateFields) { const fieldName = _.get(config, field); if (this[`__${fieldName}`] && this[`__${fieldName}`] !== this[fieldName]) { this[config.userFields.accountUpdates].push({ fieldName, current: this[fieldName], previous: this[`__${fieldName}`] }); // reset so we don't get into infinite loop this[`__${fieldName}`] = this[fieldName]; } } next(); }); User.postCreate((user, next) => { logger.info('user created', { user: user.toObject(), slack: true }); next(); }); module.exports = mongoose.model('User', User);