var _ = require('lodash'), validator = require('validator'), Promise = require('bluebird'), pipeline = require('../utils/pipeline'), settings = require('./settings'), mail = require('./../mail'), apiMail = require('./mail'), globalUtils = require('../utils'), utils = require('./utils'), errors = require('../errors'), models = require('../models'), logging = require('../logging'), events = require('../events'), config = require('../config'), i18n = require('../i18n'), authentication, tokenSecurity = {}; /** * Returns setup status * * @return {Promise} */ 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 utils.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 settings.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 = { /** * Generate a pair of tokens */ createTokens: function createTokens(data, options) { var localAccessToken = globalUtils.uid(191), localRefreshToken = globalUtils.uid(191), accessExpires = Date.now() + globalUtils.ONE_HOUR_MS, refreshExpires = Date.now() + globalUtils.ONE_WEEK_MS, client = options.context.client_id, user = options.context.user; return models.Accesstoken.add({ token: localAccessToken, user_id: user, client_id: client, expires: accessExpires }).then(function () { return models.Refreshtoken.add({ token: localRefreshToken, user_id: user, client_id: client, expires: refreshExpires }); }).then(function () { return { access_token: localAccessToken, refresh_token: localRefreshToken, expires_in: globalUtils.ONE_HOUR_S }; }); }, /** * @description generate a reset token for a given email address * @param {Object} object * @returns {Promise} message */ generateResetToken: function generateResetToken(object) { var tasks; function validateRequest(object) { return utils.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 settings.read(_.merge({key: 'dbHash'}, 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 baseUrl = config.get('forceAdminSSL') ? (config.get('urlSSL') || config.get('url')) : config.get('url'), resetUrl = baseUrl.replace(/\/$/, '') + '/ghost/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 apiMail.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} message */ resetPassword: function resetPassword(object) { var tasks, tokenIsCorrect, dbHash, options = {context: {internal: true}}, tokenParts; function validateRequest() { return utils.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 settings.read(_.merge({key: 'dbHash'}, 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') })); } 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(function (err) { throw 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} */ acceptInvitation: function acceptInvitation(invitation) { var tasks, invite, options = {context: {internal: true}}; function validateInvitation(invitation) { return utils.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'}, _.merge({}, {include: ['roles']}, 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().roles }, 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} 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, validStatuses = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked']; function checkSetupStatus() { return models.User .where('status', 'in', validStatuses) .count('id') .then(function (count) { return !!count; }); } 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} 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: {} }] }; apiMail.send(payload, {context: {internal: true}}).catch(function (error) { logging.error(new errors.EmailError({ err: error, context: i18n.t('errors.api.authentication.unableToSendWelcomeEmail'), help: i18n.t('errors.api.authentication.checkEmailConfigInstructions', {url: 'http://support.ghost.org/mail/'}) })); }); }) .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} 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} 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;