🔒 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:
parent
3358ba305b
commit
75bb53f065
@ -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);
|
||||
|
30
ghost/admin/app/components/members/modals/logout-member.hbs
Normal file
30
ghost/admin/app/components/members/modals/logout-member.hbs
Normal 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>
|
31
ghost/admin/app/components/members/modals/logout-member.js
Normal file
31
ghost/admin/app/components/members/modals/logout-member.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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()
|
||||
});
|
||||
|
@ -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"
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
});
|
@ -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
|
||||
}
|
||||
);
|
@ -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});
|
@ -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: {
|
||||
|
@ -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
|
||||
|
@ -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));
|
||||
|
@ -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));
|
||||
|
@ -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(
|
||||
|
@ -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 \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -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 \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -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 () {
|
||||
|
@ -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 \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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');
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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({
|
||||
|
Loading…
Reference in New Issue
Block a user