@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
@@ -32,10 +32,10 @@ export class OpenIDConnectAuthTokenEndpoint extends Endpoint<Params, Query, Body
32
32
  async handle(request: DecodedRequest<Params, Query, Body>) {
33
33
  // Check webshop and/or organization
34
34
  await Context.setUserOrganizationScope();
35
- await Context.authenticate({ allowWithoutAccount: false });
35
+ const { token: sessionToken } = await Context.authenticate({ allowWithoutAccount: false });
36
36
 
37
37
  // Create a SSO auth token that can only be used once
38
- const token = await SSOService.createToken();
38
+ const token = await SSOService.createToken(sessionToken);
39
39
 
40
40
  return new Response(OpenIDAuthTokenResponse.create({
41
41
  ssoAuthToken: token,
@@ -10,6 +10,7 @@ import { LoginMethod, NewUser, PermissionLevel, SignupResponse, UserPermissions
10
10
  import { Context } from '../../helpers/Context.js';
11
11
  import { MemberUserSyncer } from '../../helpers/MemberUserSyncer.js';
12
12
  import { AuthenticatedStructures } from '../../helpers/AuthenticatedStructures.js';
13
+ import { VerificationCodeService } from '../../services/VerificationCodeService.js';
13
14
 
14
15
  type Params = { id: string };
15
16
  type Query = undefined;
@@ -79,10 +80,12 @@ export class PatchUserEndpoint extends Endpoint<Params, Query, Body, ResponseBod
79
80
  }
80
81
 
81
82
  if (request.body.permissions) {
83
+ const platform = await Platform.getSharedStruct();
84
+
82
85
  if (organization) {
83
86
  editUser.permissions = UserPermissions.limitedPatch(editUser.permissions, request.body.permissions, organization.id);
84
87
 
85
- if (editUser.id === user.id && (!editUser.permissions || !editUser.permissions.forOrganization(organization)?.hasFullAccess()) && STAMHOOFD.environment !== 'development') {
88
+ if (editUser.id === user.id && (!editUser.permissions || !editUser.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess()) && STAMHOOFD.environment !== 'development') {
86
89
  throw new SimpleError({
87
90
  code: 'permission_denied',
88
91
  message: $t(`%DG`),
@@ -99,7 +102,7 @@ export class PatchUserEndpoint extends Endpoint<Params, Query, Body, ResponseBod
99
102
  editUser.permissions = null;
100
103
  }
101
104
 
102
- if (editUser.id === user.id && !editUser.permissions?.platform?.hasFullAccess() && STAMHOOFD.environment !== 'development') {
105
+ if (editUser.id === user.id && !editUser.permissions?.forPlatform(platform)?.hasFullAccess() && STAMHOOFD.environment !== 'development') {
103
106
  throw new SimpleError({
104
107
  code: 'permission_denied',
105
108
  message: $t(`%DG`),
@@ -187,7 +190,7 @@ export class PatchUserEndpoint extends Endpoint<Params, Query, Body, ResponseBod
187
190
  // Create an validation code
188
191
  // We always need the code, to return it. Also on password recovery -> may not be visible to the client whether the user exists or not
189
192
  const code = await EmailVerificationCode.createFor(editUser, request.body.email);
190
- code.send(editUser, organization, request.i18n, editUser.id === user.id).catch(console.error);
193
+ VerificationCodeService.send(code, editUser, organization, request.i18n, editUser.id === user.id).catch(console.error);
191
194
 
192
195
  throw new SimpleError({
193
196
  code: 'verify_email',
@@ -0,0 +1,51 @@
1
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
+ import { SimpleError } from '@simonbackx/simple-errors';
4
+ import { RecoveryCodes } from '@stamhoofd/structures';
5
+
6
+ import { Context } from '../../helpers/Context.js';
7
+ import { RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
8
+ import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
9
+ import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
10
+
11
+ type Params = Record<string, never>;
12
+ type Query = undefined;
13
+ type Body = undefined;
14
+ type ResponseBody = RecoveryCodes;
15
+
16
+ /**
17
+ * Regenerate the user's recovery codes (replacing the previous batch). Returns the new
18
+ * plaintext codes once. Requires the user to already have a factor enrolled.
19
+ */
20
+ export class RegenerateRecoveryCodesEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
21
+ protected doesMatch(request: Request): [true, Params] | [false] {
22
+ if (request.method !== 'POST') {
23
+ return [false];
24
+ }
25
+ const params = Endpoint.parseParameters(request.url, '/mfa/recovery-codes', {});
26
+ if (params) {
27
+ return [true, params as Params];
28
+ }
29
+ return [false];
30
+ }
31
+
32
+ async handle(request: DecodedRequest<Params, Query, Body>) {
33
+ await Context.setOptionalOrganizationScope();
34
+ const { user } = await Context.authenticateFresh({ allowWithoutAccount: true, allowUnscoped: true });
35
+
36
+ if (!(await TwoFactorHelper.userHasFactors(user.id))) {
37
+ throw new SimpleError({
38
+ code: 'no_factors',
39
+ message: 'You need at least one two-factor method before generating recovery codes',
40
+ human: $t('%Zgz'),
41
+ statusCode: 400,
42
+ });
43
+ }
44
+
45
+ const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
46
+
47
+ await TwoFactorAuditLogService.logRecoveryCodesRegenerated(user);
48
+
49
+ return new Response(RecoveryCodes.create({ codes }));
50
+ }
51
+ }
@@ -0,0 +1,99 @@
1
+ import type { Decoder } from '@simonbackx/simple-encoding';
2
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
+ import { SimpleError } from '@simonbackx/simple-errors';
5
+ import { WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
6
+ import type { MFAEnrollmentResult } from '@stamhoofd/structures';
7
+ import { MFAMethodType, WebauthnRegistrationRequest } from '@stamhoofd/structures';
8
+
9
+ import { Context } from '../../helpers/Context.js';
10
+ import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
11
+ import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
12
+ import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
13
+
14
+ type Params = Record<string, never>;
15
+ type Query = undefined;
16
+ type Body = WebauthnRegistrationRequest;
17
+ type ResponseBody = MFAEnrollmentResult;
18
+
19
+ /**
20
+ * Verify and store a newly registered passkey. On the user's first factor this also
21
+ * returns recovery codes, and during forced enrollment it issues a full session token.
22
+ */
23
+ export class RegisterPasskeyEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
24
+ bodyDecoder = WebauthnRegistrationRequest as Decoder<WebauthnRegistrationRequest>;
25
+
26
+ protected doesMatch(request: Request): [true, Params] | [false] {
27
+ if (request.method !== 'POST') {
28
+ return [false];
29
+ }
30
+ const params = Endpoint.parseParameters(request.url, '/mfa/passkeys', {});
31
+ if (params) {
32
+ return [true, params as Params];
33
+ }
34
+ return [false];
35
+ }
36
+
37
+ async handle(request: DecodedRequest<Params, Query, Body>) {
38
+ await Context.setOptionalOrganizationScope();
39
+ const { user, setupToken, token } = await Context.authenticateMFAEnrollment();
40
+
41
+ if (!user.canUsePasskeys()) {
42
+ throw new SimpleError({
43
+ code: 'passkeys_not_available',
44
+ message: 'Passkeys are not available for this account',
45
+ human: $t('%ZhE'),
46
+ statusCode: 400,
47
+ });
48
+ }
49
+
50
+ const challenge = await WebauthnChallenge.consumeForUser(user.id);
51
+ if (!challenge) {
52
+ throw new SimpleError({
53
+ code: 'invalid_challenge',
54
+ message: 'No valid registration challenge found',
55
+ human: $t('%Zi9'),
56
+ statusCode: 400,
57
+ });
58
+ }
59
+
60
+ const result = await WebauthnHelper.verifyRegistration(request.body.response, challenge);
61
+ if (!result) {
62
+ throw new SimpleError({
63
+ code: 'invalid_passkey',
64
+ message: 'Could not verify the passkey',
65
+ human: $t('%Zhr'),
66
+ statusCode: 400,
67
+ });
68
+ }
69
+
70
+ if (await WebauthnCredential.getByCredentialId(result.credentialId)) {
71
+ throw new SimpleError({
72
+ code: 'passkey_already_registered',
73
+ message: 'This passkey is already registered',
74
+ human: $t('%Zhf'),
75
+ statusCode: 400,
76
+ });
77
+ }
78
+
79
+ const wasFirstFactor = !(await TwoFactorHelper.userHasFactors(user.id));
80
+
81
+ const credential = new WebauthnCredential();
82
+ credential.userId = user.id;
83
+ credential.credentialId = result.credentialId;
84
+ credential.rpId = result.rpId;
85
+ credential.publicKey = result.publicKey;
86
+ credential.counter = result.counter;
87
+ credential.transports = result.transports ? JSON.stringify(result.transports) : null;
88
+ credential.backedUp = result.backedUp;
89
+ credential.backupEligible = result.backupEligible;
90
+ credential.name = request.body.name?.trim() || '';
91
+ credential.providerId = result.providerId;
92
+ credential.providerName = result.providerName;
93
+ await credential.save();
94
+
95
+ await TwoFactorAuditLogService.logMethodAdded(user, MFAMethodType.Passkey, credential.name || credential.providerName || '');
96
+
97
+ return new Response(await TwoFactorHelper.completeEnrollment(user, setupToken, wasFirstFactor, token));
98
+ }
99
+ }
@@ -0,0 +1,49 @@
1
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
+ import { SimpleError } from '@simonbackx/simple-errors';
4
+ import { WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
5
+ import { WebauthnRegistrationOptions } from '@stamhoofd/structures';
6
+
7
+ import { Context } from '../../helpers/Context.js';
8
+ import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
9
+
10
+ type Params = Record<string, never>;
11
+ type Query = undefined;
12
+ type Body = undefined;
13
+ type ResponseBody = WebauthnRegistrationOptions;
14
+
15
+ /**
16
+ * Generate WebAuthn registration options (a challenge) for enrolling a new passkey.
17
+ */
18
+ export class RegisterPasskeyOptionsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
19
+ protected doesMatch(request: Request): [true, Params] | [false] {
20
+ if (request.method !== 'POST') {
21
+ return [false];
22
+ }
23
+ const params = Endpoint.parseParameters(request.url, '/mfa/passkeys/options', {});
24
+ if (params) {
25
+ return [true, params as Params];
26
+ }
27
+ return [false];
28
+ }
29
+
30
+ async handle(request: DecodedRequest<Params, Query, Body>) {
31
+ await Context.setOptionalOrganizationScope();
32
+ const { user } = await Context.authenticateMFAEnrollment();
33
+
34
+ if (!user.canUsePasskeys()) {
35
+ throw new SimpleError({
36
+ code: 'passkeys_not_available',
37
+ message: 'Passkeys are not available for this account',
38
+ human: $t('%ZhE'),
39
+ statusCode: 400,
40
+ });
41
+ }
42
+
43
+ const existing = await WebauthnCredential.getForUser(user.id);
44
+ const options = await WebauthnHelper.generateRegistration(user, existing);
45
+ await WebauthnChallenge.createFor(user.id, options.challenge);
46
+
47
+ return new Response(WebauthnRegistrationOptions.create({ options }));
48
+ }
49
+ }
@@ -5,6 +5,7 @@ import { EmailVerificationCode } from '@stamhoofd/models';
5
5
  import { PollEmailVerificationRequest, PollEmailVerificationResponse } from '@stamhoofd/structures';
6
6
 
7
7
  import { Context } from '../../helpers/Context.js';
8
+ import { VerificationCodeService } from '../../services/VerificationCodeService.js';
8
9
 
9
10
  type Params = Record<string, never>;
10
11
  type Query = undefined;
@@ -32,7 +33,7 @@ export class PollEmailVerificationEndpoint extends Endpoint<Params, Query, Body,
32
33
  const valid = await EmailVerificationCode.poll(organization?.id ?? null, request.body.token);
33
34
 
34
35
  if (valid) {
35
- EmailVerificationCode.resend(organization, request.body.token, request.i18n).catch(console.error);
36
+ VerificationCodeService.resend(organization, request.body.token, request.i18n).catch(console.error);
36
37
  }
37
38
 
38
39
  return new Response(PollEmailVerificationResponse.create({
@@ -0,0 +1,54 @@
1
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
+ import { MFATOTP } from '@stamhoofd/models';
4
+ import { TOTPSetupResponse } from '@stamhoofd/structures';
5
+
6
+ import { Context } from '../../helpers/Context.js';
7
+ import { TOTPHelper } from '../../helpers/TOTPHelper.js';
8
+
9
+ type Params = Record<string, never>;
10
+ type Query = undefined;
11
+ type Body = undefined;
12
+ type ResponseBody = TOTPSetupResponse;
13
+
14
+ /**
15
+ * Start enrolling a new TOTP authenticator. Creates an unconfirmed row and returns the
16
+ * secret (shown once). Confirm it with ConfirmTOTPEndpoint.
17
+ */
18
+ export class SetupTOTPEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
19
+ protected doesMatch(request: Request): [true, Params] | [false] {
20
+ if (request.method !== 'POST') {
21
+ return [false];
22
+ }
23
+ const params = Endpoint.parseParameters(request.url, '/mfa/totp', {});
24
+ if (params) {
25
+ return [true, params as Params];
26
+ }
27
+ return [false];
28
+ }
29
+
30
+ async handle(request: DecodedRequest<Params, Query, Body>) {
31
+ await Context.setOptionalOrganizationScope();
32
+ const { user } = await Context.authenticateMFAEnrollment();
33
+
34
+ // Drop any unconfirmed authenticators from earlier, abandoned setup attempts so
35
+ // they don't pile up as orphaned rows holding an (encrypted) unused secret.
36
+ await MFATOTP.deleteUnconfirmedForUser(user.id);
37
+
38
+ const secret = TOTPHelper.generateSecret();
39
+ const totp = new MFATOTP();
40
+ totp.userId = user.id;
41
+ totp.name = '';
42
+ totp.secret = TOTPHelper.encrypt(secret);
43
+ totp.confirmedAt = null;
44
+ await totp.save();
45
+
46
+ const otpauthUri = TOTPHelper.keyuri(user.email, secret);
47
+
48
+ return new Response(TOTPSetupResponse.create({
49
+ totpId: totp.id,
50
+ secret,
51
+ otpauthUri,
52
+ }));
53
+ }
54
+ }
@@ -2,10 +2,13 @@ import type { Decoder } from '@simonbackx/simple-encoding';
2
2
  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
- import { EmailVerificationCode, PasswordToken, Platform, sendEmailTemplate, User } from '@stamhoofd/models';
5
+ import { EmailVerificationCode, PasswordToken, Platform, User } from '@stamhoofd/models';
6
+ import { sendEmailTemplate } from '../../helpers/EmailBuilder.js';
6
7
  import { EmailTemplateType, LoginMethod, NewUser, Recipient, Replacement, SignupResponse } from '@stamhoofd/structures';
7
8
 
8
9
  import { Context } from '../../helpers/Context.js';
10
+ import { PasswordForgotService } from '../../services/PasswordForgotService.js';
11
+ import { VerificationCodeService } from '../../services/VerificationCodeService.js';
9
12
 
10
13
  type Params = Record<string, never>;
11
14
  type Query = undefined;
@@ -31,7 +34,17 @@ export class SignupEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
31
34
  async handle(request: DecodedRequest<Params, Query, Body>) {
32
35
  const organization = await Context.setUserOrganizationScope({ willAuthenticate: false });
33
36
 
34
- if (STAMHOOFD.userMode === 'platform') {
37
+ if (!organization) {
38
+ if (STAMHOOFD.userMode === 'organization') {
39
+ // Not supported for now
40
+ throw new SimpleError({
41
+ code: 'not_supported',
42
+ message: 'This platform does not support password login',
43
+ human: $t(`%D7`),
44
+ statusCode: 400,
45
+ });
46
+ }
47
+
35
48
  const platform = await Platform.getShared();
36
49
  if (!platform.config.loginMethods.has(LoginMethod.Password)) {
37
50
  throw new SimpleError({
@@ -77,7 +90,7 @@ export class SignupEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
77
90
  // We don't await this block to avoid user enumeration attack using request response time
78
91
  (async () => {
79
92
  // Send an e-mail to say you already have an account + follow password forgot flow
80
- const recoveryUrl = await PasswordToken.getPasswordRecoveryUrl(user, organization, request.i18n);
93
+ const recoveryUrl = await PasswordForgotService.getPasswordRecoveryUrl(user, organization, request.i18n);
81
94
 
82
95
  // Create e-mail builder
83
96
  await sendEmailTemplate(organization, {
@@ -118,7 +131,7 @@ export class SignupEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
118
131
  const code = await EmailVerificationCode.createFor(user, user.email);
119
132
 
120
133
  if (sendCode) {
121
- code.send(user, organization, request.i18n).catch(console.error);
134
+ VerificationCodeService.send(code, user, organization, request.i18n).catch(console.error);
122
135
  }
123
136
 
124
137
  return new Response(SignupResponse.create({
@@ -6,6 +6,7 @@ import { EmailVerificationCode, Token, User } from '@stamhoofd/models';
6
6
  import { Token as TokenStruct, VerifyEmailRequest } from '@stamhoofd/structures';
7
7
 
8
8
  import { Context } from '../../helpers/Context.js';
9
+ import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
9
10
  import { BalanceItemService } from '../../services/BalanceItemService.js';
10
11
 
11
12
  type Params = Record<string, never>;
@@ -50,6 +51,11 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
50
51
  });
51
52
  }
52
53
 
54
+ // Read the (optional) session of the caller before consuming the code: a user that
55
+ // is already signed in is verifying a new email address from their account
56
+ // settings, and already passed their second factor for that session.
57
+ const authenticatedUser = await Context.optionalAuthenticatedUser();
58
+
53
59
  const code = await EmailVerificationCode.verify(organization?.id ?? null, request.body.token, request.body.code);
54
60
 
55
61
  if (!code) {
@@ -105,7 +111,17 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
105
111
  throw e;
106
112
  }
107
113
 
114
+ // Verifying an email is a single primary credential: if this user already has a
115
+ // second factor (or must enroll one), don't hand out a session without it. Unless
116
+ // the request already comes from a session of that same user (changing your email
117
+ // address while signed in): the second factor was checked when it was created.
118
+ if (authenticatedUser?.id !== user.id) {
119
+ await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion());
120
+ }
121
+
108
122
  const token = await Token.createToken(user);
123
+ await user.markActive();
124
+
109
125
  const st = new TokenStruct(token);
110
126
  return new Response(st);
111
127
  }
@@ -7,6 +7,8 @@ import { EmailStatus, Email as EmailStruct, EmailTemplate as EmailTemplateStruct
7
7
 
8
8
  import { Context } from '../../../helpers/Context.js';
9
9
  import { SimpleError } from '@simonbackx/simple-errors';
10
+ import { EmailPreviewService } from '../../../services/EmailPreviewService.js';
11
+ import { EmailSendService } from '../../../services/EmailSendService.js';
10
12
 
11
13
  type Params = Record<string, never>;
12
14
  type Query = undefined;
@@ -131,7 +133,7 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
131
133
  }
132
134
 
133
135
  await model.save();
134
- await model.buildExampleRecipient(true);
136
+ await EmailSendService.buildExampleRecipient(model, true);
135
137
  await model.updateCount();
136
138
 
137
139
  if (request.body.status === EmailStatus.Sending || request.body.status === EmailStatus.Sent || request.body.status === EmailStatus.Queued) {
@@ -141,7 +143,7 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
141
143
  human: $t('%1Cw'),
142
144
  });
143
145
  }
144
- await model.queueForSending();
146
+ await EmailSendService.queueForSending(model);
145
147
  }
146
148
 
147
149
  // Delete open drafts with the same content, from the same user
@@ -162,6 +164,6 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
162
164
  await duplicate.save();
163
165
  }
164
166
 
165
- return new Response(await model.getPreviewStructure({ allLanguages: true }));
167
+ return new Response(await EmailPreviewService.getPreviewStructure(model, { allLanguages: true }));
166
168
  }
167
169
  }
@@ -157,7 +157,7 @@ describe('Endpoint.getAdminEmails', () => {
157
157
  expect(unsubscribeUrlReplacement!.value).toMatch(/^https:\/\//); // Should still be a valid URL
158
158
  expect(unsubscribeUrlReplacement!.value).not.toContain('secret-token-12345'); // Original sensitive token should be gone
159
159
 
160
- // This tests that Email.getStructureForUser properly handles sensitive data by:
160
+ // This tests that EmailPreviewService.getStructureForUser properly handles sensitive data by:
161
161
  // 1. Removing original sensitive replacements from other users' data
162
162
  // 2. Creating fresh, appropriate replacements for the current viewer
163
163
  // 3. Ensuring web safety of all replacement values
@@ -9,6 +9,7 @@ import { Email, Platform } from '@stamhoofd/models';
9
9
  import type { SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
10
10
  import { applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
11
11
  import { Context } from '../../../helpers/Context.js';
12
+ import { EmailPreviewService } from '../../../services/EmailPreviewService.js';
12
13
  import { emailFilterCompilers } from '../../../sql-filters/emails.js';
13
14
  import { emailSorters } from '../../../sql-sorters/emails.js';
14
15
 
@@ -158,7 +159,7 @@ export class GetAdminEmailsEndpoint extends Endpoint<Params, Query, Body, Respon
158
159
  }
159
160
 
160
161
  return new PaginatedResponse<EmailPreview[], LimitedFilteredRequest>({
161
- results: await Promise.all(emails.map(email => email.getPreviewStructure({ allLanguages: true }))),
162
+ results: await Promise.all(emails.map(email => EmailPreviewService.getPreviewStructure(email, { allLanguages: true }))),
162
163
  next,
163
164
  });
164
165
  }
@@ -5,6 +5,7 @@ import type { EmailPreview } from '@stamhoofd/structures';
5
5
 
6
6
  import { SimpleError } from '@simonbackx/simple-errors';
7
7
  import { Context } from '../../../helpers/Context.js';
8
+ import { EmailPreviewService } from '../../../services/EmailPreviewService.js';
8
9
 
9
10
  type Params = { id: string };
10
11
  type Query = undefined;
@@ -51,6 +52,6 @@ export class GetEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBody
51
52
  throw Context.auth.error();
52
53
  }
53
54
 
54
- return new Response(await model.getPreviewStructure({ allLanguages: true }));
55
+ return new Response(await EmailPreviewService.getPreviewStructure(model, { allLanguages: true }));
55
56
  }
56
57
  }
@@ -537,7 +537,7 @@ describe('Endpoint.GetUserEmails', () => {
537
537
  expect(unsubscribeUrlReplacement!.value).toMatch(/^https:\/\//); // Should still be a valid URL
538
538
  expect(unsubscribeUrlReplacement!.value).not.toContain('secret-token-12345'); // Original sensitive token should be gone
539
539
 
540
- // This tests that Email.getStructureForUser properly handles sensitive data by:
540
+ // This tests that EmailPreviewService.getStructureForUser properly handles sensitive data by:
541
541
  // 1. Removing original sensitive replacements from other users' data
542
542
  // 2. Creating fresh, appropriate replacements for the current viewer
543
543
  // 3. Ensuring web safety of all replacement values
@@ -11,6 +11,7 @@ import type { Language } from '@stamhoofd/types/Language';
11
11
  import type { SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
12
12
  import { applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
13
13
  import { Context } from '../../../helpers/Context.js';
14
+ import { EmailPreviewService } from '../../../services/EmailPreviewService.js';
14
15
  import { emailFilterCompilers, userEmailFilterCompilers } from '../../../sql-filters/emails.js';
15
16
  import { emailSorters } from '../../../sql-sorters/emails.js';
16
17
 
@@ -135,7 +136,7 @@ export class GetUserEmailsEndpoint extends Endpoint<Params, Query, Body, Respons
135
136
  }
136
137
 
137
138
  return new PaginatedResponse<EmailWithRecipients[], LimitedFilteredRequest>({
138
- results: await Promise.all(emails.map(email => email.getStructureForUser(user, memberIds, options))),
139
+ results: await Promise.all(emails.map(email => EmailPreviewService.getStructureForUser(email, user, memberIds, options))),
139
140
  next,
140
141
  });
141
142
  }
@@ -8,6 +8,8 @@ import type { AutoEncoderPatchType, Decoder } from '@simonbackx/simple-encoding'
8
8
  import { patchObject } from '@simonbackx/simple-encoding';
9
9
  import { SimpleError } from '@simonbackx/simple-errors';
10
10
  import { Context } from '../../../helpers/Context.js';
11
+ import { EmailPreviewService } from '../../../services/EmailPreviewService.js';
12
+ import { EmailSendService } from '../../../services/EmailSendService.js';
11
13
 
12
14
  type Params = { id: string };
13
15
  type Query = undefined;
@@ -195,7 +197,7 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
195
197
  await model.save();
196
198
 
197
199
  if (rebuild) {
198
- await model.buildExampleRecipient();
200
+ await EmailSendService.buildExampleRecipient(model);
199
201
  await model.updateCount();
200
202
  }
201
203
 
@@ -208,9 +210,9 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
208
210
  }
209
211
 
210
212
  // Preview the sending status
211
- await model.queueForSending();
213
+ await EmailSendService.queueForSending(model);
212
214
  }
213
215
 
214
- return new Response(await model.getPreviewStructure({ allLanguages: true }));
216
+ return new Response(await EmailPreviewService.getPreviewStructure(model, { allLanguages: true }));
215
217
  }
216
218
  }
@@ -5,7 +5,8 @@ import { assertSort, getSortFilter, LimitedFilteredRequest, PaginatedResponse, P
5
5
 
6
6
  import type { Decoder } from '@simonbackx/simple-encoding';
7
7
  import { SimpleError } from '@simonbackx/simple-errors';
8
- import { EmailRecipient, fillRecipientReplacements } from '@stamhoofd/models';
8
+ import { EmailRecipient } from '@stamhoofd/models';
9
+ import { fillRecipientReplacements } from '../../../helpers/EmailBuilder.js';
9
10
  import type { SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
10
11
  import { applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
11
12
  import { Context } from '../../../helpers/Context.js';
@@ -6,6 +6,7 @@ import { EmailStatus, PermissionLevel } from '@stamhoofd/structures';
6
6
 
7
7
  import { SimpleError } from '@simonbackx/simple-errors';
8
8
  import { Context } from '../../../helpers/Context.js';
9
+ import { EmailSendService } from '../../../services/EmailSendService.js';
9
10
 
10
11
  type Params = { id: string };
11
12
  type Query = undefined;
@@ -76,7 +77,7 @@ export class RetryEmailRecipientEndpoint extends Endpoint<Params, Query, Body, R
76
77
  }
77
78
 
78
79
  // Retry
79
- await model.resumeSending(emailRecipient.id);
80
+ await EmailSendService.resumeSending(model, emailRecipient.id);
80
81
 
81
82
  await emailRecipient.refresh();
82
83
 
@@ -4,9 +4,10 @@ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
5
  import type { XlsxTransformerSheet } from '@stamhoofd/excel-writer';
6
6
  import { ArchiverWriterAdapter, exportToExcel, XlsxWriter } from '@stamhoofd/excel-writer';
7
- import { Platform, RateLimiter, sendEmailTemplate } from '@stamhoofd/models';
7
+ import { Platform, RateLimiter } from '@stamhoofd/models';
8
+ import { sendEmailTemplate } from '../../../helpers/EmailBuilder.js';
8
9
  import { QueueHandler } from '@stamhoofd/queues';
9
- import type { ExcelExportType, IPaginatedResponse, LimitedFilteredRequest } from '@stamhoofd/structures';
10
+ import type { ExcelExportType, IPaginatedResponse, LimitedFilteredRequest, Platform as PlatformStruct } from '@stamhoofd/structures';
10
11
  import { EmailTemplateType, ExcelExportRequest, ExcelExportResponse, Replacement, Version } from '@stamhoofd/structures';
11
12
  import { sleep } from '@stamhoofd/utility';
12
13
  import { Context } from '../../../helpers/Context.js';
@@ -20,7 +21,12 @@ type ResponseBody = ExcelExportResponse;
20
21
 
21
22
  type ExcelExporter<T> = {
22
23
  fetch(request: LimitedFilteredRequest): Promise<IPaginatedResponse<T[], LimitedFilteredRequest>>;
23
- sheets: XlsxTransformerSheet<T, unknown>[];
24
+
25
+ /**
26
+ * The sheet definitions depend on the platform: they are built per export request instead of
27
+ * once at module load, so the callbacks never have to read a process wide platform singleton.
28
+ */
29
+ getSheets(platform: PlatformStruct): XlsxTransformerSheet<T, unknown>[];
24
30
  };
25
31
 
26
32
  export const limiter = new RateLimiter({
@@ -86,8 +92,6 @@ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, Respons
86
92
  limiter.track(user.id, 1);
87
93
  let sendEmail = false;
88
94
 
89
- await Platform.getSharedStruct();
90
-
91
95
  const result = await Promise.race([
92
96
  this.job(loader, request.body, request.params.type, user.id).then(async (url: string) => {
93
97
  if (sendEmail) {
@@ -146,6 +150,7 @@ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, Respons
146
150
  // Estimate how long it will take.
147
151
  // If too long, we'll schedule it and write it to Digitalocean Spaces
148
152
  // Otherwise we'll just return the file directly
153
+ const platform = await Platform.getSharedStruct();
149
154
  const { file, stream } = await FileCache.getWriteStream('.xlsx');
150
155
 
151
156
  const zipWriterAdapter = new ArchiverWriterAdapter(stream);
@@ -155,7 +160,7 @@ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, Respons
155
160
  request.filter.limit = STAMHOOFD.environment === 'development' ? 1000 : 100; // in development, we need to check if total count matches and pagination is working correctly
156
161
 
157
162
  await exportToExcel({
158
- definitions: loader.sheets,
163
+ definitions: loader.getSheets(platform),
159
164
  writer,
160
165
  dataGenerator: fetchToAsyncIterator(request.filter, loader, { signFiles: true }),
161
166
  filter: request.workbookFilter,