@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
|
@@ -2,6 +2,7 @@ import { Email, Organization, User } from '@stamhoofd/models';
|
|
|
2
2
|
import { SQL } from '@stamhoofd/sql';
|
|
3
3
|
import { EmailStatus } from '@stamhoofd/structures';
|
|
4
4
|
import { ContextInstance } from './Context.js';
|
|
5
|
+
import { EmailSendService } from '../services/EmailSendService.js';
|
|
5
6
|
|
|
6
7
|
export async function resumeEmails() {
|
|
7
8
|
const query = SQL.select()
|
|
@@ -28,7 +29,7 @@ export async function resumeEmails() {
|
|
|
28
29
|
|
|
29
30
|
try {
|
|
30
31
|
await ContextInstance.startForUser(user, organization, async () => {
|
|
31
|
-
await
|
|
32
|
+
await EmailSendService.resumeSending(email);
|
|
32
33
|
});
|
|
33
34
|
} catch (e) {
|
|
34
35
|
console.error('Error resuming email', email.id, e);
|
|
@@ -5,6 +5,7 @@ import { Email, EmailAddress } from '@stamhoofd/email';
|
|
|
5
5
|
import { Organization, Platform } from '@stamhoofd/models';
|
|
6
6
|
import { Formatter } from '@stamhoofd/utility';
|
|
7
7
|
import { simpleParser } from 'mailparser';
|
|
8
|
+
import { OrganizationAdminService } from '../services/OrganizationAdminService.js';
|
|
8
9
|
|
|
9
10
|
export class ForwardHandler {
|
|
10
11
|
static async handle(content: any, receipt: {
|
|
@@ -93,7 +94,7 @@ export class ForwardHandler {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
if (organization) {
|
|
96
|
-
organizationEmails = await
|
|
97
|
+
organizationEmails = await OrganizationAdminService.getReplyEmails(organization);
|
|
97
98
|
if (!organizationEmails) {
|
|
98
99
|
if (STAMHOOFD.environment === 'test') {
|
|
99
100
|
// ignore
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Symmetric encryption for TOTP secrets at rest. The key is derived from
|
|
5
|
+
* STAMHOOFD.INTERNAL_SECRET_KEY so we don't need a separate provisioned secret.
|
|
6
|
+
*/
|
|
7
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
8
|
+
|
|
9
|
+
function getKey(): Buffer {
|
|
10
|
+
const base = STAMHOOFD.INTERNAL_SECRET_KEY;
|
|
11
|
+
if (!base) {
|
|
12
|
+
throw new Error('INTERNAL_SECRET_KEY is required to encrypt MFA secrets');
|
|
13
|
+
}
|
|
14
|
+
return crypto.createHash('sha256').update(base + ':mfa-totp-secret').digest();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function encryptMFASecret(plaintext: string): string {
|
|
18
|
+
const iv = crypto.randomBytes(12);
|
|
19
|
+
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
|
20
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
21
|
+
const tag = cipher.getAuthTag();
|
|
22
|
+
return [iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function decryptMFASecret(payload: string): string {
|
|
26
|
+
const parts = payload.split(':');
|
|
27
|
+
if (parts.length !== 3) {
|
|
28
|
+
throw new Error('Invalid encrypted MFA secret');
|
|
29
|
+
}
|
|
30
|
+
const [ivB64, tagB64, dataB64] = parts;
|
|
31
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), Buffer.from(ivB64, 'base64'));
|
|
32
|
+
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
|
|
33
|
+
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]).toString('utf8');
|
|
34
|
+
}
|
|
@@ -3,6 +3,7 @@ import { BalanceItem, Member, MemberPlatformMembership, Platform, RegistrationPe
|
|
|
3
3
|
import { SQL, SQLOrderBy, SQLWhereSign } from '@stamhoofd/sql';
|
|
4
4
|
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
5
5
|
import { Formatter, sleep } from '@stamhoofd/utility';
|
|
6
|
+
import { PlatformMembershipService } from '../services/PlatformMembershipService.js';
|
|
6
7
|
|
|
7
8
|
export const MembershipCharger = {
|
|
8
9
|
async charge() {
|
|
@@ -86,7 +87,7 @@ export const MembershipCharger = {
|
|
|
86
87
|
|
|
87
88
|
// Force price update (required because could have changed - especially for free memberships in combination with deletes)
|
|
88
89
|
try {
|
|
89
|
-
await
|
|
90
|
+
await PlatformMembershipService.calculatePrice(membership, member);
|
|
90
91
|
} catch (e) {
|
|
91
92
|
console.error('Failed to update price for membership. Not charged.', membership.id, e);
|
|
92
93
|
continue;
|
|
@@ -194,7 +195,7 @@ export const MembershipCharger = {
|
|
|
194
195
|
|
|
195
196
|
// Force price update (required because could have changed - especially for free memberships in combination with deletes)
|
|
196
197
|
try {
|
|
197
|
-
await
|
|
198
|
+
await PlatformMembershipService.calculatePrice(membership, member);
|
|
198
199
|
await membership.save();
|
|
199
200
|
} catch (e) {
|
|
200
201
|
console.error('Failed to update price for membership', membership.id, e);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as argon2 from 'argon2';
|
|
2
|
+
import basex from 'base-x';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
|
|
5
|
+
import { MFARecoveryCode } from '@stamhoofd/models';
|
|
6
|
+
|
|
7
|
+
const RECOVERY_CODE_COUNT = 10;
|
|
8
|
+
const CODE_LENGTH = 16;
|
|
9
|
+
|
|
10
|
+
// No 0, O, I or L, to make the codes easier for humans to read. Mirrors
|
|
11
|
+
// Member.generateSecurityCode.
|
|
12
|
+
export const RECOVERY_CODE_ALPHABET = '123456789ABCDEFGHJKMNPQRSTUVWXYZ';
|
|
13
|
+
const recoveryCodeBase = basex(RECOVERY_CODE_ALPHABET);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A single recovery code: 16 symbols (~80 bits) from the base-x encoding of random
|
|
17
|
+
* bytes, formatted as XXXX-XXXX-XXXX-XXXX. Uses base-x (like Member.generateSecurityCode)
|
|
18
|
+
* rather than hand-rolled modulo indexing.
|
|
19
|
+
*/
|
|
20
|
+
function randomCode(): string {
|
|
21
|
+
const raw = recoveryCodeBase.encode(crypto.randomBytes(100)).toUpperCase().substring(0, CODE_LENGTH);
|
|
22
|
+
return raw.replace(/(.{4})(.{4})(.{4})(.{4})/, '$1-$2-$3-$4');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalize(code: string): string {
|
|
26
|
+
return (code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const RecoveryCodeHelper = {
|
|
30
|
+
/**
|
|
31
|
+
* Generate a new batch of recovery codes for a user, replacing any existing batch.
|
|
32
|
+
* Returns the plaintext codes (only shown once). Only Argon2 hashes are stored.
|
|
33
|
+
*/
|
|
34
|
+
async regenerateForUser(userId: string): Promise<string[]> {
|
|
35
|
+
await MFARecoveryCode.deleteForUser(userId);
|
|
36
|
+
|
|
37
|
+
const codes: string[] = [];
|
|
38
|
+
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
|
39
|
+
const code = randomCode();
|
|
40
|
+
codes.push(code);
|
|
41
|
+
|
|
42
|
+
const model = new MFARecoveryCode();
|
|
43
|
+
model.userId = userId;
|
|
44
|
+
model.codeHash = await argon2.hash(normalize(code));
|
|
45
|
+
await model.save();
|
|
46
|
+
}
|
|
47
|
+
return codes;
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Verify and consume a recovery code. Returns true if a matching unused code was
|
|
52
|
+
* found. The row is claimed with a conditional `UPDATE ... WHERE usedAt IS NULL`, so
|
|
53
|
+
* two concurrent requests can never both consume the same code.
|
|
54
|
+
*/
|
|
55
|
+
async consume(userId: string, code: string): Promise<boolean> {
|
|
56
|
+
const normalized = normalize(code);
|
|
57
|
+
if (normalized.length === 0) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const unused = await MFARecoveryCode.getUnusedForUser(userId);
|
|
62
|
+
for (const candidate of unused) {
|
|
63
|
+
try {
|
|
64
|
+
if (await argon2.verify(candidate.codeHash, normalized)) {
|
|
65
|
+
// Atomically claim the code: only the first request whose UPDATE
|
|
66
|
+
// matches (usedAt still NULL) wins the race.
|
|
67
|
+
const result = await MFARecoveryCode.update()
|
|
68
|
+
.set('usedAt', new Date())
|
|
69
|
+
.where('id', candidate.id)
|
|
70
|
+
.where('usedAt', null)
|
|
71
|
+
.update();
|
|
72
|
+
return result.changedRows === 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
// Ignore malformed hash, keep checking
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { authenticator } from 'otplib';
|
|
2
|
+
|
|
3
|
+
import { decryptMFASecret, encryptMFASecret } from './MFAEncryption.js';
|
|
4
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
5
|
+
|
|
6
|
+
// Allow a ±1 step (±30s) tolerance to account for clock drift.
|
|
7
|
+
const WINDOW = 1;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reset the shared otplib singleton to our defaults, optionally pinning the epoch (ms) so
|
|
11
|
+
* that a verification's returned delta and the counter we derive from it are computed from
|
|
12
|
+
* the exact same instant. Pass no argument to restore the live-clock behaviour.
|
|
13
|
+
*/
|
|
14
|
+
function configureAuthenticator(epoch?: number) {
|
|
15
|
+
authenticator.resetOptions();
|
|
16
|
+
authenticator.options = epoch === undefined ? { window: WINDOW } : { window: WINDOW, epoch };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
configureAuthenticator();
|
|
20
|
+
|
|
21
|
+
export const TOTPHelper = {
|
|
22
|
+
/**
|
|
23
|
+
* Generate a fresh base32 secret (plaintext, to show once to the user).
|
|
24
|
+
*/
|
|
25
|
+
generateSecret(): string {
|
|
26
|
+
return authenticator.generateSecret();
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the otpauth:// URI used to render the QR code.
|
|
31
|
+
*/
|
|
32
|
+
keyuri(accountName: string, secret: string): string {
|
|
33
|
+
const service = STAMHOOFD.domains?.dashboard ?? Formatter.capitalizeFirstLetter(STAMHOOFD.platformName);
|
|
34
|
+
return authenticator.keyuri(accountName, service, secret);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
encrypt(secret: string): string {
|
|
38
|
+
return encryptMFASecret(secret);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify a 6-digit code against an encrypted secret. Returns the absolute TOTP step
|
|
43
|
+
* counter that matched (which increases monotonically over time), or null when the
|
|
44
|
+
* code is invalid.
|
|
45
|
+
*
|
|
46
|
+
* Callers MUST reject a counter that is not strictly greater than the last counter
|
|
47
|
+
* they accepted for the same authenticator, otherwise a still-valid code can be
|
|
48
|
+
* replayed within its window (RFC 6238 §5.2).
|
|
49
|
+
*/
|
|
50
|
+
verify(code: string, encryptedSecret: string): number | null {
|
|
51
|
+
const trimmed = (code ?? '').trim();
|
|
52
|
+
if (!/^\d{6}$/.test(trimmed)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const secret = decryptMFASecret(encryptedSecret);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const step = authenticator.allOptions().step ?? 30;
|
|
59
|
+
|
|
60
|
+
// Pin the epoch so checkDelta() and our counter are derived from the same
|
|
61
|
+
// instant (otherwise a step boundary crossing between the two reads could
|
|
62
|
+
// produce an off-by-one counter).
|
|
63
|
+
configureAuthenticator(now);
|
|
64
|
+
let delta: number | null;
|
|
65
|
+
try {
|
|
66
|
+
delta = authenticator.checkDelta(trimmed, secret);
|
|
67
|
+
} finally {
|
|
68
|
+
configureAuthenticator();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (delta === null) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return Math.floor(now / 1000 / step) + delta;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Database } from '@simonbackx/simple-database';
|
|
2
|
+
import { Platform, ROOT_TENANT_ID } from '@stamhoofd/models';
|
|
3
|
+
import { PlatformConfig, PlatformMembershipType } from '@stamhoofd/structures';
|
|
4
|
+
import { STExpect } from '@stamhoofd/test-utils';
|
|
5
|
+
import { TenantContext } from './TenantContext.js';
|
|
6
|
+
|
|
7
|
+
describe('TenantContext', () => {
|
|
8
|
+
test('there is no tenant outside a scope', () => {
|
|
9
|
+
expect(TenantContext.optional).toBeNull();
|
|
10
|
+
expect(() => TenantContext.current).toThrow(
|
|
11
|
+
STExpect.simpleError({ code: 'no_tenant_context' }),
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('currentOrRoot falls back to the root tenant', () => {
|
|
16
|
+
expect(TenantContext.currentOrRoot.tenantId).toBe(ROOT_TENANT_ID);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('run enters a scope and leaves it again', async () => {
|
|
20
|
+
await TenantContext.run('tenant-a', async () => {
|
|
21
|
+
expect(TenantContext.current.tenantId).toBe('tenant-a');
|
|
22
|
+
expect(TenantContext.currentOrRoot.tenantId).toBe('tenant-a');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(TenantContext.optional).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a nested scope wins and the outer one is restored', async () => {
|
|
29
|
+
await TenantContext.run('tenant-a', async () => {
|
|
30
|
+
await TenantContext.run('tenant-b', async () => {
|
|
31
|
+
expect(TenantContext.current.tenantId).toBe('tenant-b');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(TenantContext.current.tenantId).toBe('tenant-a');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('concurrent scopes do not leak into each other', async () => {
|
|
39
|
+
const seen: string[] = [];
|
|
40
|
+
|
|
41
|
+
const observe = async (tenantId: string, delay: number) => {
|
|
42
|
+
return await TenantContext.run(tenantId, async () => {
|
|
43
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
44
|
+
seen.push(`${tenantId}:${TenantContext.current.tenantId}`);
|
|
45
|
+
return TenantContext.current.tenantId;
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// a enters first but resumes first too, so a save/restore of one mutable "current" would
|
|
50
|
+
// hand a whichever tenant entered after it
|
|
51
|
+
const [a, b] = await Promise.all([observe('tenant-a', 5), observe('tenant-b', 30)]);
|
|
52
|
+
|
|
53
|
+
expect(a).toBe('tenant-a');
|
|
54
|
+
expect(b).toBe('tenant-b');
|
|
55
|
+
expect(seen).toEqual(['tenant-a:tenant-a', 'tenant-b:tenant-b']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('resolving the tenant', () => {
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await Platform.clearCacheForTenantWithoutRefresh('context-tenant');
|
|
61
|
+
await Database.delete('DELETE FROM platform WHERE id != ?', [ROOT_TENANT_ID]);
|
|
62
|
+
await Platform.clearCache();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('the scope resolves its own struct', async () => {
|
|
66
|
+
const root = await Platform.getForEditing();
|
|
67
|
+
|
|
68
|
+
const other = new Platform();
|
|
69
|
+
other.id = 'context-tenant';
|
|
70
|
+
other.periodId = root.periodId;
|
|
71
|
+
other.config = PlatformConfig.create({
|
|
72
|
+
membershipTypes: [PlatformMembershipType.create({ id: 'ct', name: 'Context tenant type' })],
|
|
73
|
+
});
|
|
74
|
+
await other.save();
|
|
75
|
+
|
|
76
|
+
await TenantContext.run('context-tenant', async () => {
|
|
77
|
+
const struct = await TenantContext.current.getStruct();
|
|
78
|
+
expect(struct.config.membershipTypes.map(m => m.name)).toEqual(['Context tenant type']);
|
|
79
|
+
expect(struct.privateConfig).toBeNull();
|
|
80
|
+
|
|
81
|
+
const privateStruct = await TenantContext.current.getPrivateStruct();
|
|
82
|
+
expect(privateStruct.privateConfig).not.toBeNull();
|
|
83
|
+
|
|
84
|
+
expect((await TenantContext.current.getTenant()).id).toBe('context-tenant');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('an unknown tenant does not resolve', async () => {
|
|
89
|
+
await TenantContext.run('no-such-tenant', async () => {
|
|
90
|
+
await expect(TenantContext.current.getStruct()).rejects.toThrow(
|
|
91
|
+
STExpect.simpleError({ code: 'tenant_not_found' }),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
+
import { Platform, ROOT_TENANT_ID } from '@stamhoofd/models';
|
|
3
|
+
import type { PlatformPrivateConfig, Platform as PlatformStruct } from '@stamhoofd/structures';
|
|
4
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
5
|
+
|
|
6
|
+
export class TenantContextInstance {
|
|
7
|
+
readonly tenantId: string;
|
|
8
|
+
|
|
9
|
+
constructor(tenantId: string) {
|
|
10
|
+
this.tenantId = tenantId;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async getTenant(): Promise<Readonly<Platform>> {
|
|
14
|
+
return await Platform.getForTenant(this.tenantId);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async getStruct(): Promise<PlatformStruct> {
|
|
18
|
+
return await Platform.getStructForTenant(this.tenantId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async getPrivateStruct(): Promise<PlatformStruct & { privateConfig: PlatformPrivateConfig }> {
|
|
22
|
+
return await Platform.getPrivateStructForTenant(this.tenantId);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The tenant the current work belongs to.
|
|
28
|
+
*
|
|
29
|
+
* Separate from Context on purpose: crons, migrations and queued jobs run for a fixed tenant with no
|
|
30
|
+
* request, no authentication and no user, so a request-scoped context cannot carry them.
|
|
31
|
+
*/
|
|
32
|
+
export class TenantContext {
|
|
33
|
+
private static asyncLocalStorage = new AsyncLocalStorage<TenantContextInstance>();
|
|
34
|
+
|
|
35
|
+
static get optional(): TenantContextInstance | null {
|
|
36
|
+
return this.asyncLocalStorage.getStore() ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static get current(): TenantContextInstance {
|
|
40
|
+
const c = this.optional;
|
|
41
|
+
|
|
42
|
+
if (!c) {
|
|
43
|
+
throw new SimpleError({
|
|
44
|
+
code: 'no_tenant_context',
|
|
45
|
+
message: 'No tenant context found',
|
|
46
|
+
statusCode: 500,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return c;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The current tenant, or the root tenant when nothing has entered a scope yet.
|
|
55
|
+
*
|
|
56
|
+
* Only for call sites that have not been given a tenant yet. Anything that already runs for a
|
|
57
|
+
* known tenant should use current, so a missing scope fails loudly instead of quietly reading
|
|
58
|
+
* the root tenant.
|
|
59
|
+
*/
|
|
60
|
+
static get currentOrRoot(): TenantContextInstance {
|
|
61
|
+
return this.optional ?? new TenantContextInstance(ROOT_TENANT_ID);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static async run<T>(tenantId: string, handler: () => Promise<T>): Promise<T> {
|
|
65
|
+
return await this.asyncLocalStorage.run(new TenantContextInstance(tenantId), handler);
|
|
66
|
+
}
|
|
67
|
+
}
|