Ghost/core/server/api/authentication.js
Aileen Nowak c8cbbc4eb6 Improved password validation rules (#9171)
refs #9150 

- Moves the password length fn from `models/user` to `data/validation` where the other validator functions live.
- Added password validation rules. Password rules added:
   - Disallow obviously bad passwords: '1234567890', 'qwertyuiop', 'asdfghjkl;' and 'asdfghjklm' for example
   - Disallow passwords that contain the words 'password' or 'ghost'
   - Disallow passwords that match the user's email address
   - Disallow passwords that match the blog domain or blog title
   - Disallow passwords that include 50% or more of the same characters: 'aaaaaaaaaa', '1111111111' and 'ababababab' for example.
- Password validation returns an `Object` now, that includes an `isValid` and `message` property to differentiate between the two error messages (password too short or password insecure).
- Use a catch predicate in `api/authentication` on `passwordReset`, so the correct `ValidationError` will be thrown during the password reset flow rather then an `UnauthorizedError`.
- When in setup flow, the blog title is not available yet from `settingsCache`. We therefore supply it from the received form data in the user model `setup` method to have it accessible for the validation.
2017-10-26 11:01:24 +01:00

662 lines
21 KiB
JavaScript

var Promise = require('bluebird'),
_ = require('lodash'),
validator = require('validator'),
pipeline = require('../utils/pipeline'),
mail = require('./../mail'),
globalUtils = require('../utils'),
apiUtils = require('./utils'),
models = require('../models'),
config = require('../config'),
errors = require('../errors'),
events = require('../events'),
i18n = require('../i18n'),
logging = require('../logging'),
spamPrevention = require('../middleware/api/spam-prevention'),
mailAPI = require('./mail'),
settingsAPI = require('./settings'),
authentication,
tokenSecurity = {};
/**
* Returns setup status
*
* @return {Promise<Boolean>}
*/
function checkSetup() {
return authentication.isSetup().then(function then(result) {
return result.setup[0].status;
});
}
/**
* Allows an assertion to be made about setup status.
*
* @param {Boolean} status True: setup must be complete. False: setup must not be complete.
* @return {Function} returns a "task ready" function
*/
function assertSetupCompleted(status) {
return function checkPermission(__) {
return checkSetup().then(function then(isSetup) {
if (isSetup === status) {
return __;
}
var completed = i18n.t('errors.api.authentication.setupAlreadyCompleted'),
notCompleted = i18n.t('errors.api.authentication.setupMustBeCompleted');
function throwReason(reason) {
throw new errors.NoPermissionError({message: reason});
}
if (isSetup) {
throwReason(completed);
} else {
throwReason(notCompleted);
}
});
};
}
function setupTasks(setupData) {
var tasks;
function validateData(setupData) {
return apiUtils.checkObject(setupData, 'setup').then(function then(checked) {
var data = checked.setup[0];
return {
name: data.name,
email: data.email,
password: data.password,
blogTitle: data.blogTitle,
status: 'active'
};
});
}
function setupUser(userData) {
var context = {context: {internal: true}},
User = models.User;
return User.findOne({role: 'Owner', status: 'all'}).then(function then(owner) {
if (!owner) {
throw new errors.GhostError({
message: i18n.t('errors.api.authentication.setupUnableToRun')
});
}
return User.setup(userData, _.extend({id: owner.id}, context));
}).then(function then(user) {
return {
user: user,
userData: userData
};
});
}
function doSettings(data) {
var user = data.user,
blogTitle = data.userData.blogTitle,
context = {context: {user: data.user.id}},
userSettings;
if (!blogTitle || typeof blogTitle !== 'string') {
return user;
}
userSettings = [
{key: 'title', value: blogTitle.trim()},
{key: 'description', value: i18n.t('common.api.authentication.sampleBlogDescription')}
];
return settingsAPI.edit({settings: userSettings}, context).return(user);
}
function formatResponse(user) {
return user.toJSON({context: {internal: true}});
}
tasks = [
validateData,
setupUser,
doSettings,
formatResponse
];
return pipeline(tasks, setupData);
}
/**
* ## Authentication API Methods
*
* **See:** [API Methods](index.js.html#api%20methods)
*/
authentication = {
/**
* @description generate a reset token for a given email address
* @param {Object} object
* @returns {Promise<Object>} message
*/
generateResetToken: function generateResetToken(object) {
var tasks;
function validateRequest(object) {
return apiUtils.checkObject(object, 'passwordreset').then(function then(data) {
var email = data.passwordreset[0].email;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new errors.BadRequestError({
message: i18n.t('errors.api.authentication.noEmailProvided')
});
}
return email;
});
}
function generateToken(email) {
var options = {context: {internal: true}},
dbHash, token;
return settingsAPI.read(_.merge({key: 'db_hash'}, options))
.then(function fetchedSettings(response) {
dbHash = response.settings[0].value;
return models.User.getByEmail(email, options);
})
.then(function fetchedUser(user) {
if (!user) {
throw new errors.NotFoundError({message: i18n.t('errors.api.users.userNotFound')});
}
token = globalUtils.tokens.resetToken.generateHash({
expires: Date.now() + globalUtils.ONE_DAY_MS,
email: email,
dbHash: dbHash,
password: user.get('password')
});
return {
email: email,
resetToken: token
};
});
}
function sendResetNotification(data) {
var adminUrl = globalUtils.url.urlFor('admin', true),
resetUrl = globalUtils.url.urlJoin(adminUrl, 'reset', globalUtils.encodeBase64URLsafe(data.resetToken), '/');
return mail.utils.generateContent({
data: {
resetUrl: resetUrl
},
template: 'reset-password'
}).then(function then(content) {
var payload = {
mail: [{
message: {
to: data.email,
subject: i18n.t('common.api.authentication.mail.resetPassword'),
html: content.html,
text: content.text
},
options: {}
}]
};
return mailAPI.send(payload, {context: {internal: true}});
});
}
function formatResponse() {
return {
passwordreset: [
{message: i18n.t('common.api.authentication.mail.checkEmailForInstructions')}
]
};
}
tasks = [
assertSetupCompleted(true),
validateRequest,
generateToken,
sendResetNotification,
formatResponse
];
return pipeline(tasks, object);
},
/**
* ## Reset Password
* reset password if a valid token and password (2x) is passed
* @param {Object} object
* @returns {Promise<Object>} message
*/
resetPassword: function resetPassword(object, opts) {
var tasks, tokenIsCorrect, dbHash, options = {context: {internal: true}}, tokenParts;
function validateRequest() {
return apiUtils.validate('passwordreset')(object, options)
.then(function (options) {
var data = options.data.passwordreset[0];
if (data.newPassword !== data.ne2Password) {
return Promise.reject(new errors.ValidationError({
message: i18n.t('errors.models.user.newPasswordsDoNotMatch')
}));
}
return Promise.resolve(options);
});
}
function extractTokenParts(options) {
options.data.passwordreset[0].token = globalUtils.decodeBase64URLsafe(options.data.passwordreset[0].token);
tokenParts = globalUtils.tokens.resetToken.extract({
token: options.data.passwordreset[0].token
});
if (!tokenParts) {
return Promise.reject(new errors.UnauthorizedError({
message: i18n.t('errors.api.common.invalidTokenStructure')
}));
}
return Promise.resolve(options);
}
// @TODO: use brute force middleware (see https://github.com/TryGhost/Ghost/pull/7579)
function protectBruteForce(options) {
if (tokenSecurity[tokenParts.email + '+' + tokenParts.expires] &&
tokenSecurity[tokenParts.email + '+' + tokenParts.expires].count >= 10) {
return Promise.reject(new errors.NoPermissionError({
message: i18n.t('errors.models.user.tokenLocked')
}));
}
return Promise.resolve(options);
}
function doReset(options) {
var data = options.data.passwordreset[0],
resetToken = data.token,
oldPassword = data.oldPassword,
newPassword = data.newPassword;
return settingsAPI.read(_.merge({key: 'db_hash'}, options))
.then(function fetchedSettings(response) {
dbHash = response.settings[0].value;
return models.User.getByEmail(tokenParts.email, options);
})
.then(function fetchedUser(user) {
if (!user) {
throw new errors.NotFoundError({message: i18n.t('errors.api.users.userNotFound')});
}
tokenIsCorrect = globalUtils.tokens.resetToken.compare({
token: resetToken,
dbHash: dbHash,
password: user.get('password')
});
if (!tokenIsCorrect) {
return Promise.reject(new errors.BadRequestError({
message: i18n.t('errors.api.common.invalidTokenStructure')
}));
}
spamPrevention.userLogin().reset(opts.ip, tokenParts.email + 'login');
return models.User.changePassword({
oldPassword: oldPassword,
newPassword: newPassword,
user_id: user.id
}, options);
})
.then(function changedPassword(updatedUser) {
updatedUser.set('status', 'active');
return updatedUser.save(options);
})
.catch(errors.ValidationError, function (err) {
return Promise.reject(err);
})
.catch(function (err) {
if (errors.utils.isIgnitionError(err)) {
return Promise.reject(err);
}
return Promise.reject(new errors.UnauthorizedError({err: err}));
});
}
function formatResponse() {
return {
passwordreset: [
{message: i18n.t('common.api.authentication.mail.passwordChanged')}
]
};
}
tasks = [
validateRequest,
assertSetupCompleted(true),
extractTokenParts,
protectBruteForce,
doReset,
formatResponse
];
return pipeline(tasks, object, options);
},
/**
* ### Accept Invitation
* @param {Object} invitation an invitation object
* @returns {Promise<Object>}
*/
acceptInvitation: function acceptInvitation(invitation) {
var tasks, invite, options = {context: {internal: true}};
function validateInvitation(invitation) {
return apiUtils.checkObject(invitation, 'invitation')
.then(function () {
if (!invitation.invitation[0].token) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.api.authentication.noTokenProvided')}));
}
if (!invitation.invitation[0].email) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.api.authentication.noEmailProvided')}));
}
if (!invitation.invitation[0].password) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.api.authentication.noPasswordProvided')}));
}
if (!invitation.invitation[0].name) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.api.authentication.noNameProvided')}));
}
return invitation;
});
}
function processInvitation(invitation) {
var data = invitation.invitation[0], inviteToken = globalUtils.decodeBase64URLsafe(data.token);
return models.Invite.findOne({token: inviteToken, status: 'sent'}, options)
.then(function (_invite) {
invite = _invite;
if (!invite) {
throw new errors.NotFoundError({message: i18n.t('errors.api.invites.inviteNotFound')});
}
if (invite.get('expires') < Date.now()) {
throw new errors.NotFoundError({message: i18n.t('errors.api.invites.inviteExpired')});
}
return models.User.add({
email: data.email,
name: data.name,
password: data.password,
roles: [invite.toJSON().role_id]
}, options);
})
.then(function () {
return invite.destroy(options);
});
}
function formatResponse() {
return {
invitation: [
{message: i18n.t('common.api.authentication.mail.invitationAccepted')}
]
};
}
tasks = [
assertSetupCompleted(true),
validateInvitation,
processInvitation,
formatResponse
];
return pipeline(tasks, invitation);
},
/**
* ### Check for invitation
* @param {Object} options
* @returns {Promise<Object>} An invitation status
*/
isInvitation: function isInvitation(options) {
var tasks,
localOptions = _.cloneDeep(options || {});
function processArgs(options) {
var email = options.email;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new errors.BadRequestError({
message: i18n.t('errors.api.authentication.invalidEmailReceived')
});
}
return email;
}
function checkInvitation(email) {
return models.Invite
.findOne({email: email, status: 'sent'}, options)
.then(function fetchedInvite(invite) {
if (!invite) {
return {invitation: [{valid: false}]};
}
return models.User.findOne({id: invite.get('created_by')})
.then(function fetchedUser(user) {
return {invitation: [{valid: true, invitedBy: user.get('name')}]};
});
});
}
tasks = [
processArgs,
assertSetupCompleted(true),
checkInvitation
];
return pipeline(tasks, localOptions);
},
/**
* Checks the setup status
* @return {Promise}
*/
isSetup: function isSetup() {
var tasks;
function checkSetupStatus() {
return models.User.isSetup();
}
function formatResponse(isSetup) {
return {setup: [
{
status: isSetup,
// Pre-populate from config if, and only if the values exist in config.
title: config.title || undefined,
name: config.user_name || undefined,
email: config.user_email || undefined
}
]};
}
tasks = [
checkSetupStatus,
formatResponse
];
return pipeline(tasks);
},
/**
* Executes the setup tasks and sends an email to the owner
* @param {Object} setupDetails
* @return {Promise<Object>} a user api payload
*/
setup: function setup(setupDetails) {
var tasks;
function doSetup(setupDetails) {
return setupTasks(setupDetails);
}
function sendNotification(setupUser) {
var data = {
ownerEmail: setupUser.email
};
events.emit('setup.completed', setupUser);
return mail.utils.generateContent({data: data, template: 'welcome'})
.then(function then(content) {
var message = {
to: setupUser.email,
subject: i18n.t('common.api.authentication.mail.yourNewGhostBlog'),
html: content.html,
text: content.text
},
payload = {
mail: [{
message: message,
options: {}
}]
};
mailAPI.send(payload, {context: {internal: true}})
.catch(function (err) {
err.context = i18n.t('errors.api.authentication.unableToSendWelcomeEmail');
logging.error(err);
});
})
.return(setupUser);
}
function formatResponse(setupUser) {
return {users: [setupUser]};
}
tasks = [
assertSetupCompleted(false),
doSetup,
sendNotification,
formatResponse
];
return pipeline(tasks, setupDetails);
},
/**
* Updates the blog setup
* @param {Object} setupDetails request payload with setup details
* @param {Object} options
* @return {Promise<Object>} a User API response payload
*/
updateSetup: function updateSetup(setupDetails, options) {
var tasks,
localOptions = _.cloneDeep(options || {});
function processArgs(setupDetails, options) {
if (!options.context || !options.context.user) {
throw new errors.NoPermissionError({message: i18n.t('errors.api.authentication.notTheBlogOwner')});
}
return _.assign({setupDetails: setupDetails}, options);
}
function checkPermission(options) {
return models.User.findOne({role: 'Owner', status: 'all'})
.then(function (owner) {
if (owner.id !== options.context.user) {
throw new errors.NoPermissionError({message: i18n.t('errors.api.authentication.notTheBlogOwner')});
}
return options.setupDetails;
});
}
function formatResponse(user) {
return {users: [user]};
}
tasks = [
processArgs,
assertSetupCompleted(true),
checkPermission,
setupTasks,
formatResponse
];
return pipeline(tasks, setupDetails, localOptions);
},
/**
* Revokes a bearer token.
* @param {Object} tokenDetails
* @param {Object} options
* @return {Promise<Object>} an object containing the revoked token.
*/
revoke: function revokeToken(tokenDetails, options) {
var tasks,
localOptions = _.cloneDeep(options || {});
function processArgs(tokenDetails, options) {
return _.assign({}, tokenDetails, options);
}
function revokeToken(options) {
var providers = [
models.Refreshtoken,
models.Accesstoken
],
response = {token: options.token};
function destroyToken(provider, options, providers) {
return provider.destroyByToken(options)
.return(response)
.catch(provider.NotFoundError, function () {
if (!providers.length) {
return {
token: tokenDetails.token,
error: i18n.t('errors.api.authentication.invalidTokenProvided')
};
}
return destroyToken(providers.pop(), options, providers);
})
.catch(function () {
throw new errors.TokenRevocationError({
message: i18n.t('errors.api.authentication.tokenRevocationFailed')
});
});
}
return destroyToken(providers.pop(), options, providers);
}
tasks = [
processArgs,
revokeToken
];
return pipeline(tasks, tokenDetails, localOptions);
}
};
module.exports = authentication;