apostrophe
Version:
The Apostrophe Content Management System.
613 lines (554 loc) • 20.7 kB
JavaScript
// The `@apostrophecms/user` module provides user accounts. It is **not**
// intended to be extended with new subclass modules. The `@apostrophecms/login`
// module only looks for instances of `@apostrophecms/user`. Of course you may
// implicitly subclass it at project level (not changing the name) in order to
// alter its behavior.
//
// ### Public "staff directories" vs. users
//
// In our experience, combining the concept of a "user" who can log in and do
// things with the concept of a "staff member" who appears in a staff directory
// is more trouble than it is worth.
//
// So for a staff directory, we suggest you create a separate `employee` module
// or similar, extending `@apostrophecms/piece-type`, unless it's true that
// basically everyone should be allowed to log in.
//
// ### `secrets` option
//
// For security the `password` property is not stored as plaintext and
// is not kept in the aposDocs collection. Instead, it is hashed and salted
// using the `credentials` module and the resulting hash is stored
// in a separate `aposUsersSafe` collection.
//
// Additional secrets may be hashed in this way. If you set the
// `secrets` option to an array of property names, those properties
// are never stored directly to the database. Instead, only their
// hashes are stored, and only in `aposUsersSafe`.
//
// You may also call `apos.user.addSecret('name')` to add a new
// secret property. This is convenient when implementing a module
// such as `@apostrophecms/signup`.
//
// ### `adminLocale` schema field
// A select field auto-created by the module, allowing the user to choose
// their preferred language for the admin UI. It will be added only if
// @apostrophecms/i18n is configured with `adminLocales`.
const passwordHash = require('./lib/password-hash.js');
const prompts = require('prompts');
module.exports = {
extend: '@apostrophecms/piece-type',
options: {
alias: 'user',
name: '@apostrophecms/user',
label: 'apostrophe:user',
pluralLabel: 'apostrophe:users',
quickCreate: false,
searchable: false,
slugPrefix: 'user-',
localized: false,
versions: false,
editRole: 'admin',
publishRole: 'admin',
viewRole: 'admin',
showPermissions: true,
relationshipSuggestionIcon: 'account-box-icon',
scrypt: {
// These are the defaults. If you choose to pass
// this option, you can pass one or more new values.
// "cost" must be a power of 2. See:
//
// https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
//
// Do not pass maxmem, it is calculated automatically.
//
// cost: 131072,
// parallelization: 1,
// blockSize: 8
}
},
fields(self, options) {
const fields = {};
// UI Locale
const locales = [ ...options.apos.i18n.adminLocales ];
if (locales.length > 0) {
const def = options.apos.i18n.defaultAdminLocale || '';
fields.localeField = {
type: 'select',
name: 'adminLocale',
label: 'apostrophe:uiLanguageLabel',
choices: [
{
label: 'apostrophe:uiLanguageWebsite',
value: ''
},
...locales
],
def
};
}
return {
add: {
title: {
type: 'string',
label: 'apostrophe:displayName',
required: true
},
disabled: {
type: 'boolean',
label: 'apostrophe:loginDisabled',
def: false
},
username: {
type: 'string',
label: 'apostrophe:username',
required: true,
following: 'title'
},
email: {
type: 'email',
label: 'apostrophe:email'
},
password: {
type: 'password',
label: 'apostrophe:password'
},
role: {
type: 'role',
label: 'apostrophe:role',
choices: [
{
label: 'apostrophe:guest',
value: 'guest'
},
{
label: 'apostrophe:contributor',
value: 'contributor'
},
{
label: 'apostrophe:editor',
value: 'editor'
},
{
label: 'apostrophe:admin',
value: 'admin'
}
],
def: 'guest',
required: true
},
...fields
},
remove: [ 'visibility' ],
group: {
basics: {
label: 'apostrophe:basics',
fields: [
'title',
'adminLocale'
]
},
utility: {
fields: [
'username',
'email',
'password',
'slug',
'archived'
]
},
permissions: {
label: 'apostrophe:permissions',
fields: [
'disabled',
'role'
]
}
}
};
},
filters: {
remove: [ 'visibility' ]
},
columns: {
remove: [ 'visibility' ]
},
async init(self) {
self.initializeCredential();
self.addOurArchivedPrefixFields();
self.enableSecrets();
self.addLegacyMigrations();
await self.ensureSafe();
},
handlers(self) {
return {
beforeInsert: {
async ensurePassword(req, doc, options) {
if (!doc.password) {
doc.password = self.apos.util.generateId();
}
},
normalizeLoginNames(req, doc, options) {
self.normalizeUserLoginInfo(doc);
},
async insertSafe(req, doc, options) {
return self.insertOrUpdateSafe(req, doc, 'insert');
}
},
beforeUpdate: {
async updateSafe(req, doc, options) {
self.normalizeUserLoginInfo(doc);
return self.insertOrUpdateSafe(req, doc, 'update');
}
},
beforeSave: {
// There is a migration that sets the role to admin if the role does
// not exist, accommodating databases prior to 3.0 beta 1. To keep this
// from becoming a possible security concern, refuse any new
// inserts/updates with no role
async requireRole(req, doc, options) {
if (![ 'guest', 'editor', 'contributor', 'admin' ].includes(doc.role)) {
throw self.apos.error('invalid', 'The role property of a user must be guest, editor, contributor or admin');
}
},
async invalidatePriorLogins(req, doc, options) {
const effectiveUserId = req.user && req.user._id;
// Invalidate prior login sessions if the password field changes or
// the user is newly marked as disabled.
if (doc._id && doc._passwordUpdated && (effectiveUserId !== doc._id)) {
// Invalidate old sessions
doc.loginInvalidBefore = Date.now();
// Just delete old bearer tokens
return self.apos.login.bearerTokens.removeMany({
userId: doc._id
});
}
}
},
// Reflect email and username changes in the safe after deduplicating in
// the piece
afterArchive: {
async updateSafe(req, piece) {
await self.insertOrUpdateSafe(req, piece, 'update');
}
},
afterRescue: {
// Reflect email and username changes in the safe after deduplicating
// in the piece
async updateSafe(req, piece) {
self.normalizeUserLoginInfo(piece);
await self.insertOrUpdateSafe(req, piece, 'update');
}
}
};
},
methods(self) {
return {
// Add `username` and `email` to the list of fields that automatically
// get uniquely prefixed when a user is in the archive, so that they can
// be reused by another piece. When the piece is rescued from the archive
// the prefix is removed again, unless the username or email address has
// been claimed by another user in the meanwhile.
addOurArchivedPrefixFields() {
self.addDeduplicatePrefixFields([
'username',
'email'
]);
},
// See `options.secrets` and also the `addSecret` method. `enableSecrets`
// is part of the implementation and should not be called directly.
enableSecrets() {
self.secrets = self.options.secrets || [];
},
// Index and obtain access to the `aposUsersSafe` MongoDB collection as
// `self.safe`.
async ensureSafe() {
await self.ensureSafeCollection();
await self.ensureSafeIndexes();
},
// Obtain the `aposUsersSafe` MongoDB collection as `self.safe`.
async ensureSafeCollection() {
self.safe = await self.apos.db.collection('aposUsersSafe');
},
// Index the safe.
async ensureSafeIndexes() {
await self.safe.createIndex({ username: 1 }, { unique: true });
await self.safe.createIndex({ email: 1 }, {
unique: true,
sparse: true
});
},
// After a user is updated, check to see if the `groups` option is
// configured for simplified user management. If it is, convert the
// single-select choice made via `piece.group` to an array stored in
// `groupIds`, so that all other code can find groups in a consistent way.
async afterConvert(req, piece) {
if (self.options.groups) {
piece.groupIds = [];
piece.groupIds.push(piece.group);
delete piece.group;
}
},
// Insert or update a user's record in the safe, which stores the
// password hash completely outside of the `aposDocs` collection.
// First checks to be sure this is an `@apostrophecms/user` and returns
// immediately if not. Invoked on promise events.
async insertOrUpdateSafe(req, doc, action) {
// Store password hash exclusively in the safe so that it can never be
// accidentally exposed as part of a query on docs. Also store the
// username and, if present, the email address to take advantage of
// unique and sparse indexes which prevent duplication.
const safeUser = {
_id: doc._id,
username: doc.username,
updatedAt: new Date()
};
if (doc.email) {
safeUser.email = doc.email;
}
await self.hashPassword(doc, safeUser);
await self.hashSecrets(doc, safeUser);
await self.emit('beforeSaveSafe', req, safeUser, doc);
if (action === 'insert') {
await self.safe.insertOne(safeUser);
} else {
const changes = { $set: safeUser };
if (!safeUser.email) {
// Sparse indexes are only sparse on null/undefined, an empty
// string is not good enough
changes.$unset = { email: 1 };
}
await self.safe.updateOne({ _id: safeUser._id }, changes);
}
},
// Hash the `password` property of `doc`, then delete that property
// and update the `passwordHash` property of `safeUser`. This method is
// called by the `docBeforeInsert` and `docBeforeSave handlers of this
// module. If `password` is falsy (i.e. the user left it blank,
// requesting no change), it is left alone and `safeUser` is
// not updated.
async hashPassword(doc, safeUser) {
await self.hashSecret(doc, safeUser, 'password');
},
// Similar to `hashPassword`, this method hashes all of the properties
// enumerated in `options.secrets` and via `addSecrets`, then deletes them
// and updates the corresponding properties of `safeUser`. If
// a secret is named `signup`, the corresponding property in
// `safeUser` will be named `signupHash`.
//
// This method is called by the `docBeforeInsert` and `docBeforeSave`
// handlers of this module.
async hashSecrets(doc, safeUser) {
for (const secret of self.secrets) {
await self.hashSecret(doc, safeUser, secret);
}
},
// Add the property specified by `name` to a list of
// secret properties. These are never stored directly
// to the user's doc in mongodb. Instead, if any of
// them have non-falsy values at the time a user is saved,
// those values are hashed and the hash is recorded
// in a separate mongodb collection used only for this purpose.
// You may then call `verifySecret` later to verify that
// a newly entered value matches the previously hashed
// value. This is useful to verify password reset codes,
// signup verification codes and the like with security
// just as good as that used for the password.
addSecret(name) {
self.secrets.push(name);
},
// Hashes a secret property of `doc`, deletes the property,
// and stores only the hash in `safeUser`. `secret` is
// the name of the property of `doc`, not the secret itself.
//
// If `secret` is the string `'password'`, then the `password`
// property will be deleted from `doc` and the `passwordHash`
// property of `safeUser` will be set.
//
// If the secret property is falsy (i.e. the user left the
// password field blank, requesting no change), it is left
// alone and `safeUser` is not updated.
//
// Called automatically by `hashSecrets`, above.
//
// The secret property itself is immediately deleted from doc
// to avoid any risk of accidentally storing it in cleartext.
// However there is a way to detect that it was updated:
// if `secret` is `password`, then the `_passwordUpdated` temporary
// property is set to true. This provides a way to take additional
// actions stemming from this change in a `beforeSave` handler, etc.
async hashSecret(doc, safeUser, secret) {
if (!doc[secret]) {
return;
}
const hash = await self.pw.hash(doc[secret]);
const annotatedHash = JSON.stringify({
...JSON.parse(hash),
credentials3: true
});
delete doc[secret];
safeUser[secret + 'Hash'] = annotatedHash;
doc[`_${secret}Updated`] = true;
},
// Verify the given password by checking it against the
// hash in the safe. `user` is an `@apostrophecms/user` doc.
// Throws `'invalid'` as an exception if the password is incorrect.
async verifyPassword(user, password) {
return self.verifySecret(user, 'password', password);
},
// Check whether the provided value `attempt` matches
// the hash of the secret property `secret`. For security
// the user's password and other property names specified
// in `options.secrets` when configuring this module or via
// `addSecrets` are not stored as plaintext and are not kept in the
// aposDocs collection. Instead, they are hashed and salted using the
// the same algorithm applied to passwords and the resulting hash is
// stored in a separate `aposUsersSafe` collection. This method can be
// used to verify that `attempt` matches the previously hashed value for
// the property named `secret`, without ever storing the actual value of
// the secret.
//
// If the secret does not match, an `invalid` error is thrown.
// Otherwise the method returns normally.
async verifySecret(user, secret, attempt) {
const safeUser = await self.safe.findOne({ _id: user._id });
if (!safeUser) {
throw new Error('No such user in the safe.');
}
const key = secret + 'Hash';
const isVerified = await self.pw.verify(migrate(safeUser[key]), attempt);
if (isVerified) {
if ((typeof isVerified) === 'string') {
// "verify" updated the hash, store the new one
const $set = {};
$set[key] = isVerified;
await self.safe.updateOne({
_id: user._id
}, {
$set
});
}
return null;
} else {
throw self.apos.error('invalid', `Incorrect ${secret}`);
}
function migrate(json) {
const data = JSON.parse(json);
// * Do not re-encode legacy salt generated by credentials@3
// * Do not alter salts not generated by the credentials module
if (data.credentials3 || (data.hashMethod !== 'pbkdf2')) {
return json;
}
return JSON.stringify({
...data,
salt: Buffer.from(data.salt, 'utf8').toString('base64')
});
}
},
// Forget the secret associated with the property name
// passed in `secret`. If `secret` is `'passwordReset'`,
// then the `passwordResetHash` property is deleted from
// the appropriate record in the `aposUsersSafe`
// collection. Note that the plaintext of the secret
// was never stored in the database in the first place.
async forgetSecret(user, secret) {
const changes = { $unset: {} };
changes.$unset[secret + 'Hash'] = 1;
await self.safe.updateOne({ _id: user._id }, changes);
},
// Initialize password hashing system. Name is for
// legacy reasons
initializeCredential() {
self.pw = passwordHash({
error(s) {
return self.apos.error('invalid', s);
},
scrypt: self.options.scrypt
});
},
// Implement the `@apostrophecms/user:add` command line task.
async addFromTask() {
const argv = self.apos.argv;
// Support positional arguments for bc, but the named
// arguments make the intent clear
const username = argv._[1] || argv.username;
if (!username) {
throw 'You must specify --username=usernamehere';
}
const req = self.apos.task.getReq();
const user = {
username,
title: username
};
await self.addPermissionsFromTask(argv, user);
user.password = (await prompts(
{
type: 'password',
name: 'password',
message: `Enter a password for ${username}:`,
validate (input) {
return input ? true : 'Password is required';
}
}
)).password;
return self.apos.user.insert(req, user);
},
async addPermissionsFromTask(argv, user) {
let role = argv._[2] || argv.role;
if (!role) {
role = 'admin';
console.log('You did not pass a second argument or --role, assuming admin');
}
if (![ 'guest', 'contributor', 'editor', 'admin' ].includes(role)) {
throw 'Second argument or --role must be one of: guest, contributor, editor, admin';
}
user.role = role;
},
// Implement the `@apostrophecms/user:change-password` task.
async changePasswordFromTask() {
const argv = self.apos.argv;
if (argv._.length !== 2) {
throw 'Incorrect number of arguments.';
}
const username = argv._[1];
const req = self.apos.task.getReq();
const user = await self.apos.user.find(req, { username }).toObject();
if (!user) {
throw new Error('No such user.');
}
const { password } = await prompts(
{
type: 'password',
name: 'password',
message: `Change password for ${username} to:`,
validate (input) {
return input ? true : 'Password is required';
}
}
);
// This module's docBeforeUpdate handler does all the magic here
user.password = password;
return self.update(req, user);
},
normalizeUserLoginInfo(doc) {
doc.username = self.apos.login.normalizeLoginName(doc.username);
if (doc.email) {
doc.email = self.apos.login.normalizeLoginName(doc.email);
}
},
...require('./lib/legacy-migrations')(self)
};
},
tasks(self) {
return {
add: {
usage: 'Usage: node app @apostrophecms/user:add username groupname\n\nThis adds a new user and assigns them to a group.\nYou will be prompted for a password.',
task: self.addFromTask
},
'change-password': {
usage: 'Usage: node app @apostrophecms/user:change-password username\n\nThis prompts you for a new password for the given user.',
task: self.changePasswordFromTask
}
};
}
};