Ghost OAuth (#7451)

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
This commit is contained in:
Katharina Irrgang 2016-09-30 13:45:59 +02:00 committed by Kevin Ansfield
parent 3e727d01f7
commit 6473c9e858
26 changed files with 744 additions and 275 deletions

View File

@ -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
@ -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);

View File

@ -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
}
});

View File

@ -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;

View File

@ -1,10 +1,8 @@
var passport = require('passport'),
errors = require('../errors'),
events = require('../events'),
labs = require('../utils/labs'),
i18n = require('../i18n'),
auth;
authenticate;
function isBearerAutorizationHeader(req) {
var parts,
@ -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
@ -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;
// ### 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);
if (!req.query.code) {
return errors.handleAPIError(new errors.UnauthorizedError(i18n.t('errors.middleware.auth.accessDenied')), 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;

View File

@ -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
core/server/auth/index.js Normal file
View File

@ -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;

97
core/server/auth/oauth.js Normal file
View File

@ -0,0 +1,97 @@
var oauth2orize = require('oauth2orize'),
models = require('../models'),
utils = require('../utils'),
errors = require('../errors'),
authenticationAPI = require('../api/authentication'),
spamPrevention = require('../middleware/spam-prevention'),
i18n = require('../i18n'),
oauthServer,
oauth;
function exchangeRefreshToken(client, refreshToken, scope, done) {
models.Refreshtoken.findOne({token: refreshToken})
.then(function then(model) {
if (!model) {
return done(new errors.NoPermissionError(i18n.t('errors.middleware.oauth.invalidRefreshToken')), false);
} else {
var token = model.toJSON(),
accessToken = utils.uid(191),
accessExpires = Date.now() + utils.ONE_HOUR_MS,
refreshExpires = Date.now() + utils.ONE_WEEK_MS;
if (token.expires > Date.now()) {
models.Accesstoken.add({
token: accessToken,
user_id: token.user_id,
client_id: token.client_id,
expires: accessExpires
}).then(function then() {
return models.Refreshtoken.edit({expires: refreshExpires}, {id: token.id});
}).then(function then() {
return done(null, accessToken, {expires_in: utils.ONE_HOUR_S});
}).catch(function handleError(error) {
return done(error, false);
});
} else {
done(new errors.UnauthorizedError(i18n.t('errors.middleware.oauth.refreshTokenExpired')), false);
}
}
});
}
function exchangePassword(client, username, password, scope, done) {
// Validate the client
models.Client.findOne({slug: client.slug})
.then(function then(client) {
if (!client) {
return done(new errors.NoPermissionError(i18n.t('errors.middleware.oauth.invalidClient')), false);
}
// Validate the user
return models.User.check({email: username, password: password})
.then(function then(user) {
return authenticationAPI.createTokens({}, {context: {client_id: client.id, user: user.id}});
})
.then(function then(response) {
spamPrevention.resetCounter(username);
return done(null, response.access_token, response.refresh_token, {expires_in: response.expires_in});
});
})
.catch(function handleError(error) {
return done(error, false);
});
}
oauth = {
init: function init() {
oauthServer = oauth2orize.createServer();
// remove all expired accesstokens on startup
models.Accesstoken.destroyAllExpired();
// remove all expired refreshtokens on startup
models.Refreshtoken.destroyAllExpired();
// Exchange user id and password for access tokens. The callback accepts the
// `client`, which is exchanging the user's name and password from the
// authorization request for verification. If these values are validated, the
// application issues an access token on behalf of the user who authorized the code.
oauthServer.exchange(oauth2orize.exchange.password({userProperty: 'client'},
exchangePassword));
// Exchange the refresh token to obtain an access token. The callback accepts the
// `client`, which is exchanging a `refreshToken` previously issued by the server
// for verification. If these values are validated, the application issues an
// access token on behalf of the user who authorized the code.
oauthServer.exchange(oauth2orize.exchange.refreshToken({userProperty: 'client'},
exchangeRefreshToken));
},
// ### Generate access token Middleware
// register the oauth2orize middleware for password and refresh token grants
generateAccessToken: function generateAccessToken(req, res, next) {
return oauthServer.token()(req, res, next);
}
};
module.exports = oauth;

View File

@ -0,0 +1,103 @@
var ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
BearerStrategy = require('passport-http-bearer').Strategy,
GhostOAuth2Strategy = require('passport-ghost').Strategy,
passport = require('passport'),
Promise = require('bluebird'),
authStrategies = require('./auth-strategies'),
utils = require('../utils'),
errors = require('../errors'),
models = require('../models'),
_private = {};
/**
* Public client registration at Ghost.org
*/
_private.registerClient = function registerClient(options) {
var ghostOAuth2Strategy = options.ghostOAuth2Strategy,
url = options.url;
return new Promise(function (resolve, reject) {
var retry = function retry(retryCount, done) {
models.Client.findOne({name: 'Ghost Patronus'}, {context: {internal: true}})
.then(function (client) {
// CASE: patronus client is already registered
if (client) {
return done(null, {
client_id: client.get('uuid'),
client_secret: client.get('secret')
});
}
return ghostOAuth2Strategy.registerClient({clientName: url})
.then(function addClient(credentials) {
return models.Client.add({
name: 'Ghost Patronus',
slug: 'patronus',
uuid: credentials.client_id,
secret: credentials.client_secret
}, {context: {internal: true}});
})
.then(function returnClient(client) {
return done(null, {
client_id: client.get('uuid'),
client_secret: client.get('secret')
});
})
.catch(function publicClientRegistrationError(err) {
if (retryCount < 0) {
return done(new errors.IncorrectUsage(
'Public client registration failed: ' + err.code || err.message,
'Please verify that the url is reachable: ' + ghostOAuth2Strategy.url
));
}
console.log('RETRY: Public Client Registration...');
var timeout = setTimeout(function () {
clearTimeout(timeout);
retryCount = retryCount - 1;
retry(retryCount, done);
}, 3000);
});
})
.catch(reject);
};
retry(10, function retryPublicClientRegistration(err, client) {
if (err) {
return reject(err);
}
resolve(client);
});
});
};
exports.init = function initPassport(options) {
var type = options.type,
url = options.url;
return new Promise(function (resolve, reject) {
passport.use(new ClientPasswordStrategy(authStrategies.clientPasswordStrategy));
passport.use(new BearerStrategy(authStrategies.bearerStrategy));
if (type !== 'patronus') {
return resolve({passport: passport.initialize()});
}
var ghostOAuth2Strategy = new GhostOAuth2Strategy({
callbackURL: utils.url.getBaseUrl() + '/ghost/',
url: url,
passReqToCallback: true
}, authStrategies.ghostStrategy);
_private.registerClient({ghostOAuth2Strategy: ghostOAuth2Strategy, url: utils.url.getBaseUrl()})
.then(function setClient(client) {
console.log('SUCCESS: Public Client Registration');
ghostOAuth2Strategy.setClient(client);
passport.use(ghostOAuth2Strategy);
return resolve({passport: passport.initialize()});
})
.catch(reject);
});
};

View File

@ -24,5 +24,8 @@
},
"scheduling": {
"active": "SchedulingDefault"
},
"auth": {
"type": "password"
}
}

View File

@ -8,5 +8,9 @@
},
"paths": {
"contentPath": "content/"
},
"auth": {
"type": "patronus",
"url": "http://devauth.ghost.org:8080"
}
}

View File

@ -1,6 +1,7 @@
var _ = require('lodash'),
Promise = require('bluebird'),
api = require('../api'),
config = require('../config'),
errors = require('../errors'),
updateCheck = require('../update-check'),
i18n = require('../i18n'),
@ -17,7 +18,12 @@ adminControllers = {
var configuration,
fetch = {
configuration: api.configuration.read().then(function (res) { return res.configuration[0]; }),
client: api.clients.read({slug: 'ghost-admin'}).then(function (res) { return res.clients[0]; })
client: api.clients.read({slug: 'ghost-admin'}).then(function (res) { return res.clients[0]; }),
patronus: api.clients.read({slug: 'patronus'})
.then(function (res) { return res.clients[0]; })
.catch(function () {
return;
})
};
return Promise.props(fetch).then(function renderIndex(result) {
@ -26,6 +32,10 @@ adminControllers = {
configuration.clientId = {value: result.client.slug, type: 'string'};
configuration.clientSecret = {value: result.client.secret, type: 'string'};
if (result.patronus && config.get('auth:type') === 'patronus') {
configuration.ghostAuthId = {value: result.patronus.uuid, type: 'string'};
}
res.render('default', {
configuration: configuration
});

View File

@ -29,6 +29,7 @@ module.exports = {
uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}},
name: {type: 'string', maxlength: 150, nullable: false},
slug: {type: 'string', maxlength: 150, nullable: false, unique: true},
patronus_access_token: {type: 'string', nullable: true},
password: {type: 'string', maxlength: 60, nullable: false},
email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},
image: {type: 'text', maxlength: 2000, nullable: true},

View File

@ -25,6 +25,7 @@ var express = require('express'),
models = require('./models'),
permissions = require('./permissions'),
apps = require('./apps'),
auth = require('./auth'),
xmlrpc = require('./data/xml/xmlrpc'),
slack = require('./data/slack'),
GhostServer = require('./ghost-server'),
@ -61,7 +62,7 @@ function initDbHashAndFirstRun() {
function init(options) {
options = options || {};
var ghostServer = null;
var ghostServer, parentApp;
// ### Initialisation
// The server and its dependencies require a populated config
@ -101,7 +102,7 @@ function init(options) {
);
}).then(function () {
// Get reference to an express app instance.
var parentApp = express();
parentApp = express();
// ## Middleware and Routing
middleware(parentApp);
@ -119,6 +120,11 @@ function init(options) {
});
});
return auth.init(config.get('auth'))
.then(function (response) {
parentApp.use(response.auth);
});
}).then(function () {
return new GhostServer(parentApp);
}).then(function (_ghostServer) {
ghostServer = _ghostServer;

View File

@ -1,61 +0,0 @@
var models = require('../models'),
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);
}
});
}
};
module.exports = strategies;

View File

@ -10,17 +10,13 @@ var bodyParser = require('body-parser'),
serveStatic = require('express').static,
slashes = require('connect-slashes'),
storage = require('../storage'),
passport = require('passport'),
utils = require('../utils'),
sitemapHandler = require('../data/xml/sitemap/handler'),
multer = require('multer'),
tmpdir = require('os').tmpdir,
authStrategies = require('./auth-strategies'),
auth = require('./auth'),
cacheControl = require('./cache-control'),
checkSSL = require('./check-ssl'),
decideIsAdmin = require('./decide-is-admin'),
oauth = require('./oauth'),
redirectToSetup = require('./redirect-to-setup'),
serveSharedFile = require('./serve-shared-file'),
spamPrevention = require('./spam-prevention'),
@ -34,10 +30,6 @@ var bodyParser = require('body-parser'),
netjet = require('netjet'),
labs = require('./labs'),
helpers = require('../helpers'),
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
BearerStrategy = require('passport-http-bearer').Strategy,
middleware,
setupMiddleware;
@ -46,12 +38,7 @@ middleware = {
validation: validation,
cacheControl: cacheControl,
spamPrevention: spamPrevention,
oauth: oauth,
api: {
authenticateClient: auth.authenticateClient,
authenticateUser: auth.authenticateUser,
requiresAuthorizedUser: auth.requiresAuthorizedUser,
requiresAuthorizedUserPublicAPI: auth.requiresAuthorizedUserPublicAPI,
errorHandler: errors.handleAPIError,
cors: cors,
labs: labs,
@ -84,11 +71,6 @@ setupMiddleware = function setupMiddleware(blogApp) {
// Load helpers
helpers.loadCoreHelpers(adminHbs);
// Initialize Auth Handlers & OAuth middleware
passport.use(new ClientPasswordStrategy(authStrategies.clientPasswordStrategy));
passport.use(new BearerStrategy(authStrategies.bearerStrategy));
oauth.init();
// Make sure 'req.secure' is valid for proxied requests
// (X-Forwarded-Proto header will be checked, if present)
blogApp.enable('trust proxy');
@ -180,8 +162,6 @@ setupMiddleware = function setupMiddleware(blogApp) {
blogApp.use(bodyParser.json({limit: '1mb'}));
blogApp.use(bodyParser.urlencoded({extended: true, limit: '1mb'}));
blogApp.use(passport.initialize());
// ### Caching
// Blog frontend is cacheable
blogApp.use(cacheControl('public'));

View File

@ -1,103 +0,0 @@
var oauth2orize = require('oauth2orize'),
models = require('../models'),
utils = require('../utils'),
errors = require('../errors'),
spamPrevention = require('./spam-prevention'),
i18n = require('../i18n'),
oauthServer,
oauth;
function exchangeRefreshToken(client, refreshToken, scope, done) {
models.Refreshtoken.findOne({token: refreshToken}).then(function then(model) {
if (!model) {
return done(new errors.NoPermissionError(i18n.t('errors.middleware.oauth.invalidRefreshToken')), false);
} else {
var token = model.toJSON(),
accessToken = utils.uid(191),
accessExpires = Date.now() + utils.ONE_HOUR_MS,
refreshExpires = Date.now() + utils.ONE_WEEK_MS;
if (token.expires > Date.now()) {
models.Accesstoken.add({
token: accessToken,
user_id: token.user_id,
client_id: token.client_id,
expires: accessExpires
}).then(function then() {
return models.Refreshtoken.edit({expires: refreshExpires}, {id: token.id});
}).then(function then() {
return done(null, accessToken, {expires_in: utils.ONE_HOUR_S});
}).catch(function handleError(error) {
return done(error, false);
});
} else {
done(new errors.UnauthorizedError(i18n.t('errors.middleware.oauth.refreshTokenExpired')), false);
}
}
});
}
function exchangePassword(client, username, password, scope, done) {
// Validate the client
models.Client.findOne({slug: client.slug}).then(function then(client) {
if (!client) {
return done(new errors.NoPermissionError(i18n.t('errors.middleware.oauth.invalidClient')), false);
}
// Validate the user
return models.User.check({email: username, password: password}).then(function then(user) {
// Everything validated, return the access- and refreshtoken
var accessToken = utils.uid(191),
refreshToken = utils.uid(191),
accessExpires = Date.now() + utils.ONE_HOUR_MS,
refreshExpires = Date.now() + utils.ONE_WEEK_MS;
return models.Accesstoken.add(
{token: accessToken, user_id: user.id, client_id: client.id, expires: accessExpires}
).then(function then() {
return models.Refreshtoken.add(
{token: refreshToken, user_id: user.id, client_id: client.id, expires: refreshExpires}
);
}).then(function then() {
spamPrevention.resetCounter(username);
return done(null, accessToken, refreshToken, {expires_in: utils.ONE_HOUR_S});
});
}).catch(function handleError(error) {
return done(error, false);
});
});
}
oauth = {
init: function init() {
oauthServer = oauth2orize.createServer();
// remove all expired accesstokens on startup
models.Accesstoken.destroyAllExpired();
// remove all expired refreshtokens on startup
models.Refreshtoken.destroyAllExpired();
// Exchange user id and password for access tokens. The callback accepts the
// `client`, which is exchanging the user's name and password from the
// authorization request for verification. If these values are validated, the
// application issues an access token on behalf of the user who authorized the code.
oauthServer.exchange(oauth2orize.exchange.password({userProperty: 'client'},
exchangePassword));
// Exchange the refresh token to obtain an access token. The callback accepts the
// `client`, which is exchanging a `refreshToken` previously issued by the server
// for verification. If these values are validated, the application issues an
// access token on behalf of the user who authorized the code.
oauthServer.exchange(oauth2orize.exchange.refreshToken({userProperty: 'client'},
exchangeRefreshToken));
},
// ### Generate access token Middleware
// register the oauth2orize middleware for password and refresh token grants
generateAccessToken: function generateAccessToken(req, res, next) {
return oauthServer.token()(req, res, next);
}
};
module.exports = oauth;

View File

@ -3,8 +3,11 @@ var api = require('../api'),
// Redirect to setup if no user exists
function redirectToSetup(req, res, next) {
var isSetupRequest = req.path.match(/\/setup\//),
isOauthAuthorization = req.path.match(/\/$/) && req.query && (req.query.code || req.query.error);
api.authentication.isSetup().then(function then(exists) {
if (!exists.setup[0].status && !req.path.match(/\/setup\//)) {
if (!exists.setup[0].status && !isSetupRequest && !isOauthAuthorization) {
return res.redirect(utils.url.getSubdir() + '/ghost/setup/');
}
next();

View File

@ -131,6 +131,7 @@ User = ghostBookshelf.Model.extend({
var attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
// remove password hash for security reasons
delete attrs.password;
delete attrs.patronus_access_token;
if (!options || !options.context || (!options.context.user && !options.context.internal)) {
delete attrs.email;

View File

@ -1,22 +1,23 @@
// # API routes
var express = require('express'),
api = require('../api'),
auth = require('../auth'),
apiRoutes;
apiRoutes = function apiRoutes(middleware) {
var router = express.Router(),
// Authentication for public endpoints
authenticatePublic = [
middleware.api.authenticateClient,
middleware.api.authenticateUser,
middleware.api.requiresAuthorizedUserPublicAPI,
auth.authenticate.authenticateClient,
auth.authenticate.authenticateUser,
auth.authorize.requiresAuthorizedUserPublicAPI,
middleware.api.cors
],
// Require user for private endpoints
authenticatePrivate = [
middleware.api.authenticateClient,
middleware.api.authenticateUser,
middleware.api.requiresAuthorizedUser,
auth.authenticate.authenticateClient,
auth.authenticate.authenticateUser,
auth.authorize.requiresAuthorizedUser,
middleware.api.cors
];
@ -48,7 +49,10 @@ apiRoutes = function apiRoutes(middleware) {
router.del('/posts/:id', authenticatePrivate, api.http(api.posts.destroy));
// ## Schedules
router.put('/schedules/posts/:id', [middleware.api.authenticateClient, middleware.api.authenticateUser], api.http(api.schedules.publishPost));
router.put('/schedules/posts/:id', [
auth.authenticate.authenticateClient,
auth.authenticate.authenticateUser
], api.http(api.schedules.publishPost));
// ## Settings
router.get('/settings', authenticatePrivate, api.http(api.settings.browse));
@ -57,13 +61,15 @@ apiRoutes = function apiRoutes(middleware) {
// ## Users
router.get('/users', authenticatePublic, api.http(api.users.browse));
router.get('/users/:id', authenticatePublic, api.http(api.users.read));
router.get('/users/slug/:slug', authenticatePublic, api.http(api.users.read));
router.get('/users/email/:email', authenticatePublic, api.http(api.users.read));
router.put('/users/password', authenticatePrivate, api.http(api.users.changePassword));
router.put('/users/owner', authenticatePrivate, api.http(api.users.transferOwnership));
router.put('/users/:id', authenticatePrivate, api.http(api.users.edit));
router.post('/users', authenticatePrivate, api.http(api.users.add));
router.del('/users/:id', authenticatePrivate, api.http(api.users.destroy));
// ## Tags
@ -151,9 +157,16 @@ apiRoutes = function apiRoutes(middleware) {
router.get('/authentication/setup', api.http(api.authentication.isSetup));
router.post('/authentication/token',
middleware.spamPrevention.signin,
middleware.api.authenticateClient,
middleware.oauth.generateAccessToken
auth.authenticate.authenticateClient,
auth.oauth.generateAccessToken
);
router.post('/authentication/ghost', [
auth.authenticate.authenticateClient,
auth.authenticate.authenticateGhostUser,
api.http(api.authentication.createTokens)
]);
router.post('/authentication/revoke', authenticatePrivate, api.http(api.authentication.revoke));
// ## Uploads

View File

@ -394,6 +394,7 @@ describe('Authentication API', function () {
.then(function (response) {
should.exist(response);
response.invitation[0].valid.should.be.true();
response.invitation[0].invitedBy.should.eql('Joe Bloggs');
});
});

View File

@ -1,9 +1,11 @@
var should = require('should'),
sinon = require('sinon'),
Promise = require('bluebird'),
_ = require('lodash'),
authStrategies = require('../../../server/middleware/auth-strategies'),
authStrategies = require('../../../server/auth/auth-strategies'),
Models = require('../../../server/models'),
errors = require('../../../server/errors'),
globalUtils = require('../../../server/utils'),
sandbox = sinon.sandbox.create(),
@ -50,7 +52,9 @@ describe('Auth Strategies', function () {
clientStub = sandbox.stub(Models.Client, 'findOne');
clientStub.returns(new Promise.resolve());
clientStub.withArgs({slug: fakeClient.slug}).returns(new Promise.resolve({
toJSON: function () { return fakeClient; }
toJSON: function () {
return fakeClient;
}
}));
});
@ -116,16 +120,22 @@ describe('Auth Strategies', function () {
tokenStub = sandbox.stub(Models.Accesstoken, 'findOne');
tokenStub.returns(new Promise.resolve());
tokenStub.withArgs({token: fakeValidToken.token}).returns(new Promise.resolve({
toJSON: function () { return fakeValidToken; }
toJSON: function () {
return fakeValidToken;
}
}));
tokenStub.withArgs({token: fakeInvalidToken.token}).returns(new Promise.resolve({
toJSON: function () { return fakeInvalidToken; }
toJSON: function () {
return fakeInvalidToken;
}
}));
userStub = sandbox.stub(Models.User, 'findOne');
userStub.returns(new Promise.resolve());
userStub.withArgs({id: 3}).returns(new Promise.resolve({
toJSON: function () { return {id: 3}; }
toJSON: function () {
return {id: 3};
}
}));
});
@ -189,4 +199,141 @@ describe('Auth Strategies', function () {
}).catch(done);
});
});
describe('Ghost Strategy', function () {
var userByEmailStub, inviteStub, userAddStub, userEditStub, userFindOneStub;
beforeEach(function () {
userByEmailStub = sandbox.stub(Models.User, 'getByEmail');
userFindOneStub = sandbox.stub(Models.User, 'findOne');
userAddStub = sandbox.stub(Models.User, 'add');
userEditStub = sandbox.stub(Models.User, 'edit');
inviteStub = sandbox.stub(Models.Invite, 'findOne');
});
it('with invite, but with wrong invite token', function (done) {
var patronusAccessToken = '12345',
req = {body: {inviteToken: 'wrong'}},
profile = {email_address: 'kate@ghost.org'};
userByEmailStub.returns(Promise.resolve(null));
inviteStub.returns(Promise.reject(new errors.NotFoundError()));
authStrategies.ghostStrategy(req, patronusAccessToken, null, profile, function (err) {
should.exist(err);
(err instanceof errors.NotFoundError).should.eql(true);
userByEmailStub.calledOnce.should.be.true();
inviteStub.calledOnce.should.be.true();
done();
});
});
it('with correct invite token, but expired', function (done) {
var patronusAccessToken = '12345',
req = {body: {inviteToken: 'token'}},
profile = {email_address: 'kate@ghost.org'};
userByEmailStub.returns(Promise.resolve(null));
inviteStub.returns(Promise.resolve(Models.Invite.forge({
id: 1,
token: 'token',
expires: Date.now() - 1000
})));
authStrategies.ghostStrategy(req, patronusAccessToken, null, profile, function (err) {
should.exist(err);
(err instanceof errors.NotFoundError).should.eql(true);
userByEmailStub.calledOnce.should.be.true();
inviteStub.calledOnce.should.be.true();
done();
});
});
it('with correct invite token', function (done) {
var patronusAccessToken = '12345',
req = {body: {inviteToken: 'token'}},
invitedProfile = {email_address: 'kate@ghost.org'},
invitedUser = {id: 2},
inviteModel = Models.Invite.forge({
id: 1,
token: 'token',
expires: Date.now() + 1000
});
userByEmailStub.returns(Promise.resolve(null));
userAddStub.returns(Promise.resolve(invitedUser));
userEditStub.returns(Promise.resolve(invitedUser));
inviteStub.returns(Promise.resolve(inviteModel));
sandbox.stub(inviteModel, 'destroy').returns(Promise.resolve());
authStrategies.ghostStrategy(req, patronusAccessToken, null, invitedProfile, function (err, user, profile) {
should.not.exist(err);
should.exist(user);
should.exist(profile);
user.should.eql(invitedUser);
profile.should.eql(invitedProfile);
userByEmailStub.calledOnce.should.be.true();
inviteStub.calledOnce.should.be.true();
done();
});
});
it('setup', function (done) {
var patronusAccessToken = '12345',
req = {body: {}},
ownerProfile = {email_address: 'kate@ghost.org'},
owner = {id: 2};
userByEmailStub.returns(Promise.resolve(null));
userFindOneStub.returns(Promise.resolve(_.merge({}, {status: 'inactive'}, owner)));
userEditStub.withArgs({status: 'active', email: 'kate@ghost.org'}, {
context: {internal: true},
id: owner.id
}).returns(Promise.resolve(owner));
userEditStub.withArgs({patronus_access_token: patronusAccessToken}, {
context: {internal: true},
id: owner.id
}).returns(Promise.resolve(owner));
authStrategies.ghostStrategy(req, patronusAccessToken, null, ownerProfile, function (err, user, profile) {
should.not.exist(err);
userByEmailStub.calledOnce.should.be.true();
inviteStub.calledOnce.should.be.false();
should.exist(user);
should.exist(profile);
user.should.eql(owner);
profile.should.eql(ownerProfile);
done();
});
});
it('auth', function (done) {
var patronusAccessToken = '12345',
req = {body: {}},
ownerProfile = {email_address: 'kate@ghost.org'},
owner = {id: 2};
userByEmailStub.returns(Promise.resolve(owner));
userEditStub.withArgs({patronus_access_token: patronusAccessToken}, {
context: {internal: true},
id: owner.id
}).returns(Promise.resolve(owner));
authStrategies.ghostStrategy(req, patronusAccessToken, null, ownerProfile, function (err, user, profile) {
should.not.exist(err);
userByEmailStub.calledOnce.should.be.true();
userEditStub.calledOnce.should.be.true();
inviteStub.calledOnce.should.be.false();
should.exist(user);
should.exist(profile);
user.should.eql(owner);
profile.should.eql(ownerProfile);
done();
});
});
});
});

View File

@ -3,7 +3,7 @@ var sinon = require('sinon'),
passport = require('passport'),
rewire = require('rewire'),
errors = require('../../../server/errors'),
auth = rewire('../../../server/middleware/auth'),
auth = rewire('../../../server/auth'),
BearerStrategy = require('passport-http-bearer').Strategy,
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
user = {id: 1},
@ -100,7 +100,7 @@ describe('Auth', function () {
it('should require authorized user (user exists)', function (done) {
req.user = {id: 1};
auth.requiresAuthorizedUser(req, res, next);
auth.authorize.requiresAuthorizedUser(req, res, next);
next.called.should.be.true();
next.calledWith().should.be.true();
done();
@ -119,7 +119,7 @@ describe('Auth', function () {
};
});
auth.requiresAuthorizedUser(req, res, next);
auth.authorize.requiresAuthorizedUser(req, res, next);
next.called.should.be.false();
done();
});
@ -130,7 +130,7 @@ describe('Auth', function () {
req.headers.authorization = 'Bearer ' + token;
registerSuccessfulBearerStrategy();
auth.authenticateUser(req, res, next);
auth.authenticate.authenticateUser(req, res, next);
next.called.should.be.true();
next.calledWith(null, user, info).should.be.true();
@ -142,7 +142,7 @@ describe('Auth', function () {
req.client = {id: 1};
res.status = {};
auth.authenticateUser(req, res, next);
auth.authenticate.authenticateUser(req, res, next);
next.called.should.be.true();
next.calledWith().should.be.true();
@ -164,7 +164,7 @@ describe('Auth', function () {
});
registerUnsuccessfulBearerStrategy();
auth.authenticateUser(req, res, next);
auth.authenticate.authenticateUser(req, res, next);
next.called.should.be.false();
done();
@ -184,7 +184,7 @@ describe('Auth', function () {
});
registerUnsuccessfulBearerStrategy();
auth.authenticateUser(req, res, next);
auth.authenticate.authenticateUser(req, res, next);
next.called.should.be.false();
done();
@ -206,7 +206,7 @@ describe('Auth', function () {
});
registerUnsuccessfulBearerStrategy();
auth.authenticateUser(req, res, next);
auth.authenticate.authenticateUser(req, res, next);
next.called.should.be.false();
done();
@ -217,7 +217,7 @@ describe('Auth', function () {
req.headers.authorization = 'Bearer ' + token;
registerFaultyBearerStrategy();
auth.authenticateUser(req, res, next);
auth.authenticate.authenticateUser(req, res, next);
next.called.should.be.true();
next.calledWith('error').should.be.true();
@ -230,7 +230,7 @@ describe('Auth', function () {
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.true();
next.calledWith().should.be.true();
done();
@ -251,7 +251,7 @@ describe('Auth', function () {
};
});
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.false();
done();
});
@ -269,7 +269,7 @@ describe('Auth', function () {
};
});
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.false();
done();
});
@ -288,7 +288,7 @@ describe('Auth', function () {
};
});
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.false();
done();
});
@ -307,7 +307,7 @@ describe('Auth', function () {
};
});
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.false();
done();
});
@ -327,7 +327,7 @@ describe('Auth', function () {
});
registerUnsuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.false();
errorStub.calledTwice.should.be.true();
errorStub.getCall(0).args[1].should.eql('Client credentials were not provided');
@ -351,7 +351,7 @@ describe('Auth', function () {
});
registerUnsuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.false();
errorStub.calledTwice.should.be.true();
errorStub.getCall(0).args[1].should.eql('Client credentials were not valid');
@ -366,7 +366,7 @@ describe('Auth', function () {
req.headers = {};
registerSuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.true();
next.calledWith(null, client).should.be.true();
@ -381,7 +381,7 @@ describe('Auth', function () {
req.headers = {};
registerSuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.true();
next.calledWith(null, client).should.be.true();
@ -396,7 +396,7 @@ describe('Auth', function () {
req.headers = {};
registerSuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.true();
next.calledWith(null, client).should.be.true();
@ -410,7 +410,7 @@ describe('Auth', function () {
res.status = {};
registerFaultyClientPasswordStrategy();
auth.authenticateClient(req, res, next);
auth.authenticate.authenticateClient(req, res, next);
next.called.should.be.true();
next.calledWith('error').should.be.true();

View File

@ -1,8 +1,7 @@
var sinon = require('sinon'),
should = require('should'),
Promise = require('bluebird'),
oAuth = require('../../../server/middleware/oauth'),
oAuth = require('../../../server/auth/oauth'),
Models = require('../../../server/models');
describe('OAuth', function () {

View File

@ -69,4 +69,40 @@ describe('redirectToSetup', function () {
redirectToSetup(req, res, next);
});
it('should not redirect successful oauth authorization requests', function (done) {
sandbox.stub(api.authentication, 'isSetup', function () {
return Promise.resolve({setup: [{status: false}]});
});
res = {redirect: sinon.spy()};
req.path = '/';
req.query = {code: 'authCode'};
next = sinon.spy(function () {
next.called.should.be.true();
res.redirect.called.should.be.false();
done();
});
redirectToSetup(req, res, next);
});
it('should not redirect failed oauth authorization requests', function (done) {
sandbox.stub(api.authentication, 'isSetup', function () {
return Promise.resolve({setup: [{status: false}]});
});
res = {redirect: sinon.spy()};
req.path = '/';
req.query = {error: 'access_denied', state: 'randomstring'};
next = sinon.spy(function () {
next.called.should.be.true();
res.redirect.called.should.be.false();
done();
});
redirectToSetup(req, res, next);
});
});

View File

@ -22,7 +22,7 @@ var _ = require('lodash'),
// Post API swaps author_id to author, and always returns a computed 'url' property
post: _(schema.posts).keys().without('author_id').concat('author', 'url').value(),
// User API always removes the password field
user: _(schema.users).keys().without('password').value(),
user: _(schema.users).keys().without('password').without('patronus_access_token').value(),
// Tag API swaps parent_id to parent
tag: _(schema.tags).keys().without('parent_id').concat('parent').value(),
setting: _.keys(schema.settings),

View File

@ -66,6 +66,7 @@
"passport": "0.3.2",
"passport-http-bearer": "1.0.1",
"passport-oauth2-client-password": "0.1.2",
"passport-ghost": "1.0.0",
"path-match": "1.2.4",
"rss": "1.2.1",
"sanitize-html": "1.13.0",