mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-11 09:53:32 +03:00
d246a4761e
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
287 lines
9.9 KiB
JavaScript
287 lines
9.9 KiB
JavaScript
const Promise = require('bluebird'),
|
|
_ = require('lodash'),
|
|
uuid = require('uuid'),
|
|
crypto = require('crypto'),
|
|
keypair = require('keypair'),
|
|
ghostBookshelf = require('./base'),
|
|
common = require('../lib/common'),
|
|
validation = require('../data/validation'),
|
|
settingsCache = require('../services/settings/cache'),
|
|
internalContext = {context: {internal: true}};
|
|
|
|
let Settings, defaultSettings;
|
|
|
|
const doBlock = fn => fn();
|
|
|
|
const getMembersKey = doBlock(() => {
|
|
let UNO_KEYPAIRINO;
|
|
return function getMembersKey(type) {
|
|
if (!UNO_KEYPAIRINO) {
|
|
UNO_KEYPAIRINO = keypair({bits: 1024});
|
|
}
|
|
return UNO_KEYPAIRINO[type];
|
|
};
|
|
});
|
|
|
|
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
|
|
function parseDefaultSettings() {
|
|
var defaultSettingsInCategories = require('../data/schema/').defaultSettings,
|
|
defaultSettingsFlattened = {},
|
|
dynamicDefault = {
|
|
db_hash: () => uuid.v4(),
|
|
public_hash: () => crypto.randomBytes(15).toString('hex'),
|
|
// @TODO: session_secret would ideally be named "admin_session_secret"
|
|
session_secret: () => crypto.randomBytes(32).toString('hex'),
|
|
members_session_secret: () => crypto.randomBytes(32).toString('hex'),
|
|
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'),
|
|
ghost_public_key: () => getGhostKey('public'),
|
|
ghost_private_key: () => getGhostKey('private')
|
|
};
|
|
|
|
_.each(defaultSettingsInCategories, function each(settings, categoryName) {
|
|
_.each(settings, function each(setting, settingName) {
|
|
setting.type = categoryName;
|
|
setting.key = settingName;
|
|
|
|
setting.getDefaultValue = function getDefaultValue() {
|
|
const getDynamicDefault = dynamicDefault[setting.key];
|
|
if (getDynamicDefault) {
|
|
return getDynamicDefault();
|
|
} else {
|
|
return setting.defaultValue;
|
|
}
|
|
};
|
|
|
|
defaultSettingsFlattened[settingName] = setting;
|
|
});
|
|
});
|
|
|
|
return defaultSettingsFlattened;
|
|
}
|
|
|
|
function getDefaultSettings() {
|
|
if (!defaultSettings) {
|
|
defaultSettings = parseDefaultSettings();
|
|
}
|
|
|
|
return defaultSettings;
|
|
}
|
|
|
|
// Each setting is saved as a separate row in the database,
|
|
// but the overlying API treats them as a single key:value mapping
|
|
Settings = ghostBookshelf.Model.extend({
|
|
|
|
tableName: 'settings',
|
|
|
|
defaults: function defaults() {
|
|
return {
|
|
type: 'core'
|
|
};
|
|
},
|
|
|
|
emitChange: function emitChange(event, options) {
|
|
const eventToTrigger = 'settings' + '.' + event;
|
|
ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
|
|
},
|
|
|
|
onDestroyed: function onDestroyed(model, options) {
|
|
ghostBookshelf.Model.prototype.onDestroyed.apply(this, arguments);
|
|
|
|
model.emitChange('deleted', options);
|
|
model.emitChange(model._previousAttributes.key + '.' + 'deleted', options);
|
|
},
|
|
|
|
onCreated: function onCreated(model, response, options) {
|
|
ghostBookshelf.Model.prototype.onCreated.apply(this, arguments);
|
|
|
|
model.emitChange('added', options);
|
|
model.emitChange(model.attributes.key + '.' + 'added', options);
|
|
},
|
|
|
|
onUpdated: function onUpdated(model, response, options) {
|
|
ghostBookshelf.Model.prototype.onUpdated.apply(this, arguments);
|
|
|
|
model.emitChange('edited', options);
|
|
model.emitChange(model.attributes.key + '.' + 'edited', options);
|
|
},
|
|
|
|
onValidate: function onValidate() {
|
|
var self = this;
|
|
|
|
return ghostBookshelf.Model.prototype.onValidate.apply(this, arguments)
|
|
.then(function then() {
|
|
return validation.validateSettings(getDefaultSettings(), self);
|
|
});
|
|
},
|
|
|
|
format() {
|
|
const attrs = ghostBookshelf.Model.prototype.format.apply(this, arguments);
|
|
|
|
// @NOTE: type TEXT will transform boolean to "0"
|
|
if (_.isBoolean(attrs.value)) {
|
|
attrs.value = attrs.value.toString();
|
|
}
|
|
|
|
return attrs;
|
|
},
|
|
|
|
parse() {
|
|
const attrs = ghostBookshelf.Model.prototype.parse.apply(this, arguments);
|
|
|
|
// transform "0" to false
|
|
// transform "false" to false
|
|
if (attrs.value === '0' || attrs.value === '1') {
|
|
attrs.value = !!+attrs.value;
|
|
}
|
|
|
|
if (attrs.value === 'false' || attrs.value === 'true') {
|
|
attrs.value = JSON.parse(attrs.value);
|
|
}
|
|
|
|
return attrs;
|
|
}
|
|
}, {
|
|
findOne: function (data, options) {
|
|
if (_.isEmpty(data)) {
|
|
options = data;
|
|
}
|
|
|
|
// Allow for just passing the key instead of attributes
|
|
if (!_.isObject(data)) {
|
|
data = {key: data};
|
|
}
|
|
|
|
return Promise.resolve(ghostBookshelf.Model.findOne.call(this, data, options));
|
|
},
|
|
|
|
edit: function (data, unfilteredOptions) {
|
|
var options = this.filterOptions(unfilteredOptions, 'edit'),
|
|
self = this;
|
|
|
|
if (!Array.isArray(data)) {
|
|
data = [data];
|
|
}
|
|
|
|
return Promise.map(data, function (item) {
|
|
// Accept an array of models as input
|
|
if (item.toJSON) {
|
|
item = item.toJSON();
|
|
}
|
|
if (!(_.isString(item.key) && item.key.length > 0)) {
|
|
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.models.settings.valueCannotBeBlank')}));
|
|
}
|
|
|
|
item = self.filterData(item);
|
|
|
|
return Settings.forge({key: item.key}).fetch(options).then(function then(setting) {
|
|
if (setting) {
|
|
// it's allowed to edit all attributes in case of importing/migrating
|
|
if (options.importing) {
|
|
return setting.save(item, options);
|
|
} else {
|
|
// If we have a value, set it.
|
|
if (Object.prototype.hasOwnProperty.call(item, 'value')) {
|
|
setting.set('value', item.value);
|
|
}
|
|
// Internal context can overwrite type (for fixture migrations)
|
|
if (options.context && options.context.internal && Object.prototype.hasOwnProperty.call(item, 'type')) {
|
|
setting.set('type', item.type);
|
|
}
|
|
|
|
// If anything has changed, save the updated model
|
|
if (setting.hasChanged()) {
|
|
return setting.save(null, options);
|
|
}
|
|
|
|
return setting;
|
|
}
|
|
}
|
|
|
|
return Promise.reject(new common.errors.NotFoundError({message: common.i18n.t('errors.models.settings.unableToFindSetting', {key: item.key})}));
|
|
});
|
|
});
|
|
},
|
|
|
|
populateDefaults: function populateDefaults(unfilteredOptions) {
|
|
var options = this.filterOptions(unfilteredOptions, 'populateDefaults'),
|
|
self = this;
|
|
|
|
if (!options.context) {
|
|
options.context = internalContext.context;
|
|
}
|
|
|
|
return this
|
|
.findAll(options)
|
|
.then(function checkAllSettings(allSettings) {
|
|
var usedKeys = allSettings.models.map(function mapper(setting) {
|
|
return setting.get('key');
|
|
}),
|
|
insertOperations = [];
|
|
|
|
_.each(getDefaultSettings(), function forEachDefault(defaultSetting, defaultSettingKey) {
|
|
var isMissingFromDB = usedKeys.indexOf(defaultSettingKey) === -1;
|
|
if (isMissingFromDB) {
|
|
defaultSetting.value = defaultSetting.getDefaultValue();
|
|
insertOperations.push(Settings.forge(defaultSetting).save(null, options));
|
|
}
|
|
});
|
|
|
|
if (insertOperations.length > 0) {
|
|
return Promise.all(insertOperations).then(function fetchAllToReturn() {
|
|
return self.findAll(options);
|
|
});
|
|
}
|
|
|
|
return allSettings;
|
|
});
|
|
},
|
|
|
|
permissible: function permissible(modelId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission, hasApiKeyPermission) {
|
|
let isEdit = (action === 'edit');
|
|
let isOwner;
|
|
|
|
function isChangingMembers() {
|
|
if (unsafeAttrs && unsafeAttrs.key === 'labs') {
|
|
let editedValue = JSON.parse(unsafeAttrs.value);
|
|
if (editedValue.members !== undefined) {
|
|
return editedValue.members !== settingsCache.get('labs').members;
|
|
}
|
|
}
|
|
}
|
|
|
|
isOwner = loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Owner'});
|
|
|
|
if (isEdit && isChangingMembers()) {
|
|
// Only allow owner to toggle members flag
|
|
hasUserPermission = isOwner;
|
|
}
|
|
|
|
if (hasUserPermission && hasApiKeyPermission && hasAppPermission) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return Promise.reject(new common.errors.NoPermissionError({
|
|
message: common.i18n.t('errors.models.post.notEnoughPermission')
|
|
}));
|
|
}
|
|
});
|
|
|
|
module.exports = {
|
|
Settings: ghostBookshelf.model('Settings', Settings)
|
|
};
|