From 016422ce0691ff1864ce33c3a1d9a6b01d0a04d9 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Mon, 16 Sep 2019 12:32:51 +0800 Subject: [PATCH] Updated members-ssr to use token from query string no-issue This changes the exchangeTokenForSession method to read the token from a `token` query string, rather than from the request body. This also includes a refactor to change MembersSSR into a class, and document all methods with JsDoc type annotations which can be interpreted by the typescript compiler --- ghost/members-ssr/index.js | 432 +++++++++++++++++++++++++------------ 1 file changed, 294 insertions(+), 138 deletions(-) diff --git a/ghost/members-ssr/index.js b/ghost/members-ssr/index.js index 77b8a629fe..0e85d00007 100644 --- a/ghost/members-ssr/index.js +++ b/ghost/members-ssr/index.js @@ -1,170 +1,326 @@ -const concat = require('concat-stream'); -const Cookies = require('cookies'); +const {parse: parseUrl} = require('url'); +const createCookies = require('cookies'); const ignition = require('ghost-ignition'); - const { BadRequestError } = ignition.errors; -const EMPTY = {}; +/** + * @typedef {import('http').IncomingMessage} Request + * @typedef {import('http').ServerResponse} Response + * @typedef {import('cookies').ICookies} Cookies + * @typedef {import('cookies').Option} CookiesOptions + * @typedef {import('cookies').SetOption} SetCookieOptions + * @typedef {string} JWT + */ + +/** + * @typedef {object} Member + * @prop {string} email + */ + const SIX_MONTHS_MS = 1000 * 60 * 60 * 24 * 184; const ONE_DAY_MS = 1000 * 60 * 60 * 24; -const withCookies = (fn, cookieConfig) => (req, res) => { - return new Promise((resolve) => { - const cookies = new Cookies(req, res, cookieConfig); - resolve(fn(req, res, {cookies})); - }); -}; +class MembersSSR { + /** + * @typedef {object} MembersSSROptions + * + * @prop {string|string[]} cookieKeys - A secret or array of secrets used to sign cookies + * @prop {() => object} getMembersApi - A function which returns an instance of members-api + * @prop {boolean} [cookieSecure = true] - Whether the cookie should have Secure flag + * @prop {string} [cookieName] - The name of the members-ssr cookie + * @prop {number} [cookieMaxAge] - The max age in ms of the members-ssr cookie + * @prop {string} [cookieCacheName] - The name of the members-ssr-cache cookie + * @prop {number} [cookieCacheMaxAge] - The max age in ms of the members-ssr-cache cookie + * @prop {string} [cookiePath] - The Path flag for the cookie + */ -const withBodyAndCookies = (fn, cookieConfig) => (req, res) => { - return new Promise((resolve, reject) => { - const cookies = new Cookies(req, res, cookieConfig); - req.on('error', reject); - req.pipe(concat(function (buff) { - const body = buff.toString(); - resolve(fn(req, res, {body, cookies})); - })); - }); -}; + /** + * Create an instance of MembersSSR + * + * @param {MembersSSROptions} options - The options for the members ssr class + */ + constructor(options) { + const { + cookieSecure = true, + cookieName = 'members-ssr', + cookieMaxAge = SIX_MONTHS_MS, + cookieCacheName = 'members-ssr-cache', + cookieCacheMaxAge = ONE_DAY_MS, + cookiePath = '/', + cookieKeys, + getMembersApi + } = options; -const get = (value) => { - return typeof value === 'function' ? value() : value; -}; + if (!getMembersApi) { + throw new Error('Missing option getMembersApi'); + } -module.exports = function create(options = EMPTY) { - if (options === EMPTY) { - throw new Error('Must pass options'); + this._getMembersApi = getMembersApi; + + if (!cookieKeys) { + throw new Error('Missing option cookieKeys'); + } + + this.sessionCookieName = cookieName; + this.cacheCookieName = cookieCacheName; + + /** + * @type SetCookieOptions + */ + this.sessionCookieOptions = { + signed: true, + httpOnly: true, + sameSite: 'lax', + maxAge: cookieMaxAge, + path: cookiePath + }; + + /** + * @type SetCookieOptions + */ + this.cacheCookieOptions = { + signed: true, + httpOnly: true, + sameSite: 'lax', + maxAge: cookieCacheMaxAge, + path: cookiePath + }; + + /** + * @type CookiesOptions + */ + this.cookiesOptions = { + keys: Array.isArray(cookieKeys) ? cookieKeys : [cookieKeys], + secure: cookieSecure + }; } - const { - cookieMaxAge = SIX_MONTHS_MS, - cookieSecure = true, - cookieName = 'members-ssr', - cookieCacheName = 'members-ssr-cache', - cookieCacheMaxAge = ONE_DAY_MS, - cookiePath = '/', - cookieKeys, - membersApi - } = options; - - if (!membersApi) { - throw new Error('Missing option membersApi'); + /** + * @method _getCookies + * + * @param {Request} req + * @param {Response} res + * + * @returns {Cookies} An instance of the cookies object for current request/response + */ + _getCookies(req, res) { + return createCookies(req, res, this.cookiesOptions); } - if (!cookieKeys) { - throw new Error('Missing option cookieKeys'); + /** + * @method _removeSessionCookie + * + * @param {Request} req + * @param {Response} res + */ + _removeSessionCookie(req, res) { + const cookies = this._getCookies(req, res); + cookies.set(this.sessionCookieName, this.sessionCookieOptions); } - const cookieConfig = { - keys: [].concat(cookieKeys), - secure: cookieSecure - }; + /** + * @method _setSessionCookie + * + * @param {Request} req + * @param {Response} res + * @param {string} value + */ + _setSessionCookie(req, res, value) { + const cookies = this._getCookies(req, res); + cookies.set(this.sessionCookieName, value, this.sessionCookieOptions); + } - const getMemberDataFromToken = token => get(membersApi).getMemberDataFromMagicLinkToken(token); + /** + * @method _getSessionCookies + * + * @param {Request} req + * @param {Response} res + * + * @returns {string} The cookie value + */ + _getSessionCookies(req, res) { + const cookies = this._getCookies(req, res); + const value = cookies.get(this.sessionCookieName, {signed: true}); + if (!value) { + throw new BadRequestError({ + message: `Cookie ${this.sessionCookieName} not found` + }); + } + return value; + } - const exchangeTokenForSession = withBodyAndCookies(async (_req, _res, {body, cookies}) => { - const token = body; - if (!body || typeof body !== 'string') { + /** + * @method _removeCacheCookie + * + * @param {Request} req + * @param {Response} res + */ + _removeCacheCookie(req, res) { + const cookies = this._getCookies(req, res); + cookies.set(this.cacheCookieName, this.cacheCookieOptions); + } + + /** + * @method _setCacheCookie + * + * @param {Request} req + * @param {Response} res + * @param {object} value + */ + _setCacheCookie(req, res, value) { + const cookies = this._getCookies(req, res); + cookies.set(this.cacheCookieName, JSON.stringify(value), this.cacheCookieOptions); + } + + /** + * @method _getCacheCookie + * + * @param {Request} req + * @param {Response} res + * + * @returns {object|null} The cookie value + */ + _getCacheCookie(req, res) { + const cookies = this._getCookies(req, res); + const value = cookies.get(this.cacheCookieName, {signed: true}); + if (!value) { + return null; + } + try { + return JSON.parse(value); + } catch (err) { + this._removeCacheCookie(req, res); + throw new BadRequestError({ + message: `Invalid JSON found in cookie ${this.cacheCookieName}` + }); + } + } + + /** + * @method _getMemberDataFromToken + * + * @param {JWT} token + * + * @returns {Promise} member + */ + async _getMemberDataFromToken(token) { + const api = await this._getMembersApi(); + return api.getMemberDataFromMagicLinkToken(token); + } + + /** + * @method _getMemberIdentityData + * + * @param {string} email + * + * @returns {Promise} member + */ + async _getMemberIdentityData(email) { + const api = await this._getMembersApi(); + return api.getMemberIdentityData(email); + } + + /** + * @method _getMemberIdentityToken + * + * @param {string} email + * + * @returns {Promise} member + */ + async _getMemberIdentityToken(email) { + const api = await this._getMembersApi(); + return api.getMemberIdentityToken(email); + } + + /** + * @method exchangeTokenForSession + * @param {Request} req + * @param {Response} res + * + * @returns {Promise} The member the session was created for + */ + async exchangeTokenForSession(req, res) { + if (!req.url) { return Promise.reject(new BadRequestError({ - message: 'Expected body containing JWT' + message: 'Expected token param containing JWT' })); } - const member = await getMemberDataFromToken(token); - cookies.set(cookieName, member.email, { - signed: true, - httpOnly: true, - sameSite: 'lax', - maxAge: cookieMaxAge, - path: cookiePath - }); - cookies.set(cookieCacheName, JSON.stringify(member), { - signed: true, - httpOnly: true, - sameSite: 'lax', - maxAge: cookieCacheMaxAge, - path: cookiePath - }); - }, cookieConfig); - - const deleteSession = withCookies((_req, _res, {cookies}) => { - cookies.set(cookieName, { - signed: true, - httpOnly: true, - sameSite: 'lax', - maxAge: cookieMaxAge, - path: cookiePath - }); - cookies.set(cookieCacheName, { - signed: true, - httpOnly: true, - sameSite: 'lax', - maxAge: cookieCacheMaxAge, - path: cookiePath - }); - }, cookieConfig); - - const getMemberDataFromSession = withCookies(async (_req, _res, {cookies}) => { - const email = cookies.get(cookieName, { - signed: true - }); - - if (!email) { - throw new BadRequestError({ - message: `Cookie ${cookieName} not found` - }); + const {query} = parseUrl(req.url, true); + if (!query || !query.token) { + return Promise.reject(new BadRequestError({ + message: 'Expected token param containing JWT' + })); } - const cachedMember = cookies.get(cookieCacheName, { - signed: true - }); + const token = Array.isArray(query.token) ? query.token[0] : query.token; + const member = await this._getMemberDataFromToken(token); - if (cachedMember) { - try { - return JSON.parse(cachedMember); - } catch (e) { - cookies.set(cookieCacheName, { - signed: true, - httpOnly: true, - sameSite: 'lax', - maxAge: cookieCacheMaxAge, - path: cookiePath - }); - throw new BadRequestError({ - message: `Invalid JSON found in cookie ${cookieCacheName}` - }); - } - } - const member = await get(membersApi).getMemberIdentityData(email); - cookies.set(cookieCacheName, JSON.stringify(member), { - signed: true, - httpOnly: true, - sameSite: 'lax', - maxAge: cookieCacheMaxAge, - path: cookiePath - }); + this._setSessionCookie(req, res, member.email); + this._setCacheCookie(req, res, member); return member; - }, cookieConfig); + } - const getIdentityTokenForMemberFromSession = withCookies(async (_req, _res, {cookies}) => { - try { - const email = cookies.get(cookieName, { - signed: true - }); - return get(membersApi).getMemberIdentityToken(email); - } catch (e) { - throw new BadRequestError({ - message: `Cookie ${cookieName} not found` - }); + /** + * @method deleteSession + * @param {Request} req + * @param {Response} res + * + * @returns {Promise} + */ + async deleteSession(req, res) { + this._removeSessionCookie(req, res); + this._removeCacheCookie(req, res); + } + + /** + * @method getMemberDataFromSession + * + * @param {Request} req + * @param {Response} res + * + * @returns {Promise} + */ + async getMemberDataFromSession(req, res) { + const email = this._getSessionCookies(req, res); + + const cachedMember = this._getCacheCookie(req, res); + + if (cachedMember) { + return cachedMember; } - }, cookieConfig); - return { - exchangeTokenForSession, - deleteSession, - getMemberDataFromSession, - getIdentityTokenForMemberFromSession - }; + const member = await this._getMemberIdentityData(email); + this._setCacheCookie(req, res, member); + return member; + } + + /** + * @method getIdentityTokenForMemberFromSession + * + * @param {Request} req + * @param {Response} res + * + * @returns {Promise} identity token + */ + async getIdentityTokenForMemberFromSession(req, res) { + const email = this._getSessionCookies(req, res); + return this._getMemberIdentityToken(email); + } +} + +/** + * Factory function for creating instance of MembersSSR + * + * @param {MembersSSROptions} options + * @returns {MembersSSR} + */ +module.exports = function create(options) { + if (!options) { + throw new Error('Must pass options'); + } + return new MembersSSR(options); };