UNPKG

ghost

Version:

The professional publishing platform

442 lines (390 loc) 16.2 kB
const crypto = require('crypto'); const _ = require('lodash'); const logging = require('@tryghost/logging'); const membersService = require('./service'); const emailSuppressionList = require('../email-suppression-list'); const models = require('../../models'); const urlUtils = require('../../../shared/url-utils'); const spamPrevention = require('../../web/shared/middleware/api/spam-prevention'); const { formattedMemberResponse, formatNewsletterResponse } = require('./utils'); const errors = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const onHeaders = require('on-headers'); const tiersService = require('../tiers/service'); const config = require('../../../shared/config'); const settingsHelpers = require('../settings-helpers'); const messages = { missingUuid: 'Missing uuid.', invalidUuid: 'Invalid uuid.', invalidKey: 'Invalid key.' }; const getFreeTier = async function getFreeTier() { const response = await tiersService.api.browse(); const freeTier = response.data.find(tier => tier.type === 'free'); return freeTier; }; /** * Sets the ghost-access and ghost-access-hmac cookies on the response object * @param {Object} member - The member object * @param {import('express').Request} req - The member object * @param {import('express').Response} res - The express response object to set the cookies on * @param {Object} freeTier - The free tier object * @returns */ const setAccessCookies = function setAccessCookies(member, req, res, freeTier) { if (!member) { // If there is no cookie sent with the request, return early if (!req.headers.cookie || !req.headers.cookie.includes('ghost-access')) { return; } // If there are cookies sent with the request, set them to null and expire them immediately const accessCookie = `ghost-access=null; Max-Age=0; Path=/; HttpOnly; SameSite=Strict;`; const hmacCookie = `ghost-access-hmac=null; Max-Age=0; Path=/; HttpOnly; SameSite=Strict;`; const existingCookies = res.getHeader('Set-Cookie') || []; const cookiesToSet = [accessCookie, hmacCookie].concat(existingCookies); res.setHeader('Set-Cookie', cookiesToSet); return; } const hmacSecret = config.get('cacheMembersContent:hmacSecret'); if (!hmacSecret) { return; } const hmacSecretBuffer = Buffer.from(hmacSecret, 'base64'); if (hmacSecretBuffer.length === 0) { return; } const activeSubscription = member.subscriptions?.find(sub => sub.status === 'active'); const cookieTimestamp = Math.floor(Date.now() / 1000); // to mitigate a cookie replay attack const memberTier = activeSubscription && activeSubscription.tier.id || freeTier.id; const memberTierAndTimestamp = `${memberTier}:${cookieTimestamp}`; const memberTierHmac = crypto.createHmac('sha256', hmacSecretBuffer).update(memberTierAndTimestamp).digest('hex'); const maxAge = 3600; const accessCookie = `ghost-access=${memberTierAndTimestamp}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Strict;`; const hmacCookie = `ghost-access-hmac=${memberTierHmac}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Strict;`; const existingCookies = res.getHeader('Set-Cookie') || []; const cookiesToSet = [accessCookie, hmacCookie].concat(existingCookies); res.setHeader('Set-Cookie', cookiesToSet); }; const accessInfoSession = async function accessInfoSession(req, res, next) { const freeTier = await getFreeTier(); onHeaders(res, function () { setAccessCookies(req.member, req, res, freeTier); }); next(); }; // @TODO: This piece of middleware actually belongs to the frontend, not to the member app // Need to figure a way to separate these things (e.g. frontend actually talks to members API) const loadMemberSession = async function loadMemberSession(req, res, next) { try { const member = await membersService.ssr.getMemberDataFromSession(req, res); Object.assign(req, {member}); res.locals.member = req.member; next(); } catch (err) { Object.assign(req, {member: null}); next(); } }; /** * Require member authentication, and make it possible to authenticate via uuid + hashed key. * You can chain this after loadMemberSession to make it possible to authenticate via both the uuid and the session. */ const authMemberByUuid = async function authMemberByUuid(req, res, next) { try { const uuid = req.query.uuid; if (!uuid) { if (res.locals.member && req.member) { // Already authenticated via session return next(); } throw new errors.UnauthorizedError({ message: tpl(messages.missingUuid) }); } const key = req.query.key; if (!key) { throw new errors.UnauthorizedError({ message: tpl(messages.invalidKey) }); } // the request key is a hashed value from the member uuid and the members validation key so we can verify the source // (only Ghost should be able to generate the key) const memberHmac = crypto.createHmac('sha256', settingsHelpers.getMembersValidationKey()).update(uuid).digest('hex'); if (memberHmac !== key) { throw new errors.UnauthorizedError({ message: tpl(messages.invalidKey) }); } const member = await membersService.api.memberBREADService.read({uuid}); if (!member) { throw new errors.UnauthorizedError({ message: tpl(messages.invalidUuid) }); } Object.assign(req, {member}); res.locals.member = req.member; next(); } catch (err) { next(err); } }; const getIdentityToken = async function getIdentityToken(req, res) { try { const token = await membersService.ssr.getIdentityTokenForMemberFromSession(req, res); res.writeHead(200); res.end(token); } catch (err) { res.writeHead(204); res.end(); } }; const createIntegrityToken = async function createIntegrityToken(req, res) { try { const token = membersService.requestIntegrityTokenProvider.create(); res.writeHead(200); res.end(token); } catch (err) { res.writeHead(204); res.end(); } }; const verifyIntegrityToken = async function verifyIntegrityToken(req, res, next) { const shouldThrowForInvalidToken = config.get('verifyRequestIntegrity'); try { const token = req.body.integrityToken; if (!token) { logging.warn('Request with missing integrity token.'); if (shouldThrowForInvalidToken) { throw new errors.BadRequestError(); } else { return next(); } } if (membersService.requestIntegrityTokenProvider.validate(token)) { return next(); } else { logging.warn('Request with invalid integrity token.'); if (shouldThrowForInvalidToken) { throw new errors.BadRequestError(); } else { return next(); } } } catch (err) { next(err); } }; const deleteSession = async function deleteSession(req, res) { try { await membersService.ssr.deleteSession(req, res); res.writeHead(204); res.end(); } catch (err) { if (!err.statusCode) { logging.error(err); } res.writeHead(err.statusCode ?? 500, { 'Content-Type': 'text/plain;charset=UTF-8' }); res.end(err.message); } }; const getMemberData = async function getMemberData(req, res) { try { const member = await membersService.ssr.getMemberDataFromSession(req, res); if (member) { res.json(formattedMemberResponse(member)); } else { res.json(null); } } catch (err) { res.writeHead(204); res.end(); } }; const deleteSuppression = async function deleteSuppression(req, res) { try { const member = await membersService.ssr.getMemberDataFromSession(req, res); const options = { id: member.id }; await emailSuppressionList.removeEmail(member.email); await membersService.api.members.update({email_disabled: false}, options); res.writeHead(204); res.end(); } catch (err) { if (!err.statusCode) { logging.error(err); } res.writeHead(err.statusCode ?? 500, { 'Content-Type': 'text/plain;charset=UTF-8' }); res.end(err.message); } }; const getMemberNewsletters = async function getMemberNewsletters(req, res) { try { const memberData = req.member; // validation assumed if (!memberData) { res.writeHead(404); return res.end('Email address not found.'); } const data = _.pick(memberData, 'uuid', 'email', 'name', 'newsletters', 'enable_comment_notifications', 'status'); if (data.newsletters) { data.newsletters = formatNewsletterResponse(data.newsletters); } return res.json(data); } catch (err) { res.writeHead(400); res.end('Failed to unsubscribe this email address'); } }; const updateMemberNewsletters = async function updateMemberNewsletters(req, res) { try { const memberData = req.member; // validation assumed if (!memberData) { res.writeHead(404); return res.end('Email address not found.'); } const data = _.pick(req.body, 'newsletters', 'enable_comment_notifications'); const options = { id: memberData.id, withRelated: ['newsletters'] }; const updatedMember = await membersService.api.members.update(data, options); const updatedMemberData = _.pick(updatedMember.toJSON(), ['uuid', 'email', 'name', 'newsletters', 'enable_comment_notifications', 'status']); if (updatedMemberData.newsletters) { updatedMemberData.newsletters = formatNewsletterResponse(updatedMemberData.newsletters); } res.json(updatedMemberData); } catch (err) { res.writeHead(400); res.end('Failed to update newsletters'); } }; const updateMemberData = async function updateMemberData(req, res) { try { const data = _.pick(req.body, 'name', 'expertise', 'subscribed', 'newsletters', 'enable_comment_notifications'); const member = await membersService.ssr.getMemberDataFromSession(req, res); if (member) { const options = { id: member.id, withRelated: ['stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'newsletters'] }; await membersService.api.members.update(data, options); const updatedMember = await membersService.ssr.getMemberDataFromSession(req, res); res.json(formattedMemberResponse(updatedMember)); } else { res.json(null); } } catch (err) { if (!err.statusCode) { logging.error(err); } res.writeHead(err.statusCode ?? 500, { 'Content-Type': 'text/plain;charset=UTF-8' }); res.end(err.message); } }; const createSessionFromMagicLink = async function createSessionFromMagicLink(req, res, next) { if (!req.url.includes('token=')) { return next(); } // req.query is a plain object, copy it to a URLSearchParams object so we can call toString() const searchParams = new URLSearchParams(''); Object.keys(req.query).forEach((param) => { // don't copy the "token" or "r" params if (param !== 'token' && param !== 'r') { searchParams.set(param, req.query[param]); } }); try { const member = await membersService.ssr.exchangeTokenForSession(req, res); spamPrevention.membersAuth().reset(req.ip, `${member.email}login`); // Note: don't reset 'member_login', or that would give an easy way around user enumeration by logging in to a manually created account const subscriptions = member && member.subscriptions || []; if (config.get('cacheMembersContent:enabled')) { // Set the ghost-access cookies to enable tier-based caching try { const freeTier = await getFreeTier(); setAccessCookies(member, req, res, freeTier); } catch { // This is a non-critical operation, so we can safely ignore any errors } } const action = req.query.action; if (action === 'signup' || action === 'signup-paid' || action === 'subscribe') { let customRedirect = ''; const mostRecentActiveSubscription = subscriptions .sort((a, b) => { const aStartDate = new Date(a.start_date); const bStartDate = new Date(b.start_date); return bStartDate.valueOf() - aStartDate.valueOf(); }) .find(sub => ['active', 'trialing'].includes(sub.status)); if (mostRecentActiveSubscription) { customRedirect = mostRecentActiveSubscription.tier.welcome_page_url; } else { const freeTier = await models.Product.findOne({type: 'free'}); customRedirect = freeTier && freeTier.get('welcome_page_url') || ''; } if (customRedirect && customRedirect !== '/') { const baseUrl = urlUtils.getSiteUrl(); const ensureEndsWith = (string, endsWith) => (string.endsWith(endsWith) ? string : string + endsWith); const removeLeadingSlash = string => string.replace(/^\//, ''); // Add query parameters so the frontend can detect that the signup went fine const redirectUrl = new URL(removeLeadingSlash(ensureEndsWith(customRedirect, '/')), ensureEndsWith(baseUrl, '/')); if (urlUtils.isSiteUrl(redirectUrl)) { // Add only for non-external URLs redirectUrl.searchParams.set('success', 'true'); redirectUrl.searchParams.set('action', 'signup'); } return res.redirect(redirectUrl.href); } } // If a custom referrer/redirect was passed, redirect the user to that URL const referrer = req.query.r; const siteUrl = urlUtils.getSiteUrl(); if (referrer && referrer.startsWith(siteUrl)) { const redirectUrl = new URL(referrer); // Copy search params searchParams.forEach((value, key) => { redirectUrl.searchParams.set(key, value); }); redirectUrl.searchParams.set('success', 'true'); if (action === 'signin') { // Not sure if we can delete this, this is a legacy param redirectUrl.searchParams.set('action', 'signin'); } return res.redirect(redirectUrl.href); } // Do a standard 302 redirect to the homepage, with success=true searchParams.set('success', 'true'); res.redirect(`${urlUtils.getSubdir()}/?${searchParams.toString()}`); } catch (err) { logging.warn(err.message); // Do a standard 302 redirect to the homepage, with success=false searchParams.set('success', false); res.redirect(`${urlUtils.getSubdir()}/?${searchParams.toString()}`); } }; // Set req.member & res.locals.member if a cookie is set module.exports = { loadMemberSession, authMemberByUuid, createSessionFromMagicLink, getIdentityToken, getMemberNewsletters, getMemberData, updateMemberData, updateMemberNewsletters, deleteSession, accessInfoSession, deleteSuppression, createIntegrityToken, verifyIntegrityToken };