diff --git a/ghost/session-service/.eslintignore b/ghost/session-service/.eslintignore new file mode 100644 index 0000000000..6461deecd1 --- /dev/null +++ b/ghost/session-service/.eslintignore @@ -0,0 +1 @@ +*.ts diff --git a/ghost/session-service/.eslintrc.js b/ghost/session-service/.eslintrc.js new file mode 100644 index 0000000000..6a5eab530d --- /dev/null +++ b/ghost/session-service/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node', + ] +}; diff --git a/ghost/session-service/LICENSE b/ghost/session-service/LICENSE new file mode 100644 index 0000000000..a8ebdea81d --- /dev/null +++ b/ghost/session-service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-2020 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ghost/session-service/README.md b/ghost/session-service/README.md new file mode 100644 index 0000000000..cfa29ae5e6 --- /dev/null +++ b/ghost/session-service/README.md @@ -0,0 +1,85 @@ +# Session Service + +## Install + +`npm install @tryghost/session-service --save` + +or + +`yarn add @tryghost/session-service` + + +## Usage + +```js +const SessionService = require('@tryghost/session-service'); + +const sessionService = SessionService({ + async getSession(req, res) { + return new Promise((resolve, reject) => { + require('express-session')(config)(req, res, (err) => { + if (err) { + reject(err); + } + resolve(req.session); + }) + }) + }, + async findUserById({id}) { + return UserModel.findUserById(id); + }, + getOriginOfRequest(req) { + return req.headers.origin; + } +}); + +app.use(async (req, res, next) => { + try { + const user = await sessionService.getUserForSession(req, res); + req.user = user; + next(); + } catch (err) { + next(err); + } +}); + +app.post('/login', async (req, res) => { + try { + const user = await UserModel.verify(req.body); + await sessionService.createSessionForUser(req, res, user); + res.redirect('/home'); + } catch (err) { + return next(err); + } +}); + +app.post('/logout', async (req, res) => { + try { + await sessionService.destroyCurrentSession(req, res); + res.redirect('/login'); + } catch (err) { + return next(err); + } +}); +``` + +## Develop + +This is a mono repository, managed with [lerna](https://lernajs.io/). + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + +## Test + +- `yarn types` run just type check +- `yarn lint` run just eslint +- `yarn test` run lint and tests and type check + + + + +# Copyright & License + +Copyright (c) 2020 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/ghost/session-service/index.js b/ghost/session-service/index.js new file mode 100644 index 0000000000..d54ee47ba2 --- /dev/null +++ b/ghost/session-service/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/SessionService'); diff --git a/ghost/session-service/lib/SessionService.js b/ghost/session-service/lib/SessionService.js new file mode 100644 index 0000000000..306a766055 --- /dev/null +++ b/ghost/session-service/lib/SessionService.js @@ -0,0 +1,142 @@ +const { + BadRequestError, + InternalServerError +} = require('@tryghost/errors'); + +/** + * @typedef {object} User + * @prop {string} id + */ + +/** + * @typedef {object} Session + * @prop {(cb: (err: Error | null) => any) => void} destroy + * @prop {string} user_id + * @prop {string} origin + * @prop {string} user_agent + * @prop {string} ip + */ + +/** + * @typedef {import('express').Request} Req + * @typedef {import('express').Response} Res + */ + +/** + * @typedef {object} SessionService + * @prop {(req: Req, res: Res) => Promise} getUserForSession + * @prop {(req: Req, res: Res) => Promise} destroyCurrentSession + * @prop {(req: Req, res: Res, user: User) => Promise} createSessionForUser + */ + +/** + * @param {object} deps + * @param {(req: Req, res: Res) => Promise} deps.getSession + * @param {(data: {id: string}) => Promise} deps.findUserById + * @param {(req: Req) => string} deps.getOriginOfRequest + * + * @returns {SessionService} + */ + +module.exports = function createSessionService({getSession, findUserById, getOriginOfRequest}) { + /** + * cookieCsrfProtection + * + * @param {Req} req + * @param {Session} session + * @returns {Promise} + */ + function cookieCsrfProtection(req, session) { + // 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 (!session.origin) { + return; + } + + const origin = getOriginOfRequest(req); + + if (session.origin !== origin) { + throw new BadRequestError({ + message: `Request made from incorrect origin. Expected '${session.origin}' received '${origin}'.` + }); + } + } + + /** + * createSessionForUser + * + * @param {Req} req + * @param {Res} res + * @param {User} user + * @returns {Promise} + */ + async function createSessionForUser(req, res, user) { + const session = await getSession(req, res); + const origin = getOriginOfRequest(req); + if (!origin) { + throw new BadRequestError({ + message: 'Could not determine origin of request. Please ensure an Origin or Referrer header is present.' + }); + } + + session.user_id = user.id; + session.origin = origin; + session.user_agent = req.get('user-agent'); + session.ip = req.ip; + } + + /** + * destroyCurrentSession + * + * @param {Req} req + * @param {Res} res + * @returns {Promise} + */ + async function destroyCurrentSession(req, res) { + const session = await getSession(req, res); + return new Promise((resolve, reject) => { + session.destroy((err) => { + if (err) { + return reject(new InternalServerError({err})); + } + resolve(); + }); + }); + } + + /** + * getUserForSession + * + * @param {Req} req + * @param {Res} res + * @returns {Promise} + */ + async function getUserForSession(req, res) { + // CASE: we don't have a cookie header so allow fallthrough to other + // auth middleware or final "ensure authenticated" check + if (!req.headers || !req.headers.cookie) { + return null; + } + + const session = await getSession(req, res); + cookieCsrfProtection(req, session); + + if (!session || !session.user_id) { + return null; + } + + try { + const user = await findUserById({id: session.user_id}); + return user; + } catch (err) { + return null; + } + } + + return { + getUserForSession, + createSessionForUser, + destroyCurrentSession + }; +}; + diff --git a/ghost/session-service/package.json b/ghost/session-service/package.json new file mode 100644 index 0000000000..880286114b --- /dev/null +++ b/ghost/session-service/package.json @@ -0,0 +1,36 @@ +{ + "name": "@tryghost/session-service", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost-Utils/tree/master/packages/session-service", + "author": "Ghost Foundation", + "license": "MIT", + "main": "index.js", + "types": "./types/index.d.ts", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "posttest": "yarn lint", + "pretest": "yarn types", + "types": "rm -r types && tsc" + }, + "files": [ + "index.js", + "lib" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/express": "^4.17.4", + "@types/mocha": "^7.0.2", + "express": "^4.17.1", + "mocha": "7.1.1", + "should": "13.2.3", + "sinon": "9.0.1", + "typescript": "^3.8.3" + }, + "dependencies": { + "@tryghost/errors": "^0.1.1" + } +} diff --git a/ghost/session-service/test/.eslintrc.js b/ghost/session-service/test/.eslintrc.js new file mode 100644 index 0000000000..7e76c1a010 --- /dev/null +++ b/ghost/session-service/test/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test', + ], + parserOptions: { + ecmaVersion: 2017 + } +}; diff --git a/ghost/session-service/test/SessionService.test.js b/ghost/session-service/test/SessionService.test.js new file mode 100644 index 0000000000..0e432e046f --- /dev/null +++ b/ghost/session-service/test/SessionService.test.js @@ -0,0 +1,56 @@ +const should = require('should'); +const sinon = require('sinon'); +const express = require('express'); +const SessionService = require('../'); + +describe('SessionService', function () { + it('Returns the user for the id stored on the session', async function () { + const getSession = async (req) => { + if (req.session) { + return req.session; + } + req.session = { + destroy: sinon.spy(cb => cb()) + }; + return req.session; + }; + const findUserById = sinon.spy(async ({id}) => ({id})); + const getOriginOfRequest = sinon.stub().returns('origin'); + + const sessionService = SessionService({ + getSession, + findUserById, + getOriginOfRequest + }); + + const req = Object.create(express.request, { + ip: { + value: '0.0.0.0' + }, + headers: { + value: { + cookie: 'thing' + } + }, + get: { + value: () => 'Fake' + } + }); + const res = Object.create(express.response); + const user = {id: 'egg'}; + + await sessionService.createSessionForUser(req, res, user); + + should.equal(req.session.user_id, 'egg'); + + const actualUser = await sessionService.getUserForSession(req, res); + should.ok(findUserById.calledWith(sinon.match({id: 'egg'}))); + + const expectedUser = await findUserById.returnValues[0]; + should.equal(actualUser, expectedUser); + + await sessionService.destroyCurrentSession(req, res); + should.ok(req.session.destroy.calledOnce); + }); +}); + diff --git a/ghost/session-service/tsconfig.json b/ghost/session-service/tsconfig.json new file mode 100644 index 0000000000..03f41e871e --- /dev/null +++ b/ghost/session-service/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "types", + "allowJs": true, + "checkJs": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "es6" + }, + "exclude": [ + "node_modules" + ] +} diff --git a/ghost/session-service/types/index.d.ts b/ghost/session-service/types/index.d.ts new file mode 100644 index 0000000000..9cbd17abd1 --- /dev/null +++ b/ghost/session-service/types/index.d.ts @@ -0,0 +1,8 @@ +declare const _exports: ({ getSession, findUserById, getOriginOfRequest }: { + getSession: (req: import("express").Request, res: import("express").Response) => Promise; + findUserById: (data: { + id: string; + }) => Promise; + getOriginOfRequest: (req: import("express").Request) => string; +}) => import("./lib/SessionService").SessionService; +export = _exports; diff --git a/ghost/session-service/types/lib/SessionService.d.ts b/ghost/session-service/types/lib/SessionService.d.ts new file mode 100644 index 0000000000..eb64abad28 --- /dev/null +++ b/ghost/session-service/types/lib/SessionService.d.ts @@ -0,0 +1,25 @@ +declare function _exports({ getSession, findUserById, getOriginOfRequest }: { + getSession: (req: import("express").Request, res: import("express").Response) => Promise; + findUserById: (data: { + id: string; + }) => Promise; + getOriginOfRequest: (req: import("express").Request) => string; +}): SessionService; +export = _exports; +export type User = { + id: string; +}; +export type Session = { + destroy: (cb: (err: Error) => any) => void; + user_id: string; + origin: string; + user_agent: string; + ip: string; +}; +export type Req = import("express").Request; +export type Res = import("express").Response; +export type SessionService = { + getUserForSession: (req: import("express").Request, res: import("express").Response) => Promise; + destroyCurrentSession: (req: import("express").Request, res: import("express").Response) => Promise; + createSessionForUser: (req: import("express").Request, res: import("express").Response, user: User) => Promise; +}; diff --git a/ghost/session-service/types/test/SessionService.test.d.ts b/ghost/session-service/types/test/SessionService.test.d.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/ghost/session-service/types/test/SessionService.test.d.ts @@ -0,0 +1 @@ +export {};