Ghost/core/server/models/user.js
Katharina Irrgang 0aff9f33d9
Improved validation layer (#9427)
refs https://github.com/TryGhost/Ghost/issues/3658

- the `validateSchema` helper was a bit broken
  - if you add a user without email, you will receive a database error
  - but the validation error should catch that email is passed with null
- it was broken, because:
  - A: it called `toJSON` -> this can remove properties from the output (e.g. password)
  - B: we only validated fields, which were part of the JSON data (model.hasOwnProperty)
- we now differentiate between schema validation for update and insert
- fixed one broken import test
  - if you import a post without a status, it should not error
  - it falls back to the default value
- removed user model `onValidate`
  - the user model added a custom implementation of `onValidate`, because of a bug which we experienced (see https://github.com/TryGhost/Ghost/issues/3638)
  - with the refactoring this is no longer required - we only validate fields which have changed when updating resources
  - also, removed extra safe catch when logging in (no longer needed - unit tested)
- add lot's of unit tests to proof the code change
- always call the base class, except you have a good reason
2018-02-16 00:49:15 +01:00

830 lines
31 KiB
JavaScript

var _ = require('lodash'),
Promise = require('bluebird'),
validator = require('validator'),
ObjectId = require('bson-objectid'),
ghostBookshelf = require('./base'),
baseUtils = require('./base/utils'),
common = require('../lib/common'),
security = require('../lib/security'),
imageLib = require('../lib/image'),
pipeline = require('../lib/promise/pipeline'),
validation = require('../data/validation'),
activeStates = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4'],
/**
* inactive: owner user before blog setup, suspended users
* locked user: imported users, they get a random passport
*/
inactiveStates = ['inactive', 'locked'],
allStates = activeStates.concat(inactiveStates),
User,
Users;
User = ghostBookshelf.Model.extend({
tableName: 'users',
defaults: function defaults() {
return {
password: security.identifier.uid(50)
};
},
emitChange: function emitChange(event, options) {
common.events.emit('user' + '.' + event, this, options);
},
onDestroyed: function onDestroyed(model, response, options) {
if (_.includes(activeStates, model.previous('status'))) {
model.emitChange('deactivated', options);
}
model.emitChange('deleted');
},
onCreated: function onCreated(model) {
model.emitChange('added');
// active is the default state, so if status isn't provided, this will be an active user
if (!model.get('status') || _.includes(activeStates, model.get('status'))) {
model.emitChange('activated');
}
},
onUpdated: function onUpdated(model, response, options) {
model.statusChanging = model.get('status') !== model.updated('status');
model.isActive = _.includes(activeStates, model.get('status'));
if (model.statusChanging) {
model.emitChange(model.isActive ? 'activated' : 'deactivated', options);
} else {
if (model.isActive) {
model.emitChange('activated.edited');
}
}
model.emitChange('edited');
},
isActive: function isActive() {
return activeStates.indexOf(this.get('status')) !== -1;
},
isLocked: function isLocked() {
return this.get('status') === 'locked';
},
isInactive: function isInactive() {
return this.get('status') === 'inactive';
},
/**
* Lookup Gravatar if email changes to update image url
* Generating a slug requires a db call to look for conflicting slugs
*/
onSaving: function onSaving(newPage, attr, options) {
var self = this,
tasks = [],
passwordValidation = {};
ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);
/**
* Bookshelf call order:
* - onSaving
* - onValidate (validates the model against the schema)
*
* Before we can generate a slug, we have to ensure that the name is not blank.
*/
if (!this.get('name')) {
throw new common.errors.ValidationError({
message: common.i18n.t('notices.data.validation.index.valueCannotBeBlank', {
tableName: this.tableName,
columnKey: 'name'
})
});
}
// If the user's email is set & has changed & we are not importing
if (self.hasChanged('email') && self.get('email') && !options.importing) {
tasks.gravatar = (function lookUpGravatar() {
return imageLib.gravatar.lookup({
email: self.get('email')
}).then(function (response) {
if (response && response.image) {
self.set('profile_image', response.image);
}
});
})();
}
if (this.hasChanged('slug') || !this.get('slug')) {
tasks.slug = (function generateSlug() {
return ghostBookshelf.Model.generateSlug(
User,
self.get('slug') || self.get('name'),
{
status: 'all',
transacting: options.transacting,
shortSlug: !self.get('slug')
})
.then(function then(slug) {
self.set({slug: slug});
});
})();
}
/**
* CASE: add model, hash password
* CASE: update model, hash password
*
* Important:
* - Password hashing happens when we import a database
* - we do some pre-validation checks, because onValidate is called AFTER onSaving
* - when importing, we set the password to a random uid and don't validate, just hash it and lock the user
* - when importing with `importPersistUser` we check if the password is a bcrypt hash already and fall back to
* normal behaviour if not (set random password, lock user, and hash password)
* - no validations should run, when importing
*/
if (self.hasChanged('password')) {
this.set('password', String(this.get('password')));
// CASE: import with `importPersistUser` should always be an bcrypt password already,
// and won't re-hash or overwrite it.
// In case the password is not bcrypt hashed we fall back to the standard behaviour.
if (options.importPersistUser && this.get('password').match(/^\$2[ayb]\$.{56}$/i)) {
return;
}
if (options.importing) {
// always set password to a random uid when importing
this.set('password', security.identifier.uid(50));
// lock users so they have to follow the password reset flow
if (this.get('status') !== 'inactive') {
this.set('status', 'locked');
}
} else {
// CASE: we're not importing data, run the validations
passwordValidation = validation.validatePassword(this.get('password'), this.get('email'));
if (!passwordValidation.isValid) {
return Promise.reject(new common.errors.ValidationError({message: passwordValidation.message}));
}
}
tasks.hashPassword = (function hashPassword() {
return security.password.hash(self.get('password'))
.then(function (hash) {
self.set('password', hash);
});
})();
}
return Promise.props(tasks);
},
toJSON: function toJSON(unfilteredOptions) {
var options = User.filterOptions(unfilteredOptions, 'toJSON'),
attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
// remove password hash for security reasons
delete attrs.password;
delete attrs.ghost_auth_access_token;
// NOTE: We don't expose the email address for for external, app and public context.
// @TODO: Why? External+Public is actually the same context? Was also mentioned here https://github.com/TryGhost/Ghost/issues/9043
if (!options || !options.context || (!options.context.user && !options.context.internal)) {
delete attrs.email;
}
// We don't expose these fields when fetching data via the public API.
if (options && options.context && options.context.public) {
delete attrs.created_at;
delete attrs.created_by;
delete attrs.updated_at;
delete attrs.updated_by;
delete attrs.last_seen;
delete attrs.status;
delete attrs.ghost_auth_id;
}
return attrs;
},
format: function format(options) {
if (!_.isEmpty(options.website) &&
!validator.isURL(options.website, {
require_protocol: true,
protocols: ['http', 'https']
})) {
options.website = 'http://' + options.website;
}
return ghostBookshelf.Model.prototype.format.call(this, options);
},
posts: function posts() {
return this.hasMany('Posts', 'created_by');
},
roles: function roles() {
return this.belongsToMany('Role');
},
permissions: function permissions() {
return this.belongsToMany('Permission');
},
hasRole: function hasRole(roleName) {
var roles = this.related('roles');
return roles.some(function getRole(role) {
return role.get('name') === roleName;
});
},
enforcedFilters: function enforcedFilters(options) {
if (options.context && options.context.internal) {
return null;
}
return options.context && options.context.public ? 'status:[' + allStates.join(',') + ']' : null;
},
defaultFilters: function defaultFilters(options) {
if (options.context && options.context.internal) {
return null;
}
return options.context && options.context.public ? null : 'status:[' + allStates.join(',') + ']';
}
}, {
orderDefaultOptions: function orderDefaultOptions() {
return {
last_seen: 'DESC',
name: 'ASC',
created_at: 'DESC'
};
},
/**
* @deprecated in favour of filter
*/
processOptions: function processOptions(options) {
if (!options.status) {
return options;
}
// This is the only place that 'options.where' is set now
options.where = {statements: []};
var value;
// Filter on the status. A status of 'all' translates to no filter since we want all statuses
if (options.status !== 'all') {
// make sure that status is valid
options.status = allStates.indexOf(options.status) > -1 ? options.status : 'active';
}
if (options.status === 'active') {
value = activeStates;
} else if (options.status === 'all') {
value = allStates;
} else {
value = options.status;
}
options.where.statements.push({prop: 'status', op: 'IN', value: value});
delete options.status;
return options;
},
/**
* Returns an array of keys permitted in a method's `options` hash, depending on the current method.
* @param {String} methodName The name of the method to check valid options for.
* @return {Array} Keys allowed in the `options` hash of the model's method.
*/
permittedOptions: function permittedOptions(methodName, options) {
var permittedOptionsToReturn = ghostBookshelf.Model.permittedOptions(),
// whitelists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
validOptions = {
findOne: ['withRelated', 'status'],
setup: ['id'],
edit: ['withRelated', 'id', 'importPersistUser'],
add: ['importPersistUser'],
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status'],
findAll: ['filter']
};
if (validOptions[methodName]) {
permittedOptionsToReturn = permittedOptionsToReturn.concat(validOptions[methodName]);
}
// CASE: The `withRelated` parameter is allowed when using the public API, but not the `roles` value.
// Otherwise we expose too much information.
if (options && options.context && options.context.public) {
if (options.withRelated && options.withRelated.indexOf('roles') !== -1) {
options.withRelated.splice(options.withRelated.indexOf('roles'), 1);
}
}
return permittedOptionsToReturn;
},
/**
* ### Find One
*
* We have to clone the data, because we remove values from this object.
* This is not expected from outside!
*
* @TODO: use base class
*
* @extends ghostBookshelf.Model.findOne to include roles
* **See:** [ghostBookshelf.Model.findOne](base.js.html#Find%20One)
*/
findOne: function findOne(dataToClone, unfilteredOptions) {
var options = this.filterOptions(unfilteredOptions, 'findOne'),
query,
status,
data = _.cloneDeep(dataToClone),
lookupRole = data.role;
delete data.role;
data = _.defaults(data || {}, {
status: 'all'
});
status = data.status;
delete data.status;
data = this.filterData(data);
// Support finding by role
if (lookupRole) {
options.withRelated = _.union(options.withRelated, ['roles']);
query = this.forge(data);
query.query('join', 'roles_users', 'users.id', '=', 'roles_users.user_id');
query.query('join', 'roles', 'roles_users.role_id', '=', 'roles.id');
query.query('where', 'roles.name', '=', lookupRole);
} else {
query = this.forge(data);
}
if (status === 'active') {
query.query('whereIn', 'status', activeStates);
} else if (status !== 'all') {
query.query('where', {status: status});
}
return query.fetch(options);
},
/**
* ### Edit
*
* Note: In case of login the last_seen attribute gets updated.
*
* @extends ghostBookshelf.Model.edit to handle returning the full object
* **See:** [ghostBookshelf.Model.edit](base.js.html#edit)
*/
edit: function edit(data, unfilteredOptions) {
var options = this.filterOptions(unfilteredOptions, 'edit'),
self = this,
ops = [];
if (data.roles && data.roles.length > 1) {
return Promise.reject(
new common.errors.ValidationError({message: common.i18n.t('errors.models.user.onlyOneRolePerUserSupported')})
);
}
if (data.email) {
ops.push(function checkForDuplicateEmail() {
return self.getByEmail(data.email, options).then(function then(user) {
if (user && user.id !== options.id) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.models.user.userUpdateError.emailIsAlreadyInUse')}));
}
});
});
}
ops.push(function update() {
return ghostBookshelf.Model.edit.call(self, data, options).then(function then(user) {
var roleId;
if (!data.roles) {
return user;
}
roleId = data.roles[0].id || data.roles[0];
return user.roles().fetch().then(function then(roles) {
// return if the role is already assigned
if (roles.models[0].id === roleId) {
return;
}
return ghostBookshelf.model('Role').findOne({id: roleId});
}).then(function then(roleToAssign) {
if (roleToAssign && roleToAssign.get('name') === 'Owner') {
return Promise.reject(
new common.errors.ValidationError({message: common.i18n.t('errors.models.user.methodDoesNotSupportOwnerRole')})
);
} else {
// assign all other roles
return user.roles().updatePivot({role_id: roleId});
}
}).then(function then() {
options.status = 'all';
return self.findOne({id: user.id}, options);
});
});
});
return pipeline(ops);
},
/**
* ## Add
* Naive user add
* Hashes the password provided before saving to the database.
*
* We have to clone the data, because we remove values from this object.
* This is not expected from outside!
*
* @param {object} dataToClone
* @param {object} unfilteredOptions
* @extends ghostBookshelf.Model.add to manage all aspects of user signup
* **See:** [ghostBookshelf.Model.add](base.js.html#Add)
*/
add: function add(dataToClone, unfilteredOptions) {
var options = this.filterOptions(unfilteredOptions, 'add'),
self = this,
data = _.cloneDeep(dataToClone),
userData = this.filterData(data),
roles;
// check for too many roles
if (data.roles && data.roles.length > 1) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.models.user.onlyOneRolePerUserSupported')}));
}
function getAuthorRole() {
return ghostBookshelf.model('Role').findOne({name: 'Author'}, _.pick(options, 'transacting'))
.then(function then(authorRole) {
return [authorRole.get('id')];
});
}
/**
* We need this default author role because of the following Ghost feature:
* You setup your blog and you can invite people instantly, but without choosing a role.
* roles: [] -> no default role (used for owner creation, see fixtures.json)
* roles: undefined -> default role
*/
roles = data.roles;
delete data.roles;
return ghostBookshelf.Model.add.call(self, userData, options)
.then(function then(addedUser) {
// Assign the userData to our created user so we can pass it back
userData = addedUser;
})
.then(function () {
if (!roles) {
return getAuthorRole();
}
return Promise.resolve(roles);
})
.then(function (_roles) {
roles = _roles;
// CASE: it is possible to add roles by name, by id or by object
if (_.isString(roles[0]) && !ObjectId.isValid(roles[0])) {
return Promise.map(roles, function (roleName) {
return ghostBookshelf.model('Role').findOne({
name: roleName
}, options);
}).then(function (roleModels) {
roles = [];
_.each(roleModels, function (roleModel) {
roles.push(roleModel.id);
});
});
}
return Promise.resolve();
})
.then(function () {
return baseUtils.attach(User, userData.id, 'roles', roles, options);
})
.then(function then() {
// find and return the added user
return self.findOne({id: userData.id, status: 'all'}, options);
});
},
/**
* We override the owner!
* Owner already has a slug -> force setting a new one by setting slug to null
* @TODO: kill setup function?
*/
setup: function setup(data, unfilteredOptions) {
var options = this.filterOptions(unfilteredOptions, 'setup'),
self = this,
userData = this.filterData(data),
passwordValidation = {};
passwordValidation = validation.validatePassword(userData.password, userData.email, data.blogTitle);
if (!passwordValidation.isValid) {
return Promise.reject(new common.errors.ValidationError({message: passwordValidation.message}));
}
userData.slug = null;
return self.edit(userData, options);
},
/**
* Right now the setup of the blog depends on the user status.
* Only if the owner user is `inactive`, then the blog is not setup.
* e.g. if you transfer ownership to a locked user, you blog is still setup.
*
* @TODO: Rename `inactive` status to something else, it's confusing. e.g. requires-setup
* @TODO: Depending on the user status results in https://github.com/TryGhost/Ghost/issues/8003
*/
isSetup: function isSetup(options) {
return this.getOwnerUser(options)
.then(function (owner) {
return owner.get('status') !== 'inactive';
});
},
getOwnerUser: function getOwnerUser(options) {
options = options || {};
return this.findOne({
role: 'Owner',
status: 'all'
}, options).then(function (owner) {
if (!owner) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.models.user.ownerNotFound')
}));
}
return owner;
});
},
permissible: function permissible(userModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission) {
var self = this,
userModel = userModelOrId,
origArgs;
// If we passed in a model without its related roles, we need to fetch it again
if (_.isObject(userModelOrId) && !_.isObject(userModelOrId.related('roles'))) {
userModelOrId = userModelOrId.id;
}
// If we passed in an id instead of a model get the model first
if (_.isNumber(userModelOrId) || _.isString(userModelOrId)) {
// Grab the original args without the first one
origArgs = _.toArray(arguments).slice(1);
// Get the actual user model
return this.findOne({
id: userModelOrId,
status: 'all'
}, {withRelated: ['roles']}).then(function then(foundUserModel) {
if (!foundUserModel) {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.models.user.userNotFound')
});
}
// Build up the original args but substitute with actual model
var newArgs = [foundUserModel].concat(origArgs);
return self.permissible.apply(self, newArgs);
});
}
if (action === 'edit') {
// Users with the role 'Editor', 'Author', and 'Contributor' have complex permissions when the action === 'edit'
// We now have all the info we need to construct the permissions
if (context.user === userModel.get('id')) {
// If this is the same user that requests the operation allow it.
hasUserPermission = true;
} else if (loadedPermissions.user && userModel.hasRole('Owner')) {
// Owner can only be edited by owner
hasUserPermission = loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Owner'});
} else if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) {
// If the user we are trying to edit is an Author or Contributor, allow it
hasUserPermission = userModel.hasRole('Author') || userModel.hasRole('Contributor');
}
}
if (action === 'destroy') {
// Owner cannot be deleted EVER
if (userModel.hasRole('Owner')) {
return Promise.reject(new common.errors.NoPermissionError({message: common.i18n.t('errors.models.user.notEnoughPermission')}));
}
// Users with the role 'Editor' have complex permissions when the action === 'destroy'
if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) {
// Alternatively, if the user we are trying to edit is an Author, allow it
hasUserPermission = context.user === userModel.get('id') || userModel.hasRole('Author') || userModel.hasRole('Contributor');
}
}
if (hasUserPermission && hasAppPermission) {
return Promise.resolve();
}
return Promise.reject(new common.errors.NoPermissionError({message: common.i18n.t('errors.models.user.notEnoughPermission')}));
},
// Finds the user by email, and checks the password
// @TODO: shorten this function and rename...
check: function check(object) {
var self = this;
return this.getByEmail(object.email)
.then(function then(user) {
if (!user) {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.models.user.noUserWithEnteredEmailAddr')
});
}
if (user.isLocked()) {
throw new common.errors.NoPermissionError({
message: common.i18n.t('errors.models.user.accountLocked')
});
}
if (user.isInactive()) {
throw new common.errors.NoPermissionError({
message: common.i18n.t('errors.models.user.accountSuspended')
});
}
return self.isPasswordCorrect({plainPassword: object.password, hashedPassword: user.get('password')})
.then(function then() {
user.set({status: 'active', last_seen: new Date()});
return user.save();
});
})
.catch(function (err) {
if (err.message === 'NotFound' || err.message === 'EmptyResponse') {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.models.user.noUserWithEnteredEmailAddr')
});
}
throw err;
});
},
isPasswordCorrect: function isPasswordCorrect(object) {
var plainPassword = object.plainPassword,
hashedPassword = object.hashedPassword;
if (!plainPassword || !hashedPassword) {
return Promise.reject(new common.errors.ValidationError({
message: common.i18n.t('errors.models.user.passwordRequiredForOperation')
}));
}
return security.password.compare(plainPassword, hashedPassword)
.then(function (matched) {
if (matched) {
return;
}
return Promise.reject(new common.errors.ValidationError({
context: common.i18n.t('errors.models.user.incorrectPassword'),
message: common.i18n.t('errors.models.user.incorrectPassword'),
help: common.i18n.t('errors.models.user.userUpdateError.help'),
code: 'PASSWORD_INCORRECT'
}));
});
},
/**
* Naive change password method
* @param {Object} object
* @param {Object} unfilteredOptions
*/
changePassword: function changePassword(object, unfilteredOptions) {
var options = this.filterOptions(unfilteredOptions, 'changePassword'),
self = this,
newPassword = object.newPassword,
userId = object.user_id,
oldPassword = object.oldPassword,
isLoggedInUser = userId === options.context.user,
user;
options.require = true;
return self.forge({id: userId}).fetch(options)
.then(function then(_user) {
user = _user;
if (isLoggedInUser) {
return self.isPasswordCorrect({
plainPassword: oldPassword,
hashedPassword: user.get('password')
});
}
})
.then(function then() {
return user.save({password: newPassword});
});
},
transferOwnership: function transferOwnership(object, unfilteredOptions) {
var options = ghostBookshelf.Model.filterOptions(unfilteredOptions, 'transferOwnership'),
ownerRole,
contextUser;
return Promise.join(
ghostBookshelf.model('Role').findOne({name: 'Owner'}),
User.findOne({id: options.context.user}, {withRelated: ['roles']})
)
.then(function then(results) {
ownerRole = results[0];
contextUser = results[1];
// check if user has the owner role
var currentRoles = contextUser.toJSON(options).roles;
if (!_.some(currentRoles, {id: ownerRole.id})) {
return Promise.reject(new common.errors.NoPermissionError({message: common.i18n.t('errors.models.user.onlyOwnerCanTransferOwnerRole')}));
}
return Promise.join(ghostBookshelf.model('Role').findOne({name: 'Administrator'}),
User.findOne({id: object.id}, {withRelated: ['roles']}));
})
.then(function then(results) {
var adminRole = results[0],
user = results[1],
currentRoles = user.toJSON(options).roles;
if (!_.some(currentRoles, {id: adminRole.id})) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.models.user.onlyAdmCanBeAssignedOwnerRole')}));
}
// convert owner to admin
return Promise.join(contextUser.roles().updatePivot({role_id: adminRole.id}),
user.roles().updatePivot({role_id: ownerRole.id}),
user.id);
})
.then(function then(results) {
return Users.forge()
.query('whereIn', 'id', [contextUser.id, results[2]])
.fetch({withRelated: ['roles']});
})
.then(function then(users) {
options.withRelated = ['roles'];
return users.toJSON(options);
});
},
// Get the user by email address, enforces case insensitivity rejects if the user is not found
// When multi-user support is added, email addresses must be deduplicated with case insensitivity, so that
// joe@bloggs.com and JOE@BLOGGS.COM cannot be created as two separate users.
getByEmail: function getByEmail(email, unfilteredOptions) {
var options = ghostBookshelf.Model.filterOptions(unfilteredOptions, 'getByEmail');
// We fetch all users and process them in JS as there is no easy way to make this query across all DBs
// Although they all support `lower()`, sqlite can't case transform unicode characters
// This is somewhat mute, as validator.isEmail() also doesn't support unicode, but this is much easier / more
// likely to be fixed in the near future.
options.require = true;
return Users.forge().fetch(options).then(function then(users) {
var userWithEmail = users.find(function findUser(user) {
return user.get('email').toLowerCase() === email.toLowerCase();
});
if (userWithEmail) {
return userWithEmail;
}
});
},
inactiveStates: inactiveStates
});
Users = ghostBookshelf.Collection.extend({
model: User
});
module.exports = {
User: ghostBookshelf.model('User', User),
Users: ghostBookshelf.collection('Users', Users)
};