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:
parent
c559c59dd2
commit
dc4d421e14
1 changed files with 97 additions and 90 deletions
|
@ -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
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue