@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.137.4",
3
+ "version": "2.138.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -19,6 +19,12 @@
19
19
  "import": "./dist/src/boot.js",
20
20
  "require": "./dist/src/boot.js"
21
21
  },
22
+ "./services/*": {
23
+ "types": "./dist/src/services/*.d.ts",
24
+ "import": "./dist/src/services/*.js",
25
+ "require": "./dist/src/services/*.js",
26
+ "@stamhoofd/source": "./src/services/*.ts"
27
+ },
22
28
  "./tests/helpers": {
23
29
  "types": "./dist/tests/helpers/index.d.ts",
24
30
  "import": "./dist/tests/helpers/index.js",
@@ -58,21 +64,23 @@
58
64
  "@simonbackx/simple-endpoints": "1.22.0",
59
65
  "@simonbackx/simple-errors": "1.5.0",
60
66
  "@simonbackx/simple-logging": "1.0.1",
61
- "@stamhoofd/backend-env": "2.137.4",
62
- "@stamhoofd/backend-i18n": "2.137.4",
63
- "@stamhoofd/backend-middleware": "2.137.4",
64
- "@stamhoofd/crons": "2.137.4",
65
- "@stamhoofd/email": "2.137.4",
66
- "@stamhoofd/excel-writer": "2.137.4",
67
- "@stamhoofd/logging": "2.137.4",
68
- "@stamhoofd/models": "2.137.4",
69
- "@stamhoofd/object-differ": "2.137.4",
70
- "@stamhoofd/queues": "2.137.4",
71
- "@stamhoofd/sql": "2.137.4",
72
- "@stamhoofd/structures": "2.137.4",
73
- "@stamhoofd/types": "2.137.4",
74
- "@stamhoofd/utility": "2.137.4",
67
+ "@simplewebauthn/server": "13.3.2",
68
+ "@stamhoofd/backend-env": "2.138.0",
69
+ "@stamhoofd/backend-i18n": "2.138.0",
70
+ "@stamhoofd/backend-middleware": "2.138.0",
71
+ "@stamhoofd/crons": "2.138.0",
72
+ "@stamhoofd/email": "2.138.0",
73
+ "@stamhoofd/excel-writer": "2.138.0",
74
+ "@stamhoofd/logging": "2.138.0",
75
+ "@stamhoofd/models": "2.138.0",
76
+ "@stamhoofd/object-differ": "2.138.0",
77
+ "@stamhoofd/queues": "2.138.0",
78
+ "@stamhoofd/sql": "2.138.0",
79
+ "@stamhoofd/structures": "2.138.0",
80
+ "@stamhoofd/types": "2.138.0",
81
+ "@stamhoofd/utility": "2.138.0",
75
82
  "archiver": "7.0.1",
83
+ "argon2": "0.44.0",
76
84
  "axios": "1.18.1",
77
85
  "base-x": "3.0.11",
78
86
  "chalk": "5.6.2",
@@ -89,11 +97,12 @@
89
97
  "mysql2": "3.23.1",
90
98
  "node-rsa": "2.0.0",
91
99
  "openid-client": "5.7.1",
100
+ "otplib": "12.0.1",
92
101
  "stripe": "16.12.0",
93
102
  "uuid": "14.0.1"
94
103
  },
95
104
  "devDependencies": {
96
- "@stamhoofd/test-utils": "2.137.4",
105
+ "@stamhoofd/test-utils": "2.138.0",
97
106
  "@types/cookie": "0.6.0",
98
107
  "@types/luxon": "3.7.2",
99
108
  "@types/mailparser": "3.4.6",
@@ -108,5 +117,5 @@
108
117
  "publishConfig": {
109
118
  "access": "public"
110
119
  },
111
- "gitHead": "22cb2b32de8f785d8e0d94f324940d6419fbe7ea"
120
+ "gitHead": "781877acb024516e384b4119af7e86f847a9d237"
112
121
  }
package/src/boot.ts CHANGED
@@ -18,6 +18,7 @@ import { resumeEmails } from './helpers/EmailResumer.js';
18
18
  import { GlobalHelper } from './helpers/GlobalHelper.js';
19
19
  import { SetupStepUpdater } from './helpers/SetupStepUpdater.js';
20
20
  import { ContextMiddleware } from './middleware/ContextMiddleware.js';
21
+ import { TenantScopeMiddleware } from './middleware/TenantScopeMiddleware.js';
21
22
  import { AuditLogService } from './services/AuditLogService.js';
22
23
  import { BalanceItemService } from './services/BalanceItemService.js';
23
24
  import { BootChecksService } from './services/BootChecksService.js';
@@ -137,6 +138,10 @@ export const boot = async (options: { killProcess: boolean }) => {
137
138
  // Contexts
138
139
  routerServer.addRequestMiddleware(ContextMiddleware);
139
140
 
141
+ // After ContextMiddleware on purpose: the last registered wrapRun is the outermost, and the
142
+ // tenant has to be in scope before the context that reads it.
143
+ routerServer.addRequestMiddleware(TenantScopeMiddleware);
144
+
140
145
  // Add version headers and minimum version
141
146
  const versionMiddleware = new VersionMiddleware({
142
147
  latestVersions: {
@@ -6,6 +6,7 @@ import type { OrganizationEmail, StamhoofdFilter } from '@stamhoofd/structures';
6
6
  import { EmailRecipientFilter, EmailRecipientSubfilter, EmailTemplateType, ReceivableBalanceType } from '@stamhoofd/structures';
7
7
  import { ContextInstance } from '../helpers/Context.js';
8
8
  import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
9
+ import { EmailSendService } from '../services/EmailSendService.js';
9
10
 
10
11
  registerCron('balanceEmails', balanceEmails);
11
12
 
@@ -201,7 +202,7 @@ async function sendTemplate({
201
202
 
202
203
  try {
203
204
  const upToDate = await ContextInstance.startForUser(systemUser, organization, async () => {
204
- return await model.queueForSending(true);
205
+ return await EmailSendService.queueForSending(model, true);
205
206
  });
206
207
 
207
208
  if (!upToDate) {
@@ -0,0 +1,35 @@
1
+ import { registerCron } from '@stamhoofd/crons';
2
+ import { MFAToken, WebauthnChallenge } from '@stamhoofd/models';
3
+
4
+ let lastRunDate: number | null = null;
5
+
6
+ registerCron('deleteExpiredMFATokens', deleteExpiredMFATokens);
7
+
8
+ /**
9
+ * Sweep the short-lived tokens of the two-factor flows.
10
+ *
11
+ * Both are also cleaned up while they are used (a new challenge supersedes the previous
12
+ * one, and an expired token is dropped when it is looked up), but a token of a login that
13
+ * was simply abandoned has nothing left to trigger that. Run every night at 5 AM.
14
+ */
15
+ export async function deleteExpiredMFATokens() {
16
+ const now = new Date();
17
+
18
+ if (now.getDate() === lastRunDate) {
19
+ return;
20
+ }
21
+
22
+ const hour = now.getHours();
23
+
24
+ // between 5 and 6 AM
25
+ if (hour !== 5 && STAMHOOFD.environment !== 'development') {
26
+ return;
27
+ }
28
+
29
+ const tokens = await MFAToken.deleteExpired();
30
+ const challenges = await WebauthnChallenge.deleteExpired();
31
+
32
+ console.log(`Deleted ${tokens} expired MFA tokens and ${challenges} expired WebAuthn challenges.`);
33
+
34
+ lastRunDate = now.getDate();
35
+ }
@@ -1,5 +1,6 @@
1
1
  import { registerCron } from '@stamhoofd/crons';
2
2
  import { Organization } from '@stamhoofd/models';
3
+ import { OrganizationEmailService } from '../services/OrganizationEmailService.js';
3
4
  import { isOutside } from './helpers/isOutside.js';
4
5
  import { useSavedIterator } from './helpers/useSavedIterator.js';
5
6
 
@@ -30,7 +31,7 @@ async function checkDrips() {
30
31
  // Get the next x organization to send e-mails for
31
32
  for await (const organization of iterate()) {
32
33
  try {
33
- await organization.checkDrips();
34
+ await OrganizationEmailService.checkDrips(organization);
34
35
  } catch (e) {
35
36
  console.error(e);
36
37
  }
@@ -5,6 +5,7 @@ import './update-cached-balances.js';
5
5
  import './cleanup-orphaned-cached-balances.js';
6
6
  import './balance-emails.js';
7
7
  import './delete-old-email-drafts.js';
8
+ import './delete-expired-mfa-tokens.js';
8
9
  import './delete-archived-data.js';
9
10
  import './mollie-chargebacks.js';
10
11
  import './mollie-refunds.js';
@@ -1,13 +1,15 @@
1
1
  import { isSimpleError, isSimpleErrors } from '@simonbackx/simple-errors';
2
2
  import { registerCron } from '@stamhoofd/crons';
3
3
  import type { Invoice } from '@stamhoofd/models';
4
- import { Organization, Payment, sendEmailTemplate } from '@stamhoofd/models';
4
+ import { Organization, Payment } from '@stamhoofd/models';
5
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
5
6
  import { EmailTemplateType, InvoiceStruct, PaymentStatus, Replacement } from '@stamhoofd/structures';
6
7
  import { Formatter, Sorter } from '@stamhoofd/utility';
7
8
  import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
8
9
  import { InvoiceService } from '../services/InvoiceService.js';
9
10
  import { useSavedIterator } from './helpers/useSavedIterator.js';
10
11
  import { isOutside } from './helpers/isOutside.js';
12
+ import { OrganizationAdminService } from '../services/OrganizationAdminService.js';
11
13
 
12
14
  registerCron('invoices', invoices);
13
15
 
@@ -50,7 +52,7 @@ async function createInvoicesFor(organization: Organization) {
50
52
 
51
53
  // Belgian rules: allowed to invoice up to the 15th day of the next month. We extend it with one month to fix mistakes.
52
54
  const today = Formatter.luxon();
53
- const startDate = today.day <= 15 ? today.minus({ month: 2 }).startOf('month') : today.minus({ month: 1 }).startOf('month');
55
+ const startDate = today.day <= 15 ? today.minus({ month: 3 }).startOf('month') : today.minus({ month: 2 }).startOf('month');
54
56
 
55
57
  // Don't invoice below 4 euro - unless we reached the timeout date for invoices (end of month + 15 days - 5 days margin) OR + 15 day offset
56
58
  const invoiceLimit = STAMHOOFD.environment === 'development' ? 0 : 4_0000;
@@ -147,7 +149,7 @@ async function createInvoicesFor(organization: Organization) {
147
149
  template: {
148
150
  type: EmailTemplateType.InvoiceGenerationErrors,
149
151
  },
150
- recipients: await organization.getAdminRecipients(),
152
+ recipients: await OrganizationAdminService.getAdminRecipients(organization),
151
153
  type: 'transactional',
152
154
  fromStamhoofd: true,
153
155
  defaultReplacements: [
package/src/crons.ts CHANGED
@@ -4,8 +4,10 @@ import { Group, Organization, Payment, Registration, STPackage, Webshop } from '
4
4
  import { SQL } from '@stamhoofd/sql';
5
5
  import { PaymentMethod, PaymentProvider, PaymentStatus } from '@stamhoofd/structures';
6
6
  import { checkSettlements } from './helpers/CheckSettlements.js';
7
+ import { OrganizationDNSService } from './services/OrganizationDNSService.js';
7
8
  import { PaymentService } from './services/PaymentService.js';
8
9
  import { RegistrationService } from './services/RegistrationService.js';
10
+ import { STPackageService } from './services/STPackageService.js';
9
11
 
10
12
  let lastDNSCheck: Date | null = null;
11
13
  let lastDNSId = '';
@@ -39,7 +41,7 @@ async function checkDNS() {
39
41
  console.log('[DNS] ' + organization.name);
40
42
  }
41
43
  try {
42
- await organization.updateDNSRecords();
44
+ await OrganizationDNSService.updateDNSRecords(organization);
43
45
  } catch (e) {
44
46
  console.error(e);
45
47
  }
@@ -76,7 +78,7 @@ async function checkExpirationEmails() {
76
78
  console.log('[EXPIRATION EMAILS] Sending expiration emails...');
77
79
 
78
80
  for (const pack of packages) {
79
- await pack.sendExpiryEmail();
81
+ await STPackageService.sendExpiryEmail(pack);
80
82
  }
81
83
  lastExpirationCheck = new Date();
82
84
  }
@@ -1,4 +1,5 @@
1
- import { Email, runWithRecipientLocale, Webshop } from '@stamhoofd/models';
1
+ import { Email, Webshop } from '@stamhoofd/models';
2
+ import { runWithRecipientLocale } from '../helpers/EmailBuilder.js';
2
3
  import type { EmailRecipient, LimitedFilteredRequest, WebshopPreview } from '@stamhoofd/structures';
3
4
  import { mergeFilters, PaginatedResponse } from '@stamhoofd/structures';
4
5
  import { GetWebshopOrdersEndpoint } from '../endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.js';
@@ -6,6 +6,7 @@ import { baseInMemoryFilterCompilers, compileToInMemoryFilter, createInMemoryFil
6
6
  import { PaginatedResponse } from '@stamhoofd/structures';
7
7
  import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
8
8
  import { GetOrganizationsEndpoint } from '../endpoints/admin/organizations/GetOrganizationsEndpoint.js';
9
+ import { OrganizationAdminService } from '../services/OrganizationAdminService.js';
9
10
 
10
11
  function userToFilterableAdmin(user: User, platform: PlatformStruct, organization: Organization) {
11
12
  return {
@@ -49,14 +50,14 @@ async function fetchRecipients(query: LimitedFilteredRequest, subfilter: Stamhoo
49
50
  const recipients: EmailRecipient[] = [];
50
51
  for (const organization of result.results) {
51
52
  // todo: filter admins
52
- const users = await organization.getAdmins();
53
+ const users = await OrganizationAdminService.getAdmins(organization);
53
54
  const filteredUsers = users.filter((user) => {
54
55
  const filterable = userToFilterableAdmin(user, platform, organization);
55
56
  return compiledFilter(filterable);
56
57
  });
57
58
 
58
59
  recipients.push(
59
- ...organization.adminsToRecipients(filteredUsers).map((r) => {
60
+ ...OrganizationAdminService.adminsToRecipients(filteredUsers).map((r) => {
60
61
  return EmailRecipient.create({
61
62
  ...r,
62
63
  replacements: [
@@ -1,5 +1,5 @@
1
1
  import type { RecipientLoader } from '@stamhoofd/models';
2
- import { BalanceItem, BalanceItemPayment, Email, Member, MemberResponsibilityRecord, Organization, Payment, User } from '@stamhoofd/models';
2
+ import { BalanceItem, BalanceItemPayment, Email, Member, MemberResponsibilityRecord, Organization, Payment, Platform, User } from '@stamhoofd/models';
3
3
  import { compileToSQLFilter, SQL } from '@stamhoofd/sql';
4
4
  import type { LimitedFilteredRequest, PaymentGeneral, StamhoofdFilter } from '@stamhoofd/structures';
5
5
  import { CountFilteredRequest, EmailRecipient, PaginatedResponse, PaymentMethod } from '@stamhoofd/structures';
@@ -301,12 +301,13 @@ async function getOrganizationRecipients(ids: { organizationId: string; payment:
301
301
  if (subFilter === null) {
302
302
  // Use full admins instead
303
303
  const admins = await User.getAdmins(allOrganizationIds, { verified: true });
304
+ const platform = await Platform.getSharedStruct();
304
305
  for (const { organizationId, payment } of ids) {
305
306
  const organization = organizationMap.get(organizationId);
306
307
  if (!organization) {
307
308
  continue;
308
309
  }
309
- const users = admins.filter(a => a.permissions?.forOrganization(organization)?.hasFullAccess());
310
+ const users = admins.filter(a => a.permissions?.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess());
310
311
 
311
312
  if (users.length === 0) {
312
313
  console.warn('No admins found for organization with id ', organizationId, ' while fetching email recipients for payment with id ', payment.id);
@@ -0,0 +1,84 @@
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 { MFATOTP } from '@stamhoofd/models';
6
+ import type { MFAEnrollmentResult } from '@stamhoofd/structures';
7
+ import { MFAMethodType, TOTPConfirmRequest } from '@stamhoofd/structures';
8
+
9
+ import { Context } from '../../helpers/Context.js';
10
+ import { TOTPHelper } from '../../helpers/TOTPHelper.js';
11
+ import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
12
+ import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
13
+
14
+ type Params = { id: string };
15
+ type Query = undefined;
16
+ type Body = TOTPConfirmRequest;
17
+ type ResponseBody = MFAEnrollmentResult;
18
+
19
+ /**
20
+ * Confirm a pending TOTP authenticator by verifying the first code. On the user's first
21
+ * factor this also returns recovery codes, and during forced enrollment it issues a
22
+ * full session token.
23
+ */
24
+ export class ConfirmTOTPEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
25
+ bodyDecoder = TOTPConfirmRequest as Decoder<TOTPConfirmRequest>;
26
+
27
+ protected doesMatch(request: Request): [true, Params] | [false] {
28
+ if (request.method !== 'POST') {
29
+ return [false];
30
+ }
31
+ const params = Endpoint.parseParameters(request.url, '/mfa/totp/@id/confirm', { id: String });
32
+ if (params) {
33
+ return [true, params as Params];
34
+ }
35
+ return [false];
36
+ }
37
+
38
+ async handle(request: DecodedRequest<Params, Query, Body>) {
39
+ await Context.setOptionalOrganizationScope();
40
+ const { user, setupToken, token } = await Context.authenticateMFAEnrollment();
41
+
42
+ const totp = await MFATOTP.getByID(request.params.id);
43
+ if (!totp || totp.userId !== user.id) {
44
+ throw new SimpleError({
45
+ code: 'not_found',
46
+ message: 'TOTP authenticator not found',
47
+ human: $t('%ZgJ'),
48
+ statusCode: 404,
49
+ });
50
+ }
51
+
52
+ if (totp.confirmedAt) {
53
+ throw new SimpleError({
54
+ code: 'already_confirmed',
55
+ message: 'This authenticator is already confirmed',
56
+ human: $t('%ZgM'),
57
+ statusCode: 400,
58
+ });
59
+ }
60
+
61
+ const counter = TOTPHelper.verify(request.body.code, totp.secret);
62
+ if (counter === null) {
63
+ throw new SimpleError({
64
+ code: 'invalid_mfa_code',
65
+ message: 'Invalid code',
66
+ human: $t('%Zgp'),
67
+ field: 'code',
68
+ statusCode: 400,
69
+ });
70
+ }
71
+
72
+ const wasFirstFactor = !(await TwoFactorHelper.userHasFactors(user.id));
73
+
74
+ totp.confirmedAt = new Date();
75
+ totp.name = request.body.name.trim() || $t('%ZiG');
76
+ // Record the counter so the confirmation code can't be replayed as a login code.
77
+ totp.lastUsedCounter = counter;
78
+ await totp.save();
79
+
80
+ await TwoFactorAuditLogService.logMethodAdded(user, MFAMethodType.TOTP, totp.name);
81
+
82
+ return new Response(await TwoFactorHelper.completeEnrollment(user, setupToken, wasFirstFactor, token));
83
+ }
84
+ }
@@ -2,13 +2,15 @@ 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 { PasswordToken, Platform, sendEmailTemplate, User } from '@stamhoofd/models';
5
+ import { Platform, User } from '@stamhoofd/models';
6
+ import { sendEmailTemplate } from '../../helpers/EmailBuilder.js';
6
7
  import type { UserWithMembers } from '@stamhoofd/structures';
7
8
  import { EmailTemplateType, Recipient, Replacement, UserPermissions, User as UserStruct } from '@stamhoofd/structures';
8
9
  import { Formatter } from '@stamhoofd/utility';
9
10
 
10
11
  import { AuthenticatedStructures } from '../../helpers/AuthenticatedStructures.js';
11
12
  import { Context } from '../../helpers/Context.js';
13
+ import { PasswordForgotService } from '../../services/PasswordForgotService.js';
12
14
  type Params = Record<string, never>;
13
15
  type Query = undefined;
14
16
  type Body = UserStruct;
@@ -117,7 +119,7 @@ export class CreateAdminEndpoint extends Endpoint<Params, Query, Body, ResponseB
117
119
  validUntil.setTime(validUntil.getTime() + 7 * 24 * 3600 * 1000);
118
120
 
119
121
  const dateTime = Formatter.dateTime(validUntil);
120
- const recoveryUrl = await PasswordToken.getPasswordRecoveryUrl(admin, organization, request.i18n, validUntil);
122
+ const recoveryUrl = await PasswordForgotService.getPasswordRecoveryUrl(admin, organization, request.i18n, validUntil);
121
123
  const platformName = ((await Platform.getSharedStruct()).config.name);
122
124
 
123
125
  const name = organization?.name ?? platformName;
@@ -1,5 +1,5 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
- import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
2
+ import { OrganizationFactory, Token, User, UserFactory } from '@stamhoofd/models';
3
3
  import { Token as TokenStruct } from '@stamhoofd/structures';
4
4
 
5
5
  import { testServer } from '../../../tests/helpers/TestServer.js';
@@ -65,4 +65,64 @@ describe('Endpoint.CreateToken', () => {
65
65
  const byRefresh = await Token.getByRefreshToken(response.body.refreshToken);
66
66
  expect(byRefresh).toBeDefined();
67
67
  });
68
+
69
+ describe('last activity', () => {
70
+ const password = 'test-password-1234';
71
+
72
+ async function getLastActiveAt(user: User): Promise<Date | null> {
73
+ const updated = await User.getByID(user.id);
74
+ return updated!.lastActiveAt;
75
+ }
76
+
77
+ test('a password login marks the user as active', async () => {
78
+ const organization = await new OrganizationFactory({}).create();
79
+ const user = await new UserFactory({ organization, password }).create();
80
+ expect(user.lastActiveAt).toBeNull();
81
+
82
+ const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
83
+ grant_type: 'password',
84
+ username: user.email,
85
+ password,
86
+ });
87
+ await testServer.test(endpoint, r);
88
+
89
+ const lastActiveAt = await getLastActiveAt(user);
90
+ expect(lastActiveAt).not.toBeNull();
91
+ expect(lastActiveAt!.getTime()).toBeGreaterThan(Date.now() - 60 * 1000);
92
+ });
93
+
94
+ test('refreshing a token marks the user as active again', async () => {
95
+ const organization = await new OrganizationFactory({}).create();
96
+ const user = await new UserFactory({ organization, password }).create();
97
+ const token = await Token.createToken(user);
98
+
99
+ const monthsAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
100
+ monthsAgo.setMilliseconds(0);
101
+ user.lastActiveAt = monthsAgo;
102
+ await user.save();
103
+
104
+ const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
105
+ grant_type: 'refresh_token',
106
+ refresh_token: token.refreshToken,
107
+ });
108
+ await testServer.test(endpoint, r);
109
+
110
+ const lastActiveAt = await getLastActiveAt(user);
111
+ expect(lastActiveAt!.getTime()).toBeGreaterThan(Date.now() - 60 * 1000);
112
+ });
113
+
114
+ test('a failed login does not mark the user as active', async () => {
115
+ const organization = await new OrganizationFactory({}).create();
116
+ const user = await new UserFactory({ organization, password }).create();
117
+
118
+ const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
119
+ grant_type: 'password',
120
+ username: user.email,
121
+ password: 'wrong-password',
122
+ });
123
+ await expect(testServer.test(endpoint, r)).rejects.toThrow();
124
+
125
+ expect(await getLastActiveAt(user)).toBeNull();
126
+ });
127
+ });
68
128
  });