Added @tryghost/session-service module (#35)

no-issue

This was refactored out of https://github.com/TryGhost/Ghost/pull/11701/
This commit is contained in:
Fabien O'Carroll 2020-04-02 15:26:05 +02:00 committed by GitHub
parent 348185e3a4
commit e95dffb1db
13 changed files with 406 additions and 0 deletions

View File

@ -0,0 +1 @@
*.ts

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node',
]
};

View File

@ -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.

View File

@ -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).

View File

@ -0,0 +1 @@
module.exports = require('./lib/SessionService');

View File

@ -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<User | null>} getUserForSession
* @prop {(req: Req, res: Res) => Promise<void>} destroyCurrentSession
* @prop {(req: Req, res: Res, user: User) => Promise<void>} createSessionForUser
*/
/**
* @param {object} deps
* @param {(req: Req, res: Res) => Promise<Session>} deps.getSession
* @param {(data: {id: string}) => Promise<User>} 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<void>}
*/
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<void>}
*/
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<void>}
*/
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<User | null>}
*/
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
};
};

View File

@ -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"
}
}

View File

@ -0,0 +1,9 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test',
],
parserOptions: {
ecmaVersion: 2017
}
};

View File

@ -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);
});
});

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "types",
"allowJs": true,
"checkJs": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es6"
},
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1,8 @@
declare const _exports: ({ getSession, findUserById, getOriginOfRequest }: {
getSession: (req: import("express").Request<import("express-serve-static-core").ParamsDictionary>, res: import("express").Response<any>) => Promise<import("./lib/SessionService").Session>;
findUserById: (data: {
id: string;
}) => Promise<import("./lib/SessionService").User>;
getOriginOfRequest: (req: import("express").Request<import("express-serve-static-core").ParamsDictionary>) => string;
}) => import("./lib/SessionService").SessionService;
export = _exports;

View File

@ -0,0 +1,25 @@
declare function _exports({ getSession, findUserById, getOriginOfRequest }: {
getSession: (req: import("express").Request<import("express-serve-static-core").ParamsDictionary>, res: import("express").Response<any>) => Promise<Session>;
findUserById: (data: {
id: string;
}) => Promise<User>;
getOriginOfRequest: (req: import("express").Request<import("express-serve-static-core").ParamsDictionary>) => 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<import("express-serve-static-core").ParamsDictionary>;
export type Res = import("express").Response<any>;
export type SessionService = {
getUserForSession: (req: import("express").Request<import("express-serve-static-core").ParamsDictionary>, res: import("express").Response<any>) => Promise<User>;
destroyCurrentSession: (req: import("express").Request<import("express-serve-static-core").ParamsDictionary>, res: import("express").Response<any>) => Promise<void>;
createSessionForUser: (req: import("express").Request<import("express-serve-static-core").ParamsDictionary>, res: import("express").Response<any>, user: User) => Promise<void>;
};

View File

@ -0,0 +1 @@
export {};