@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.137.5",
3
+ "version": "2.138.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -64,21 +64,23 @@
64
64
  "@simonbackx/simple-endpoints": "1.22.0",
65
65
  "@simonbackx/simple-errors": "1.5.0",
66
66
  "@simonbackx/simple-logging": "1.0.1",
67
- "@stamhoofd/backend-env": "2.137.5",
68
- "@stamhoofd/backend-i18n": "2.137.5",
69
- "@stamhoofd/backend-middleware": "2.137.5",
70
- "@stamhoofd/crons": "2.137.5",
71
- "@stamhoofd/email": "2.137.5",
72
- "@stamhoofd/excel-writer": "2.137.5",
73
- "@stamhoofd/logging": "2.137.5",
74
- "@stamhoofd/models": "2.137.5",
75
- "@stamhoofd/object-differ": "2.137.5",
76
- "@stamhoofd/queues": "2.137.5",
77
- "@stamhoofd/sql": "2.137.5",
78
- "@stamhoofd/structures": "2.137.5",
79
- "@stamhoofd/types": "2.137.5",
80
- "@stamhoofd/utility": "2.137.5",
67
+ "@simplewebauthn/server": "13.3.2",
68
+ "@stamhoofd/backend-env": "2.138.1",
69
+ "@stamhoofd/backend-i18n": "2.138.1",
70
+ "@stamhoofd/backend-middleware": "2.138.1",
71
+ "@stamhoofd/crons": "2.138.1",
72
+ "@stamhoofd/email": "2.138.1",
73
+ "@stamhoofd/excel-writer": "2.138.1",
74
+ "@stamhoofd/logging": "2.138.1",
75
+ "@stamhoofd/models": "2.138.1",
76
+ "@stamhoofd/object-differ": "2.138.1",
77
+ "@stamhoofd/queues": "2.138.1",
78
+ "@stamhoofd/sql": "2.138.1",
79
+ "@stamhoofd/structures": "2.138.1",
80
+ "@stamhoofd/types": "2.138.1",
81
+ "@stamhoofd/utility": "2.138.1",
81
82
  "archiver": "7.0.1",
83
+ "argon2": "0.44.0",
82
84
  "axios": "1.18.1",
83
85
  "base-x": "3.0.11",
84
86
  "chalk": "5.6.2",
@@ -95,11 +97,12 @@
95
97
  "mysql2": "3.23.1",
96
98
  "node-rsa": "2.0.0",
97
99
  "openid-client": "5.7.1",
100
+ "otplib": "12.0.1",
98
101
  "stripe": "16.12.0",
99
102
  "uuid": "14.0.1"
100
103
  },
101
104
  "devDependencies": {
102
- "@stamhoofd/test-utils": "2.137.5",
105
+ "@stamhoofd/test-utils": "2.138.1",
103
106
  "@types/cookie": "0.6.0",
104
107
  "@types/luxon": "3.7.2",
105
108
  "@types/mailparser": "3.4.6",
@@ -114,5 +117,5 @@
114
117
  "publishConfig": {
115
118
  "access": "public"
116
119
  },
117
- "gitHead": "52c8072d0294d8005c9d36a79219708ea148a357"
120
+ "gitHead": "ea8e0dcbc22e86969883fa09e920931896110781"
118
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
+ }
@@ -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: [
@@ -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: [
@@ -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,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 { 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';
@@ -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
  });
@@ -1,16 +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';
9
13
  import { VerificationCodeService } from '../../services/VerificationCodeService.js';
10
14
 
11
15
  type Params = Record<string, never>;
12
16
  type Query = undefined;
13
- type Body = RequestChallengeGrantStruct | ChallengeGrantStruct | RefreshTokenGrantStruct | PasswordTokenGrantStruct | PasswordGrantStruct;
17
+ type Body = RequestChallengeGrantStruct | ChallengeGrantStruct | RefreshTokenGrantStruct | PasswordTokenGrantStruct | PasswordGrantStruct | MFAGrantStruct;
14
18
  type ResponseBody = TokenStruct;
15
19
 
16
20
  export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
@@ -82,6 +86,8 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
82
86
  });
83
87
  }
84
88
 
89
+ await oldToken.user.markActive();
90
+
85
91
  const st = new TokenStruct(token);
86
92
  return new Response(st);
87
93
  }
@@ -141,7 +147,12 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
141
147
  });
142
148
  }
143
149
 
144
- 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());
145
156
 
146
157
  if (!token) {
147
158
  throw new SimpleError({
@@ -152,6 +163,110 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
152
163
  });
153
164
  }
154
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
+
155
270
  const st = new TokenStruct(token);
156
271
  return new Response(st);
157
272
  }
@@ -188,8 +303,18 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
188
303
  });
189
304
  }
190
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
+
191
316
  // Important to create a new token before adjusting the old token
192
- const token = await Token.createToken(passwordToken.user);
317
+ const token = await Token.createToken(passwordToken.user, new Date());
193
318
 
194
319
  // TODO: make token short lived until renewal
195
320
 
@@ -211,6 +336,8 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
211
336
  await token.user.save();
212
337
  }
213
338
 
339
+ await token.user.markActive();
340
+
214
341
  const st = new TokenStruct(token);
215
342
  return new Response(st);
216
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
+ }