From 6473c9e858342dba214ac3483a5918afee7e486b Mon Sep 17 00:00:00 2001 From: Katharina Irrgang Date: Fri, 30 Sep 2016 13:45:59 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=20Ghost=20OAuth=20(#7451)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/server/api/authentication.js | 52 ++++-- core/server/api/index.js | 4 +- core/server/auth/auth-strategies.js | 149 +++++++++++++++ .../auth.js => auth/authenticate.js} | 55 +++--- core/server/auth/authorize.js | 31 ++++ core/server/auth/index.js | 17 ++ core/server/auth/oauth.js | 97 ++++++++++ core/server/auth/passport.js | 103 +++++++++++ core/server/config/defaults.json | 3 + .../server/config/env/config.development.json | 4 + core/server/controllers/admin.js | 12 +- core/server/data/schema/schema.js | 1 + core/server/index.js | 10 +- core/server/middleware/auth-strategies.js | 61 ------- core/server/middleware/index.js | 20 --- core/server/middleware/oauth.js | 103 ----------- core/server/middleware/redirect-to-setup.js | 5 +- core/server/models/user.js | 1 + core/server/routes/api.js | 39 ++-- .../api/api_authentication_spec.js | 1 + .../auth-strategies_spec.js | 169 ++++++++++++++++-- .../authenticate_spec.js} | 40 ++--- .../unit/{middleware => auth}/oauth_spec.js | 3 +- .../unit/middleware/redirect-to-setup_spec.js | 36 ++++ core/test/utils/api.js | 2 +- package.json | 1 + 26 files changed, 744 insertions(+), 275 deletions(-) create mode 100644 core/server/auth/auth-strategies.js rename core/server/{middleware/auth.js => auth/authenticate.js} (77%) create mode 100644 core/server/auth/authorize.js create mode 100644 core/server/auth/index.js create mode 100644 core/server/auth/oauth.js create mode 100644 core/server/auth/passport.js delete mode 100644 core/server/middleware/auth-strategies.js delete mode 100644 core/server/middleware/oauth.js rename core/test/unit/{middleware => auth}/auth-strategies_spec.js (51%) rename core/test/unit/{middleware/authentication_spec.js => auth/authenticate_spec.js} (90%) rename core/test/unit/{middleware => auth}/oauth_spec.js (99%) diff --git a/core/server/api/authentication.js b/core/server/api/authentication.js index 09f1e6a062..41a5e8b700 100644 --- a/core/server/api/authentication.js +++ b/core/server/api/authentication.js @@ -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); diff --git a/core/server/api/index.js b/core/server/api/index.js index 7e583b66ea..dc09d8d74d 100644 --- a/core/server/api/index.js +++ b/core/server/api/index.js @@ -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 } }); diff --git a/core/server/auth/auth-strategies.js b/core/server/auth/auth-strategies.js new file mode 100644 index 0000000000..f6ec2ab547 --- /dev/null +++ b/core/server/auth/auth-strategies.js @@ -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; diff --git a/core/server/middleware/auth.js b/core/server/auth/authenticate.js similarity index 77% rename from core/server/middleware/auth.js rename to core/server/auth/authenticate.js index 4e1a656eff..770565b79b 100644 --- a/core/server/middleware/auth.js +++ b/core/server/auth/authenticate.js @@ -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, @@ -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; diff --git a/core/server/auth/authorize.js b/core/server/auth/authorize.js new file mode 100644 index 0000000000..817ca3de38 --- /dev/null +++ b/core/server/auth/authorize.js @@ -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; diff --git a/core/server/auth/index.js b/core/server/auth/index.js new file mode 100644 index 0000000000..9795e00763 --- /dev/null +++ b/core/server/auth/index.js @@ -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; diff --git a/core/server/auth/oauth.js b/core/server/auth/oauth.js new file mode 100644 index 0000000000..3be66e5fab --- /dev/null +++ b/core/server/auth/oauth.js @@ -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; diff --git a/core/server/auth/passport.js b/core/server/auth/passport.js new file mode 100644 index 0000000000..eac7a41fcc --- /dev/null +++ b/core/server/auth/passport.js @@ -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); + }); +}; diff --git a/core/server/config/defaults.json b/core/server/config/defaults.json index 208657d5ae..5c4e28ed1a 100644 --- a/core/server/config/defaults.json +++ b/core/server/config/defaults.json @@ -24,5 +24,8 @@ }, "scheduling": { "active": "SchedulingDefault" + }, + "auth": { + "type": "password" } } diff --git a/core/server/config/env/config.development.json b/core/server/config/env/config.development.json index e1f37c33e9..cadc187bb6 100644 --- a/core/server/config/env/config.development.json +++ b/core/server/config/env/config.development.json @@ -8,5 +8,9 @@ }, "paths": { "contentPath": "content/" + }, + "auth": { + "type": "patronus", + "url": "http://devauth.ghost.org:8080" } } diff --git a/core/server/controllers/admin.js b/core/server/controllers/admin.js index 9efa5e5e2c..59688865ec 100644 --- a/core/server/controllers/admin.js +++ b/core/server/controllers/admin.js @@ -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 }); diff --git a/core/server/data/schema/schema.js b/core/server/data/schema/schema.js index ffff1da8e7..a7bac0e08b 100644 --- a/core/server/data/schema/schema.js +++ b/core/server/data/schema/schema.js @@ -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}, diff --git a/core/server/index.js b/core/server/index.js index aab3e9bbbe..6608c92bc6 100644 --- a/core/server/index.js +++ b/core/server/index.js @@ -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; diff --git a/core/server/middleware/auth-strategies.js b/core/server/middleware/auth-strategies.js deleted file mode 100644 index 5f221f15e7..0000000000 --- a/core/server/middleware/auth-strategies.js +++ /dev/null @@ -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; diff --git a/core/server/middleware/index.js b/core/server/middleware/index.js index 72d8546c89..0483493309 100644 --- a/core/server/middleware/index.js +++ b/core/server/middleware/index.js @@ -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')); diff --git a/core/server/middleware/oauth.js b/core/server/middleware/oauth.js deleted file mode 100644 index 662f73f816..0000000000 --- a/core/server/middleware/oauth.js +++ /dev/null @@ -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; diff --git a/core/server/middleware/redirect-to-setup.js b/core/server/middleware/redirect-to-setup.js index 7afd73868f..20b384b81d 100644 --- a/core/server/middleware/redirect-to-setup.js +++ b/core/server/middleware/redirect-to-setup.js @@ -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(); diff --git a/core/server/models/user.js b/core/server/models/user.js index eacfe6f4a9..2e84ac03fd 100644 --- a/core/server/models/user.js +++ b/core/server/models/user.js @@ -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; diff --git a/core/server/routes/api.js b/core/server/routes/api.js index e6420eb82e..e1da7ad3d1 100644 --- a/core/server/routes/api.js +++ b/core/server/routes/api.js @@ -1,22 +1,23 @@ // # API routes -var express = require('express'), - api = require('../api'), +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 @@ -87,7 +93,7 @@ apiRoutes = function apiRoutes(middleware) { router.get('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.read)); router.post('/subscribers', middleware.api.labs.subscribers, authenticatePublic, api.http(api.subscribers.add)); router.put('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.edit)); - router.del('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.destroy)); + router.del('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.destroy)); // ## Roles router.get('/roles/', authenticatePrivate, api.http(api.roles.browse)); @@ -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 diff --git a/core/test/integration/api/api_authentication_spec.js b/core/test/integration/api/api_authentication_spec.js index d1a4285d84..f8ee411056 100644 --- a/core/test/integration/api/api_authentication_spec.js +++ b/core/test/integration/api/api_authentication_spec.js @@ -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'); }); }); diff --git a/core/test/unit/middleware/auth-strategies_spec.js b/core/test/unit/auth/auth-strategies_spec.js similarity index 51% rename from core/test/unit/middleware/auth-strategies_spec.js rename to core/test/unit/auth/auth-strategies_spec.js index b338b2d2fb..325e80aa17 100644 --- a/core/test/unit/middleware/auth-strategies_spec.js +++ b/core/test/unit/auth/auth-strategies_spec.js @@ -1,14 +1,16 @@ -var should = require('should'), - sinon = require('sinon'), - Promise = require('bluebird'), +var should = require('should'), + sinon = require('sinon'), + Promise = require('bluebird'), + _ = require('lodash'), - authStrategies = require('../../../server/middleware/auth-strategies'), - Models = require('../../../server/models'), - globalUtils = require('../../../server/utils'), + authStrategies = require('../../../server/auth/auth-strategies'), + Models = require('../../../server/models'), + errors = require('../../../server/errors'), + globalUtils = require('../../../server/utils'), sandbox = sinon.sandbox.create(), - fakeClient = { + fakeClient = { slug: 'ghost-admin', secret: 'not_available', status: 'enabled' @@ -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(); + }); + }); + }); }); diff --git a/core/test/unit/middleware/authentication_spec.js b/core/test/unit/auth/authenticate_spec.js similarity index 90% rename from core/test/unit/middleware/authentication_spec.js rename to core/test/unit/auth/authenticate_spec.js index 6eadd23e7e..1a686b8550 100644 --- a/core/test/unit/middleware/authentication_spec.js +++ b/core/test/unit/auth/authenticate_spec.js @@ -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(); diff --git a/core/test/unit/middleware/oauth_spec.js b/core/test/unit/auth/oauth_spec.js similarity index 99% rename from core/test/unit/middleware/oauth_spec.js rename to core/test/unit/auth/oauth_spec.js index e460485e32..9cbb79f705 100644 --- a/core/test/unit/middleware/oauth_spec.js +++ b/core/test/unit/auth/oauth_spec.js @@ -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 () { diff --git a/core/test/unit/middleware/redirect-to-setup_spec.js b/core/test/unit/middleware/redirect-to-setup_spec.js index ecb199dc5c..5e5c82587d 100644 --- a/core/test/unit/middleware/redirect-to-setup_spec.js +++ b/core/test/unit/middleware/redirect-to-setup_spec.js @@ -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); + }); }); diff --git a/core/test/utils/api.js b/core/test/utils/api.js index d3f1ad103e..4083f1a897 100644 --- a/core/test/utils/api.js +++ b/core/test/utils/api.js @@ -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), diff --git a/package.json b/package.json index 545e35e7d5..3c074fa62d 100644 --- a/package.json +++ b/package.json @@ -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",