@stamhoofd/backend 2.138.2 → 2.140.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 +17 -17
- package/src/crons/fake-settlements.test.ts +165 -0
- package/src/crons/fake-settlements.ts +117 -0
- package/src/crons/index.ts +1 -0
- package/src/endpoints/auth/CreateTokenEndpoint.ts +5 -2
- package/src/endpoints/auth/ForgotPasswordEndpoint.test.ts +45 -0
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -23
- package/src/endpoints/auth/MFA.security.test.ts +76 -1
- package/src/endpoints/auth/MFA.test.ts +187 -3
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +15 -1
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
- package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
- package/src/excel-loaders/balance-item-payments.ts +145 -0
- package/src/excel-loaders/index.ts +1 -0
- package/src/excel-loaders/payments.ts +45 -37
- package/src/helpers/StripeInvoicer.ts +1 -1
- package/src/helpers/TwoFactorHelper.ts +91 -9
- package/src/helpers/breakdownRelations.ts +48 -0
- package/src/helpers/getNextPageRequest.ts +24 -0
- package/src/helpers/streamForBreakdown.test.ts +107 -0
- package/src/helpers/streamForBreakdown.ts +104 -0
- package/src/services/DocumentRenderService.test.ts +72 -1
- package/src/services/PasswordForgotService.ts +31 -1
- package/src/services/PaymentService.ts +9 -1
- package/src/services/SSOService.ts +14 -1
- package/src/sql-filters/balance-item-payments-root.ts +48 -0
- package/src/sql-filters/balance-items.ts +55 -0
- package/src/sql-filters/orders.ts +6 -2
- package/src/sql-filters/payment-settlement.test.ts +68 -0
- package/src/sql-filters/payment-settlement.ts +43 -0
- package/src/sql-filters/payments.ts +93 -31
- package/src/sql-sorters/balance-item-payments.ts +32 -0
- package/tests/filters/orders.test.ts +635 -0
- package/tests/helpers/ExportSlice.ts +71 -0
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { Endpoint, Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
4
|
+
import { AuditLog, EmailTemplateFactory, EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, PasswordToken, Platform, Token, User, UserFactory, OrganizationFactory, WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
|
|
5
|
+
import { AuditLogReplacementType, AuditLogType, EmailTemplateType, MFAMethodType, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
|
|
5
6
|
import { authenticator } from 'otplib';
|
|
6
7
|
import crypto from 'crypto';
|
|
7
8
|
|
|
8
9
|
import { MFATestHelper } from '../../../tests/helpers/MFATestHelper.js';
|
|
9
10
|
import { testServer } from '../../../tests/helpers/TestServer.js';
|
|
10
11
|
import { RECOVERY_CODE_ALPHABET, RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
|
|
11
|
-
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
12
|
+
import { INACTIVE_ADMIN_ENROLLMENT_DAYS, TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
12
13
|
import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
|
|
14
|
+
import { PasswordForgotService } from '../../services/PasswordForgotService.js';
|
|
13
15
|
import { ConfirmTOTPEndpoint } from './ConfirmTOTPEndpoint.js';
|
|
14
16
|
import { CreateTokenEndpoint } from './CreateTokenEndpoint.js';
|
|
15
17
|
import { DeletePasskeyEndpoint } from './DeletePasskeyEndpoint.js';
|
|
@@ -511,6 +513,188 @@ describe('MFA', () => {
|
|
|
511
513
|
});
|
|
512
514
|
});
|
|
513
515
|
|
|
516
|
+
// -----------------------------------------------------------------------
|
|
517
|
+
// Forced enrollment of accounts that were not used for a long time
|
|
518
|
+
// -----------------------------------------------------------------------
|
|
519
|
+
describe('inactive admins confirm their email before enrolling', () => {
|
|
520
|
+
const day = 24 * 60 * 60 * 1000;
|
|
521
|
+
|
|
522
|
+
beforeEach(async () => {
|
|
523
|
+
// Without a template no email is built at all, and the assertions below would
|
|
524
|
+
// pass on a version that never sends one.
|
|
525
|
+
await new EmailTemplateFactory({ type: EmailTemplateType.ForgotPassword }).create();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
async function inactiveAdmin({ inactiveDays = INACTIVE_ADMIN_ENROLLMENT_DAYS + 1, requireTwoFactor = true }: { inactiveDays?: number; requireTwoFactor?: boolean } = {}) {
|
|
529
|
+
const organization = await new OrganizationFactory({}).create();
|
|
530
|
+
organization.privateMeta.requireTwoFactor = requireTwoFactor;
|
|
531
|
+
await organization.save();
|
|
532
|
+
|
|
533
|
+
const user = await new UserFactory({ organization, password, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
534
|
+
user.lastActiveAt = new Date(Date.now() - inactiveDays * day);
|
|
535
|
+
await user.save();
|
|
536
|
+
return { organization, user };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function passwordTokensFor(user: User): Promise<PasswordToken[]> {
|
|
540
|
+
return await PasswordToken.select().where('userId', user.id).fetch();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function recoveryEmailsFor(user: User) {
|
|
544
|
+
return (await EmailMocker.transactional.getSucceededEmails()).filter(e => e.to.includes(user.email));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
test('a long inactive admin is locked out and gets a password recovery link', async () => {
|
|
548
|
+
const { organization, user } = await inactiveAdmin();
|
|
549
|
+
|
|
550
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
551
|
+
expect(err.code).toBe('require_email_confirmation');
|
|
552
|
+
expect(err.statusCode).toBe(403);
|
|
553
|
+
|
|
554
|
+
const [passwordToken] = await passwordTokensFor(user);
|
|
555
|
+
expect(passwordToken).toBeDefined();
|
|
556
|
+
|
|
557
|
+
// The link in the email is the one that gets them back in.
|
|
558
|
+
const emails = await recoveryEmailsFor(user);
|
|
559
|
+
expect(emails).toHaveLength(1);
|
|
560
|
+
expect(emails[0].html).toContain(encodeURIComponent(passwordToken.token));
|
|
561
|
+
|
|
562
|
+
// No setup token was handed out: the enrollment may not start from the password alone.
|
|
563
|
+
expect(await MFAToken.select().where('userId', user.id).fetch()).toHaveLength(0);
|
|
564
|
+
expect((await User.getByID(user.id))!.lastActiveAt!.getTime()).toBeLessThan(Date.now() - INACTIVE_ADMIN_ENROLLMENT_DAYS * day);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('an admin who signed in recently is only forced to enroll', async () => {
|
|
568
|
+
const { organization, user } = await inactiveAdmin({ inactiveDays: INACTIVE_ADMIN_ENROLLMENT_DAYS - 1 });
|
|
569
|
+
|
|
570
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
571
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
572
|
+
expect(await passwordTokensFor(user)).toHaveLength(0);
|
|
573
|
+
expect(await recoveryEmailsFor(user)).toHaveLength(0);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('a long inactive platform admin is locked out too', async () => {
|
|
577
|
+
await setPlatformRequiresTwoFactor(true);
|
|
578
|
+
const user = await new UserFactory({ password, globalPermissions: Permissions.create({ level: PermissionLevel.Full }) }).create();
|
|
579
|
+
user.lastActiveAt = new Date(Date.now() - (INACTIVE_ADMIN_ENROLLMENT_DAYS + 1) * day);
|
|
580
|
+
await user.save();
|
|
581
|
+
|
|
582
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(null, user.email, password)));
|
|
583
|
+
expect(err.code).toBe('require_email_confirmation');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('an inactive admin who already enrolled is challenged as usual', async () => {
|
|
587
|
+
const { organization, user } = await inactiveAdmin();
|
|
588
|
+
const { secret } = await addConfirmedTOTP(user);
|
|
589
|
+
|
|
590
|
+
const challenge = await requireMfa(organization, user.email, password);
|
|
591
|
+
const response = await testServer.test(tokenEndpoint, mfaGrant(organization, { mfa_token: challenge.token, method: 'TOTP', code: authenticator.generate(secret) }));
|
|
592
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
593
|
+
expect(await passwordTokensFor(user)).toHaveLength(0);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('an inactive user without permissions logs in normally', async () => {
|
|
597
|
+
const organization = await new OrganizationFactory({}).create();
|
|
598
|
+
organization.privateMeta.requireTwoFactor = true;
|
|
599
|
+
await organization.save();
|
|
600
|
+
|
|
601
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
602
|
+
user.lastActiveAt = new Date(Date.now() - 400 * day);
|
|
603
|
+
await user.save();
|
|
604
|
+
|
|
605
|
+
const response = await testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password));
|
|
606
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test('an inactive admin is not blocked when 2FA is not required', async () => {
|
|
610
|
+
const { organization, user } = await inactiveAdmin({ requireTwoFactor: false });
|
|
611
|
+
|
|
612
|
+
const response = await testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password));
|
|
613
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test('an admin who never signed in is judged on the creation date', async () => {
|
|
617
|
+
const { organization, user } = await inactiveAdmin();
|
|
618
|
+
user.lastActiveAt = null;
|
|
619
|
+
await user.save();
|
|
620
|
+
|
|
621
|
+
const fresh = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
622
|
+
expect(fresh.code).toBe('require_mfa_setup');
|
|
623
|
+
|
|
624
|
+
user.createdAt = new Date(Date.now() - (INACTIVE_ADMIN_ENROLLMENT_DAYS + 1) * day);
|
|
625
|
+
await user.save();
|
|
626
|
+
|
|
627
|
+
const stale = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
628
|
+
expect(stale.code).toBe('require_email_confirmation');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('the emailed link confirms the email address and unlocks the enrollment', async () => {
|
|
632
|
+
const { organization, user } = await inactiveAdmin();
|
|
633
|
+
await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
634
|
+
|
|
635
|
+
const [passwordToken] = await passwordTokensFor(user);
|
|
636
|
+
expect(passwordToken).toBeDefined();
|
|
637
|
+
|
|
638
|
+
const err = await captureError(testServer.test(tokenEndpoint, Request.buildJson('POST', '/oauth/token', organization.getApiHost(), { grant_type: 'password_token', token: passwordToken.token })));
|
|
639
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
640
|
+
expect((err.meta as { setupToken: string }).setupToken).toBeTruthy();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test('an email verification code also unlocks the enrollment', async () => {
|
|
644
|
+
const { organization, user } = await inactiveAdmin();
|
|
645
|
+
|
|
646
|
+
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
647
|
+
const err = await captureError(testServer.test(new VerifyEmailEndpoint(), Request.buildJson('POST', '/verify-email', organization.getApiHost(), { token: code.token, code: code.code })));
|
|
648
|
+
expect(err.code).toBe('require_mfa_setup');
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test('repeated logins stop sending new recovery links', async () => {
|
|
652
|
+
const { organization, user } = await inactiveAdmin();
|
|
653
|
+
|
|
654
|
+
for (let i = 0; i < 6; i++) {
|
|
655
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
656
|
+
expect(err.code).toBe('require_email_confirmation');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
expect(await passwordTokensFor(user)).toHaveLength(3);
|
|
660
|
+
expect(await recoveryEmailsFor(user)).toHaveLength(3);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test('a failing email does not turn the lockout into a server error', async () => {
|
|
664
|
+
const { organization, user } = await inactiveAdmin();
|
|
665
|
+
const send = vi.spyOn(PasswordForgotService, 'sendPasswordRecoveryEmail').mockRejectedValue(new Error('Mail server down'));
|
|
666
|
+
|
|
667
|
+
try {
|
|
668
|
+
const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
|
|
669
|
+
expect(err.code).toBe('require_email_confirmation');
|
|
670
|
+
expect(send).toHaveBeenCalledOnce();
|
|
671
|
+
}
|
|
672
|
+
finally {
|
|
673
|
+
send.mockRestore();
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('the limit is only crossed after a full 45 days', async () => {
|
|
678
|
+
const { user } = await inactiveAdmin();
|
|
679
|
+
|
|
680
|
+
// A minute on either side of the limit, so the datetime column losing
|
|
681
|
+
// milliseconds cannot decide the outcome.
|
|
682
|
+
user.lastActiveAt = new Date(Date.now() - INACTIVE_ADMIN_ENROLLMENT_DAYS * day + 60 * 1000);
|
|
683
|
+
expect(TwoFactorHelper.isInactiveForEnrollment(user)).toBe(false);
|
|
684
|
+
|
|
685
|
+
user.lastActiveAt = new Date(Date.now() - INACTIVE_ADMIN_ENROLLMENT_DAYS * day - 60 * 1000);
|
|
686
|
+
expect(TwoFactorHelper.isInactiveForEnrollment(user)).toBe(true);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test('an SSO login of an inactive admin is not blocked', async () => {
|
|
690
|
+
// The identity provider authenticated the user, so there is nothing to confirm.
|
|
691
|
+
const { organization, user } = await inactiveAdmin();
|
|
692
|
+
|
|
693
|
+
const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'sso' });
|
|
694
|
+
expect(requirement.type).toBe('setup');
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
514
698
|
// -----------------------------------------------------------------------
|
|
515
699
|
// Enrollment + management while logged in (happy paths)
|
|
516
700
|
// -----------------------------------------------------------------------
|
|
@@ -82,6 +82,20 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
82
82
|
const other = await User.getForAuthentication(user.organizationId, code.email, { allowWithoutAccount: true });
|
|
83
83
|
|
|
84
84
|
if (other) {
|
|
85
|
+
// Merging absorbs the other account (its permissions included) and then
|
|
86
|
+
// deletes it, second factor and all. Reading the mailbox is exactly the
|
|
87
|
+
// single credential a second factor exists to back up, so an account that
|
|
88
|
+
// has one may not be taken over this way: nothing in this request proves
|
|
89
|
+
// the caller can pass it.
|
|
90
|
+
if (await TwoFactorHelper.userHasFactors(other.id)) {
|
|
91
|
+
throw new SimpleError({
|
|
92
|
+
code: 'email_in_use',
|
|
93
|
+
message: 'This e-mail is already in use by an account with two-factor authentication',
|
|
94
|
+
human: $t('%Zis'),
|
|
95
|
+
statusCode: 400,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
// Delete the other user, but merge data
|
|
86
100
|
await user.merge(other);
|
|
87
101
|
if (user.organizationId) {
|
|
@@ -116,7 +130,7 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
116
130
|
// the request already comes from a session of that same user (changing your email
|
|
117
131
|
// address while signed in): the second factor was checked when it was created.
|
|
118
132
|
if (authenticatedUser?.id !== user.id) {
|
|
119
|
-
await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion());
|
|
133
|
+
await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion(), { loginMethod: 'email', i18n: request.i18n });
|
|
120
134
|
}
|
|
121
135
|
|
|
122
136
|
const token = await Token.createToken(user);
|