diff --git a/core/server/services/auth/index.js b/core/server/services/auth/index.js index ab0b665581..1ffbed1cd1 100644 --- a/core/server/services/auth/index.js +++ b/core/server/services/auth/index.js @@ -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; diff --git a/core/server/services/auth/session/index.js b/core/server/services/auth/session/index.js new file mode 100644 index 0000000000..c5c3ac9e8a --- /dev/null +++ b/core/server/services/auth/session/index.js @@ -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; + } +}; diff --git a/core/server/services/auth/session/middleware.js b/core/server/services/auth/session/middleware.js new file mode 100644 index 0000000000..7cb77e0c76 --- /dev/null +++ b/core/server/services/auth/session/middleware.js @@ -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 +}; diff --git a/core/server/services/auth/session/store.js b/core/server/services/auth/session/store.js new file mode 100644 index 0000000000..22eba1b3d8 --- /dev/null +++ b/core/server/services/auth/session/store.js @@ -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); + } +}; diff --git a/core/server/translations/en.json b/core/server/translations/en.json index 4b8f494e05..508bd9cedd 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -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" }, diff --git a/core/test/unit/services/auth/authenticate_spec.js b/core/test/unit/services/auth/authenticate_spec.js index a7b766e888..8fd8f221ea 100644 --- a/core/test/unit/services/auth/authenticate_spec.js +++ b/core/test/unit/services/auth/authenticate_spec.js @@ -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 = {}; diff --git a/core/test/unit/services/auth/session/index_spec.js b/core/test/unit/services/auth/session/index_spec.js new file mode 100644 index 0000000000..239d191d1f --- /dev/null +++ b/core/test/unit/services/auth/session/index_spec.js @@ -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 + ]); + }); + }); +}); diff --git a/core/test/unit/services/auth/session/store_spec.js b/core/test/unit/services/auth/session/store_spec.js new file mode 100644 index 0000000000..33a805f9cb --- /dev/null +++ b/core/test/unit/services/auth/session/store_spec.js @@ -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(); + }); + }); + }); +}); diff --git a/package.json b/package.json index 4926f25d18..0ba4ba7471 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 6d7a48ae12..b12dcdfceb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"