Skip to content

Commit

Permalink
✨ Ghost OAuth (#7451)
Browse files Browse the repository at this point in the history
issue #7452

Remote oauth2 authentication with Ghost.org.

This PR supports:

- oauth2 login or local login
- authentication on blog setup
- authentication on invite
- normal authentication
- does not contain many, many tests, but we'll improve in the next alpha weeks
  • Loading branch information
kirrg001 authored and kevinansfield committed Sep 30, 2016
1 parent 3e727d0 commit 6473c9e
Show file tree
Hide file tree
Showing 26 changed files with 743 additions and 274 deletions.
52 changes: 42 additions & 10 deletions core/server/api/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,37 @@ function setupTasks(setupData) {
* **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: accessExpires
};
});
},

/**
* @description generate a reset token for a given email address
Expand Down Expand Up @@ -364,22 +395,23 @@ authentication = {

function checkInvitation(email) {
return models.Invite
.where({email: email, status: 'sent'})
.count('id')
.then(function then(count) {
return !!count;
});
}
.findOne({email: email, status: 'sent'}, options)
.then(function fetchedInvite(invite) {
if (!invite) {
return {invitation: [{valid: false}]};
}

function formatResponse(isInvited) {
return {invitation: [{valid: isInvited}]};
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,
formatResponse
checkInvitation
];

return pipeline(tasks, localOptions);
Expand Down
4 changes: 3 additions & 1 deletion core/server/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,10 @@ http = function http(apiMethod) {
var object = req.body,
options = _.extend({}, req.file, req.query, req.params, {
context: {
// @TODO: forward the client and user obj in 1.0 (options.context.user.id)
user: ((req.user && req.user.id) || (req.user && req.user.id === 0)) ? req.user.id : null,
client: (req.client && req.client.slug) ? req.client.slug : null
client: (req.client && req.client.slug) ? req.client.slug : null,
client_id: (req.client && req.client.id) ? req.client.id : null
}
});

Expand Down
149 changes: 149 additions & 0 deletions core/server/auth/auth-strategies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
var models = require('../models'),
utils = require('../utils'),
i18n = require('../i18n'),
errors = require('../errors'),
_ = require('lodash'),
strategies;

strategies = {

/**
* ClientPasswordStrategy
*
* This strategy is used to authenticate registered OAuth clients. It is
* employed to protect the `token` endpoint, which consumers use to obtain
* access tokens. The OAuth 2.0 specification suggests that clients use the
* HTTP Basic scheme to authenticate (not implemented yet).
* Use of the client password strategy is implemented to support ember-simple-auth.
*/
clientPasswordStrategy: function clientPasswordStrategy(clientId, clientSecret, done) {
return models.Client.findOne({slug: clientId}, {withRelated: ['trustedDomains']})
.then(function then(model) {
if (model) {
var client = model.toJSON({include: ['trustedDomains']});
if (client.status === 'enabled' && client.secret === clientSecret) {
return done(null, client);
}
}
return done(null, false);
});
},

/**
* BearerStrategy
*
* This strategy is used to authenticate users based on an access token (aka a
* bearer token). The user must have previously authorized a client
* application, which is issued an access token to make requests on behalf of
* the authorizing user.
*/
bearerStrategy: function bearerStrategy(accessToken, done) {
return models.Accesstoken.findOne({token: accessToken})
.then(function then(model) {
if (model) {
var token = model.toJSON();
if (token.expires > Date.now()) {
return models.User.findOne({id: token.user_id})
.then(function then(model) {
if (model) {
var user = model.toJSON(),
info = {scope: '*'};
return done(null, {id: user.id}, info);
}
return done(null, false);
});
} else {
return done(null, false);
}
} else {
return done(null, false);
}
});
},

/**
* Ghost Strategy
* patronusRefreshToken: will be null for now, because we don't need it right now
*
* CASES:
* - via invite token
* - via normal auth
* - via setup
*
* @TODO: validate patronus profile?
*/
ghostStrategy: function ghostStrategy(req, patronusAccessToken, patronusRefreshToken, profile, done) {
var inviteToken = req.body.inviteToken,
options = {context: {internal: true}},
handleInviteToken, handleSetup;

handleInviteToken = function handleInviteToken() {
var user, invite;
inviteToken = utils.decodeBase64URLsafe(inviteToken);

return models.Invite.findOne({token: inviteToken}, options)
.then(function addInviteUser(_invite) {
invite = _invite;

if (!invite) {
throw new errors.NotFoundError(i18n.t('errors.api.invites.inviteNotFound'));
}

if (invite.get('expires') < Date.now()) {
throw new errors.NotFoundError(i18n.t('errors.api.invites.inviteExpired'));
}

return models.User.add({
email: profile.email_address,
name: profile.email_address,
password: utils.uid(50),
roles: invite.toJSON().roles
}, options);
})
.then(function destroyInvite(_user) {
user = _user;
return invite.destroy(options);
})
.then(function () {
return user;
});
};

handleSetup = function handleSetup() {
return models.User.findOne({slug: 'ghost-owner', status: 'all'}, options)
.then(function fetchedOwner(owner) {
if (!owner) {
throw new errors.NotFoundError(i18n.t('errors.models.user.userNotFound'));
}

return models.User.edit({
email: profile.email_address,
status: 'active'
}, _.merge({id: owner.id}, options));
});
};

models.User.getByEmail(profile.email_address, options)
.then(function fetchedUser(user) {
if (user) {
return user;
}

if (inviteToken) {
return handleInviteToken();
}

return handleSetup();
})
.then(function updatePatronusToken(user) {
options.id = user.id;
return models.User.edit({patronus_access_token: patronusAccessToken}, options);
})
.then(function returnResponse(user) {
done(null, user, profile);
})
.catch(done);
}
};

module.exports = strategies;
53 changes: 25 additions & 28 deletions core/server/middleware/auth.js → core/server/auth/authenticate.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
var passport = require('passport'),
errors = require('../errors'),
events = require('../events'),
labs = require('../utils/labs'),
i18n = require('../i18n'),

auth;
var passport = require('passport'),
errors = require('../errors'),
events = require('../events'),
i18n = require('../i18n'),
authenticate;

function isBearerAutorizationHeader(req) {
var parts,
Expand All @@ -29,8 +27,7 @@ function isBearerAutorizationHeader(req) {
return false;
}

auth = {

authenticate = {
// ### Authenticate Client Middleware
authenticateClient: function authenticateClient(req, res, next) {
// skip client authentication if bearer token is present
Expand Down Expand Up @@ -108,28 +105,28 @@ auth = {
)(req, res, next);
},

// Workaround for missing permissions
// TODO: rework when https://github.com/TryGhost/Ghost/issues/3911 is done
requiresAuthorizedUser: function requiresAuthorizedUser(req, res, next) {
if (req.user && req.user.id) {
return next();
} else {
return errors.handleAPIError(new errors.NoPermissionError(i18n.t('errors.middleware.auth.pleaseSignIn')), req, res, next);
// ### Authenticate Ghost.org User
authenticateGhostUser: function authenticateGhostUser(req, res, next) {
req.query.code = req.body.authorizationCode;

if (!req.query.code) {
return errors.handleAPIError(new errors.UnauthorizedError(i18n.t('errors.middleware.auth.accessDenied')), req, res, next);
}
},

// ### Require user depending on public API being activated.
requiresAuthorizedUserPublicAPI: function requiresAuthorizedUserPublicAPI(req, res, next) {
if (labs.isSet('publicAPI') === true) {
return next();
} else {
if (req.user && req.user.id) {
return next();
} else {
return errors.handleAPIError(new errors.NoPermissionError(i18n.t('errors.middleware.auth.pleaseSignIn')), req, res, next);
passport.authenticate('ghost', {session: false, failWithError: false}, function authenticate(err, user, info) {
if (err) {
return next(err);
}
}

if (!user) {
return errors.handleAPIError(new errors.UnauthorizedError(i18n.t('errors.middleware.auth.accessDenied')), req, res, next);
}

req.authInfo = info;
req.user = user;
next();
})(req, res, next);
}
};

module.exports = auth;
module.exports = authenticate;
31 changes: 31 additions & 0 deletions core/server/auth/authorize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
var errors = require('../errors'),
labs = require('../utils/labs'),
i18n = require('../i18n'),
authorize;

authorize = {
// Workaround for missing permissions
// TODO: rework when https://github.com/TryGhost/Ghost/issues/3911 is done
requiresAuthorizedUser: function requiresAuthorizedUser(req, res, next) {
if (req.user && req.user.id) {
return next();
} else {
return errors.handleAPIError(new errors.NoPermissionError(i18n.t('errors.middleware.auth.pleaseSignIn')), req, res, next);
}
},

// ### Require user depending on public API being activated.
requiresAuthorizedUserPublicAPI: function requiresAuthorizedUserPublicAPI(req, res, next) {
if (labs.isSet('publicAPI') === true) {
return next();
} else {
if (req.user && req.user.id) {
return next();
} else {
return errors.handleAPIError(new errors.NoPermissionError(i18n.t('errors.middleware.auth.pleaseSignIn')), req, res, next);
}
}
}
};

module.exports = authorize;
17 changes: 17 additions & 0 deletions core/server/auth/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
var passport = require('./passport'),
authorize = require('./authorize'),
authenticate = require('./authenticate'),
oauth = require('./oauth');

exports.init = function (options) {
oauth.init();

return passport.init(options)
.then(function (response) {
return {auth: response.passport};
});
};

exports.oauth = oauth;
exports.authorize = authorize;
exports.authenticate = authenticate;
Loading

0 comments on commit 6473c9e

Please sign in to comment.