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 = {
|
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
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue