From a02a43e6fa59c604e3a8ae9ef2de51e7ea725877 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Sat, 23 Feb 2019 04:47:42 +0100 Subject: [PATCH] Improved Members security and performance (#10511) no-issue * Corrected function names for rpc methods * Updated gateway to store tokens locally * Fixed lint * Added hardcoded 30 minute expiry for member tokens * Added default contentApiAccess config; * Updated validateAudience method This is required for security, we need to restrict which domains can access tokens meant for the content api --- core/server/config/defaults.json | 1 + .../lib/members/static/gateway/bundle.js | 80 ++++++++++++++++++- core/server/lib/members/tokens.js | 1 + core/server/services/members/api.js | 20 +++-- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/core/server/config/defaults.json b/core/server/config/defaults.json index b43656b248..35d7789bbf 100644 --- a/core/server/config/defaults.json +++ b/core/server/config/defaults.json @@ -20,6 +20,7 @@ "active": "SchedulingDefault" }, "members": { + "contentApiAccess": [], "paymentProcessors": [] }, "logging": { diff --git a/core/server/lib/members/static/gateway/bundle.js b/core/server/lib/members/static/gateway/bundle.js index 659159c7ed..1605546092 100644 --- a/core/server/lib/members/static/gateway/bundle.js +++ b/core/server/lib/members/static/gateway/bundle.js @@ -1,4 +1,4 @@ -/* global window document location fetch */ +/* global atob window document location fetch */ (function () { if (window.parent === window) { return; @@ -23,9 +23,76 @@ }; } + function isTokenExpired(token) { + try { + const [header, claims, signature] = token.split('.'); // eslint-disable-line no-unused-vars + + const parsedClaims = JSON.parse(atob(claims.replace('+', '-').replace('/', '_'))); + + const expiry = parsedClaims.exp * 1000; + const now = Date.now(); + + const nearFuture = now + (30 * 1000); + + if (expiry > nearFuture) { + return true; + } + + return false; + } catch (e) { + return true; + } + } + + function getStoredToken(audience) { + const tokenKey = 'members:token:aud:' + audience; + const storedToken = storage.getItem(tokenKey); + if (isTokenExpired(storedToken)) { + storage.removeItem(tokenKey); + return null; + } + return storedToken; + } + + function getStoredTokenKeys() { + try { + return JSON.parse(storage.getItem('members:tokens') || '[]'); + } catch (e) { + storage.removeItem('members:tokens'); + return []; + } + } + + function addStoredToken(audience, token) { + const storedTokenKeys = getStoredTokenKeys(); + const tokenKey = 'members:token:aud:' + audience; + + storage.setItem(tokenKey, token); + if (!storedTokenKeys.includes(tokenKey)) { + storage.setItem('members:tokens', JSON.stringify(storedTokenKeys.concat(tokenKey))); + } + } + + function clearStorage() { + storage.removeItem('signedin'); + const storedTokenKeys = getStoredTokenKeys(); + + storedTokenKeys.forEach(function (key) { + storage.removeItem(key); + }); + + storage.removeItem('members:tokens'); + } + // @TODO this needs to be configurable const membersApi = location.pathname.replace(/\/members\/gateway\/?$/, '/ghost/api/v2/members'); function getToken({audience}) { + const storedToken = getStoredToken(audience); + + if (storedToken) { + return Promise.resolve(storedToken); + } + return fetch(`${membersApi}/token`, { method: 'POST', headers: { @@ -44,6 +111,11 @@ } storage.setItem('signedin', true); return res.text(); + }).then(function (token) { + if (token) { + addStoredToken(audience, token); + } + return token; }); } @@ -130,13 +202,13 @@ }) }).then((res) => { if (res.ok) { - storage.removeItem('signedin'); + clearStorage(); } return res.ok; }); }); - addMethod('requestPasswordReset', function signout({email}) { + addMethod('requestPasswordReset', function requestPasswordReset({email}) { return fetch(`${membersApi}/request-password-reset`, { method: 'POST', headers: { @@ -151,7 +223,7 @@ }); }); - addMethod('resetPassword', function signout({token, password}) { + addMethod('resetPassword', function resetPassword({token, password}) { return fetch(`${membersApi}/reset-password`, { method: 'POST', headers: { diff --git a/core/server/lib/members/tokens.js b/core/server/lib/members/tokens.js index d1655ff604..b72ae533a3 100644 --- a/core/server/lib/members/tokens.js +++ b/core/server/lib/members/tokens.js @@ -17,6 +17,7 @@ module.exports = function ({ }, privateKey, { algorithm: 'RS512', audience: aud, + expiresIn: '30m', issuer })); } diff --git a/core/server/services/members/api.js b/core/server/services/members/api.js index 4fbf54f21f..7e149e41be 100644 --- a/core/server/services/members/api.js +++ b/core/server/services/members/api.js @@ -59,14 +59,6 @@ function validateMember({email, password}) { }); } -// @TODO this should check some config/settings and return Promise.reject by default -function validateAudience({audience, origin}) { - if (audience === origin) { - return Promise.resolve(); - } - return Promise.resolve(); -} - const publicKey = settingsCache.get('members_public_key'); const privateKey = settingsCache.get('members_private_key'); const sessionSecret = settingsCache.get('members_session_secret'); @@ -79,6 +71,18 @@ let mailer; const membersConfig = config.get('members'); +function validateAudience({audience, origin}) { + if (audience === origin) { + return Promise.resolve(); + } + if (audience === siteOrigin) { + if (membersConfig.contentApiAccess.includes(origin)) { + return Promise.resolve(); + } + } + return Promise.reject(); +} + function sendEmail(member, {token}) { if (!(mailer instanceof mail.GhostMailer)) { mailer = new mail.GhostMailer();