4a6d427673
ref https://linear.app/tryghost/issue/KTLO-58/dont-send-ghost-acess-cookies-if-no-member-is-logged-in - Currently when member's caching is enabled, but no member is logged in, we always send `ghost-access=null;` and `ghost-access-hmac=null;` cookies in the requests to `/members/api/member/`. This is done to clear the cookies, but an unintended consequence is that these requests can never be cached since there is a cookie in the response. - This PR removes the cookies from the requests when no member is logged in, the cookies will not be sent, allowing the requests to be cached - It also unsets the cookies when deleting a member's session, so that the cookies are not sent in the requests after the member logs out - This should improve the cache hit ratio with members caching enabled
334 lines
9.5 KiB
JavaScript
334 lines
9.5 KiB
JavaScript
const {parse: parseUrl} = require('url');
|
|
const createCookies = require('cookies');
|
|
const debug = require('@tryghost/debug')('members-ssr');
|
|
|
|
const {
|
|
BadRequestError,
|
|
IncorrectUsageError
|
|
} = require('@tryghost/errors');
|
|
|
|
/**
|
|
* @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} id
|
|
* @prop {string} transient_id
|
|
* @prop {string} email
|
|
*/
|
|
|
|
const SIX_MONTHS_MS = 1000 * 60 * 60 * 24 * 184;
|
|
|
|
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} [cookiePath] - The Path flag for the cookie
|
|
* @prop {boolean} [dangerousRemovalOfSignedCookie] - Flag for removing signed cookie
|
|
*/
|
|
|
|
/**
|
|
* 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,
|
|
cookiePath = '/',
|
|
cookieKeys,
|
|
getMembersApi,
|
|
dangerousRemovalOfSignedCookie
|
|
} = options;
|
|
|
|
if (!getMembersApi) {
|
|
throw new IncorrectUsageError({message: 'Missing option getMembersApi'});
|
|
}
|
|
|
|
this._getMembersApi = getMembersApi;
|
|
|
|
if (!cookieKeys) {
|
|
throw new IncorrectUsageError({message: 'Missing option cookieKeys'});
|
|
}
|
|
|
|
this.sessionCookieName = cookieName;
|
|
|
|
/**
|
|
* @type SetCookieOptions
|
|
*/
|
|
this.sessionCookieOptions = {
|
|
signed: true,
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
maxAge: cookieMaxAge,
|
|
path: cookiePath
|
|
};
|
|
|
|
if (dangerousRemovalOfSignedCookie === true) {
|
|
this.sessionCookieOptions.signed = false;
|
|
}
|
|
|
|
/**
|
|
* @type CookiesOptions
|
|
*/
|
|
this.cookiesOptions = {
|
|
keys: Array.isArray(cookieKeys) ? cookieKeys : [cookieKeys],
|
|
secure: cookieSecure
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
|
|
/**
|
|
* @method _removeSessionCookie
|
|
*
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
*/
|
|
_removeSessionCookie(req, res) {
|
|
const cookies = this._getCookies(req, res);
|
|
cookies.set(this.sessionCookieName, null, this.sessionCookieOptions);
|
|
// If members caching cookies are set, remove them
|
|
if (cookies.get('ghost-access') || cookies.get('ghost-access-hmac')) {
|
|
cookies.set('ghost-access', null, {...this.sessionCookieOptions, signed: false});
|
|
cookies.set('ghost-access-hmac', null, {...this.sessionCookieOptions, signed: false});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @method _setSessionCookie
|
|
*
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
* @param {string} value
|
|
*/
|
|
_setSessionCookie(req, res, value) {
|
|
if (!value) {
|
|
return this._removeSessionCookie(req, res);
|
|
}
|
|
const cookies = this._getCookies(req, res);
|
|
cookies.set(this.sessionCookieName, value, this.sessionCookieOptions);
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* @method _getMemberDataFromToken
|
|
*
|
|
* @param {JWT} token
|
|
*
|
|
* @returns {Promise<Member>} member
|
|
*/
|
|
async _getMemberDataFromToken(token) {
|
|
const api = await this._getMembersApi();
|
|
return api.getMemberDataFromMagicLinkToken(token);
|
|
}
|
|
|
|
/**
|
|
* @method _getMemberIdentityData
|
|
*
|
|
* @param {string} email
|
|
*
|
|
* @returns {Promise<Member>} member
|
|
*/
|
|
async _getMemberIdentityData(email) {
|
|
const api = await this._getMembersApi();
|
|
return api.getMemberIdentityData(email);
|
|
}
|
|
|
|
/**
|
|
* @method _getMemberIdentityData
|
|
*
|
|
* @param {string} transientId
|
|
*
|
|
* @returns {Promise<Member>} member
|
|
*/
|
|
async _getMemberIdentityDataFromTransientId(transientId) {
|
|
const api = await this._getMembersApi();
|
|
return api.getMemberIdentityDataFromTransientId(transientId);
|
|
}
|
|
|
|
/**
|
|
* @method _getMemberIdentityToken
|
|
*
|
|
* @param {string} email
|
|
*
|
|
* @returns {Promise<JWT>} member
|
|
*/
|
|
async _getMemberIdentityToken(transientId) {
|
|
const api = await this._getMembersApi();
|
|
return api.getMemberIdentityToken(transientId);
|
|
}
|
|
|
|
/**
|
|
* @method _setMemberGeolocationFromIp
|
|
* @param {string} email
|
|
* @param {string} ip
|
|
*
|
|
* @returns {Promise<Member>} member
|
|
*/
|
|
async _setMemberGeolocationFromIp(email, ip) {
|
|
const api = await this._getMembersApi();
|
|
return api.setMemberGeolocationFromIp(email, ip);
|
|
}
|
|
|
|
/**
|
|
* @method exchangeTokenForSession
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
*
|
|
* @returns {Promise<Member>} The member the session was created for
|
|
*/
|
|
async exchangeTokenForSession(req, res) {
|
|
if (!req.url) {
|
|
return Promise.reject(new BadRequestError({
|
|
message: 'Expected token param containing JWT'
|
|
}));
|
|
}
|
|
|
|
const {query} = parseUrl(req.url, true);
|
|
if (!query || !query.token) {
|
|
return Promise.reject(new BadRequestError({
|
|
message: 'Expected token param containing JWT'
|
|
}));
|
|
}
|
|
|
|
const token = Array.isArray(query.token) ? query.token[0] : query.token;
|
|
const member = await this._getMemberDataFromToken(token);
|
|
|
|
if (!member) {
|
|
// The member doesn't exist any longer (could be a sign in token for a member that was deleted)
|
|
return Promise.reject(new BadRequestError({
|
|
message: 'Invalid token'
|
|
}));
|
|
}
|
|
|
|
// perform and store geoip lookup for members when they log in
|
|
if (!member.geolocation) {
|
|
try {
|
|
await this._setMemberGeolocationFromIp(member.email, req.ip);
|
|
} catch (err) {
|
|
// no-op, we don't want to stop anything working due to
|
|
// geolocation lookup failing
|
|
debug(`Geolocation lookup failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
this._setSessionCookie(req, res, member.transient_id);
|
|
|
|
return member;
|
|
}
|
|
|
|
async _cycleTransientId(memberId) {
|
|
const api = await this._getMembersApi();
|
|
return api.cycleTransientId(memberId);
|
|
}
|
|
|
|
/**
|
|
* @method deleteSession
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async deleteSession(req, res) {
|
|
if (req.body && typeof req.body === 'object' && req.body.all) {
|
|
// Update transient_id to invalidate all sessions
|
|
const member = await this.getMemberDataFromSession(req, res);
|
|
if (member) {
|
|
await this._cycleTransientId(member.id);
|
|
}
|
|
}
|
|
this._removeSessionCookie(req, res);
|
|
}
|
|
|
|
/**
|
|
* @method getMemberDataFromSession
|
|
*
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
*
|
|
* @returns {Promise<Member>}
|
|
*/
|
|
async getMemberDataFromSession(req, res) {
|
|
const transientId = this._getSessionCookies(req, res);
|
|
const member = await this._getMemberIdentityDataFromTransientId(transientId);
|
|
return member;
|
|
}
|
|
|
|
/**
|
|
* @method getIdentityTokenForMemberFromSession
|
|
*
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
*
|
|
* @returns {Promise<JWT>} identity token
|
|
*/
|
|
async getIdentityTokenForMemberFromSession(req, res) {
|
|
const transientId = this._getSessionCookies(req, res);
|
|
const token = await this._getMemberIdentityToken(transientId);
|
|
if (!token) {
|
|
await this.deleteSession(req, res);
|
|
throw new BadRequestError({
|
|
message: 'Invalid session, could not get identity token'
|
|
});
|
|
}
|
|
return token;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Factory function for creating instance of MembersSSR
|
|
*
|
|
* @param {MembersSSROptions} options
|
|
* @returns {MembersSSR}
|
|
*/
|
|
module.exports = function create(options) {
|
|
if (!options) {
|
|
throw new IncorrectUsageError({
|
|
message: 'Must pass options'
|
|
});
|
|
}
|
|
return new MembersSSR(options);
|
|
};
|