@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.
- package/package.json +26 -17
- package/src/boot.ts +5 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/delete-expired-mfa-tokens.ts +35 -0
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
- package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
- package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
- package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
- package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
- package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
- package/src/endpoints/auth/MFA.security.test.ts +688 -0
- package/src/endpoints/auth/MFA.test.ts +1398 -0
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +17 -4
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +21 -10
- package/src/helpers/Context.ts +114 -4
- package/src/helpers/EmailBuilder.test.ts +287 -0
- package/src/helpers/EmailBuilder.ts +811 -0
- package/src/helpers/EmailResumer.ts +2 -1
- package/src/helpers/ForwardHandler.ts +2 -1
- package/src/helpers/MFAEncryption.ts +34 -0
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/helpers/RecoveryCodeHelper.ts +81 -0
- package/src/helpers/TOTPHelper.ts +79 -0
- package/src/helpers/TenantContext.test.ts +96 -0
- package/src/helpers/TenantContext.ts +67 -0
- package/src/helpers/TwoFactorHelper.ts +356 -0
- package/src/helpers/WebauthnHelper.ts +211 -0
- package/src/helpers/data/aaguids.json +1006 -0
- package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
- package/src/middleware/TenantScopeMiddleware.ts +24 -0
- package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
- package/src/services/AdminSessionService.ts +41 -0
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/EmailSendService.test.ts +1218 -0
- package/src/services/EmailSendService.ts +705 -0
- package/src/services/EventNotificationService.ts +2 -1
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +215 -0
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +212 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PaymentService.ts +5 -3
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +4 -2
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +115 -1
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +101 -0
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
- package/tests/helpers/MFATestHelper.ts +42 -0
- package/tests/helpers/TestServer.ts +4 -0
- package/tests/helpers/index.ts +1 -0
- package/tsconfig.build.json +2 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
|
|
2
|
+
import { isXlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
|
|
3
|
+
import { EventNotificationType, ExcelExportType, Organization, OrganizationMetaData, OrganizationTag, Platform, PlatformConfig, PlatformMembership, PlatformMembershipMemberDetails, PlatformMembershipOrganizationDetails, PlatformMembershipType, RecordCategory, RecordSettings, RecordType, TranslatedString } from '@stamhoofd/structures';
|
|
4
|
+
import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
|
|
5
|
+
import './index.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The sheets are built per export request, so every callback should read the platform it was built
|
|
9
|
+
* with instead of a process wide singleton.
|
|
10
|
+
*/
|
|
11
|
+
describe('Excel loaders platform argument', () => {
|
|
12
|
+
function getColumns(type: ExcelExportType, platform: Platform, sheetId: string): XlsxTransformerColumn<unknown>[] {
|
|
13
|
+
const loader = ExportToExcelEndpoint.loaders.get(type);
|
|
14
|
+
|
|
15
|
+
if (!loader) {
|
|
16
|
+
throw new Error('Loader ' + type + ' not registered');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sheet = loader.getSheets(platform).find(s => s.id === sheetId);
|
|
20
|
+
|
|
21
|
+
if (!sheet) {
|
|
22
|
+
throw new Error('Sheet ' + sheetId + ' not found');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return sheet.columns;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getColumn(type: ExcelExportType, platform: Platform, sheetId: string, columnId: string): XlsxTransformerConcreteColumn<unknown> {
|
|
29
|
+
const column = getColumns(type, platform, sheetId).find(c => isXlsxTransformerConcreteColumn(c) && c.id === columnId);
|
|
30
|
+
|
|
31
|
+
if (!column || !isXlsxTransformerConcreteColumn(column)) {
|
|
32
|
+
throw new Error('Column ' + columnId + ' not found');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return column;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createPlatformWithTag(tagName: string) {
|
|
39
|
+
return Platform.create({
|
|
40
|
+
config: PlatformConfig.create({
|
|
41
|
+
tags: [
|
|
42
|
+
OrganizationTag.create({ id: 'tag-1', name: tagName }),
|
|
43
|
+
],
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
it('reads the organization tags from the platform the sheets were built with', () => {
|
|
49
|
+
const organization = Organization.create({
|
|
50
|
+
meta: OrganizationMetaData.create({ tags: ['tag-1'] }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const first = getColumn(ExcelExportType.Organizations, createPlatformWithTag('Gewest Noord'), 'organizations', 'tags');
|
|
54
|
+
expect(first.getValue(organization).value).toBe('Gewest Noord');
|
|
55
|
+
|
|
56
|
+
// Building the sheets again with another platform should not reuse the first platform
|
|
57
|
+
const second = getColumn(ExcelExportType.Organizations, createPlatformWithTag('Gewest Zuid'), 'organizations', 'tags');
|
|
58
|
+
expect(second.getValue(organization).value).toBe('Gewest Zuid');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('reads the membership type from the platform the sheets were built with', () => {
|
|
62
|
+
const platform = Platform.create({
|
|
63
|
+
config: PlatformConfig.create({
|
|
64
|
+
membershipTypes: [
|
|
65
|
+
PlatformMembershipType.create({ id: 'type-1', name: 'Aansluiting' }),
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const membership = PlatformMembership.create({
|
|
71
|
+
memberId: 'member-1',
|
|
72
|
+
membershipTypeId: 'type-1',
|
|
73
|
+
organizationId: 'organization-1',
|
|
74
|
+
periodId: 'period-1',
|
|
75
|
+
member: PlatformMembershipMemberDetails.create({ firstName: 'John', lastName: 'Doe', memberNumber: null }),
|
|
76
|
+
organization: PlatformMembershipOrganizationDetails.create({}),
|
|
77
|
+
balanceItem: null,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const column = getColumn(ExcelExportType.PlatformMemberships, platform, 'platform-memberships', 'type');
|
|
81
|
+
expect(column.getValue(membership).value).toBe('Aansluiting');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reads the record categories of an event notification from the platform the sheets were built with', () => {
|
|
85
|
+
const platform = Platform.create({
|
|
86
|
+
config: PlatformConfig.create({
|
|
87
|
+
eventNotificationTypes: [
|
|
88
|
+
EventNotificationType.create({
|
|
89
|
+
recordCategories: [
|
|
90
|
+
RecordCategory.create({
|
|
91
|
+
name: new TranslatedString('Kamp'),
|
|
92
|
+
records: [
|
|
93
|
+
RecordSettings.create({
|
|
94
|
+
id: 'record-1',
|
|
95
|
+
name: new TranslatedString('Aantal deelnemers'),
|
|
96
|
+
type: RecordType.Text,
|
|
97
|
+
}),
|
|
98
|
+
],
|
|
99
|
+
}),
|
|
100
|
+
],
|
|
101
|
+
}),
|
|
102
|
+
],
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const columns = getColumns(ExcelExportType.EventNotifications, platform, 'event-notifications');
|
|
107
|
+
const matched = columns.flatMap(c => isXlsxTransformerConcreteColumn(c) ? [] : (c.match('recordAnswers.record-1') ?? []));
|
|
108
|
+
|
|
109
|
+
expect(matched).toHaveLength(1);
|
|
110
|
+
expect(matched[0].name).toBe('Aantal deelnemers');
|
|
111
|
+
expect(matched[0].category).toBe('Kamp');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type { XlsxTransformerSheet } from '@stamhoofd/excel-writer';
|
|
2
2
|
import { XlsxBuiltInNumberFormat } from '@stamhoofd/excel-writer';
|
|
3
3
|
import { Platform } from '@stamhoofd/models';
|
|
4
|
-
import type { LimitedFilteredRequest, PlatformMember } from '@stamhoofd/structures';
|
|
5
|
-
import { ExcelExportType, getGroupTypeName, PlatformRegistration,
|
|
4
|
+
import type { LimitedFilteredRequest, PlatformMember, Platform as PlatformStruct } from '@stamhoofd/structures';
|
|
5
|
+
import { ExcelExportType, getGroupTypeName, PlatformRegistration, UnencodeablePaginatedResponse } from '@stamhoofd/structures';
|
|
6
6
|
import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
|
|
7
7
|
import { GetRegistrationsEndpoint } from '../endpoints/global/registration/GetRegistrationsEndpoint.js';
|
|
8
8
|
import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
|
|
9
9
|
import { Context } from '../helpers/Context.js';
|
|
10
10
|
import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper.js';
|
|
11
|
-
import {
|
|
11
|
+
import { getBaseMemberColumns } from './members.js';
|
|
12
12
|
|
|
13
13
|
// Assign to a typed variable to assure we have correct type checking in place
|
|
14
|
-
const
|
|
14
|
+
const getSheet = (platform: PlatformStruct): XlsxTransformerSheet<PlatformMember, PlatformRegistration> => ({
|
|
15
15
|
id: 'registrations',
|
|
16
16
|
name: $t('%1EI'),
|
|
17
17
|
columns: [
|
|
@@ -294,7 +294,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
|
|
|
294
294
|
];
|
|
295
295
|
},
|
|
296
296
|
},
|
|
297
|
-
...
|
|
297
|
+
...getBaseMemberColumns(platform).map(column => XlsxTransformerColumnHelper.transformColumnForProperty({
|
|
298
298
|
column,
|
|
299
299
|
key: 'member',
|
|
300
300
|
getPropertyValue: (registration: PlatformRegistration) => registration.member,
|
|
@@ -341,7 +341,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
|
|
|
341
341
|
};
|
|
342
342
|
}
|
|
343
343
|
return {
|
|
344
|
-
value:
|
|
344
|
+
value: platform.config.defaultAgeGroups.find(g => g.id === defaultAgeGroupId)?.name ?? $t(`%wJ`),
|
|
345
345
|
};
|
|
346
346
|
},
|
|
347
347
|
},
|
|
@@ -356,7 +356,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
|
|
|
356
356
|
},
|
|
357
357
|
},
|
|
358
358
|
],
|
|
359
|
-
};
|
|
359
|
+
});
|
|
360
360
|
|
|
361
361
|
ExportToExcelEndpoint.loaders.set(ExcelExportType.Registrations, {
|
|
362
362
|
fetch: async (query: LimitedFilteredRequest) => {
|
|
@@ -370,7 +370,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Registrations, {
|
|
|
370
370
|
next: result.next,
|
|
371
371
|
});
|
|
372
372
|
},
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
getSheets: platform => [
|
|
374
|
+
getSheet(platform),
|
|
375
375
|
],
|
|
376
376
|
});
|
|
@@ -794,7 +794,7 @@ export class AdminPermissionChecker {
|
|
|
794
794
|
|
|
795
795
|
// Check if this user has permissions for the current scoped organization
|
|
796
796
|
if (this.organization && await this.hasFullAccess(this.organization.id)) {
|
|
797
|
-
if (user.permissions?.forOrganization(this.organization)) {
|
|
797
|
+
if (user.permissions?.forOrganization(this.organization, this.platform, { inheritFromPlatform: false })) {
|
|
798
798
|
return true;
|
|
799
799
|
}
|
|
800
800
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
2
|
import type { AuditLog, Document, EventNotification, MemberWithUsersRegistrationsAndGroups, Order, Ticket } from '@stamhoofd/models';
|
|
3
|
-
import { BalanceItem, CachedBalance, Event, Group, Invoice, Member, MemberPlatformMembership, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Payment, Registration, RegistrationInvitation, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItem, CachedBalance, Event, Group, Invoice, Member, MemberPlatformMembership, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Payment, Platform as PlatformModel, Registration, RegistrationInvitation, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
|
|
4
4
|
import type { PaymentGeneral } from '@stamhoofd/structures';
|
|
5
5
|
import { BaseOrganization, getAppHost, OrganizationPrivateMetaData } from '@stamhoofd/structures';
|
|
6
|
-
import { Payment as PaymentStruct, AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, BalanceItem as BalanceItemStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, InvitationGroupData, InvitationMemberData, InvoicedBalanceItem, InvoiceStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberRegistrationInvitation, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentCustomer, PermissionLevel,
|
|
6
|
+
import { Payment as PaymentStruct, AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, BalanceItem as BalanceItemStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, InvitationGroupData, InvitationMemberData, InvoicedBalanceItem, InvoiceStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberRegistrationInvitation, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentCustomer, PermissionLevel, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, RegistrationInvitation as RegistrationInvitationStruct, RegistrationsBlob, RegistrationWithMemberBlob, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
|
|
7
7
|
import { Sorter } from '@stamhoofd/utility';
|
|
8
8
|
|
|
9
9
|
import { SQL } from '@stamhoofd/sql';
|
|
10
10
|
import { Formatter } from '@stamhoofd/utility';
|
|
11
11
|
import { Context } from './Context.js';
|
|
12
|
+
import { TwoFactorHelper } from './TwoFactorHelper.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Builds authenticated structures for the current user
|
|
@@ -425,7 +426,7 @@ export class AuthenticatedStructures {
|
|
|
425
426
|
}
|
|
426
427
|
}
|
|
427
428
|
|
|
428
|
-
|
|
429
|
+
const struct = UserWithMembers.create({
|
|
429
430
|
...user,
|
|
430
431
|
hasAccount: user.hasAccount(),
|
|
431
432
|
hasPassword: user.hasPasswordBasedAccount(),
|
|
@@ -433,6 +434,8 @@ export class AuthenticatedStructures {
|
|
|
433
434
|
// Always include the current context organization - because it is possible we switch organization and we don't want to refetch every time
|
|
434
435
|
members: await this.membersBlob(filtered, true, user),
|
|
435
436
|
});
|
|
437
|
+
await TwoFactorHelper.fillTwoFactorStatus([struct]);
|
|
438
|
+
return struct;
|
|
436
439
|
}
|
|
437
440
|
|
|
438
441
|
/**
|
|
@@ -453,6 +456,8 @@ export class AuthenticatedStructures {
|
|
|
453
456
|
}));
|
|
454
457
|
}
|
|
455
458
|
|
|
459
|
+
await TwoFactorHelper.fillTwoFactorStatus(structs);
|
|
460
|
+
|
|
456
461
|
return structs;
|
|
457
462
|
}
|
|
458
463
|
|
|
@@ -465,11 +470,12 @@ export class AuthenticatedStructures {
|
|
|
465
470
|
return MembersBlob.create({ members: [], organizations: [] });
|
|
466
471
|
}
|
|
467
472
|
|
|
473
|
+
const platform = await PlatformModel.getSharedStruct();
|
|
468
474
|
const organizations = new Map<string, Organization>();
|
|
469
475
|
const relevantPeriodIds = Formatter.uniqueArray([
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
476
|
+
platform.period.previousPeriodId,
|
|
477
|
+
platform.period.id,
|
|
478
|
+
platform.period.nextPeriodId,
|
|
473
479
|
Context.organization?.periodId ?? null,
|
|
474
480
|
].filter(id => id !== null)) as string[];
|
|
475
481
|
|
|
@@ -520,7 +526,7 @@ export class AuthenticatedStructures {
|
|
|
520
526
|
organizationIds.push(STAMHOOFD.singleOrganization);
|
|
521
527
|
}
|
|
522
528
|
|
|
523
|
-
const membershipOrganizationId =
|
|
529
|
+
const membershipOrganizationId = platform.membershipOrganizationId;
|
|
524
530
|
if (membershipOrganizationId && Context.auth.hasSomePlatformAccess()) {
|
|
525
531
|
if (await Context.auth.hasSomeAccess(membershipOrganizationId)) {
|
|
526
532
|
organizationIds.push(membershipOrganizationId);
|
|
@@ -696,6 +702,9 @@ export class AuthenticatedStructures {
|
|
|
696
702
|
);
|
|
697
703
|
}
|
|
698
704
|
|
|
705
|
+
// After filterMemberData, so we only query for the users we actually return.
|
|
706
|
+
await TwoFactorHelper.fillTwoFactorStatus(memberBlobs.flatMap(b => b.users));
|
|
707
|
+
|
|
699
708
|
for (const blob of memberBlobs) {
|
|
700
709
|
blob.responsibilities = responsibilities.filter(r => r.memberId == blob.id).map((r) => {
|
|
701
710
|
const group = allGroups.get(r.groupId ?? '') ?? null;
|
|
@@ -1007,6 +1016,7 @@ export class AuthenticatedStructures {
|
|
|
1007
1016
|
...balances.filter(b => b.objectType === ReceivableBalanceType.user || b.objectType === ReceivableBalanceType.userWithoutMembers).map(b => b.objectId),
|
|
1008
1017
|
]);
|
|
1009
1018
|
const users = userIds.length > 0 ? await User.getByIDs(...userIds) : [];
|
|
1019
|
+
const platform = await PlatformModel.getSharedStruct();
|
|
1010
1020
|
|
|
1011
1021
|
const result: { balance: CachedBalance; object: ReceivableBalanceObject }[] = [];
|
|
1012
1022
|
|
|
@@ -1131,7 +1141,7 @@ export class AuthenticatedStructures {
|
|
|
1131
1141
|
} else if (balance.objectType === ReceivableBalanceType.user || balance.objectType === ReceivableBalanceType.userWithoutMembers) {
|
|
1132
1142
|
const user = users.find(m => m.id === balance.objectId) ?? null;
|
|
1133
1143
|
if (user) {
|
|
1134
|
-
const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + getAppHost('registration', Context.organization, user.permissions?.forOrganization(Context.organization)?.isEmpty === false) : '';
|
|
1144
|
+
const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + getAppHost('registration', Context.organization, user.permissions?.forOrganization(Context.organization, platform, { inheritFromPlatform: false })?.isEmpty === false) : '';
|
|
1135
1145
|
object = ReceivableBalanceObject.create({
|
|
1136
1146
|
id: balance.objectId,
|
|
1137
1147
|
name: user.name || user.email,
|
|
@@ -1193,6 +1203,7 @@ export class AuthenticatedStructures {
|
|
|
1193
1203
|
const users = await User.getByIDs(...userIds);
|
|
1194
1204
|
const organizationsIds = Formatter.uniqueArray(logs.map(l => l.organizationId).filter(id => id !== null));
|
|
1195
1205
|
const organizations = await Organization.getByIDs(...organizationsIds);
|
|
1206
|
+
const platform = await PlatformModel.getSharedStruct();
|
|
1196
1207
|
|
|
1197
1208
|
for (const log of logs) {
|
|
1198
1209
|
const user = log.userId ? (users.find(u => u.id === log.userId) ?? null) : null;
|
|
@@ -1200,10 +1211,10 @@ export class AuthenticatedStructures {
|
|
|
1200
1211
|
|
|
1201
1212
|
if (user) {
|
|
1202
1213
|
if (!await Context.auth.canAccessUser(user)) {
|
|
1203
|
-
if (user.permissions?.platform !== null) {
|
|
1214
|
+
if (user.permissions?.forPlatform(platform) !== null) {
|
|
1204
1215
|
userStruct = NamedObject.create({
|
|
1205
1216
|
id: '',
|
|
1206
|
-
name: $t(`%wi`) + ' ' +
|
|
1217
|
+
name: $t(`%wi`) + ' ' + platform.config.name,
|
|
1207
1218
|
});
|
|
1208
1219
|
} else {
|
|
1209
1220
|
userStruct = NamedObject.create({
|
package/src/helpers/Context.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { isSimpleError, SimpleError } from '@simonbackx/simple-errors';
|
|
3
3
|
import { I18n } from '@stamhoofd/backend-i18n';
|
|
4
|
-
import
|
|
5
|
-
import { Organization, Platform, RateLimiter, Token } from '@stamhoofd/models';
|
|
4
|
+
import { MFAToken, Organization, Platform, RateLimiter, Token, User } from '@stamhoofd/models';
|
|
6
5
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
7
6
|
|
|
8
7
|
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
9
8
|
import { AutoEncoder, field, StringDecoder } from '@simonbackx/simple-encoding';
|
|
10
9
|
import { ApiUserRateLimits } from '@stamhoofd/structures';
|
|
11
10
|
import { AdminPermissionChecker } from './AdminPermissionChecker.js';
|
|
11
|
+
import { TwoFactorHelper } from './TwoFactorHelper.js';
|
|
12
12
|
|
|
13
13
|
export const apiUserRateLimiter = new RateLimiter({
|
|
14
14
|
limits: [
|
|
@@ -180,7 +180,7 @@ export class ContextInstance {
|
|
|
180
180
|
if (STAMHOOFD.userMode === 'platform') {
|
|
181
181
|
return null;
|
|
182
182
|
}
|
|
183
|
-
return await this.
|
|
183
|
+
return await this.setOptionalOrganizationScope(options);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
async setOrganizationScope(options?: { willAuthenticate?: boolean }) {
|
|
@@ -223,7 +223,24 @@ export class ContextInstance {
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Identify the caller from the Authorization header without ever throwing: returns
|
|
228
|
+
* null when the request is unauthenticated or carries an invalid/expired token.
|
|
229
|
+
* Use this to give an already signed in user a smoother experience, never to grant
|
|
230
|
+
* access (there is no error to react to).
|
|
231
|
+
*/
|
|
232
|
+
async optionalAuthenticatedUser(): Promise<User | null> {
|
|
233
|
+
try {
|
|
234
|
+
const { user } = await this.authenticate({ allowWithoutAccount: true });
|
|
235
|
+
return user;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async authenticate(options: { allowMFASetupToken: true; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }>;
|
|
242
|
+
async authenticate(options?: { allowMFASetupToken?: false | undefined; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token }>;
|
|
243
|
+
async authenticate({ allowWithoutAccount = false, allowUnscoped = false, allowMFASetupToken = false }: { allowMFASetupToken?: boolean; allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }> {
|
|
227
244
|
let header = this.request.headers.authorization;
|
|
228
245
|
|
|
229
246
|
if (!header && this.request.method === 'POST') {
|
|
@@ -244,6 +261,12 @@ export class ContextInstance {
|
|
|
244
261
|
}
|
|
245
262
|
|
|
246
263
|
if (!header.startsWith('Bearer ')) {
|
|
264
|
+
if (allowMFASetupToken) {
|
|
265
|
+
const result = await this.authenticateMFASetup();
|
|
266
|
+
if (result) {
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
247
270
|
throw new SimpleError({
|
|
248
271
|
code: 'not_supported_authentication',
|
|
249
272
|
message: 'Authentication method not supported. Please authenticate with OAuth2',
|
|
@@ -320,6 +343,93 @@ export class ContextInstance {
|
|
|
320
343
|
return { user, token };
|
|
321
344
|
}
|
|
322
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Like authenticate(), but additionally requires the access token to be "fresh"
|
|
348
|
+
* (minted by a real authentication within the FRESH_WINDOW, not via a refresh).
|
|
349
|
+
* Used to gate sensitive actions such as managing 2FA methods.
|
|
350
|
+
*/
|
|
351
|
+
async authenticateFresh(options: { allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token }> {
|
|
352
|
+
const result = await this.authenticate(options);
|
|
353
|
+
if (!result.token.isFresh()) {
|
|
354
|
+
throw new SimpleError({
|
|
355
|
+
code: 'require_fresh_auth',
|
|
356
|
+
message: 'A recent authentication is required for this action',
|
|
357
|
+
human: $t('%Zgs'),
|
|
358
|
+
statusCode: 403,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Authorize a 2FA enrollment/management action. Accepts either a fresh full session
|
|
366
|
+
* (Bearer access token) or a setup token (Authorization: MFASetup <token>) issued
|
|
367
|
+
* during forced enrollment, before a session exists.
|
|
368
|
+
*/
|
|
369
|
+
async authenticateMFASetup(): Promise<{ user: User; setupToken: MFAToken } | null> {
|
|
370
|
+
const header = this.request.headers.authorization;
|
|
371
|
+
if (header && header.startsWith('MFASetup ')) {
|
|
372
|
+
const raw = header.substring('MFASetup '.length);
|
|
373
|
+
const setupToken = await MFAToken.getValid(raw, 'setup');
|
|
374
|
+
if (!setupToken) {
|
|
375
|
+
throw new SimpleError({
|
|
376
|
+
code: 'mfa_setup_expired',
|
|
377
|
+
message: 'The MFA setup session is invalid or expired',
|
|
378
|
+
human: $t('%ZhJ'),
|
|
379
|
+
statusCode: 401,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const user = await User.getByID(setupToken.userId);
|
|
383
|
+
if (!user) {
|
|
384
|
+
throw new SimpleError({
|
|
385
|
+
code: 'mfa_setup_expired',
|
|
386
|
+
message: 'The MFA setup session is invalid or expired',
|
|
387
|
+
human: $t('%ZhJ'),
|
|
388
|
+
statusCode: 401,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A setup token is issued on the strength of a password alone, to bootstrap a
|
|
393
|
+
// *first* factor. If the user enrolled one in the meantime (e.g. from another
|
|
394
|
+
// device), it must stop being worth a session: otherwise someone who only
|
|
395
|
+
// knows the password keeps a 15 minute window in which they can enroll a
|
|
396
|
+
// factor of their own and sign in, even though the account is now protected.
|
|
397
|
+
if (await TwoFactorHelper.userHasFactors(user.id)) {
|
|
398
|
+
await setupToken.consume();
|
|
399
|
+
throw new SimpleError({
|
|
400
|
+
code: 'mfa_setup_expired',
|
|
401
|
+
message: 'The MFA setup session is no longer valid: the user already has a second factor',
|
|
402
|
+
human: $t('%ZhJ'),
|
|
403
|
+
statusCode: 401,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.user = user;
|
|
408
|
+
await this.insecurelyAuthenticateAs(user);
|
|
409
|
+
return { user, setupToken };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Authorize a 2FA enrollment/management action. Accepts either a fresh full session
|
|
417
|
+
* (Bearer access token) or a setup token (Authorization: MFASetup <token>) issued
|
|
418
|
+
* during forced enrollment, before a session exists.
|
|
419
|
+
*
|
|
420
|
+
* Exactly one of `setupToken` / `token` is set: `token` is the session the request was
|
|
421
|
+
* made with, which enrollment keeps alive while signing out the user's other sessions.
|
|
422
|
+
*/
|
|
423
|
+
async authenticateMFAEnrollment(): Promise<{ user: User; setupToken: MFAToken | null; token: Token | null }> {
|
|
424
|
+
const mfa = await this.authenticateMFASetup();
|
|
425
|
+
if (mfa) {
|
|
426
|
+
return { ...mfa, token: null };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const { user, token } = await this.authenticateFresh();
|
|
430
|
+
return { user, setupToken: null, token };
|
|
431
|
+
}
|
|
432
|
+
|
|
323
433
|
async insecurelyAuthenticateAs(user: User) {
|
|
324
434
|
this.#auth = new AdminPermissionChecker(user, await Platform.getSharedPrivateStruct(), this.organization);
|
|
325
435
|
|