@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
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
3
|
+
import { EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, OrganizationFactory, PasswordToken, Token, User, UserFactory, WebauthnCredential } from '@stamhoofd/models';
|
|
4
|
+
import { PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
|
|
5
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import { authenticator } from 'otplib';
|
|
8
|
+
|
|
9
|
+
import { MFATestHelper } from '../../../tests/helpers/MFATestHelper.js';
|
|
10
|
+
import { testServer } from '../../../tests/helpers/TestServer.js';
|
|
11
|
+
import { RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
|
|
12
|
+
import { TOTPHelper } from '../../helpers/TOTPHelper.js';
|
|
13
|
+
import { ConfirmTOTPEndpoint } from './ConfirmTOTPEndpoint.js';
|
|
14
|
+
import { CreateTokenEndpoint } from './CreateTokenEndpoint.js';
|
|
15
|
+
import { DeletePasskeyEndpoint } from './DeletePasskeyEndpoint.js';
|
|
16
|
+
import { DeleteTOTPEndpoint } from './DeleteTOTPEndpoint.js';
|
|
17
|
+
import { GetMFAChallengeEndpoint } from './GetMFAChallengeEndpoint.js';
|
|
18
|
+
import { GetMFAStatusEndpoint } from './GetMFAStatusEndpoint.js';
|
|
19
|
+
import { RegisterPasskeyOptionsEndpoint } from './RegisterPasskeyOptionsEndpoint.js';
|
|
20
|
+
import { SetupTOTPEndpoint } from './SetupTOTPEndpoint.js';
|
|
21
|
+
import { VerifyEmailEndpoint } from './VerifyEmailEndpoint.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Adversarial counterpart of MFA.test.ts.
|
|
25
|
+
*
|
|
26
|
+
* Where MFA.test.ts describes what the feature does, every test here plays an attacker
|
|
27
|
+
* that already got *one* thing (the password, a reset link, a stolen refresh token, a
|
|
28
|
+
* setup token, their own account) and tries to turn it into a session that should have
|
|
29
|
+
* required a second factor, or into the removal of someone's second factor.
|
|
30
|
+
*
|
|
31
|
+
* Each test therefore asserts a rejection. If one of these ever starts passing a request
|
|
32
|
+
* through, that is a real authentication bypass, not a change in behaviour.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const tokenEndpoint = new CreateTokenEndpoint();
|
|
36
|
+
const password = 'test-password-1234';
|
|
37
|
+
|
|
38
|
+
function firstError(e: unknown): SimpleError {
|
|
39
|
+
if (isSimpleErrors(e)) {
|
|
40
|
+
return e.errors[0];
|
|
41
|
+
}
|
|
42
|
+
if (isSimpleError(e)) {
|
|
43
|
+
return e;
|
|
44
|
+
}
|
|
45
|
+
throw new Error('Not a SimpleError: ' + String(e));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function captureError(promise: Promise<unknown>): Promise<SimpleError> {
|
|
49
|
+
try {
|
|
50
|
+
await promise;
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return firstError(e);
|
|
54
|
+
}
|
|
55
|
+
throw new Error('Expected the request to be rejected, but it succeeded');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function passwordLogin(organization: Organization | null, email: string, pw: string) {
|
|
59
|
+
return Request.buildJson('POST', '/oauth/token', organization?.getApiHost(), {
|
|
60
|
+
grant_type: 'password',
|
|
61
|
+
username: email,
|
|
62
|
+
password: pw,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mfaGrant(organization: Organization | null, body: Record<string, unknown>) {
|
|
67
|
+
return Request.buildJson('POST', '/oauth/token', organization?.getApiHost(), { grant_type: 'mfa', ...body });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function bearer(request: Request, token: Token | string) {
|
|
71
|
+
request.headers.authorization = 'Bearer ' + (typeof token === 'string' ? token : token.accessToken);
|
|
72
|
+
return request;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function withSetup(request: Request, setupToken: string) {
|
|
76
|
+
request.headers.authorization = 'MFASetup ' + setupToken;
|
|
77
|
+
return request;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function freshToken(user: User): Promise<Token> {
|
|
81
|
+
return await Token.createToken(user, new Date());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function addConfirmedTOTP(user: User): Promise<{ id: string; secret: string }> {
|
|
85
|
+
return await MFATestHelper.addConfirmedTOTP(user, 'Confirmed authenticator');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function addPasskey(user: User): Promise<WebauthnCredential> {
|
|
89
|
+
const credential = new WebauthnCredential();
|
|
90
|
+
credential.userId = user.id;
|
|
91
|
+
credential.credentialId = 'cred-' + crypto.randomBytes(16).toString('base64url');
|
|
92
|
+
credential.publicKey = crypto.randomBytes(32).toString('base64url');
|
|
93
|
+
credential.counter = 0;
|
|
94
|
+
credential.name = 'Test passkey';
|
|
95
|
+
await credential.save();
|
|
96
|
+
return credential;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function requireMfa(organization: Organization | null, email: string): Promise<{ token: string; methods: string[] }> {
|
|
100
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, email, password)));
|
|
101
|
+
expect(err.code).toBe('require_mfa');
|
|
102
|
+
return err.meta as { token: string; methods: string[] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function organizationRequiringTwoFactor(): Promise<Organization> {
|
|
106
|
+
const organization = await new OrganizationFactory({}).create();
|
|
107
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
108
|
+
await organization.save();
|
|
109
|
+
return organization;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The code an authenticator app would have shown at a different point in time.
|
|
114
|
+
* Uses a cloned otplib instance so the shared singleton keeps its own options.
|
|
115
|
+
*/
|
|
116
|
+
function totpCodeAt(secret: string, epoch: number): string {
|
|
117
|
+
return authenticator.clone({ epoch }).generate(secret);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe('MFA security', () => {
|
|
121
|
+
// -----------------------------------------------------------------------
|
|
122
|
+
// The attacker knows the password and tries to skip the second factor
|
|
123
|
+
// -----------------------------------------------------------------------
|
|
124
|
+
describe('attacker knows the password', () => {
|
|
125
|
+
test('a second factor of their own account does not unlock someone else\'s login', async () => {
|
|
126
|
+
const organization = await new OrganizationFactory({}).create();
|
|
127
|
+
const victim = await new UserFactory({ organization, password }).create();
|
|
128
|
+
await addConfirmedTOTP(victim);
|
|
129
|
+
|
|
130
|
+
const attacker = await new UserFactory({ organization, password }).create();
|
|
131
|
+
const attackerTotp = await addConfirmedTOTP(attacker);
|
|
132
|
+
|
|
133
|
+
const challenge = await requireMfa(organization, victim.email);
|
|
134
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, {
|
|
135
|
+
mfa_token: challenge.token,
|
|
136
|
+
method: 'TOTP',
|
|
137
|
+
code: authenticator.generate(attackerTotp.secret),
|
|
138
|
+
})));
|
|
139
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a recovery code of their own account does not unlock someone else\'s login', async () => {
|
|
143
|
+
const organization = await new OrganizationFactory({}).create();
|
|
144
|
+
const victim = await new UserFactory({ organization, password }).create();
|
|
145
|
+
await addConfirmedTOTP(victim);
|
|
146
|
+
|
|
147
|
+
const attacker = await new UserFactory({ organization, password }).create();
|
|
148
|
+
await addConfirmedTOTP(attacker);
|
|
149
|
+
const attackerCodes = await RecoveryCodeHelper.regenerateForUser(attacker.id);
|
|
150
|
+
|
|
151
|
+
const challenge = await requireMfa(organization, victim.email);
|
|
152
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, {
|
|
153
|
+
mfa_token: challenge.token,
|
|
154
|
+
method: 'RecoveryCode',
|
|
155
|
+
code: attackerCodes[0],
|
|
156
|
+
})));
|
|
157
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
158
|
+
|
|
159
|
+
// The attacker's own code must not have been consumed by the attempt either.
|
|
160
|
+
expect((await MFARecoveryCode.getUnusedForUser(attacker.id)).length).toBe(attackerCodes.length);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('an unconfirmed authenticator is not a usable second factor', async () => {
|
|
164
|
+
const organization = await new OrganizationFactory({}).create();
|
|
165
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
166
|
+
await addConfirmedTOTP(user);
|
|
167
|
+
|
|
168
|
+
// A never-confirmed enrolment attempt (e.g. abandoned, or started by an
|
|
169
|
+
// attacker with a stolen session) must not be accepted at login.
|
|
170
|
+
const pending = new MFATOTP();
|
|
171
|
+
const pendingSecret = authenticator.generateSecret();
|
|
172
|
+
pending.userId = user.id;
|
|
173
|
+
pending.name = 'Pending';
|
|
174
|
+
pending.secret = TOTPHelper.encrypt(pendingSecret);
|
|
175
|
+
pending.confirmedAt = null;
|
|
176
|
+
await pending.save();
|
|
177
|
+
|
|
178
|
+
const challenge = await requireMfa(organization, user.email);
|
|
179
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, {
|
|
180
|
+
mfa_token: challenge.token,
|
|
181
|
+
method: 'TOTP',
|
|
182
|
+
code: authenticator.generate(pendingSecret),
|
|
183
|
+
})));
|
|
184
|
+
expect(err.code).toBe('invalid_mfa_code');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a TOTP code from outside the accepted time window is rejected', async () => {
|
|
188
|
+
const organization = await new OrganizationFactory({}).create();
|
|
189
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
190
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
191
|
+
|
|
192
|
+
// A code intercepted 5 minutes ago (e.g. from a phishing page or a screenshot)
|
|
193
|
+
// must no longer be worth anything.
|
|
194
|
+
const stale = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, {
|
|
195
|
+
mfa_token: (await requireMfa(organization, user.email)).token,
|
|
196
|
+
method: 'TOTP',
|
|
197
|
+
code: totpCodeAt(secret, Date.now() - 5 * 60 * 1000),
|
|
198
|
+
})));
|
|
199
|
+
expect(stale.code).toBe('invalid_mfa_code');
|
|
200
|
+
|
|
201
|
+
// Nor may a code be accepted far ahead of the current step.
|
|
202
|
+
const future = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, {
|
|
203
|
+
mfa_token: (await requireMfa(organization, user.email)).token,
|
|
204
|
+
method: 'TOTP',
|
|
205
|
+
code: totpCodeAt(secret, Date.now() + 5 * 60 * 1000),
|
|
206
|
+
})));
|
|
207
|
+
expect(future.code).toBe('invalid_mfa_code');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('the challenge never leaks the secret, the recovery codes or a session token', async () => {
|
|
211
|
+
const organization = await new OrganizationFactory({}).create();
|
|
212
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
213
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
214
|
+
const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
215
|
+
|
|
216
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
217
|
+
expect(err.code).toBe('require_mfa');
|
|
218
|
+
|
|
219
|
+
const meta = err.meta as Record<string, unknown>;
|
|
220
|
+
expect(Object.keys(meta).sort()).toEqual(['methods', 'token', 'webauthnAuthenticationOptions']);
|
|
221
|
+
|
|
222
|
+
const serialized = JSON.stringify(meta);
|
|
223
|
+
expect(serialized).not.toContain(secret);
|
|
224
|
+
for (const code of codes) {
|
|
225
|
+
expect(serialized).not.toContain(code);
|
|
226
|
+
}
|
|
227
|
+
// No access/refresh token is smuggled along with the challenge.
|
|
228
|
+
expect(serialized).not.toContain('access_token');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// -----------------------------------------------------------------------
|
|
233
|
+
// Using one kind of token where another is expected
|
|
234
|
+
// -----------------------------------------------------------------------
|
|
235
|
+
describe('token confusion', () => {
|
|
236
|
+
test('a login MFA token cannot be used as a setup token to enrol a new factor', async () => {
|
|
237
|
+
// The nastiest variant: the attacker knows the password, gets a `require_mfa`
|
|
238
|
+
// challenge, and tries to enrol a factor of their own with that same token.
|
|
239
|
+
const organization = await new OrganizationFactory({}).create();
|
|
240
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
241
|
+
await addConfirmedTOTP(user);
|
|
242
|
+
|
|
243
|
+
const challenge = await requireMfa(organization, user.email);
|
|
244
|
+
|
|
245
|
+
const err = await captureError(testServer.test(new SetupTOTPEndpoint(), withSetup(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), challenge.token)));
|
|
246
|
+
expect(err.code).toBe('mfa_setup_expired');
|
|
247
|
+
expect(await MFATOTP.getForUser(user.id)).toHaveLength(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('a login MFA token cannot be used as an access token', async () => {
|
|
251
|
+
const organization = await new OrganizationFactory({}).create();
|
|
252
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
253
|
+
await addConfirmedTOTP(user);
|
|
254
|
+
|
|
255
|
+
const challenge = await requireMfa(organization, user.email);
|
|
256
|
+
const err = await captureError(testServer.test(new GetMFAStatusEndpoint(), bearer(Request.buildJson('GET', '/mfa', organization.getApiHost()), challenge.token)));
|
|
257
|
+
expect(err.code).toBe('invalid_access_token');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('a setup token cannot be redeemed through the mfa grant', async () => {
|
|
261
|
+
const organization = await organizationRequiringTwoFactor();
|
|
262
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
263
|
+
|
|
264
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
265
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
266
|
+
const setupToken = (err.meta as { setupToken: string }).setupToken;
|
|
267
|
+
|
|
268
|
+
const reuse = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: setupToken, method: 'TOTP', code: '000000' })));
|
|
269
|
+
expect(reuse.code).toBe('invalid_mfa_token');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('a password reset token cannot be used as a setup token', async () => {
|
|
273
|
+
const organization = await new OrganizationFactory({}).create();
|
|
274
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
275
|
+
const passwordToken = await PasswordToken.createToken(user);
|
|
276
|
+
|
|
277
|
+
const err = await captureError(testServer.test(new SetupTOTPEndpoint(), withSetup(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), passwordToken.token)));
|
|
278
|
+
expect(err.code).toBe('mfa_setup_expired');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// -----------------------------------------------------------------------
|
|
283
|
+
// Abusing the forced-enrollment setup token
|
|
284
|
+
// -----------------------------------------------------------------------
|
|
285
|
+
describe('setup token abuse', () => {
|
|
286
|
+
async function startForcedSetup() {
|
|
287
|
+
const organization = await organizationRequiringTwoFactor();
|
|
288
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
289
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
290
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
291
|
+
return { organization, user, setupToken: (err.meta as { setupToken: string }).setupToken };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
test('a setup token stops working once the user enrolled a factor elsewhere', async () => {
|
|
295
|
+
const { organization, user, setupToken } = await startForcedSetup();
|
|
296
|
+
|
|
297
|
+
// The attacker (who only knows the password) starts enrolling an authenticator
|
|
298
|
+
// of their own with the setup token they were handed.
|
|
299
|
+
const setup = await testServer.test(new SetupTOTPEndpoint(), withSetup(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), setupToken));
|
|
300
|
+
const { totpId, secret } = setup.body as { totpId: string; secret: string };
|
|
301
|
+
|
|
302
|
+
// In the meantime the real user completes the enrolment on their own device.
|
|
303
|
+
await addConfirmedTOTP(user);
|
|
304
|
+
|
|
305
|
+
// The account is protected now, so the password-only setup token must be dead.
|
|
306
|
+
const err = await captureError(testServer.test(new ConfirmTOTPEndpoint(), withSetup(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: authenticator.generate(secret), name: 'Attacker' }), setupToken)));
|
|
307
|
+
expect(err.code).toBe('mfa_setup_expired');
|
|
308
|
+
|
|
309
|
+
// The attacker's authenticator was never confirmed, so it is not a factor.
|
|
310
|
+
expect((await MFATOTP.getByID(totpId))?.confirmedAt ?? null).toBeNull();
|
|
311
|
+
|
|
312
|
+
// And the token is gone: retrying does not help.
|
|
313
|
+
expect(await MFAToken.getValid(setupToken, 'setup')).toBeUndefined();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('a dead setup token cannot start a passkey enrolment either', async () => {
|
|
317
|
+
const { organization, user, setupToken } = await startForcedSetup();
|
|
318
|
+
await addPasskey(user);
|
|
319
|
+
|
|
320
|
+
const err = await captureError(testServer.test(new RegisterPasskeyOptionsEndpoint(), withSetup(Request.buildJson('POST', '/mfa/passkeys/options', organization.getApiHost()), setupToken)));
|
|
321
|
+
expect(err.code).toBe('mfa_setup_expired');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('a setup token only acts for its own user', async () => {
|
|
325
|
+
const { organization, setupToken } = await startForcedSetup();
|
|
326
|
+
|
|
327
|
+
const victim = await new UserFactory({ organization, password }).create();
|
|
328
|
+
const victimTotp = await addConfirmedTOTP(victim);
|
|
329
|
+
|
|
330
|
+
const err = await captureError(testServer.test(new ConfirmTOTPEndpoint(), withSetup(Request.buildJson('POST', `/mfa/totp/${victimTotp.id}/confirm`, organization.getApiHost(), { code: authenticator.generate(victimTotp.secret), name: 'x' }), setupToken)));
|
|
331
|
+
expect(err.code).toBe('not_found');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('an expired setup token is refused', async () => {
|
|
335
|
+
const { organization, setupToken } = await startForcedSetup();
|
|
336
|
+
|
|
337
|
+
const row = await MFAToken.select().where('token', setupToken).first(true);
|
|
338
|
+
row.expiresAt = new Date(Date.now() - 1000);
|
|
339
|
+
await row.save();
|
|
340
|
+
|
|
341
|
+
const err = await captureError(testServer.test(new SetupTOTPEndpoint(), withSetup(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), setupToken)));
|
|
342
|
+
expect(err.code).toBe('mfa_setup_expired');
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// -----------------------------------------------------------------------
|
|
347
|
+
// Attacker holds a session and tries to strip the second factor
|
|
348
|
+
// -----------------------------------------------------------------------
|
|
349
|
+
describe('removing a second factor', () => {
|
|
350
|
+
test('a stolen refresh token cannot be traded for a session that removes a factor', async () => {
|
|
351
|
+
const organization = await new OrganizationFactory({}).create();
|
|
352
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
353
|
+
const { id: totpId, secret } = await addConfirmedTOTP(user);
|
|
354
|
+
|
|
355
|
+
// A complete, legitimate login (password + TOTP).
|
|
356
|
+
const challenge = await requireMfa(organization, user.email);
|
|
357
|
+
const login = await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(secret) }));
|
|
358
|
+
const refreshToken = (login.body as TokenStruct).refreshToken;
|
|
359
|
+
|
|
360
|
+
// The attacker exfiltrates the refresh token and rotates it into a session.
|
|
361
|
+
const refreshed = await testServer.test(tokenEndpoint, Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
|
|
362
|
+
grant_type: 'refresh_token',
|
|
363
|
+
refresh_token: refreshToken,
|
|
364
|
+
}));
|
|
365
|
+
const accessToken = (refreshed.body as TokenStruct).accessToken;
|
|
366
|
+
expect((await Token.getByAccessToken(accessToken))?.isFresh()).toBe(false);
|
|
367
|
+
|
|
368
|
+
// That session must not be enough to disable two-factor authentication.
|
|
369
|
+
const err = await captureError(testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totpId}`, organization.getApiHost()), accessToken)));
|
|
370
|
+
expect(err.code).toBe('require_fresh_auth');
|
|
371
|
+
expect(await MFATOTP.getByID(totpId)).toBeDefined();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('a session that authenticated longer ago than the freshness window is refused', async () => {
|
|
375
|
+
const organization = await new OrganizationFactory({}).create();
|
|
376
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
377
|
+
const { id: totpId } = await addConfirmedTOTP(user);
|
|
378
|
+
|
|
379
|
+
const token = await freshToken(user);
|
|
380
|
+
token.authenticatedAt = new Date(Date.now() - Token.FRESH_WINDOW - 60 * 1000);
|
|
381
|
+
await token.save();
|
|
382
|
+
|
|
383
|
+
const err = await captureError(testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totpId}`, organization.getApiHost()), token)));
|
|
384
|
+
expect(err.code).toBe('require_fresh_auth');
|
|
385
|
+
expect(await MFATOTP.getByID(totpId)).toBeDefined();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('another user\'s passkey cannot be deleted, not even with a fresh session', async () => {
|
|
389
|
+
const organization = await new OrganizationFactory({}).create();
|
|
390
|
+
const victim = await new UserFactory({ organization, password }).create();
|
|
391
|
+
const passkey = await addPasskey(victim);
|
|
392
|
+
|
|
393
|
+
const attacker = await new UserFactory({ organization, password }).create();
|
|
394
|
+
const err = await captureError(testServer.test(new DeletePasskeyEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/passkeys/${passkey.id}`, organization.getApiHost()), await freshToken(attacker))));
|
|
395
|
+
expect(err.code).toBe('not_found');
|
|
396
|
+
expect(await WebauthnCredential.getByID(passkey.id)).toBeDefined();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// -----------------------------------------------------------------------
|
|
401
|
+
// Evading the organization scope (platform mode)
|
|
402
|
+
// -----------------------------------------------------------------------
|
|
403
|
+
describe('organization scope evasion', () => {
|
|
404
|
+
/**
|
|
405
|
+
* In platform mode users are not bound to an organization: the scope comes from the
|
|
406
|
+
* API host, while the session that comes out works everywhere. So the requirement of
|
|
407
|
+
* an organization may never depend on which host the request happened to hit.
|
|
408
|
+
*/
|
|
409
|
+
async function platformModeAdmin() {
|
|
410
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
411
|
+
const organization = await organizationRequiringTwoFactor();
|
|
412
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
413
|
+
expect(user.organizationId).toBeNull();
|
|
414
|
+
return { organization, user };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
test('an admin cannot dodge forced enrollment by signing in on the unscoped host', async () => {
|
|
418
|
+
const { organization, user } = await platformModeAdmin();
|
|
419
|
+
|
|
420
|
+
// Sanity check: on the organization's own host, enrollment is enforced.
|
|
421
|
+
const scoped = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
422
|
+
expect(scoped.code).toBe('require_mfa_setup');
|
|
423
|
+
|
|
424
|
+
// Same account, same password, no organization in scope.
|
|
425
|
+
const unscoped = await captureError(testServer.test(tokenEndpoint, passwordLogin(null, user.email, password)));
|
|
426
|
+
expect(unscoped.code).toBe('require_mfa_setup');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('an admin cannot remove their last factor by dropping the organization scope', async () => {
|
|
430
|
+
const { user } = await platformModeAdmin();
|
|
431
|
+
const { id: totpId } = await addConfirmedTOTP(user);
|
|
432
|
+
|
|
433
|
+
const err = await captureError(testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totpId}`, undefined), await freshToken(user))));
|
|
434
|
+
expect(err.code).toBe('cannot_remove_last_factor');
|
|
435
|
+
expect(await MFATOTP.getByID(totpId)).toBeDefined();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('a user without permissions in the organization is still not forced', async () => {
|
|
439
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
440
|
+
await organizationRequiringTwoFactor();
|
|
441
|
+
const user = await new UserFactory({ password }).create();
|
|
442
|
+
|
|
443
|
+
const response = await testServer.test(tokenEndpoint, passwordLogin(null, user.email, password));
|
|
444
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('an MFA challenge cannot be completed from another organization\'s scope', async () => {
|
|
448
|
+
const organization = await new OrganizationFactory({}).create();
|
|
449
|
+
const other = await new OrganizationFactory({}).create();
|
|
450
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
451
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
452
|
+
|
|
453
|
+
const challenge = await requireMfa(organization, user.email);
|
|
454
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(other, {
|
|
455
|
+
mfa_token: challenge.token,
|
|
456
|
+
method: 'TOTP',
|
|
457
|
+
code: authenticator.generate(secret),
|
|
458
|
+
})));
|
|
459
|
+
expect(err.code).toBe('invalid_mfa_token');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('an MFA challenge for an organization user cannot be completed unscoped', async () => {
|
|
463
|
+
const organization = await new OrganizationFactory({}).create();
|
|
464
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
465
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
466
|
+
|
|
467
|
+
const challenge = await requireMfa(organization, user.email);
|
|
468
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(null, {
|
|
469
|
+
mfa_token: challenge.token,
|
|
470
|
+
method: 'TOTP',
|
|
471
|
+
code: authenticator.generate(secret),
|
|
472
|
+
})));
|
|
473
|
+
expect(err.code).toBe('invalid_mfa_token');
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// -----------------------------------------------------------------------
|
|
478
|
+
// Email verification as an alternative way in
|
|
479
|
+
// -----------------------------------------------------------------------
|
|
480
|
+
describe('email verification', () => {
|
|
481
|
+
test('an invalid session header does not turn into a skipped second factor', async () => {
|
|
482
|
+
// The signed-in shortcut in VerifyEmailEndpoint swallows every authentication
|
|
483
|
+
// error, so an unusable Authorization header must not read as "already signed
|
|
484
|
+
// in as this user".
|
|
485
|
+
const organization = await new OrganizationFactory({}).create();
|
|
486
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
487
|
+
await addConfirmedTOTP(user);
|
|
488
|
+
|
|
489
|
+
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
490
|
+
const request = bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), 'not-a-real-access-token');
|
|
491
|
+
|
|
492
|
+
const err = await captureError(testServer.test(new VerifyEmailEndpoint(), request));
|
|
493
|
+
expect(err.code).toBe('require_mfa');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('an expired session of the same user does not skip the second factor', async () => {
|
|
497
|
+
const organization = await new OrganizationFactory({}).create();
|
|
498
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
499
|
+
await addConfirmedTOTP(user);
|
|
500
|
+
|
|
501
|
+
const expired = await freshToken(user);
|
|
502
|
+
expired.accessTokenValidUntil = new Date(Date.now() - 60 * 1000);
|
|
503
|
+
await expired.save();
|
|
504
|
+
|
|
505
|
+
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
506
|
+
const err = await captureError(testServer.test(new VerifyEmailEndpoint(), bearer(Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code }), expired)));
|
|
507
|
+
expect(err.code).toBe('require_mfa');
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// -----------------------------------------------------------------------
|
|
512
|
+
// Changing the factors of an account ends the sessions the user is not on
|
|
513
|
+
// -----------------------------------------------------------------------
|
|
514
|
+
describe('other sessions are signed out', () => {
|
|
515
|
+
/**
|
|
516
|
+
* Enrolling or removing a factor is what a user does when they suspect someone else
|
|
517
|
+
* is in their account. A session the attacker already holds must not survive it.
|
|
518
|
+
*/
|
|
519
|
+
test('enrolling a factor signs out the sessions the user is not enrolling from', async () => {
|
|
520
|
+
const organization = await new OrganizationFactory({}).create();
|
|
521
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
522
|
+
|
|
523
|
+
const stolen = await freshToken(user);
|
|
524
|
+
const current = await freshToken(user);
|
|
525
|
+
|
|
526
|
+
const setup = await testServer.test(new SetupTOTPEndpoint(), bearer(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), current));
|
|
527
|
+
const { totpId, secret } = setup.body as { totpId: string; secret: string };
|
|
528
|
+
await testServer.test(new ConfirmTOTPEndpoint(), bearer(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: authenticator.generate(secret), name: 'Phone' }), current));
|
|
529
|
+
|
|
530
|
+
expect(await Token.getByAccessToken(stolen.accessToken, true)).toBeUndefined();
|
|
531
|
+
// The session that did the enrolling stays signed in.
|
|
532
|
+
expect(await Token.getByAccessToken(current.accessToken, true)).toBeDefined();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('removing a factor signs out the other sessions', async () => {
|
|
536
|
+
const organization = await new OrganizationFactory({}).create();
|
|
537
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
538
|
+
const { id: totpId } = await addConfirmedTOTP(user);
|
|
539
|
+
await addConfirmedTOTP(user);
|
|
540
|
+
|
|
541
|
+
const stolen = await freshToken(user);
|
|
542
|
+
const current = await freshToken(user);
|
|
543
|
+
|
|
544
|
+
await testServer.test(new DeleteTOTPEndpoint(), bearer(Request.buildJson('DELETE', `/mfa/totp/${totpId}`, organization.getApiHost()), current));
|
|
545
|
+
|
|
546
|
+
expect(await Token.getByAccessToken(stolen.accessToken, true)).toBeUndefined();
|
|
547
|
+
expect(await Token.getByAccessToken(current.accessToken, true)).toBeDefined();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test('forced enrollment signs out the temporary session it started from', async () => {
|
|
551
|
+
// The password-token grant hands out a temporary session before enrollment.
|
|
552
|
+
// Once the user enrolls, only the session returned by the enrollment survives.
|
|
553
|
+
const organization = await organizationRequiringTwoFactor();
|
|
554
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
555
|
+
|
|
556
|
+
const passwordToken = await PasswordToken.createToken(user);
|
|
557
|
+
const err = await captureError(testServer.test(tokenEndpoint, Request.buildJson('POST', '/oauth/token', organization.getApiHost(), { grant_type: 'password_token', token: passwordToken.token })));
|
|
558
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
559
|
+
|
|
560
|
+
const meta = err.meta as { setupToken: string; token: { access_token: string } };
|
|
561
|
+
const temporary = meta.token.access_token;
|
|
562
|
+
|
|
563
|
+
const setup = await testServer.test(new SetupTOTPEndpoint(), withSetup(Request.buildJson('POST', '/mfa/totp', organization.getApiHost()), meta.setupToken));
|
|
564
|
+
const { totpId, secret } = setup.body as { totpId: string; secret: string };
|
|
565
|
+
const confirm = await testServer.test(new ConfirmTOTPEndpoint(), withSetup(Request.buildJson('POST', `/mfa/totp/${totpId}/confirm`, organization.getApiHost(), { code: authenticator.generate(secret), name: 'Phone' }), meta.setupToken));
|
|
566
|
+
|
|
567
|
+
const issued = (confirm.body as { token: { accessToken: string } }).token;
|
|
568
|
+
expect(await Token.getByAccessToken(temporary, true)).toBeUndefined();
|
|
569
|
+
expect(await Token.getByAccessToken(issued.accessToken, true)).toBeDefined();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('an API user keeps its key when its own permissions are unrelated', async () => {
|
|
573
|
+
// Guards against a future caller wiping machine credentials: they are managed
|
|
574
|
+
// separately and are not browser sessions.
|
|
575
|
+
const organization = await new OrganizationFactory({}).create();
|
|
576
|
+
const apiUser = await new UserFactory({ organization, apiUser: true }).create();
|
|
577
|
+
const apiToken = await Token.createToken(apiUser);
|
|
578
|
+
|
|
579
|
+
expect(await Token.deleteOtherSessions(apiUser.id, null)).toBe(0);
|
|
580
|
+
expect(await Token.getByAccessToken(apiToken.accessToken, true)).toBeDefined();
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// -----------------------------------------------------------------------
|
|
585
|
+
// Brute force: parallel guesses must each cost a try
|
|
586
|
+
// -----------------------------------------------------------------------
|
|
587
|
+
describe('parallel guessing', () => {
|
|
588
|
+
test('guesses sent at the same time all count towards the try limit', async () => {
|
|
589
|
+
const organization = await new OrganizationFactory({}).create();
|
|
590
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
591
|
+
const created = await MFAToken.createFor(user.id, 'login');
|
|
592
|
+
|
|
593
|
+
// Every request loads its own copy of the row, exactly like five concurrent
|
|
594
|
+
// requests to the mfa grant would. A read-modify-write counter would end at 1.
|
|
595
|
+
const copies = await Promise.all(
|
|
596
|
+
Array.from({ length: MFAToken.MAX_TRIES }, () => MFAToken.getValid(created.token, 'login')),
|
|
597
|
+
);
|
|
598
|
+
await Promise.all(copies.map(copy => copy!.registerFailedAttempt()));
|
|
599
|
+
|
|
600
|
+
const after = await MFAToken.select().where('token', created.token).first(true);
|
|
601
|
+
expect(after.tries).toBe(MFAToken.MAX_TRIES);
|
|
602
|
+
|
|
603
|
+
// And the token is spent: it is no longer accepted.
|
|
604
|
+
expect(await MFAToken.getValid(created.token, 'login')).toBeUndefined();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('a superseded challenge token stops working', async () => {
|
|
608
|
+
const organization = await new OrganizationFactory({}).create();
|
|
609
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
610
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
611
|
+
|
|
612
|
+
const first = await requireMfa(organization, user.email);
|
|
613
|
+
await requireMfa(organization, user.email);
|
|
614
|
+
|
|
615
|
+
const err = await captureError(testServer.test(tokenEndpoint, mfaGrant(organization, {
|
|
616
|
+
mfa_token: first.token,
|
|
617
|
+
method: 'TOTP',
|
|
618
|
+
code: authenticator.generate(secret),
|
|
619
|
+
})));
|
|
620
|
+
expect(err.code).toBe('invalid_mfa_token');
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// -----------------------------------------------------------------------
|
|
625
|
+
// The SSO challenge endpoint is not a way to reach anything else
|
|
626
|
+
// -----------------------------------------------------------------------
|
|
627
|
+
describe('challenge endpoint', () => {
|
|
628
|
+
test('a setup token cannot be described as a login challenge', async () => {
|
|
629
|
+
const organization = await organizationRequiringTwoFactor();
|
|
630
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
631
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
632
|
+
const setupToken = (err.meta as { setupToken: string }).setupToken;
|
|
633
|
+
|
|
634
|
+
const rejected = await captureError(testServer.test(new GetMFAChallengeEndpoint(), Request.buildJson('POST', '/mfa/challenge', organization.getApiHost(), { token: setupToken })));
|
|
635
|
+
expect(rejected.code).toBe('invalid_mfa_token');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test('an unknown token is rejected', async () => {
|
|
639
|
+
const organization = await new OrganizationFactory({}).create();
|
|
640
|
+
const err = await captureError(testServer.test(new GetMFAChallengeEndpoint(), Request.buildJson('POST', '/mfa/challenge', organization.getApiHost(), { token: 'does-not-exist' })));
|
|
641
|
+
expect(err.code).toBe('invalid_mfa_token');
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test('a locked out token is rejected', async () => {
|
|
645
|
+
const organization = await new OrganizationFactory({}).create();
|
|
646
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
647
|
+
await addConfirmedTOTP(user);
|
|
648
|
+
const challenge = await requireMfa(organization, user.email);
|
|
649
|
+
|
|
650
|
+
const row = await MFAToken.select().where('token', challenge.token).first(true);
|
|
651
|
+
row.tries = MFAToken.MAX_TRIES;
|
|
652
|
+
await row.save();
|
|
653
|
+
|
|
654
|
+
const err = await captureError(testServer.test(new GetMFAChallengeEndpoint(), Request.buildJson('POST', '/mfa/challenge', organization.getApiHost(), { token: challenge.token })));
|
|
655
|
+
expect(err.code).toBe('invalid_mfa_token');
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// -----------------------------------------------------------------------
|
|
660
|
+
// Storage: nothing that unlocks an account may be readable in the database
|
|
661
|
+
// -----------------------------------------------------------------------
|
|
662
|
+
describe('secrets at rest', () => {
|
|
663
|
+
test('TOTP secrets are not stored in plaintext', async () => {
|
|
664
|
+
const organization = await new OrganizationFactory({}).create();
|
|
665
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
666
|
+
const { id, secret } = await addConfirmedTOTP(user);
|
|
667
|
+
|
|
668
|
+
const stored = await MFATOTP.getByID(id);
|
|
669
|
+
expect(stored!.secret).not.toContain(secret);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test('recovery codes are only stored as hashes', async () => {
|
|
673
|
+
const organization = await new OrganizationFactory({}).create();
|
|
674
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
675
|
+
const codes = await RecoveryCodeHelper.regenerateForUser(user.id);
|
|
676
|
+
|
|
677
|
+
const stored = await MFARecoveryCode.getForUser(user.id);
|
|
678
|
+
expect(stored.length).toBe(codes.length);
|
|
679
|
+
|
|
680
|
+
for (const row of stored) {
|
|
681
|
+
expect(row.codeHash.startsWith('$argon2')).toBe(true);
|
|
682
|
+
for (const code of codes) {
|
|
683
|
+
expect(row.codeHash).not.toContain(code.replace(/-/g, ''));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
});
|