@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
@@ -1,5 +1,6 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { EventNotification, Member, MemberResponsibilityRecord, Organization, Platform, sendEmailTemplate, User } from '@stamhoofd/models';
2
+ import { EventNotification, Member, MemberResponsibilityRecord, Organization, Platform, User } from '@stamhoofd/models';
3
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
3
4
  import type { EmailTemplateType } from '@stamhoofd/structures';
4
5
  import { PermissionLevel, Recipient, RecordCategory, Replacement } from '@stamhoofd/structures';
5
6
  import { Formatter } from '@stamhoofd/utility';
@@ -295,7 +295,7 @@ export class InvoicePdfService {
295
295
  throw new SimpleError({
296
296
  code: 'pdf_creation_timeout',
297
297
  message: 'Failed to generate invoice PDF',
298
- human: $t('Er ging iets mis bij het aanmaken van de factuur'),
298
+ human: $t('%ZgV'),
299
299
  });
300
300
  }
301
301
  }
@@ -306,13 +306,13 @@ export class InvoicePdfService {
306
306
  throw new SimpleError({
307
307
  code: 'pdf_creation_timeout',
308
308
  message: 'Failed to generate invoice PDF: timeout',
309
- human: $t('Er ging iets mis bij het aanmaken van de factuur'),
309
+ human: $t('%ZgV'),
310
310
  });
311
311
  }
312
312
  throw new SimpleError({
313
313
  code: 'pdf_creation_failed',
314
314
  message: 'Failed to generate invoice PDF',
315
- human: $t('Er ging iets mis bij het aanmaken van de factuur'),
315
+ human: $t('%ZgV'),
316
316
  });
317
317
  } finally {
318
318
  clearTimeout(timeout);
@@ -1,6 +1,7 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import { Email } from '@stamhoofd/email';
3
- import { BalanceItem, Invoice, InvoicedBalanceItem, Organization, Payment, sendEmailTemplate } from '@stamhoofd/models';
3
+ import { BalanceItem, Invoice, InvoicedBalanceItem, Organization, Payment } from '@stamhoofd/models';
4
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
4
5
  import { InvoiceCounter } from '@stamhoofd/models/helpers/InvoiceCounter.js';
5
6
  import type { Invoice as InvoiceStruct } from '@stamhoofd/structures';
6
7
  import { EmailTemplateType, PaymentStatus, Recipient, Replacement } from '@stamhoofd/structures';
@@ -9,6 +10,7 @@ import { ViesHelper } from '../helpers/ViesHelper.js';
9
10
  import { BalanceItemService } from './BalanceItemService.js';
10
11
  import { InvoicePdfService } from './InvoicePdfService.js';
11
12
  import { InvoiceXMlService } from './InvoiceXMLService.js';
13
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
12
14
 
13
15
  export class InvoiceService {
14
16
  static async createFrom(organization: Organization, struct: InvoiceStruct, options?: { payments?: Payment[]; balanceItems?: BalanceItem[] }) {
@@ -435,7 +437,7 @@ export class InvoiceService {
435
437
  if (!email && invoice.payingOrganizationId) {
436
438
  const payingOrganization = await Organization.getByID(invoice.payingOrganizationId);
437
439
  if (payingOrganization) {
438
- email = (await payingOrganization.getInvoicingToEmail()) ?? undefined;
440
+ email = (await OrganizationAdminService.getInvoicingToEmail(payingOrganization)) ?? undefined;
439
441
  }
440
442
  }
441
443
 
@@ -7,6 +7,7 @@ import { Country } from '@stamhoofd/types/Country';
7
7
  import { Formatter, STMath } from '@stamhoofd/utility';
8
8
  import { v4 as uuidv4 } from 'uuid';
9
9
  import { InvoicePdfService } from './InvoicePdfService.js';
10
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
10
11
 
11
12
  export class InvoiceXMlService {
12
13
  static async buildXml(invoice: Invoice) {
@@ -68,7 +69,7 @@ export class InvoiceXMlService {
68
69
  if (!customerEmail && invoice.payingOrganizationId) {
69
70
  const payingOrganization = await Organization.getByID(invoice.payingOrganizationId);
70
71
  if (payingOrganization) {
71
- customerEmail = (await payingOrganization.getInvoicingToEmail()) ?? null;
72
+ customerEmail = (await OrganizationAdminService.getInvoicingToEmail(payingOrganization)) ?? null;
72
73
  }
73
74
  }
74
75
 
@@ -1,6 +1,7 @@
1
1
  import { I18n } from '@stamhoofd/backend-i18n';
2
2
  import type { Organization, Payment, Ticket } from '@stamhoofd/models';
3
- import { Order, sendEmailTemplate, Webshop, WebshopCounter } from '@stamhoofd/models';
3
+ import { Order, Webshop, WebshopCounter } from '@stamhoofd/models';
4
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
4
5
  import { EmailTemplateType, OrderStatus, PaymentMethod, Recipient, Replacement, WebshopPreview, WebshopStatus, WebshopTicketType } from '@stamhoofd/structures';
5
6
  import { Formatter } from '@stamhoofd/utility';
6
7
 
@@ -0,0 +1,47 @@
1
+ import { AccessRight, PermissionLevel, PermissionRoleDetailed, Permissions } from '@stamhoofd/structures';
2
+ import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
3
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
4
+
5
+ describe('Organization admin recipients', () => {
6
+ async function createOrganizationWithAdmins() {
7
+ const financeRole = PermissionRoleDetailed.create({
8
+ name: 'financial director',
9
+ accessRights: [AccessRight.OrganizationFinanceDirector],
10
+ });
11
+
12
+ const organization = await new OrganizationFactory({ roles: [financeRole] }).create();
13
+
14
+ const fullAdmin = await new UserFactory({
15
+ organization,
16
+ email: 'full-admin@example.com',
17
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
18
+ }).create();
19
+
20
+ const financeAdmin = await new UserFactory({
21
+ organization,
22
+ email: 'finance-admin@example.com',
23
+ permissions: Permissions.create({
24
+ level: PermissionLevel.None,
25
+ roles: [financeRole],
26
+ }),
27
+ }).create();
28
+
29
+ return { organization, fullAdmin, financeAdmin };
30
+ }
31
+
32
+ test('The finance admin recipients include a financial director without full access', async () => {
33
+ const { organization, fullAdmin, financeAdmin } = await createOrganizationWithAdmins();
34
+
35
+ const emails = (await OrganizationAdminService.getFinanceAdminRecipients(organization)).map(r => r.email);
36
+
37
+ expect(emails).toIncludeSameMembers([fullAdmin.email, financeAdmin.email]);
38
+ });
39
+
40
+ test('The regular admin recipients only include full admins', async () => {
41
+ const { organization, fullAdmin } = await createOrganizationWithAdmins();
42
+
43
+ const emails = (await OrganizationAdminService.getAdminRecipients(organization)).map(r => r.email);
44
+
45
+ expect(emails).toIncludeSameMembers([fullAdmin.email]);
46
+ });
47
+ });
@@ -0,0 +1,139 @@
1
+ import type { Organization } from '@stamhoofd/models';
2
+ import { Platform, Token, User } from '@stamhoofd/models';
3
+ import type { EmailInterfaceRecipient } from '@stamhoofd/email';
4
+ import type { OrganizationEmail } from '@stamhoofd/structures';
5
+ import { AccessRight, Recipient } from '@stamhoofd/structures';
6
+ import { Sorter } from '@stamhoofd/utility';
7
+
8
+ /**
9
+ * Resolves which administrators of an organization should receive a given kind of email.
10
+ */
11
+ export class OrganizationAdminService {
12
+ /**
13
+ * E-mail address when we receive replies for organization@stamhoofd.email.
14
+ * Note that this sould be private because it can contain personal e-mail addresses if the organization is not configured correctly
15
+ */
16
+ static async getReplyEmails(organization: Organization): Promise<EmailInterfaceRecipient[]> {
17
+ const sender: OrganizationEmail | undefined = organization.privateMeta.emails.find(e => e.default) ?? organization.privateMeta.emails[0];
18
+
19
+ if (sender) {
20
+ return [
21
+ {
22
+ name: sender.name,
23
+ email: sender.email,
24
+ },
25
+ ];
26
+ }
27
+
28
+ return await this.getAdminToEmails(organization);
29
+ }
30
+
31
+ static async getAdmins(organization: Organization) {
32
+ return await User.getAdmins(organization.id, { verified: true });
33
+ }
34
+
35
+ /**
36
+ * These email addresess are private
37
+ */
38
+ static async getFullAdmins(organization: Organization) {
39
+ const admins = await this.getAdmins(organization);
40
+ const platform = await Platform.getSharedStruct();
41
+
42
+ // Only full access
43
+ return admins.filter(a => a.permissions && a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess());
44
+ }
45
+
46
+ /**
47
+ * These email addresess are private
48
+ */
49
+ static async getFinanceAdmins(organization: Organization) {
50
+ const admins = await this.getAdmins(organization);
51
+ const platform = await Platform.getSharedStruct();
52
+ const filtered = admins.filter(a => a.permissions && (a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess() || a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasAccessRight(AccessRight.OrganizationFinanceDirector)));
53
+
54
+ // Only full access
55
+ return filtered;
56
+ }
57
+
58
+ /**
59
+ * These email addresess are private
60
+ */
61
+ static async getAdminToEmails(organization: Organization): Promise<EmailInterfaceRecipient[]> {
62
+ const filtered = await this.getFullAdmins(organization);
63
+
64
+ if (STAMHOOFD.environment === 'production') {
65
+ if (filtered.length > 1) {
66
+ // remove stamhoofd email addresses
67
+ const f = filtered.flatMap(f => f.getEmailTo()).filter(e => !e.email.endsWith('@stamhoofd.be') && !e.email.endsWith('@stamhoofd.nl'));
68
+ if (f.length > 0) {
69
+ return f;
70
+ }
71
+ }
72
+ }
73
+
74
+ return filtered.flatMap(f => f.getEmailTo());
75
+ }
76
+
77
+ static adminsToRecipients(admins: User[]) {
78
+ return admins.flatMap((f) => {
79
+ return Recipient.create({
80
+ firstName: f.firstName,
81
+ lastName: f.lastName,
82
+ email: f.email,
83
+ replacements: [],
84
+ });
85
+ });
86
+ }
87
+
88
+ /**
89
+ * These email addresess are private
90
+ */
91
+ static async getAdminRecipients(organization: Organization): Promise<Recipient[]> {
92
+ const filtered = await this.getFullAdmins(organization);
93
+ return this.adminsToRecipients(filtered);
94
+ }
95
+
96
+ /**
97
+ * These email addresess are private
98
+ */
99
+ static async getFinanceAdminRecipients(organization: Organization): Promise<Recipient[]> {
100
+ const filtered = await this.getFinanceAdmins(organization);
101
+ return this.adminsToRecipients(filtered);
102
+ }
103
+
104
+ /**
105
+ * Returns one email for invoices. since in ubl we can only add one address.
106
+ * We choose the oldest user that was active in the last 3 months (otherwise the oldest user if noone was active)
107
+ */
108
+ static async getInvoicingToEmail(organization: Organization): Promise<string | undefined> {
109
+ const admins = await this.getAdmins(organization);
110
+
111
+ const tokens = await Token.select().where('userId', admins.map(a => a.id)).fetch();
112
+
113
+ // Sort by admins that were active in the last 3 months, then creation date
114
+ const cutoffDate = new Date(Date.now() - 1000 * 60 * 60 * 24 * 31 * 3);
115
+ admins.sort((a, b) => {
116
+ const aTokens = tokens.filter(t => t.userId === a.id);
117
+ const bTokens = tokens.filter(t => t.userId === b.id);
118
+ const aActive = !!aTokens.find(t => t.updatedAt > cutoffDate);
119
+ const bActive = !!bTokens.find(t => t.updatedAt > cutoffDate);
120
+ return Sorter.stack(
121
+ Sorter.byBooleanValue(aActive, bActive),
122
+ Sorter.byDateValue(b.createdAt, a.createdAt),
123
+ );
124
+ });
125
+
126
+ const platform = await Platform.getSharedStruct();
127
+ const filtered = admins.filter(a => a.verified && a.permissions && !a.email.endsWith('@stamhoofd.be') && (a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess() || a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasAccessRight(AccessRight.OrganizationFinanceDirector)));
128
+
129
+ if (filtered.length > 0) {
130
+ return filtered.map(f => f.email)[0];
131
+ }
132
+ const filtered2 = admins.filter(a => a.verified && a.permissions && (a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess() || a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasAccessRight(AccessRight.OrganizationFinanceDirector)));
133
+
134
+ if (filtered2.length > 0) {
135
+ return filtered2.map(f => f.email)[0];
136
+ }
137
+ return undefined;
138
+ }
139
+ }
@@ -1,9 +1,10 @@
1
1
  import { I18n } from '@stamhoofd/backend-i18n';
2
2
  import type { Organization } from '@stamhoofd/models';
3
- import { sendEmailTemplate } from '@stamhoofd/models';
3
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
4
4
  import { EmailTemplateType, Replacement, STPackageType } from '@stamhoofd/structures';
5
5
  import { Country } from '@stamhoofd/types/Country';
6
6
  import { Language } from '@stamhoofd/types/Language';
7
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
7
8
 
8
9
  export class OrganizationEmailService {
9
10
  static async sendEmailTemplate(organization: Organization, data: {
@@ -11,7 +12,7 @@ export class OrganizationEmailService {
11
12
  personal?: boolean;
12
13
  bcc?: boolean;
13
14
  }) {
14
- const recipients = await organization.getAdminRecipients();
15
+ const recipients = await OrganizationAdminService.getAdminRecipients(organization);
15
16
  const defaultI18n = new I18n(Language.Dutch, Country.Belgium);
16
17
  const i18n = organization.i18n;
17
18
 
@@ -1,6 +1,7 @@
1
1
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
2
2
  import type { Member, User } from '@stamhoofd/models';
3
- import { BalanceItem, BalanceItemPayment, Group, MolliePayment, Organization, PayconiqPayment, Payment, Platform, sendEmailTemplate, UsedRegisterCode } from '@stamhoofd/models';
3
+ import { BalanceItem, BalanceItemPayment, Group, MolliePayment, Organization, PayconiqPayment, Payment, Platform, UsedRegisterCode } from '@stamhoofd/models';
4
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
4
5
  import { QueueHandler } from '@stamhoofd/queues';
5
6
  import type { Checkoutable, PaymentConfiguration, PrivatePaymentConfiguration } from '@stamhoofd/structures';
6
7
  import { AuditLogSource, BalanceItemPaymentDetailed, BalanceItemType, EmailTemplateType, Invoice, PaymentCustomer, PaymentGeneral, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, PaymentType, Recipient, Replacement, VATExcemptReason, Version } from '@stamhoofd/structures';
@@ -22,6 +23,7 @@ import { PaymentMandateService } from './PaymentMandateService.js';
22
23
  import { ReferralService } from './ReferralService.js';
23
24
  import { VATService } from './VATService.js';
24
25
  import { STPackageService } from './STPackageService.js';
26
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
25
27
 
26
28
  export class PaymentService {
27
29
  static async updateReversedPaymentsFor(payment: Payment) {
@@ -237,7 +239,7 @@ export class PaymentService {
237
239
  const payingOrganization = await Organization.getByID(payment.payingOrganizationId, true);
238
240
 
239
241
  await sendEmailTemplate(organization, {
240
- recipients: await payingOrganization.getFinanceAdminRecipients(),
242
+ recipients: await OrganizationAdminService.getFinanceAdminRecipients(payingOrganization),
241
243
  defaultReplacements: [
242
244
  Replacement.create({
243
245
  token: 'payingOrganizationName',
@@ -274,7 +276,7 @@ export class PaymentService {
274
276
  const payingOrganization = await Organization.getByID(payment.payingOrganizationId, true);
275
277
 
276
278
  await sendEmailTemplate(organization, {
277
- recipients: await payingOrganization.getFinanceAdminRecipients(),
279
+ recipients: await OrganizationAdminService.getFinanceAdminRecipients(payingOrganization),
278
280
  defaultReplacements: [
279
281
  Replacement.create({
280
282
  token: 'payingOrganizationName',
@@ -6,6 +6,7 @@ import { BalanceItem, Organization, Platform, RegisterCode, UsedRegisterCode } f
6
6
  import { BalanceItemStatus, BalanceItemType } from '@stamhoofd/structures';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
  import { VATService } from './VATService.js';
9
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
9
10
 
10
11
  export class ReferralService {
11
12
  /**
@@ -76,7 +77,7 @@ export class ReferralService {
76
77
  }
77
78
 
78
79
  if (otherOrganization) {
79
- const admins = await otherOrganization.getAdminToEmails();
80
+ const admins = await OrganizationAdminService.getAdminToEmails(otherOrganization);
80
81
  if (admins) {
81
82
  if (code.invoiceValue) {
82
83
  // Delay email until everything is validated and saved
@@ -196,7 +197,7 @@ export class ReferralService {
196
197
  await item.save();
197
198
  }
198
199
 
199
- const admins = await receivingOrganization.getAdminRecipients();
200
+ const admins = await OrganizationAdminService.getAdminRecipients(receivingOrganization);
200
201
  if (admins) {
201
202
  if (code.invoiceValue) {
202
203
  Email.send({
@@ -1,6 +1,7 @@
1
1
  import { ManyToOneRelation } from '@simonbackx/simple-database';
2
2
  import { encodeObject } from '@simonbackx/simple-encoding';
3
- import { BalanceItem, Document, Group, Member, Organization, Platform, Registration, RegistrationInvitation, sendEmailTemplate } from '@stamhoofd/models';
3
+ import { BalanceItem, Document, Group, Member, Organization, Platform, Registration, RegistrationInvitation } from '@stamhoofd/models';
4
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
4
5
  import { QueueHandler } from '@stamhoofd/queues';
5
6
  import { AppliedRegistrationDiscount, AuditLogSource, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, EmailTemplateType, getAppHost, GroupType, Recipient, Replacement, StockReservation, Version } from '@stamhoofd/structures';
6
7
  import { Formatter } from '@stamhoofd/utility';
@@ -10,6 +10,7 @@ import { Context } from '../helpers/Context.js';
10
10
 
11
11
  import type { ObjectWithHeaders } from '../helpers/CookieHelper.js';
12
12
  import { CookieHelper } from '../helpers/CookieHelper.js';
13
+ import { TwoFactorHelper } from '../helpers/TwoFactorHelper.js';
13
14
 
14
15
  async function randomBytes(size: number): Promise<Buffer> {
15
16
  return new Promise((resolve, reject) => {
@@ -37,12 +38,24 @@ type SSOSessionContext = {
37
38
  * Link this method to this existing user and don't create a new token
38
39
  */
39
40
  userId?: string | null;
41
+
42
+ /**
43
+ * The session (access token) that has to count as freshly authenticated again once the
44
+ * provider confirms the identity of `userId`. Only set for a reauthentication flow.
45
+ */
46
+ reauthenticateAccessToken?: string | null;
40
47
  };
41
48
 
42
49
  export class SSOAuthToken {
43
50
  validUntil: Date;
44
51
  token: string;
45
52
  userId: string;
53
+
54
+ /**
55
+ * The session this token was requested with, so a reauthentication can mark it fresh
56
+ * again. Null for API users and other sessionless callers.
57
+ */
58
+ accessToken: string | null = null;
46
59
  }
47
60
 
48
61
  export class SSOService {
@@ -76,7 +89,7 @@ export class SSOService {
76
89
  }
77
90
  }
78
91
 
79
- static async createToken() {
92
+ static async createToken(sessionToken: Token | null = null) {
80
93
  if (!Context.user) {
81
94
  throw new SimpleError({
82
95
  code: 'invalid_user',
@@ -89,6 +102,7 @@ export class SSOService {
89
102
  token.validUntil = new Date(Date.now() + 1000 * 60 * 5);
90
103
  token.token = (await randomBytes(192)).toString('base64').toUpperCase();
91
104
  token.userId = Context.user.id;
105
+ token.accessToken = sessionToken?.accessToken ?? null;
92
106
 
93
107
  await this.clearExpiredTokensOrFromUser(token.userId);
94
108
  this.authTokens.set(token.token, token);
@@ -369,6 +383,7 @@ export class SSOService {
369
383
  }
370
384
 
371
385
  let user: User | undefined = undefined;
386
+ let reauthenticateAccessToken: string | null = null;
372
387
 
373
388
  if (data.authToken) {
374
389
  const token = await SSOService.validateToken(data.authToken);
@@ -384,10 +399,27 @@ export class SSOService {
384
399
  }
385
400
 
386
401
  this.validateEmail(user.email);
402
+
403
+ // Force reentry of login credentials (no automatic login)
404
+ // Sadly select_account is not reliable and sometimes works automatically
405
+ // since this would automatically link a possibly wrong user, we don't allow that
406
+ // we check this with the auth_time, so a user cannot really get around this
407
+ data.prompt = 'login';
408
+
409
+ if (data.reauthenticate) {
410
+ if (!token.accessToken) {
411
+ throw new SimpleError({
412
+ code: 'invalid_token',
413
+ message: 'Cannot reauthenticate without a session',
414
+ statusCode: 400,
415
+ });
416
+ }
417
+ reauthenticateAccessToken = token.accessToken;
418
+ }
387
419
  }
388
420
  }
389
421
 
390
- return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt, user);
422
+ return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt, user, reauthenticateAccessToken);
391
423
  }
392
424
 
393
425
  validateRedirectUri(uri: string) {
@@ -422,7 +454,7 @@ export class SSOService {
422
454
  }
423
455
  }
424
456
 
425
- async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null, user?: User): Promise<Response<undefined>> {
457
+ async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null, user?: User, reauthenticateAccessToken: string | null = null): Promise<Response<undefined>> {
426
458
  const code_verifier = generators.codeVerifier();
427
459
  const state = generators.state(); // this is the internal state backend <-> SSO provider
428
460
  const nonce = generators.nonce();
@@ -439,6 +471,7 @@ export class SSOService {
439
471
  providerType: this.provider,
440
472
  organizationId: this.organization?.id ?? null,
441
473
  userId: user?.id ?? null,
474
+ reauthenticateAccessToken,
442
475
  };
443
476
 
444
477
  try {
@@ -465,7 +498,8 @@ export class SSOService {
465
498
  prompt: prompt ?? undefined,
466
499
  login_hint: user?.email ?? undefined,
467
500
  redirect_uri: this.externalRedirectUri,
468
-
501
+ max_age: prompt === 'login' ? 0 : undefined, // required to get auth_time in the response
502
+ claims: prompt === 'login' ? { id_token: { auth_time: { essential: true } } } : undefined,
469
503
  // Google has this instead of the offline_access scope
470
504
  access_type: this.provider === LoginProviderType.Google ? 'offline' : undefined,
471
505
  });
@@ -585,6 +619,21 @@ export class SSOServiceWithSession {
585
619
 
586
620
  this.service.validateEmail(claims.email);
587
621
 
622
+ if (this.session.userId) {
623
+ if (!claims.auth_time || claims.auth_time * 1000 < Date.now() - 1000 * 60 * 5) {
624
+ if (STAMHOOFD.environment === 'development' || STAMHOOFD.environment === 'test') {
625
+ // Dex does not support this (yet)
626
+ } else {
627
+ throw new SimpleError({
628
+ code: 'invalid_user',
629
+ message: 'No recent authentication',
630
+ human: $t('%ZhH'),
631
+ statusCode: 400,
632
+ });
633
+ }
634
+ }
635
+ }
636
+
588
637
  // Get user from database
589
638
  let user = await User.getForRegister(this.service.organization?.id ?? null, claims.email);
590
639
  if (!user) {
@@ -639,23 +688,66 @@ export class SSOServiceWithSession {
639
688
  const redirectUri = new URL(session.redirectUri);
640
689
 
641
690
  if (!this.session.userId) {
642
- const token = await Token.createExpiredToken(user);
643
-
644
- if (!token) {
691
+ // The identity provider authenticated the user, but that is only the first
692
+ // factor. A user who enrolled a second factor still has to pass it, and an
693
+ // account that also has a password must still enroll one when it is
694
+ // required — the password would otherwise stay a way in that skips
695
+ // whatever the provider enforces.
696
+ //
697
+ // This is a redirect, not a request/response pair, so the client gets the
698
+ // token of the pending step in the URL and picks the flow up from there.
699
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, this.service.organization, { loginMethod: 'sso' });
700
+
701
+ if (requirement.type === 'challenge') {
702
+ redirectUri.searchParams.set('oid_mfa', requirement.challenge.token);
703
+ redirectUri.searchParams.set('s', session.spaState);
704
+ } else if (requirement.type === 'setup') {
705
+ redirectUri.searchParams.set('oid_mfa_setup', requirement.setupToken.token);
706
+ // Which methods to offer. Only a hint for the enrollment screen: the
707
+ // enrollment endpoints check this themselves.
708
+ redirectUri.searchParams.set('oid_mfa_passkeys', user.canUsePasskeys() ? '1' : '0');
709
+ redirectUri.searchParams.set('s', session.spaState);
710
+ } else {
711
+ const token = await Token.createExpiredToken(user);
712
+
713
+ if (!token) {
714
+ throw new SimpleError({
715
+ code: 'error',
716
+ message: 'Could not generate token',
717
+ human: $t(`%D6`),
718
+ statusCode: 500,
719
+ });
720
+ }
721
+
722
+ const st = new TokenStruct(token);
723
+ redirectUri.searchParams.set('oid_rt', st.refreshToken);
724
+ redirectUri.searchParams.set('s', session.spaState);
725
+ }
726
+ } else if (this.session.reauthenticateAccessToken) {
727
+ // The provider confirmed the identity of the user that is already signed in,
728
+ // so their session counts as freshly authenticated again and the sensitive
729
+ // actions behind Context.authenticateFresh() unlock. This is the only way an
730
+ // SSO-only account can reach them: it has no password to confirm with.
731
+ const token = await Token.getByAccessToken(this.session.reauthenticateAccessToken);
732
+
733
+ if (!token || token.userId !== user.id) {
645
734
  throw new SimpleError({
646
- code: 'error',
647
- message: 'Could not generate token',
648
- human: $t(`%D6`),
649
- statusCode: 500,
735
+ code: 'invalid_token',
736
+ message: 'The session to reauthenticate no longer exists',
737
+ human: $t('%ZhJ'),
738
+ statusCode: 401,
650
739
  });
651
740
  }
652
741
 
653
- const st = new TokenStruct(token);
654
- redirectUri.searchParams.set('oid_rt', st.refreshToken);
742
+ token.authenticatedAt = new Date();
743
+ await token.save();
744
+ await user.markActive();
745
+
655
746
  redirectUri.searchParams.set('s', session.spaState);
747
+ redirectUri.searchParams.set('msg', $t('%Zhh'));
656
748
  } else {
657
749
  redirectUri.searchParams.set('s', session.spaState);
658
- redirectUri.searchParams.set('msg', 'Je account is succesvol gekoppeld');
750
+ redirectUri.searchParams.set('msg', $t('%Zh3'));
659
751
  }
660
752
 
661
753
  response.headers['location'] = redirectUri.toString();
@@ -1,9 +1,11 @@
1
- import { BalanceItem, Organization, Platform, sendEmailTemplate, STPackage } from '@stamhoofd/models';
1
+ import { BalanceItem, Organization, Platform, STPackage } from '@stamhoofd/models';
2
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
2
3
  import { SQL } from '@stamhoofd/sql';
3
4
  import type { Company } from '@stamhoofd/structures';
4
5
  import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, EmailTemplateType, getPricingTypeName, Recipient, Replacement, STPackageStatus, STPackageType, STPackageTypeHelper, STPricingType, TranslatedString } from '@stamhoofd/structures';
5
6
  import { Formatter, STMath } from '@stamhoofd/utility';
6
7
  import { VATService } from './VATService.js';
8
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
7
9
 
8
10
  export class STPackageService {
9
11
  static async getActivePackages(organizationId: string) {
@@ -334,7 +336,7 @@ export class STPackageService {
334
336
  return;
335
337
  }
336
338
 
337
- const admins = await organization.getFullAdmins();
339
+ const admins = await OrganizationAdminService.getFullAdmins(organization);
338
340
 
339
341
  const recipients = admins.map(admin =>
340
342
  Recipient.create({