@stamhoofd/backend 2.137.5 → 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 +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.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/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,43 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { CountResponse } from '@stamhoofd/structures';
|
|
4
|
+
|
|
5
|
+
import { Context } from '../../../helpers/Context.js';
|
|
6
|
+
import { AdminSessionService } from '../../../services/AdminSessionService.js';
|
|
7
|
+
|
|
8
|
+
type Params = Record<string, never>;
|
|
9
|
+
type Query = undefined;
|
|
10
|
+
type Body = undefined;
|
|
11
|
+
type ResponseBody = CountResponse;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* End the sessions of all administrators of the platform, so they have to sign in again.
|
|
15
|
+
* Used after making two-factor authentication required, which is only enforced on new
|
|
16
|
+
* logins.
|
|
17
|
+
*/
|
|
18
|
+
export class SignOutPlatformAdminsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
19
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
20
|
+
if (request.method !== 'POST') {
|
|
21
|
+
return [false];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const params = Endpoint.parseParameters(request.url, '/platform/admins/sign-out', {});
|
|
25
|
+
|
|
26
|
+
if (params) {
|
|
27
|
+
return [true, params as Params];
|
|
28
|
+
}
|
|
29
|
+
return [false];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
33
|
+
const { token } = await Context.authenticate();
|
|
34
|
+
|
|
35
|
+
if (!Context.auth.canManagePlatformAdmins()) {
|
|
36
|
+
throw Context.auth.error();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const count = await AdminSessionService.signOutPlatformAdmins(token.accessToken);
|
|
40
|
+
|
|
41
|
+
return new Response(CountResponse.create({ count }));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -166,6 +166,7 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
166
166
|
organization.privateMeta.balanceNotificationSettings = patchObject(organization.privateMeta.balanceNotificationSettings, request.body.privateMeta.balanceNotificationSettings);
|
|
167
167
|
organization.privateMeta.invoiceSettings = patchObject(organization.privateMeta.invoiceSettings, request.body.privateMeta.invoiceSettings);
|
|
168
168
|
organization.privateMeta.recordAnswers = request.body.privateMeta.recordAnswers.applyTo(organization.privateMeta.recordAnswers);
|
|
169
|
+
organization.privateMeta.requireTwoFactor = patchObject(organization.privateMeta.requireTwoFactor, request.body.privateMeta.requireTwoFactor);
|
|
169
170
|
|
|
170
171
|
if (request.body.privateMeta.responsibilities || request.body.privateMeta.roles) {
|
|
171
172
|
shouldUpdateUserPermissions = true;
|
package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
|
|
|
13
13
|
import { PaymentMandateService } from '../../../../services/PaymentMandateService.js';
|
|
14
14
|
import { BalanceItemService } from '../../../../services/BalanceItemService.js';
|
|
15
15
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
16
|
+
import { OrganizationAdminService } from '../../../../services/OrganizationAdminService.js';
|
|
16
17
|
|
|
17
18
|
type Params = Record<string, never>;
|
|
18
19
|
type Query = undefined;
|
|
@@ -121,11 +122,11 @@ export class ChargeReceivableBalancesEndpoint extends Endpoint<Params, Query, Bo
|
|
|
121
122
|
continue;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
customerUser = customerUser ?? (payingOrganization ? (await
|
|
125
|
+
customerUser = customerUser ?? (payingOrganization ? (await OrganizationAdminService.getFullAdmins(payingOrganization))[0] : null);
|
|
125
126
|
const customer = PaymentCustomer.create({
|
|
126
127
|
firstName: customerUser?.firstName,
|
|
127
128
|
lastName: customerUser?.lastName,
|
|
128
|
-
email: customerUser?.email ?? (payingOrganization ? (await
|
|
129
|
+
email: customerUser?.email ?? (payingOrganization ? (await OrganizationAdminService.getReplyEmails(payingOrganization))[0].email : null),
|
|
129
130
|
company: payingOrganization?.defaultCompanies[0],
|
|
130
131
|
});
|
|
131
132
|
|
|
@@ -15,6 +15,35 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
|
|
|
15
15
|
TestUtils.setEnvironment('userMode', 'platform');
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
test('A session that did not recently authenticate cannot create a key', async () => {
|
|
19
|
+
// An API key is not covered by two-factor authentication and does not expire, so a
|
|
20
|
+
// stolen session must not be enough to mint one: the user has to prove who they
|
|
21
|
+
// are again first.
|
|
22
|
+
const organization = await new OrganizationFactory({}).create();
|
|
23
|
+
const user = await new UserFactory({
|
|
24
|
+
permissions: Permissions.create({
|
|
25
|
+
level: PermissionLevel.Full,
|
|
26
|
+
}),
|
|
27
|
+
organization,
|
|
28
|
+
}).create();
|
|
29
|
+
|
|
30
|
+
// A token as produced by a refresh_token rotation (never authenticatedAt).
|
|
31
|
+
const token = await Token.createToken(user);
|
|
32
|
+
|
|
33
|
+
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
34
|
+
permissions: UserPermissions.create({
|
|
35
|
+
organizationPermissions: new Map([
|
|
36
|
+
[organization.id, Permissions.create({ level: PermissionLevel.Read })],
|
|
37
|
+
]),
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
createRequest.headers.authorization = 'Bearer ' + token.accessToken;
|
|
41
|
+
|
|
42
|
+
await expect(testServer.test(endpoint, createRequest)).rejects.toThrow(STExpect.simpleError({
|
|
43
|
+
code: 'require_fresh_auth',
|
|
44
|
+
}));
|
|
45
|
+
});
|
|
46
|
+
|
|
18
47
|
test('Only a platform admin can set the rate limits of a key', async () => {
|
|
19
48
|
const organization = await new OrganizationFactory({}).create();
|
|
20
49
|
const user = await new UserFactory({
|
|
@@ -22,7 +51,7 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
|
|
|
22
51
|
level: PermissionLevel.Full,
|
|
23
52
|
}),
|
|
24
53
|
}).create();
|
|
25
|
-
const token = await Token.createToken(user);
|
|
54
|
+
const token = await Token.createToken(user, new Date());
|
|
26
55
|
|
|
27
56
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
28
57
|
permissions: UserPermissions.create({
|
|
@@ -49,7 +78,7 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
|
|
|
49
78
|
}),
|
|
50
79
|
organization,
|
|
51
80
|
}).create();
|
|
52
|
-
const token = await Token.createToken(user);
|
|
81
|
+
const token = await Token.createToken(user, new Date());
|
|
53
82
|
|
|
54
83
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
55
84
|
permissions: UserPermissions.create({
|
|
@@ -76,7 +105,7 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
|
|
|
76
105
|
}),
|
|
77
106
|
organization,
|
|
78
107
|
}).create();
|
|
79
|
-
const token = await Token.createToken(user);
|
|
108
|
+
const token = await Token.createToken(user, new Date());
|
|
80
109
|
|
|
81
110
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
82
111
|
permissions: UserPermissions.create({
|
|
@@ -29,7 +29,10 @@ export class CreateApiUserEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
29
29
|
|
|
30
30
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
31
31
|
const organization = await Context.setOrganizationScope();
|
|
32
|
-
|
|
32
|
+
// An API key is a long-lived credential that is not covered by two-factor
|
|
33
|
+
// authentication, so creating one requires a recent authentication: a stolen
|
|
34
|
+
// session should not be enough to mint a permanent way back in.
|
|
35
|
+
await Context.authenticateFresh();
|
|
33
36
|
|
|
34
37
|
// Fast throw first (more in depth checking for patches later)
|
|
35
38
|
if (!await Context.auth.canManageAdmins(organization.id)) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { Organization, User } from '@stamhoofd/models';
|
|
3
|
+
import { MFATOTP, OrganizationFactory, Token, UserFactory, WebauthnCredential } from '@stamhoofd/models';
|
|
4
|
+
import { OrganizationAdmins, PermissionLevel, Permissions } from '@stamhoofd/structures';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
|
|
7
|
+
import { MFATestHelper } from '../../../../../tests/helpers/MFATestHelper.js';
|
|
8
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
9
|
+
import { GetOrganizationAdminsEndpoint } from './GetOrganizationAdminsEndpoint.js';
|
|
10
|
+
|
|
11
|
+
describe('Endpoint.GetOrganizationAdmins', () => {
|
|
12
|
+
const endpoint = new GetOrganizationAdminsEndpoint();
|
|
13
|
+
|
|
14
|
+
async function createAdmin(organization: Organization): Promise<User> {
|
|
15
|
+
return await new UserFactory({
|
|
16
|
+
organization,
|
|
17
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
18
|
+
}).create();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function getAdmins(organization: Organization, user: User) {
|
|
22
|
+
const token = await Token.createToken(user, new Date());
|
|
23
|
+
const request = Request.buildJson('GET', '/organization/admins', organization.getApiHost());
|
|
24
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
25
|
+
|
|
26
|
+
const response = await testServer.test(endpoint, request);
|
|
27
|
+
expect(response.body).toBeInstanceOf(OrganizationAdmins);
|
|
28
|
+
return (response.body as OrganizationAdmins).users;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('The last activity of every admin is returned', async () => {
|
|
32
|
+
const organization = await new OrganizationFactory({}).create();
|
|
33
|
+
const user = await new UserFactory({
|
|
34
|
+
organization,
|
|
35
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
36
|
+
}).create();
|
|
37
|
+
|
|
38
|
+
const lastActiveAt = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
39
|
+
lastActiveAt.setMilliseconds(0);
|
|
40
|
+
|
|
41
|
+
const inactiveAdmin = await new UserFactory({
|
|
42
|
+
organization,
|
|
43
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
44
|
+
}).create();
|
|
45
|
+
inactiveAdmin.lastActiveAt = lastActiveAt;
|
|
46
|
+
await inactiveAdmin.save();
|
|
47
|
+
|
|
48
|
+
const token = await Token.createToken(user, new Date());
|
|
49
|
+
const request = Request.buildJson('GET', '/organization/admins', organization.getApiHost());
|
|
50
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
51
|
+
|
|
52
|
+
const response = await testServer.test(endpoint, request);
|
|
53
|
+
expect(response.body).toBeInstanceOf(OrganizationAdmins);
|
|
54
|
+
|
|
55
|
+
const users = (response.body as OrganizationAdmins).users;
|
|
56
|
+
expect(users.find(u => u.id === inactiveAdmin.id)?.lastActiveAt?.getTime()).toEqual(lastActiveAt.getTime());
|
|
57
|
+
|
|
58
|
+
// The user never signed in: the token was created directly.
|
|
59
|
+
expect(users.find(u => u.id === user.id)?.lastActiveAt).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('Whether an admin has two-factor authentication is returned', async () => {
|
|
63
|
+
const organization = await new OrganizationFactory({}).create();
|
|
64
|
+
const me = await createAdmin(organization);
|
|
65
|
+
|
|
66
|
+
const withTotp = await createAdmin(organization);
|
|
67
|
+
await MFATestHelper.addConfirmedTOTP(withTotp);
|
|
68
|
+
|
|
69
|
+
const withPasskey = await createAdmin(organization);
|
|
70
|
+
const credential = new WebauthnCredential();
|
|
71
|
+
credential.userId = withPasskey.id;
|
|
72
|
+
credential.credentialId = 'cred-' + crypto.randomBytes(16).toString('base64url');
|
|
73
|
+
credential.publicKey = crypto.randomBytes(32).toString('base64url');
|
|
74
|
+
credential.name = 'Test passkey';
|
|
75
|
+
await credential.save();
|
|
76
|
+
|
|
77
|
+
const withoutFactors = await createAdmin(organization);
|
|
78
|
+
|
|
79
|
+
const users = await getAdmins(organization, me);
|
|
80
|
+
|
|
81
|
+
expect(users.find(u => u.id === withTotp.id)?.hasTwoFactor).toBe(true);
|
|
82
|
+
expect(users.find(u => u.id === withPasskey.id)?.hasTwoFactor).toBe(true);
|
|
83
|
+
expect(users.find(u => u.id === withoutFactors.id)?.hasTwoFactor).toBe(false);
|
|
84
|
+
expect(users.find(u => u.id === me.id)?.hasTwoFactor).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('An authenticator that was never confirmed does not count as two-factor authentication', async () => {
|
|
88
|
+
const organization = await new OrganizationFactory({}).create();
|
|
89
|
+
const me = await createAdmin(organization);
|
|
90
|
+
|
|
91
|
+
const totp = new MFATOTP();
|
|
92
|
+
totp.userId = me.id;
|
|
93
|
+
totp.name = 'Pending authenticator';
|
|
94
|
+
totp.secret = 'encrypted-secret';
|
|
95
|
+
await totp.save();
|
|
96
|
+
|
|
97
|
+
const users = await getAdmins(organization, me);
|
|
98
|
+
expect(users.find(u => u.id === me.id)?.hasTwoFactor).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -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
|
type Params = Record<string, never>;
|
|
8
9
|
type Query = undefined;
|
|
9
10
|
type Body = undefined;
|
|
@@ -34,10 +35,12 @@ export class GetOrganizationAdminsEndpoint extends Endpoint<Params, Query, Body,
|
|
|
34
35
|
|
|
35
36
|
// Get all admins
|
|
36
37
|
const admins = await User.getAdmins(organization.id);
|
|
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
|
}
|
|
@@ -24,7 +24,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
24
24
|
level: PermissionLevel.Full,
|
|
25
25
|
}),
|
|
26
26
|
}).create();
|
|
27
|
-
const token = await Token.createToken(user);
|
|
27
|
+
const token = await Token.createToken(user, new Date());
|
|
28
28
|
|
|
29
29
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
30
30
|
permissions: UserPermissions.create({
|
|
@@ -60,7 +60,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
60
60
|
}),
|
|
61
61
|
organization,
|
|
62
62
|
}).create();
|
|
63
|
-
const token = await Token.createToken(user);
|
|
63
|
+
const token = await Token.createToken(user, new Date());
|
|
64
64
|
|
|
65
65
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
66
66
|
permissions: UserPermissions.create({
|
|
@@ -95,7 +95,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
95
95
|
}),
|
|
96
96
|
organization,
|
|
97
97
|
}).create();
|
|
98
|
-
const token = await Token.createToken(user);
|
|
98
|
+
const token = await Token.createToken(user, new Date());
|
|
99
99
|
|
|
100
100
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
101
101
|
permissions: UserPermissions.create({
|
|
@@ -143,7 +143,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
143
143
|
}),
|
|
144
144
|
organization,
|
|
145
145
|
}).create();
|
|
146
|
-
const token = await Token.createToken(user);
|
|
146
|
+
const token = await Token.createToken(user, new Date());
|
|
147
147
|
|
|
148
148
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
149
149
|
permissions: UserPermissions.create({
|
|
@@ -190,7 +190,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
190
190
|
}),
|
|
191
191
|
organization,
|
|
192
192
|
}).create();
|
|
193
|
-
const token = await Token.createToken(user);
|
|
193
|
+
const token = await Token.createToken(user, new Date());
|
|
194
194
|
|
|
195
195
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
196
196
|
permissions: UserPermissions.create({
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { Organization, User } from '@stamhoofd/models';
|
|
3
|
+
import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
4
|
+
import { PermissionLevel, Permissions, UserPermissions } from '@stamhoofd/structures';
|
|
5
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
6
|
+
|
|
7
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
8
|
+
import { AdminSessionService } from '../../../../services/AdminSessionService.js';
|
|
9
|
+
import { SignOutOrganizationAdminsEndpoint } from './SignOutOrganizationAdminsEndpoint.js';
|
|
10
|
+
|
|
11
|
+
describe('Endpoint.SignOutOrganizationAdmins', () => {
|
|
12
|
+
const endpoint = new SignOutOrganizationAdminsEndpoint();
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const signOut = async (organization: Organization, token: Token) => {
|
|
19
|
+
const request = Request.buildJson('POST', '/organization/admins/sign-out', organization.getApiHost());
|
|
20
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
21
|
+
return await testServer.test(endpoint, request);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* In platform mode users are not owned by an organization: they have permissions for it.
|
|
26
|
+
*/
|
|
27
|
+
const createAdmin = async (organization: Organization, level = PermissionLevel.Full) => {
|
|
28
|
+
const user = await new UserFactory({
|
|
29
|
+
organization,
|
|
30
|
+
permissions: Permissions.create({ level }),
|
|
31
|
+
}).create();
|
|
32
|
+
user.organizationId = null;
|
|
33
|
+
await user.save();
|
|
34
|
+
return user;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const tokenExists = async (token: Token) => {
|
|
38
|
+
return !!(await Token.getByAccessToken(token.accessToken, true));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
test('all sessions of all admins are signed out, except the one making the request', async () => {
|
|
42
|
+
const organization = await new OrganizationFactory({}).create();
|
|
43
|
+
const otherOrganization = await new OrganizationFactory({}).create();
|
|
44
|
+
|
|
45
|
+
const admin = await createAdmin(organization);
|
|
46
|
+
const otherAdmin = await createAdmin(organization);
|
|
47
|
+
const otherOrganizationAdmin = await createAdmin(otherOrganization);
|
|
48
|
+
const member = await new UserFactory({ organization }).create();
|
|
49
|
+
|
|
50
|
+
const currentToken = await Token.createToken(admin, new Date());
|
|
51
|
+
const secondDeviceOfAdmin = await Token.createToken(admin, new Date());
|
|
52
|
+
const otherAdminToken = await Token.createToken(otherAdmin, new Date());
|
|
53
|
+
const otherOrganizationAdminToken = await Token.createToken(otherOrganizationAdmin, new Date());
|
|
54
|
+
const memberToken = await Token.createToken(member, new Date());
|
|
55
|
+
|
|
56
|
+
const response = await signOut(organization, currentToken);
|
|
57
|
+
|
|
58
|
+
// The other device of the admin is signed out too: only this session is spared
|
|
59
|
+
expect(response.body.count).toBe(2);
|
|
60
|
+
expect(await tokenExists(currentToken)).toBe(true);
|
|
61
|
+
expect(await tokenExists(secondDeviceOfAdmin)).toBe(false);
|
|
62
|
+
expect(await tokenExists(otherAdminToken)).toBe(false);
|
|
63
|
+
|
|
64
|
+
// Users without permissions in this organization are not affected
|
|
65
|
+
expect(await tokenExists(otherOrganizationAdminToken)).toBe(true);
|
|
66
|
+
expect(await tokenExists(memberToken)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('an admin without full access cannot sign out the other admins', async () => {
|
|
70
|
+
const organization = await new OrganizationFactory({}).create();
|
|
71
|
+
|
|
72
|
+
const admin = await createAdmin(organization, PermissionLevel.Write);
|
|
73
|
+
const otherAdmin = await createAdmin(organization);
|
|
74
|
+
const otherAdminToken = await Token.createToken(otherAdmin, new Date());
|
|
75
|
+
|
|
76
|
+
await expect(signOut(organization, await Token.createToken(admin, new Date()))).rejects.toThrow(STExpect.simpleError({
|
|
77
|
+
code: 'permission_denied',
|
|
78
|
+
}));
|
|
79
|
+
expect(await tokenExists(otherAdminToken)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an api user cannot sign out the admins', async () => {
|
|
83
|
+
const organization = await new OrganizationFactory({}).create();
|
|
84
|
+
|
|
85
|
+
const apiUser = await new UserFactory({ organization, apiUser: true }).create();
|
|
86
|
+
apiUser.permissions = UserPermissions.create({
|
|
87
|
+
organizationPermissions: new Map([[organization.id, Permissions.create({ level: PermissionLevel.Full })]]),
|
|
88
|
+
});
|
|
89
|
+
await apiUser.save();
|
|
90
|
+
|
|
91
|
+
const otherAdmin = await createAdmin(organization);
|
|
92
|
+
const otherAdminToken = await Token.createToken(otherAdmin, new Date());
|
|
93
|
+
|
|
94
|
+
await expect(signOut(organization, await Token.createApiToken(apiUser))).rejects.toThrow(STExpect.simpleError({
|
|
95
|
+
code: 'permission_denied',
|
|
96
|
+
}));
|
|
97
|
+
expect(await tokenExists(otherAdminToken)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('an organization without admins is a no-op', async () => {
|
|
101
|
+
const organization = await new OrganizationFactory({}).create();
|
|
102
|
+
const member = await new UserFactory({ organization }).create();
|
|
103
|
+
const memberToken = await Token.createToken(member, new Date());
|
|
104
|
+
|
|
105
|
+
// Not reachable through the endpoint (the caller is an admin themselves), but the
|
|
106
|
+
// empty list of users may not turn into a query that deletes everything.
|
|
107
|
+
expect(await AdminSessionService.signOutOrganizationAdmins(organization.id, null)).toBe(0);
|
|
108
|
+
expect(await tokenExists(memberToken)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('userMode organization', () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
TestUtils.setEnvironment('userMode', 'organization');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('all sessions of all admins are signed out, except the one making the request', async () => {
|
|
117
|
+
const organization = await new OrganizationFactory({}).create();
|
|
118
|
+
|
|
119
|
+
const admin: User = await new UserFactory({
|
|
120
|
+
organization,
|
|
121
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
122
|
+
}).create();
|
|
123
|
+
const otherAdmin: User = await new UserFactory({
|
|
124
|
+
organization,
|
|
125
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
126
|
+
}).create();
|
|
127
|
+
const member = await new UserFactory({ organization }).create();
|
|
128
|
+
|
|
129
|
+
const otherAdminToken = await Token.createToken(otherAdmin, new Date());
|
|
130
|
+
const memberToken = await Token.createToken(member, new Date());
|
|
131
|
+
|
|
132
|
+
const response = await signOut(organization, await Token.createToken(admin, new Date()));
|
|
133
|
+
|
|
134
|
+
expect(response.body.count).toBe(1);
|
|
135
|
+
expect(await tokenExists(otherAdminToken)).toBe(false);
|
|
136
|
+
expect(await tokenExists(memberToken)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('api users keep their token, because they cannot complete a login', async () => {
|
|
140
|
+
const organization = await new OrganizationFactory({}).create();
|
|
141
|
+
|
|
142
|
+
const admin: User = await new UserFactory({
|
|
143
|
+
organization,
|
|
144
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
145
|
+
}).create();
|
|
146
|
+
|
|
147
|
+
const apiUser = await new UserFactory({ organization, apiUser: true }).create();
|
|
148
|
+
apiUser.permissions = UserPermissions.create({
|
|
149
|
+
organizationPermissions: new Map([[organization.id, Permissions.create({ level: PermissionLevel.Full })]]),
|
|
150
|
+
});
|
|
151
|
+
await apiUser.save();
|
|
152
|
+
const apiToken = await Token.createApiToken(apiUser);
|
|
153
|
+
|
|
154
|
+
const response = await signOut(organization, await Token.createToken(admin, new Date()));
|
|
155
|
+
|
|
156
|
+
expect(response.body.count).toBe(0);
|
|
157
|
+
expect(await tokenExists(apiToken)).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { CountResponse } from '@stamhoofd/structures';
|
|
4
|
+
|
|
5
|
+
import { Context } from '../../../../helpers/Context.js';
|
|
6
|
+
import { AdminSessionService } from '../../../../services/AdminSessionService.js';
|
|
7
|
+
|
|
8
|
+
type Params = Record<string, never>;
|
|
9
|
+
type Query = undefined;
|
|
10
|
+
type Body = undefined;
|
|
11
|
+
type ResponseBody = CountResponse;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* End the sessions of all administrators of this organization, so they have to sign in
|
|
15
|
+
* again. Used after making two-factor authentication required, which is only enforced on
|
|
16
|
+
* new logins.
|
|
17
|
+
*/
|
|
18
|
+
export class SignOutOrganizationAdminsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
19
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
20
|
+
if (request.method !== 'POST') {
|
|
21
|
+
return [false];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const params = Endpoint.parseParameters(request.url, '/organization/admins/sign-out', {});
|
|
25
|
+
|
|
26
|
+
if (params) {
|
|
27
|
+
return [true, params as Params];
|
|
28
|
+
}
|
|
29
|
+
return [false];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
33
|
+
const organization = await Context.setOrganizationScope();
|
|
34
|
+
const { token } = await Context.authenticate();
|
|
35
|
+
|
|
36
|
+
if (!await Context.auth.canManageAdmins(organization.id)) {
|
|
37
|
+
throw Context.auth.error();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const count = await AdminSessionService.signOutOrganizationAdmins(organization.id, token.accessToken);
|
|
41
|
+
|
|
42
|
+
return new Response(CountResponse.create({ count }));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -9,6 +9,7 @@ import { Sorter } from '@stamhoofd/utility';
|
|
|
9
9
|
import { SQL } from '@stamhoofd/sql';
|
|
10
10
|
import { Formatter } from '@stamhoofd/utility';
|
|
11
11
|
import { Context } from './Context.js';
|
|
12
|
+
import { TwoFactorHelper } from './TwoFactorHelper.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Builds authenticated structures for the current user
|
|
@@ -425,7 +426,7 @@ export class AuthenticatedStructures {
|
|
|
425
426
|
}
|
|
426
427
|
}
|
|
427
428
|
|
|
428
|
-
|
|
429
|
+
const struct = UserWithMembers.create({
|
|
429
430
|
...user,
|
|
430
431
|
hasAccount: user.hasAccount(),
|
|
431
432
|
hasPassword: user.hasPasswordBasedAccount(),
|
|
@@ -433,6 +434,8 @@ export class AuthenticatedStructures {
|
|
|
433
434
|
// Always include the current context organization - because it is possible we switch organization and we don't want to refetch every time
|
|
434
435
|
members: await this.membersBlob(filtered, true, user),
|
|
435
436
|
});
|
|
437
|
+
await TwoFactorHelper.fillTwoFactorStatus([struct]);
|
|
438
|
+
return struct;
|
|
436
439
|
}
|
|
437
440
|
|
|
438
441
|
/**
|
|
@@ -453,6 +456,8 @@ export class AuthenticatedStructures {
|
|
|
453
456
|
}));
|
|
454
457
|
}
|
|
455
458
|
|
|
459
|
+
await TwoFactorHelper.fillTwoFactorStatus(structs);
|
|
460
|
+
|
|
456
461
|
return structs;
|
|
457
462
|
}
|
|
458
463
|
|
|
@@ -697,6 +702,9 @@ export class AuthenticatedStructures {
|
|
|
697
702
|
);
|
|
698
703
|
}
|
|
699
704
|
|
|
705
|
+
// After filterMemberData, so we only query for the users we actually return.
|
|
706
|
+
await TwoFactorHelper.fillTwoFactorStatus(memberBlobs.flatMap(b => b.users));
|
|
707
|
+
|
|
700
708
|
for (const blob of memberBlobs) {
|
|
701
709
|
blob.responsibilities = responsibilities.filter(r => r.memberId == blob.id).map((r) => {
|
|
702
710
|
const group = allGroups.get(r.groupId ?? '') ?? null;
|