// user.js // Copyright (C) 2021 Digital Telepresence, LLC // License: Apache-2.0 'use strict'; const mongoose = require('mongoose'); const User = mongoose.model('User'); const passport = require('passport'); const PassportLocal = require('passport-local'); const striptags = require('striptags'); const uuidv4 = require('uuid').v4; const { SiteError, SiteLog } = require('../../lib/site-lib'); class UserService { constructor (dtp) { this.dtp = dtp; this.log = new SiteLog(dtp, `svc:${module.exports.slug}`); } async start ( ) { this.log.info(`starting ${module.exports.name} service`); this.registerPassportLocal(); if (process.env.DTP_ADMIN === 'enabled') { this.registerPassportAdmin(); } } async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); } async create (userDefinition) { const NOW = new Date(); const { crypto: cryptoService, email: mailService, } = this.dtp.services; try { userDefinition.email = userDefinition.email.trim().toLowerCase(); // strip characters we don't want to allow in username userDefinition.username = userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''); const username_lc = userDefinition.username.toLowerCase(); // test the email address for validity, blacklisting, etc. await mailService.checkEmailAddress(userDefinition.email); // test if we already have a user with this email address let user = await User.findOne({ 'email': userDefinition.email.toLowerCase().trim() }).lean(); if (user) { throw new SiteError(400, `An account with email address ${userDefinition.email} already exists.`); } // test if we already have a user with this username user = await User.findOne({ username_lc }).lean(); if (user) { throw new SiteError(400, `An account with username ${userDefinition.username} already exists.`); } const passwordSalt = uuidv4(); const maskedPassword = cryptoService.maskPassword(passwordSalt, userDefinition.password); user = new User(); user.created = NOW; user.email = userDefinition.email; user.username = userDefinition.username; user.username_lc = username_lc; user.passwordSalt = passwordSalt; user.password = maskedPassword; user.flags = { isAdmin: false, isModerator: false, }; user.permissions = { canLogin: true, canChat: true, }; this.log.info('creating new user account', { email: userDefinition.email }); await user.save(); return user.toObject(); } catch (error) { this.log.error('failed to create user', { error }); throw error; } } async update (user, userDefinition) { // strip characters we don't want to allow in username userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '')); const username_lc = userDefinition.username.toLowerCase(); userDefinition.displayName = striptags(userDefinition.displayName.trim()); this.log.info('updating user', { userDefinition }); await User.updateOne( { _id: user._id }, { $set: { username: userDefinition.username, username_lc, displayName: userDefinition.displayName, 'flags.isAdmin': userDefinition.isAdmin === 'on', 'flags.isModerator': userDefinition.isModerator === 'on', 'permissions.canLogin': userDefinition.canLogin === 'on', 'permissions.canChat': userDefinition.canChat === 'on', }, }, ); } async updateSettings (user, userDefinition) { // strip characters we don't want to allow in username userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '')); const username_lc = userDefinition.username.toLowerCase(); userDefinition.displayName = striptags(userDefinition.displayName.trim()); this.log.info('updating user settings', { userDefinition }); await User.updateOne( { _id: user._id }, { $set: { username: userDefinition.username, username_lc, displayName: userDefinition.displayName, }, }, ); } async authenticate (account, options) { const { crypto } = this.dtp.services; options = Object.assign({ adminRequired: false, }, options); const accountEmail = account.username.trim().toLowerCase(); const accountUsername = await this.filterUsername(accountEmail); this.log.debug('locating user record', { accountEmail, accountUsername }); const user = await User .findOne({ $or: [ { email: accountEmail }, { username_lc: accountUsername }, ] }) .select('+passwordSalt +password +flags') .lean(); if (!user) { throw new SiteError(404, 'Member account not found'); } const maskedPassword = crypto.maskPassword( user.passwordSalt, account.password, ); if (maskedPassword !== user.password) { throw new SiteError(403, 'Account credentials do not match'); } // remove these critical fields from the user object delete user.passwordSalt; delete user.password; if (options.adminRequired && !user.flags.isAdmin) { throw new SiteError(403, 'Admin privileges required'); } this.log.debug('user authenticated', { user }); return user; } registerPassportLocal ( ) { const options = { usernameField: 'username', passwordField: 'password', session: true, }; passport.use('dtp-local', new PassportLocal(options, this.handleLocalLogin.bind(this))); } async handleLocalLogin (username, password, done) { const now = new Date(); this.log.info('handleLocalLogin', { username, password }); try { const user = await this.authenticate({ username, password }, { adminRequired: false }); await this.startUserSession(user, now); done(null, this.filterUserObject(user)); } catch (error) { this.log.error('failed to process local user login', { error }); done(error); } } registerPassportAdmin ( ) { const options = { usernameField: 'username', passwordField: 'password', session: true, }; this.log.info('registering PassportJS admin strategy', { options }); passport.use('dtp-admin', new PassportLocal(options, this.handleAdminLogin.bind(this))); } async handleAdminLogin (email, password, done) { const now = new Date(); try { const user = await this.authenticate({ email, password }, { adminRequired: true }); await this.startUserSession(user, now); done(null, this.filterUserObject(user)); } catch (error) { this.log.error('failed to process admin user login', { error }); done(error); } } async startUserSession (user, now) { await User.updateOne( { _id: user._id }, { $set: { 'stats.lastLogin': now }, $inc: { 'stats.loginCount': 1 }, }, ); } filterUserObject (user) { return { _id: user._id, email: user.email, created: user.created, flags: user.flags, permissions: user.permissions, }; } async getUserAccount (userId) { const user = await User .findById(userId) .select('+email +flags +permissions') .lean(); if (!user) { throw new SiteError(404, 'Member account not found'); } return user; } async getUserAccounts (pagination) { const users = await User .find() .sort({ username_lc: 1 }) .select('+email +flags +permissions') .skip(pagination.skip) .limit(pagination.cpp) .lean() ; return users; } async getUserProfile (userId) { let user; try { userId = mongoose.Types.ObjectId(userId); // will throw if invalid format user = User.findById(userId); } catch (error) { user = User.findOne({ username: userId }); } user = await user.select('+email +flags +settings').lean(); return user; } async setUserSettings (user, settings) { const { crypto: cryptoService, mail: mailService, phone: phoneService, } = this.dtp.platform.services; const update = { $set: { } }; const actions = [ ]; if (settings.name && (settings.name !== user.name)) { update.name = striptags(settings.name.trim()); update.name_lc = update.name.toLowerCase(); actions.push('Display name updated'); } if (settings.username && (settings.username !== user.username)) { update.username = this.filterUsername(settings.username); const isReserved = await this.isUsernameReserved(update.username); if (!isReserved) { throw new SiteError(403, 'The username you entered is taken'); } } if (settings.email && (settings.email !== user.email)) { settings.email = settings.email.toLowerCase().trim(); await mailService.checkEmailAddress(settings.email); update.$set['flags.isEmailVerified'] = false; update.$set.email = settings.email; actions.push('Email address updated and verification email sent. Please check your inbox and follow the instructions included to complete the change of your email address.'); } /* * User is changing the phone number stored on the account. * "There's a lot to unpack here" */ if (settings.phone) { // update the phone number (there's a lot going on here) try { update.$set.phone = await phoneService.processPhoneNumberInput(settings.phone); } catch (error) { throw error; } // un-verify the account's phone number update.$set['flags.isPhoneVerified'] = false; actions.push('Phone number updated and verification message sent. Please follow the instructions in the text message to complete the change of your mobile phone number.'); } if (settings.password) { if (settings.password !== settings.passwordv) { throw new SiteError(400, 'Password and password verification do not match.'); } update.$set.passwordSalt = uuidv4(); update.$set.password = cryptoService.maskPassword(update.$set.passwordSalt, settings.password); actions.push('Password changed successfully.'); } if (settings.theme) { update.$set['settings.theme'] = striptags(settings.theme.trim()); } if (settings.language) { update.$set['settings.language'] = mongoose.Types.ObjectId(settings.language); actions.push('Interface language changed.'); } await User.updateOne({ _id: user._id }, update); return actions; } async filterUsername (username) { return striptags(username.trim().toLowerCase()).replace(/\W/g, ''); } async isUsernameReserved (username) { const reservedNames = ['digitaltelepresence', 'dtp', 'rob', 'amy', 'zack']; if (reservedNames.includes(username)) { this.log.alert('prohibiting use of reserved username', { username }); return true; } const user = await User.findOne({ username: username}).select('username').lean(); if (user) { this.log.alert('username is already registered', { username }); return true; } return false; } } module.exports = { slug: 'user', name: 'user', create: (dtp) => { return new UserService(dtp); }, };