@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
|
@@ -32,10 +32,10 @@ export class OpenIDConnectAuthTokenEndpoint extends Endpoint<Params, Query, Body
|
|
|
32
32
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
33
33
|
// Check webshop and/or organization
|
|
34
34
|
await Context.setUserOrganizationScope();
|
|
35
|
-
await Context.authenticate({ allowWithoutAccount: false });
|
|
35
|
+
const { token: sessionToken } = await Context.authenticate({ allowWithoutAccount: false });
|
|
36
36
|
|
|
37
37
|
// Create a SSO auth token that can only be used once
|
|
38
|
-
const token = await SSOService.createToken();
|
|
38
|
+
const token = await SSOService.createToken(sessionToken);
|
|
39
39
|
|
|
40
40
|
return new Response(OpenIDAuthTokenResponse.create({
|
|
41
41
|
ssoAuthToken: token,
|
|
@@ -0,0 +1,51 @@
|
|
|
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 { RecoveryCodes } from '@stamhoofd/structures';
|
|
5
|
+
|
|
6
|
+
import { Context } from '../../helpers/Context.js';
|
|
7
|
+
import { RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
|
|
8
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
9
|
+
import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
|
|
10
|
+
|
|
11
|
+
type Params = Record<string, never>;
|
|
12
|
+
type Query = undefined;
|
|
13
|
+
type Body = undefined;
|
|
14
|
+
type ResponseBody = RecoveryCodes;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Regenerate the user's recovery codes (replacing the previous batch). Returns the new
|
|
18
|
+
* plaintext codes once. Requires the user to already have a factor enrolled.
|
|
19
|
+
*/
|
|
20
|
+
export class RegenerateRecoveryCodesEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
21
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
22
|
+
if (request.method !== 'POST') {
|
|
23
|
+
return [false];
|
|
24
|
+
}
|
|
25
|
+
const params = Endpoint.parseParameters(request.url, '/mfa/recovery-codes', {});
|
|
26
|
+
if (params) {
|
|
27
|
+
return [true, params as Params];
|
|
28
|
+
}
|
|
29
|
+
return [false];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
33
|
+
await Context.setOptionalOrganizationScope();
|
|
34
|
+
const { user } = await Context.authenticateFresh({ allowWithoutAccount: true, allowUnscoped: true });
|
|
35
|
+
|
|
36
|
+
if (!(await TwoFactorHelper.userHasFactors(user.id))) {
|
|
37
|
+
throw new SimpleError({
|
|
38
|
+
code: 'no_factors',
|
|
39
|
+
message: 'You need at least one two-factor method before generating recovery codes',
|
|
40
|
+
human: $t('%Zgz'),
|
|
41
|
+
statusCode: 400,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
46
|
+
|
|
47
|
+
await TwoFactorAuditLogService.logRecoveryCodesRegenerated(user);
|
|
48
|
+
|
|
49
|
+
return new Response(RecoveryCodes.create({ codes }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
|
|
6
|
+
import type { MFAEnrollmentResult } from '@stamhoofd/structures';
|
|
7
|
+
import { MFAMethodType, WebauthnRegistrationRequest } from '@stamhoofd/structures';
|
|
8
|
+
|
|
9
|
+
import { Context } from '../../helpers/Context.js';
|
|
10
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
11
|
+
import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
|
|
12
|
+
import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
|
|
13
|
+
|
|
14
|
+
type Params = Record<string, never>;
|
|
15
|
+
type Query = undefined;
|
|
16
|
+
type Body = WebauthnRegistrationRequest;
|
|
17
|
+
type ResponseBody = MFAEnrollmentResult;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Verify and store a newly registered passkey. On the user's first factor this also
|
|
21
|
+
* returns recovery codes, and during forced enrollment it issues a full session token.
|
|
22
|
+
*/
|
|
23
|
+
export class RegisterPasskeyEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
24
|
+
bodyDecoder = WebauthnRegistrationRequest as Decoder<WebauthnRegistrationRequest>;
|
|
25
|
+
|
|
26
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
27
|
+
if (request.method !== 'POST') {
|
|
28
|
+
return [false];
|
|
29
|
+
}
|
|
30
|
+
const params = Endpoint.parseParameters(request.url, '/mfa/passkeys', {});
|
|
31
|
+
if (params) {
|
|
32
|
+
return [true, params as Params];
|
|
33
|
+
}
|
|
34
|
+
return [false];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
38
|
+
await Context.setOptionalOrganizationScope();
|
|
39
|
+
const { user, setupToken, token } = await Context.authenticateMFAEnrollment();
|
|
40
|
+
|
|
41
|
+
if (!user.canUsePasskeys()) {
|
|
42
|
+
throw new SimpleError({
|
|
43
|
+
code: 'passkeys_not_available',
|
|
44
|
+
message: 'Passkeys are not available for this account',
|
|
45
|
+
human: $t('%ZhE'),
|
|
46
|
+
statusCode: 400,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const challenge = await WebauthnChallenge.consumeForUser(user.id);
|
|
51
|
+
if (!challenge) {
|
|
52
|
+
throw new SimpleError({
|
|
53
|
+
code: 'invalid_challenge',
|
|
54
|
+
message: 'No valid registration challenge found',
|
|
55
|
+
human: $t('%Zi9'),
|
|
56
|
+
statusCode: 400,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = await WebauthnHelper.verifyRegistration(request.body.response, challenge);
|
|
61
|
+
if (!result) {
|
|
62
|
+
throw new SimpleError({
|
|
63
|
+
code: 'invalid_passkey',
|
|
64
|
+
message: 'Could not verify the passkey',
|
|
65
|
+
human: $t('%Zhr'),
|
|
66
|
+
statusCode: 400,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (await WebauthnCredential.getByCredentialId(result.credentialId)) {
|
|
71
|
+
throw new SimpleError({
|
|
72
|
+
code: 'passkey_already_registered',
|
|
73
|
+
message: 'This passkey is already registered',
|
|
74
|
+
human: $t('%Zhf'),
|
|
75
|
+
statusCode: 400,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const wasFirstFactor = !(await TwoFactorHelper.userHasFactors(user.id));
|
|
80
|
+
|
|
81
|
+
const credential = new WebauthnCredential();
|
|
82
|
+
credential.userId = user.id;
|
|
83
|
+
credential.credentialId = result.credentialId;
|
|
84
|
+
credential.rpId = result.rpId;
|
|
85
|
+
credential.publicKey = result.publicKey;
|
|
86
|
+
credential.counter = result.counter;
|
|
87
|
+
credential.transports = result.transports ? JSON.stringify(result.transports) : null;
|
|
88
|
+
credential.backedUp = result.backedUp;
|
|
89
|
+
credential.backupEligible = result.backupEligible;
|
|
90
|
+
credential.name = request.body.name?.trim() || '';
|
|
91
|
+
credential.providerId = result.providerId;
|
|
92
|
+
credential.providerName = result.providerName;
|
|
93
|
+
await credential.save();
|
|
94
|
+
|
|
95
|
+
await TwoFactorAuditLogService.logMethodAdded(user, MFAMethodType.Passkey, credential.name || credential.providerName || '');
|
|
96
|
+
|
|
97
|
+
return new Response(await TwoFactorHelper.completeEnrollment(user, setupToken, wasFirstFactor, token));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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 { WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
|
|
5
|
+
import { WebauthnRegistrationOptions } from '@stamhoofd/structures';
|
|
6
|
+
|
|
7
|
+
import { Context } from '../../helpers/Context.js';
|
|
8
|
+
import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
|
|
9
|
+
|
|
10
|
+
type Params = Record<string, never>;
|
|
11
|
+
type Query = undefined;
|
|
12
|
+
type Body = undefined;
|
|
13
|
+
type ResponseBody = WebauthnRegistrationOptions;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate WebAuthn registration options (a challenge) for enrolling a new passkey.
|
|
17
|
+
*/
|
|
18
|
+
export class RegisterPasskeyOptionsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
19
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
20
|
+
if (request.method !== 'POST') {
|
|
21
|
+
return [false];
|
|
22
|
+
}
|
|
23
|
+
const params = Endpoint.parseParameters(request.url, '/mfa/passkeys/options', {});
|
|
24
|
+
if (params) {
|
|
25
|
+
return [true, params as Params];
|
|
26
|
+
}
|
|
27
|
+
return [false];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
31
|
+
await Context.setOptionalOrganizationScope();
|
|
32
|
+
const { user } = await Context.authenticateMFAEnrollment();
|
|
33
|
+
|
|
34
|
+
if (!user.canUsePasskeys()) {
|
|
35
|
+
throw new SimpleError({
|
|
36
|
+
code: 'passkeys_not_available',
|
|
37
|
+
message: 'Passkeys are not available for this account',
|
|
38
|
+
human: $t('%ZhE'),
|
|
39
|
+
statusCode: 400,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const existing = await WebauthnCredential.getForUser(user.id);
|
|
44
|
+
const options = await WebauthnHelper.generateRegistration(user, existing);
|
|
45
|
+
await WebauthnChallenge.createFor(user.id, options.challenge);
|
|
46
|
+
|
|
47
|
+
return new Response(WebauthnRegistrationOptions.create({ options }));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { MFATOTP } from '@stamhoofd/models';
|
|
4
|
+
import { TOTPSetupResponse } from '@stamhoofd/structures';
|
|
5
|
+
|
|
6
|
+
import { Context } from '../../helpers/Context.js';
|
|
7
|
+
import { TOTPHelper } from '../../helpers/TOTPHelper.js';
|
|
8
|
+
|
|
9
|
+
type Params = Record<string, never>;
|
|
10
|
+
type Query = undefined;
|
|
11
|
+
type Body = undefined;
|
|
12
|
+
type ResponseBody = TOTPSetupResponse;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Start enrolling a new TOTP authenticator. Creates an unconfirmed row and returns the
|
|
16
|
+
* secret (shown once). Confirm it with ConfirmTOTPEndpoint.
|
|
17
|
+
*/
|
|
18
|
+
export class SetupTOTPEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
19
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
20
|
+
if (request.method !== 'POST') {
|
|
21
|
+
return [false];
|
|
22
|
+
}
|
|
23
|
+
const params = Endpoint.parseParameters(request.url, '/mfa/totp', {});
|
|
24
|
+
if (params) {
|
|
25
|
+
return [true, params as Params];
|
|
26
|
+
}
|
|
27
|
+
return [false];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
31
|
+
await Context.setOptionalOrganizationScope();
|
|
32
|
+
const { user } = await Context.authenticateMFAEnrollment();
|
|
33
|
+
|
|
34
|
+
// Drop any unconfirmed authenticators from earlier, abandoned setup attempts so
|
|
35
|
+
// they don't pile up as orphaned rows holding an (encrypted) unused secret.
|
|
36
|
+
await MFATOTP.deleteUnconfirmedForUser(user.id);
|
|
37
|
+
|
|
38
|
+
const secret = TOTPHelper.generateSecret();
|
|
39
|
+
const totp = new MFATOTP();
|
|
40
|
+
totp.userId = user.id;
|
|
41
|
+
totp.name = '';
|
|
42
|
+
totp.secret = TOTPHelper.encrypt(secret);
|
|
43
|
+
totp.confirmedAt = null;
|
|
44
|
+
await totp.save();
|
|
45
|
+
|
|
46
|
+
const otpauthUri = TOTPHelper.keyuri(user.email, secret);
|
|
47
|
+
|
|
48
|
+
return new Response(TOTPSetupResponse.create({
|
|
49
|
+
totpId: totp.id,
|
|
50
|
+
secret,
|
|
51
|
+
otpauthUri,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -2,7 +2,8 @@ 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
4
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
5
|
-
import { EmailVerificationCode, PasswordToken, Platform,
|
|
5
|
+
import { EmailVerificationCode, PasswordToken, Platform, User } from '@stamhoofd/models';
|
|
6
|
+
import { sendEmailTemplate } from '../../helpers/EmailBuilder.js';
|
|
6
7
|
import { EmailTemplateType, LoginMethod, NewUser, Recipient, Replacement, SignupResponse } from '@stamhoofd/structures';
|
|
7
8
|
|
|
8
9
|
import { Context } from '../../helpers/Context.js';
|
|
@@ -33,7 +34,17 @@ export class SignupEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
|
|
|
33
34
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
34
35
|
const organization = await Context.setUserOrganizationScope({ willAuthenticate: false });
|
|
35
36
|
|
|
36
|
-
if (
|
|
37
|
+
if (!organization) {
|
|
38
|
+
if (STAMHOOFD.userMode === 'organization') {
|
|
39
|
+
// Not supported for now
|
|
40
|
+
throw new SimpleError({
|
|
41
|
+
code: 'not_supported',
|
|
42
|
+
message: 'This platform does not support password login',
|
|
43
|
+
human: $t(`%D7`),
|
|
44
|
+
statusCode: 400,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
const platform = await Platform.getShared();
|
|
38
49
|
if (!platform.config.loginMethods.has(LoginMethod.Password)) {
|
|
39
50
|
throw new SimpleError({
|
|
@@ -6,6 +6,7 @@ import { EmailVerificationCode, Token, User } from '@stamhoofd/models';
|
|
|
6
6
|
import { Token as TokenStruct, VerifyEmailRequest } from '@stamhoofd/structures';
|
|
7
7
|
|
|
8
8
|
import { Context } from '../../helpers/Context.js';
|
|
9
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
9
10
|
import { BalanceItemService } from '../../services/BalanceItemService.js';
|
|
10
11
|
|
|
11
12
|
type Params = Record<string, never>;
|
|
@@ -50,6 +51,11 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// Read the (optional) session of the caller before consuming the code: a user that
|
|
55
|
+
// is already signed in is verifying a new email address from their account
|
|
56
|
+
// settings, and already passed their second factor for that session.
|
|
57
|
+
const authenticatedUser = await Context.optionalAuthenticatedUser();
|
|
58
|
+
|
|
53
59
|
const code = await EmailVerificationCode.verify(organization?.id ?? null, request.body.token, request.body.code);
|
|
54
60
|
|
|
55
61
|
if (!code) {
|
|
@@ -105,7 +111,17 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
105
111
|
throw e;
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
// Verifying an email is a single primary credential: if this user already has a
|
|
115
|
+
// second factor (or must enroll one), don't hand out a session without it. Unless
|
|
116
|
+
// the request already comes from a session of that same user (changing your email
|
|
117
|
+
// address while signed in): the second factor was checked when it was created.
|
|
118
|
+
if (authenticatedUser?.id !== user.id) {
|
|
119
|
+
await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion());
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
const token = await Token.createToken(user);
|
|
123
|
+
await user.markActive();
|
|
124
|
+
|
|
109
125
|
const st = new TokenStruct(token);
|
|
110
126
|
return new Response(st);
|
|
111
127
|
}
|
|
@@ -8,6 +8,7 @@ import { EmailStatus, Email as EmailStruct, EmailTemplate as EmailTemplateStruct
|
|
|
8
8
|
import { Context } from '../../../helpers/Context.js';
|
|
9
9
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
10
10
|
import { EmailPreviewService } from '../../../services/EmailPreviewService.js';
|
|
11
|
+
import { EmailSendService } from '../../../services/EmailSendService.js';
|
|
11
12
|
|
|
12
13
|
type Params = Record<string, never>;
|
|
13
14
|
type Query = undefined;
|
|
@@ -132,7 +133,7 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
await model.save();
|
|
135
|
-
await
|
|
136
|
+
await EmailSendService.buildExampleRecipient(model, true);
|
|
136
137
|
await model.updateCount();
|
|
137
138
|
|
|
138
139
|
if (request.body.status === EmailStatus.Sending || request.body.status === EmailStatus.Sent || request.body.status === EmailStatus.Queued) {
|
|
@@ -142,7 +143,7 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
142
143
|
human: $t('%1Cw'),
|
|
143
144
|
});
|
|
144
145
|
}
|
|
145
|
-
await
|
|
146
|
+
await EmailSendService.queueForSending(model);
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
// Delete open drafts with the same content, from the same user
|
|
@@ -9,6 +9,7 @@ import { patchObject } from '@simonbackx/simple-encoding';
|
|
|
9
9
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
10
10
|
import { Context } from '../../../helpers/Context.js';
|
|
11
11
|
import { EmailPreviewService } from '../../../services/EmailPreviewService.js';
|
|
12
|
+
import { EmailSendService } from '../../../services/EmailSendService.js';
|
|
12
13
|
|
|
13
14
|
type Params = { id: string };
|
|
14
15
|
type Query = undefined;
|
|
@@ -196,7 +197,7 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
196
197
|
await model.save();
|
|
197
198
|
|
|
198
199
|
if (rebuild) {
|
|
199
|
-
await
|
|
200
|
+
await EmailSendService.buildExampleRecipient(model);
|
|
200
201
|
await model.updateCount();
|
|
201
202
|
}
|
|
202
203
|
|
|
@@ -209,7 +210,7 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
// Preview the sending status
|
|
212
|
-
await
|
|
213
|
+
await EmailSendService.queueForSending(model);
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
return new Response(await EmailPreviewService.getPreviewStructure(model, { allLanguages: true }));
|
|
@@ -5,7 +5,8 @@ import { assertSort, getSortFilter, LimitedFilteredRequest, PaginatedResponse, P
|
|
|
5
5
|
|
|
6
6
|
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
7
7
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
8
|
-
import { EmailRecipient
|
|
8
|
+
import { EmailRecipient } from '@stamhoofd/models';
|
|
9
|
+
import { fillRecipientReplacements } from '../../../helpers/EmailBuilder.js';
|
|
9
10
|
import type { SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
|
|
10
11
|
import { applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
|
|
11
12
|
import { Context } from '../../../helpers/Context.js';
|
|
@@ -6,6 +6,7 @@ import { EmailStatus, PermissionLevel } from '@stamhoofd/structures';
|
|
|
6
6
|
|
|
7
7
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
8
8
|
import { Context } from '../../../helpers/Context.js';
|
|
9
|
+
import { EmailSendService } from '../../../services/EmailSendService.js';
|
|
9
10
|
|
|
10
11
|
type Params = { id: string };
|
|
11
12
|
type Query = undefined;
|
|
@@ -76,7 +77,7 @@ export class RetryEmailRecipientEndpoint extends Endpoint<Params, Query, Body, R
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
// Retry
|
|
79
|
-
await
|
|
80
|
+
await EmailSendService.resumeSending(model, emailRecipient.id);
|
|
80
81
|
|
|
81
82
|
await emailRecipient.refresh();
|
|
82
83
|
|
|
@@ -4,7 +4,8 @@ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
|
4
4
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
5
5
|
import type { XlsxTransformerSheet } from '@stamhoofd/excel-writer';
|
|
6
6
|
import { ArchiverWriterAdapter, exportToExcel, XlsxWriter } from '@stamhoofd/excel-writer';
|
|
7
|
-
import { Platform, RateLimiter
|
|
7
|
+
import { Platform, RateLimiter } from '@stamhoofd/models';
|
|
8
|
+
import { sendEmailTemplate } from '../../../helpers/EmailBuilder.js';
|
|
8
9
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
9
10
|
import type { ExcelExportType, IPaginatedResponse, LimitedFilteredRequest, Platform as PlatformStruct } from '@stamhoofd/structures';
|
|
10
11
|
import { EmailTemplateType, ExcelExportRequest, ExcelExportResponse, Replacement, Version } from '@stamhoofd/structures';
|
|
@@ -133,7 +133,7 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
|
133
133
|
throw new SimpleError({
|
|
134
134
|
code: 'invalid_file_type',
|
|
135
135
|
message: 'Unsupported file type ' + (file.mimetype ?? 'unknown') + ' for file ' + (file.originalFilename ?? 'unknown'),
|
|
136
|
-
human: $t('
|
|
136
|
+
human: $t('%ZiD'),
|
|
137
137
|
field: 'file',
|
|
138
138
|
statusCode: 400,
|
|
139
139
|
});
|
|
@@ -140,7 +140,7 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
|
140
140
|
throw new SimpleError({
|
|
141
141
|
code: 'invalid_file_type',
|
|
142
142
|
message: 'Unsupported image type ' + (file.mimetype ?? 'unknown') + ' for file ' + (file.originalFilename ?? 'unknown'),
|
|
143
|
-
human: $t('
|
|
143
|
+
human: $t('%ZhP'),
|
|
144
144
|
field: 'file',
|
|
145
145
|
statusCode: 400,
|
|
146
146
|
});
|
|
@@ -3,7 +3,8 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
|
3
3
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
5
5
|
import type { Organization } from '@stamhoofd/models';
|
|
6
|
-
import { AuditLog, Member, Platform, RateLimiter
|
|
6
|
+
import { AuditLog, Member, Platform, RateLimiter } from '@stamhoofd/models';
|
|
7
|
+
import { sendEmailTemplate } from '../../../helpers/EmailBuilder.js';
|
|
7
8
|
import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType, EmailTemplateType, Recipient, Replacement, SecurityCodeSendMethod, SendMemberSecurityCodeRequest, SendMemberSecurityCodeResponse } from '@stamhoofd/structures';
|
|
8
9
|
import type { Country } from '@stamhoofd/types/Country';
|
|
9
10
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -4,6 +4,7 @@ import { User } from '@stamhoofd/models';
|
|
|
4
4
|
import { OrganizationAdmins } from '@stamhoofd/structures';
|
|
5
5
|
|
|
6
6
|
import { Context } from '../../../helpers/Context.js';
|
|
7
|
+
import { TwoFactorHelper } from '../../../helpers/TwoFactorHelper.js';
|
|
7
8
|
|
|
8
9
|
type Params = Record<string, never>;
|
|
9
10
|
type Query = undefined;
|
|
@@ -34,10 +35,12 @@ export class GetPlatformAdminsEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
34
35
|
|
|
35
36
|
// Get all admins
|
|
36
37
|
const admins = await User.getPlatformAdmins();
|
|
38
|
+
const users = admins.map(user => user.getStructure());
|
|
39
|
+
await TwoFactorHelper.fillTwoFactorStatus(users);
|
|
37
40
|
|
|
38
41
|
return new Response(
|
|
39
42
|
OrganizationAdmins.create({
|
|
40
|
-
users
|
|
43
|
+
users,
|
|
41
44
|
}),
|
|
42
45
|
);
|
|
43
46
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import type { Organization } from '@stamhoofd/models';
|
|
3
3
|
import { OrganizationFactory, Platform, RegistrationPeriod, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
4
|
-
import { PermissionLevel, Permissions, PlatformConfig, Platform as PlatformStruct, Version } from '@stamhoofd/structures';
|
|
4
|
+
import { PermissionLevel, Permissions, PlatformConfig, PlatformPrivateConfig, Platform as PlatformStruct, Version } from '@stamhoofd/structures';
|
|
5
5
|
|
|
6
6
|
import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
|
|
7
7
|
import { TestUtils } from '@stamhoofd/test-utils';
|
|
@@ -22,6 +22,40 @@ describe('Endpoint.PatchPlatform', () => {
|
|
|
22
22
|
return await testServer.test(endpoint, request);
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
const setPlatformRequiresTwoFactor = async (requireTwoFactor: boolean) => {
|
|
26
|
+
const platform = await Platform.getForEditing();
|
|
27
|
+
platform.privateConfig.requireTwoFactor = requireTwoFactor;
|
|
28
|
+
await platform.save();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test('Should save whether two-factor authentication is required for platform admins', async () => {
|
|
32
|
+
const organization = await new OrganizationFactory({ }).create();
|
|
33
|
+
|
|
34
|
+
const admin = await new UserFactory({
|
|
35
|
+
globalPermissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
36
|
+
}).create();
|
|
37
|
+
const token = await Token.createToken(admin);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const response = await patchPlatform({
|
|
41
|
+
patch: PlatformStruct.patch({
|
|
42
|
+
privateConfig: PlatformPrivateConfig.patch({
|
|
43
|
+
requireTwoFactor: true,
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
organization,
|
|
47
|
+
token,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(response.body.privateConfig?.requireTwoFactor).toBe(true);
|
|
51
|
+
expect((await Platform.getForEditing()).privateConfig.requireTwoFactor).toBe(true);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
// The platform row is shared by every test file
|
|
55
|
+
await setPlatformRequiresTwoFactor(false);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
25
59
|
describe('userMode organization', () => {
|
|
26
60
|
beforeEach(async () => {
|
|
27
61
|
TestUtils.setEnvironment('userMode', 'organization');
|
|
@@ -82,6 +82,14 @@ export class PatchPlatformEndpoint extends Endpoint<
|
|
|
82
82
|
request.body.privateConfig.emails,
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
if (request.body.privateConfig.requireTwoFactor !== undefined) {
|
|
87
|
+
if (!Context.auth.hasPlatformFullAccess()) {
|
|
88
|
+
throw Context.auth.error();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
platform.privateConfig.requireTwoFactor = request.body.privateConfig.requireTwoFactor;
|
|
92
|
+
}
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
let shouldUpdateSetupSteps = false;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { Organization } from '@stamhoofd/models';
|
|
3
|
+
import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
4
|
+
import { PermissionLevel, Permissions } from '@stamhoofd/structures';
|
|
5
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
6
|
+
|
|
7
|
+
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
8
|
+
import { SignOutPlatformAdminsEndpoint } from './SignOutPlatformAdminsEndpoint.js';
|
|
9
|
+
|
|
10
|
+
describe('Endpoint.SignOutPlatformAdmins', () => {
|
|
11
|
+
const endpoint = new SignOutPlatformAdminsEndpoint();
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const signOut = async (organization: Organization, token: Token) => {
|
|
18
|
+
const request = Request.buildJson('POST', '/platform/admins/sign-out', organization.getApiHost());
|
|
19
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
20
|
+
return await testServer.test(endpoint, request);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const createPlatformAdmin = async (level = PermissionLevel.Full) => {
|
|
24
|
+
return await new UserFactory({
|
|
25
|
+
globalPermissions: Permissions.create({ level }),
|
|
26
|
+
}).create();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const tokenExists = async (token: Token) => {
|
|
30
|
+
return !!(await Token.getByAccessToken(token.accessToken, true));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
test('all sessions of all platform admins are signed out, except the one making the request', async () => {
|
|
34
|
+
const organization = await new OrganizationFactory({}).create();
|
|
35
|
+
|
|
36
|
+
const admin = await createPlatformAdmin();
|
|
37
|
+
const otherAdmin = await createPlatformAdmin();
|
|
38
|
+
|
|
39
|
+
// An admin of an organization is not a platform admin
|
|
40
|
+
const organizationAdmin = await new UserFactory({
|
|
41
|
+
organization,
|
|
42
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
43
|
+
}).create();
|
|
44
|
+
organizationAdmin.organizationId = null;
|
|
45
|
+
await organizationAdmin.save();
|
|
46
|
+
|
|
47
|
+
const currentToken = await Token.createToken(admin, new Date());
|
|
48
|
+
const secondDeviceOfAdmin = await Token.createToken(admin, new Date());
|
|
49
|
+
const otherAdminToken = await Token.createToken(otherAdmin, new Date());
|
|
50
|
+
const organizationAdminToken = await Token.createToken(organizationAdmin, new Date());
|
|
51
|
+
|
|
52
|
+
const response = await signOut(organization, currentToken);
|
|
53
|
+
|
|
54
|
+
expect(response.body.count).toBe(2);
|
|
55
|
+
expect(await tokenExists(currentToken)).toBe(true);
|
|
56
|
+
expect(await tokenExists(secondDeviceOfAdmin)).toBe(false);
|
|
57
|
+
expect(await tokenExists(otherAdminToken)).toBe(false);
|
|
58
|
+
expect(await tokenExists(organizationAdminToken)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a platform admin without full access cannot sign out the other admins', async () => {
|
|
62
|
+
const organization = await new OrganizationFactory({}).create();
|
|
63
|
+
|
|
64
|
+
const admin = await createPlatformAdmin(PermissionLevel.Write);
|
|
65
|
+
const otherAdmin = await createPlatformAdmin();
|
|
66
|
+
const otherAdminToken = await Token.createToken(otherAdmin, new Date());
|
|
67
|
+
|
|
68
|
+
await expect(signOut(organization, await Token.createToken(admin, new Date()))).rejects.toThrow(STExpect.simpleError({
|
|
69
|
+
code: 'permission_denied',
|
|
70
|
+
}));
|
|
71
|
+
expect(await tokenExists(otherAdminToken)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('an admin of an organization cannot sign out the platform admins', async () => {
|
|
75
|
+
const organization = await new OrganizationFactory({}).create();
|
|
76
|
+
|
|
77
|
+
const organizationAdmin = await new UserFactory({
|
|
78
|
+
organization,
|
|
79
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
80
|
+
}).create();
|
|
81
|
+
organizationAdmin.organizationId = null;
|
|
82
|
+
await organizationAdmin.save();
|
|
83
|
+
|
|
84
|
+
const platformAdmin = await createPlatformAdmin();
|
|
85
|
+
const platformAdminToken = await Token.createToken(platformAdmin, new Date());
|
|
86
|
+
|
|
87
|
+
await expect(signOut(organization, await Token.createToken(organizationAdmin, new Date()))).rejects.toThrow(STExpect.simpleError({
|
|
88
|
+
code: 'permission_denied',
|
|
89
|
+
}));
|
|
90
|
+
expect(await tokenExists(platformAdminToken)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|