Ghost/ghost/members-ssr/lib/members-ssr.js
Simon Backx 75bb53f065
🔒 Added support for logging out members on all devices (#18935)
fixes https://github.com/TryGhost/Product/issues/3738
https://www.notion.so/ghost/Member-Session-Invalidation-13254316f2244c34bcbc65c101eb5cc4

- Adds the transient_id column to the members table. This defaults to
email, to keep it backwards compatible (not logging out all existing
sessions)
- Instead of using the email in the cookies, we now use the transient_id
- Updating the transient_id means invalidating all sessions of a member
- Adds an endpoint to the admin api to log out a member from all devices
- Added the `all` body property to the DELETE session endpoint in the
members API. Setting it to true will sign a member out from all devices.
- Adds a UI button in Admin to sign a member out from all devices
- Portal 'sign out of all devices' will not be added for now

Related changes (added because these areas were affected by the code
changes):
- Adds a serializer to member events / activity feed endpoints - all
member fields were returned here, so the transient_id would also be
returned - which is not needed and bloats the API response size
(`transient_id` is not a secret because the cookies are signed)
- Removed `loadMemberSession` from public settings browse (not used
anymore + bad pattern)

Performance tests on site with 50.000 members (on Macbook M1 Pro):
- Migrate: 6s (adding column 4s, setting to email is 1s, dropping
nullable: 1s)
- Rollback: 2s
2023-11-15 17:10:28 +01:00

329 lines
9.1 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);
}
/**
* @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.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) {
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);
};