Ghost/core/server/api/v0.1/authentication.js
Naz Gargol abda6e6338
Migrated to use url-utils from Ghost-SDK (#10787)
closes #10773

- The refactoring is a substitute for `urlService.utils` used previously throughout the codebase and now extracted into the separate module in Ghost-SDK
- Added url-utils stubbing utility for test suites
- Some tests had to be refactored to avoid double mocks (when url's are being reset inside of rested 'describe' groups)
2019-06-18 15:13:55 +02:00

674 lines
22 KiB
JavaScript

const Promise = require('bluebird'),
{extend, merge, omit, cloneDeep, assign} = require('lodash'),
validator = require('validator'),
config = require('../../config'),
common = require('../../lib/common'),
security = require('../../lib/security'),
constants = require('../../lib/constants'),
pipeline = require('../../lib/promise/pipeline'),
urlUtils = require('../../lib/url-utils'),
mail = require('../../services/mail'),
localUtils = require('./utils'),
models = require('../../models'),
web = require('../../web'),
mailAPI = require('./mail'),
settingsAPI = require('./settings'),
tokenSecurity = {};
let authentication;
/**
* Returns setup status
*
* @return {Promise<Boolean>}
*/
function checkSetup() {
return authentication.isSetup().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((isSetup) => {
if (isSetup === status) {
return __;
}
const completed = common.i18n.t('errors.api.authentication.setupAlreadyCompleted'),
notCompleted = common.i18n.t('errors.api.authentication.setupMustBeCompleted');
function throwReason(reason) {
throw new common.errors.NoPermissionError({message: reason});
}
if (isSetup) {
throwReason(completed);
} else {
throwReason(notCompleted);
}
});
};
}
function setupTasks(setupData) {
let tasks;
function validateData(setupData) {
return localUtils.checkObject(setupData, 'setup').then((checked) => {
const data = checked.setup[0];
return {
name: data.name,
email: data.email,
password: data.password,
blogTitle: data.blogTitle,
status: 'active'
};
});
}
function setupUser(userData) {
const context = {context: {internal: true}},
User = models.User;
return User.findOne({role: 'Owner', status: 'all'}).then((owner) => {
if (!owner) {
throw new common.errors.GhostError({
message: common.i18n.t('errors.api.authentication.setupUnableToRun')
});
}
return User.setup(userData, extend({id: owner.id}, context));
}).then((user) => {
return {
user: user,
userData: userData
};
});
}
function doSettings(data) {
const user = data.user,
blogTitle = data.userData.blogTitle,
context = {context: {user: data.user.id}};
let userSettings;
if (!blogTitle || typeof blogTitle !== 'string') {
return user;
}
userSettings = [
{key: 'title', value: blogTitle.trim()},
{key: 'description', value: common.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](events.js.html#api%20methods)
*/
authentication = {
/**
* @description generate a reset token for a given email address
* @param {Object} object
* @returns {Promise<Object>} message
*/
generateResetToken(object) {
let tasks;
function validateRequest(object) {
return localUtils.checkObject(object, 'passwordreset').then((data) => {
const email = data.passwordreset[0].email;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new common.errors.BadRequestError({
message: common.i18n.t('errors.api.authentication.noEmailProvided')
});
}
return email;
});
}
function generateToken(email) {
const options = {context: {internal: true}};
let dbHash, token;
return settingsAPI.read(merge({key: 'db_hash'}, options))
.then((response) => {
dbHash = response.settings[0].value;
return models.User.getByEmail(email, options);
})
.then((user) => {
if (!user) {
throw new common.errors.NotFoundError({message: common.i18n.t('errors.api.users.userNotFound')});
}
token = security.tokens.resetToken.generateHash({
expires: Date.now() + constants.ONE_DAY_MS,
email: email,
dbHash: dbHash,
password: user.get('password')
});
return {
email: email,
resetToken: token
};
});
}
function sendResetNotification(data) {
const adminUrl = urlUtils.urlFor('admin', true),
resetUrl = urlUtils.urlJoin(adminUrl, 'reset', security.url.encodeBase64(data.resetToken), '/');
return mail.utils.generateContent({
data: {
resetUrl: resetUrl
},
template: 'reset-password'
}).then((content) => {
const payload = {
mail: [{
message: {
to: data.email,
subject: common.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: common.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(object, opts) {
let tasks,
tokenIsCorrect,
dbHash,
tokenParts;
const options = {context: {internal: true}};
function validateRequest() {
return localUtils.validate('passwordreset')(object, options)
.then((options) => {
const data = options.data.passwordreset[0];
if (data.newPassword !== data.ne2Password) {
return Promise.reject(new common.errors.ValidationError({
message: common.i18n.t('errors.models.user.newPasswordsDoNotMatch')
}));
}
return Promise.resolve(options);
});
}
function extractTokenParts(options) {
options.data.passwordreset[0].token = security.url.decodeBase64(options.data.passwordreset[0].token);
tokenParts = security.tokens.resetToken.extract({
token: options.data.passwordreset[0].token
});
if (!tokenParts) {
return Promise.reject(new common.errors.UnauthorizedError({
message: common.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 common.errors.NoPermissionError({
message: common.i18n.t('errors.models.user.tokenLocked')
}));
}
return Promise.resolve(options);
}
function doReset(options) {
const data = options.data.passwordreset[0],
resetToken = data.token,
oldPassword = data.oldPassword,
newPassword = data.newPassword;
return settingsAPI.read(merge({key: 'db_hash'}, omit(options, 'data')))
.then((response) => {
dbHash = response.settings[0].value;
return models.User.getByEmail(tokenParts.email, options);
})
.then((user) => {
if (!user) {
throw new common.errors.NotFoundError({message: common.i18n.t('errors.api.users.userNotFound')});
}
tokenIsCorrect = security.tokens.resetToken.compare({
token: resetToken,
dbHash: dbHash,
password: user.get('password')
});
if (!tokenIsCorrect) {
return Promise.reject(new common.errors.BadRequestError({
message: common.i18n.t('errors.api.common.invalidTokenStructure')
}));
}
web.shared.middlewares.api.spamPrevention.userLogin()
.reset(opts.ip, `${tokenParts.email}login`);
return models.User.changePassword({
oldPassword: oldPassword,
newPassword: newPassword,
user_id: user.id
}, options);
})
.then((updatedUser) => {
updatedUser.set('status', 'active');
return updatedUser.save(options);
})
.catch(common.errors.ValidationError, (err) => {
return Promise.reject(err);
})
.catch((err) => {
if (common.errors.utils.isIgnitionError(err)) {
return Promise.reject(err);
}
return Promise.reject(new common.errors.UnauthorizedError({err: err}));
});
}
function formatResponse() {
return {
passwordreset: [
{message: common.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(invitation) {
let tasks,
invite;
const options = {context: {internal: true}};
function validateInvitation(invitation) {
return localUtils.checkObject(invitation, 'invitation')
.then(() => {
if (!invitation.invitation[0].token) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noTokenProvided')}));
}
if (!invitation.invitation[0].email) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noEmailProvided')}));
}
if (!invitation.invitation[0].password) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noPasswordProvided')}));
}
if (!invitation.invitation[0].name) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.authentication.noNameProvided')}));
}
return invitation;
});
}
function processInvitation(invitation) {
const data = invitation.invitation[0],
inviteToken = security.url.decodeBase64(data.token);
return models.Invite.findOne({token: inviteToken, status: 'sent'}, options)
.then((_invite) => {
invite = _invite;
if (!invite) {
throw new common.errors.NotFoundError({message: common.i18n.t('errors.api.invites.inviteNotFound')});
}
if (invite.get('expires') < Date.now()) {
throw new common.errors.NotFoundError({message: common.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(() => {
return invite.destroy(options);
});
}
function formatResponse() {
return {
invitation: [
{message: common.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(options) {
let tasks;
const localOptions = cloneDeep(options || {});
function processArgs(options) {
const email = options.email;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new common.errors.BadRequestError({
message: common.i18n.t('errors.api.authentication.invalidEmailReceived')
});
}
return email;
}
function checkInvitation(email) {
return models.Invite
.findOne({email: email, status: 'sent'}, options)
.then((invite) => {
if (!invite) {
return {invitation: [{valid: false}]};
}
return {invitation: [{valid: true}]};
});
}
tasks = [
processArgs,
assertSetupCompleted(true),
checkInvitation
];
return pipeline(tasks, localOptions);
},
/**
* Checks the setup status
* @return {Promise}
*/
isSetup() {
let 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(setupDetails) {
let tasks;
function doSetup(setupDetails) {
return setupTasks(setupDetails);
}
function sendNotification(setupUser) {
const data = {
ownerEmail: setupUser.email
};
common.events.emit('setup.completed', setupUser);
if (config.get('sendWelcomeEmail')) {
return mail.utils.generateContent({data: data, template: 'welcome'})
.then((content) => {
const message = {
to: setupUser.email,
subject: common.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((err) => {
err.context = common.i18n.t('errors.api.authentication.unableToSendWelcomeEmail');
common.logging.error(err);
});
})
.return(setupUser);
}
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(setupDetails, options) {
let tasks;
const localOptions = cloneDeep(options || {});
function processArgs(setupDetails, options) {
if (!options.context || !options.context.user) {
throw new common.errors.NoPermissionError({message: common.i18n.t('errors.api.authentication.notTheBlogOwner')});
}
return assign({setupDetails: setupDetails}, options);
}
function checkPermission(options) {
return models.User.findOne({role: 'Owner', status: 'all'})
.then((owner) => {
if (owner.id !== options.context.user) {
throw new common.errors.NoPermissionError({message: common.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(tokenDetails, options) {
let tasks;
const localOptions = cloneDeep(options || {});
function processArgs(tokenDetails, options) {
return assign({}, tokenDetails, options);
}
function revokeToken(options) {
const providers = [
models.Refreshtoken,
models.Accesstoken
],
response = {token: options.token};
function destroyToken(provider, options, providers) {
return provider.destroyByToken(options)
.return(response)
.catch(provider.NotFoundError, () => {
if (!providers.length) {
return {
token: tokenDetails.token,
error: common.i18n.t('errors.api.authentication.invalidTokenProvided')
};
}
return destroyToken(providers.pop(), options, providers);
})
.catch(() => {
throw new common.errors.TokenRevocationError({
message: common.i18n.t('errors.api.authentication.tokenRevocationFailed')
});
});
}
return destroyToken(providers.pop(), options, providers);
}
tasks = [
processArgs,
revokeToken
];
return pipeline(tasks, tokenDetails, localOptions);
}
};
module.exports = authentication;