UNPKG

apostrophe

Version:
613 lines (554 loc) • 20.7 kB
// 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 } }; } };