@stamhoofd/backend 2.137.5 → 2.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/package.json +20 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/index.ts +1 -0
  6. package/src/crons/invoices.ts +5 -3
  7. package/src/email-recipient-loaders/orders.ts +2 -1
  8. package/src/email-recipient-loaders/organizations.ts +3 -2
  9. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  10. package/src/endpoints/auth/CreateAdminEndpoint.ts +2 -1
  11. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  12. package/src/endpoints/auth/CreateTokenEndpoint.ts +133 -6
  13. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  14. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  15. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  16. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -1
  17. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  18. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  19. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  20. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  21. package/src/endpoints/auth/MFA.test.ts +1398 -0
  22. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  23. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  24. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  25. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  26. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  27. package/src/endpoints/auth/SignupEndpoint.ts +13 -2
  28. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  29. package/src/endpoints/global/email/CreateEmailEndpoint.ts +3 -2
  30. package/src/endpoints/global/email/PatchEmailEndpoint.ts +3 -2
  31. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  32. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  33. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +2 -1
  34. package/src/endpoints/global/files/UploadFile.ts +1 -1
  35. package/src/endpoints/global/files/UploadImage.ts +1 -1
  36. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  37. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  38. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  39. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  40. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  41. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  42. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  43. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  44. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  45. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  46. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  47. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  48. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  49. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  50. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  51. package/src/helpers/AuthenticatedStructures.ts +9 -1
  52. package/src/helpers/Context.ts +114 -4
  53. package/src/helpers/EmailBuilder.test.ts +287 -0
  54. package/src/helpers/EmailBuilder.ts +811 -0
  55. package/src/helpers/EmailResumer.ts +2 -1
  56. package/src/helpers/ForwardHandler.ts +2 -1
  57. package/src/helpers/MFAEncryption.ts +34 -0
  58. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  59. package/src/helpers/TOTPHelper.ts +79 -0
  60. package/src/helpers/TenantContext.test.ts +96 -0
  61. package/src/helpers/TenantContext.ts +67 -0
  62. package/src/helpers/TwoFactorHelper.ts +356 -0
  63. package/src/helpers/WebauthnHelper.ts +211 -0
  64. package/src/helpers/data/aaguids.json +1006 -0
  65. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  66. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  67. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  68. package/src/services/AdminSessionService.ts +41 -0
  69. package/src/services/EmailPreviewService.ts +1 -1
  70. package/src/services/EmailSendService.test.ts +1218 -0
  71. package/src/services/EmailSendService.ts +705 -0
  72. package/src/services/EventNotificationService.ts +2 -1
  73. package/src/services/InvoicePdfService.ts +3 -3
  74. package/src/services/InvoiceService.ts +4 -2
  75. package/src/services/InvoiceXMLService.ts +2 -1
  76. package/src/services/OrderService.ts +2 -1
  77. package/src/services/OrganizationAdminService.test.ts +47 -0
  78. package/src/services/OrganizationAdminService.ts +139 -0
  79. package/src/services/OrganizationEmailService.ts +3 -2
  80. package/src/services/PaymentService.ts +5 -3
  81. package/src/services/ReferralService.ts +3 -2
  82. package/src/services/RegistrationService.ts +2 -1
  83. package/src/services/SSOService.ts +106 -14
  84. package/src/services/STPackageService.ts +4 -2
  85. package/src/services/TwoFactorAuditLogService.ts +64 -0
  86. package/src/services/VerificationCodeService.ts +2 -1
  87. package/tests/e2e/api-rate-limits.test.ts +1 -1
  88. package/tests/helpers/MFATestHelper.ts +42 -0
  89. package/tests/helpers/TestServer.ts +4 -0
  90. package/tests/helpers/index.ts +1 -0
  91. package/tsconfig.build.json +2 -1
@@ -0,0 +1,43 @@
1
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
+ import { CountResponse } from '@stamhoofd/structures';
4
+
5
+ import { Context } from '../../../helpers/Context.js';
6
+ import { AdminSessionService } from '../../../services/AdminSessionService.js';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = undefined;
10
+ type Body = undefined;
11
+ type ResponseBody = CountResponse;
12
+
13
+ /**
14
+ * End the sessions of all administrators of the platform, so they have to sign in again.
15
+ * Used after making two-factor authentication required, which is only enforced on new
16
+ * logins.
17
+ */
18
+ export class SignOutPlatformAdminsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
19
+ protected doesMatch(request: Request): [true, Params] | [false] {
20
+ if (request.method !== 'POST') {
21
+ return [false];
22
+ }
23
+
24
+ const params = Endpoint.parseParameters(request.url, '/platform/admins/sign-out', {});
25
+
26
+ if (params) {
27
+ return [true, params as Params];
28
+ }
29
+ return [false];
30
+ }
31
+
32
+ async handle(_: DecodedRequest<Params, Query, Body>) {
33
+ const { token } = await Context.authenticate();
34
+
35
+ if (!Context.auth.canManagePlatformAdmins()) {
36
+ throw Context.auth.error();
37
+ }
38
+
39
+ const count = await AdminSessionService.signOutPlatformAdmins(token.accessToken);
40
+
41
+ return new Response(CountResponse.create({ count }));
42
+ }
43
+ }
@@ -166,6 +166,7 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
166
166
  organization.privateMeta.balanceNotificationSettings = patchObject(organization.privateMeta.balanceNotificationSettings, request.body.privateMeta.balanceNotificationSettings);
167
167
  organization.privateMeta.invoiceSettings = patchObject(organization.privateMeta.invoiceSettings, request.body.privateMeta.invoiceSettings);
168
168
  organization.privateMeta.recordAnswers = request.body.privateMeta.recordAnswers.applyTo(organization.privateMeta.recordAnswers);
169
+ organization.privateMeta.requireTwoFactor = patchObject(organization.privateMeta.requireTwoFactor, request.body.privateMeta.requireTwoFactor);
169
170
 
170
171
  if (request.body.privateMeta.responsibilities || request.body.privateMeta.roles) {
171
172
  shouldUpdateUserPermissions = true;
@@ -13,6 +13,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
13
13
  import { PaymentMandateService } from '../../../../services/PaymentMandateService.js';
14
14
  import { BalanceItemService } from '../../../../services/BalanceItemService.js';
15
15
  import { QueueHandler } from '@stamhoofd/queues';
16
+ import { OrganizationAdminService } from '../../../../services/OrganizationAdminService.js';
16
17
 
17
18
  type Params = Record<string, never>;
18
19
  type Query = undefined;
@@ -121,11 +122,11 @@ export class ChargeReceivableBalancesEndpoint extends Endpoint<Params, Query, Bo
121
122
  continue;
122
123
  }
123
124
 
124
- customerUser = customerUser ?? (payingOrganization ? (await payingOrganization.getFullAdmins())[0] : null);
125
+ customerUser = customerUser ?? (payingOrganization ? (await OrganizationAdminService.getFullAdmins(payingOrganization))[0] : null);
125
126
  const customer = PaymentCustomer.create({
126
127
  firstName: customerUser?.firstName,
127
128
  lastName: customerUser?.lastName,
128
- email: customerUser?.email ?? (payingOrganization ? (await payingOrganization.getReplyEmails())[0].email : null),
129
+ email: customerUser?.email ?? (payingOrganization ? (await OrganizationAdminService.getReplyEmails(payingOrganization))[0].email : null),
129
130
  company: payingOrganization?.defaultCompanies[0],
130
131
  });
131
132
 
@@ -15,6 +15,35 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
15
15
  TestUtils.setEnvironment('userMode', 'platform');
16
16
  });
17
17
 
18
+ test('A session that did not recently authenticate cannot create a key', async () => {
19
+ // An API key is not covered by two-factor authentication and does not expire, so a
20
+ // stolen session must not be enough to mint one: the user has to prove who they
21
+ // are again first.
22
+ const organization = await new OrganizationFactory({}).create();
23
+ const user = await new UserFactory({
24
+ permissions: Permissions.create({
25
+ level: PermissionLevel.Full,
26
+ }),
27
+ organization,
28
+ }).create();
29
+
30
+ // A token as produced by a refresh_token rotation (never authenticatedAt).
31
+ const token = await Token.createToken(user);
32
+
33
+ const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
34
+ permissions: UserPermissions.create({
35
+ organizationPermissions: new Map([
36
+ [organization.id, Permissions.create({ level: PermissionLevel.Read })],
37
+ ]),
38
+ }),
39
+ }));
40
+ createRequest.headers.authorization = 'Bearer ' + token.accessToken;
41
+
42
+ await expect(testServer.test(endpoint, createRequest)).rejects.toThrow(STExpect.simpleError({
43
+ code: 'require_fresh_auth',
44
+ }));
45
+ });
46
+
18
47
  test('Only a platform admin can set the rate limits of a key', async () => {
19
48
  const organization = await new OrganizationFactory({}).create();
20
49
  const user = await new UserFactory({
@@ -22,7 +51,7 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
22
51
  level: PermissionLevel.Full,
23
52
  }),
24
53
  }).create();
25
- const token = await Token.createToken(user);
54
+ const token = await Token.createToken(user, new Date());
26
55
 
27
56
  const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
28
57
  permissions: UserPermissions.create({
@@ -49,7 +78,7 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
49
78
  }),
50
79
  organization,
51
80
  }).create();
52
- const token = await Token.createToken(user);
81
+ const token = await Token.createToken(user, new Date());
53
82
 
54
83
  const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
55
84
  permissions: UserPermissions.create({
@@ -76,7 +105,7 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
76
105
  }),
77
106
  organization,
78
107
  }).create();
79
- const token = await Token.createToken(user);
108
+ const token = await Token.createToken(user, new Date());
80
109
 
81
110
  const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
82
111
  permissions: UserPermissions.create({
@@ -29,7 +29,10 @@ export class CreateApiUserEndpoint extends Endpoint<Params, Query, Body, Respons
29
29
 
30
30
  async handle(request: DecodedRequest<Params, Query, Body>) {
31
31
  const organization = await Context.setOrganizationScope();
32
- await Context.authenticate();
32
+ // An API key is a long-lived credential that is not covered by two-factor
33
+ // authentication, so creating one requires a recent authentication: a stolen
34
+ // session should not be enough to mint a permanent way back in.
35
+ await Context.authenticateFresh();
33
36
 
34
37
  // Fast throw first (more in depth checking for patches later)
35
38
  if (!await Context.auth.canManageAdmins(organization.id)) {
@@ -0,0 +1,100 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, User } from '@stamhoofd/models';
3
+ import { MFATOTP, OrganizationFactory, Token, UserFactory, WebauthnCredential } from '@stamhoofd/models';
4
+ import { OrganizationAdmins, PermissionLevel, Permissions } from '@stamhoofd/structures';
5
+ import crypto from 'crypto';
6
+
7
+ import { MFATestHelper } from '../../../../../tests/helpers/MFATestHelper.js';
8
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
9
+ import { GetOrganizationAdminsEndpoint } from './GetOrganizationAdminsEndpoint.js';
10
+
11
+ describe('Endpoint.GetOrganizationAdmins', () => {
12
+ const endpoint = new GetOrganizationAdminsEndpoint();
13
+
14
+ async function createAdmin(organization: Organization): Promise<User> {
15
+ return await new UserFactory({
16
+ organization,
17
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
18
+ }).create();
19
+ }
20
+
21
+ async function getAdmins(organization: Organization, user: User) {
22
+ const token = await Token.createToken(user, new Date());
23
+ const request = Request.buildJson('GET', '/organization/admins', organization.getApiHost());
24
+ request.headers.authorization = 'Bearer ' + token.accessToken;
25
+
26
+ const response = await testServer.test(endpoint, request);
27
+ expect(response.body).toBeInstanceOf(OrganizationAdmins);
28
+ return (response.body as OrganizationAdmins).users;
29
+ }
30
+
31
+ test('The last activity of every admin is returned', async () => {
32
+ const organization = await new OrganizationFactory({}).create();
33
+ const user = await new UserFactory({
34
+ organization,
35
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
36
+ }).create();
37
+
38
+ const lastActiveAt = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
39
+ lastActiveAt.setMilliseconds(0);
40
+
41
+ const inactiveAdmin = await new UserFactory({
42
+ organization,
43
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
44
+ }).create();
45
+ inactiveAdmin.lastActiveAt = lastActiveAt;
46
+ await inactiveAdmin.save();
47
+
48
+ const token = await Token.createToken(user, new Date());
49
+ const request = Request.buildJson('GET', '/organization/admins', organization.getApiHost());
50
+ request.headers.authorization = 'Bearer ' + token.accessToken;
51
+
52
+ const response = await testServer.test(endpoint, request);
53
+ expect(response.body).toBeInstanceOf(OrganizationAdmins);
54
+
55
+ const users = (response.body as OrganizationAdmins).users;
56
+ expect(users.find(u => u.id === inactiveAdmin.id)?.lastActiveAt?.getTime()).toEqual(lastActiveAt.getTime());
57
+
58
+ // The user never signed in: the token was created directly.
59
+ expect(users.find(u => u.id === user.id)?.lastActiveAt).toBeNull();
60
+ });
61
+
62
+ test('Whether an admin has two-factor authentication is returned', async () => {
63
+ const organization = await new OrganizationFactory({}).create();
64
+ const me = await createAdmin(organization);
65
+
66
+ const withTotp = await createAdmin(organization);
67
+ await MFATestHelper.addConfirmedTOTP(withTotp);
68
+
69
+ const withPasskey = await createAdmin(organization);
70
+ const credential = new WebauthnCredential();
71
+ credential.userId = withPasskey.id;
72
+ credential.credentialId = 'cred-' + crypto.randomBytes(16).toString('base64url');
73
+ credential.publicKey = crypto.randomBytes(32).toString('base64url');
74
+ credential.name = 'Test passkey';
75
+ await credential.save();
76
+
77
+ const withoutFactors = await createAdmin(organization);
78
+
79
+ const users = await getAdmins(organization, me);
80
+
81
+ expect(users.find(u => u.id === withTotp.id)?.hasTwoFactor).toBe(true);
82
+ expect(users.find(u => u.id === withPasskey.id)?.hasTwoFactor).toBe(true);
83
+ expect(users.find(u => u.id === withoutFactors.id)?.hasTwoFactor).toBe(false);
84
+ expect(users.find(u => u.id === me.id)?.hasTwoFactor).toBe(false);
85
+ });
86
+
87
+ test('An authenticator that was never confirmed does not count as two-factor authentication', async () => {
88
+ const organization = await new OrganizationFactory({}).create();
89
+ const me = await createAdmin(organization);
90
+
91
+ const totp = new MFATOTP();
92
+ totp.userId = me.id;
93
+ totp.name = 'Pending authenticator';
94
+ totp.secret = 'encrypted-secret';
95
+ await totp.save();
96
+
97
+ const users = await getAdmins(organization, me);
98
+ expect(users.find(u => u.id === me.id)?.hasTwoFactor).toBe(false);
99
+ });
100
+ });
@@ -4,6 +4,7 @@ import { User } from '@stamhoofd/models';
4
4
  import { OrganizationAdmins } from '@stamhoofd/structures';
5
5
 
6
6
  import { Context } from '../../../../helpers/Context.js';
7
+ import { TwoFactorHelper } from '../../../../helpers/TwoFactorHelper.js';
7
8
  type Params = Record<string, never>;
8
9
  type Query = undefined;
9
10
  type Body = undefined;
@@ -34,10 +35,12 @@ export class GetOrganizationAdminsEndpoint extends Endpoint<Params, Query, Body,
34
35
 
35
36
  // Get all admins
36
37
  const admins = await User.getAdmins(organization.id);
38
+ const users = admins.map(user => user.getStructure());
39
+ await TwoFactorHelper.fillTwoFactorStatus(users);
37
40
 
38
41
  return new Response(
39
42
  OrganizationAdmins.create({
40
- users: admins.map(user => user.getStructure()),
43
+ users,
41
44
  }),
42
45
  );
43
46
  }
@@ -24,7 +24,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
24
24
  level: PermissionLevel.Full,
25
25
  }),
26
26
  }).create();
27
- const token = await Token.createToken(user);
27
+ const token = await Token.createToken(user, new Date());
28
28
 
29
29
  const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
30
30
  permissions: UserPermissions.create({
@@ -60,7 +60,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
60
60
  }),
61
61
  organization,
62
62
  }).create();
63
- const token = await Token.createToken(user);
63
+ const token = await Token.createToken(user, new Date());
64
64
 
65
65
  const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
66
66
  permissions: UserPermissions.create({
@@ -95,7 +95,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
95
95
  }),
96
96
  organization,
97
97
  }).create();
98
- const token = await Token.createToken(user);
98
+ const token = await Token.createToken(user, new Date());
99
99
 
100
100
  const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
101
101
  permissions: UserPermissions.create({
@@ -143,7 +143,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
143
143
  }),
144
144
  organization,
145
145
  }).create();
146
- const token = await Token.createToken(user);
146
+ const token = await Token.createToken(user, new Date());
147
147
 
148
148
  const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
149
149
  permissions: UserPermissions.create({
@@ -190,7 +190,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
190
190
  }),
191
191
  organization,
192
192
  }).create();
193
- const token = await Token.createToken(user);
193
+ const token = await Token.createToken(user, new Date());
194
194
 
195
195
  const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
196
196
  permissions: UserPermissions.create({
@@ -0,0 +1,160 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, User } from '@stamhoofd/models';
3
+ import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
4
+ import { PermissionLevel, Permissions, UserPermissions } from '@stamhoofd/structures';
5
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
6
+
7
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
8
+ import { AdminSessionService } from '../../../../services/AdminSessionService.js';
9
+ import { SignOutOrganizationAdminsEndpoint } from './SignOutOrganizationAdminsEndpoint.js';
10
+
11
+ describe('Endpoint.SignOutOrganizationAdmins', () => {
12
+ const endpoint = new SignOutOrganizationAdminsEndpoint();
13
+
14
+ beforeEach(() => {
15
+ TestUtils.setEnvironment('userMode', 'platform');
16
+ });
17
+
18
+ const signOut = async (organization: Organization, token: Token) => {
19
+ const request = Request.buildJson('POST', '/organization/admins/sign-out', organization.getApiHost());
20
+ request.headers.authorization = 'Bearer ' + token.accessToken;
21
+ return await testServer.test(endpoint, request);
22
+ };
23
+
24
+ /**
25
+ * In platform mode users are not owned by an organization: they have permissions for it.
26
+ */
27
+ const createAdmin = async (organization: Organization, level = PermissionLevel.Full) => {
28
+ const user = await new UserFactory({
29
+ organization,
30
+ permissions: Permissions.create({ level }),
31
+ }).create();
32
+ user.organizationId = null;
33
+ await user.save();
34
+ return user;
35
+ };
36
+
37
+ const tokenExists = async (token: Token) => {
38
+ return !!(await Token.getByAccessToken(token.accessToken, true));
39
+ };
40
+
41
+ test('all sessions of all admins are signed out, except the one making the request', async () => {
42
+ const organization = await new OrganizationFactory({}).create();
43
+ const otherOrganization = await new OrganizationFactory({}).create();
44
+
45
+ const admin = await createAdmin(organization);
46
+ const otherAdmin = await createAdmin(organization);
47
+ const otherOrganizationAdmin = await createAdmin(otherOrganization);
48
+ const member = await new UserFactory({ organization }).create();
49
+
50
+ const currentToken = await Token.createToken(admin, new Date());
51
+ const secondDeviceOfAdmin = await Token.createToken(admin, new Date());
52
+ const otherAdminToken = await Token.createToken(otherAdmin, new Date());
53
+ const otherOrganizationAdminToken = await Token.createToken(otherOrganizationAdmin, new Date());
54
+ const memberToken = await Token.createToken(member, new Date());
55
+
56
+ const response = await signOut(organization, currentToken);
57
+
58
+ // The other device of the admin is signed out too: only this session is spared
59
+ expect(response.body.count).toBe(2);
60
+ expect(await tokenExists(currentToken)).toBe(true);
61
+ expect(await tokenExists(secondDeviceOfAdmin)).toBe(false);
62
+ expect(await tokenExists(otherAdminToken)).toBe(false);
63
+
64
+ // Users without permissions in this organization are not affected
65
+ expect(await tokenExists(otherOrganizationAdminToken)).toBe(true);
66
+ expect(await tokenExists(memberToken)).toBe(true);
67
+ });
68
+
69
+ test('an admin without full access cannot sign out the other admins', async () => {
70
+ const organization = await new OrganizationFactory({}).create();
71
+
72
+ const admin = await createAdmin(organization, PermissionLevel.Write);
73
+ const otherAdmin = await createAdmin(organization);
74
+ const otherAdminToken = await Token.createToken(otherAdmin, new Date());
75
+
76
+ await expect(signOut(organization, await Token.createToken(admin, new Date()))).rejects.toThrow(STExpect.simpleError({
77
+ code: 'permission_denied',
78
+ }));
79
+ expect(await tokenExists(otherAdminToken)).toBe(true);
80
+ });
81
+
82
+ test('an api user cannot sign out the admins', async () => {
83
+ const organization = await new OrganizationFactory({}).create();
84
+
85
+ const apiUser = await new UserFactory({ organization, apiUser: true }).create();
86
+ apiUser.permissions = UserPermissions.create({
87
+ organizationPermissions: new Map([[organization.id, Permissions.create({ level: PermissionLevel.Full })]]),
88
+ });
89
+ await apiUser.save();
90
+
91
+ const otherAdmin = await createAdmin(organization);
92
+ const otherAdminToken = await Token.createToken(otherAdmin, new Date());
93
+
94
+ await expect(signOut(organization, await Token.createApiToken(apiUser))).rejects.toThrow(STExpect.simpleError({
95
+ code: 'permission_denied',
96
+ }));
97
+ expect(await tokenExists(otherAdminToken)).toBe(true);
98
+ });
99
+
100
+ test('an organization without admins is a no-op', async () => {
101
+ const organization = await new OrganizationFactory({}).create();
102
+ const member = await new UserFactory({ organization }).create();
103
+ const memberToken = await Token.createToken(member, new Date());
104
+
105
+ // Not reachable through the endpoint (the caller is an admin themselves), but the
106
+ // empty list of users may not turn into a query that deletes everything.
107
+ expect(await AdminSessionService.signOutOrganizationAdmins(organization.id, null)).toBe(0);
108
+ expect(await tokenExists(memberToken)).toBe(true);
109
+ });
110
+
111
+ describe('userMode organization', () => {
112
+ beforeEach(() => {
113
+ TestUtils.setEnvironment('userMode', 'organization');
114
+ });
115
+
116
+ test('all sessions of all admins are signed out, except the one making the request', async () => {
117
+ const organization = await new OrganizationFactory({}).create();
118
+
119
+ const admin: User = await new UserFactory({
120
+ organization,
121
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
122
+ }).create();
123
+ const otherAdmin: User = await new UserFactory({
124
+ organization,
125
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
126
+ }).create();
127
+ const member = await new UserFactory({ organization }).create();
128
+
129
+ const otherAdminToken = await Token.createToken(otherAdmin, new Date());
130
+ const memberToken = await Token.createToken(member, new Date());
131
+
132
+ const response = await signOut(organization, await Token.createToken(admin, new Date()));
133
+
134
+ expect(response.body.count).toBe(1);
135
+ expect(await tokenExists(otherAdminToken)).toBe(false);
136
+ expect(await tokenExists(memberToken)).toBe(true);
137
+ });
138
+
139
+ test('api users keep their token, because they cannot complete a login', async () => {
140
+ const organization = await new OrganizationFactory({}).create();
141
+
142
+ const admin: User = await new UserFactory({
143
+ organization,
144
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
145
+ }).create();
146
+
147
+ const apiUser = await new UserFactory({ organization, apiUser: true }).create();
148
+ apiUser.permissions = UserPermissions.create({
149
+ organizationPermissions: new Map([[organization.id, Permissions.create({ level: PermissionLevel.Full })]]),
150
+ });
151
+ await apiUser.save();
152
+ const apiToken = await Token.createApiToken(apiUser);
153
+
154
+ const response = await signOut(organization, await Token.createToken(admin, new Date()));
155
+
156
+ expect(response.body.count).toBe(0);
157
+ expect(await tokenExists(apiToken)).toBe(true);
158
+ });
159
+ });
160
+ });
@@ -0,0 +1,44 @@
1
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
+ import { CountResponse } from '@stamhoofd/structures';
4
+
5
+ import { Context } from '../../../../helpers/Context.js';
6
+ import { AdminSessionService } from '../../../../services/AdminSessionService.js';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = undefined;
10
+ type Body = undefined;
11
+ type ResponseBody = CountResponse;
12
+
13
+ /**
14
+ * End the sessions of all administrators of this organization, so they have to sign in
15
+ * again. Used after making two-factor authentication required, which is only enforced on
16
+ * new logins.
17
+ */
18
+ export class SignOutOrganizationAdminsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
19
+ protected doesMatch(request: Request): [true, Params] | [false] {
20
+ if (request.method !== 'POST') {
21
+ return [false];
22
+ }
23
+
24
+ const params = Endpoint.parseParameters(request.url, '/organization/admins/sign-out', {});
25
+
26
+ if (params) {
27
+ return [true, params as Params];
28
+ }
29
+ return [false];
30
+ }
31
+
32
+ async handle(_: DecodedRequest<Params, Query, Body>) {
33
+ const organization = await Context.setOrganizationScope();
34
+ const { token } = await Context.authenticate();
35
+
36
+ if (!await Context.auth.canManageAdmins(organization.id)) {
37
+ throw Context.auth.error();
38
+ }
39
+
40
+ const count = await AdminSessionService.signOutOrganizationAdmins(organization.id, token.accessToken);
41
+
42
+ return new Response(CountResponse.create({ count }));
43
+ }
44
+ }
@@ -9,6 +9,7 @@ import { Sorter } from '@stamhoofd/utility';
9
9
  import { SQL } from '@stamhoofd/sql';
10
10
  import { Formatter } from '@stamhoofd/utility';
11
11
  import { Context } from './Context.js';
12
+ import { TwoFactorHelper } from './TwoFactorHelper.js';
12
13
 
13
14
  /**
14
15
  * Builds authenticated structures for the current user
@@ -425,7 +426,7 @@ export class AuthenticatedStructures {
425
426
  }
426
427
  }
427
428
 
428
- return UserWithMembers.create({
429
+ const struct = UserWithMembers.create({
429
430
  ...user,
430
431
  hasAccount: user.hasAccount(),
431
432
  hasPassword: user.hasPasswordBasedAccount(),
@@ -433,6 +434,8 @@ export class AuthenticatedStructures {
433
434
  // Always include the current context organization - because it is possible we switch organization and we don't want to refetch every time
434
435
  members: await this.membersBlob(filtered, true, user),
435
436
  });
437
+ await TwoFactorHelper.fillTwoFactorStatus([struct]);
438
+ return struct;
436
439
  }
437
440
 
438
441
  /**
@@ -453,6 +456,8 @@ export class AuthenticatedStructures {
453
456
  }));
454
457
  }
455
458
 
459
+ await TwoFactorHelper.fillTwoFactorStatus(structs);
460
+
456
461
  return structs;
457
462
  }
458
463
 
@@ -697,6 +702,9 @@ export class AuthenticatedStructures {
697
702
  );
698
703
  }
699
704
 
705
+ // After filterMemberData, so we only query for the users we actually return.
706
+ await TwoFactorHelper.fillTwoFactorStatus(memberBlobs.flatMap(b => b.users));
707
+
700
708
  for (const blob of memberBlobs) {
701
709
  blob.responsibilities = responsibilities.filter(r => r.memberId == blob.id).map((r) => {
702
710
  const group = allGroups.get(r.groupId ?? '') ?? null;