@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.
- package/package.json +20 -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/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +2 -1
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +133 -6
- 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 +2 -1
- 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/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +13 -2
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +3 -2
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +3 -2
- 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 +2 -1
- package/src/endpoints/global/files/UploadFile.ts +1 -1
- package/src/endpoints/global/files/UploadImage.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.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/organization/PatchOrganizationEndpoint.ts +1 -0
- 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/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/helpers/AuthenticatedStructures.ts +9 -1
- 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/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.test.ts +144 -0
- package/src/helpers/WebauthnHelper.ts +239 -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/EmailPreviewService.ts +1 -1
- 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/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.ts +2 -1
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationEmailService.ts +3 -2
- package/src/services/PaymentService.ts +5 -3
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +2 -1
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.ts +4 -2
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.ts +2 -1
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
-
import { EventNotification, Member, MemberResponsibilityRecord, Organization, Platform,
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 '
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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: '
|
|
647
|
-
message: '
|
|
648
|
-
human: $t(
|
|
649
|
-
statusCode:
|
|
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
|
-
|
|
654
|
-
|
|
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', '
|
|
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,
|
|
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
|
|
339
|
+
const admins = await OrganizationAdminService.getFullAdmins(organization);
|
|
338
340
|
|
|
339
341
|
const recipients = admins.map(admin =>
|
|
340
342
|
Recipient.create({
|