🔒 Added support for logging out members on all devices (#18935)

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
This commit is contained in:
Simon Backx 2023-11-15 17:10:28 +01:00 committed by GitHub
parent 3358ba305b
commit 75bb53f065
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 365 additions and 32 deletions

View File

@ -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);

View File

@ -0,0 +1,30 @@
<div class="modal-content" data-test-modal="logout-member">
<header class="modal-header">
<h1>Sign out member from all devices?</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" (fn @close false)}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p class="mb6">
<strong>{{or @data.member.name @data.member.email}}</strong> will be signed out of all active sessions, and will need to sign in again upon return.
</p>
</div>
<div class="modal-footer">
<button
type="button"
class="gh-btn"
{{on "click" (fn @close false)}}
data-test-button="cancel"
>
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText="Sign out"
@successText="Signed out"
@task={{this.logoutMemberTask}}
@class="gh-btn gh-btn-red gh-btn-icon"
data-test-button="confirm"
/>
</div>
</div>

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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()
});

View File

@ -67,6 +67,16 @@
<span>Impersonate</span>
</button>
</li>
<li>
<button
type="button"
class="mr2"
{{on "click" this.confirmLogoutMember}}
data-test-button="logout-member"
>
<span>Sign out of all devices</span>
</button>
</li>
<li>
<button
type="button"

View File

@ -143,6 +143,31 @@ module.exports = {
}
},
logout: {
statusCode: 204,
headers: {
cacheInvalidate: false
},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: {
method: 'edit'
},
async query(frame) {
const member = await membersService.api.memberBREADService.logout(frame.options);
return member;
}
},
editSubscription: {
statusCode: 200,
headers: {

View File

@ -107,7 +107,7 @@ const feedbackEventMapper = (json, frame) => {
} else {
response.post = null;
}
if (data.member) {
response.member = _.pick(data.member, memberFields);
} else {
@ -159,6 +159,12 @@ const activityFeedMapper = (event, frame) => {
if (event.data?.subscriptionCreatedEvent) {
delete event.data.subscriptionCreatedEvent;
}
if (event.data.member) {
event.data.member = _.pick(event.data.member, memberFields);
} else {
event.data.member = null;
}
return event;
};

View File

@ -0,0 +1,9 @@
const {createAddColumnMigration} = require('../../utils');
// First make a nullable column
module.exports = createAddColumnMigration('members', 'transient_id', {
type: 'string',
maxlength: 191,
nullable: true,
unique: true
});

View File

@ -0,0 +1,17 @@
const logging = require('@tryghost/logging');
const {createTransactionalMigration} = require('../../utils');
module.exports = createTransactionalMigration(
async function up(knex) {
logging.info('Setting transient_id column to email');
const updatedRows = await knex('members')
.update('transient_id', knex.raw('email'));
logging.info(`Updated ${updatedRows} members with transient_id = email`);
},
async function down() {
// Not required
}
);

View File

@ -0,0 +1,4 @@
const {createDropNullableMigration} = require('../../utils');
// We need to disable foreign key checks because if MySQL is missing the STRICT_TRANS_TABLES mode, we cannot revert the migration
module.exports = createDropNullableMigration('members', 'transient_id', {disableForeignKeyChecks: true});

View File

@ -417,6 +417,7 @@ module.exports = {
members: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
uuid: {type: 'string', maxlength: 36, nullable: true, unique: true, validations: {isUUID: true}},
transient_id: {type: 'string', maxlength: 191, nullable: false, unique: true},
email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},
status: {
type: 'string', maxlength: 50, nullable: false, defaultTo: 'free', validations: {

View File

@ -11,6 +11,7 @@ const Member = ghostBookshelf.Model.extend({
return {
status: 'free',
uuid: uuid.v4(),
transient_id: uuid.v4(),
email_count: 0,
email_opened_count: 0,
enable_comment_notifications: true

View File

@ -144,6 +144,7 @@ module.exports = function apiRoutes() {
router.get('/members/:id', mw.authAdminApi, http(api.members.read));
router.put('/members/:id', mw.authAdminApi, http(api.members.edit));
router.del('/members/:id', mw.authAdminApi, http(api.members.destroy));
router.del('/members/:id/sessions', mw.authAdminApi, http(api.members.logout));
router.post('/members/:id/subscriptions/', mw.authAdminApi, http(api.members.createSubscription));
router.put('/members/:id/subscriptions/:subscription_id', mw.authAdminApi, http(api.members.editSubscription));

View File

@ -4,7 +4,6 @@ const api = require('../../../../api').endpoints;
const {http} = require('@tryghost/api-framework');
const mw = require('./middleware');
const config = require('../../../../../shared/config');
const membersService = require('../../../../../server/services/members');
module.exports = function apiRoutes() {
const router = express.Router('content api');
@ -32,7 +31,7 @@ module.exports = function apiRoutes() {
router.get('/tags/slug/:slug', mw.authenticatePublic, http(api.tagsPublic.read));
// ## Settings
router.get('/settings', mw.authenticatePublic, membersService.middleware.loadMemberSession, http(api.publicSettings.browse));
router.get('/settings', mw.authenticatePublic, http(api.publicSettings.browse));
// ## Members
router.get('/newsletters', mw.authenticatePublic, http(api.newslettersPublic.browse));

View File

@ -51,7 +51,7 @@ module.exports = function setupMembersApp() {
// Manage session
membersApp.get('/api/session', middleware.getIdentityToken);
membersApp.delete('/api/session', middleware.deleteSession);
membersApp.delete('/api/session', bodyParser.json({limit: '5mb'}), middleware.deleteSession);
// NOTE: this is wrapped in a function to ensure we always go via the getter
membersApp.post(

View File

@ -22699,7 +22699,7 @@ exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "20655",
"content-length": "17559",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -23852,7 +23852,7 @@ exports[`Activity Feed API Returns email delivered events in activity feed 2: [h
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1361",
"content-length": "1051",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -23886,7 +23886,7 @@ exports[`Activity Feed API Returns email opened events in activity feed 2: [head
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1358",
"content-length": "1048",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -23944,7 +23944,7 @@ exports[`Activity Feed API Returns email sent events in activity feed 2: [header
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "5096",
"content-length": "3860",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -24204,7 +24204,7 @@ exports[`Activity Feed API Returns signup events in activity feed 2: [headers] 1
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "23603",
"content-length": "21127",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -381,7 +381,7 @@ exports[`Members API - member attribution Returns sign up attributions of all ty
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "9604",
"content-length": "8054",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -4279,6 +4279,19 @@ Object {
}
`;
exports[`Members API Can log out 1: [body] 1`] = `Object {}`;
exports[`Members API Can log out 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin",
"x-powered-by": "Express",
}
`;
exports[`Members API Can order by email_open_rate 1: [body] 1`] = `
Object {
"members": Array [
@ -5275,7 +5288,7 @@ exports[`Members API Can subscribe to a newsletter 5: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "5778",
"content-length": "4538",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -66,6 +66,16 @@ async function getNewsletters() {
return (await models.Newsletter.findAll({filter: 'status:active'})).models;
}
async function createMember(data) {
const member = await models.Member.add({
name: '',
email_disabled: false,
...data
});
return member;
}
const newsletterSnapshot = {
id: anyObjectId
};
@ -2142,6 +2152,28 @@ describe('Members API', function () {
});
});
// Log out
it('Can log out', async function () {
const member = await createMember({
name: 'test',
email: 'member-log-out-test@test.com'
});
const startTransientId = member.get('transient_id');
await agent
.delete(`/members/${member.id}/sessions/`)
.expectStatus(204)
.matchBodySnapshot()
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await member.refresh();
assert.notEqual(member.get('transient_id'), startTransientId, 'The transient_id should have changed');
});
// Delete a member
it('Can destroy', async function () {

View File

@ -429,7 +429,7 @@ exports[`Members API Member attribution Returns subscription created attribution
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "8144",
"content-length": "5664",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -113,7 +113,7 @@ describe('Create Stripe Checkout Session for Donations', function () {
const membersService = require('../../../core/server/services/members');
const member = await membersService.api.members.create({email, name: 'Member Test'});
const token = await membersService.api.getMemberIdentityToken(email);
const token = await membersService.api.getMemberIdentityToken(member.get('transient_id'));
await DomainEvents.allSettled();

View File

@ -32,7 +32,7 @@ describe('Front-end members behavior', function () {
async function loginAsMember(email) {
// Member should exist, because we are signin in
await models.Member.findOne({email}, {require: true});
const member = await models.Member.findOne({email}, {require: true});
// membersService needs to be required after Ghost start so that settings
// are pre-populated with defaults
@ -51,6 +51,8 @@ describe('Front-end members behavior', function () {
should.exist(redirectUrl.searchParams.get('success'));
redirectUrl.searchParams.get('success').should.eql('true');
});
return member;
}
before(async function () {
@ -141,8 +143,8 @@ describe('Front-end members behavior', function () {
it('should error for invalid subscription id on members create update session endpoint', async function () {
const membersService = require('../../core/server/services/members');
const email = 'test-member-create-update-session@email.com';
await membersService.api.members.create({email});
const token = await membersService.api.getMemberIdentityToken(email);
const member = await membersService.api.members.create({email});
const token = await membersService.api.getMemberIdentityToken(member.get('transient_id'));
await request.post('/members/api/create-stripe-update-session')
.send({
identity: token,
@ -541,6 +543,80 @@ describe('Front-end members behavior', function () {
});
});
describe('log out', function () {
let member;
beforeEach(async function () {
member = await loginAsMember('member1@test.com');
});
it('by default only destroys current session', async function () {
const transientId = member.get('transient_id');
// Check logged in
await request.get('/members/api/member')
.expect(200);
await request.del('/members/api/session')
.expect('set-cookie', /ghost-members-ssr=.*;.*?expires=Thu, 01 Jan 1970 00:00:00 GMT;.*?/)
.expect(204);
// Check logged out
await request.get('/members/api/member')
.expect(204);
// Check transient id has NOT changed
await member.refresh();
assert.equal(member.get('transient_id'), transientId);
});
it('can destroy all sessions', async function () {
const transientId = member.get('transient_id');
// Check logged in
await request.get('/members/api/member')
.expect(200);
await request.del('/members/api/session')
.send({
all: true
})
.expect('set-cookie', /ghost-members-ssr=.*;.*?expires=Thu, 01 Jan 1970 00:00:00 GMT;.*?/)
.expect(204);
// Check logged out
await request.get('/members/api/member')
.expect(204);
// Check transient id has changed
await member.refresh();
assert.notEqual(member.get('transient_id'), transientId);
});
it('can destroy only current session', async function () {
const transientId = member.get('transient_id');
// Check logged in
await request.get('/members/api/member')
.expect(200);
await request.del('/members/api/session')
.send({
all: false
})
.expect('set-cookie', /ghost-members-ssr=.*;.*?expires=Thu, 01 Jan 1970 00:00:00 GMT;.*?/)
.expect(204);
// Check logged out
await request.get('/members/api/member')
.expect(204);
// Check transient id has NOT changed
await member.refresh();
assert.equal(member.get('transient_id'), transientId);
});
});
describe('as free member', function () {
before(async function () {
await loginAsMember('member1@test.com');

View File

@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route
*/
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '1c1f476e830ce01a0167229697bd4523';
const currentSchemaHash = '93dc3e803ab6d73462d59da699c46ed9';
const currentFixturesHash = '4db87173699ad9c9d8a67ccab96dfd2d';
const currentSettingsHash = '3128d4ec667a50049486b0c21f04be07';
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';

View File

@ -70,6 +70,7 @@ class MembersImporter extends TableImporter {
return {
id,
uuid: faker.datatype.uuid(),
transient_id: faker.datatype.uuid(),
email: `${name.replace(' ', '.').replace(/[^a-zA-Z0-9]/g, '').toLowerCase()}${faker.datatype.number({min: 1000, max: 9999})}@example.com`,
status: luck(5) ? 'comped' : luck(25) ? 'paid' : 'free',
name: name,

View File

@ -275,8 +275,16 @@ module.exports = function MembersAPI({
return memberBREADService.read({email});
}
async function getMemberIdentityToken(email) {
const member = await getMemberIdentityData(email);
async function getMemberIdentityDataFromTransientId(transientId) {
return memberBREADService.read({transient_id: transientId});
}
async function cycleTransientId(memberId) {
await users.cycleTransientId({id: memberId});
}
async function getMemberIdentityToken(transientId) {
const member = await getMemberIdentityDataFromTransientId(transientId);
if (!member) {
return null;
}
@ -367,7 +375,9 @@ module.exports = function MembersAPI({
middleware,
getMemberDataFromMagicLinkToken,
getMemberIdentityToken,
getMemberIdentityDataFromTransientId,
getMemberIdentityData,
cycleTransientId,
setMemberGeolocationFromIp,
getPublicConfig,
bus,

View File

@ -7,6 +7,7 @@ const {SubscriptionActivatedEvent, MemberCreatedEvent, SubscriptionCreatedEvent,
const ObjectId = require('bson-objectid').default;
const {NotFoundError} = require('@tryghost/errors');
const validator = require('@tryghost/validator');
const uuid = require('uuid');
const messages = {
noStripeConnection: 'Cannot {action} without a Stripe Connection',
@ -200,7 +201,7 @@ module.exports = class MemberRepository {
}
return null;
}
return this._Member.findOne(data, options);
return await this._Member.findOne(data, options);
}
async getByToken(token, options) {
@ -211,6 +212,16 @@ module.exports = class MemberRepository {
}, options);
}
_generateTransientId() {
return uuid.v4();
}
async cycleTransientId({id, email}) {
await this.update({
transient_id: this._generateTransientId()
}, {id, email});
}
/**
* Create a member
* @param {Object} data
@ -252,6 +263,9 @@ module.exports = class MemberRepository {
const memberData = _.pick(data, ['email', 'name', 'note', 'subscribed', 'geolocation', 'created_at', 'products', 'newsletters', 'email_disabled']);
// Generate a random transient_id
memberData.transient_id = await this._generateTransientId();
// Throw error if email is invalid using latest validator
if (!validator.isEmail(memberData.email, {legacy: false})) {
throw new errors.ValidationError({
@ -421,7 +435,8 @@ module.exports = class MemberRepository {
'last_seen_at',
'last_commented_at',
'expertise',
'email_disabled'
'email_disabled',
'transient_id'
]);
// Trim whitespaces from expertise

View File

@ -365,6 +365,10 @@ module.exports = class MemberBREADService {
return this.read({id: model.id}, options);
}
async logout(options) {
await this.memberRepository.cycleTransientId(options);
}
async browse(options) {
const defaultWithRelated = [
'labels',

View File

@ -47,6 +47,7 @@
"jsonwebtoken": "8.5.1",
"lodash": "4.17.21",
"moment": "2.29.4",
"node-jose": "2.2.0"
"node-jose": "2.2.0",
"uuid": "9.0.1"
}
}

View File

@ -18,6 +18,8 @@ const {
/**
* @typedef {object} Member
* @prop {string} id
* @prop {string} transient_id
* @prop {string} email
*/
@ -169,6 +171,18 @@ class MembersSSR {
return api.getMemberIdentityData(email);
}
/**
* @method _getMemberIdentityData
*
* @param {string} transientId
*
* @returns {Promise<Member>} member
*/
async _getMemberIdentityDataFromTransientId(transientId) {
const api = await this._getMembersApi();
return api.getMemberIdentityDataFromTransientId(transientId);
}
/**
* @method _getMemberIdentityToken
*
@ -176,9 +190,9 @@ class MembersSSR {
*
* @returns {Promise<JWT>} member
*/
async _getMemberIdentityToken(email) {
async _getMemberIdentityToken(transientId) {
const api = await this._getMembersApi();
return api.getMemberIdentityToken(email);
return api.getMemberIdentityToken(transientId);
}
/**
@ -235,11 +249,16 @@ class MembersSSR {
}
}
this._setSessionCookie(req, res, member.email);
this._setSessionCookie(req, res, member.transient_id);
return member;
}
async _cycleTransientId(memberId) {
const api = await this._getMembersApi();
return api.cycleTransientId(memberId);
}
/**
* @method deleteSession
* @param {Request} req
@ -248,6 +267,13 @@ class MembersSSR {
* @returns {Promise<void>}
*/
async deleteSession(req, res) {
if (req.body.all) {
// Update transient_id to invalidate all sessions
const member = await this.getMemberDataFromSession(req, res);
if (member) {
await this._cycleTransientId(member.id);
}
}
this._removeSessionCookie(req, res);
}
@ -260,9 +286,8 @@ class MembersSSR {
* @returns {Promise<Member>}
*/
async getMemberDataFromSession(req, res) {
const email = this._getSessionCookies(req, res);
const member = await this._getMemberIdentityData(email);
const transientId = this._getSessionCookies(req, res);
const member = await this._getMemberIdentityDataFromTransientId(transientId);
return member;
}
@ -275,8 +300,8 @@ class MembersSSR {
* @returns {Promise<JWT>} identity token
*/
async getIdentityTokenForMemberFromSession(req, res) {
const email = this._getSessionCookies(req, res);
const token = await this._getMemberIdentityToken(email);
const transientId = this._getSessionCookies(req, res);
const token = await this._getMemberIdentityToken(transientId);
if (!token) {
this.deleteSession(req, res);
throw new BadRequestError({