A web application allowing people to create an account, configure a profile, and share a list of URLs on that profile.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

448 lines
13 KiB

// 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();
await this.checkUsername(username_lc);
// 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;
if (userDefinition.displayName) {
user.displayName = striptags(userDefinition.displayName.trim());
}
user.passwordSalt = passwordSalt;
user.password = maskedPassword;
user.flags = {
isAdmin: false,
isModerator: false,
};
user.permissions = {
canLogin: true,
canChat: true,
canCreateLinks: 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());
userDefinition.bio = striptags(userDefinition.bio.trim());
this.log.info('updating user', { userDefinition });
await User.updateOne(
{ _id: user._id },
{
$set: {
username: userDefinition.username,
username_lc,
displayName: userDefinition.displayName,
bio: userDefinition.bio,
'flags.isAdmin': userDefinition.isAdmin === 'on',
'flags.isModerator': userDefinition.isModerator === 'on',
'permissions.canLogin': userDefinition.canLogin === 'on',
'permissions.canChat': userDefinition.canChat === 'on',
'permissions.canCreateLinks': userDefinition.canCreateLinks === '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());
userDefinition.bio = striptags(userDefinition.bio.trim());
this.log.info('updating user settings', { userDefinition });
await User.updateOne(
{ _id: user._id },
{
$set: {
username: userDefinition.username,
username_lc,
displayName: userDefinition.displayName,
bio: userDefinition.bio,
},
},
);
}
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, username) {
let search = { };
if (username) {
search.username_lc = { $regex: `^${username.toLowerCase().trim()}` };
}
const users = await User
.find(search)
.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 getPublicProfile (username) {
if (!username || (typeof username !== 'string') || (username.length === 0)) {
throw new SiteError(406, 'Invalid username');
}
username = username.trim().toLowerCase();
const user = await User
.findOne({ username_lc: username })
.select('_id created username username_lc displayName bio picture');
return user;
}
async getRecent (maxCount = 3) {
const users = User
.find()
.select('_id created username username_lc displayName bio picture')
.sort({ created: -1 })
.limit(maxCount)
.lean();
return users;
}
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);
await this.checkUsername(update.username);
}
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 checkUsername (username) {
if (!username || (typeof username !== 'string') || (username.length === 0)) {
throw new SiteError(406, 'Invalid username');
}
const reservedNames = [
'about',
'admin',
'amy',
'auth',
'digitaltelepresence',
'dist',
'dtp',
'fontawesome',
'fonts',
'img',
'image',
'less',
'manifest.json',
'moment',
'newsletter',
'numeral',
'rob',
'socket.io',
'uikit',
'user',
'welcome',
'zack'
];
if (reservedNames.includes(username.trim().toLowerCase())) {
throw new SiteError(403, 'That username is reserved for system use');
}
const user = await User.findOne({ username: username}).select('username').lean();
if (user) {
this.log.alert('username is already registered', { username });
throw new SiteError(403, 'Username is already registered');
}
}
async recordProfileView (user, req) {
const { resource: resourceService } = this.dtp.services;
await resourceService.recordView(req, 'User', user._id);
}
async getTotalCount ( ) {
return await User.estimatedDocumentCount();
}
}
module.exports = {
slug: 'user',
name: 'user',
create: (dtp) => { return new UserService(dtp); },
};