@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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateAuthenticationOptions,
|
|
3
|
+
generateRegistrationOptions,
|
|
4
|
+
verifyAuthenticationResponse,
|
|
5
|
+
verifyRegistrationResponse,
|
|
6
|
+
} from '@simplewebauthn/server';
|
|
7
|
+
import type { AuthenticationResponseJSON, AuthenticatorTransportFuture, RegistrationResponseJSON } from '@simplewebauthn/server';
|
|
8
|
+
import type { User, WebauthnCredential } from '@stamhoofd/models';
|
|
9
|
+
import type { WebauthnAuthenticationCredential, WebauthnRegistrationCredential } from '@stamhoofd/structures';
|
|
10
|
+
import { getWebauthnRpId } from '@stamhoofd/structures';
|
|
11
|
+
import aaguids from './data/aaguids.json' with { type: 'json' };
|
|
12
|
+
const RP_NAME = 'Stamhoofd';
|
|
13
|
+
|
|
14
|
+
const VALID_TRANSPORTS: AuthenticatorTransportFuture[] = ['ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', 'usb'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Narrow an untrusted string[] to valid AuthenticatorTransport values (no unchecked casts).
|
|
18
|
+
*/
|
|
19
|
+
function coerceTransports(transports: string[] | null | undefined): AuthenticatorTransportFuture[] | undefined {
|
|
20
|
+
if (!transports) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const filtered = transports.filter((t): t is AuthenticatorTransportFuture => (VALID_TRANSPORTS as string[]).includes(t));
|
|
24
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The WebAuthn Relying Party ID new passkeys are registered against. Passkeys are bound to
|
|
29
|
+
* a single registrable domain, so we scope them to the dashboard domain (admin/staff
|
|
30
|
+
* logins). Shared with the client through @stamhoofd/structures, so both sides agree on
|
|
31
|
+
* which domain passkeys live on.
|
|
32
|
+
*/
|
|
33
|
+
function getRpID(): string {
|
|
34
|
+
const rpId = getWebauthnRpId();
|
|
35
|
+
if (!rpId) {
|
|
36
|
+
throw new Error('Dashboard domain is required for WebAuthn');
|
|
37
|
+
}
|
|
38
|
+
return rpId;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The RP ID an existing credential has to be verified against.
|
|
43
|
+
*
|
|
44
|
+
* Credentials created before the RP ID was stored per credential all used the platform RP
|
|
45
|
+
* ID of the time, so that is the fallback.
|
|
46
|
+
*/
|
|
47
|
+
function getCredentialRpID(credential: WebauthnCredential): string {
|
|
48
|
+
return credential.rpId ?? getRpID();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert a SHA-256 certificate fingerprint to the base64url encoding Android uses in its
|
|
53
|
+
* origin. Accepts the colon separated hexadecimal notation of assetlinks.json, with or
|
|
54
|
+
* without separators.
|
|
55
|
+
*/
|
|
56
|
+
function fingerprintToBase64URL(fingerprint: string): string {
|
|
57
|
+
const hex = fingerprint.replace(/[\s:-]/g, '');
|
|
58
|
+
if (!/^[0-9a-f]{64}$/i.test(hex)) {
|
|
59
|
+
throw new Error('Invalid SHA-256 certificate fingerprint in ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS: ' + fingerprint);
|
|
60
|
+
}
|
|
61
|
+
return Buffer.from(hex, 'hex').toString('base64url');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The origins the Android app presents a passkey from.
|
|
66
|
+
*
|
|
67
|
+
* Android reports the app that made the call instead of the address of the web view:
|
|
68
|
+
* `android:apk-key-hash:<base64url sha-256 of the signing certificate>`. The certificates
|
|
69
|
+
* are configured through ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS and are the same ones as
|
|
70
|
+
* in the assetlinks.json of the RP ID, which is what makes Android hand our passkeys to
|
|
71
|
+
* the app in the first place.
|
|
72
|
+
*/
|
|
73
|
+
function getAndroidOrigins(): string[] {
|
|
74
|
+
return (STAMHOOFD.ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS ?? []).map(fingerprint => 'android:apk-key-hash:' + fingerprintToBase64URL(fingerprint));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The origins a passkey for `rpId` may be presented from.
|
|
79
|
+
*
|
|
80
|
+
* The web apps run on https. The Capacitor app serves its web view from the same host but
|
|
81
|
+
* a custom scheme (see frontend/app/mobile/capacitor.config.json), which iOS reports as
|
|
82
|
+
* `capacitor://<host>`; Android reports its own signature instead, see getAndroidOrigins.
|
|
83
|
+
*
|
|
84
|
+
* Widening the origin does not widen who can use these passkeys: the app only gets to see
|
|
85
|
+
* them because it is listed in the associated domains (webcredentials / assetlinks.json)
|
|
86
|
+
* of the RP ID, which the operating system verifies before it releases a credential to any
|
|
87
|
+
* app.
|
|
88
|
+
*/
|
|
89
|
+
function getExpectedOrigins(rpId: string): string[] {
|
|
90
|
+
return ['https://' + rpId, 'capacitor://' + rpId, ...getAndroidOrigins()];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const WebauthnHelper = {
|
|
94
|
+
getRpID,
|
|
95
|
+
getExpectedOrigins,
|
|
96
|
+
|
|
97
|
+
async generateRegistration(user: User, existingCredentials: WebauthnCredential[]) {
|
|
98
|
+
return await generateRegistrationOptions({
|
|
99
|
+
rpName: RP_NAME,
|
|
100
|
+
rpID: getRpID(),
|
|
101
|
+
userName: user.email,
|
|
102
|
+
userID: new Uint8Array(Buffer.from(user.id)),
|
|
103
|
+
userDisplayName: user.name || user.email,
|
|
104
|
+
attestationType: 'direct',
|
|
105
|
+
excludeCredentials: existingCredentials.map(c => ({
|
|
106
|
+
id: c.credentialId,
|
|
107
|
+
transports: coerceTransports(c.transportsArray),
|
|
108
|
+
})),
|
|
109
|
+
authenticatorSelection: {
|
|
110
|
+
residentKey: 'preferred',
|
|
111
|
+
userVerification: 'preferred',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @returns the verified credential info to persist, or null when verification failed.
|
|
118
|
+
*/
|
|
119
|
+
async verifyRegistration(credential: WebauthnRegistrationCredential, expectedChallenge: string) {
|
|
120
|
+
const response: RegistrationResponseJSON = {
|
|
121
|
+
id: credential.id,
|
|
122
|
+
rawId: credential.rawId,
|
|
123
|
+
type: 'public-key',
|
|
124
|
+
clientExtensionResults: {},
|
|
125
|
+
response: {
|
|
126
|
+
clientDataJSON: credential.response.clientDataJSON,
|
|
127
|
+
attestationObject: credential.response.attestationObject,
|
|
128
|
+
authenticatorData: credential.response.authenticatorData ?? undefined,
|
|
129
|
+
transports: coerceTransports(credential.response.transports),
|
|
130
|
+
publicKeyAlgorithm: credential.response.publicKeyAlgorithm ?? undefined,
|
|
131
|
+
publicKey: credential.response.publicKey ?? undefined,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const rpId = getRpID();
|
|
136
|
+
const expectedOrigin = getExpectedOrigins(rpId);
|
|
137
|
+
|
|
138
|
+
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
|
139
|
+
try {
|
|
140
|
+
verification = await verifyRegistrationResponse({
|
|
141
|
+
response,
|
|
142
|
+
expectedChallenge,
|
|
143
|
+
expectedOrigin,
|
|
144
|
+
expectedRPID: rpId,
|
|
145
|
+
requireUserVerification: false,
|
|
146
|
+
});
|
|
147
|
+
} catch (e) {
|
|
148
|
+
// Malformed / tampered input makes the library throw; treat as a failed
|
|
149
|
+
// verification instead of surfacing a 500.
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { credential: verifiedCredential, credentialBackedUp, credentialDeviceType, aaguid } = verification.registrationInfo;
|
|
158
|
+
return {
|
|
159
|
+
rpId,
|
|
160
|
+
providerId: aaguid || null,
|
|
161
|
+
providerName: aaguids[aaguid]?.name || null,
|
|
162
|
+
credentialId: verifiedCredential.id,
|
|
163
|
+
publicKey: Buffer.from(verifiedCredential.publicKey).toString('base64url'),
|
|
164
|
+
counter: verifiedCredential.counter,
|
|
165
|
+
transports: verifiedCredential.transports ?? null,
|
|
166
|
+
backedUp: credentialBackedUp,
|
|
167
|
+
backupEligible: credentialDeviceType === 'multiDevice',
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Only credentials of the RP ID we are authenticating against can take part: an
|
|
173
|
+
* authenticator will not release a passkey for a different domain. Today they all match,
|
|
174
|
+
* but this is what keeps the challenge correct once more than one domain is in play.
|
|
175
|
+
*/
|
|
176
|
+
filterForCurrentRpID(credentials: WebauthnCredential[]): WebauthnCredential[] {
|
|
177
|
+
const rpId = getRpID();
|
|
178
|
+
return credentials.filter(c => getCredentialRpID(c) === rpId);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async generateAuthentication(credentials: WebauthnCredential[]) {
|
|
182
|
+
return await generateAuthenticationOptions({
|
|
183
|
+
rpID: getRpID(),
|
|
184
|
+
allowCredentials: WebauthnHelper.filterForCurrentRpID(credentials).map(c => ({
|
|
185
|
+
id: c.credentialId,
|
|
186
|
+
transports: coerceTransports(c.transportsArray),
|
|
187
|
+
})),
|
|
188
|
+
userVerification: 'preferred',
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @returns the new signature counter on success, or null when verification failed.
|
|
194
|
+
*/
|
|
195
|
+
async verifyAuthentication(credential: WebauthnAuthenticationCredential, expectedChallenge: string, storedCredential: WebauthnCredential): Promise<number | null> {
|
|
196
|
+
const response: AuthenticationResponseJSON = {
|
|
197
|
+
id: credential.id,
|
|
198
|
+
rawId: credential.rawId,
|
|
199
|
+
type: 'public-key',
|
|
200
|
+
clientExtensionResults: {},
|
|
201
|
+
response: {
|
|
202
|
+
clientDataJSON: credential.response.clientDataJSON,
|
|
203
|
+
authenticatorData: credential.response.authenticatorData,
|
|
204
|
+
signature: credential.response.signature,
|
|
205
|
+
userHandle: credential.response.userHandle ?? undefined,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Verify against the RP ID this credential was created for, not the current one.
|
|
210
|
+
const rpId = getCredentialRpID(storedCredential);
|
|
211
|
+
const expectedOrigin = getExpectedOrigins(rpId);
|
|
212
|
+
|
|
213
|
+
let verification: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
|
|
214
|
+
try {
|
|
215
|
+
verification = await verifyAuthenticationResponse({
|
|
216
|
+
response,
|
|
217
|
+
expectedChallenge,
|
|
218
|
+
expectedOrigin,
|
|
219
|
+
expectedRPID: rpId,
|
|
220
|
+
credential: {
|
|
221
|
+
id: storedCredential.credentialId,
|
|
222
|
+
publicKey: new Uint8Array(Buffer.from(storedCredential.publicKey, 'base64url')),
|
|
223
|
+
counter: storedCredential.counter,
|
|
224
|
+
transports: coerceTransports(storedCredential.transportsArray),
|
|
225
|
+
},
|
|
226
|
+
requireUserVerification: false,
|
|
227
|
+
});
|
|
228
|
+
} catch (e) {
|
|
229
|
+
// Malformed / tampered input makes the library throw; treat as a failed
|
|
230
|
+
// verification instead of surfacing a 500.
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!verification.verified) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return verification.authenticationInfo.newCounter;
|
|
238
|
+
},
|
|
239
|
+
};
|