2
1
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2023-12-13 21:00:40 +01:00

Public API

refs #4180
closes #4181
- added client and user authentication
- added authenticatePublic/authenticatePrivate as workaround for
missing permissions
- added domain validation
- added CORS header for valid clients
- merged authenticate.js and client-auth.js into auth.js
- removed middleware/api-error-handlers.js
- removed authentication middleware
- added and updated tests
This commit is contained in:
Sebastian Gierlinger 2015-10-22 15:28:47 +02:00
parent d666fba855
commit f48dfb09cf
22 changed files with 910 additions and 293 deletions

View file

@ -212,7 +212,7 @@ users = {
});
});
}).catch(function handleError(error) {
return errors.handleAPIError(error, 'You do not have permission to edit this user');
return errors.formatAndRejectAPIError(error, 'You do not have permission to edit this user');
});
}
@ -280,7 +280,7 @@ users = {
return options;
}).catch(function handleError(error) {
return errors.handleAPIError(error, 'You do not have permission to add this user');
return errors.formatAndRejectAPIError(error, 'You do not have permission to add this user');
});
}
@ -375,7 +375,7 @@ users = {
options.status = 'all';
return options;
}).catch(function handleError(error) {
return errors.handleAPIError(error, 'You do not have permission to destroy this user.');
return errors.formatAndRejectAPIError(error, 'You do not have permission to destroy this user.');
});
}
@ -407,7 +407,7 @@ users = {
return Promise.reject(new errors.InternalServerError(error));
});
}, function (error) {
return errors.handleAPIError(error);
return errors.formatAndRejectAPIError(error);
});
}
@ -442,7 +442,7 @@ users = {
return canThis(options.context).edit.user(options.data.password[0].user_id).then(function permissionGranted() {
return options;
}).catch(function (error) {
return errors.handleAPIError(error, 'You do not have permission to change the password for this user');
return errors.formatAndRejectAPIError(error, 'You do not have permission to change the password for this user');
});
}
@ -494,7 +494,7 @@ users = {
}).then(function () {
return options;
}).catch(function (error) {
return errors.handleAPIError(error);
return errors.formatAndRejectAPIError(error);
});
}
@ -520,7 +520,7 @@ users = {
return pipeline(tasks, object, options).then(function formatResult(result) {
return Promise.resolve({users: result});
}).catch(function (error) {
return errors.handleAPIError(error);
return errors.formatAndRejectAPIError(error);
});
}
};

View file

@ -182,7 +182,7 @@ utils = {
return permsPromise.then(function permissionGranted() {
return options;
}).catch(function handleError(error) {
return errors.handleAPIError(error);
return errors.formatAndRejectAPIError(error);
});
};
},
@ -213,7 +213,7 @@ utils = {
// forward error to next catch()
return Promise.reject(error);
}).catch(function handleError(error) {
return errors.handleAPIError(error);
return errors.formatAndRejectAPIError(error);
});
};
},

View file

@ -207,7 +207,7 @@ errors = {
return {errors: errors, statusCode: statusCode};
},
handleAPIError: function (error, permsMessage) {
formatAndRejectAPIError: function (error, permsMessage) {
if (!error) {
return this.rejectError(
new this.NoPermissionError(permsMessage || 'You do not have permission to perform this action')
@ -234,6 +234,14 @@ errors = {
return this.rejectError(new this.InternalServerError(error));
},
handleAPIError: function errorHandler(err, req, res, next) {
/*jshint unused:false */
var httpErrors = this.formatHttpErrors(err);
this.logError(err);
// Send a properly formatted HTTP response containing the errors
res.status(httpErrors.statusCode).json({errors: httpErrors.errors});
},
renderErrorPage: function (code, err, req, res, next) {
/*jshint unused:false*/
var self = this,
@ -374,6 +382,7 @@ _.each([
'logErrorAndExit',
'logErrorWithRedirect',
'handleAPIError',
'formatAndRejectAPIError',
'formatHttpErrors',
'renderErrorPage',
'error404',

View file

@ -1,9 +0,0 @@
var errors = require('../errors');
module.exports.errorHandler = function errorHandler(err, req, res, next) {
/*jshint unused:false */
var httpErrors = errors.formatHttpErrors(err);
errors.logError(err);
// Send a properly formatted HTTP response containing the errors
res.status(httpErrors.statusCode).json({errors: httpErrors.errors});
};

View file

@ -13,10 +13,10 @@ strategies = {
* 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})
return models.Client.findOne({slug: clientId}, {withRelated: ['trustedDomains']})
.then(function then(model) {
if (model) {
var client = model.toJSON();
var client = model.toJSON({include: ['trustedDomains']});
if (client.secret === clientSecret) {
return done(null, client);
}

View file

@ -0,0 +1,137 @@
var _ = require('lodash'),
passport = require('passport'),
url = require('url'),
errors = require('../errors'),
config = require('../config'),
oauthServer,
auth;
function cacheOauthServer(server) {
oauthServer = server;
}
function isBearerAutorizationHeader(req) {
var parts,
scheme,
credentials;
if (req.headers && req.headers.authorization) {
parts = req.headers.authorization.split(' ');
} else {
return false;
}
if (parts.length === 2) {
scheme = parts[0];
credentials = parts[1];
if (/^Bearer$/i.test(scheme)) {
return true;
}
}
return false;
}
function isValidOrigin(origin, client) {
if (origin && client && client.type === 'ua' && (
_.some(client.trustedDomains, {trusted_domain: origin})
|| origin === url.parse(config.url).hostname
|| origin === url.parse(config.urlSSL ? config.urlSSL : '').hostname
)) {
return true;
} else {
return false;
}
}
auth = {
// ### Authenticate Client Middleware
authenticateClient: function authenticateClient(req, res, next) {
// skip client authentication if bearer token is present
if (isBearerAutorizationHeader(req)) {
return next();
}
if (req.query && req.query.client_id) {
req.body.client_id = req.query.client_id;
}
if (req.query && req.query.client_secret) {
req.body.client_secret = req.query.client_secret;
}
if (!req.body.client_id || !req.body.client_secret) {
return errors.handleAPIError(new errors.UnauthorizedError('Access denied.'), req, res, next);
}
return passport.authenticate(['oauth2-client-password'], {session: false, failWithError: false},
function authenticate(err, client) {
var origin = null;
if (err) {
return next(err); // will generate a 500 error
}
if (req.headers && req.headers.origin) {
origin = url.parse(req.headers.origin).hostname;
}
if (!origin && client && client.type === 'ua') {
res.header('Access-Control-Allow-Origin', config.url);
req.client = client;
return next(null, client);
}
if (isValidOrigin(origin, client)) {
res.header('Access-Control-Allow-Origin', req.headers.origin);
req.client = client;
return next(null, client);
} else {
return errors.handleAPIError(new errors.UnauthorizedError('Access denied.'), req, res, next);
}
}
)(req, res, next);
},
// ### Authenticate User Middleware
authenticateUser: function authenticateUser(req, res, next) {
return passport.authenticate('bearer', {session: false, failWithError: false},
function authenticate(err, user, info) {
if (err) {
return next(err); // will generate a 500 error
}
if (user) {
req.authInfo = info;
req.user = user;
return next(null, user, info);
} else if (isBearerAutorizationHeader(req)) {
return errors.handleAPIError(new errors.UnauthorizedError('Access denied.'), req, res, next);
} else if (req.client) {
return next();
}
return errors.handleAPIError(new errors.UnauthorizedError('Access denied.'), req, res, next);
}
)(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) {
return next();
} else {
return errors.handleAPIError(new errors.NoPermissionError('Please Sign In'), req, res, next);
}
},
// ### 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 = auth;
module.exports.cacheOauthServer = cacheOauthServer;

View file

@ -1,48 +0,0 @@
var passport = require('passport'),
apiErrorHandlers = require('./api-error-handlers');
// ### Authenticate Middleware
// authentication has to be done for /ghost/* routes with
// exceptions for signin, signout, signup, forgotten, reset only
// api and frontend use different authentication mechanisms atm
function authenticate(req, res, next) {
var path,
subPath;
// SubPath is the url path starting after any default subdirectories
// it is stripped of anything after the two levels `/ghost/.*?/` as the reset link has an argument
path = req.path;
/*jslint regexp:true, unparam:true*/
subPath = path.replace(/^(\/.*?\/.*?\/)(.*)?/, function replace(match, a) {
return a;
});
if (subPath.indexOf('/ghost/api/') === 0
&& (path.indexOf('/ghost/api/v0.1/authentication/') !== 0
|| (path.indexOf('/ghost/api/v0.1/authentication/setup/') === 0 && req.method === 'PUT'))) {
return passport.authenticate('bearer', {session: false, failWithError: true},
function authenticate(err, user, info) {
if (err) {
return next(err); // will generate a 500 error
}
// Generate a JSON response reflecting authentication status
if (!user) {
var error = {
code: 401,
errorType: 'NoPermissionError',
message: 'Please Sign In'
};
return apiErrorHandlers.errorHandler(error, req, res, next);
}
// TODO: figure out, why user & authInfo is lost
req.authInfo = info;
req.user = user;
return next(null, user, info);
}
)(req, res, next);
}
next();
}
module.exports = authenticate;

View file

@ -1,25 +0,0 @@
var passport = require('passport'),
oauthServer,
clientAuth;
function cacheOauthServer(server) {
oauthServer = server;
}
clientAuth = {
// ### Authenticate Client Middleware
// authenticate client that is asking for an access token
authenticateClient: function authenticateClient(req, res, next) {
return passport.authenticate(['oauth2-client-password'], {session: false})(req, res, next);
},
// ### 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 = clientAuth;
module.exports.cacheOauthServer = cacheOauthServer;

View file

@ -12,11 +12,9 @@ var bodyParser = require('body-parser'),
utils = require('../utils'),
sitemapHandler = require('../data/xml/sitemap/handler'),
apiErrorHandlers = require('./api-error-handlers'),
authenticate = require('./authenticate'),
authStrategies = require('./auth-strategies'),
busboy = require('./ghost-busboy'),
clientAuth = require('./client-auth'),
auth = require('./auth'),
cacheControl = require('./cache-control'),
checkSSL = require('./check-ssl'),
decideIsAdmin = require('./decide-is-admin'),
@ -41,10 +39,12 @@ middleware = {
spamPrevention: spamPrevention,
privateBlogging: privateBlogging,
api: {
cacheOauthServer: clientAuth.cacheOauthServer,
authenticateClient: clientAuth.authenticateClient,
generateAccessToken: clientAuth.generateAccessToken,
errorHandler: apiErrorHandlers.errorHandler
cacheOauthServer: auth.cacheOauthServer,
authenticateClient: auth.authenticateClient,
authenticateUser: auth.authenticateUser,
requiresAuthorizedUser: auth.requiresAuthorizedUser,
generateAccessToken: auth.generateAccessToken,
errorHandler: errors.handleAPIError
}
};
@ -135,9 +135,6 @@ setupMiddleware = function setupMiddleware(blogApp, adminApp) {
// API shouldn't be cached
blogApp.use(routes.apiBaseUri, cacheControl('private'));
// enable authentication
blogApp.use(authenticate);
// local data
blogApp.use(themeHandler.ghostLocals);

View file

@ -18,7 +18,7 @@ oauth = {
// `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(function exchange(client, username, password, scope, done) {
oauthServer.exchange(oauth2orize.exchange.password({userProperty: 'client'}, function exchange(client, username, password, scope, done) {
// Validate the client
models.Client.forge({slug: client.slug})
.fetch()

View file

@ -8,6 +8,27 @@ Client = ghostBookshelf.Model.extend({
trustedDomains: function trustedDomains() {
return this.hasMany('ClientTrustedDomain', 'client_id');
}
}, {
/**
* Returns an array of keys permitted in a method's `options` hash, depending on the current method.
* @param {String} methodName The name of the method to check valid options for.
* @return {Array} Keys allowed in the `options` hash of the model's method.
*/
permittedOptions: function permittedOptions(methodName) {
var options = ghostBookshelf.Model.permittedOptions(),
// whitelists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
validOptions = {
findOne: ['withRelated']
};
if (validOptions[methodName]) {
options = options.concat(validOptions[methodName]);
}
return options;
}
});
Clients = ghostBookshelf.Collection.extend({

View file

@ -456,17 +456,17 @@ User = ghostBookshelf.Model.extend({
if (action === 'edit') {
// Owner can only be editted by owner
if (userModel.hasRole('Owner')) {
if (loadedPermissions.user && userModel.hasRole('Owner')) {
hasUserPermission = _.any(loadedPermissions.user.roles, {name: 'Owner'});
}
// Users with the role 'Editor' and 'Author' have complex permissions when the action === 'edit'
// We now have all the info we need to construct the permissions
if (_.any(loadedPermissions.user.roles, {name: 'Author'})) {
if (loadedPermissions.user && _.any(loadedPermissions.user.roles, {name: 'Author'})) {
// If this is the same user that requests the operation allow it.
hasUserPermission = hasUserPermission || context.user === userModel.get('id');
}
if (_.any(loadedPermissions.user.roles, {name: 'Editor'})) {
if (loadedPermissions.user && _.any(loadedPermissions.user.roles, {name: 'Editor'})) {
// If this is the same user that requests the operation allow it.
hasUserPermission = context.user === userModel.get('id');
@ -477,12 +477,12 @@ User = ghostBookshelf.Model.extend({
if (action === 'destroy') {
// Owner cannot be deleted EVER
if (userModel.hasRole('Owner')) {
if (loadedPermissions.user && userModel.hasRole('Owner')) {
return Promise.reject(new errors.NoPermissionError('You do not have permission to perform this action'));
}
// Users with the role 'Editor' have complex permissions when the action === 'destroy'
if (_.any(loadedPermissions.user.roles, {name: 'Editor'})) {
if (loadedPermissions.user && _.any(loadedPermissions.user.roles, {name: 'Editor'})) {
// If this is the same user that requests the operation allow it.
hasUserPermission = context.user === userModel.get('id');

View file

@ -169,7 +169,6 @@ CanThisResult.prototype.buildObjectTypeHandlers = function (objTypes, actType, c
// TODO: String vs Int comparison possibility here?
return modelId === permObjId;
};
// Check user permissions for matching action, object and id.
if (loadedPermissions.user && _.any(loadedPermissions.user.roles, {name: 'Owner'})) {
hasUserPermission = true;

View file

@ -4,72 +4,86 @@ var express = require('express'),
apiRoutes;
apiRoutes = function apiRoutes(middleware) {
var router = express.Router();
var router = express.Router(),
// Authentication for public endpoints
authenticatePublic = [
middleware.api.authenticateClient,
middleware.api.authenticateUser
],
// Require user for private endpoints
authenticatePrivate = [
middleware.api.authenticateClient,
middleware.api.authenticateUser,
middleware.api.requiresAuthorizedUser
];
// alias delete with del
router.del = router.delete;
// ## Configuration
router.get('/configuration', api.http(api.configuration.browse));
router.get('/configuration/:key', api.http(api.configuration.read));
router.get('/configuration', authenticatePrivate, api.http(api.configuration.browse));
router.get('/configuration/:key', authenticatePrivate, api.http(api.configuration.read));
// ## Posts
router.get('/posts', api.http(api.posts.browse));
router.post('/posts', api.http(api.posts.add));
router.get('/posts/:id', api.http(api.posts.read));
router.get('/posts/slug/:slug', api.http(api.posts.read));
router.put('/posts/:id', api.http(api.posts.edit));
router.del('/posts/:id', api.http(api.posts.destroy));
router.get('/posts', authenticatePublic, api.http(api.posts.browse));
router.post('/posts', authenticatePrivate, api.http(api.posts.add));
router.get('/posts/:id', authenticatePublic, api.http(api.posts.read));
router.get('/posts/slug/:slug', authenticatePublic, api.http(api.posts.read));
router.put('/posts/:id', authenticatePrivate, api.http(api.posts.edit));
router.del('/posts/:id', authenticatePrivate, api.http(api.posts.destroy));
// ## Settings
router.get('/settings', api.http(api.settings.browse));
router.get('/settings/:key', api.http(api.settings.read));
router.put('/settings', api.http(api.settings.edit));
router.get('/settings', authenticatePrivate, api.http(api.settings.browse));
router.get('/settings/:key', authenticatePrivate, api.http(api.settings.read));
router.put('/settings', authenticatePrivate, api.http(api.settings.edit));
// ## Users
router.get('/users', api.http(api.users.browse));
router.get('/users/:id', api.http(api.users.read));
router.get('/users/slug/:slug', api.http(api.users.read));
router.get('/users/email/:email', api.http(api.users.read));
router.put('/users/password', api.http(api.users.changePassword));
router.put('/users/owner', api.http(api.users.transferOwnership));
router.put('/users/:id', api.http(api.users.edit));
router.post('/users', api.http(api.users.add));
router.del('/users/:id', api.http(api.users.destroy));
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
router.get('/tags', api.http(api.tags.browse));
router.get('/tags/:id', api.http(api.tags.read));
router.get('/tags/slug/:slug', api.http(api.tags.read));
router.post('/tags', api.http(api.tags.add));
router.put('/tags/:id', api.http(api.tags.edit));
router.del('/tags/:id', api.http(api.tags.destroy));
router.get('/tags', authenticatePublic, api.http(api.tags.browse));
router.get('/tags/:id', authenticatePublic, api.http(api.tags.read));
router.get('/tags/slug/:slug', authenticatePublic, api.http(api.tags.read));
router.post('/tags', authenticatePrivate, api.http(api.tags.add));
router.put('/tags/:id', authenticatePrivate, api.http(api.tags.edit));
router.del('/tags/:id', authenticatePrivate, api.http(api.tags.destroy));
// ## Roles
router.get('/roles/', api.http(api.roles.browse));
router.get('/roles/', authenticatePrivate, api.http(api.roles.browse));
// ## Clients
router.get('/clients/slug/:slug', api.http(api.clients.read));
// ## Slugs
router.get('/slugs/:type/:name', api.http(api.slugs.generate));
router.get('/slugs/:type/:name', authenticatePrivate, api.http(api.slugs.generate));
// ## Themes
router.get('/themes', api.http(api.themes.browse));
router.put('/themes/:name', api.http(api.themes.edit));
router.get('/themes', authenticatePrivate, api.http(api.themes.browse));
router.put('/themes/:name', authenticatePrivate, api.http(api.themes.edit));
// ## Notifications
router.get('/notifications', api.http(api.notifications.browse));
router.post('/notifications', api.http(api.notifications.add));
router.del('/notifications/:id', api.http(api.notifications.destroy));
router.get('/notifications', authenticatePrivate, api.http(api.notifications.browse));
router.post('/notifications', authenticatePrivate, api.http(api.notifications.add));
router.del('/notifications/:id', authenticatePrivate, api.http(api.notifications.destroy));
// ## DB
router.get('/db', api.http(api.db.exportContent));
router.post('/db', middleware.busboy, api.http(api.db.importContent));
router.del('/db', api.http(api.db.deleteAllContent));
router.get('/db', authenticatePrivate, api.http(api.db.exportContent));
router.post('/db', authenticatePrivate, middleware.busboy, api.http(api.db.importContent));
router.del('/db', authenticatePrivate, api.http(api.db.deleteAllContent));
// ## Mail
router.post('/mail', api.http(api.mail.send));
router.post('/mail/test', api.http(api.mail.sendTest));
router.post('/mail', authenticatePrivate, api.http(api.mail.send));
router.post('/mail/test', authenticatePrivate, api.http(api.mail.sendTest));
// ## Authentication
router.post('/authentication/passwordreset',
@ -87,10 +101,10 @@ apiRoutes = function apiRoutes(middleware) {
middleware.api.authenticateClient,
middleware.api.generateAccessToken
);
router.post('/authentication/revoke', api.http(api.authentication.revoke));
router.post('/authentication/revoke', authenticatePrivate, api.http(api.authentication.revoke));
// ## Uploads
router.post('/uploads', middleware.busboy, api.http(api.uploads.add));
router.post('/uploads', authenticatePrivate, middleware.busboy, api.http(api.uploads.add));
// API Router middleware
router.use(middleware.api.errorHandler);

View file

@ -5,6 +5,7 @@ var supertest = require('supertest'),
testUtils = require('../../../utils'),
user = testUtils.DataGenerator.forModel.users[0],
ghost = require('../../../../../core'),
config = require('../../../../../core/server/config'),
request;
describe('Authentication API', function () {
@ -31,8 +32,14 @@ describe('Authentication API', function () {
it('can authenticate', function (done) {
request.post(testUtils.API.getApiQuery('authentication/token'))
.send({grant_type: 'password', username: user.email, password: user.password, client_id: 'ghost-admin', client_secret: 'not_available'})
.expect('Content-Type', /json/)
.set('Origin', config.url)
.send({
grant_type: 'password',
username: user.email,
password: user.password,
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
// TODO: make it possible to override oauth2orize's header so that this is consistent
.expect('Cache-Control', 'no-store')
.expect(200)
@ -52,8 +59,14 @@ describe('Authentication API', function () {
it('can\'t authenticate unknown user', function (done) {
request.post(testUtils.API.getApiQuery('authentication/token'))
.send({grant_type: 'password', username: 'invalid@email.com', password: user.password, client_id: 'ghost-admin', client_secret: 'not_available'})
.expect('Content-Type', /json/)
.set('Origin', config.url)
.send({
grant_type: 'password',
username: 'invalid@email.com',
password: user.password,
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(404)
.end(function (err, res) {
@ -69,8 +82,14 @@ describe('Authentication API', function () {
it('can\'t authenticate invalid password user', function (done) {
request.post(testUtils.API.getApiQuery('authentication/token'))
.send({grant_type: 'password', username: user.email, password: 'invalid', client_id: 'ghost-admin', client_secret: 'not_available'})
.expect('Content-Type', /json/)
.set('Origin', config.url)
.send({
grant_type: 'password',
username: user.email,
password: 'invalid',
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(401)
.end(function (err, res) {
@ -86,8 +105,14 @@ describe('Authentication API', function () {
it('can request new access token', function (done) {
request.post(testUtils.API.getApiQuery('authentication/token'))
.send({grant_type: 'password', username: user.email, password: user.password, client_id: 'ghost-admin', client_secret: 'not_available'})
.expect('Content-Type', /json/)
.set('Origin', config.url)
.send({
grant_type: 'password',
username: user.email,
password: user.password,
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
// TODO: make it possible to override oauth2orize's header so that this is consistent
.expect('Cache-Control', 'no-store')
.expect(200)
@ -97,10 +122,15 @@ describe('Authentication API', function () {
}
var refreshToken = res.body.refresh_token;
request.post(testUtils.API.getApiQuery('authentication/token'))
.send({grant_type: 'refresh_token', refresh_token: refreshToken, client_id: 'ghost-admin', client_secret: 'not_available'})
.expect('Content-Type', /json/)
.set('Origin', config.url)
.send({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
// TODO: make it possible to override oauth2orize's header so that this is consistent
.expect('Cache-Control', 'no-store')
.expect('Cache-Control', 'no-store')
.expect(200)
.end(function (err, res) {
if (err) {
@ -116,8 +146,13 @@ describe('Authentication API', function () {
it('can\'t request new access token with invalid refresh token', function (done) {
request.post(testUtils.API.getApiQuery('authentication/token'))
.send({grant_type: 'refresh_token', refresh_token: 'invalid', client_id: 'ghost-admin', client_secret: 'not_available'})
.expect('Content-Type', /json/)
.set('Origin', config.url)
.send({
grant_type: 'refresh_token',
refresh_token: 'invalid',
client_id: 'ghost-admin',
client_secret: 'not_available'
}).expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(403)
.end(function (err, res) {

View file

@ -0,0 +1,153 @@
/*global describe, it, before, after */
/*jshint expr:true*/
var testUtils = require('../../../utils'),
should = require('should'),
supertest = require('supertest'),
_ = require('lodash'),
ghost = require('../../../../../core'),
request;
describe('Public API', function () {
before(function (done) {
// starting ghost automatically populates the db
// TODO: prevent db init, and manage bringing up the DB with fixtures ourselves
ghost().then(function (ghostServer) {
request = supertest.agent(ghostServer.rootApp);
done();
}).catch(done);
});
after(function (done) {
testUtils.clearData().then(function () {
done();
}).catch(done);
});
it('browse posts', function (done) {
request.get(testUtils.API.getApiQuery('posts/?client_id=ghost-admin&client_secret=not_available'))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse, 'posts');
jsonResponse.posts.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
_.isBoolean(jsonResponse.posts[0].featured).should.eql(true);
_.isBoolean(jsonResponse.posts[0].page).should.eql(true);
done();
});
});
it('browse tags', function (done) {
request.get(testUtils.API.getApiQuery('tags/?client_id=ghost-admin&client_secret=not_available'))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.tags.should.exist;
testUtils.API.checkResponse(jsonResponse, 'tags');
jsonResponse.tags.should.have.length(1);
testUtils.API.checkResponse(jsonResponse.tags[0], 'tag');
testUtils.API.checkResponse(jsonResponse.meta.pagination, 'pagination');
done();
});
});
it('denies access with invalid client_secret', function (done) {
request.get(testUtils.API.getApiQuery('posts/?client_id=ghost-admin&client_secret=invalid_secret'))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(401)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.should.exist;
jsonResponse.errors.should.exist;
testUtils.API.checkResponseValue(jsonResponse.errors[0], ['message', 'errorType']);
done();
});
});
it('denies access with invalid client_id', function (done) {
request.get(testUtils.API.getApiQuery('posts/?client_id=invalid-id&client_secret=not_available'))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(401)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.should.exist;
jsonResponse.errors.should.exist;
testUtils.API.checkResponseValue(jsonResponse.errors[0], ['message', 'errorType']);
done();
});
});
it('denies access from invalid origin', function (done) {
request.get(testUtils.API.getApiQuery('posts/?client_id=ghost-admin&client_secret=not_available'))
.set('Origin', 'http://invalid-origin')
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(401)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.should.exist;
jsonResponse.errors.should.exist;
testUtils.API.checkResponseValue(jsonResponse.errors[0], ['message', 'errorType']);
done();
});
});
it('denies access to settings endpoint', function (done) {
request.get(testUtils.API.getApiQuery('settings/?client_id=ghost-admin&client_secret=not_available'))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(403)
.end(function (err, res) {
if (err) {
return done(err);
}
should.not.exist(res.headers['x-cache-invalidate']);
var jsonResponse = res.body;
jsonResponse.should.exist;
jsonResponse.errors.should.exist;
testUtils.API.checkResponseValue(jsonResponse.errors[0], ['message', 'errorType']);
done();
});
});
});

View file

@ -276,6 +276,42 @@ describe('Error handling', function () {
});
});
describe('API Error Handlers', function () {
var sandbox, req, res, next;
beforeEach(function () {
sandbox = sinon.sandbox.create();
req = {};
res = {};
res.json = sandbox.spy();
res.status = sandbox.stub().returns(res);
next = sandbox.spy();
});
afterEach(function () {
sandbox.restore();
});
it('handleAPIError: sends a JSON error response', function () {
errors.logError = sandbox.spy(errors, 'logError');
errors.formatHttpErrors = sandbox.spy(errors, 'formatHttpErrors');
var msg = 'Something got lost',
err = new errors.NotFoundError(msg);
errors.handleAPIError(err, req, res, next);
next.called.should.be.false;
errors.logError.calledOnce.should.be.true;
errors.formatHttpErrors.calledOnce.should.be.true;
res.status.calledWith(404).should.be.true;
res.json.calledOnce.should.be.true;
res.json.firstCall.args[0].errors[0].message.should.eql(msg);
res.json.firstCall.args[0].errors[0].errorType.should.eql('NotFoundError');
});
});
describe('Rendering', function () {
var sandbox,
originalConfig;

View file

@ -1,48 +0,0 @@
/*globals describe, beforeEach, afterEach, it*/
/*jshint expr:true*/
var should = require('should'),
sinon = require('sinon'),
middleware = require('../../../server/middleware').middleware,
errors = require('../../../server/errors');
// To stop jshint complaining
should.equal(true, true);
describe('Middleware: API Error Handlers', function () {
var sandbox, req, res, next;
beforeEach(function () {
sandbox = sinon.sandbox.create();
req = {};
res = {};
res.json = sandbox.spy();
res.status = sandbox.stub().returns(res);
next = sandbox.spy();
});
afterEach(function () {
sandbox.restore();
});
describe('errorHandler', function () {
it('sends a JSON error response', function () {
errors.logError = sandbox.spy(errors, 'logError');
errors.formatHttpErrors = sandbox.spy(errors, 'formatHttpErrors');
var msg = 'Something got lost',
err = new errors.NotFoundError(msg);
middleware.api.errorHandler(err, req, res, next);
next.called.should.be.false;
errors.logError.calledOnce.should.be.true;
errors.formatHttpErrors.calledOnce.should.be.true;
res.status.calledWith(404).should.be.true;
res.json.calledOnce.should.be.true;
res.json.firstCall.args[0].errors[0].message.should.eql(msg);
res.json.firstCall.args[0].errors[0].errorType.should.eql('NotFoundError');
});
});
});

View file

@ -1,13 +1,24 @@
/*globals describe, it, beforeEach, afterEach */
/*jshint expr:true*/
var sinon = require('sinon'),
should = require('should'),
passport = require('passport'),
authenticate = require('../../../server/middleware/authenticate'),
BearerStrategy = require('passport-http-bearer').Strategy,
user = {id: 1},
info = {scope: '*'},
token = 'test_token';
var _ = require('lodash'),
sinon = require('sinon'),
should = require('should'),
passport = require('passport'),
rewire = require('rewire'),
config = require('../../../server/config'),
defaultConfig = rewire('../../../../config.example')[process.env.NODE_ENV],
auth = rewire('../../../server/middleware/auth'),
BearerStrategy = require('passport-http-bearer').Strategy,
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
user = {id: 1},
info = {scope: '*'},
token = 'test_token',
testClient = 'test_client',
testSecret = 'not_available',
client = {
id: 2,
type: 'ua'
};
should.equal(true, true);
@ -31,7 +42,50 @@ function registerUnsuccessfulBearerStrategy() {
));
}
describe('authenticate', function () {
function registerFaultyBearerStrategy() {
// register fake BearerStrategy which always authenticates
passport.use(new BearerStrategy(
function strategy(accessToken, done) {
accessToken.should.eql(token);
return done('error');
}
));
}
function registerSuccessfulClientPasswordStrategy() {
// register fake BearerStrategy which always authenticates
passport.use(new ClientPasswordStrategy(
function strategy(clientId, clientSecret, done) {
clientId.should.eql(testClient);
clientSecret.should.eql('not_available');
return done(null, client);
}
));
}
function registerUnsuccessfulClientPasswordStrategy() {
// register fake BearerStrategy which always authenticates
passport.use(new ClientPasswordStrategy(
function strategy(clientId, clientSecret, done) {
clientId.should.eql(testClient);
clientSecret.should.eql('not_available');
return done(null, false);
}
));
}
function registerFaultyClientPasswordStrategy() {
// register fake BearerStrategy which always authenticates
passport.use(new ClientPasswordStrategy(
function strategy(clientId, clientSecret, done) {
clientId.should.eql(testClient);
clientSecret.should.eql('not_available');
return done('error');
}
));
}
describe('Auth', function () {
var res, req, next, sandbox;
beforeEach(function () {
@ -45,82 +99,21 @@ describe('authenticate', function () {
sandbox.restore();
});
it('should skip authentication if not hitting /ghost', function (done) {
req.path = '/tag/foo/';
req.method = 'GET';
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
it('should require authorized user (user exists)', function (done) {
req.user = {id: 1};
auth.requiresAuthorizedUser(req, res, next);
next.called.should.be.true;
next.calledWith().should.be.true;
done();
});
it('should skip authentication if hitting /ghost/api/v0.1/authenticaton/', function (done) {
req.path = '/ghost/api/v0.1/authentication/';
req.method = 'GET';
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
next.called.should.be.true;
next.calledWith().should.be.true;
done();
});
it('should skip authentication if hitting GET /ghost/api/v0.1/authenticaton/setup/', function (done) {
req.path = '/ghost/api/v0.1/authentication/setup/';
req.method = 'GET';
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
next.called.should.be.true;
next.calledWith().should.be.true;
done();
});
it('should authentication if hitting PUT /ghost/api/v0.1/authenticaton/setup/', function (done) {
req.path = '/ghost/api/v0.1/authentication/setup/';
req.method = 'PUT';
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
next.called.should.be.true;
next.calledWith(null, user, info).should.be.true;
done();
});
it('should authenticate if hitting /ghost/api/ endpoint', function (done) {
req.path = '/ghost/api/v0.1/test/';
req.method = 'PUT';
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
registerSuccessfulBearerStrategy();
authenticate(req, res, next);
next.called.should.be.true;
next.calledWith(null, user, info).should.be.true;
done();
});
it('shouldn\'t authenticate if hitting /ghost/ auth endpoint with invalid credentials', function (done) {
it('should require authorized user (user is missing)', function (done) {
req.user = false;
res.status = {};
req.path = '/ghost/api/v0.1/test/';
req.method = 'PUT';
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
registerUnsuccessfulBearerStrategy();
// stub res.status for error handling
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
statusCode.should.eql(403);
return {
json: function (err) {
err.errors[0].errorType.should.eql('NoPermissionError');
@ -128,8 +121,349 @@ describe('authenticate', function () {
};
});
authenticate(req, res, next);
auth.requiresAuthorizedUser(req, res, next);
next.called.should.be.false;
done();
});
describe('User Authentication', function () {
beforeEach(function () {
var newConfig = _.extend({}, config, defaultConfig);
auth.__get__('config', newConfig);
config.set(newConfig);
});
it('should authenticate user', function (done) {
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
registerSuccessfulBearerStrategy();
auth.authenticateUser(req, res, next);
next.called.should.be.true;
next.calledWith(null, user, info).should.be.true;
done();
});
it('shouldn\'t pass with client, no bearer token', function (done) {
req.headers = {};
req.client = {id: 1};
res.status = {};
auth.authenticateUser(req, res, next);
next.called.should.be.true;
next.calledWith().should.be.true;
done();
});
it('shouldn\'t authenticate user', function (done) {
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
res.status = {};
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('UnauthorizedError');
}
};
});
registerUnsuccessfulBearerStrategy();
auth.authenticateUser(req, res, next);
next.called.should.be.false;
done();
});
it('shouldn\'t authenticate without bearer token', function (done) {
req.headers = {};
res.status = {};
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('UnauthorizedError');
}
};
});
registerUnsuccessfulBearerStrategy();
auth.authenticateUser(req, res, next);
next.called.should.be.false;
done();
});
it('shouldn\'t authenticate with bearer token and client', function (done) {
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
req.client = {id: 1};
res.status = {};
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('UnauthorizedError');
}
};
});
registerUnsuccessfulBearerStrategy();
auth.authenticateUser(req, res, next);
next.called.should.be.false;
done();
});
it('shouldn\'t authenticate when error', function (done) {
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
registerFaultyBearerStrategy();
auth.authenticateUser(req, res, next);
next.called.should.be.true;
next.calledWith('error').should.be.true;
done();
});
});
describe('Client Authentication', function () {
it('shouldn\'t require authorized client with bearer token', function (done) {
req.headers = {};
req.headers.authorization = 'Bearer ' + token;
auth.authenticateClient(req, res, next);
next.called.should.be.true;
next.calledWith().should.be.true;
done();
});
it('shouldn\'t authenticate client with broken bearer token', function (done) {
req.body = {};
req.headers = {};
req.headers.authorization = 'Bearer';
res.status = {};
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('UnauthorizedError');
}
};
});
auth.authenticateClient(req, res, next);
next.called.should.be.false;
done();
});
it('shouldn\'t authenticate client without client_id/client_secret', function (done) {
req.body = {};
res.status = {};
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('UnauthorizedError');
}
};
});
auth.authenticateClient(req, res, next);
next.called.should.be.false;
done();
});
it('shouldn\'t authenticate client without client_id', function (done) {
req.body = {};
req.body.client_secret = testSecret;
res.status = {};
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('UnauthorizedError');
}
};
});
auth.authenticateClient(req, res, next);
next.called.should.be.false;
done();
});
it('shouldn\'t authenticate client without client_secret', function (done) {
req.body = {};
req.body.client_id = testClient;
res.status = {};
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('UnauthorizedError');
}
};
});
auth.authenticateClient(req, res, next);
next.called.should.be.false;
done();
});
it('shouldn\'t authenticate client', function (done) {
req.body = {};
req.body.client_id = testClient;
res.status = {};
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('UnauthorizedError');
}
};
});
registerUnsuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
next.called.should.be.false;
done();
});
it('shouldn\'t authenticate client with invalid origin', function (done) {
req.body = {};
req.body.client_id = testClient;
req.body.client_secret = testSecret;
req.headers = {};
req.headers.origin = 'http://invalid.origin.com';
res.status = {};
sandbox.stub(res, 'status', function (statusCode) {
statusCode.should.eql(401);
return {
json: function (err) {
err.errors[0].errorType.should.eql('UnauthorizedError');
}
};
});
registerSuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
next.called.should.be.false;
done();
});
it('should authenticate client', function (done) {
req.body = {};
req.body.client_id = testClient;
req.body.client_secret = testSecret;
req.headers = {};
req.headers.origin = config.url;
res.header = {};
sandbox.stub(res, 'header', function (key, value) {
key.should.equal('Access-Control-Allow-Origin');
value.should.equal(config.url);
});
registerSuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
next.called.should.be.true;
next.calledWith(null, client).should.be.true;
done();
});
it('should authenticate client without origin', function (done) {
req.body = {};
req.body.client_id = testClient;
req.body.client_secret = testSecret;
res.header = {};
sandbox.stub(res, 'header', function (key, value) {
key.should.equal('Access-Control-Allow-Origin');
value.should.equal(config.url);
});
registerSuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
next.called.should.be.true;
next.calledWith(null, client).should.be.true;
done();
});
it('should authenticate client with id in query', function (done) {
req.body = {};
req.query = {};
req.query.client_id = testClient;
req.query.client_secret = testSecret;
req.headers = {};
req.headers.origin = config.url;
res.header = {};
sandbox.stub(res, 'header', function (key, value) {
key.should.equal('Access-Control-Allow-Origin');
value.should.equal(config.url);
});
registerSuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
next.called.should.be.true;
next.calledWith(null, client).should.be.true;
done();
});
it('should authenticate client with id + secret in query', function (done) {
req.body = {};
req.query = {};
req.query.client_id = testClient;
req.query.client_secret = testSecret;
req.headers = {};
req.headers.origin = config.url;
res.header = {};
sandbox.stub(res, 'header', function (key, value) {
key.should.equal('Access-Control-Allow-Origin');
value.should.equal(config.url);
});
registerSuccessfulClientPasswordStrategy();
auth.authenticateClient(req, res, next);
next.called.should.be.true;
next.calledWith(null, client).should.be.true;
done();
});
it('shouldn\'t authenticate when error', function (done) {
req.body = {};
req.body.client_id = testClient;
req.body.client_secret = testSecret;
res.status = {};
registerFaultyClientPasswordStrategy();
auth.authenticateClient(req, res, next);
next.called.should.be.true;
next.calledWith('error').should.be.true;
done();
});
});
});

View file

@ -9,6 +9,7 @@ var _ = require('lodash'),
expectedProperties = {
configuration: ['key', 'value'],
posts: ['posts', 'meta'],
tags: ['tags', 'meta'],
users: ['users', 'meta'],
roles: ['roles'],
pagination: ['page', 'limit', 'pages', 'total', 'next', 'prev'],
@ -75,11 +76,16 @@ function isISO8601(date) {
return moment(date).parsingFlags().iso;
}
function getURL() {
return schema + host;
}
module.exports = {
getApiURL: getApiURL,
getApiQuery: getApiQuery,
getSigninURL: getSigninURL,
getAdminURL: getAdminURL,
getURL: getURL,
checkResponse: checkResponse,
checkResponseValue: checkResponseValue,
isISO8601: isISO8601

View file

@ -377,7 +377,7 @@ DataGenerator.forKnex = (function () {
];
clients = [
createBasic({name: 'Ghost Admin', slug: 'ghost-admin', secret: 'not_available'})
createBasic({name: 'Ghost Admin', slug: 'ghost-admin', secret: 'not_available', type: 'ua', status: 'enabled'})
];
roles_users = [

View file

@ -525,8 +525,14 @@ login = function login(request) {
return new Promise(function (resolve, reject) {
request.post('/ghost/api/v0.1/authentication/token/')
.send({grant_type: 'password', username: user.email, password: user.password, client_id: 'ghost-admin', client_secret: 'not_available'})
.end(function (err, res) {
.set('Origin', config.url)
.send({
grant_type: 'password',
username: user.email,
password: user.password,
client_id: 'ghost-admin',
client_secret: 'not_available'
}).end(function (err, res) {
if (err) {
return reject(err);
}