@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
@@ -1,15 +1,20 @@
1
1
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
2
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
- import { EmailVerificationCode, PasswordToken, Platform, Token, User } from '@stamhoofd/models';
5
- import type { ChallengeGrantStruct, PasswordGrantStruct, PasswordTokenGrantStruct, RefreshTokenGrantStruct, RequestChallengeGrantStruct } from '@stamhoofd/structures';
6
- import { CreateTokenStruct, LoginMethod, SignupResponse, Token as TokenStruct } from '@stamhoofd/structures';
4
+ import { EmailVerificationCode, MFATOTP, MFAToken, PasswordToken, Platform, Token, User, WebauthnCredential } from '@stamhoofd/models';
5
+ import type { ChallengeGrantStruct, MFAGrantStruct, PasswordGrantStruct, PasswordTokenGrantStruct, RefreshTokenGrantStruct, RequestChallengeGrantStruct } from '@stamhoofd/structures';
6
+ import { CreateTokenStruct, LoginMethod, MFAMethodType, SignupResponse, Token as TokenStruct } from '@stamhoofd/structures';
7
7
 
8
8
  import { Context } from '../../helpers/Context.js';
9
+ import { RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
10
+ import { TOTPHelper } from '../../helpers/TOTPHelper.js';
11
+ import { mfaVerificationRateLimiter, TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
12
+ import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
13
+ import { VerificationCodeService } from '../../services/VerificationCodeService.js';
9
14
 
10
15
  type Params = Record<string, never>;
11
16
  type Query = undefined;
12
- type Body = RequestChallengeGrantStruct | ChallengeGrantStruct | RefreshTokenGrantStruct | PasswordTokenGrantStruct | PasswordGrantStruct;
17
+ type Body = RequestChallengeGrantStruct | ChallengeGrantStruct | RefreshTokenGrantStruct | PasswordTokenGrantStruct | PasswordGrantStruct | MFAGrantStruct;
13
18
  type ResponseBody = TokenStruct;
14
19
 
15
20
  export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
@@ -81,6 +86,8 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
81
86
  });
82
87
  }
83
88
 
89
+ await oldToken.user.markActive();
90
+
84
91
  const st = new TokenStruct(token);
85
92
  return new Response(st);
86
93
  }
@@ -127,7 +134,7 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
127
134
  // if not: throw a validation error (e-mail validation is required)
128
135
  if (!user.verified) {
129
136
  const code = await EmailVerificationCode.createFor(user, user.email);
130
- code.send(user, organization, request.i18n).catch(console.error);
137
+ VerificationCodeService.send(code, user, organization, request.i18n).catch(console.error);
131
138
 
132
139
  throw new SimpleError({
133
140
  code: 'verify_email',
@@ -140,7 +147,12 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
140
147
  });
141
148
  }
142
149
 
143
- const token = await Token.createToken(user);
150
+ // Second factor / forced enrollment: block the session until the user
151
+ // completes (or first sets up) a second factor. Shared with every other
152
+ // grant that mints a session from a single primary credential.
153
+ await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion());
154
+
155
+ const token = await Token.createToken(user, new Date());
144
156
 
145
157
  if (!token) {
146
158
  throw new SimpleError({
@@ -151,6 +163,110 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
151
163
  });
152
164
  }
153
165
 
166
+ await user.markActive();
167
+
168
+ const st = new TokenStruct(token);
169
+ return new Response(st);
170
+ }
171
+
172
+ case 'mfa': {
173
+ const mfaToken = await MFAToken.getValid(request.body.mfaToken, 'login');
174
+ if (!mfaToken) {
175
+ throw new SimpleError({
176
+ code: 'invalid_mfa_token',
177
+ message: 'The MFA session is invalid or expired',
178
+ human: $t('%ZhJ'),
179
+ statusCode: 400,
180
+ });
181
+ }
182
+
183
+ const user = await User.getByID(mfaToken.userId);
184
+ if (!user) {
185
+ await mfaToken.consume();
186
+ throw new SimpleError({
187
+ code: 'invalid_mfa_token',
188
+ message: 'The MFA session is invalid or expired',
189
+ human: $t('%ZhJ'),
190
+ statusCode: 400,
191
+ });
192
+ }
193
+
194
+ // Scope check (same rules as refresh/password_token)
195
+ if ((organization && user.organizationId && user.organizationId !== organization.id) || (!organization && user.organizationId)) {
196
+ throw new SimpleError({
197
+ code: 'invalid_mfa_token',
198
+ message: 'The MFA session is invalid or expired',
199
+ human: $t('%ZhJ'),
200
+ statusCode: 400,
201
+ });
202
+ }
203
+
204
+ // Aggregate brute-force protection across ALL of this user's MFA tokens
205
+ // (the per-token counter alone can be bypassed by minting new tokens).
206
+ // This MUST run before verification so that, once the limit is hit, even a
207
+ // correct code is rejected — otherwise a lucky/leaked code would still let
208
+ // an attacker through after unlimited guesses.
209
+ mfaVerificationRateLimiter.track(user.id);
210
+
211
+ let verified = false;
212
+ if (request.body.method === MFAMethodType.TOTP) {
213
+ const code = request.body.code ?? '';
214
+ for (const totp of await MFATOTP.getConfirmedForUser(user.id)) {
215
+ const counter = TOTPHelper.verify(code, totp.secret);
216
+ if (counter === null) {
217
+ continue;
218
+ }
219
+ // Claiming the step counter both rejects a still-valid code that was
220
+ // accepted before (replay) and settles two requests that submit the
221
+ // same code at the same time: only one of them gets the claim.
222
+ if (await totp.claimCounter(counter)) {
223
+ verified = true;
224
+ break;
225
+ }
226
+ }
227
+ } else if (request.body.method === MFAMethodType.RecoveryCode) {
228
+ verified = await RecoveryCodeHelper.consume(user.id, request.body.code ?? '');
229
+ } else if (request.body.method === MFAMethodType.Passkey) {
230
+ const assertion = request.body.assertion;
231
+ const credentialId = assertion?.id;
232
+ if (mfaToken.webauthnChallenge && assertion && credentialId) {
233
+ const credential = await WebauthnCredential.getByCredentialId(credentialId);
234
+ if (credential && credential.userId === user.id) {
235
+ const newCounter = await WebauthnHelper.verifyAuthentication(assertion, mfaToken.webauthnChallenge, credential);
236
+ if (newCounter !== null) {
237
+ credential.counter = newCounter;
238
+ credential.lastUsedAt = new Date();
239
+ await credential.save();
240
+ verified = true;
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ if (!verified) {
247
+ const locked = await mfaToken.registerFailedAttempt();
248
+ throw new SimpleError({
249
+ code: locked ? 'too_many_attempts' : 'invalid_mfa_code',
250
+ message: locked ? 'Too many attempts' : 'Invalid code',
251
+ human: locked ? $t('%ZhT') : $t('%Zgp'),
252
+ statusCode: locked ? 429 : 400,
253
+ field: 'code',
254
+ });
255
+ }
256
+
257
+ if (!await mfaToken.consume()) {
258
+ // Another request already completed this challenge.
259
+ throw new SimpleError({
260
+ code: 'invalid_mfa_token',
261
+ message: 'The MFA session is invalid or expired',
262
+ human: $t('%ZhJ'),
263
+ statusCode: 400,
264
+ });
265
+ }
266
+
267
+ const token = await Token.createToken(user, new Date());
268
+ await user.markActive();
269
+
154
270
  const st = new TokenStruct(token);
155
271
  return new Response(st);
156
272
  }
@@ -187,8 +303,18 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
187
303
  });
188
304
  }
189
305
 
306
+ // A valid password-reset/magic-link token is a single primary credential:
307
+ // enforce the same second-factor gate as the password grant, otherwise
308
+ // anyone with a reset link (i.e. email access) could bypass the user's 2FA
309
+ // and even wipe their factors with the resulting fresh session.
310
+ //
311
+ // Forced enrollment (the user has no factor yet) additionally returns a
312
+ // temporary session token: this flow is also used to choose a first
313
+ // password (invites), which the client can only do with a session.
314
+ await TwoFactorHelper.assertSecondFactorOrThrow(passwordToken.user, organization, request.request.getVersion(), { allowTemporarySession: true });
315
+
190
316
  // Important to create a new token before adjusting the old token
191
- const token = await Token.createToken(passwordToken.user);
317
+ const token = await Token.createToken(passwordToken.user, new Date());
192
318
 
193
319
  // TODO: make token short lived until renewal
194
320
 
@@ -210,6 +336,8 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
210
336
  await token.user.save();
211
337
  }
212
338
 
339
+ await token.user.markActive();
340
+
213
341
  const st = new TokenStruct(token);
214
342
  return new Response(st);
215
343
  }
@@ -0,0 +1,61 @@
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 DeletePasskeyEndpoint 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/passkeys/@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 credential = await WebauthnCredential.getByID(request.params.id);
34
+ if (!credential || credential.userId !== user.id) {
35
+ throw new SimpleError({
36
+ code: 'not_found',
37
+ message: 'Passkey not found',
38
+ human: $t('%Zho'),
39
+ statusCode: 404,
40
+ });
41
+ }
42
+
43
+ const confirmedTotp = await MFATOTP.getConfirmedForUser(user.id);
44
+ const passkeys = await WebauthnCredential.getForUser(user.id);
45
+ const remaining = confirmedTotp.length + passkeys.filter(p => p.id !== credential.id).length;
46
+ if (remaining === 0 && await TwoFactorHelper.isTwoFactorRequired(user, organization ?? Context.organization ?? null)) {
47
+ throw new SimpleError({
48
+ code: 'cannot_remove_last_factor',
49
+ message: 'Cannot remove the last two-factor method',
50
+ human: $t('%Zh4'),
51
+ statusCode: 400,
52
+ });
53
+ }
54
+
55
+ await credential.delete();
56
+
57
+ await TwoFactorAuditLogService.logMethodDeleted(user, MFAMethodType.Passkey, credential.name || credential.providerName || '');
58
+
59
+ return new Response(await TwoFactorHelper.completeRemoval(user, token));
60
+ }
61
+ }
@@ -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,11 +1,13 @@
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 { PasswordToken, 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';
8
9
  import { Context } from '../../helpers/Context.js';
10
+ import { PasswordForgotService } from '../../services/PasswordForgotService.js';
9
11
 
10
12
  type Params = Record<string, never>;
11
13
  type Query = undefined;
@@ -72,7 +74,7 @@ export class ForgotPasswordEndpoint extends Endpoint<Params, Query, Body, Respon
72
74
  return new Response(undefined);
73
75
  }
74
76
 
75
- const recoveryUrl = await PasswordToken.getPasswordRecoveryUrl(user, organization, request.i18n);
77
+ const recoveryUrl = await PasswordForgotService.getPasswordRecoveryUrl(user, organization, request.i18n);
76
78
 
77
79
  // Create e-mail builder
78
80
  await sendEmailTemplate(organization, {
@@ -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());