0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-10 23:36:14 -05:00

Decoupled admin auth logic from express middleware

This will allow us to reuse the logic in the context of Nest
This commit is contained in:
Fabien "egg" O'Carroll 2023-12-12 16:02:09 +07:00
parent c559c59dd2
commit dc4d421e14

View file

@ -17,6 +17,7 @@ const messages = {
}; };
let JWT_OPTIONS_DEFAULTS = { let JWT_OPTIONS_DEFAULTS = {
/** @type import('jsonwebtoken').Algorithm[] */
algorithms: ['HS256'], algorithms: ['HS256'],
maxAge: '5m' maxAge: '5m'
}; };
@ -60,7 +61,7 @@ const authenticate = function apiKeyAdminAuth(req, res, next) {
})); }));
} }
return authenticateWithToken(req, res, next, {token, JWT_OPTIONS: JWT_OPTIONS_DEFAULTS}); return wrappedAuthenticateWithToken(req, res, next, {token});
}; };
const authenticateWithUrl = function apiKeyAuthenticateWithUrl(req, res, next) { const authenticateWithUrl = function apiKeyAuthenticateWithUrl(req, res, next) {
@ -72,9 +73,20 @@ const authenticateWithUrl = function apiKeyAuthenticateWithUrl(req, res, next) {
})); }));
} }
// CASE: Scheduler publish URLs can have long maxAge but controllerd by expiry and neverBefore // CASE: Scheduler publish URLs can have long maxAge but controllerd by expiry and neverBefore
return authenticateWithToken(req, res, next, {token, JWT_OPTIONS: _.omit(JWT_OPTIONS_DEFAULTS, 'maxAge')}); return wrappedAuthenticateWithToken(req, res, next, {token, ignoreMaxAge: true});
}; };
async function wrappedAuthenticateWithToken(req, res, next, options) {
try {
const {apiKey, user} = await authenticateWithToken(req.originalUrl, options.token, options.ignoreMaxAge);
req.api_key = apiKey;
req.user = user;
next();
} catch (err) {
next(err);
}
}
/** /**
* Admin API key authentication flow: * Admin API key authentication flow:
* 1. extract the JWT token from the `Authorization: Ghost xxxx` header or from URL(for schedules) * 1. extract the JWT token from the `Authorization: Ghost xxxx` header or from URL(for schedules)
@ -89,114 +101,109 @@ const authenticateWithUrl = function apiKeyAuthenticateWithUrl(req, res, next) {
* - the "Audience" claim should match the requested API path * - the "Audience" claim should match the requested API path
* https://tools.ietf.org/html/rfc7519#section-4.1.3 * https://tools.ietf.org/html/rfc7519#section-4.1.3
*/ */
const authenticateWithToken = async function apiKeyAuthenticateWithToken(req, res, next, {token, JWT_OPTIONS}) { const authenticateWithToken = async function apiKeyAuthenticateWithToken(originalUrl, token, ignoreMaxAge) {
const decoded = jwt.decode(token, {complete: true}); const decoded = jwt.decode(token, {complete: true});
const jwtValidationOptions = ignoreMaxAge ? _.omit(JWT_OPTIONS_DEFAULTS, 'maxAge') : JWT_OPTIONS_DEFAULTS;
if (!decoded || !decoded.header) { if (!decoded || !decoded.header) {
return next(new errors.BadRequestError({ throw new errors.BadRequestError({
message: tpl(messages.invalidToken), message: tpl(messages.invalidToken),
code: 'INVALID_JWT' code: 'INVALID_JWT'
})); });
} }
const apiKeyId = decoded.header.kid; const apiKeyId = decoded.header.kid;
if (!apiKeyId) { if (!apiKeyId) {
return next(new errors.BadRequestError({ throw new errors.BadRequestError({
message: tpl(messages.adminApiKidMissing), message: tpl(messages.adminApiKidMissing),
code: 'MISSING_ADMIN_API_KID' code: 'MISSING_ADMIN_API_KID'
})); });
}
const apiKey = await models.ApiKey.findOne({id: apiKeyId}, {withRelated: ['integration']});
if (!apiKey) {
throw new errors.UnauthorizedError({
message: tpl(messages.unknownAdminApiKey),
code: 'UNKNOWN_ADMIN_API_KEY'
});
}
if (apiKey.get('type') !== 'admin') {
throw new errors.UnauthorizedError({
message: tpl(messages.invalidApiKeyType),
code: 'INVALID_API_KEY_TYPE'
});
}
// CASE: blocking all non-internal: "custom" and "builtin" integration requests when the limit is reached
if (limitService.isLimited('customIntegrations')
&& (apiKey.relations.integration && !['internal', 'core'].includes(apiKey.relations.integration.get('type')))) {
// NOTE: using "checkWouldGoOverLimit" instead of "checkIsOverLimit" here because flag limits don't have
// a concept of measuring if the limit has been surpassed
await limitService.errorIfWouldGoOverLimit('customIntegrations');
}
// Decoding from hex and transforming into bytes is here to
// keep comparison of the bytes that are stored in the secret.
// Useful context:
// https://github.com/auth0/node-jsonwebtoken/issues/208#issuecomment-231861138
const secret = Buffer.from(apiKey.get('secret'), 'hex');
// Using req.originalUrl means we get the right url even if version-rewrites have happened
const {version, api} = legacyApiPathMatch(originalUrl);
// ensure the token was meant for this api
let options;
if (version) {
// CASE: legacy versioned api request
options = Object.assign({
audience: new RegExp(`/?${version}/${api}/?$`)
}, jwtValidationOptions);
} else {
options = Object.assign({
audience: new RegExp(`/?${api}/?$`)
}, jwtValidationOptions);
} }
try { try {
const apiKey = await models.ApiKey.findOne({id: apiKeyId}, {withRelated: ['integration']}); jwt.verify(token, secret, options);
if (!apiKey) {
return next(new errors.UnauthorizedError({
message: tpl(messages.unknownAdminApiKey),
code: 'UNKNOWN_ADMIN_API_KEY'
}));
}
if (apiKey.get('type') !== 'admin') {
return next(new errors.UnauthorizedError({
message: tpl(messages.invalidApiKeyType),
code: 'INVALID_API_KEY_TYPE'
}));
}
// CASE: blocking all non-internal: "custom" and "builtin" integration requests when the limit is reached
if (limitService.isLimited('customIntegrations')
&& (apiKey.relations.integration && !['internal', 'core'].includes(apiKey.relations.integration.get('type')))) {
// NOTE: using "checkWouldGoOverLimit" instead of "checkIsOverLimit" here because flag limits don't have
// a concept of measuring if the limit has been surpassed
await limitService.errorIfWouldGoOverLimit('customIntegrations');
}
// Decoding from hex and transforming into bytes is here to
// keep comparison of the bytes that are stored in the secret.
// Useful context:
// https://github.com/auth0/node-jsonwebtoken/issues/208#issuecomment-231861138
const secret = Buffer.from(apiKey.get('secret'), 'hex');
// Using req.originalUrl means we get the right url even if version-rewrites have happened
const {version, api} = legacyApiPathMatch(req.originalUrl);
// ensure the token was meant for this api
let options;
if (version) {
// CASE: legacy versioned api request
options = Object.assign({
audience: new RegExp(`/?${version}/${api}/?$`)
}, JWT_OPTIONS);
} else {
options = Object.assign({
audience: new RegExp(`/?${api}/?$`)
}, JWT_OPTIONS);
}
try {
jwt.verify(token, secret, options);
} catch (err) {
if (err.name === 'TokenExpiredError' || err.name === 'JsonWebTokenError') {
return next(new errors.UnauthorizedError({
message: tpl(messages.invalidTokenWithMessage, {message: err.message}),
code: 'INVALID_JWT',
err
}));
}
// unknown error
return next(new errors.InternalServerError({err}));
}
// authenticated OK
if (apiKey.get('user_id')) {
// fetch the user and store it on the request for later checks and logging
const user = await models.User.findOne(
{id: apiKey.get('user_id'), status: 'active'},
{require: true}
);
req.user = user;
}
// store the api key on the request for later checks and logging
req.api_key = apiKey;
next();
} catch (err) { } catch (err) {
if (err instanceof errors.HostLimitError) { if (err.name === 'TokenExpiredError' || err.name === 'JsonWebTokenError') {
next(err); throw new errors.UnauthorizedError({
} else { message: tpl(messages.invalidTokenWithMessage, {message: err.message}),
next(new errors.InternalServerError({err})); code: 'INVALID_JWT',
err
});
} }
// unknown error
throw new errors.InternalServerError({err});
} }
// authenticated OK
let result = {
user: null,
apiKey: apiKey
};
if (apiKey.get('user_id')) {
// fetch the user and store it on the request for later checks and logging
const user = await models.User.findOne(
{id: apiKey.get('user_id'), status: 'active'},
{require: true}
);
result.user = user;
}
return result;
}; };
module.exports = { module.exports = {
authenticate, authenticate,
authenticateWithUrl authenticateWithUrl,
authenticateWithToken
}; };