Session auth service (#9910)

refs #9865

* This service handles the session store and exporting middleware to be
used for creating and managing sessions

* Updates the auth service index.js file in line with how we do things elsewhere

* After wrapping the exports in a getter, the usage of rewire had broken
the authenticate tests, this commit _removes_ rewire from the tests, calls `init` on
the models before the tests (needed because rewire isn't there) and also
cleans up the use of var.
This commit is contained in:
Fabien O'Carroll 2018-10-02 15:35:23 +07:00 committed by GitHub
parent 6cd1dc8005
commit cb0c5dc582
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 707 additions and 30 deletions

View File

@ -1,13 +1,25 @@
var passport = require('./passport'),
authorize = require('./authorize'),
authenticate = require('./authenticate'),
oauth = require('./oauth');
module.exports = {
get authorize() {
return require('./authorize');
},
exports.init = function (options) {
oauth.init(options);
return passport.init(options);
get authenticate() {
return require('./authenticate');
},
get session() {
return require('./session');
},
/*
* TODO: Get rid of these when v0.1 is gone
*/
get init() {
return (options) => {
require('./oauth').init(options);
return require('./passport').init(options);
};
},
get oauth() {
return require('./oauth');
}
};
exports.oauth = oauth;
exports.authorize = authorize;
exports.authenticate = authenticate;

View File

@ -0,0 +1,23 @@
module.exports = {
get getSession() {
return require('./middleware').getSession;
},
get cookieCsrfProtection() {
return require('./middleware').cookieCsrfProtection;
},
get safeGetSession() {
return require('./middleware').safeGetSession;
},
get createSession() {
return require('./middleware').createSession;
},
get destroySession() {
return require('./middleware').destroySession;
},
get getUser() {
return require('./middleware').getUser;
},
get ensureUser() {
return require('./middleware').ensureUser;
}
};

View File

@ -0,0 +1,124 @@
const {URL} = require('url');
const common = require('../../../lib/common');
const constants = require('../../../lib/constants');
const config = require('../../../config');
const settingsCache = require('../../settings/cache');
const models = require('../../../models');
const session = require('express-session');
const SessionStore = require('./store');
const urlService = require('../../url');
const getOrigin = (req) => {
const origin = req.get('origin');
const referrer = req.get('referrer');
if (!origin && !referrer) {
return null;
}
if (origin) {
return origin;
}
try {
return new URL(req.get('referrer')).origin;
} catch (e) {
return null;
}
};
let UNO_SESSIONIONA;
const getSession = (req, res, next) => {
if (!UNO_SESSIONIONA) {
UNO_SESSIONIONA = session({
store: new SessionStore(models.Session),
secret: settingsCache.get('session_secret'),
resave: false,
saveUninitialized: false,
name: 'ghost-admin-api-session',
cookie: {
maxAge: constants.SIX_MONTH_MS,
httpOnly: true,
path: '/ghost',
sameSite: 'lax',
secure: urlService.utils.isSSL(config.get('url'))
}
});
}
return UNO_SESSIONIONA(req, res, next);
};
const createSession = (req, res, next) => {
getSession(req, res, function () {
const origin = getOrigin(req);
if (!origin) {
return next(new common.errors.BadRequestError({
message: common.i18n.t('errors.middleware.auth.unknownOrigin')
}));
}
req.session.user_id = req.user.id;
req.session.origin = origin;
req.session.user_agent = req.get('user-agent');
req.session.ip = req.ip;
res.sendStatus(201);
});
};
const destroySession = (req, res, next) => {
req.session.destroy((err) => {
if (err) {
return next(new common.errors.InternalServerError({err}));
}
return res.sendStatus(204);
});
};
const getUser = (req, res, next) => {
if (!req.session || !req.session.user_id) {
req.user = null;
return next();
}
models.User.findOne({id: req.session.user_id})
.then((user) => {
req.user = user;
next();
}).catch(() => {
req.user = null;
next();
});
};
const ensureUser = (req, res, next) => {
if (req.user && req.user.id) {
return next();
}
next(new common.errors.UnauthorizedError({
message: common.i18n.t('errors.middleware.auth.accessDenied')
}));
};
const cookieCsrfProtection = (req, res, next) => {
// If there is no origin on the session object it means this is a *new*
// session, that hasn't been initialised yet. So we don't need CSRF protection
if (!req.session.origin) {
return next();
}
if (req.session.origin !== getOrigin(req)) {
return next(new common.errors.BadRequestError({
message: common.i18n.t('errors.middleware.auth.mismatchedOrigin')
}));
}
return next();
};
module.exports = exports = {
getSession,
cookieCsrfProtection,
safeGetSession: [getSession, cookieCsrfProtection],
createSession,
destroySession,
getUser,
ensureUser
};

View File

@ -0,0 +1,44 @@
const {Store} = require('express-session');
const common = require('../../../lib/common');
module.exports = class SessionStore extends Store {
constructor(SessionModel) {
super();
this.SessionModel = SessionModel;
}
destroy(sid, callback) {
this.SessionModel
.destroy({session_id: sid})
.then(() => {
callback(null);
})
.catch(callback);
}
get(sid, callback) {
this.SessionModel
.findOne({session_id: sid})
.then((model) => {
if (!model) {
return callback(null, null);
}
callback(null, model.get('session_data'));
})
.catch(callback);
}
set(sid, sessionData, callback) {
if (!sessionData.user_id) {
return callback(new common.errors.InternalServerError({
message: common.i18n.t('errors.middleware.auth.missingUserID')
}));
}
this.SessionModel
.upsert({session_data: sessionData}, {session_id: sid})
.then(() => {
callback(null);
})
.catch(callback);
}
};

View File

@ -73,6 +73,9 @@
"clientCredentialsNotProvided": "Client credentials were not provided",
"clientCredentialsNotValid": "Client credentials were not valid",
"forInformationRead": "For information on how to fix this, please read {url}.",
"unknownOrigin": "Could not determine origin of request.",
"mismatchedOrigin": "Request made from incorrect origin.",
"missingUserIDForSession": "Cannot create session without user id.",
"accessDenied": "Access denied.",
"pleaseSignIn": "Please Sign In"
},

View File

@ -1,22 +1,21 @@
var should = require('should'),
sinon = require('sinon'),
passport = require('passport'),
rewire = require('rewire'),
BearerStrategy = require('passport-http-bearer').Strategy,
ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy,
auth = rewire('../../../../server/services/auth'),
common = require('../../../../server/lib/common'),
user = {id: 1},
info = {scope: '*'},
token = 'test_token',
testClient = 'test_client',
testSecret = 'not_available',
client = {
id: 2,
type: 'ua'
},
sandbox = sinon.sandbox.create();
const should = require('should');
const sinon = require('sinon');
const passport = require('passport');
const BearerStrategy = require('passport-http-bearer').Strategy;
const ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy;
const auth = require('../../../../server/services/auth');
const common = require('../../../../server/lib/common');
const models = require('../../../../server/models');
const user = {id: 1};
const info = {scope: '*'};
const token = 'test_token';
const testClient = 'test_client';
const testSecret = 'not_available';
const client = {
id: 2,
type: 'ua'
};
const sandbox = sinon.sandbox.create();
function registerSuccessfulBearerStrategy() {
// register fake BearerStrategy which always authenticates
@ -84,6 +83,10 @@ function registerFaultyClientPasswordStrategy() {
describe('Auth', function () {
var res, req, next, loggingStub;
before(function () {
models.init();
});
beforeEach(function () {
req = {};
res = {};

View File

@ -0,0 +1,254 @@
const sessionService = require('../../../../../server/services/auth/session');
const SessionStore = require('../../../../../server/services/auth/session/store');
const config = require('../../../../../server/config');
const models = require('../../../../../server/models');
const sinon = require('sinon');
const should = require('should');
const {
BadRequestError,
UnauthorizedError,
InternalServerError
} = require('../../../../../server/lib/common/errors');
describe('Session Service', function () {
let sandbox;
before(function () {
models.init();
sandbox = sinon.sandbox.create();
});
afterEach(function () {
sandbox.restore();
});
const fakeReq = function fakeReq() {
return {
session: {
destroy() {}
},
body: {},
get() {}
};
};
const fakeRes = function fakeRes() {
return {
sendStatus() {}
};
};
describe('createSession', function () {
it('calls next with a BadRequestError if there is no Origin or Refferer', function (done) {
const req = fakeReq();
sandbox.stub(req, 'get')
.withArgs('origin').returns('')
.withArgs('referrer').returns('');
sessionService.createSession(req, fakeRes(), function next(err) {
should.equal(err instanceof BadRequestError, true);
done();
});
});
it('sets req.session.user_id,origin,user_agent,ip and calls sendStatus with 201 if the check succeeds', function (done) {
const req = fakeReq();
const res = fakeRes();
sandbox.stub(req, 'get')
.withArgs('origin').returns('http://host.tld')
.withArgs('user-agent').returns('bububang');
req.ip = '127.0.0.1';
req.user = models.User.forge({id: 23});
sandbox.stub(res, 'sendStatus')
.callsFake(function (statusCode) {
should.equal(req.session.user_id, 23);
should.equal(req.session.origin, 'http://host.tld');
should.equal(req.session.user_agent, 'bububang');
should.equal(req.session.ip, '127.0.0.1');
should.equal(statusCode, 201);
done();
});
sessionService.createSession(req, res);
});
});
describe('destroySession', function () {
it('calls req.session.destroy', function () {
const req = fakeReq();
const res = fakeRes();
const destroyStub = sandbox.stub(req.session, 'destroy');
sessionService.destroySession(req, res);
should.equal(destroyStub.callCount, 1);
});
it('calls next with InternalServerError if destroy errors', function (done) {
const req = fakeReq();
const res = fakeRes();
sandbox.stub(req.session, 'destroy')
.callsFake(function (fn) {
fn(new Error('oops'));
});
sessionService.destroySession(req, res, function next(err) {
should.equal(err instanceof InternalServerError, true);
done();
});
});
it('calls sendStatus with 204 if destroy does not error', function (done) {
const req = fakeReq();
const res = fakeRes();
sandbox.stub(req.session, 'destroy')
.callsFake(function (fn) {
fn();
});
sandbox.stub(res, 'sendStatus')
.callsFake(function (status) {
should.equal(status, 204);
done();
});
sessionService.destroySession(req, res);
});
});
describe('getUser', function () {
it('sets req.user to null and calls next if there is no session', function (done) {
const req = fakeReq();
const res = fakeRes();
delete req.session;
sessionService.getUser(req, res, function next() {
should.equal(req.user, null);
done();
});
});
it('sets req.user to null and calls next if there is no session', function (done) {
const req = fakeReq();
const res = fakeRes();
sessionService.getUser(req, res, function next() {
should.equal(req.user, null);
done();
});
});
it('calls User.findOne with id set to req.session.user_id', function (done) {
const req = fakeReq();
const res = fakeRes();
sandbox.stub(models.User, 'findOne')
.callsFake(function (opts) {
should.equal(opts.id, 23);
done();
});
req.session.user_id = 23;
sessionService.getUser(req, res);
});
it('sets req.user to null and calls next if the user is not found', function (done) {
const req = fakeReq();
const res = fakeRes();
sandbox.stub(models.User, 'findOne')
.rejects();
req.session.user_id = 23;
sessionService.getUser(req, res, function next() {
should.equal(req.user, null);
done();
});
});
it('calls next after settign req.user to the found user', function (done) {
const req = fakeReq();
const res = fakeRes();
const user = models.User.forge({id: 23});
sandbox.stub(models.User, 'findOne')
.resolves(user);
req.session.user_id = 23;
sessionService.getUser(req, res, function next() {
should.equal(req.user, user);
done();
});
});
});
describe('ensureUser', function () {
it('calls next with no error if req.user.id exists', function (done) {
const req = fakeReq();
const res = fakeRes();
const user = models.User.forge({id: 23});
req.user = user;
sessionService.ensureUser(req, res, function next(err) {
should.equal(err, null);
done();
});
});
it('calls next with UnauthorizedError if req.user.id does not exist', function (done) {
const req = fakeReq();
const res = fakeRes();
sessionService.ensureUser(req, res, function next(err) {
should.equal(err instanceof UnauthorizedError, true);
done();
});
});
});
describe('CSRF protection', function () {
it('calls next if the session is uninitialized', function (done) {
const req = fakeReq();
const res = fakeRes();
sessionService.cookieCsrfProtection(req, res, function next(err) {
should.not.exist(err);
done();
});
});
it('calls next if req origin matches the session origin', function (done) {
const req = fakeReq();
const res = fakeRes();
sandbox.stub(req, 'get')
.withArgs('origin').returns('http://host.tld');
req.session.origin = 'http://host.tld';
sessionService.cookieCsrfProtection(req, res, function next(err) {
should.not.exist(err);
done();
});
});
it('calls next with BadRequestError if the origin of req does not match the session', function (done) {
const req = fakeReq();
const res = fakeRes();
sandbox.stub(req, 'get')
.withArgs('origin').returns('http://host.tld');
req.session.origin = 'http://different-host.tld';
sessionService.cookieCsrfProtection(req, res, function next(err) {
should.equal(err instanceof BadRequestError, true);
done();
});
});
});
describe('safeGetSession', function () {
it('is an array of getSession and cookieCsrfProtection', function () {
should.deepEqual(sessionService.safeGetSession, [
sessionService.getSession,
sessionService.cookieCsrfProtection
]);
});
});
});

View File

@ -0,0 +1,185 @@
const SessionStore = require('../../../../../server/services/auth/session/store');
const models = require('../../../../../server/models');
const EventEmitter = require('events');
const {Store} = require('express-session');
const sinon = require('sinon');
const should = require('should');
describe('Auth Service SessionStore', function () {
let sandbox;
before(function () {
models.init();
sandbox = sinon.sandbox.create();
});
afterEach(function () {
sandbox.restore();
});
describe('inheritance', function () {
it('Is an instance of EventEmitter', function () {
const store = new SessionStore();
should.equal(store instanceof EventEmitter, true);
});
it('Is an instance of Store', function () {
const store = new SessionStore();
should.equal(store instanceof Store, true);
});
});
describe('SessionStore#destroy', function () {
it('calls destroy on the model with the session_id `sid`', function (done) {
const destroyStub = sandbox.stub(models.Session, 'destroy')
.resolves();
const store = new SessionStore(models.Session);
const sid = 1;
store.destroy(sid, function () {
const destroyStubCall = destroyStub.getCall(0);
should.equal(destroyStubCall.args[0].session_id, sid);
done();
});
});
it('calls back with null if destroy resolve', function (done) {
sandbox.stub(models.Session, 'destroy')
.resolves();
const store = new SessionStore(models.Session);
const sid = 1;
store.destroy(sid, function (err) {
should.equal(err, null);
done();
});
});
it('calls back with the error if destroy errors', function (done) {
const error = new Error('beam me up scotty');
sandbox.stub(models.Session, 'destroy')
.rejects(error);
const store = new SessionStore(models.Session);
const sid = 1;
store.destroy(sid, function (err) {
should.equal(err, error);
done();
});
});
});
describe('SessionStore#get', function () {
it('calls findOne on the model with the session_id `sid`', function (done) {
const findOneStub = sandbox.stub(models.Session, 'findOne')
.resolves();
const store = new SessionStore(models.Session);
const sid = 1;
store.get(sid, function () {
const findOneStubCall = findOneStub.getCall(0);
should.equal(findOneStubCall.args[0].session_id, sid);
done();
});
});
it('callsback with null, null if findOne does not return a model', function (done) {
sandbox.stub(models.Session, 'findOne')
.resolves(null);
const store = new SessionStore(models.Session);
const sid = 1;
store.get(sid, function (err, session) {
should.equal(err, null);
should.equal(session, null);
done();
});
});
it('callsback with null, model.session_data if findOne does return a model', function (done) {
const model = models.Session.forge({
session_data: {
ice: 'cube'
}
});
sandbox.stub(models.Session, 'findOne')
.resolves(model);
const store = new SessionStore(models.Session);
const sid = 1;
store.get(sid, function (err, session) {
should.equal(err, null);
should.deepEqual(session, {
ice: 'cube'
});
done();
});
});
it('callsback with an error if the findOne does error', function (done) {
const error = new Error('hot damn');
sandbox.stub(models.Session, 'findOne')
.rejects(error);
const store = new SessionStore(models.Session);
const sid = 1;
store.get(sid, function (err) {
should.equal(err, error);
done();
});
});
});
describe('SessionStore#set', function () {
it('calls back with an error if there is no user_id on the session_data', function (done) {
const store = new SessionStore(models.Session);
const sid = 1;
const session_data = {};
store.set(sid, session_data, function (err) {
should.exist(err);
done();
});
});
it('calls upsert on the model with the session_id and the session_data', function (done) {
const upsertStub = sandbox.stub(models.Session, 'upsert')
.resolves();
const store = new SessionStore(models.Session);
const sid = 1;
const session_data = {user_id: 100};
store.set(sid, session_data, function () {
const upsertStubCall = upsertStub.getCall(0);
should.equal(upsertStubCall.args[0].session_data, session_data);
should.equal(upsertStubCall.args[1].session_id, sid);
done();
});
});
it('calls back with an error if upsert errors', function (done) {
const error = new Error('huuuuuurrr');
sandbox.stub(models.Session, 'upsert')
.rejects(error);
const store = new SessionStore(models.Session);
const sid = 1;
const session_data = {user_id: 100};
store.set(sid, session_data, function (err) {
should.equal(err, error);
done();
});
});
it('calls back with null, null if upsert succeed', function (done) {
sandbox.stub(models.Session, 'upsert')
.resolves('success');
const store = new SessionStore(models.Session);
const sid = 1;
const session_data = {user_id: 100};
store.set(sid, session_data, function (err, data) {
should.equal(err, null);
should.equal(data, null);
done();
});
});
});
});

View File

@ -52,6 +52,7 @@
"express-brute": "1.0.1",
"express-hbs": "1.0.4",
"express-query-boolean": "2.0.0",
"express-session": "1.15.6",
"extract-zip": "1.6.7",
"fs-extra": "3.0.1",
"ghost-gql": "0.0.10",

View File

@ -1157,6 +1157,10 @@ crc32-stream@^2.0.0:
crc "^3.4.4"
readable-stream "^2.0.0"
crc@3.4.4:
version "3.4.4"
resolved "https://registry.yarnpkg.com/crc/-/crc-3.4.4.tgz#9da1e980e3bd44fc5c93bf5ab3da3378d85e466b"
crc@^3.4.4:
version "3.5.0"
resolved "https://registry.yarnpkg.com/crc/-/crc-3.5.0.tgz#98b8ba7d489665ba3979f59b21381374101a1964"
@ -1790,6 +1794,20 @@ express-query-boolean@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/express-query-boolean/-/express-query-boolean-2.0.0.tgz#ea56ac8138e2b95b171b8eee2af88738302941c3"
express-session@1.15.6:
version "1.15.6"
resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.15.6.tgz#47b4160c88f42ab70fe8a508e31cbff76757ab0a"
dependencies:
cookie "0.3.1"
cookie-signature "1.0.6"
crc "3.4.4"
debug "2.6.9"
depd "~1.1.1"
on-headers "~1.0.1"
parseurl "~1.3.2"
uid-safe "~2.1.5"
utils-merge "1.0.1"
express@4.16.3, express@^4.16.2:
version "4.16.3"
resolved "https://registry.yarnpkg.com/express/-/express-4.16.3.tgz#6af8a502350db3246ecc4becf6b5a34d22f7ed53"
@ -5025,6 +5043,10 @@ rai@~0.1.11:
version "0.1.12"
resolved "https://registry.yarnpkg.com/rai/-/rai-0.1.12.tgz#8ccfd014d0f9608630dd73c19b8e4b057754a6a6"
random-bytes@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b"
range-parser@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e"
@ -6173,6 +6195,12 @@ uglify-to-browserify@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7"
uid-safe@~2.1.5:
version "2.1.5"
resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a"
dependencies:
random-bytes "~1.0.0"
uid2@0.0.x:
version "0.0.3"
resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82"