const { BadRequestError } = require('@tryghost/errors'); const errors = require('@tryghost/errors'); const crypto = require('crypto'); const emailTemplate = require('./emails/signin'); const UAParser = require('ua-parser-js'); const got = require('got').default; const totp = require('../totp'); const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; const AUTH_CODE_VALIDITY_MS = 5 * 60 * 1000; const AUTH_CODE_CHALLENGE_BYTES = 16; /** * @typedef {object} User * @prop {string} id * @prop {(attr: string) => string} get */ /** * @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 * @prop {boolean} verified * @prop {string} [verified_user_id] * @prop {string} [auth_code_challenge] * @prop {number} [auth_code_generated_at] */ /** * @typedef {import('express').Request} Req * @typedef {import('express').Response} Res */ /** * @typedef {object} SessionService * @prop {(req: Req, res: Res) => Promise} getUserForSession * @prop {(req: Req, res: Res) => Promise} removeUserForSession * @prop {(req: Req, res: Res, user: User) => Promise} createSessionForUser * @prop {(req: Req, res: Res) => Promise} createVerifiedSessionForUser * @prop {(options: {session: Session, user: User, origin: string, userAgent?: string, ip?: string}) => Promise} assignVerifiedUserToSession * @prop {(req: Req, res: Res) => Promise} verifySession * @prop {(req: Req, res: Res) => Promise} sendAuthCodeToUser * @prop {(req: Req, res: Res) => Promise} verifyAuthCodeForUser * @prop {(req: Req, res: Res) => Promise} isVerifiedSession * @prop {() => boolean} isVerificationRequired */ /** * @param {object} deps * @param {(req: Req, res: Res) => Promise} deps.getSession * @param {(data: {id: string}) => Promise} deps.findUserById * @param {(req: Req) => string} deps.getOriginOfRequest * @param {((key: 'require_email_mfa') => boolean) & ((key: 'admin_session_secret' | 'title') => string)} deps.getSettingsCache * @param {() => string} deps.getBlogLogo * @param {import('../../../lib/mail').GhostMailer} deps.mailer * @param {import('../../i18n').t} deps.t * @param {import('../../../../shared/url-utils').default} deps.urlUtils * @param {() => boolean} deps.isStaffDeviceVerificationDisabled * @returns {SessionService} */ module.exports = function createSessionService({ getSession, findUserById, getOriginOfRequest, getSettingsCache, getBlogLogo, mailer, urlUtils, isStaffDeviceVerificationDisabled, t, }) { function createAuthCodeChallenge() { return crypto.randomBytes(AUTH_CODE_CHALLENGE_BYTES).toString('hex'); } function rotateAuthCodeChallenge(session) { session.auth_code_challenge = createAuthCodeChallenge(); session.auth_code_generated_at = Date.now(); } function ensureAuthCodeChallenge(session) { if (!session.auth_code_challenge) { rotateAuthCodeChallenge(session); } return session.auth_code_challenge; } function invalidateAuthCodeChallenge(session) { session.auth_code_challenge = undefined; session.auth_code_generated_at = undefined; } function hasValidAuthCodeChallenge(session) { if (!session.auth_code_challenge || !session.auth_code_generated_at) { return false; } return Date.now() - session.auth_code_generated_at <= AUTH_CODE_VALIDITY_MS; } function verifyAuthCode(session, token, secret) { if (!token || !session.user_id || !hasValidAuthCodeChallenge(session)) { return false; } const verified = totp.verify(session.user_id, token, secret, session.auth_code_challenge); if (verified) { invalidateAuthCodeChallenge(session); } return verified; } // Reject any request whose origin isn't the admin site. This check is // used to prevent cross-origin logins (login CSRF). function assertRequestFromAdminOrigin(origin) { const adminUrl = urlUtils.getAdminUrl() || urlUtils.getSiteUrl(); const adminOrigin = new URL(adminUrl).origin; if (origin !== adminOrigin) { throw new BadRequestError({ message: `Request made from incorrect origin. Expected '${adminOrigin}' received '${origin}'.`, }); } } /** * cookieCsrfProtection * * @param {Req} req * @param {Session} session * @returns {Promise} */ function cookieCsrfProtection(req, session) { const origin = getOriginOfRequest(req); // Check that the origin matches the admin URL to prevent cross-origin // requests (e.g. no-cors form submissions from phishing sites) assertRequestFromAdminOrigin(origin); // 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; } if (session.origin !== origin) { throw new BadRequestError({ message: `Request made from incorrect origin. Expected '${session.origin}' received '${origin}'.`, }); } } /** * isVerificationRequired * Determines if 2FA verification is required based on site settings * @returns {boolean} */ function isVerificationRequired() { return getSettingsCache('require_email_mfa') === true; } async function assignUserToSession({ session, user, origin, userAgent, ip, verificationToken }) { if (!origin) { throw new BadRequestError({ message: 'Could not determine origin of request. Please ensure an Origin or Referrer header is present.', }); } // A session may only ever be created for the admin panel's own origin, so a // cross-origin request (e.g. a login-CSRF form post) can't create a session. assertRequestFromAdminOrigin(origin); if (session.user_id && session.user_id !== user.id) { invalidateAuthCodeChallenge(session); } session.user_id = user.id; session.origin = origin; session.user_agent = userAgent; session.ip = ip; // If a verification token was provided with the login request, verify it if (verificationToken) { const secret = getSettingsCache('admin_session_secret'); const isAuthCodeVerified = verifyAuthCode(session, verificationToken, secret); if (isAuthCodeVerified) { session.verified = true; session.verified_user_id = user.id; invalidateAuthCodeChallenge(session); } } if (isStaffDeviceVerificationDisabled()) { session.verified = true; session.verified_user_id = user.id; } } /** * createSessionForUser * * @param {Req} req * @param {Res} res * @param {User} user * @returns {Promise} */ async function createSessionForUser(req, res, user) { const previousSession = await getSession(req, res); // Carried over to the new session so verification state survives login const { user_id: previousUserId, verified: previousVerified, verified_user_id: previousVerifiedUserId, auth_code_challenge: previousAuthCodeChallenge, auth_code_generated_at: previousAuthCodeGeneratedAt, } = previousSession; // Ensure a new session is always created await new Promise((resolve, reject) => { req.session.regenerate((err) => { if (err) { reject(err); return; } resolve(); }); }); const session = req.session; session.user_id = previousUserId; // Verification is bound to the user who completed it — any other user // (including sessions with no verified_user_id) must verify again const carryVerification = previousVerified === true && previousVerifiedUserId === user.id; session.verified = carryVerification ? true : undefined; session.verified_user_id = carryVerification ? previousVerifiedUserId : undefined; session.auth_code_challenge = previousAuthCodeChallenge; session.auth_code_generated_at = previousAuthCodeGeneratedAt; const origin = getOriginOfRequest(req); await assignUserToSession({ session, user, origin, userAgent: req.get('user-agent'), ip: req.ip, verificationToken: req.body && req.body.token, }); } /** * createVerifiedSessionForUser * * @param {Req} req * @param {Res} res * @param {User} user * @returns {Promise} */ async function createVerifiedSessionForUser(req, res, user) { await createSessionForUser(req, res, user); await verifySession(req, res); } /** * assignVerifiedUserToSession * * @param {{session: Session, user: User, origin: string, userAgent?: string, ip?: string}} options * @returns {Promise} */ async function assignVerifiedUserToSession({ session, user, origin, userAgent, ip }) { await assignUserToSession({ session, user, origin, userAgent, ip, }); session.verified = true; session.verified_user_id = user.id; invalidateAuthCodeChallenge(session); } /** * rotateAndAssignVerifiedUserToSession * Regenerates the Express session (issuing a new session_id) and then * assigns the verified user to the new session. Used after a password * change or reset so that any cloned or stolen copy of the pre-change * cookie is rejected on its next request. * * @param {{req: Req, user: User, ip?: string}} options * @returns {Promise} */ async function rotateAndAssignVerifiedUserToSession({ req, user, ip }) { await new Promise((resolve, reject) => { req.session.regenerate((err) => { if (err) { reject(err); return; } resolve(); }); }); await assignVerifiedUserToSession({ session: req.session, user, origin: getOriginOfRequest(req), ip, }); } /** * generateAuthCodeForUser * * @param {Req} req * @param {Res} res * @returns {Promise} */ async function generateAuthCodeForUser(req, res) { const session = await getSession(req, res); const secret = getSettingsCache('admin_session_secret'); const challenge = ensureAuthCodeChallenge(session); return totp.generate(session.user_id, secret, challenge); } /** * verifyAuthCodeForUser * * @param {Req} req * @param {Res} res * @returns {Promise} */ async function verifyAuthCodeForUser(req, res) { const session = await getSession(req, res); cookieCsrfProtection(req, session); const secret = getSettingsCache('admin_session_secret'); const token = req.body && req.body.token; return verifyAuthCode(session, token, secret); } const formatTime = new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC', timeZoneName: 'short', }).format; /** * Get a readable location string from an IP address. * @param {string} ip - The IP address to look up. * @returns {Promise} - A readable location string or 'Unknown'. */ async function getGeolocationFromIP(ip) { if (!ip || (!IPV4_REGEX.test(ip) && !IPV6_REGEX.test(ip))) { return 'Unknown'; } const gotOpts = { timeout: { request: 500, }, }; if (process.env.NODE_ENV?.startsWith('test')) { gotOpts.retry = { limit: 0, }; } const geojsUrl = `https://get.geojs.io/v1/ip/geo/${encodeURIComponent(ip)}.json`; try { const response = await got(geojsUrl, gotOpts).json(); const { city, region, country } = response || {}; // Only include non-empty parts in the result const locationParts = [city, region, country].filter(Boolean); // If no valid parts, return 'Unknown' return locationParts.length > 0 ? locationParts.join(', ').trim() : 'Unknown'; } catch (error) { return 'Unknown'; } } async function getDeviceDetails(userAgent, ip) { const parser = new UAParser(); parser.setUA(userAgent); const result = parser.getResult(); const deviceParts = [result.browser?.name || '', result.os?.name || ''].filter(Boolean); return { device: deviceParts.join(', '), location: await getGeolocationFromIP(ip), time: formatTime(new Date()), }; } /** * sendAuthCodeToUser * * @param {Req} req * @param {Res} res * @returns {Promise} */ async function sendAuthCodeToUser(req, res) { const session = await getSession(req, res); cookieCsrfProtection(req, session); if (!session.user_id) { throw new BadRequestError({ message: 'Could not fetch user from the session.', }); } rotateAuthCodeChallenge(session); const token = await generateAuthCodeForUser(req, res); let user; try { user = await findUserById({ id: session.user_id }); } catch (error) { // User session likely doesn't contain a valid user ID throw new BadRequestError({ message: 'Could not fetch user from the session.', }); } const recipient = user.get('email'); const siteTitle = getSettingsCache('title'); const siteLogo = getBlogLogo(); const siteUrl = urlUtils.urlFor('home', true); const domain = urlUtils .urlFor('home', true) .match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i')); const siteDomain = domain && domain[1]; const email = emailTemplate({ t, siteTitle: siteTitle, email: recipient, siteDomain: siteDomain, siteUrl: siteUrl, siteLogo: siteLogo, token: token, deviceDetails: await getDeviceDetails(session.user_agent, session.ip), is2FARequired: isVerificationRequired(), }); try { await mailer.send({ to: recipient, subject: `${token} is your Ghost sign in verification code`, html: email, }); } catch (error) { throw new errors.EmailError({ ...error, message: 'Failed to send email. Please check your site configuration and try again.', }); } } /** * verifySession * * @param {Req} req * @param {Res} res */ async function verifySession(req, res) { const session = await getSession(req, res); session.verified = true; session.verified_user_id = session.user_id; invalidateAuthCodeChallenge(session); } /** * isVerifiedSession * * @param {Req} req * @param {Res} res */ async function isVerifiedSession(req, res) { const session = await getSession(req, res); // Verification is bound to a user; a session with no verified_user_id // (e.g. logged out, or predating this field) fails closed rather than // matching an absent user_id via undefined === undefined return ( session.verified === true && !!session.verified_user_id && session.verified_user_id === session.user_id ); } /** * removeUserForSession * * @param {Req} req * @param {Res} res * @returns {Promise} */ async function removeUserForSession(req, res) { const session = await getSession(req, res); if (isVerificationRequired()) { session.verified = undefined; session.verified_user_id = undefined; } invalidateAuthCodeChallenge(session); session.user_id = undefined; } /** * getUserForSession * * @param {Req} req * @param {Res} res * @returns {Promise} */ 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); // Enable CSRF bypass (useful for OAuth for example) if (!res || !res.locals || !res.locals.bypassCsrfProtection) { 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, createVerifiedSessionForUser, assignVerifiedUserToSession, rotateAndAssignVerifiedUserToSession, removeUserForSession, verifySession, isVerifiedSession, sendAuthCodeToUser, verifyAuthCodeForUser, generateAuthCodeForUser, isVerificationRequired, }; };