@stamhoofd/backend 2.137.5 → 2.138.1

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 (92) hide show
  1. package/package.json +20 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/index.ts +1 -0
  6. package/src/crons/invoices.ts +5 -3
  7. package/src/email-recipient-loaders/orders.ts +2 -1
  8. package/src/email-recipient-loaders/organizations.ts +3 -2
  9. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  10. package/src/endpoints/auth/CreateAdminEndpoint.ts +2 -1
  11. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  12. package/src/endpoints/auth/CreateTokenEndpoint.ts +133 -6
  13. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  14. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  15. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  16. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -1
  17. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  18. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  19. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  20. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  21. package/src/endpoints/auth/MFA.test.ts +1398 -0
  22. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  23. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  24. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  25. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  26. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  27. package/src/endpoints/auth/SignupEndpoint.ts +13 -2
  28. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  29. package/src/endpoints/global/email/CreateEmailEndpoint.ts +3 -2
  30. package/src/endpoints/global/email/PatchEmailEndpoint.ts +3 -2
  31. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  32. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  33. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +2 -1
  34. package/src/endpoints/global/files/UploadFile.ts +1 -1
  35. package/src/endpoints/global/files/UploadImage.ts +1 -1
  36. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  37. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  38. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  39. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  40. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  41. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  42. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  43. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  44. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  45. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  46. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  47. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  48. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  49. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  50. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  51. package/src/helpers/AuthenticatedStructures.ts +9 -1
  52. package/src/helpers/Context.ts +114 -4
  53. package/src/helpers/EmailBuilder.test.ts +287 -0
  54. package/src/helpers/EmailBuilder.ts +811 -0
  55. package/src/helpers/EmailResumer.ts +2 -1
  56. package/src/helpers/ForwardHandler.ts +2 -1
  57. package/src/helpers/MFAEncryption.ts +34 -0
  58. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  59. package/src/helpers/TOTPHelper.ts +79 -0
  60. package/src/helpers/TenantContext.test.ts +96 -0
  61. package/src/helpers/TenantContext.ts +67 -0
  62. package/src/helpers/TwoFactorHelper.ts +356 -0
  63. package/src/helpers/WebauthnHelper.test.ts +144 -0
  64. package/src/helpers/WebauthnHelper.ts +239 -0
  65. package/src/helpers/data/aaguids.json +1006 -0
  66. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  67. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  68. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  69. package/src/services/AdminSessionService.ts +41 -0
  70. package/src/services/EmailPreviewService.ts +1 -1
  71. package/src/services/EmailSendService.test.ts +1218 -0
  72. package/src/services/EmailSendService.ts +705 -0
  73. package/src/services/EventNotificationService.ts +2 -1
  74. package/src/services/InvoicePdfService.ts +3 -3
  75. package/src/services/InvoiceService.ts +4 -2
  76. package/src/services/InvoiceXMLService.ts +2 -1
  77. package/src/services/OrderService.ts +2 -1
  78. package/src/services/OrganizationAdminService.test.ts +47 -0
  79. package/src/services/OrganizationAdminService.ts +139 -0
  80. package/src/services/OrganizationEmailService.ts +3 -2
  81. package/src/services/PaymentService.ts +5 -3
  82. package/src/services/ReferralService.ts +3 -2
  83. package/src/services/RegistrationService.ts +2 -1
  84. package/src/services/SSOService.ts +106 -14
  85. package/src/services/STPackageService.ts +4 -2
  86. package/src/services/TwoFactorAuditLogService.ts +64 -0
  87. package/src/services/VerificationCodeService.ts +2 -1
  88. package/tests/e2e/api-rate-limits.test.ts +1 -1
  89. package/tests/helpers/MFATestHelper.ts +42 -0
  90. package/tests/helpers/TestServer.ts +4 -0
  91. package/tests/helpers/index.ts +1 -0
  92. 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,
@@ -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
+ }
@@ -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,7 +2,8 @@ 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';
@@ -33,7 +34,17 @@ export class SignupEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
33
34
  async handle(request: DecodedRequest<Params, Query, Body>) {
34
35
  const organization = await Context.setUserOrganizationScope({ willAuthenticate: false });
35
36
 
36
- 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
+
37
48
  const platform = await Platform.getShared();
38
49
  if (!platform.config.loginMethods.has(LoginMethod.Password)) {
39
50
  throw new SimpleError({
@@ -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
  }
@@ -8,6 +8,7 @@ import { EmailStatus, Email as EmailStruct, EmailTemplate as EmailTemplateStruct
8
8
  import { Context } from '../../../helpers/Context.js';
9
9
  import { SimpleError } from '@simonbackx/simple-errors';
10
10
  import { EmailPreviewService } from '../../../services/EmailPreviewService.js';
11
+ import { EmailSendService } from '../../../services/EmailSendService.js';
11
12
 
12
13
  type Params = Record<string, never>;
13
14
  type Query = undefined;
@@ -132,7 +133,7 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
132
133
  }
133
134
 
134
135
  await model.save();
135
- await model.buildExampleRecipient(true);
136
+ await EmailSendService.buildExampleRecipient(model, true);
136
137
  await model.updateCount();
137
138
 
138
139
  if (request.body.status === EmailStatus.Sending || request.body.status === EmailStatus.Sent || request.body.status === EmailStatus.Queued) {
@@ -142,7 +143,7 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
142
143
  human: $t('%1Cw'),
143
144
  });
144
145
  }
145
- await model.queueForSending();
146
+ await EmailSendService.queueForSending(model);
146
147
  }
147
148
 
148
149
  // Delete open drafts with the same content, from the same user
@@ -9,6 +9,7 @@ import { patchObject } from '@simonbackx/simple-encoding';
9
9
  import { SimpleError } from '@simonbackx/simple-errors';
10
10
  import { Context } from '../../../helpers/Context.js';
11
11
  import { EmailPreviewService } from '../../../services/EmailPreviewService.js';
12
+ import { EmailSendService } from '../../../services/EmailSendService.js';
12
13
 
13
14
  type Params = { id: string };
14
15
  type Query = undefined;
@@ -196,7 +197,7 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
196
197
  await model.save();
197
198
 
198
199
  if (rebuild) {
199
- await model.buildExampleRecipient();
200
+ await EmailSendService.buildExampleRecipient(model);
200
201
  await model.updateCount();
201
202
  }
202
203
 
@@ -209,7 +210,7 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
209
210
  }
210
211
 
211
212
  // Preview the sending status
212
- await model.queueForSending();
213
+ await EmailSendService.queueForSending(model);
213
214
  }
214
215
 
215
216
  return new Response(await EmailPreviewService.getPreviewStructure(model, { allLanguages: true }));
@@ -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,7 +4,8 @@ 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
10
  import type { ExcelExportType, IPaginatedResponse, LimitedFilteredRequest, Platform as PlatformStruct } from '@stamhoofd/structures';
10
11
  import { EmailTemplateType, ExcelExportRequest, ExcelExportResponse, Replacement, Version } from '@stamhoofd/structures';
@@ -133,7 +133,7 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
133
133
  throw new SimpleError({
134
134
  code: 'invalid_file_type',
135
135
  message: 'Unsupported file type ' + (file.mimetype ?? 'unknown') + ' for file ' + (file.originalFilename ?? 'unknown'),
136
- human: $t('Dit type bestand kan je niet uploaden.'),
136
+ human: $t('%ZiD'),
137
137
  field: 'file',
138
138
  statusCode: 400,
139
139
  });
@@ -140,7 +140,7 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
140
140
  throw new SimpleError({
141
141
  code: 'invalid_file_type',
142
142
  message: 'Unsupported image type ' + (file.mimetype ?? 'unknown') + ' for file ' + (file.originalFilename ?? 'unknown'),
143
- human: $t('Dit soort afbeelding kan je niet opladen.'),
143
+ human: $t('%ZhP'),
144
144
  field: 'file',
145
145
  statusCode: 400,
146
146
  });
@@ -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';
@@ -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
+ });