From 1c56221d80286145ff38228ca250ee8ecd24c0d2 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Fri, 18 Jan 2019 13:45:06 +0100 Subject: [PATCH] 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 --- core/server/services/auth/api-key/admin.js | 112 ++++++++++ core/server/services/auth/api-key/index.js | 3 + core/server/services/auth/authenticate.js | 1 + core/server/translations/en.json | 5 +- core/server/web/api/v2/admin/app.js | 2 +- .../unit/services/auth/api-key/admin_spec.js | 209 ++++++++++++++++++ yarn.lock | 21 ++ 7 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 core/server/services/auth/api-key/admin.js create mode 100644 core/test/unit/services/auth/api-key/admin_spec.js diff --git a/core/server/services/auth/api-key/admin.js b/core/server/services/auth/api-key/admin.js new file mode 100644 index 0000000000..24714447a0 --- /dev/null +++ b/core/server/services/auth/api-key/admin.js @@ -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 +}; diff --git a/core/server/services/auth/api-key/index.js b/core/server/services/auth/api-key/index.js index 0782127e45..c36d468772 100644 --- a/core/server/services/auth/api-key/index.js +++ b/core/server/services/auth/api-key/index.js @@ -1,4 +1,7 @@ module.exports = { + get admin() { + return require('./admin'); + }, get content() { return require('./content'); } diff --git a/core/server/services/auth/authenticate.js b/core/server/services/auth/authenticate.js index b251cdf92f..b642a9161a 100644 --- a/core/server/services/auth/authenticate.js +++ b/core/server/services/auth/authenticate.js @@ -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] }; diff --git a/core/server/translations/en.json b/core/server/translations/en.json index 2b3d0a22ed..ed835e830e 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -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.", diff --git a/core/server/web/api/v2/admin/app.js b/core/server/web/api/v2/admin/app.js index aca24e65dd..06c7d93916 100644 --- a/core/server/web/api/v2/admin/app.js +++ b/core/server/web/api/v2/admin/app.js @@ -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 diff --git a/core/test/unit/services/auth/api-key/admin_spec.js b/core/test/unit/services/auth/api-key/admin_spec.js new file mode 100644 index 0000000000..314c712aee --- /dev/null +++ b/core/test/unit/services/auth/api-key/admin_spec.js @@ -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(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7d691c2c9f..13800d6a90 100644 --- a/yarn.lock +++ b/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"