mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-27 18:52:14 +03:00
Added API Key auth middleware to v2 Admin API (#10006)
refs #9865 - Added `auth.authenticate.authenticateAdminApiKey` middleware - accepts signed JWT in an `Authorization: Ghost [token]` header - sets `req.api_key` if the token is valid - Updated `authenticatePrivate` middleware stack for v2 admin routes
This commit is contained in:
parent
809a167a55
commit
1c56221d80
112
core/server/services/auth/api-key/admin.js
Normal file
112
core/server/services/auth/api-key/admin.js
Normal file
@ -0,0 +1,112 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const models = require('../../../models');
|
||||
const common = require('../../../lib/common');
|
||||
|
||||
const JWT_OPTIONS = {
|
||||
maxAge: '5m',
|
||||
algorithms: ['HS256']
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove 'Ghost' from raw authorization header and extract the JWT token.
|
||||
* Eg. Authorization: Ghost ${JWT}
|
||||
* @param {string} header
|
||||
*/
|
||||
const _extractTokenFromHeader = function extractTokenFromHeader(header) {
|
||||
const [scheme, token] = header.split(' ');
|
||||
|
||||
if (/^Ghost$/i.test(scheme)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin API key authentication flow:
|
||||
* 1. extract the JWT token from the `Authorization: Ghost xxxx` header
|
||||
* 2. decode the JWT to extract the api_key id from the "key id" header claim
|
||||
* 3. find a matching api_key record
|
||||
* 4. verify the JWT (matching secret, matching URL path, not expired)
|
||||
* 5. place the api_key object on `req.api_key`
|
||||
*
|
||||
* There are some specifcs of the JWT that we expect:
|
||||
* - the "Key ID" header parameter should be set to the id of the api_key used to sign the token
|
||||
* https://tools.ietf.org/html/rfc7515#section-4.1.4
|
||||
* - the "Audience" claim should match the requested API path
|
||||
* https://tools.ietf.org/html/rfc7519#section-4.1.3
|
||||
*/
|
||||
const authenticateAdminApiKey = function authenticateAdminApiKey(req, res, next) {
|
||||
// we don't have an Authorization header so allow fallthrough to other
|
||||
// auth middleware or final "ensure authenticated" check
|
||||
if (!req.headers || !req.headers.authorization) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = _extractTokenFromHeader(req.headers.authorization);
|
||||
|
||||
if (!token) {
|
||||
return next(new common.errors.UnauthorizedError({
|
||||
message: common.i18n.t('errors.middleware.auth.incorrectAuthHeaderFormat'),
|
||||
code: 'INVALID_AUTH_HEADER'
|
||||
}));
|
||||
}
|
||||
|
||||
const decoded = jwt.decode(token, {complete: true});
|
||||
|
||||
if (!decoded || !decoded.header) {
|
||||
return next(new common.errors.BadRequestError({
|
||||
message: common.i18n.t('errors.middleware.auth.invalidToken'),
|
||||
code: 'INVALID_JWT'
|
||||
}));
|
||||
}
|
||||
|
||||
const apiKeyId = decoded.header.kid;
|
||||
|
||||
models.ApiKey.findOne({id: apiKeyId}).then((apiKey) => {
|
||||
if (!apiKey) {
|
||||
return next(new common.errors.UnauthorizedError({
|
||||
message: common.i18n.t('errors.middleware.auth.unknownAdminApiKey'),
|
||||
code: 'UNKNOWN_ADMIN_API_KEY'
|
||||
}));
|
||||
}
|
||||
|
||||
if (apiKey.get('type') !== 'admin') {
|
||||
return next(new common.errors.UnauthorizedError({
|
||||
message: common.i18n.t('errors.middleware.auth.invalidApiKeyType'),
|
||||
code: 'INVALID_API_KEY_TYPE'
|
||||
}));
|
||||
}
|
||||
|
||||
const secret = Buffer.from(apiKey.get('secret'), 'hex');
|
||||
// ensure the token was meant for this endpoint
|
||||
const options = Object.assign({
|
||||
aud: req.originalUrl
|
||||
}, JWT_OPTIONS);
|
||||
|
||||
try {
|
||||
jwt.verify(token, secret, options);
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError' || err.name === 'JsonWebTokenError') {
|
||||
return next(new common.errors.UnauthorizedError({
|
||||
message: common.i18n.t('errors.middleware.auth.invalidTokenWithMessage', {message: err.message}),
|
||||
code: 'INVALID_JWT',
|
||||
err
|
||||
}));
|
||||
}
|
||||
|
||||
// unknown error
|
||||
return next(new common.errors.InternalServerError({err}));
|
||||
}
|
||||
|
||||
// authenticated OK, store the api key on the request for later checks and logging
|
||||
req.api_key = apiKey;
|
||||
next();
|
||||
}).catch((err) => {
|
||||
next(new common.errors.InternalServerError({err}));
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateAdminApiKey
|
||||
};
|
@ -1,4 +1,7 @@
|
||||
module.exports = {
|
||||
get admin() {
|
||||
return require('./admin');
|
||||
},
|
||||
get content() {
|
||||
return require('./content');
|
||||
}
|
||||
|
@ -104,6 +104,7 @@ const authenticate = {
|
||||
|
||||
// ### v2 API auth middleware
|
||||
authenticateAdminApi: [session.safeGetSession, session.getUser],
|
||||
authenticateAdminApiKey: apiKeyAuth.admin.authenticateAdminApiKey,
|
||||
authenticateContentApi: [apiKeyAuth.content.authenticateContentApiKey, members.authenticateMembersToken]
|
||||
};
|
||||
|
||||
|
@ -81,7 +81,10 @@
|
||||
"pleaseSignInOrAuthenticate": "Please sign in or authenticate with an API Key",
|
||||
"unknownAdminApiKey": "Unknown Admin API Key",
|
||||
"unknownContentApiKey": "Unknown Content API Key",
|
||||
"invalidApiKeyType": "Invalid API Key type"
|
||||
"invalidApiKeyType": "Invalid API Key type",
|
||||
"invalidToken": "Invalid token",
|
||||
"invalidTokenWithMessage": "Invalid token: {message}",
|
||||
"incorrectAuthHeaderFormat": "Authorization header format is \"Authorization: Ghost [token]\""
|
||||
},
|
||||
"oauth": {
|
||||
"invalidClient": "Invalid client.",
|
||||
|
@ -25,7 +25,7 @@ module.exports = function setupApiApp() {
|
||||
// Therefore must come after themeHandler.ghostLocals, for now
|
||||
apiApp.use(shared.middlewares.api.versionMatch);
|
||||
|
||||
// API shouldn't be cached
|
||||
// Admin API shouldn't be cached
|
||||
apiApp.use(shared.middlewares.cacheControl('private'));
|
||||
|
||||
// Routing
|
||||
|
209
core/test/unit/services/auth/api-key/admin_spec.js
Normal file
209
core/test/unit/services/auth/api-key/admin_spec.js
Normal file
@ -0,0 +1,209 @@
|
||||
const {authenticateAdminApiKey} = require('../../../../../server/services/auth/api-key/admin');
|
||||
const common = require('../../../../../server/lib/common');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const models = require('../../../../../server/models');
|
||||
const should = require('should');
|
||||
const sinon = require('sinon');
|
||||
const testUtils = require('../../../../utils');
|
||||
|
||||
const sandbox = sinon.sandbox.create();
|
||||
|
||||
describe('Admin API Key Auth', function () {
|
||||
before(models.init);
|
||||
before(testUtils.teardown);
|
||||
|
||||
beforeEach(function () {
|
||||
const fakeApiKey = {
|
||||
id: '1234',
|
||||
type: 'admin',
|
||||
secret: Buffer.from('testing').toString('hex'),
|
||||
get(prop) {
|
||||
return this[prop];
|
||||
}
|
||||
};
|
||||
this.fakeApiKey = fakeApiKey;
|
||||
this.secret = Buffer.from(fakeApiKey.secret, 'hex');
|
||||
|
||||
this.apiKeyStub = sandbox.stub(models.ApiKey, 'findOne');
|
||||
this.apiKeyStub.returns(new Promise.resolve());
|
||||
this.apiKeyStub.withArgs({id: fakeApiKey.id}).returns(new Promise.resolve(fakeApiKey));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('should authenticate known+valid API key', function (done) {
|
||||
const token = jwt.sign({}, this.secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '5m',
|
||||
audience: '/test/',
|
||||
issuer: this.fakeApiKey.id,
|
||||
keyid: this.fakeApiKey.id
|
||||
});
|
||||
|
||||
const req = {
|
||||
originalUrl: '/test/',
|
||||
headers: {
|
||||
authorization: `Ghost ${token}`
|
||||
}
|
||||
};
|
||||
const res = {};
|
||||
|
||||
authenticateAdminApiKey(req, res, (arg) => {
|
||||
should.not.exist(arg);
|
||||
req.api_key.should.eql(this.fakeApiKey);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('shouldn\'t authenticate with missing Ghost token', function (done) {
|
||||
const token = '';
|
||||
const req = {
|
||||
headers: {
|
||||
authorization: `Ghost ${token}`
|
||||
}
|
||||
};
|
||||
const res = {};
|
||||
|
||||
authenticateAdminApiKey(req, res, function next(err) {
|
||||
should.exist(err);
|
||||
should.equal(err instanceof common.errors.UnauthorizedError, true);
|
||||
err.code.should.eql('INVALID_AUTH_HEADER');
|
||||
should.not.exist(req.api_key);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('shouldn\'t authenticate with broken Ghost token', function (done) {
|
||||
const token = 'invalid';
|
||||
const req = {
|
||||
headers: {
|
||||
authorization: `Ghost ${token}`
|
||||
}
|
||||
};
|
||||
const res = {};
|
||||
|
||||
authenticateAdminApiKey(req, res, function next(err) {
|
||||
should.exist(err);
|
||||
should.equal(err instanceof common.errors.BadRequestError, true);
|
||||
err.code.should.eql('INVALID_JWT');
|
||||
should.not.exist(req.api_key);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('shouldn\'t authenticate with invalid/unknown key', function (done) {
|
||||
const token = jwt.sign({}, this.secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '5m',
|
||||
audience: '/test/',
|
||||
issuer: 'unknown',
|
||||
keyid: 'unknown'
|
||||
});
|
||||
|
||||
const req = {
|
||||
originalUrl: '/test/',
|
||||
headers: {
|
||||
authorization: `Ghost ${token}`
|
||||
}
|
||||
};
|
||||
const res = {};
|
||||
|
||||
authenticateAdminApiKey(req, res, function next(err) {
|
||||
should.exist(err);
|
||||
should.equal(err instanceof common.errors.UnauthorizedError, true);
|
||||
err.code.should.eql('UNKNOWN_ADMIN_API_KEY');
|
||||
should.not.exist(req.api_key);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('shouldn\'t authenticate with JWT signed > 5min ago', function (done) {
|
||||
const payload = {
|
||||
iat: Math.floor(Date.now() / 1000) - 6 * 60
|
||||
};
|
||||
const token = jwt.sign(payload, this.secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '5m',
|
||||
audience: '/test/',
|
||||
issuer: this.fakeApiKey.id,
|
||||
keyid: this.fakeApiKey.id
|
||||
});
|
||||
|
||||
const req = {
|
||||
originalUrl: '/test/',
|
||||
headers: {
|
||||
authorization: `Ghost ${token}`
|
||||
}
|
||||
};
|
||||
const res = {};
|
||||
|
||||
authenticateAdminApiKey(req, res, function next(err) {
|
||||
should.exist(err);
|
||||
should.equal(err instanceof common.errors.UnauthorizedError, true);
|
||||
err.code.should.eql('INVALID_JWT');
|
||||
err.message.should.match(/jwt expired/);
|
||||
should.not.exist(req.api_key);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('shouldn\'t authenticate with JWT with maxAge > 5min', function (done) {
|
||||
const payload = {
|
||||
iat: Math.floor(Date.now() / 1000) - 6 * 60
|
||||
};
|
||||
const token = jwt.sign(payload, this.secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '10m',
|
||||
audience: '/test/',
|
||||
issuer: this.fakeApiKey.id,
|
||||
keyid: this.fakeApiKey.id
|
||||
});
|
||||
|
||||
const req = {
|
||||
originalUrl: '/test/',
|
||||
headers: {
|
||||
authorization: `Ghost ${token}`
|
||||
}
|
||||
};
|
||||
const res = {};
|
||||
|
||||
authenticateAdminApiKey(req, res, function next(err) {
|
||||
should.exist(err);
|
||||
should.equal(err instanceof common.errors.UnauthorizedError, true);
|
||||
err.code.should.eql('INVALID_JWT');
|
||||
err.message.should.match(/maxAge exceeded/);
|
||||
should.not.exist(req.api_key);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('shouldn\'t authenticate with a Content API Key', function (done) {
|
||||
const token = jwt.sign({}, this.secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '5m',
|
||||
audience: '/test/',
|
||||
issuer: this.fakeApiKey.id,
|
||||
keyid: this.fakeApiKey.id
|
||||
});
|
||||
|
||||
const req = {
|
||||
originalUrl: '/test/',
|
||||
headers: {
|
||||
authorization: `Ghost ${token}`
|
||||
}
|
||||
};
|
||||
const res = {};
|
||||
|
||||
this.fakeApiKey.type = 'content';
|
||||
|
||||
authenticateAdminApiKey(req, res, function next(err) {
|
||||
should.exist(err);
|
||||
should.equal(err instanceof common.errors.UnauthorizedError, true);
|
||||
err.code.should.eql('INVALID_API_KEY_TYPE');
|
||||
should.not.exist(req.api_key);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
21
yarn.lock
21
yarn.lock
@ -1592,6 +1592,12 @@ ecdsa-sig-formatter@1.0.10:
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
ecdsa-sig-formatter@1.0.10:
|
||||
version "1.0.10"
|
||||
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz#1c595000f04a8897dfb85000892a0f4c33af86c3"
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
editorconfig@^0.13.2:
|
||||
version "0.13.3"
|
||||
resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.13.3.tgz#e5219e587951d60958fd94ea9a9a008cdeff1b34"
|
||||
@ -3540,6 +3546,21 @@ jws@^3.1.5:
|
||||
jwa "^1.1.5"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
jwa@^1.1.5:
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.6.tgz#87240e76c9808dbde18783cf2264ef4929ee50e6"
|
||||
dependencies:
|
||||
buffer-equal-constant-time "1.0.1"
|
||||
ecdsa-sig-formatter "1.0.10"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
jws@^3.1.5:
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.5.tgz#80d12d05b293d1e841e7cb8b4e69e561adcf834f"
|
||||
dependencies:
|
||||
jwa "^1.1.5"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
keygrip@~1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.0.3.tgz#399d709f0aed2bab0a059e0cdd3a5023a053e1dc"
|
||||
|
Loading…
Reference in New Issue
Block a user