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 = {
/** @type import('jsonwebtoken').Algorithm[] */
algorithms: ['HS256'],
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) {
@ -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
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:
* 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
* 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 jwtValidationOptions = ignoreMaxAge ? _.omit(JWT_OPTIONS_DEFAULTS, 'maxAge') : JWT_OPTIONS_DEFAULTS;
if (!decoded || !decoded.header) {
return next(new errors.BadRequestError({
throw new errors.BadRequestError({
message: tpl(messages.invalidToken),
code: 'INVALID_JWT'
}));
});
}
const apiKeyId = decoded.header.kid;
if (!apiKeyId) {
return next(new errors.BadRequestError({
throw new errors.BadRequestError({
message: tpl(messages.adminApiKidMissing),
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 {
const apiKey = await models.ApiKey.findOne({id: apiKeyId}, {withRelated: ['integration']});
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();
jwt.verify(token, secret, options);
} catch (err) {
if (err instanceof errors.HostLimitError) {
next(err);
} else {
next(new errors.InternalServerError({err}));
if (err.name === 'TokenExpiredError' || err.name === 'JsonWebTokenError') {
throw new errors.UnauthorizedError({
message: tpl(messages.invalidTokenWithMessage, {message: err.message}),
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 = {
authenticate,
authenticateWithUrl
authenticateWithUrl,
authenticateWithToken
};