@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
@@ -0,0 +1,62 @@
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 { MFATOTP, WebauthnCredential } from '@stamhoofd/models';
5
+ import type { MFAStatus } from '@stamhoofd/structures';
6
+ import { MFAMethodType } from '@stamhoofd/structures';
7
+
8
+ import { Context } from '../../helpers/Context.js';
9
+ import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
10
+ import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
11
+
12
+ type Params = { id: string };
13
+ type Query = undefined;
14
+ type Body = undefined;
15
+ type ResponseBody = MFAStatus;
16
+
17
+ export class DeleteTOTPEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
18
+ protected doesMatch(request: Request): [true, Params] | [false] {
19
+ if (request.method !== 'DELETE') {
20
+ return [false];
21
+ }
22
+ const params = Endpoint.parseParameters(request.url, '/mfa/totp/@id', { id: String });
23
+ if (params) {
24
+ return [true, params as Params];
25
+ }
26
+ return [false];
27
+ }
28
+
29
+ async handle(request: DecodedRequest<Params, Query, Body>) {
30
+ const organization = await Context.setOptionalOrganizationScope();
31
+ const { user, token } = await Context.authenticateFresh({ allowWithoutAccount: true, allowUnscoped: true });
32
+
33
+ const totp = await MFATOTP.getByID(request.params.id);
34
+ if (!totp || totp.userId !== user.id) {
35
+ throw new SimpleError({
36
+ code: 'not_found',
37
+ message: 'TOTP authenticator not found',
38
+ human: $t('%ZgJ'),
39
+ statusCode: 404,
40
+ });
41
+ }
42
+
43
+ // Prevent removing the last factor when 2FA is required.
44
+ const confirmedTotp = await MFATOTP.getConfirmedForUser(user.id);
45
+ const passkeys = await WebauthnCredential.getForUser(user.id);
46
+ const remaining = confirmedTotp.filter(t => t.id !== totp.id).length + passkeys.length;
47
+ if (remaining === 0 && await TwoFactorHelper.isTwoFactorRequired(user, organization ?? Context.organization ?? null)) {
48
+ throw new SimpleError({
49
+ code: 'cannot_remove_last_factor',
50
+ message: 'Cannot remove the last two-factor method',
51
+ human: $t('%Zh4'),
52
+ statusCode: 400,
53
+ });
54
+ }
55
+
56
+ await totp.delete();
57
+
58
+ await TwoFactorAuditLogService.logMethodDeleted(user, MFAMethodType.TOTP, totp.name);
59
+
60
+ return new Response(await TwoFactorHelper.completeRemoval(user, token));
61
+ }
62
+ }
@@ -1,7 +1,7 @@
1
1
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
2
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
3
 
4
- import { getDefaultEmailFrom, sendEmailTemplate } from '@stamhoofd/models';
4
+ import { getDefaultEmailFrom, sendEmailTemplate } from '../../helpers/EmailBuilder.js';
5
5
  import { EmailTemplateType, Recipient } from '@stamhoofd/structures';
6
6
  import { Context } from '../../helpers/Context.js';
7
7
 
@@ -1,7 +1,8 @@
1
1
  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
- import { Platform, sendEmailTemplate, User } from '@stamhoofd/models';
4
+ import { Platform, User } from '@stamhoofd/models';
5
+ import { sendEmailTemplate } from '../../helpers/EmailBuilder.js';
5
6
  import { EmailTemplateType, ForgotPasswordRequest, LoginMethod, Recipient, Replacement } from '@stamhoofd/structures';
6
7
 
7
8
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -0,0 +1,56 @@
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 { MFAToken } from '@stamhoofd/models';
6
+ import type { MFAChallengeResponse } from '@stamhoofd/structures';
7
+ import { MFAChallengeRequest } from '@stamhoofd/structures';
8
+
9
+ import { Context } from '../../helpers/Context.js';
10
+ import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
11
+
12
+ type Params = Record<string, never>;
13
+ type Query = undefined;
14
+ type Body = MFAChallengeRequest;
15
+ type ResponseBody = MFAChallengeResponse;
16
+
17
+ /**
18
+ * Describe the second factors that can complete a pending login.
19
+ *
20
+ * Password logins receive this as the `meta` of the `require_mfa` error, but an SSO login
21
+ * ends in a browser redirect that can only carry the token itself. The client posts that
22
+ * token here to learn which methods it may offer.
23
+ *
24
+ * Holding the token means the primary credential was already accepted, and the response
25
+ * carries no secrets: only which method types are enrolled, plus fresh WebAuthn options.
26
+ */
27
+ export class GetMFAChallengeEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
28
+ bodyDecoder = MFAChallengeRequest as Decoder<MFAChallengeRequest>;
29
+
30
+ protected doesMatch(request: Request): [true, Params] | [false] {
31
+ if (request.method !== 'POST') {
32
+ return [false];
33
+ }
34
+ const params = Endpoint.parseParameters(request.url, '/mfa/challenge', {});
35
+ if (params) {
36
+ return [true, params as Params];
37
+ }
38
+ return [false];
39
+ }
40
+
41
+ async handle(request: DecodedRequest<Params, Query, Body>) {
42
+ await Context.setOptionalOrganizationScope({ willAuthenticate: false });
43
+
44
+ const mfaToken = await MFAToken.getValid(request.body.token, 'login');
45
+ if (!mfaToken) {
46
+ throw new SimpleError({
47
+ code: 'invalid_mfa_token',
48
+ message: 'The MFA session is invalid or expired',
49
+ human: $t('%ZhJ'),
50
+ statusCode: 400,
51
+ });
52
+ }
53
+
54
+ return new Response(await TwoFactorHelper.describeLoginChallenge(mfaToken));
55
+ }
56
+ }
@@ -0,0 +1,31 @@
1
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
+ import type { MFAStatus } from '@stamhoofd/structures';
4
+
5
+ import { Context } from '../../helpers/Context.js';
6
+ import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = undefined;
10
+ type Body = undefined;
11
+ type ResponseBody = MFAStatus;
12
+
13
+ export class GetMFAStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
+ protected doesMatch(request: Request): [true, Params] | [false] {
15
+ if (request.method !== 'GET') {
16
+ return [false];
17
+ }
18
+ const params = Endpoint.parseParameters(request.url, '/mfa', {});
19
+ if (params) {
20
+ return [true, params as Params];
21
+ }
22
+ return [false];
23
+ }
24
+
25
+ async handle(request: DecodedRequest<Params, Query, Body>) {
26
+ await Context.setOptionalOrganizationScope();
27
+ const { user } = await Context.authenticate({ allowWithoutAccount: true, allowUnscoped: true });
28
+
29
+ return new Response(await TwoFactorHelper.buildStatus(user));
30
+ }
31
+ }
@@ -1,6 +1,7 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
- import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
2
+ import { MemberFactory, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
3
3
 
4
+ import { MFATestHelper } from '../../../tests/helpers/MFATestHelper.js';
4
5
  import { testServer } from '../../../tests/helpers/TestServer.js';
5
6
  import { GetUserEndpoint } from './GetUserEndpoint.js';
6
7
  import { STExpect } from '@stamhoofd/test-utils';
@@ -22,6 +23,36 @@ describe('Endpoint.GetUser', () => {
22
23
  expect(response.body.id).toEqual(user.id);
23
24
  });
24
25
 
26
+ test('Two-factor authentication is reported on the user and on the accounts of a member', async () => {
27
+ const organization = await new OrganizationFactory({}).create();
28
+ const user = await new UserFactory({ organization }).create();
29
+ await new MemberFactory({ organization, user }).create();
30
+ await MFATestHelper.addConfirmedTOTP(user);
31
+
32
+ const token = await Token.createToken(user);
33
+ const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
34
+ r.headers.authorization = 'Bearer ' + token.accessToken;
35
+
36
+ const response = await testServer.test(endpoint, r);
37
+ expect(response.body.hasTwoFactor).toBe(true);
38
+
39
+ // The same flag is what MemberAccountBox shows for every account of a member.
40
+ const member = response.body.members.members.find(m => m.users.some(u => u.id === user.id));
41
+ expect(member?.users.find(u => u.id === user.id)?.hasTwoFactor).toBe(true);
42
+ });
43
+
44
+ test('A user without a second factor is not reported as having two-factor authentication', async () => {
45
+ const organization = await new OrganizationFactory({}).create();
46
+ const user = await new UserFactory({ organization }).create();
47
+ const token = await Token.createToken(user);
48
+
49
+ const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
50
+ r.headers.authorization = 'Bearer ' + token.accessToken;
51
+
52
+ const response = await testServer.test(endpoint, r);
53
+ expect(response.body.hasTwoFactor).toBe(false);
54
+ });
55
+
25
56
  test('Request user details when not signed in is not working', async () => {
26
57
  const organization = await new OrganizationFactory({}).create();
27
58
  const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());