From 75bb53f0659923aa0446aab045d6e920c7633457 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Wed, 15 Nov 2023 17:10:28 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20Added=20support=20for=20logging?= =?UTF-8?q?=20out=20members=20on=20all=20devices=20(#18935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes https://github.com/TryGhost/Product/issues/3738 https://www.notion.so/ghost/Member-Session-Invalidation-13254316f2244c34bcbc65c101eb5cc4 - Adds the transient_id column to the members table. This defaults to email, to keep it backwards compatible (not logging out all existing sessions) - Instead of using the email in the cookies, we now use the transient_id - Updating the transient_id means invalidating all sessions of a member - Adds an endpoint to the admin api to log out a member from all devices - Added the `all` body property to the DELETE session endpoint in the members API. Setting it to true will sign a member out from all devices. - Adds a UI button in Admin to sign a member out from all devices - Portal 'sign out of all devices' will not be added for now Related changes (added because these areas were affected by the code changes): - Adds a serializer to member events / activity feed endpoints - all member fields were returned here, so the transient_id would also be returned - which is not needed and bloats the API response size (`transient_id` is not a secret because the cookies are signed) - Removed `loadMemberSession` from public settings browse (not used anymore + bad pattern) Performance tests on site with 50.000 members (on Macbook M1 Pro): - Migrate: 6s (adding column 4s, setting to email is 1s, dropping nullable: 1s) - Rollback: 2s --- apps/portal/src/utils/api.js | 10 ++- .../members/modals/logout-member.hbs | 30 +++++++ .../members/modals/logout-member.js | 31 +++++++ ghost/admin/app/controllers/member.js | 11 +++ ghost/admin/app/models/member.js | 5 ++ ghost/admin/app/templates/member.hbs | 10 +++ .../core/core/server/api/endpoints/members.js | 25 ++++++ .../output/mappers/activity-feed-events.js | 8 +- ...-15-00-add-transient-id-column-nullable.js | 9 ++ ...11-14-11-16-00-fill-transient-id-column.js | 17 ++++ ...17-00-drop-nullable-transient-id-column.js | 4 + ghost/core/core/server/data/schema/schema.js | 1 + ghost/core/core/server/models/member.js | 1 + .../server/web/api/endpoints/admin/routes.js | 1 + .../web/api/endpoints/content/routes.js | 3 +- ghost/core/core/server/web/members/app.js | 2 +- .../__snapshots__/activity-feed.test.js.snap | 10 +-- .../admin/__snapshots__/members.test.js.snap | 17 +++- ghost/core/test/e2e-api/admin/members.test.js | 32 ++++++++ .../__snapshots__/webhooks.test.js.snap | 2 +- .../members/donation-checkout-session.test.js | 2 +- ghost/core/test/e2e-frontend/members.test.js | 82 ++++++++++++++++++- .../unit/server/data/schema/integrity.test.js | 2 +- .../lib/importers/MembersImporter.js | 1 + ghost/members-api/lib/members-api.js | 14 +++- .../lib/repositories/MemberRepository.js | 19 ++++- .../lib/services/MemberBREADService.js | 4 + ghost/members-api/package.json | 3 +- ghost/members-ssr/lib/members-ssr.js | 41 ++++++++-- 29 files changed, 365 insertions(+), 32 deletions(-) create mode 100644 ghost/admin/app/components/members/modals/logout-member.hbs create mode 100644 ghost/admin/app/components/members/modals/logout-member.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.74/2023-11-14-11-15-00-add-transient-id-column-nullable.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.74/2023-11-14-11-16-00-fill-transient-id-column.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.74/2023-11-14-11-17-00-drop-nullable-transient-id-column.js diff --git a/apps/portal/src/utils/api.js b/apps/portal/src/utils/api.js index e6478631c8..5ecc72430b 100644 --- a/apps/portal/src/utils/api.js +++ b/apps/portal/src/utils/api.js @@ -282,11 +282,17 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { } }, - signout() { + signout(all = false) { const url = endpointFor({type: 'members', resource: 'session'}); return makeRequest({ url, - method: 'DELETE' + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + all + }) }).then(function (res) { if (res.ok) { window.location.replace(siteUrl); diff --git a/ghost/admin/app/components/members/modals/logout-member.hbs b/ghost/admin/app/components/members/modals/logout-member.hbs new file mode 100644 index 0000000000..824fd5615a --- /dev/null +++ b/ghost/admin/app/components/members/modals/logout-member.hbs @@ -0,0 +1,30 @@ + diff --git a/ghost/admin/app/components/members/modals/logout-member.js b/ghost/admin/app/components/members/modals/logout-member.js new file mode 100644 index 0000000000..1fe9cd28db --- /dev/null +++ b/ghost/admin/app/components/members/modals/logout-member.js @@ -0,0 +1,31 @@ +import Component from '@glimmer/component'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export default class LogoutMemberModal extends Component { + @service notifications; + @service ajax; + @service ghostPaths; + + get member() { + return this.args.data.member; + } + + @task({drop: true}) + *logoutMemberTask() { + try { + const url = this.ghostPaths.url.api('/members/', this.member.id, '/sessions/'); + const options = {}; + yield this.ajax.delete(url, options); + + this.args.data.afterLogout?.(); + this.notifications.showNotification(`${this.member.name || this.member.email} has been successfully signed out from all devices.`, {type: 'success'}); + this.args.close(true); + return true; + } catch (e) { + this.notifications.showAPIError(e, {key: 'member.logout'}); + this.args.close(false); + throw e; + } + } +} diff --git a/ghost/admin/app/controllers/member.js b/ghost/admin/app/controllers/member.js index f64cf52edb..1ec80427ff 100644 --- a/ghost/admin/app/controllers/member.js +++ b/ghost/admin/app/controllers/member.js @@ -1,6 +1,7 @@ import Controller, {inject as controller} from '@ember/controller'; import DeleteMemberModal from '../components/members/modals/delete-member'; import EmberObject, {action, defineProperty} from '@ember/object'; +import LogoutMemberModal from '../components/members/modals/logout-member'; import boundOneWay from 'ghost-admin/utils/bound-one-way'; import moment from 'moment-timezone'; import {inject as service} from '@ember/service'; @@ -143,6 +144,16 @@ export default class MemberController extends Controller { }); } + @action + confirmLogoutMember() { + this.modals.open(LogoutMemberModal, { + member: this.member, + afterLogout: () => { + this.members.refreshData(); + } + }); + } + @action toggleImpersonateMemberModal() { this.showImpersonateMemberModal = !this.showImpersonateMemberModal; diff --git a/ghost/admin/app/models/member.js b/ghost/admin/app/models/member.js index 06945044ed..a965705df9 100644 --- a/ghost/admin/app/models/member.js +++ b/ghost/admin/app/models/member.js @@ -49,5 +49,10 @@ export default Model.extend(ValidationEngine, { let response = yield this.ajax.request(url); return response.member_signin_urls[0]; + }).drop(), + + logoutAllDevices: task(function* () { + let url = this.get('ghostPaths.url').api('members', this.id, 'signout'); + yield this.ajax.post(url); }).drop() }); diff --git a/ghost/admin/app/templates/member.hbs b/ghost/admin/app/templates/member.hbs index a5b7186ee1..3a7cf1aada 100644 --- a/ghost/admin/app/templates/member.hbs +++ b/ghost/admin/app/templates/member.hbs @@ -67,6 +67,16 @@ Impersonate +
  • + +