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:
Fabien O'Carroll 2019-01-18 13:45:06 +01:00 committed by Naz Gargol
parent 809a167a55
commit 1c56221d80
7 changed files with 351 additions and 2 deletions

View 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
};

View File

@ -1,4 +1,7 @@
module.exports = {
get admin() {
return require('./admin');
},
get content() {
return require('./content');
}

View File

@ -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]
};

View File

@ -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.",

View File

@ -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

View 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();
});
});
});

View File

@ -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"