Implemented externally verifiable identity tokens

no-issue

This adds two new endpoints, one at /ghost/.well-known/jwks.json for exposing
a public key, and one on the canary api /identities, which allows the
Owner user to fetch a JWT.

This token can then be used by external services to verify the domain

* Added ghost_{public,private}_key settings

    This key can be used for generating tokens for communicating with
    external services on behalf of Ghost

* Added .well-known directory to /ghost/.well-known

    We add a jwks.json file to the .well-known directory which exposes a
    public JWK which can be used to verify the signatures of JWT's created
    by Ghost

    This is added to the /ghost/ path so that it can live on the admin
    domain, rather than the frontend. This is because most of its
    uses/functions will be in relation to the admin domain.

* Improved settings model tests

    This removes hardcoded positions in favour of testing that a particular
    event wasn't emitted which is less brittle and more precise about what's
    being tested

* Fixed parent app unit tests for well-known

    This updates the parent app unit tests to check that the well-known
    route is mounted. We all change proxyquire to use `noCallThru` which
    ensures that the ubderlying modules are not required. This stops the
    initialisation logic in ./well-known erroring in tests

https://github.com/thlorenz/proxyquire/issues/215

* Moved jwt signature to a separate 'token' propery

    This structure corresponds to other resources and allows to exptend with
    additional properties in future if needed
This commit is contained in:
Fabien O'Carroll 2020-01-20 13:45:58 +02:00
parent 318484d737
commit d246a4761e
17 changed files with 325 additions and 13 deletions

View File

@ -0,0 +1,36 @@
const settings = require('../../services/settings/cache');
const urlUtils = require('../../lib/url-utils');
const jwt = require('jsonwebtoken');
const jose = require('node-jose');
const issuer = urlUtils.urlFor('admin', true);
const dangerousPrivateKey = settings.get('ghost_private_key');
const keyStore = jose.JWK.createKeyStore();
const keyStoreReady = keyStore.add(dangerousPrivateKey, 'pem');
const getKeyID = async () => {
const key = await keyStoreReady;
return key.kid;
};
const sign = async (claims, options) => {
const kid = await getKeyID();
return jwt.sign(claims, dangerousPrivateKey, Object.assign({
issuer,
expiresIn: '5m',
algorithm: 'RS256',
keyid: kid
}, options));
};
module.exports = {
docName: 'identities',
permissions: true,
read: {
permissions: true,
async query(frame) {
const token = await sign({sub: frame.user.get('email')});
return {token};
}
}
};

View File

@ -14,6 +14,10 @@ module.exports = {
return shared.pipeline(require('./db'), localUtils);
},
get identities() {
return shared.pipeline(require('./identities'), localUtils);
},
get integrations() {
return shared.pipeline(require('./integrations'), localUtils);
},

View File

@ -14,8 +14,12 @@ const common = require('../../../lib/common');
const nonePublicAuth = (apiConfig, frame) => {
debug('check admin permissions');
const singular = apiConfig.docName.replace(/s$/, '');
let singular;
if (apiConfig.docName.match(/ies$/)) {
singular = apiConfig.docName.replace(/ies$/, 'y');
} else {
singular = apiConfig.docName.replace(/s$/, '');
}
let permissionIdentifier = frame.options.id;
// CASE: Target ctrl can override the identifier. The identifier is the unique identifier of the target resource

View File

@ -0,0 +1,7 @@
module.exports = {
read(data, apiConfig, frame) {
frame.response = {
identities: [data]
};
}
};

View File

@ -67,6 +67,10 @@ module.exports = {
return require('./member-signin_urls');
},
get identities() {
return require('./identities');
},
get images() {
return require('./images');
},

View File

@ -14,6 +14,12 @@
},
"theme_session_secret": {
"defaultValue": null
},
"ghost_public_key": {
"defaultValue": null
},
"ghost_private_key": {
"defaultValue": null
}
},
"blog": {

View File

@ -417,6 +417,11 @@
"name": "Read member signin urls",
"action_type": "read",
"object_type": "member_signin_url"
},
{
"name": "Read identities",
"action_type": "read",
"object_type": "identity"
}
]
},

View File

@ -23,6 +23,16 @@ const getMembersKey = doBlock(() => {
};
});
const getGhostKey = doBlock(() => {
let UNO_KEYPAIRINO;
return function getGhostKey(type) {
if (!UNO_KEYPAIRINO) {
UNO_KEYPAIRINO = keypair({bits: 1024});
}
return UNO_KEYPAIRINO[type];
};
});
// For neatness, the defaults file is split into categories.
// It's much easier for us to work with it as a single level
// instead of iterating those categories every time
@ -38,7 +48,9 @@ function parseDefaultSettings() {
theme_session_secret: () => crypto.randomBytes(32).toString('hex'),
members_public_key: () => getMembersKey('public'),
members_private_key: () => getMembersKey('private'),
members_email_auth_secret: () => crypto.randomBytes(64).toString('hex')
members_email_auth_secret: () => crypto.randomBytes(64).toString('hex'),
ghost_public_key: () => getGhostKey('public'),
ghost_private_key: () => getGhostKey('private')
};
_.each(defaultSettingsInCategories, function each(settings, categoryName) {

View File

@ -177,6 +177,9 @@ module.exports = function apiRoutes() {
);
router.del('/session', mw.authAdminApi, http(apiCanary.session.delete));
// ## Identity
router.get('/identities', mw.authAdminApi, http(apiCanary.identities.read));
// ## Authentication
router.post('/authentication/passwordreset',
shared.middlewares.brute.globalReset,

View File

@ -58,6 +58,7 @@ module.exports = function setupParentApp(options = {}) {
adminApp.use(sentry.requestHandler);
adminApp.enable('trust proxy'); // required to respect x-forwarded-proto in admin requests
adminApp.use('/ghost/api', require('./api')());
adminApp.use('/ghost/.well-known', require('./well-known')());
adminApp.use('/ghost', require('./admin')());
// TODO: remove {admin url}/content/* once we're sure the API is not returning relative asset URLs anywhere

View File

@ -0,0 +1,23 @@
const express = require('express');
const settings = require('../services/settings/cache');
const jose = require('node-jose');
const dangerousPrivateKey = settings.get('ghost_private_key');
const keyStore = jose.JWK.createKeyStore();
const keyStoreReady = keyStore.add(dangerousPrivateKey, 'pem');
const getSafePublicJWKS = async () => {
await keyStoreReady;
return keyStore.toJSON();
};
module.exports = function setupWellKnownApp() {
const wellKnownApp = express();
wellKnownApp.get('/jwks.json', async (req, res) => {
const jwks = await getSafePublicJWKS();
res.json(jwks);
});
return wellKnownApp;
};

View File

@ -0,0 +1,103 @@
const should = require('should');
const supertest = require('supertest');
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const testUtils = require('../../../../utils');
const localUtils = require('./utils');
const config = require('../../../../../server/config');
const ghost = testUtils.startGhost;
let request;
const verifyJWKS = (endpoint, token) => {
return new Promise((resolve, reject) => {
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: endpoint
});
function getKey(header, callback){
client.getSigningKey(header.kid, (err, key) => {
let signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}
jwt.verify(token, getKey, {}, (err, decoded) => {
if (err) {
reject(err);
}
resolve(decoded);
});
});
};
describe('Identities API', function () {
describe('As Owner', function () {
before(function () {
return ghost()
.then(function () {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request);
});
});
it('Can create JWT token and verify it afterwards with public jwks', function () {
let identity;
return request
.get(localUtils.API.getApiQuery(`identities/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.identities);
identity = jsonResponse.identities[0];
})
.then(() => {
return verifyJWKS(`${request.app}/ghost/.well-known/jwks.json`, identity.token);
})
.then((decoded) => {
decoded.sub.should.equal('jbloggs@example.com');
});
});
});
describe('As non-Owner', function () {
before(function () {
return ghost()
.then(function (_ghostServer) {
request = supertest.agent(config.get('url'));
})
.then(function () {
return testUtils.createUser({
user: testUtils.DataGenerator.forKnex.createUser({email: 'admin+1@ghost.org'}),
role: testUtils.DataGenerator.Content.roles[0].name
});
})
.then(function (admin) {
request.user = admin;
return localUtils.doAuth(request);
});
});
it('Cannot read', function () {
return request
.get(localUtils.API.getApiQuery(`identities/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(403);
});
});
});

View File

@ -20,7 +20,7 @@ var should = require('should'),
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '7cd198f085844aa5725964069b051189';
const currentFixturesHash = 'b2e26827d712513907054782a0be5735';
const currentFixturesHash = '1e5856f5172a4389bd72a98b388792e6';
// If this test is failing, then it is likely a change has been made that requires a DB version bump,
// and the values above will need updating as confirmation

View File

@ -114,7 +114,7 @@ describe('Unit: models/settings', function () {
return models.Settings.populateDefaults()
.then(() => {
// 2 events per item - settings.added and settings.[name].added
eventSpy.callCount.should.equal(88);
eventSpy.callCount.should.equal(92);
const eventsEmitted = eventSpy.args.map(args => args[0]);
const checkEventEmitted = event => should.ok(eventsEmitted.includes(event), `${event} event should be emitted`);
@ -136,10 +136,9 @@ describe('Unit: models/settings', function () {
return models.Settings.populateDefaults()
.then(() => {
// 2 events per item - settings.added and settings.[name].added
eventSpy.callCount.should.equal(86);
eventSpy.args[13][0].should.equal('settings.logo.added');
const eventsEmitted = eventSpy.args.map(args => args[0]);
const checkEventNotEmitted = event => should.ok(!eventsEmitted.includes(event), `${event} event should be emitted`);
checkEventNotEmitted('settings.description.added');
});
});
});

View File

@ -1,6 +1,6 @@
const should = require('should');
const sinon = require('sinon');
const proxyquire = require('proxyquire');
const proxyquire = require('proxyquire').noCallThru();
const configUtils = require('../../utils/configUtils');
describe('parent app', function () {
@ -10,6 +10,7 @@ describe('parent app', function () {
let apiSpy;
let parentApp;
let adminSpy;
let wellKnownSpy;
let siteSpy;
let gatewaySpy;
let authPagesSpy;
@ -24,6 +25,7 @@ describe('parent app', function () {
vhostSpy = sinon.spy();
apiSpy = sinon.spy();
adminSpy = sinon.spy();
wellKnownSpy = sinon.spy();
siteSpy = sinon.spy();
gatewaySpy = sinon.spy();
authPagesSpy = sinon.spy();
@ -33,6 +35,7 @@ describe('parent app', function () {
'@tryghost/vhost-middleware': vhostSpy,
'./api': apiSpy,
'./admin': adminSpy,
'./well-known': wellKnownSpy,
'./site': siteSpy,
'../services/members': {
gateway: gatewaySpy,
@ -54,10 +57,12 @@ describe('parent app', function () {
parentApp();
use.calledWith('/ghost/api').should.be.true();
use.calledWith('/ghost/.well-known').should.be.true();
use.calledWith('/ghost').should.be.true();
use.calledWith('/content/images').should.be.false();
apiSpy.called.should.be.true();
wellKnownSpy.called.should.be.true();
adminSpy.called.should.be.true();
siteSpy.called.should.be.true();

View File

@ -152,6 +152,7 @@
"grunt-shell": "3.0.1",
"grunt-subgrunt": "1.3.0",
"grunt-update-submodules": "0.4.1",
"jwks-rsa": "1.7.0",
"matchdep": "2.0.0",
"mocha": "7.1.0",
"mock-knex": "0.4.7",

105
yarn.lock
View File

@ -416,6 +416,14 @@
resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.27.tgz#61eb4d75dc6bfbce51cf49ee9bbebe941b2cb5d0"
integrity sha512-6BmYWSBea18+tSjjSC3QIyV93ZKAeNWGM7R6aYt1ryTZXrlHF+QLV0G2yV0viEGVyRkyQsWfMoJ0k/YghBX5sQ==
"@types/body-parser@*":
version "1.19.0"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==
dependencies:
"@types/connect" "*"
"@types/node" "*"
"@types/cacheable-request@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.1.tgz#5d22f3dded1fd3a84c0bbeb5039a7419c2c91976"
@ -431,6 +439,45 @@
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/connect@*":
version "3.4.33"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546"
integrity sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==
dependencies:
"@types/node" "*"
"@types/express-jwt@0.0.42":
version "0.0.42"
resolved "https://registry.yarnpkg.com/@types/express-jwt/-/express-jwt-0.0.42.tgz#4f04e1fadf9d18725950dc041808a4a4adf7f5ae"
integrity sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==
dependencies:
"@types/express" "*"
"@types/express-unless" "*"
"@types/express-serve-static-core@*":
version "4.17.2"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.2.tgz#f6f41fa35d42e79dbf6610eccbb2637e6008a0cf"
integrity sha512-El9yMpctM6tORDAiBwZVLMcxoTMcqqRO9dVyYcn7ycLWbvR8klrDn8CAOwRfZujZtWD7yS/mshTdz43jMOejbg==
dependencies:
"@types/node" "*"
"@types/range-parser" "*"
"@types/express-unless@*":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@types/express-unless/-/express-unless-0.5.1.tgz#4f440b905e42bbf53382b8207bc337dc5ff9fd1f"
integrity sha512-5fuvg7C69lemNgl0+v+CUxDYWVPSfXHhJPst4yTLcqi4zKJpORCxnDrnnilk3k0DTq/WrAUdvXFs01+vUqUZHw==
dependencies:
"@types/express" "*"
"@types/express@*":
version "4.17.2"
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.2.tgz#a0fb7a23d8855bac31bc01d5a58cadd9b2173e6c"
integrity sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==
dependencies:
"@types/body-parser" "*"
"@types/express-serve-static-core" "*"
"@types/serve-static" "*"
"@types/http-cache-semantics@*":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz#9140779736aa2655635ee756e2467d787cfe8a2a"
@ -443,11 +490,21 @@
dependencies:
"@types/node" "*"
"@types/mime@*":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==
"@types/node@*":
version "12.7.11"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.11.tgz#be879b52031cfb5d295b047f5462d8ef1a716446"
integrity sha512-Otxmr2rrZLKRYIybtdG/sgeO+tHY20GxeDjcGmUnmmlCWyEnv2a2x1ZXBo3BTec4OiTXMQCiazB8NMBf0iRlFw==
"@types/range-parser@*":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
"@types/responselike@*":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29"
@ -455,6 +512,14 @@
dependencies:
"@types/node" "*"
"@types/serve-static@*":
version "1.13.3"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1"
integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==
dependencies:
"@types/express-serve-static-core" "*"
"@types/mime" "*"
"@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
@ -4804,6 +4869,19 @@ jwa@^1.4.1:
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jwks-rsa@1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/jwks-rsa/-/jwks-rsa-1.7.0.tgz#5f3362bb428d72a5f40719b5e10785fa094d1a01"
integrity sha512-tq7DVJt9J6wTvl9+AQfwZIiPSuY2Vf0F+MovfRTFuBqLB1xgDVhegD33ChEAQ6yBv9zFvUIyj4aiwrSA5VehUw==
dependencies:
"@types/express-jwt" "0.0.42"
debug "^4.1.0"
jsonwebtoken "^8.5.1"
limiter "^1.1.4"
lru-memoizer "^2.0.1"
ms "^2.1.2"
request "^2.88.0"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
@ -4998,6 +5076,11 @@ liftoff@~2.5.0:
rechoir "^0.6.2"
resolve "^1.1.7"
limiter@^1.1.4:
version "1.1.5"
resolved "https://registry.yarnpkg.com/limiter/-/limiter-1.1.5.tgz#8f92a25b3b16c6131293a0cc834b4a838a2aa7c2"
integrity sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==
linkify-it@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.2.0.tgz#e3b54697e78bf915c70a38acd78fd09e0058b1cf"
@ -5251,6 +5334,22 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
lru-cache@~4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e"
integrity sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=
dependencies:
pseudomap "^1.0.1"
yallist "^2.0.0"
lru-memoizer@^2.0.1:
version "2.1.0"
resolved "https://registry.yarnpkg.com/lru-memoizer/-/lru-memoizer-2.1.0.tgz#2551b29d5181188695d30f12f6a56fad5b418b61"
integrity sha512-oKjxgJhL+m1wfEkez7/a6iyRZUdohej+2u04qCaAQ7BBfx/qD4RH3jOQhPsd8Y3pcm7IhcNtE3kCEIDCMPiJFQ==
dependencies:
lodash.clonedeep "^4.5.0"
lru-cache "~4.0.0"
lru_map@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd"
@ -5784,7 +5883,7 @@ ms@2.1.1:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
ms@^2.0.0, ms@^2.1.1:
ms@^2.0.0, ms@^2.1.1, ms@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
@ -7036,7 +7135,7 @@ proxyquire@2.1.3:
module-not-found-error "^1.0.1"
resolve "^1.11.1"
pseudomap@^1.0.2:
pseudomap@^1.0.1, pseudomap@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
@ -9214,7 +9313,7 @@ y18n@^4.0.0:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
yallist@^2.1.2:
yallist@^2.0.0, yallist@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=