Ghost/ghost/session-service/lib/session-service.js
Sam Lord 07afa6500d
Changed SSO adapter to automatically verify sessions (#21388)
ref ENG-1680

SSO is a different flow that wouldn't need the extra email verification
flow
2024-11-11 22:26:40 +00:00

354 lines
10 KiB
JavaScript

const {
BadRequestError
} = require('@tryghost/errors');
const errors = require('@tryghost/errors');
const emailTemplate = require('../lib/emails/signin');
const UAParser = require('ua-parser-js');
const got = require('got');
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 {totp} = require('otplib');
totp.options = {
digits: 6,
step: 60,
window: [10, 10]
};
/**
* @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
*/
/**
* @typedef {import('express').Request} Req
* @typedef {import('express').Response} Res
*/
/**
* @typedef {object} SessionService
* @prop {(req: Req, res: Res) => Promise<User | null>} getUserForSession
* @prop {(req: Req, res: Res) => Promise<void>} removeUserForSession
* @prop {(req: Req, res: Res, user: User) => Promise<void>} createSessionForUser
* @prop {(req: Req, res: Res) => Promise<void>} createVerifiedSessionForUser
* @prop {(req: Req, res: Res) => Promise<void>} verifySession
* @prop {(req: Req, res: Res) => Promise<void>} sendAuthCodeToUser
* @prop {(req: Req, res: Res) => Promise<boolean>} verifyAuthCodeForUser
* @prop {(req: Req, res: Res) => Promise<boolean>} isVerifiedSession
*/
/**
* @param {object} deps
* @param {(req: Req, res: Res) => Promise<Session>} deps.getSession
* @param {(data: {id: string}) => Promise<User>} deps.findUserById
* @param {(req: Req) => string} deps.getOriginOfRequest
* @param {(key: string) => string} deps.getSettingsCache
* @param {() => string} deps.getBlogLogo
* @param {import('../../core/core/server/services/mail').GhostMailer} deps.mailer
* @param {import('../../core/core/shared/labs')} deps.labs
* @param {import('../../core/core/server/services/i18n').t} deps.t
* @param {import('../../core/core/shared/url-utils')} deps.urlUtils
* @returns {SessionService}
*/
module.exports = function createSessionService({
getSession,
findUserById,
getOriginOfRequest,
getSettingsCache,
getBlogLogo,
mailer,
urlUtils,
labs,
t
}) {
/**
* cookieCsrfProtection
*
* @param {Req} req
* @param {Session} session
* @returns {Promise<void>}
*/
function cookieCsrfProtection(req, session) {
// 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;
}
const origin = getOriginOfRequest(req);
if (session.origin !== origin) {
throw new BadRequestError({
message: `Request made from incorrect origin. Expected '${session.origin}' received '${origin}'.`
});
}
}
/**
* createSessionForUser
*
* @param {Req} req
* @param {Res} res
* @param {User} user
* @returns {Promise<void>}
*/
async function createSessionForUser(req, res, user) {
const session = await getSession(req, res);
const origin = getOriginOfRequest(req);
if (!origin) {
throw new BadRequestError({
message: 'Could not determine origin of request. Please ensure an Origin or Referrer header is present.'
});
}
session.user_id = user.id;
session.origin = origin;
session.user_agent = req.get('user-agent');
session.ip = req.ip;
if (!labs.isSet('staff2fa')) {
session.verified = true;
}
}
/**
* createVerifiedSessionForUser
*
* @param {Req} req
* @param {Res} res
* @param {User} user
* @returns {Promise<void>}
*/
async function createVerifiedSessionForUser(req, res, user) {
await createSessionForUser(req, res, user);
await verifySession(req, res);
}
/**
* generateAuthCodeForUser
*
* @param {Req} req
* @param {Res} res
* @returns {Promise<string>}
*/
async function generateAuthCodeForUser(req, res) {
const session = await getSession(req, res);
const secret = getSettingsCache('admin_session_secret') + session.user_id;
const token = totp.generate(secret);
return token;
}
/**
* verifyAuthCodeForUser
*
* @param {Req} req
* @param {Res} res
* @returns {Promise<boolean>}
*/
async function verifyAuthCodeForUser(req, res) {
const session = await getSession(req, res);
const secret = getSettingsCache('admin_session_secret') + session.user_id;
const isValid = totp.check(req.body.token, secret);
return isValid;
}
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<string>} - 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: 500
};
if (process.env.NODE_ENV?.startsWith('test')) {
gotOpts.retry = 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<void>}
*/
async function sendAuthCodeToUser(req, res) {
const session = await getSession(req, res);
const token = await generateAuthCodeForUser(req, res);
const user = await findUserById({id: session.user_id});
if (!user) {
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)
});
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;
}
/**
* isVerifiedSession
*
* @param {Req} req
* @param {Res} res
*/
async function isVerifiedSession(req, res) {
const session = await getSession(req, res);
return session.verified;
}
/**
* removeUserForSession
*
* @param {Req} req
* @param {Res} res
* @returns {Promise<void>}
*/
async function removeUserForSession(req, res) {
const session = await getSession(req, res);
session.user_id = undefined;
}
/**
* getUserForSession
*
* @param {Req} req
* @param {Res} res
* @returns {Promise<User | null>}
*/
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,
removeUserForSession,
verifySession,
isVerifiedSession,
sendAuthCodeToUser,
verifyAuthCodeForUser,
generateAuthCodeForUser
};
};