diff --git a/core/server/adapters/scheduling/post-scheduling/index.js b/core/server/adapters/scheduling/post-scheduling/index.js index 1ffdcf3f46..bf5618b989 100644 --- a/core/server/adapters/scheduling/post-scheduling/index.js +++ b/core/server/adapters/scheduling/post-scheduling/index.js @@ -1,10 +1,65 @@ -const Promise = require('bluebird'), - moment = require('moment'), - localUtils = require('../utils'), - common = require('../../../lib/common'), - models = require('../../../models'), - urlUtils = require('../../../lib/url-utils'), - _private = {}; +const Promise = require('bluebird'); +const moment = require('moment'); +const jwt = require('jsonwebtoken'); +const localUtils = require('../utils'); +const common = require('../../../lib/common'); +const models = require('../../../models'); +const urlUtils = require('../../../lib/url-utils'); +const _private = {}; +const SCHEDULED_RESOURCES = ['post', 'page']; + +/** + * @description Load the internal scheduler integration + * + * @return {Promise} + */ +_private.getSchedulerIntegration = function () { + return models.Integration.findOne({slug: 'ghost-scheduler'}, {withRelated: 'api_keys'}) + .then((integration) => { + if (!integration) { + throw new common.errors.NotFoundError({ + message: common.i18n.t('errors.api.resource.resourceNotFound', { + resource: 'Integration' + }) + }); + } + return integration.toJSON(); + }); +}; + +/** + * @description Get signed admin token for making authenticated scheduling requests + * + * @return {Promise} + */ +_private.getSignedAdminToken = function (options) { + const {model, apiUrl, integration} = options; + let key = integration.api_keys[0]; + + const JWT_OPTIONS = { + keyid: key.id, + algorithm: 'HS256', + audience: apiUrl + }; + + // Default token expiry is till 6 hours after scheduled time + // or if published_at is in past then till 6 hours after blog start + // to allow for retries in case of network issues + // and never before 10 mins to publish time + let tokenExpiry = moment(model.get('published_at')).add(6, 'h'); + if (tokenExpiry.isBefore(moment())) { + tokenExpiry = moment().add(6, 'h'); + } + + return jwt.sign( + { + exp: tokenExpiry.unix(), + nbf: moment(model.get('published_at')).subtract(10, 'm').unix() + }, + Buffer.from(key.secret, 'hex'), + JWT_OPTIONS + ); +}; /** * @description Normalize model data into scheduler notation. @@ -12,13 +67,14 @@ const Promise = require('bluebird'), * @return {Object} */ _private.normalize = function normalize(options) { - const {model, apiUrl, client} = options; - + const {model, apiUrl, resourceType} = options; + const resource = `${resourceType}s`; + const signedAdminToken = _private.getSignedAdminToken(options); + let url = `${urlUtils.urlJoin(apiUrl, 'schedules', resource, model.get('id'))}/?token=${signedAdminToken}`; return { // NOTE: The scheduler expects a unix timestamp. time: moment(model.get('published_at')).valueOf(), - // @TODO: We are still using API v0.1 - url: `${urlUtils.urlJoin(apiUrl, 'schedules', 'posts', model.get('id'))}?client_id=${client.get('slug')}&client_secret=${client.get('secret')}`, + url: url, extra: { httpMethod: 'PUT', oldTime: model.previous('published_at') ? moment(model.previous('published_at')).valueOf() : null @@ -27,27 +83,27 @@ _private.normalize = function normalize(options) { }; /** - * @description Load the client credentials for v0.1 API. - * - * @TODO: Remove when we drop v0.1. API v2 uses integrations. + * @description Load all scheduled posts/pages from database. * @return {Promise} */ -_private.loadClient = function loadClient() { - return models.Client.findOne({slug: 'ghost-scheduler'}, {columns: ['slug', 'secret']}); -}; - -/** - * @description Load all scheduled posts from database. - * @return {Promise} - */ -_private.loadScheduledPosts = function () { - // TODO: make this version aware? - // const api = require('../../../api'); - // return api.schedules.getScheduled() - // .then((result) => { - // return result.posts || []; - // }); - return Promise.resolve([]); +_private.loadScheduledResources = function () { + const api = require('../../../api'); + // Fetches all scheduled resources(posts/pages) with default API + return Promise.mapSeries(SCHEDULED_RESOURCES, (resourceType) => { + return api.schedules.getScheduled.query({ + options: { + resource: resourceType + } + }).then((result) => { + return result[resourceType] || []; + }); + }).then((results) => { + return SCHEDULED_RESOURCES.reduce(function (obj, entry, index) { + return Object.assign(obj, { + [entry]: results[index] + }); + }, {}); + }); }; /** @@ -56,12 +112,9 @@ _private.loadScheduledPosts = function () { * @return {*} */ exports.init = function init(options = {}) { - return Promise.resolve(); - // TODO: fix once working on scheduler migration to v2 - /*eslint-disable */ const {apiUrl} = options; - let adapter = null, - client = null; + let adapter = null; + let integration = null; if (!Object.keys(options).length) { return Promise.reject(new common.errors.IncorrectUsageError({message: 'post-scheduling: no config was provided'})); @@ -71,9 +124,9 @@ exports.init = function init(options = {}) { return Promise.reject(new common.errors.IncorrectUsageError({message: 'post-scheduling: no apiUrl was provided'})); } - return _private.loadClient() - .then((_client) => { - client = _client; + return _private.getSchedulerIntegration() + .then((_integration) => { + integration = _integration; return localUtils.createAdapter(options); }) .then((_adapter) => { @@ -83,42 +136,38 @@ exports.init = function init(options = {}) { return []; } - return _private.loadScheduledPosts(); + return _private.loadScheduledResources(); }) - .then((scheduledPosts) => { - if (!scheduledPosts.length) { + .then((scheduledResources) => { + if (!Object.keys(scheduledResources).length) { return; } - scheduledPosts.forEach((model) => { - // NOTE: We are using reschedule, because custom scheduling adapter could use a database, which needs to be updated - // and not an in-process implementation! - adapter.reschedule(_private.normalize({model, apiUrl, client}), {bootstrap: true}); + // Reschedules all scheduled resources on boot + // NOTE: We are using reschedule, because custom scheduling adapter could use a database, which needs to be updated + // and not an in-process implementation! + Object.keys(scheduledResources).forEach((resourceType) => { + scheduledResources[resourceType].forEach((model) => { + adapter.reschedule(_private.normalize({model, apiUrl, integration, resourceType}), {bootstrap: true}); + }); }); }) .then(() => { adapter.run(); }) .then(() => { - common.events.onMany([ - 'post.scheduled', - 'page.scheduled' - ], (model) => { - adapter.schedule(_private.normalize({model, apiUrl, client})); - }); + SCHEDULED_RESOURCES.forEach((resource) => { + common.events.on(`${resource}.scheduled`, (model) => { + adapter.schedule(_private.normalize({model, apiUrl, integration, resourceType: resource})); + }); - common.events.onMany([ - 'post.rescheduled', - 'page.rescheduled' - ], (model) => { - adapter.reschedule(_private.normalize({model, apiUrl, client})); - }); + common.events.on(`${resource}.rescheduled`, (model) => { + adapter.reschedule(_private.normalize({model, apiUrl, integration, resourceType: resource})); + }); - common.events.onMany([ - 'post.unscheduled', - 'page.unscheduled' - ], (model) => { - adapter.unschedule(_private.normalize({model, apiUrl, client})); + common.events.on(`${resource}.unscheduled`, (model) => { + adapter.unschedule(_private.normalize({model, apiUrl, integration, resourceType: resource})); + }); }); }); }; diff --git a/core/server/api/canary/schedules.js b/core/server/api/canary/schedules.js index 14440035be..225809d6bd 100644 --- a/core/server/api/canary/schedules.js +++ b/core/server/api/canary/schedules.js @@ -111,18 +111,16 @@ module.exports = { } }, query(frame) { - const resourceType = frame.options.resource; - const resourceModel = (resourceType === 'posts') ? 'Post' : 'Page'; - + const resourceModel = 'Post'; + const resourceType = (frame.options.resource === 'post') ? 'post' : 'page'; const cleanOptions = {}; - cleanOptions.filter = 'status:scheduled'; - cleanOptions.columns = ['id', 'published_at', 'created_at']; + cleanOptions.filter = `status:scheduled+type:${resourceType}`; + cleanOptions.columns = ['id', 'published_at', 'created_at', 'type']; return models[resourceModel].findAll(cleanOptions) .then((result) => { let response = {}; response[resourceType] = result; - return response; }); } diff --git a/core/server/api/v2/schedules.js b/core/server/api/v2/schedules.js index 14440035be..225809d6bd 100644 --- a/core/server/api/v2/schedules.js +++ b/core/server/api/v2/schedules.js @@ -111,18 +111,16 @@ module.exports = { } }, query(frame) { - const resourceType = frame.options.resource; - const resourceModel = (resourceType === 'posts') ? 'Post' : 'Page'; - + const resourceModel = 'Post'; + const resourceType = (frame.options.resource === 'post') ? 'post' : 'page'; const cleanOptions = {}; - cleanOptions.filter = 'status:scheduled'; - cleanOptions.columns = ['id', 'published_at', 'created_at']; + cleanOptions.filter = `status:scheduled+type:${resourceType}`; + cleanOptions.columns = ['id', 'published_at', 'created_at', 'type']; return models[resourceModel].findAll(cleanOptions) .then((result) => { let response = {}; response[resourceType] = result; - return response; }); } diff --git a/core/server/index.js b/core/server/index.js index 574373122c..53b34d1463 100644 --- a/core/server/index.js +++ b/core/server/index.js @@ -45,7 +45,7 @@ function initialiseServices() { active: config.get('scheduling').active, // NOTE: When changing API version need to consider how to migrate custom scheduling adapters // that rely on URL to lookup persisted scheduled records (jobs, etc.). Ref: https://github.com/TryGhost/Ghost/pull/10726#issuecomment-489557162 - apiUrl: urlUtils.urlFor('api', {version: 'v2', versionType: 'content'}, true), + apiUrl: urlUtils.urlFor('api', {version: 'v2', versionType: 'admin'}, true), internalPath: config.get('paths').internalSchedulingPath, contentPath: config.getContentPath('scheduling') }) diff --git a/core/server/services/auth/api-key/admin.js b/core/server/services/auth/api-key/admin.js index ce897f2d67..be55056f54 100644 --- a/core/server/services/auth/api-key/admin.js +++ b/core/server/services/auth/api-key/admin.js @@ -2,10 +2,11 @@ const jwt = require('jsonwebtoken'); const url = require('url'); const models = require('../../../models'); const common = require('../../../lib/common'); +const _ = require('lodash'); -const JWT_OPTIONS = { - maxAge: '5m', - algorithms: ['HS256'] +let JWT_OPTIONS = { + algorithms: ['HS256'], + maxAge: '5m' }; /** @@ -21,9 +22,50 @@ const _extractTokenFromHeader = function extractTokenFromHeader(header) { } }; +/** + * Extract JWT token from admin API URL query + * Eg. ${ADMIN_API_URL}/?token=${JWT} + * @param {string} reqUrl + */ +const _extractTokenFromUrl = function extractTokenFromUrl(reqUrl) { + const {query} = url.parse(reqUrl, true); + return query.token; +}; + +const authenticate = (req, res, next) => { + // CASE: 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) { + req.api_key = null; + 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' + })); + } + + return authenticateWithToken(req, res, next, {token, JWT_OPTIONS}); +}; + +const authenticateWithUrl = (req, res, next) => { + const token = _extractTokenFromUrl(req.originalUrl); + if (!token) { + return next(new common.errors.UnauthorizedError({ + message: common.i18n.t('errors.middleware.auth.invalidTokenWithMessage', {message: 'No token found in URL'}), + code: 'INVALID_JWT' + })); + } + // CASE: Scheduler publish URLs can have long maxAge but controllerd by expiry and neverBefore + return authenticateWithToken(req, res, next, {token, JWT_OPTIONS: _.omit(JWT_OPTIONS, 'maxAge')}); +}; + /** * Admin API key authentication flow: - * 1. extract the JWT token from the `Authorization: Ghost xxxx` header + * 1. extract the JWT token from the `Authorization: Ghost xxxx` header or from URL(for schedules) * 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) @@ -35,23 +77,7 @@ const _extractTokenFromHeader = function extractTokenFromHeader(header) { * - the "Audience" claim should match the requested API path * https://tools.ietf.org/html/rfc7519#section-4.1.3 */ -const authenticate = (req, res, next) => { - // CASE: 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) { - req.api_key = null; - 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 authenticateWithToken = (req, res, next, {token, JWT_OPTIONS}) => { const decoded = jwt.decode(token, {complete: true}); if (!decoded || !decoded.header) { @@ -123,5 +149,6 @@ const authenticate = (req, res, next) => { }; module.exports = { - authenticate + authenticate, + authenticateWithUrl }; diff --git a/core/server/services/auth/authenticate.js b/core/server/services/auth/authenticate.js index ab5f211199..5d60bf1722 100644 --- a/core/server/services/auth/authenticate.js +++ b/core/server/services/auth/authenticate.js @@ -4,6 +4,7 @@ const members = require('./members'); const authenticate = { authenticateAdminApi: [apiKeyAuth.admin.authenticate, session.authenticate], + authenticateAdminApiWithUrl: [apiKeyAuth.admin.authenticateWithUrl], authenticateContentApi: [apiKeyAuth.content.authenticateContentApiKey, members.authenticateMembersToken] }; diff --git a/core/server/web/api/canary/admin/middleware.js b/core/server/web/api/canary/admin/middleware.js index 677722e1a3..e6a312fac7 100644 --- a/core/server/web/api/canary/admin/middleware.js +++ b/core/server/web/api/canary/admin/middleware.js @@ -57,6 +57,20 @@ module.exports.authAdminApi = [ notImplemented ]; +/** + * Authentication for private endpoints with token in URL + * Ex.: For scheduler publish endpoint + */ +module.exports.authAdminApiWithUrl = [ + auth.authenticate.authenticateAdminApiWithUrl, + auth.authorize.authorizeAdminApi, + shared.middlewares.updateUserLastSeen, + shared.middlewares.api.cors, + shared.middlewares.urlRedirects.adminRedirect, + shared.middlewares.prettyUrls, + notImplemented +]; + /** * Middleware for public admin endpoints */ diff --git a/core/server/web/api/canary/admin/routes.js b/core/server/web/api/canary/admin/routes.js index 43f80b0108..6224763388 100644 --- a/core/server/web/api/canary/admin/routes.js +++ b/core/server/web/api/canary/admin/routes.js @@ -48,7 +48,7 @@ module.exports = function apiRoutes() { router.del('/integrations/:id', mw.authAdminApi, http(apiCanary.integrations.destroy)); // ## Schedules - router.put('/schedules/:resource/:id', mw.authAdminApi, http(apiCanary.schedules.publish)); + router.put('/schedules/:resource/:id', mw.authAdminApiWithUrl, http(apiCanary.schedules.publish)); // ## Settings router.get('/settings/routes/yaml', mw.authAdminApi, http(apiCanary.settings.download)); diff --git a/core/server/web/api/v2/admin/middleware.js b/core/server/web/api/v2/admin/middleware.js index 342ff0f399..57827fb0c8 100644 --- a/core/server/web/api/v2/admin/middleware.js +++ b/core/server/web/api/v2/admin/middleware.js @@ -56,6 +56,20 @@ module.exports.authAdminApi = [ notImplemented ]; +/** + * Authentication for private endpoints with token in URL + * Ex.: For scheduler publish endpoint + */ +module.exports.authAdminApiWithUrl = [ + auth.authenticate.authenticateAdminApiWithUrl, + auth.authorize.authorizeAdminApi, + shared.middlewares.updateUserLastSeen, + shared.middlewares.api.cors, + shared.middlewares.urlRedirects.adminRedirect, + shared.middlewares.prettyUrls, + notImplemented +]; + /** * Middleware for public admin endpoints */ diff --git a/core/server/web/api/v2/admin/routes.js b/core/server/web/api/v2/admin/routes.js index 11956cb646..cecd3d9248 100644 --- a/core/server/web/api/v2/admin/routes.js +++ b/core/server/web/api/v2/admin/routes.js @@ -48,7 +48,7 @@ module.exports = function apiRoutes() { router.del('/integrations/:id', mw.authAdminApi, http(apiv2.integrations.destroy)); // ## Schedules - router.put('/schedules/:resource/:id', mw.authAdminApi, http(apiv2.schedules.publish)); + router.put('/schedules/:resource/:id', mw.authAdminApiWithUrl, http(apiv2.schedules.publish)); // ## Settings router.get('/settings/routes/yaml', mw.authAdminApi, http(apiv2.settings.download));