@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
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
3
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
|
-
import { EmailVerificationCode, PasswordToken, Platform, Token, User } from '@stamhoofd/models';
|
|
5
|
-
import type { ChallengeGrantStruct, PasswordGrantStruct, PasswordTokenGrantStruct, RefreshTokenGrantStruct, RequestChallengeGrantStruct } from '@stamhoofd/structures';
|
|
6
|
-
import { CreateTokenStruct, LoginMethod, SignupResponse, Token as TokenStruct } from '@stamhoofd/structures';
|
|
4
|
+
import { EmailVerificationCode, MFATOTP, MFAToken, PasswordToken, Platform, Token, User, WebauthnCredential } from '@stamhoofd/models';
|
|
5
|
+
import type { ChallengeGrantStruct, MFAGrantStruct, PasswordGrantStruct, PasswordTokenGrantStruct, RefreshTokenGrantStruct, RequestChallengeGrantStruct } from '@stamhoofd/structures';
|
|
6
|
+
import { CreateTokenStruct, LoginMethod, MFAMethodType, SignupResponse, Token as TokenStruct } from '@stamhoofd/structures';
|
|
7
7
|
|
|
8
8
|
import { Context } from '../../helpers/Context.js';
|
|
9
|
+
import { RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
|
|
10
|
+
import { TOTPHelper } from '../../helpers/TOTPHelper.js';
|
|
11
|
+
import { mfaVerificationRateLimiter, TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
12
|
+
import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
|
|
13
|
+
import { VerificationCodeService } from '../../services/VerificationCodeService.js';
|
|
9
14
|
|
|
10
15
|
type Params = Record<string, never>;
|
|
11
16
|
type Query = undefined;
|
|
12
|
-
type Body = RequestChallengeGrantStruct | ChallengeGrantStruct | RefreshTokenGrantStruct | PasswordTokenGrantStruct | PasswordGrantStruct;
|
|
17
|
+
type Body = RequestChallengeGrantStruct | ChallengeGrantStruct | RefreshTokenGrantStruct | PasswordTokenGrantStruct | PasswordGrantStruct | MFAGrantStruct;
|
|
13
18
|
type ResponseBody = TokenStruct;
|
|
14
19
|
|
|
15
20
|
export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
@@ -81,6 +86,8 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
81
86
|
});
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
await oldToken.user.markActive();
|
|
90
|
+
|
|
84
91
|
const st = new TokenStruct(token);
|
|
85
92
|
return new Response(st);
|
|
86
93
|
}
|
|
@@ -127,7 +134,7 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
127
134
|
// if not: throw a validation error (e-mail validation is required)
|
|
128
135
|
if (!user.verified) {
|
|
129
136
|
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
130
|
-
|
|
137
|
+
VerificationCodeService.send(code, user, organization, request.i18n).catch(console.error);
|
|
131
138
|
|
|
132
139
|
throw new SimpleError({
|
|
133
140
|
code: 'verify_email',
|
|
@@ -140,7 +147,12 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
140
147
|
});
|
|
141
148
|
}
|
|
142
149
|
|
|
143
|
-
|
|
150
|
+
// Second factor / forced enrollment: block the session until the user
|
|
151
|
+
// completes (or first sets up) a second factor. Shared with every other
|
|
152
|
+
// grant that mints a session from a single primary credential.
|
|
153
|
+
await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion());
|
|
154
|
+
|
|
155
|
+
const token = await Token.createToken(user, new Date());
|
|
144
156
|
|
|
145
157
|
if (!token) {
|
|
146
158
|
throw new SimpleError({
|
|
@@ -151,6 +163,110 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
151
163
|
});
|
|
152
164
|
}
|
|
153
165
|
|
|
166
|
+
await user.markActive();
|
|
167
|
+
|
|
168
|
+
const st = new TokenStruct(token);
|
|
169
|
+
return new Response(st);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'mfa': {
|
|
173
|
+
const mfaToken = await MFAToken.getValid(request.body.mfaToken, 'login');
|
|
174
|
+
if (!mfaToken) {
|
|
175
|
+
throw new SimpleError({
|
|
176
|
+
code: 'invalid_mfa_token',
|
|
177
|
+
message: 'The MFA session is invalid or expired',
|
|
178
|
+
human: $t('%ZhJ'),
|
|
179
|
+
statusCode: 400,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const user = await User.getByID(mfaToken.userId);
|
|
184
|
+
if (!user) {
|
|
185
|
+
await mfaToken.consume();
|
|
186
|
+
throw new SimpleError({
|
|
187
|
+
code: 'invalid_mfa_token',
|
|
188
|
+
message: 'The MFA session is invalid or expired',
|
|
189
|
+
human: $t('%ZhJ'),
|
|
190
|
+
statusCode: 400,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Scope check (same rules as refresh/password_token)
|
|
195
|
+
if ((organization && user.organizationId && user.organizationId !== organization.id) || (!organization && user.organizationId)) {
|
|
196
|
+
throw new SimpleError({
|
|
197
|
+
code: 'invalid_mfa_token',
|
|
198
|
+
message: 'The MFA session is invalid or expired',
|
|
199
|
+
human: $t('%ZhJ'),
|
|
200
|
+
statusCode: 400,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Aggregate brute-force protection across ALL of this user's MFA tokens
|
|
205
|
+
// (the per-token counter alone can be bypassed by minting new tokens).
|
|
206
|
+
// This MUST run before verification so that, once the limit is hit, even a
|
|
207
|
+
// correct code is rejected — otherwise a lucky/leaked code would still let
|
|
208
|
+
// an attacker through after unlimited guesses.
|
|
209
|
+
mfaVerificationRateLimiter.track(user.id);
|
|
210
|
+
|
|
211
|
+
let verified = false;
|
|
212
|
+
if (request.body.method === MFAMethodType.TOTP) {
|
|
213
|
+
const code = request.body.code ?? '';
|
|
214
|
+
for (const totp of await MFATOTP.getConfirmedForUser(user.id)) {
|
|
215
|
+
const counter = TOTPHelper.verify(code, totp.secret);
|
|
216
|
+
if (counter === null) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Claiming the step counter both rejects a still-valid code that was
|
|
220
|
+
// accepted before (replay) and settles two requests that submit the
|
|
221
|
+
// same code at the same time: only one of them gets the claim.
|
|
222
|
+
if (await totp.claimCounter(counter)) {
|
|
223
|
+
verified = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} else if (request.body.method === MFAMethodType.RecoveryCode) {
|
|
228
|
+
verified = await RecoveryCodeHelper.consume(user.id, request.body.code ?? '');
|
|
229
|
+
} else if (request.body.method === MFAMethodType.Passkey) {
|
|
230
|
+
const assertion = request.body.assertion;
|
|
231
|
+
const credentialId = assertion?.id;
|
|
232
|
+
if (mfaToken.webauthnChallenge && assertion && credentialId) {
|
|
233
|
+
const credential = await WebauthnCredential.getByCredentialId(credentialId);
|
|
234
|
+
if (credential && credential.userId === user.id) {
|
|
235
|
+
const newCounter = await WebauthnHelper.verifyAuthentication(assertion, mfaToken.webauthnChallenge, credential);
|
|
236
|
+
if (newCounter !== null) {
|
|
237
|
+
credential.counter = newCounter;
|
|
238
|
+
credential.lastUsedAt = new Date();
|
|
239
|
+
await credential.save();
|
|
240
|
+
verified = true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!verified) {
|
|
247
|
+
const locked = await mfaToken.registerFailedAttempt();
|
|
248
|
+
throw new SimpleError({
|
|
249
|
+
code: locked ? 'too_many_attempts' : 'invalid_mfa_code',
|
|
250
|
+
message: locked ? 'Too many attempts' : 'Invalid code',
|
|
251
|
+
human: locked ? $t('%ZhT') : $t('%Zgp'),
|
|
252
|
+
statusCode: locked ? 429 : 400,
|
|
253
|
+
field: 'code',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!await mfaToken.consume()) {
|
|
258
|
+
// Another request already completed this challenge.
|
|
259
|
+
throw new SimpleError({
|
|
260
|
+
code: 'invalid_mfa_token',
|
|
261
|
+
message: 'The MFA session is invalid or expired',
|
|
262
|
+
human: $t('%ZhJ'),
|
|
263
|
+
statusCode: 400,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const token = await Token.createToken(user, new Date());
|
|
268
|
+
await user.markActive();
|
|
269
|
+
|
|
154
270
|
const st = new TokenStruct(token);
|
|
155
271
|
return new Response(st);
|
|
156
272
|
}
|
|
@@ -187,8 +303,18 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
187
303
|
});
|
|
188
304
|
}
|
|
189
305
|
|
|
306
|
+
// A valid password-reset/magic-link token is a single primary credential:
|
|
307
|
+
// enforce the same second-factor gate as the password grant, otherwise
|
|
308
|
+
// anyone with a reset link (i.e. email access) could bypass the user's 2FA
|
|
309
|
+
// and even wipe their factors with the resulting fresh session.
|
|
310
|
+
//
|
|
311
|
+
// Forced enrollment (the user has no factor yet) additionally returns a
|
|
312
|
+
// temporary session token: this flow is also used to choose a first
|
|
313
|
+
// password (invites), which the client can only do with a session.
|
|
314
|
+
await TwoFactorHelper.assertSecondFactorOrThrow(passwordToken.user, organization, request.request.getVersion(), { allowTemporarySession: true });
|
|
315
|
+
|
|
190
316
|
// Important to create a new token before adjusting the old token
|
|
191
|
-
const token = await Token.createToken(passwordToken.user);
|
|
317
|
+
const token = await Token.createToken(passwordToken.user, new Date());
|
|
192
318
|
|
|
193
319
|
// TODO: make token short lived until renewal
|
|
194
320
|
|
|
@@ -210,6 +336,8 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
210
336
|
await token.user.save();
|
|
211
337
|
}
|
|
212
338
|
|
|
339
|
+
await token.user.markActive();
|
|
340
|
+
|
|
213
341
|
const st = new TokenStruct(token);
|
|
214
342
|
return new Response(st);
|
|
215
343
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
|
+
import { MFATOTP, WebauthnCredential } from '@stamhoofd/models';
|
|
5
|
+
import type { MFAStatus } from '@stamhoofd/structures';
|
|
6
|
+
import { MFAMethodType } from '@stamhoofd/structures';
|
|
7
|
+
|
|
8
|
+
import { Context } from '../../helpers/Context.js';
|
|
9
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
10
|
+
import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
|
|
11
|
+
|
|
12
|
+
type Params = { id: string };
|
|
13
|
+
type Query = undefined;
|
|
14
|
+
type Body = undefined;
|
|
15
|
+
type ResponseBody = MFAStatus;
|
|
16
|
+
|
|
17
|
+
export class DeletePasskeyEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
18
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
19
|
+
if (request.method !== 'DELETE') {
|
|
20
|
+
return [false];
|
|
21
|
+
}
|
|
22
|
+
const params = Endpoint.parseParameters(request.url, '/mfa/passkeys/@id', { id: String });
|
|
23
|
+
if (params) {
|
|
24
|
+
return [true, params as Params];
|
|
25
|
+
}
|
|
26
|
+
return [false];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
30
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
31
|
+
const { user, token } = await Context.authenticateFresh({ allowWithoutAccount: true, allowUnscoped: true });
|
|
32
|
+
|
|
33
|
+
const credential = await WebauthnCredential.getByID(request.params.id);
|
|
34
|
+
if (!credential || credential.userId !== user.id) {
|
|
35
|
+
throw new SimpleError({
|
|
36
|
+
code: 'not_found',
|
|
37
|
+
message: 'Passkey not found',
|
|
38
|
+
human: $t('%Zho'),
|
|
39
|
+
statusCode: 404,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const confirmedTotp = await MFATOTP.getConfirmedForUser(user.id);
|
|
44
|
+
const passkeys = await WebauthnCredential.getForUser(user.id);
|
|
45
|
+
const remaining = confirmedTotp.length + passkeys.filter(p => p.id !== credential.id).length;
|
|
46
|
+
if (remaining === 0 && await TwoFactorHelper.isTwoFactorRequired(user, organization ?? Context.organization ?? null)) {
|
|
47
|
+
throw new SimpleError({
|
|
48
|
+
code: 'cannot_remove_last_factor',
|
|
49
|
+
message: 'Cannot remove the last two-factor method',
|
|
50
|
+
human: $t('%Zh4'),
|
|
51
|
+
statusCode: 400,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await credential.delete();
|
|
56
|
+
|
|
57
|
+
await TwoFactorAuditLogService.logMethodDeleted(user, MFAMethodType.Passkey, credential.name || credential.providerName || '');
|
|
58
|
+
|
|
59
|
+
return new Response(await TwoFactorHelper.completeRemoval(user, token));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
|
+
import { MFATOTP, WebauthnCredential } from '@stamhoofd/models';
|
|
5
|
+
import type { MFAStatus } from '@stamhoofd/structures';
|
|
6
|
+
import { MFAMethodType } from '@stamhoofd/structures';
|
|
7
|
+
|
|
8
|
+
import { Context } from '../../helpers/Context.js';
|
|
9
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
10
|
+
import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
|
|
11
|
+
|
|
12
|
+
type Params = { id: string };
|
|
13
|
+
type Query = undefined;
|
|
14
|
+
type Body = undefined;
|
|
15
|
+
type ResponseBody = MFAStatus;
|
|
16
|
+
|
|
17
|
+
export class DeleteTOTPEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
18
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
19
|
+
if (request.method !== 'DELETE') {
|
|
20
|
+
return [false];
|
|
21
|
+
}
|
|
22
|
+
const params = Endpoint.parseParameters(request.url, '/mfa/totp/@id', { id: String });
|
|
23
|
+
if (params) {
|
|
24
|
+
return [true, params as Params];
|
|
25
|
+
}
|
|
26
|
+
return [false];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
30
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
31
|
+
const { user, token } = await Context.authenticateFresh({ allowWithoutAccount: true, allowUnscoped: true });
|
|
32
|
+
|
|
33
|
+
const totp = await MFATOTP.getByID(request.params.id);
|
|
34
|
+
if (!totp || totp.userId !== user.id) {
|
|
35
|
+
throw new SimpleError({
|
|
36
|
+
code: 'not_found',
|
|
37
|
+
message: 'TOTP authenticator not found',
|
|
38
|
+
human: $t('%ZgJ'),
|
|
39
|
+
statusCode: 404,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Prevent removing the last factor when 2FA is required.
|
|
44
|
+
const confirmedTotp = await MFATOTP.getConfirmedForUser(user.id);
|
|
45
|
+
const passkeys = await WebauthnCredential.getForUser(user.id);
|
|
46
|
+
const remaining = confirmedTotp.filter(t => t.id !== totp.id).length + passkeys.length;
|
|
47
|
+
if (remaining === 0 && await TwoFactorHelper.isTwoFactorRequired(user, organization ?? Context.organization ?? null)) {
|
|
48
|
+
throw new SimpleError({
|
|
49
|
+
code: 'cannot_remove_last_factor',
|
|
50
|
+
message: 'Cannot remove the last two-factor method',
|
|
51
|
+
human: $t('%Zh4'),
|
|
52
|
+
statusCode: 400,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await totp.delete();
|
|
57
|
+
|
|
58
|
+
await TwoFactorAuditLogService.logMethodDeleted(user, MFAMethodType.TOTP, totp.name);
|
|
59
|
+
|
|
60
|
+
return new Response(await TwoFactorHelper.completeRemoval(user, token));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
3
|
|
|
4
|
-
import { getDefaultEmailFrom, sendEmailTemplate } from '
|
|
4
|
+
import { getDefaultEmailFrom, sendEmailTemplate } from '../../helpers/EmailBuilder.js';
|
|
5
5
|
import { EmailTemplateType, Recipient } from '@stamhoofd/structures';
|
|
6
6
|
import { Context } from '../../helpers/Context.js';
|
|
7
7
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
2
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
3
3
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
4
|
-
import {
|
|
4
|
+
import { Platform, User } from '@stamhoofd/models';
|
|
5
|
+
import { sendEmailTemplate } from '../../helpers/EmailBuilder.js';
|
|
5
6
|
import { EmailTemplateType, ForgotPasswordRequest, LoginMethod, Recipient, Replacement } from '@stamhoofd/structures';
|
|
6
7
|
|
|
7
8
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
8
9
|
import { Context } from '../../helpers/Context.js';
|
|
10
|
+
import { PasswordForgotService } from '../../services/PasswordForgotService.js';
|
|
9
11
|
|
|
10
12
|
type Params = Record<string, never>;
|
|
11
13
|
type Query = undefined;
|
|
@@ -72,7 +74,7 @@ export class ForgotPasswordEndpoint extends Endpoint<Params, Query, Body, Respon
|
|
|
72
74
|
return new Response(undefined);
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
const recoveryUrl = await
|
|
77
|
+
const recoveryUrl = await PasswordForgotService.getPasswordRecoveryUrl(user, organization, request.i18n);
|
|
76
78
|
|
|
77
79
|
// Create e-mail builder
|
|
78
80
|
await sendEmailTemplate(organization, {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
5
|
+
import { MFAToken } from '@stamhoofd/models';
|
|
6
|
+
import type { MFAChallengeResponse } from '@stamhoofd/structures';
|
|
7
|
+
import { MFAChallengeRequest } from '@stamhoofd/structures';
|
|
8
|
+
|
|
9
|
+
import { Context } from '../../helpers/Context.js';
|
|
10
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
11
|
+
|
|
12
|
+
type Params = Record<string, never>;
|
|
13
|
+
type Query = undefined;
|
|
14
|
+
type Body = MFAChallengeRequest;
|
|
15
|
+
type ResponseBody = MFAChallengeResponse;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Describe the second factors that can complete a pending login.
|
|
19
|
+
*
|
|
20
|
+
* Password logins receive this as the `meta` of the `require_mfa` error, but an SSO login
|
|
21
|
+
* ends in a browser redirect that can only carry the token itself. The client posts that
|
|
22
|
+
* token here to learn which methods it may offer.
|
|
23
|
+
*
|
|
24
|
+
* Holding the token means the primary credential was already accepted, and the response
|
|
25
|
+
* carries no secrets: only which method types are enrolled, plus fresh WebAuthn options.
|
|
26
|
+
*/
|
|
27
|
+
export class GetMFAChallengeEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
28
|
+
bodyDecoder = MFAChallengeRequest as Decoder<MFAChallengeRequest>;
|
|
29
|
+
|
|
30
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
31
|
+
if (request.method !== 'POST') {
|
|
32
|
+
return [false];
|
|
33
|
+
}
|
|
34
|
+
const params = Endpoint.parseParameters(request.url, '/mfa/challenge', {});
|
|
35
|
+
if (params) {
|
|
36
|
+
return [true, params as Params];
|
|
37
|
+
}
|
|
38
|
+
return [false];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
42
|
+
await Context.setOptionalOrganizationScope({ willAuthenticate: false });
|
|
43
|
+
|
|
44
|
+
const mfaToken = await MFAToken.getValid(request.body.token, 'login');
|
|
45
|
+
if (!mfaToken) {
|
|
46
|
+
throw new SimpleError({
|
|
47
|
+
code: 'invalid_mfa_token',
|
|
48
|
+
message: 'The MFA session is invalid or expired',
|
|
49
|
+
human: $t('%ZhJ'),
|
|
50
|
+
statusCode: 400,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return new Response(await TwoFactorHelper.describeLoginChallenge(mfaToken));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import type { MFAStatus } from '@stamhoofd/structures';
|
|
4
|
+
|
|
5
|
+
import { Context } from '../../helpers/Context.js';
|
|
6
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
7
|
+
|
|
8
|
+
type Params = Record<string, never>;
|
|
9
|
+
type Query = undefined;
|
|
10
|
+
type Body = undefined;
|
|
11
|
+
type ResponseBody = MFAStatus;
|
|
12
|
+
|
|
13
|
+
export class GetMFAStatusEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
14
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
15
|
+
if (request.method !== 'GET') {
|
|
16
|
+
return [false];
|
|
17
|
+
}
|
|
18
|
+
const params = Endpoint.parseParameters(request.url, '/mfa', {});
|
|
19
|
+
if (params) {
|
|
20
|
+
return [true, params as Params];
|
|
21
|
+
}
|
|
22
|
+
return [false];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
26
|
+
await Context.setOptionalOrganizationScope();
|
|
27
|
+
const { user } = await Context.authenticate({ allowWithoutAccount: true, allowUnscoped: true });
|
|
28
|
+
|
|
29
|
+
return new Response(await TwoFactorHelper.buildStatus(user));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
-
import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
2
|
+
import { MemberFactory, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
3
3
|
|
|
4
|
+
import { MFATestHelper } from '../../../tests/helpers/MFATestHelper.js';
|
|
4
5
|
import { testServer } from '../../../tests/helpers/TestServer.js';
|
|
5
6
|
import { GetUserEndpoint } from './GetUserEndpoint.js';
|
|
6
7
|
import { STExpect } from '@stamhoofd/test-utils';
|
|
@@ -22,6 +23,36 @@ describe('Endpoint.GetUser', () => {
|
|
|
22
23
|
expect(response.body.id).toEqual(user.id);
|
|
23
24
|
});
|
|
24
25
|
|
|
26
|
+
test('Two-factor authentication is reported on the user and on the accounts of a member', async () => {
|
|
27
|
+
const organization = await new OrganizationFactory({}).create();
|
|
28
|
+
const user = await new UserFactory({ organization }).create();
|
|
29
|
+
await new MemberFactory({ organization, user }).create();
|
|
30
|
+
await MFATestHelper.addConfirmedTOTP(user);
|
|
31
|
+
|
|
32
|
+
const token = await Token.createToken(user);
|
|
33
|
+
const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
|
|
34
|
+
r.headers.authorization = 'Bearer ' + token.accessToken;
|
|
35
|
+
|
|
36
|
+
const response = await testServer.test(endpoint, r);
|
|
37
|
+
expect(response.body.hasTwoFactor).toBe(true);
|
|
38
|
+
|
|
39
|
+
// The same flag is what MemberAccountBox shows for every account of a member.
|
|
40
|
+
const member = response.body.members.members.find(m => m.users.some(u => u.id === user.id));
|
|
41
|
+
expect(member?.users.find(u => u.id === user.id)?.hasTwoFactor).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('A user without a second factor is not reported as having two-factor authentication', async () => {
|
|
45
|
+
const organization = await new OrganizationFactory({}).create();
|
|
46
|
+
const user = await new UserFactory({ organization }).create();
|
|
47
|
+
const token = await Token.createToken(user);
|
|
48
|
+
|
|
49
|
+
const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
|
|
50
|
+
r.headers.authorization = 'Bearer ' + token.accessToken;
|
|
51
|
+
|
|
52
|
+
const response = await testServer.test(endpoint, r);
|
|
53
|
+
expect(response.body.hasTwoFactor).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
25
56
|
test('Request user details when not signed in is not working', async () => {
|
|
26
57
|
const organization = await new OrganizationFactory({}).create();
|
|
27
58
|
const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
|