@stamhoofd/backend 2.137.4 → 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 (142) hide show
  1. package/package.json +26 -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/drip-emails.ts +2 -1
  6. package/src/crons/index.ts +1 -0
  7. package/src/crons/invoices.ts +5 -3
  8. package/src/crons.ts +4 -2
  9. package/src/email-recipient-loaders/orders.ts +2 -1
  10. package/src/email-recipient-loaders/organizations.ts +3 -2
  11. package/src/email-recipient-loaders/payments.ts +3 -2
  12. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  13. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
  14. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  15. package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
  16. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  17. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  18. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  19. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
  20. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  21. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  22. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  23. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  24. package/src/endpoints/auth/MFA.test.ts +1398 -0
  25. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  26. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  27. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  28. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  29. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  30. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  31. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  32. package/src/endpoints/auth/SignupEndpoint.ts +17 -4
  33. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  34. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
  35. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  36. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  37. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  38. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  39. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  40. package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
  41. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  42. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  43. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
  44. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  45. package/src/endpoints/global/files/UploadFile.ts +31 -6
  46. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  47. package/src/endpoints/global/files/UploadImage.ts +23 -4
  48. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  49. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  50. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  51. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  52. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  53. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  54. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  55. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  56. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  57. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  58. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  59. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  60. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  62. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  63. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  64. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  65. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  66. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  67. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  68. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  69. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  70. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  72. package/src/excel-loaders/balance-items.ts +1 -1
  73. package/src/excel-loaders/event-notifications.ts +6 -7
  74. package/src/excel-loaders/members.test.ts +2 -2
  75. package/src/excel-loaders/members.ts +9 -10
  76. package/src/excel-loaders/organizations.ts +14 -20
  77. package/src/excel-loaders/payments.ts +1 -1
  78. package/src/excel-loaders/platform-memberships.ts +7 -7
  79. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  80. package/src/excel-loaders/receivable-balances.ts +1 -1
  81. package/src/excel-loaders/registrations.ts +9 -9
  82. package/src/helpers/AdminPermissionChecker.ts +1 -1
  83. package/src/helpers/AuthenticatedStructures.ts +21 -10
  84. package/src/helpers/Context.ts +114 -4
  85. package/src/helpers/EmailBuilder.test.ts +287 -0
  86. package/src/helpers/EmailBuilder.ts +811 -0
  87. package/src/helpers/EmailResumer.ts +2 -1
  88. package/src/helpers/ForwardHandler.ts +2 -1
  89. package/src/helpers/MFAEncryption.ts +34 -0
  90. package/src/helpers/MembershipCharger.ts +3 -2
  91. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  92. package/src/helpers/TOTPHelper.ts +79 -0
  93. package/src/helpers/TenantContext.test.ts +96 -0
  94. package/src/helpers/TenantContext.ts +67 -0
  95. package/src/helpers/TwoFactorHelper.ts +356 -0
  96. package/src/helpers/WebauthnHelper.ts +211 -0
  97. package/src/helpers/data/aaguids.json +1006 -0
  98. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  99. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  100. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  101. package/src/services/AdminSessionService.ts +41 -0
  102. package/src/services/BalanceItemService.ts +2 -1
  103. package/src/services/DocumentRenderService.test.ts +229 -0
  104. package/src/services/DocumentRenderService.ts +180 -0
  105. package/src/services/EmailPreviewService.test.ts +300 -0
  106. package/src/services/EmailPreviewService.ts +239 -0
  107. package/src/services/EmailSendService.test.ts +1218 -0
  108. package/src/services/EmailSendService.ts +705 -0
  109. package/src/services/EventNotificationService.ts +2 -1
  110. package/src/services/FileSignService.test.ts +85 -0
  111. package/src/services/FileSignService.ts +23 -1
  112. package/src/services/InvoicePdfService.ts +3 -3
  113. package/src/services/InvoiceService.ts +4 -2
  114. package/src/services/InvoiceXMLService.ts +2 -1
  115. package/src/services/OrderService.test.ts +308 -0
  116. package/src/services/OrderService.ts +215 -0
  117. package/src/services/OrganizationAdminService.test.ts +47 -0
  118. package/src/services/OrganizationAdminService.ts +139 -0
  119. package/src/services/OrganizationDNSService.test.ts +177 -0
  120. package/src/services/OrganizationDNSService.ts +282 -0
  121. package/src/services/OrganizationEmailService.test.ts +57 -0
  122. package/src/services/OrganizationEmailService.ts +212 -0
  123. package/src/services/PasswordForgotService.test.ts +99 -0
  124. package/src/services/PasswordForgotService.ts +38 -0
  125. package/src/services/PaymentService.ts +5 -3
  126. package/src/services/PlatformMembershipService.test.ts +180 -0
  127. package/src/services/PlatformMembershipService.ts +281 -4
  128. package/src/services/ReferralService.ts +3 -2
  129. package/src/services/RegistrationService.ts +4 -2
  130. package/src/services/SSOService.ts +106 -14
  131. package/src/services/STPackageService.test.ts +191 -0
  132. package/src/services/STPackageService.ts +115 -1
  133. package/src/services/TwoFactorAuditLogService.ts +64 -0
  134. package/src/services/VerificationCodeService.test.ts +71 -0
  135. package/src/services/VerificationCodeService.ts +101 -0
  136. package/tests/e2e/api-rate-limits.test.ts +1 -1
  137. package/tests/e2e/documents.test.ts +2 -1
  138. package/tests/e2e/private-files.test.ts +77 -14
  139. package/tests/helpers/MFATestHelper.ts +42 -0
  140. package/tests/helpers/TestServer.ts +4 -0
  141. package/tests/helpers/index.ts +1 -0
  142. package/tsconfig.build.json +2 -1
@@ -585,7 +585,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
585
585
  membership.locked = false;
586
586
 
587
587
  // Correct price and dates
588
- await membership.calculatePrice(member);
588
+ await PlatformMembershipService.calculatePrice(membership, member);
589
589
 
590
590
  const incompatible = await MemberPlatformMembership.select()
591
591
  .where('memberId', member.id)
@@ -3,7 +3,8 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
3
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
5
  import type { Organization } from '@stamhoofd/models';
6
- import { AuditLog, Member, Platform, RateLimiter, sendEmailTemplate } from '@stamhoofd/models';
6
+ import { AuditLog, Member, Platform, RateLimiter } from '@stamhoofd/models';
7
+ import { sendEmailTemplate } from '../../../helpers/EmailBuilder.js';
7
8
  import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType, EmailTemplateType, Recipient, Replacement, SecurityCodeSendMethod, SendMemberSecurityCodeRequest, SendMemberSecurityCodeResponse } from '@stamhoofd/structures';
8
9
  import type { Country } from '@stamhoofd/types/Country';
9
10
  import { Formatter } from '@stamhoofd/utility';
@@ -12,6 +12,7 @@ import { Formatter } from '@stamhoofd/utility';
12
12
  import { v4 as uuidv4 } from 'uuid';
13
13
  import { AuditLogService } from '../../../services/AuditLogService.js';
14
14
  import { ReferralService } from '../../../services/ReferralService.js';
15
+ import { VerificationCodeService } from '../../../services/VerificationCodeService.js';
15
16
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures.js';
16
17
  import { Context } from '../../../helpers/Context.js';
17
18
 
@@ -160,7 +161,7 @@ export class CreateOrganizationEndpoint extends Endpoint<Params, Query, Body, Re
160
161
  await AuditLog.update().where('type', AuditLogType.OrganizationAdded).where('objectId', organization.id).set('userId', user.id).set('source', AuditLogSource.User).update();
161
162
 
162
163
  const code = await EmailVerificationCode.createFor(user, user.email);
163
- code.send(user, organization, request.i18n).catch(console.error);
164
+ VerificationCodeService.send(code, user, organization, request.i18n).catch(console.error);
164
165
 
165
166
  for (const email of delayEmails) {
166
167
  Email.send({
@@ -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
 
8
9
  type Params = Record<string, never>;
9
10
  type Query = undefined;
@@ -34,10 +35,12 @@ export class GetPlatformAdminsEndpoint extends Endpoint<Params, Query, Body, Res
34
35
 
35
36
  // Get all admins
36
37
  const admins = await User.getPlatformAdmins();
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
  }
@@ -1,7 +1,7 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import type { Organization } from '@stamhoofd/models';
3
3
  import { OrganizationFactory, Platform, RegistrationPeriod, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
4
- import { PermissionLevel, Permissions, PlatformConfig, Platform as PlatformStruct, Version } from '@stamhoofd/structures';
4
+ import { PermissionLevel, Permissions, PlatformConfig, PlatformPrivateConfig, Platform as PlatformStruct, Version } from '@stamhoofd/structures';
5
5
 
6
6
  import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
7
7
  import { TestUtils } from '@stamhoofd/test-utils';
@@ -22,6 +22,40 @@ describe('Endpoint.PatchPlatform', () => {
22
22
  return await testServer.test(endpoint, request);
23
23
  };
24
24
 
25
+ const setPlatformRequiresTwoFactor = async (requireTwoFactor: boolean) => {
26
+ const platform = await Platform.getForEditing();
27
+ platform.privateConfig.requireTwoFactor = requireTwoFactor;
28
+ await platform.save();
29
+ };
30
+
31
+ test('Should save whether two-factor authentication is required for platform admins', async () => {
32
+ const organization = await new OrganizationFactory({ }).create();
33
+
34
+ const admin = await new UserFactory({
35
+ globalPermissions: Permissions.create({ level: PermissionLevel.Full }),
36
+ }).create();
37
+ const token = await Token.createToken(admin);
38
+
39
+ try {
40
+ const response = await patchPlatform({
41
+ patch: PlatformStruct.patch({
42
+ privateConfig: PlatformPrivateConfig.patch({
43
+ requireTwoFactor: true,
44
+ }),
45
+ }),
46
+ organization,
47
+ token,
48
+ });
49
+
50
+ expect(response.body.privateConfig?.requireTwoFactor).toBe(true);
51
+ expect((await Platform.getForEditing()).privateConfig.requireTwoFactor).toBe(true);
52
+ }
53
+ finally {
54
+ // The platform row is shared by every test file
55
+ await setPlatformRequiresTwoFactor(false);
56
+ }
57
+ });
58
+
25
59
  describe('userMode organization', () => {
26
60
  beforeEach(async () => {
27
61
  TestUtils.setEnvironment('userMode', 'organization');
@@ -82,6 +82,14 @@ export class PatchPlatformEndpoint extends Endpoint<
82
82
  request.body.privateConfig.emails,
83
83
  );
84
84
  }
85
+
86
+ if (request.body.privateConfig.requireTwoFactor !== undefined) {
87
+ if (!Context.auth.hasPlatformFullAccess()) {
88
+ throw Context.auth.error();
89
+ }
90
+
91
+ platform.privateConfig.requireTwoFactor = request.body.privateConfig.requireTwoFactor;
92
+ }
85
93
  }
86
94
 
87
95
  let shouldUpdateSetupSteps = false;
@@ -0,0 +1,92 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization } from '@stamhoofd/models';
3
+ import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
4
+ import { PermissionLevel, Permissions } from '@stamhoofd/structures';
5
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
6
+
7
+ import { testServer } from '../../../../tests/helpers/TestServer.js';
8
+ import { SignOutPlatformAdminsEndpoint } from './SignOutPlatformAdminsEndpoint.js';
9
+
10
+ describe('Endpoint.SignOutPlatformAdmins', () => {
11
+ const endpoint = new SignOutPlatformAdminsEndpoint();
12
+
13
+ beforeEach(() => {
14
+ TestUtils.setEnvironment('userMode', 'platform');
15
+ });
16
+
17
+ const signOut = async (organization: Organization, token: Token) => {
18
+ const request = Request.buildJson('POST', '/platform/admins/sign-out', organization.getApiHost());
19
+ request.headers.authorization = 'Bearer ' + token.accessToken;
20
+ return await testServer.test(endpoint, request);
21
+ };
22
+
23
+ const createPlatformAdmin = async (level = PermissionLevel.Full) => {
24
+ return await new UserFactory({
25
+ globalPermissions: Permissions.create({ level }),
26
+ }).create();
27
+ };
28
+
29
+ const tokenExists = async (token: Token) => {
30
+ return !!(await Token.getByAccessToken(token.accessToken, true));
31
+ };
32
+
33
+ test('all sessions of all platform admins are signed out, except the one making the request', async () => {
34
+ const organization = await new OrganizationFactory({}).create();
35
+
36
+ const admin = await createPlatformAdmin();
37
+ const otherAdmin = await createPlatformAdmin();
38
+
39
+ // An admin of an organization is not a platform admin
40
+ const organizationAdmin = await new UserFactory({
41
+ organization,
42
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
43
+ }).create();
44
+ organizationAdmin.organizationId = null;
45
+ await organizationAdmin.save();
46
+
47
+ const currentToken = await Token.createToken(admin, new Date());
48
+ const secondDeviceOfAdmin = await Token.createToken(admin, new Date());
49
+ const otherAdminToken = await Token.createToken(otherAdmin, new Date());
50
+ const organizationAdminToken = await Token.createToken(organizationAdmin, new Date());
51
+
52
+ const response = await signOut(organization, currentToken);
53
+
54
+ expect(response.body.count).toBe(2);
55
+ expect(await tokenExists(currentToken)).toBe(true);
56
+ expect(await tokenExists(secondDeviceOfAdmin)).toBe(false);
57
+ expect(await tokenExists(otherAdminToken)).toBe(false);
58
+ expect(await tokenExists(organizationAdminToken)).toBe(true);
59
+ });
60
+
61
+ test('a platform admin without full access cannot sign out the other admins', async () => {
62
+ const organization = await new OrganizationFactory({}).create();
63
+
64
+ const admin = await createPlatformAdmin(PermissionLevel.Write);
65
+ const otherAdmin = await createPlatformAdmin();
66
+ const otherAdminToken = await Token.createToken(otherAdmin, new Date());
67
+
68
+ await expect(signOut(organization, await Token.createToken(admin, new Date()))).rejects.toThrow(STExpect.simpleError({
69
+ code: 'permission_denied',
70
+ }));
71
+ expect(await tokenExists(otherAdminToken)).toBe(true);
72
+ });
73
+
74
+ test('an admin of an organization cannot sign out the platform admins', async () => {
75
+ const organization = await new OrganizationFactory({}).create();
76
+
77
+ const organizationAdmin = await new UserFactory({
78
+ organization,
79
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
80
+ }).create();
81
+ organizationAdmin.organizationId = null;
82
+ await organizationAdmin.save();
83
+
84
+ const platformAdmin = await createPlatformAdmin();
85
+ const platformAdminToken = await Token.createToken(platformAdmin, new Date());
86
+
87
+ await expect(signOut(organization, await Token.createToken(organizationAdmin, new Date()))).rejects.toThrow(STExpect.simpleError({
88
+ code: 'permission_denied',
89
+ }));
90
+ expect(await tokenExists(platformAdminToken)).toBe(true);
91
+ });
92
+ });
@@ -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
+ }
@@ -4,6 +4,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
4
4
  import { DocumentTemplate } from '@stamhoofd/models';
5
5
 
6
6
  import { Context } from '../../../../helpers/Context.js';
7
+ import { DocumentRenderService } from '../../../../services/DocumentRenderService.js';
7
8
 
8
9
  type Params = { id: string };
9
10
  type Query = undefined;
@@ -42,7 +43,7 @@ export class GetDocumentTemplateXMLEndpoint extends Endpoint<Params, Query, Body
42
43
  }
43
44
 
44
45
  // Update documents
45
- const xml = await template.getRenderedXml(organization);
46
+ const xml = await DocumentRenderService.getRenderedXml(template, organization);
46
47
  if (!xml) {
47
48
  throw new SimpleError({
48
49
  code: 'failed_generating',
@@ -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;
@@ -9,6 +9,7 @@ import NodeRSA from 'node-rsa';
9
9
  import { Formatter } from '@stamhoofd/utility';
10
10
  import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
11
11
  import { Context } from '../../../../helpers/Context.js';
12
+ import { OrganizationDNSService } from '../../../../services/OrganizationDNSService.js';
12
13
 
13
14
  type Params = Record<string, never>;
14
15
  type Query = undefined;
@@ -169,7 +170,7 @@ export class SetOrganizationDomainEndpoint extends Endpoint<Params, Query, Body,
169
170
  }));
170
171
  } else {
171
172
  if (oldMailDomain) {
172
- organization.deleteAWSMailIdenitity(oldMailDomain).catch(console.error);
173
+ OrganizationDNSService.deleteAWSMailIdenitity(organization, oldMailDomain).catch(console.error);
173
174
  }
174
175
 
175
176
  if (organization.serverMeta.privateDKIMKey && organization.serverMeta.publicDKIMKey) {
@@ -188,7 +189,7 @@ export class SetOrganizationDomainEndpoint extends Endpoint<Params, Query, Body,
188
189
  } else {
189
190
  // Validate DNS-records if not empty
190
191
  console.log('Validating domains');
191
- await organization.updateDNSRecords();
192
+ await OrganizationDNSService.updateDNSRecords(organization);
192
193
  }
193
194
 
194
195
  console.log('Done.');
@@ -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({
@@ -3,7 +3,7 @@ import { isPatch } from '@simonbackx/simple-encoding';
3
3
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
4
4
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
- import { Token, User } from '@stamhoofd/models';
6
+ import { Platform, Token, User } from '@stamhoofd/models';
7
7
  import { ApiUser, PermissionLevel, UserMeta, UserPermissions } from '@stamhoofd/structures';
8
8
  import { Context } from '../../../../helpers/Context.js';
9
9
 
@@ -58,10 +58,12 @@ export class PatchApiUserEndpoint extends Endpoint<Params, Query, Body, Response
58
58
  }
59
59
 
60
60
  if (request.body.permissions) {
61
+ const platform = await Platform.getSharedStruct();
62
+
61
63
  if (organization) {
62
64
  editUser.permissions = UserPermissions.limitedPatch(editUser.permissions, request.body.permissions, organization.id);
63
65
 
64
- if (editUser.id === user.id && (!editUser.permissions || !editUser.permissions.forOrganization(organization)?.hasFullAccess())) {
66
+ if (editUser.id === user.id && (!editUser.permissions || !editUser.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess())) {
65
67
  throw new SimpleError({
66
68
  code: 'permission_denied',
67
69
  message: 'Je kan jezelf niet verwijderen als hoofdbeheerder',
@@ -74,7 +76,7 @@ export class PatchApiUserEndpoint extends Endpoint<Params, Query, Body, Response
74
76
  editUser.permissions = request.body.permissions.isPut() ? request.body.permissions : null;
75
77
  }
76
78
 
77
- if (editUser.id === user.id && !editUser.permissions?.platform?.hasFullAccess()) {
79
+ if (editUser.id === user.id && !editUser.permissions?.forPlatform(platform)?.hasFullAccess()) {
78
80
  throw new SimpleError({
79
81
  code: 'permission_denied',
80
82
  message: 'Je kan jezelf niet verwijderen als hoofdbeheerder',