mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-27 22:49:56 -05:00
06151ef5ac
no issue When using Ghost OAuth, exchanging the authorization code for an access token was returning a token along with an `expires_in` property containing a JavaScript date representation rather than the number of seconds the token is valid for. This was resulting in the client expecting it's access token to be valid until the year 48796(!) and so never attempting to refresh it's access_token. - return token expiration time of 3600 seconds / 1hr
614 lines
20 KiB
JavaScript
614 lines
20 KiB
JavaScript
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;
|
|
|
|
/**
|
|
* 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 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} resetRequest
|
|
* @returns {Promise<Object>} message
|
|
*/
|
|
generateResetToken: function generateResetToken(resetRequest) {
|
|
var tasks;
|
|
|
|
function validateRequest(resetRequest) {
|
|
return utils.checkObject(resetRequest, '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 settingsQuery = {context: {internal: true}, key: 'dbHash'};
|
|
|
|
return settings.read(settingsQuery).then(function then(response) {
|
|
var dbHash = response.settings[0].value,
|
|
expiresAt = Date.now() + globalUtils.ONE_DAY_MS;
|
|
|
|
return models.User.generateResetToken(email, expiresAt, dbHash);
|
|
}).then(function then(resetToken) {
|
|
return {
|
|
email: email,
|
|
resetToken: resetToken
|
|
};
|
|
});
|
|
}
|
|
|
|
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, resetRequest);
|
|
},
|
|
|
|
/**
|
|
* ## Reset Password
|
|
* reset password if a valid token and password (2x) is passed
|
|
* @param {Object} resetRequest
|
|
* @returns {Promise<Object>} message
|
|
*/
|
|
resetPassword: function resetPassword(resetRequest) {
|
|
var tasks;
|
|
|
|
function validateRequest(resetRequest) {
|
|
return utils.checkObject(resetRequest, 'passwordreset');
|
|
}
|
|
|
|
function doReset(resetRequest) {
|
|
var settingsQuery = {context: {internal: true}, key: 'dbHash'},
|
|
data = resetRequest.passwordreset[0],
|
|
resetToken = data.token,
|
|
newPassword = data.newPassword,
|
|
ne2Password = data.ne2Password;
|
|
|
|
return settings.read(settingsQuery).then(function then(response) {
|
|
return models.User.resetPassword({
|
|
token: resetToken,
|
|
newPassword: newPassword,
|
|
ne2Password: ne2Password,
|
|
dbHash: response.settings[0].value
|
|
});
|
|
}).catch(function (err) {
|
|
throw new errors.UnauthorizedError({err: err});
|
|
});
|
|
}
|
|
|
|
function formatResponse() {
|
|
return {
|
|
passwordreset: [
|
|
{message: i18n.t('common.api.authentication.mail.passwordChanged')}
|
|
]
|
|
};
|
|
}
|
|
|
|
tasks = [
|
|
assertSetupCompleted(true),
|
|
validateRequest,
|
|
doReset,
|
|
formatResponse
|
|
];
|
|
|
|
return pipeline(tasks, resetRequest);
|
|
},
|
|
|
|
/**
|
|
* ### 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 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<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,
|
|
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<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: {}
|
|
}]
|
|
};
|
|
|
|
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<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;
|