@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
@@ -10,6 +10,7 @@ import { Context } from '../helpers/Context.js';
10
10
 
11
11
  import type { ObjectWithHeaders } from '../helpers/CookieHelper.js';
12
12
  import { CookieHelper } from '../helpers/CookieHelper.js';
13
+ import { TwoFactorHelper } from '../helpers/TwoFactorHelper.js';
13
14
 
14
15
  async function randomBytes(size: number): Promise<Buffer> {
15
16
  return new Promise((resolve, reject) => {
@@ -37,12 +38,24 @@ type SSOSessionContext = {
37
38
  * Link this method to this existing user and don't create a new token
38
39
  */
39
40
  userId?: string | null;
41
+
42
+ /**
43
+ * The session (access token) that has to count as freshly authenticated again once the
44
+ * provider confirms the identity of `userId`. Only set for a reauthentication flow.
45
+ */
46
+ reauthenticateAccessToken?: string | null;
40
47
  };
41
48
 
42
49
  export class SSOAuthToken {
43
50
  validUntil: Date;
44
51
  token: string;
45
52
  userId: string;
53
+
54
+ /**
55
+ * The session this token was requested with, so a reauthentication can mark it fresh
56
+ * again. Null for API users and other sessionless callers.
57
+ */
58
+ accessToken: string | null = null;
46
59
  }
47
60
 
48
61
  export class SSOService {
@@ -76,7 +89,7 @@ export class SSOService {
76
89
  }
77
90
  }
78
91
 
79
- static async createToken() {
92
+ static async createToken(sessionToken: Token | null = null) {
80
93
  if (!Context.user) {
81
94
  throw new SimpleError({
82
95
  code: 'invalid_user',
@@ -89,6 +102,7 @@ export class SSOService {
89
102
  token.validUntil = new Date(Date.now() + 1000 * 60 * 5);
90
103
  token.token = (await randomBytes(192)).toString('base64').toUpperCase();
91
104
  token.userId = Context.user.id;
105
+ token.accessToken = sessionToken?.accessToken ?? null;
92
106
 
93
107
  await this.clearExpiredTokensOrFromUser(token.userId);
94
108
  this.authTokens.set(token.token, token);
@@ -369,6 +383,7 @@ export class SSOService {
369
383
  }
370
384
 
371
385
  let user: User | undefined = undefined;
386
+ let reauthenticateAccessToken: string | null = null;
372
387
 
373
388
  if (data.authToken) {
374
389
  const token = await SSOService.validateToken(data.authToken);
@@ -384,10 +399,27 @@ export class SSOService {
384
399
  }
385
400
 
386
401
  this.validateEmail(user.email);
402
+
403
+ // Force reentry of login credentials (no automatic login)
404
+ // Sadly select_account is not reliable and sometimes works automatically
405
+ // since this would automatically link a possibly wrong user, we don't allow that
406
+ // we check this with the auth_time, so a user cannot really get around this
407
+ data.prompt = 'login';
408
+
409
+ if (data.reauthenticate) {
410
+ if (!token.accessToken) {
411
+ throw new SimpleError({
412
+ code: 'invalid_token',
413
+ message: 'Cannot reauthenticate without a session',
414
+ statusCode: 400,
415
+ });
416
+ }
417
+ reauthenticateAccessToken = token.accessToken;
418
+ }
387
419
  }
388
420
  }
389
421
 
390
- return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt, user);
422
+ return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt, user, reauthenticateAccessToken);
391
423
  }
392
424
 
393
425
  validateRedirectUri(uri: string) {
@@ -422,7 +454,7 @@ export class SSOService {
422
454
  }
423
455
  }
424
456
 
425
- async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null, user?: User): Promise<Response<undefined>> {
457
+ async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null, user?: User, reauthenticateAccessToken: string | null = null): Promise<Response<undefined>> {
426
458
  const code_verifier = generators.codeVerifier();
427
459
  const state = generators.state(); // this is the internal state backend <-> SSO provider
428
460
  const nonce = generators.nonce();
@@ -439,6 +471,7 @@ export class SSOService {
439
471
  providerType: this.provider,
440
472
  organizationId: this.organization?.id ?? null,
441
473
  userId: user?.id ?? null,
474
+ reauthenticateAccessToken,
442
475
  };
443
476
 
444
477
  try {
@@ -465,7 +498,8 @@ export class SSOService {
465
498
  prompt: prompt ?? undefined,
466
499
  login_hint: user?.email ?? undefined,
467
500
  redirect_uri: this.externalRedirectUri,
468
-
501
+ max_age: prompt === 'login' ? 0 : undefined, // required to get auth_time in the response
502
+ claims: prompt === 'login' ? { id_token: { auth_time: { essential: true } } } : undefined,
469
503
  // Google has this instead of the offline_access scope
470
504
  access_type: this.provider === LoginProviderType.Google ? 'offline' : undefined,
471
505
  });
@@ -585,6 +619,21 @@ export class SSOServiceWithSession {
585
619
 
586
620
  this.service.validateEmail(claims.email);
587
621
 
622
+ if (this.session.userId) {
623
+ if (!claims.auth_time || claims.auth_time * 1000 < Date.now() - 1000 * 60 * 5) {
624
+ if (STAMHOOFD.environment === 'development' || STAMHOOFD.environment === 'test') {
625
+ // Dex does not support this (yet)
626
+ } else {
627
+ throw new SimpleError({
628
+ code: 'invalid_user',
629
+ message: 'No recent authentication',
630
+ human: $t('%ZhH'),
631
+ statusCode: 400,
632
+ });
633
+ }
634
+ }
635
+ }
636
+
588
637
  // Get user from database
589
638
  let user = await User.getForRegister(this.service.organization?.id ?? null, claims.email);
590
639
  if (!user) {
@@ -639,23 +688,66 @@ export class SSOServiceWithSession {
639
688
  const redirectUri = new URL(session.redirectUri);
640
689
 
641
690
  if (!this.session.userId) {
642
- const token = await Token.createExpiredToken(user);
643
-
644
- if (!token) {
691
+ // The identity provider authenticated the user, but that is only the first
692
+ // factor. A user who enrolled a second factor still has to pass it, and an
693
+ // account that also has a password must still enroll one when it is
694
+ // required — the password would otherwise stay a way in that skips
695
+ // whatever the provider enforces.
696
+ //
697
+ // This is a redirect, not a request/response pair, so the client gets the
698
+ // token of the pending step in the URL and picks the flow up from there.
699
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, this.service.organization, { loginMethod: 'sso' });
700
+
701
+ if (requirement.type === 'challenge') {
702
+ redirectUri.searchParams.set('oid_mfa', requirement.challenge.token);
703
+ redirectUri.searchParams.set('s', session.spaState);
704
+ } else if (requirement.type === 'setup') {
705
+ redirectUri.searchParams.set('oid_mfa_setup', requirement.setupToken.token);
706
+ // Which methods to offer. Only a hint for the enrollment screen: the
707
+ // enrollment endpoints check this themselves.
708
+ redirectUri.searchParams.set('oid_mfa_passkeys', user.canUsePasskeys() ? '1' : '0');
709
+ redirectUri.searchParams.set('s', session.spaState);
710
+ } else {
711
+ const token = await Token.createExpiredToken(user);
712
+
713
+ if (!token) {
714
+ throw new SimpleError({
715
+ code: 'error',
716
+ message: 'Could not generate token',
717
+ human: $t(`%D6`),
718
+ statusCode: 500,
719
+ });
720
+ }
721
+
722
+ const st = new TokenStruct(token);
723
+ redirectUri.searchParams.set('oid_rt', st.refreshToken);
724
+ redirectUri.searchParams.set('s', session.spaState);
725
+ }
726
+ } else if (this.session.reauthenticateAccessToken) {
727
+ // The provider confirmed the identity of the user that is already signed in,
728
+ // so their session counts as freshly authenticated again and the sensitive
729
+ // actions behind Context.authenticateFresh() unlock. This is the only way an
730
+ // SSO-only account can reach them: it has no password to confirm with.
731
+ const token = await Token.getByAccessToken(this.session.reauthenticateAccessToken);
732
+
733
+ if (!token || token.userId !== user.id) {
645
734
  throw new SimpleError({
646
- code: 'error',
647
- message: 'Could not generate token',
648
- human: $t(`%D6`),
649
- statusCode: 500,
735
+ code: 'invalid_token',
736
+ message: 'The session to reauthenticate no longer exists',
737
+ human: $t('%ZhJ'),
738
+ statusCode: 401,
650
739
  });
651
740
  }
652
741
 
653
- const st = new TokenStruct(token);
654
- redirectUri.searchParams.set('oid_rt', st.refreshToken);
742
+ token.authenticatedAt = new Date();
743
+ await token.save();
744
+ await user.markActive();
745
+
655
746
  redirectUri.searchParams.set('s', session.spaState);
747
+ redirectUri.searchParams.set('msg', $t('%Zhh'));
656
748
  } else {
657
749
  redirectUri.searchParams.set('s', session.spaState);
658
- redirectUri.searchParams.set('msg', 'Je account is succesvol gekoppeld');
750
+ redirectUri.searchParams.set('msg', $t('%Zh3'));
659
751
  }
660
752
 
661
753
  response.headers['location'] = redirectUri.toString();
@@ -0,0 +1,191 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
2
+ import { EmailTemplateFactory, OrganizationFactory, STPackage, UserFactory } from '@stamhoofd/models';
3
+ import { EmailTemplateType, PermissionLevel, Permissions, STPackageMeta, STPackageType } from '@stamhoofd/structures';
4
+ import { TestUtils } from '@stamhoofd/test-utils';
5
+ import { Formatter } from '@stamhoofd/utility';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { vi } from 'vitest';
8
+ import { STPackageService } from './STPackageService.js';
9
+
10
+ const DAY = 1000 * 60 * 60 * 24;
11
+
12
+ /**
13
+ * A global (not organization scoped) template that only contains the replacements the service fills
14
+ * in, so the rendered email can be asserted without depending on the real template contents.
15
+ */
16
+ async function createExpirationTemplate(type: EmailTemplateType) {
17
+ await new EmailTemplateFactory({
18
+ type,
19
+ html: '<p>organizationName: {{organizationName}}</p>'
20
+ + '<p>packageName: {{packageName}}</p>'
21
+ + '<p>validUntil: {{validUntil}}</p>'
22
+ + '<p>validUntilDate: {{validUntilDate}}</p>'
23
+ + '<p>renewUrl: {{renewUrl}}</p>',
24
+ }).create();
25
+ }
26
+
27
+ async function createPackage(options: {
28
+ organizationId: string;
29
+ type: STPackageType;
30
+ /**
31
+ * Defaults to 10 days from now: inside the reminder window of every package type.
32
+ */
33
+ validUntil?: Date | null;
34
+ removeAt?: Date | null;
35
+ validAt?: Date | null;
36
+ }) {
37
+ const pack = new STPackage();
38
+ pack.organizationId = options.organizationId;
39
+ pack.meta = STPackageMeta.create({
40
+ type: options.type,
41
+ startDate: new Date(Date.now() - 365 * DAY),
42
+ });
43
+ pack.validAt = options.validAt !== undefined ? options.validAt : new Date(Date.now() - 365 * DAY);
44
+ pack.validUntil = options.validUntil !== undefined ? options.validUntil : new Date(Date.now() + 10 * DAY);
45
+ pack.removeAt = options.removeAt ?? null;
46
+ await pack.save();
47
+ return pack;
48
+ }
49
+
50
+ async function createOrganizationWithAdmin() {
51
+ const organization = await new OrganizationFactory({}).create();
52
+ const admin = await new UserFactory({
53
+ organization,
54
+ firstName: 'Full',
55
+ lastName: 'Admin',
56
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
57
+ }).create();
58
+ return { organization, admin };
59
+ }
60
+
61
+ describe('STPackageService', () => {
62
+ afterEach(() => {
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ describe('sendExpiryEmail', () => {
67
+ test('Sends the reminder to the full admins of the organization and records it on the package', async () => {
68
+ TestUtils.setEnvironment('environment', 'production');
69
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
70
+
71
+ const { organization, admin } = await createOrganizationWithAdmin();
72
+
73
+ // A user without permissions should not receive the reminder
74
+ const member = await new UserFactory({ organization }).create();
75
+
76
+ const validUntil = new Date(Date.now() + 10 * DAY);
77
+ const pack = await createPackage({ organizationId: organization.id, type: STPackageType.Members, validUntil });
78
+
79
+ await STPackageService.sendExpiryEmail(pack);
80
+
81
+ const emails = await EmailMocker.getSucceededEmails();
82
+ expect(emails.length).toBe(1);
83
+ expect(emails[0].to).toContain(admin.email);
84
+ expect(emails[0].to).not.toContain(member.email);
85
+
86
+ expect(emails[0].html).toContain('organizationName: ' + organization.name);
87
+ expect(emails[0].html).toContain('packageName: ' + pack.meta.name);
88
+ expect(emails[0].html).toContain('validUntil: ' + Formatter.dateTime(validUntil));
89
+ expect(emails[0].html).toContain('validUntilDate: ' + Formatter.date(validUntil));
90
+ expect(emails[0].html).toContain(`renewUrl: https://${STAMHOOFD.domains.dashboard ?? 'stamhoofd.app'}/${organization.i18n.locale}/beheerders/${organization.uri}/instellingen/functionaliteiten`);
91
+
92
+ expect(pack.lastEmailAt).not.toBeNull();
93
+ expect(pack.emailCount).toBe(1);
94
+
95
+ const fromDatabase = await STPackage.getByID(pack.id);
96
+ expect(fromDatabase!.lastEmailAt).not.toBeNull();
97
+ expect(fromDatabase!.emailCount).toBe(1);
98
+ });
99
+
100
+ test('Counts the email, but does not set lastEmailAt, for a package type without a reminder', async () => {
101
+ TestUtils.setEnvironment('environment', 'production');
102
+ const { organization } = await createOrganizationWithAdmin();
103
+
104
+ // LegacyMembers has no expiration reminder template type
105
+ const pack = await createPackage({ organizationId: organization.id, type: STPackageType.LegacyMembers });
106
+
107
+ await STPackageService.sendExpiryEmail(pack);
108
+
109
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
110
+ expect(pack.lastEmailAt).toBeNull();
111
+ expect(pack.emailCount).toBe(1);
112
+
113
+ const fromDatabase = await STPackage.getByID(pack.id);
114
+ expect(fromDatabase!.lastEmailAt).toBeNull();
115
+ expect(fromDatabase!.emailCount).toBe(1);
116
+ });
117
+
118
+ test('Does not send outside production, but still records the email on the package', async () => {
119
+ // The production guard only covers the send itself: the bookkeeping runs in every environment
120
+ expect(STAMHOOFD.environment).not.toBe('production');
121
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
122
+
123
+ const { organization } = await createOrganizationWithAdmin();
124
+ const pack = await createPackage({ organizationId: organization.id, type: STPackageType.Members });
125
+
126
+ await STPackageService.sendExpiryEmail(pack);
127
+
128
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
129
+ expect(pack.lastEmailAt).not.toBeNull();
130
+ expect(pack.emailCount).toBe(1);
131
+
132
+ const fromDatabase = await STPackage.getByID(pack.id);
133
+ expect(fromDatabase!.lastEmailAt).not.toBeNull();
134
+ expect(fromDatabase!.emailCount).toBe(1);
135
+ });
136
+
137
+ test('Logs and skips the email when the organization of the package no longer exists', async () => {
138
+ TestUtils.setEnvironment('environment', 'production');
139
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
140
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
141
+
142
+ const pack = await createPackage({ organizationId: uuidv4(), type: STPackageType.Members });
143
+
144
+ await STPackageService.sendExpiryEmail(pack);
145
+
146
+ expect(consoleError).toHaveBeenCalledWith('Could not find package organization ' + pack.id);
147
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
148
+
149
+ // A missing organization does not stop the bookkeeping
150
+ expect(pack.emailCount).toBe(1);
151
+ });
152
+
153
+ test('Does not send or count a package that is not due', async () => {
154
+ TestUtils.setEnvironment('environment', 'production');
155
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
156
+
157
+ const { organization } = await createOrganizationWithAdmin();
158
+
159
+ const neverActivated = await createPackage({ organizationId: organization.id, type: STPackageType.Members, validAt: null });
160
+
161
+ // Expires in 100 days, which is outside the 32 day window of a members package
162
+ const notExpiringSoon = await createPackage({ organizationId: organization.id, type: STPackageType.Members, validUntil: new Date(Date.now() + 100 * DAY) });
163
+
164
+ await STPackageService.sendExpiryEmail(neverActivated);
165
+ await STPackageService.sendExpiryEmail(notExpiringSoon);
166
+
167
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
168
+ expect(neverActivated.emailCount).toBe(0);
169
+ expect(neverActivated.lastEmailAt).toBeNull();
170
+ expect(notExpiringSoon.emailCount).toBe(0);
171
+ expect(notExpiringSoon.lastEmailAt).toBeNull();
172
+ });
173
+
174
+ test('Counts, but does not send, a package that is already removed', async () => {
175
+ TestUtils.setEnvironment('environment', 'production');
176
+ await createExpirationTemplate(EmailTemplateType.MembersExpirationReminder);
177
+
178
+ const { organization } = await createOrganizationWithAdmin();
179
+ const pack = await createPackage({ organizationId: organization.id, type: STPackageType.Members, removeAt: new Date(Date.now() - DAY) });
180
+
181
+ await STPackageService.sendExpiryEmail(pack);
182
+
183
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
184
+ expect(pack.lastEmailAt).toBeNull();
185
+ expect(pack.emailCount).toBe(1);
186
+
187
+ const fromDatabase = await STPackage.getByID(pack.id);
188
+ expect(fromDatabase!.emailCount).toBe(1);
189
+ });
190
+ });
191
+ });
@@ -1,9 +1,11 @@
1
1
  import { BalanceItem, Organization, Platform, STPackage } from '@stamhoofd/models';
2
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
2
3
  import { SQL } from '@stamhoofd/sql';
3
4
  import type { Company } from '@stamhoofd/structures';
4
- import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, getPricingTypeName, STPackageStatus, STPackageType, STPackageTypeHelper, STPricingType, TranslatedString } from '@stamhoofd/structures';
5
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, EmailTemplateType, getPricingTypeName, Recipient, Replacement, STPackageStatus, STPackageType, STPackageTypeHelper, STPricingType, TranslatedString } from '@stamhoofd/structures';
5
6
  import { Formatter, STMath } from '@stamhoofd/utility';
6
7
  import { VATService } from './VATService.js';
8
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
7
9
 
8
10
  export class STPackageService {
9
11
  static async getActivePackages(organizationId: string) {
@@ -263,6 +265,118 @@ export class STPackageService {
263
265
  await this.updateOrganizationPackages(organizationId);
264
266
  }
265
267
 
268
+ /**
269
+ * Send the expiration reminder of a package if one is due, and record on the package that it was
270
+ * sent (also when nothing was sent, so a package is only ever considered once).
271
+ */
272
+ static async sendExpiryEmail(pack: STPackage) {
273
+ if (pack.validAt === null) {
274
+ // never activated
275
+ return;
276
+ }
277
+
278
+ if (pack.removeAt && pack.removeAt <= new Date()) {
279
+ pack.emailCount += 1;
280
+ await pack.save();
281
+ return;
282
+ }
283
+
284
+ let allowDays = 0;
285
+ let type: EmailTemplateType | null = null;
286
+
287
+ if (pack.meta.type === STPackageType.Members) {
288
+ type = EmailTemplateType.MembersExpirationReminder;
289
+ allowDays = 32;
290
+ } else if (pack.meta.type === STPackageType.Webshops) {
291
+ type = EmailTemplateType.WebshopsExpirationReminder;
292
+ allowDays = 32;
293
+ } else if (pack.meta.type === STPackageType.SingleWebshop) {
294
+ type = EmailTemplateType.SingleWebshopExpirationReminder;
295
+ allowDays = 7;
296
+ } else if (pack.meta.type === STPackageType.TrialMembers) {
297
+ type = EmailTemplateType.TrialMembersExpirationReminder;
298
+ allowDays = 3;
299
+ } else if (pack.meta.type === STPackageType.TrialWebshops) {
300
+ type = EmailTemplateType.TrialWebshopsExpirationReminder;
301
+ allowDays = 3;
302
+ }
303
+
304
+ const allowFrom = new Date(Date.now() + 1000 * 60 * 60 * 24 * allowDays);
305
+ if (type && (pack.validUntil === null || pack.validUntil < new Date() || pack.validUntil > allowFrom)) {
306
+ console.log('Skip sending expiration email for ' + pack.id);
307
+ return;
308
+ }
309
+
310
+ if (type) {
311
+ console.log('Sending expiration email for ' + pack.id, type);
312
+ if (STAMHOOFD.environment === 'production') {
313
+ await this.sendEmailTemplate(pack, {
314
+ type,
315
+ });
316
+ }
317
+ pack.lastEmailAt = new Date();
318
+ } else {
319
+ console.log('Skip sending expiration email for ' + pack.id + ' (no type)');
320
+ }
321
+
322
+ pack.emailCount += 1;
323
+ await pack.save();
324
+ }
325
+
326
+ /**
327
+ * Send an email template about a package to the full administrators of its organization.
328
+ */
329
+ static async sendEmailTemplate(pack: STPackage, data: {
330
+ type: EmailTemplateType;
331
+ }) {
332
+ const organization = await Organization.getByID(pack.organizationId);
333
+
334
+ if (!organization) {
335
+ console.error('Could not find package organization ' + pack.id);
336
+ return;
337
+ }
338
+
339
+ const admins = await OrganizationAdminService.getFullAdmins(organization);
340
+
341
+ const recipients = admins.map(admin =>
342
+ Recipient.create({
343
+ firstName: admin.firstName,
344
+ lastName: admin.lastName,
345
+ email: admin.email,
346
+ replacements: [
347
+ Replacement.create({
348
+ token: 'organizationName',
349
+ value: organization.name,
350
+ }),
351
+ Replacement.create({
352
+ token: 'packageName',
353
+ value: pack.meta.name ?? '',
354
+ }),
355
+ Replacement.create({
356
+ token: 'validUntil',
357
+ value: pack.validUntil ? Formatter.dateTime(pack.validUntil) : 'nooit',
358
+ }),
359
+ Replacement.create({
360
+ token: 'validUntilDate',
361
+ value: pack.validUntil ? Formatter.date(pack.validUntil) : 'nooit',
362
+ }),
363
+ Replacement.create({
364
+ token: 'renewUrl',
365
+ value: `https://${(STAMHOOFD.domains.dashboard ?? 'stamhoofd.app')}/${organization.i18n.locale}/beheerders/${organization.uri}/instellingen/functionaliteiten`,
366
+ }),
367
+ ],
368
+ }),
369
+ );
370
+
371
+ // Create e-mail builder
372
+ await sendEmailTemplate(null, {
373
+ template: {
374
+ type: data.type,
375
+ },
376
+ recipients,
377
+ });
378
+ }
379
+
266
380
  static async markBalanceRestored(organizationId: string) {
267
381
  console.log('Marking packages as restored for ' + organizationId);
268
382
 
@@ -0,0 +1,64 @@
1
+ import type { User } from '@stamhoofd/models';
2
+ import { AuditLog } from '@stamhoofd/models';
3
+ import type { MFAMethodType } from '@stamhoofd/structures';
4
+ import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType } from '@stamhoofd/structures';
5
+
6
+ /**
7
+ * Records the changes a user makes to their own two-factor authentication.
8
+ *
9
+ * These are security-relevant events that are not visible anywhere else: the credentials
10
+ * themselves are not audit-logged models, and an attacker who took over a session would
11
+ * use exactly these endpoints to lock the owner out. So they are logged explicitly, at the
12
+ * moment the change is effective.
13
+ */
14
+ export class TwoFactorAuditLogService {
15
+ static async logMethodAdded(user: User, method: MFAMethodType, name: string) {
16
+ await this.save(AuditLogType.UserTwoFactorMethodAdded, user, method, name);
17
+ }
18
+
19
+ static async logMethodDeleted(user: User, method: MFAMethodType, name: string) {
20
+ await this.save(AuditLogType.UserTwoFactorMethodDeleted, user, method, name);
21
+ }
22
+
23
+ /**
24
+ * Only for an explicit regeneration by the user. The batch that is generated together
25
+ * with a first factor is already covered by the method-added log.
26
+ */
27
+ static async logRecoveryCodesRegenerated(user: User) {
28
+ await this.save(AuditLogType.UserRecoveryCodesRegenerated, user, null, '');
29
+ }
30
+
31
+ private static async save(type: AuditLogType, user: User, method: MFAMethodType | null, name: string) {
32
+ const log = new AuditLog();
33
+ log.type = type;
34
+ log.source = AuditLogSource.User;
35
+
36
+ // A user changes their own factors, so the account is both the actor and the subject.
37
+ log.userId = user.id;
38
+ log.objectId = user.id;
39
+
40
+ // Deliberately the organization of the account itself, not the one the request was
41
+ // scoped to: a platform administrator enrolling a factor while working in an
42
+ // organization is not that organization's business.
43
+ log.organizationId = user.organizationId;
44
+
45
+ log.replacements = new Map([
46
+ ['u', AuditLogReplacement.create({
47
+ id: user.id,
48
+ value: user.email,
49
+ type: AuditLogReplacementType.User,
50
+ })],
51
+ ]);
52
+
53
+ const methodReplacement = AuditLogReplacement.enum('MFAMethodType', method);
54
+ if (methodReplacement) {
55
+ log.replacements.set('method', methodReplacement);
56
+ }
57
+
58
+ if (name) {
59
+ log.replacements.set('name', AuditLogReplacement.string(name));
60
+ }
61
+
62
+ await log.save();
63
+ }
64
+ }
@@ -0,0 +1,71 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n';
2
+ import { EmailMocker } from '@stamhoofd/email';
3
+ import { EmailTemplateFactory, EmailVerificationCode, OrganizationFactory, Platform, UserFactory } from '@stamhoofd/models';
4
+ import { EmailTemplateType } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import { VerificationCodeService } from './VerificationCodeService.js';
7
+
8
+ const i18n = new I18n(I18n.defaultLanguage, I18n.defaultCountry);
9
+
10
+ const PLATFORM_NAME = 'The test platform';
11
+
12
+ /**
13
+ * A template that only contains the organizationName replacement, so the name that ends up in the
14
+ * email can be asserted without depending on the real (translated) template contents.
15
+ */
16
+ async function createVerifyEmailTemplate() {
17
+ await new EmailTemplateFactory({
18
+ type: EmailTemplateType.VerifyEmail,
19
+ html: '<p>{{organizationName}}</p>',
20
+ text: '{{organizationName}}',
21
+ }).create();
22
+ }
23
+
24
+ describe('VerificationCodeService', () => {
25
+ let originalPlatformName: string;
26
+
27
+ beforeAll(async () => {
28
+ const platform = await Platform.getForEditing();
29
+ originalPlatformName = platform.config.name;
30
+ platform.config.name = PLATFORM_NAME;
31
+ await platform.save();
32
+ });
33
+
34
+ afterAll(async () => {
35
+ const platform = await Platform.getForEditing();
36
+ platform.config.name = originalPlatformName;
37
+ await platform.save();
38
+ });
39
+
40
+ describe('send', () => {
41
+ test('Uses the name of the organization when the code is scoped to an organization', async () => {
42
+ TestUtils.setEnvironment('userMode', 'organization');
43
+ await createVerifyEmailTemplate();
44
+
45
+ const organization = await new OrganizationFactory({ name: 'Organization with a name' }).create();
46
+ const user = await new UserFactory({ organization }).create();
47
+ const code = await EmailVerificationCode.createFor(user, user.email);
48
+
49
+ await VerificationCodeService.send(code, user, organization, i18n);
50
+
51
+ const emails = await EmailMocker.transactional.getSucceededEmails();
52
+ expect(emails.length).toBe(1);
53
+ expect(emails[0].text).toContain('Organization with a name');
54
+ expect(emails[0].text).not.toContain(PLATFORM_NAME);
55
+ });
56
+
57
+ test('Falls back to the name of the platform when there is no organization', async () => {
58
+ TestUtils.setEnvironment('userMode', 'platform');
59
+ await createVerifyEmailTemplate();
60
+
61
+ const user = await new UserFactory({}).create();
62
+ const code = await EmailVerificationCode.createFor(user, user.email);
63
+
64
+ await VerificationCodeService.send(code, user, null, i18n);
65
+
66
+ const emails = await EmailMocker.transactional.getSucceededEmails();
67
+ expect(emails.length).toBe(1);
68
+ expect(emails[0].text).toContain(PLATFORM_NAME);
69
+ });
70
+ });
71
+ });