@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.
Files changed (41) hide show
  1. package/package.json +17 -17
  2. package/src/crons/fake-settlements.test.ts +165 -0
  3. package/src/crons/fake-settlements.ts +117 -0
  4. package/src/crons/index.ts +1 -0
  5. package/src/endpoints/auth/CreateTokenEndpoint.ts +5 -2
  6. package/src/endpoints/auth/ForgotPasswordEndpoint.test.ts +45 -0
  7. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -23
  8. package/src/endpoints/auth/MFA.security.test.ts +76 -1
  9. package/src/endpoints/auth/MFA.test.ts +187 -3
  10. package/src/endpoints/auth/VerifyEmailEndpoint.ts +15 -1
  11. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +543 -0
  12. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.ts +78 -0
  13. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemEndpoint.ts +3 -1
  14. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +1095 -0
  15. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.ts +111 -0
  16. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +3 -79
  17. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +1 -1
  18. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +3 -1
  19. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -1
  20. package/src/excel-loaders/balance-item-payments.ts +145 -0
  21. package/src/excel-loaders/index.ts +1 -0
  22. package/src/excel-loaders/payments.ts +45 -37
  23. package/src/helpers/StripeInvoicer.ts +1 -1
  24. package/src/helpers/TwoFactorHelper.ts +91 -9
  25. package/src/helpers/breakdownRelations.ts +48 -0
  26. package/src/helpers/getNextPageRequest.ts +24 -0
  27. package/src/helpers/streamForBreakdown.test.ts +107 -0
  28. package/src/helpers/streamForBreakdown.ts +104 -0
  29. package/src/services/DocumentRenderService.test.ts +72 -1
  30. package/src/services/PasswordForgotService.ts +31 -1
  31. package/src/services/PaymentService.ts +9 -1
  32. package/src/services/SSOService.ts +14 -1
  33. package/src/sql-filters/balance-item-payments-root.ts +48 -0
  34. package/src/sql-filters/balance-items.ts +55 -0
  35. package/src/sql-filters/orders.ts +6 -2
  36. package/src/sql-filters/payment-settlement.test.ts +68 -0
  37. package/src/sql-filters/payment-settlement.ts +43 -0
  38. package/src/sql-filters/payments.ts +93 -31
  39. package/src/sql-sorters/balance-item-payments.ts +32 -0
  40. package/tests/filters/orders.test.ts +635 -0
  41. package/tests/helpers/ExportSlice.ts +71 -0
@@ -0,0 +1,111 @@
1
+ import type { Decoder } from '@simonbackx/simple-encoding';
2
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
+ import { StripeAccount } from '@stamhoofd/models';
5
+ import type { PaymentGeneral } from '@stamhoofd/structures';
6
+ import { StripeAccount as StripeAccountStruct } from '@stamhoofd/structures';
7
+ import { BreakdownRequest } from '@stamhoofd/structures/breakdown/BreakdownRequest.js';
8
+ import { PaymentBreakdownBuilder } from '@stamhoofd/structures/breakdown/PaymentBreakdownBuilder.js';
9
+ import type { PaymentBreakdown } from '@stamhoofd/structures/PaymentBreakdown.js';
10
+ import { Formatter } from '@stamhoofd/utility';
11
+ import { loadOrdersForBreakdown } from '../../../../helpers/breakdownRelations.js';
12
+ import { Context } from '../../../../helpers/Context.js';
13
+ import { streamForBreakdown } from '../../../../helpers/streamForBreakdown.js';
14
+ import { GetPaymentsEndpoint } from './GetPaymentsEndpoint.js';
15
+
16
+ type Params = Record<string, never>;
17
+ type Query = BreakdownRequest;
18
+ type Body = undefined;
19
+ type ResponseBody = PaymentBreakdown;
20
+
21
+ /**
22
+ * The Stripe accounts the payments arrived on, so they can be named after their holder. There are only
23
+ * a few of them, so they are kept for as long as the breakdown runs.
24
+ */
25
+ class StripeAccountCache {
26
+ // An account that no longer exists is remembered as null, so it is not looked up again on every page
27
+ private accounts = new Map<string, StripeAccountStruct | null>();
28
+
29
+ async load(payments: PaymentGeneral[]): Promise<StripeAccountStruct[]> {
30
+ const missing = Formatter.uniqueArray(
31
+ payments.flatMap(p => p.stripeAccountId && !this.accounts.has(p.stripeAccountId) ? [p.stripeAccountId] : []),
32
+ );
33
+
34
+ if (missing.length > 0) {
35
+ for (const id of missing) {
36
+ this.accounts.set(id, null);
37
+ }
38
+
39
+ for (const account of await StripeAccount.getByIDs(...missing)) {
40
+ this.accounts.set(account.id, StripeAccountStruct.create(account));
41
+ }
42
+ }
43
+
44
+ return [...this.accounts.values()].filter(account => account !== null);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Breaks a selection of payments down into where the money arrived, what it was for and which articles
50
+ * were paid.
51
+ *
52
+ * Reads the same payments the Excel export would and groups them with the same rules the rest of the
53
+ * app uses (see PaymentBreakdownBuilder in @stamhoofd/structures), so the numbers always describe
54
+ * exactly the payments that end up in the file.
55
+ */
56
+ export class GetPaymentBreakdownEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
57
+ queryDecoder = BreakdownRequest as Decoder<BreakdownRequest>;
58
+
59
+ protected doesMatch(request: Request): [true, Params] | [false] {
60
+ if (request.method !== 'GET') {
61
+ return [false];
62
+ }
63
+
64
+ const params = Endpoint.parseParameters(request.url, '/payments/breakdown', {});
65
+
66
+ if (params) {
67
+ return [true, params as Params];
68
+ }
69
+ return [false];
70
+ }
71
+
72
+ async handle(request: DecodedRequest<Params, Query, Body>) {
73
+ await Context.setOrganizationScope();
74
+ const { user } = await Context.authenticate();
75
+
76
+ const organization = Context.organization;
77
+
78
+ if (!organization) {
79
+ throw Context.auth.error();
80
+ }
81
+
82
+ if (!await Context.auth.canManagePayments(organization.id)) {
83
+ throw Context.auth.error();
84
+ }
85
+
86
+ const builder = new PaymentBreakdownBuilder(request.query.path);
87
+ const stripeAccounts = new StripeAccountCache();
88
+
89
+ await streamForBreakdown<PaymentGeneral>({
90
+ userId: user.id,
91
+ filter: request.query.readFilter,
92
+ search: request.query.search,
93
+ count: async (countRequest) => {
94
+ return await (await GetPaymentsEndpoint.buildQuery(countRequest)).count();
95
+ },
96
+ fetch: async (pageRequest) => {
97
+ return await GetPaymentsEndpoint.buildData(pageRequest);
98
+ },
99
+ handle: async (payments) => {
100
+ builder.add(payments, {
101
+ stripeAccounts: await stripeAccounts.load(payments),
102
+ orders: await loadOrdersForBreakdown(
103
+ payments.flatMap(p => p.balanceItemPayments.map(bp => bp.balanceItem.orderId)),
104
+ ),
105
+ });
106
+ },
107
+ });
108
+
109
+ return new Response(builder.build(request.query.filter));
110
+ }
111
+ }
@@ -5,12 +5,12 @@ import { SimpleError } from '@simonbackx/simple-errors';
5
5
  import { Payment } from '@stamhoofd/models';
6
6
  import { SQL, applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
7
7
  import type { CountFilteredRequest, PaymentGeneral, StamhoofdFilter } from '@stamhoofd/structures';
8
- import { LimitedFilteredRequest, PaginatedResponse, TransferSettings, assertSort, getSortFilter } from '@stamhoofd/structures';
8
+ import { LimitedFilteredRequest, PaginatedResponse, assertSort, getSortFilter } from '@stamhoofd/structures';
9
9
 
10
10
  import type { SQLResultNamespacedRow } from '@simonbackx/simple-database';
11
11
  import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
12
12
  import { Context } from '../../../../helpers/Context.js';
13
- import { paymentFilterCompilers } from '../../../../sql-filters/payments.js';
13
+ import { getPaymentSearchFilter, paymentFilterCompilers } from '../../../../sql-filters/payments.js';
14
14
  import { paymentSorters } from '../../../../sql-sorters/payments.js';
15
15
 
16
16
  type Params = Record<string, never>;
@@ -69,83 +69,7 @@ export class GetPaymentsEndpoint extends Endpoint<Params, Query, Body, ResponseB
69
69
  }
70
70
 
71
71
  if (q.search) {
72
- // todo
73
-
74
- let searchFilter: StamhoofdFilter | null = null;
75
- searchFilter = {
76
- $or: [
77
- {
78
- customer: {
79
- name: {
80
- $contains: q.search,
81
- },
82
- },
83
- },
84
- {
85
- customer: {
86
- company: {
87
- name: {
88
- $contains: q.search,
89
- },
90
- },
91
- },
92
- },
93
- {
94
- balanceItemPayments: {
95
- $elemMatch: {
96
- balanceItem: {
97
- description: {
98
- $contains: q.search,
99
- },
100
- },
101
- },
102
- },
103
- },
104
- {
105
- transferDescription: {
106
- $contains: q.search,
107
- },
108
- },
109
- ],
110
- };
111
-
112
- if (q.search.includes('@')) {
113
- searchFilter = {
114
- $or: [
115
- {
116
- customer: {
117
- email: {
118
- $contains: q.search,
119
- },
120
- },
121
- },
122
- {
123
- customer: {
124
- company: {
125
- administrationEmail: {
126
- $contains: q.search,
127
- },
128
- },
129
- },
130
- },
131
- ],
132
- };
133
- }
134
-
135
- const transferDescription = q.search.replaceAll('+', '').replaceAll('/', '');
136
- if (transferDescription.length === '562100153542'.length && !isNaN(parseInt(transferDescription))) {
137
- // Format to
138
- const formatted = TransferSettings.structureOGM(transferDescription);
139
-
140
- // Search for structured transfer
141
- searchFilter = {
142
- transferDescription: formatted,
143
- };
144
- }
145
-
146
- if (searchFilter) {
147
- query.where(await compileToSQLFilter(searchFilter, filterCompilers));
148
- }
72
+ query.where(await compileToSQLFilter(getPaymentSearchFilter(q.search), filterCompilers));
149
73
  }
150
74
 
151
75
  if (q instanceof LimitedFilteredRequest) {
@@ -213,7 +213,7 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
213
213
  const balanceItemPayment = new BalanceItemPayment();
214
214
  balanceItemPayment.balanceItemId = balanceItem.id;
215
215
  balanceItemPayment.paymentId = payment.id;
216
- balanceItemPayment.organizationId = organization.id;
216
+ balanceItemPayment.organizationId = payment.organizationId;
217
217
  balanceItemPayment.price = balanceItem.price;
218
218
  await balanceItemPayment.save();
219
219
 
@@ -20,7 +20,9 @@ export class GetPaymentEndpoint extends Endpoint<Params, Query, Body, ResponseBo
20
20
 
21
21
  const params = Endpoint.parseParameters(request.url, '/payments/@id', { id: String });
22
22
 
23
- if (params) {
23
+ // /payments/count and /payments/breakdown are endpoints of their own, which are not guaranteed
24
+ // to be matched before this one
25
+ if (params && params.id !== 'count' && params.id !== 'breakdown') {
24
26
  return [true, params as Params];
25
27
  }
26
28
  return [false];
@@ -234,7 +234,7 @@ export class PlaceOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBo
234
234
  const balanceItemPayment = new BalanceItemPayment();
235
235
  balanceItemPayment.balanceItemId = balanceItem.id;
236
236
  balanceItemPayment.paymentId = payment.id;
237
- balanceItemPayment.organizationId = organization.id;
237
+ balanceItemPayment.organizationId = payment.organizationId;
238
238
  balanceItemPayment.price = balanceItem.price;
239
239
  await balanceItemPayment.save();
240
240
 
@@ -0,0 +1,145 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { BalanceItemPayment, Payment } from '@stamhoofd/models';
3
+ import { applySQLSorter, compileToSQLFilter, SQL } from '@stamhoofd/sql';
4
+ import type { IPaginatedResponse, LimitedFilteredRequest } from '@stamhoofd/structures';
5
+ import { assertSort, ExcelExportType } from '@stamhoofd/structures';
6
+ import { Formatter } from '@stamhoofd/utility';
7
+ import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
8
+ import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
9
+ import { Context } from '../helpers/Context.js';
10
+ import { getNextPageRequest } from '../helpers/getNextPageRequest.js';
11
+ import { balanceItemPaymentRootFilterCompilers } from '../sql-filters/balance-item-payments-root.js';
12
+ import { getPaymentSearchFilter } from '../sql-filters/payments.js';
13
+ import { balanceItemPaymentSorters } from '../sql-sorters/balance-item-payments.js';
14
+ import type { PaymentWithItem } from './payments.js';
15
+ import { getBalanceItemPaymentColumns, PaymentGeneralWithStripeAccount } from './payments.js';
16
+
17
+ const sorters = balanceItemPaymentSorters;
18
+ const filterCompilers = balanceItemPaymentRootFilterCompilers;
19
+
20
+ /**
21
+ * What one payment paid for one balance item, as a row of its own.
22
+ *
23
+ * Money that is spread over several payments, or that paid for several things at once, only exists at
24
+ * this level: a breakdown that adds up parts of payments or of balance items selects them here, so the
25
+ * file holds exactly what was added up instead of the whole payments around it.
26
+ *
27
+ * A webshop order is not split into the articles that were ordered here, the way the payments export
28
+ * does: a row is one balance item payment, which is the unit that was counted.
29
+ */
30
+ ExportToExcelEndpoint.loaders.set(ExcelExportType.BalanceItemPayments, {
31
+ fetch: async (requestQuery: LimitedFilteredRequest) => {
32
+ const balanceItemPayments = await fetchPage(requestQuery);
33
+
34
+ const response: IPaginatedResponse<PaymentWithItem[], LimitedFilteredRequest> = {
35
+ results: await toRows(balanceItemPayments),
36
+ next: getNextPageRequest(balanceItemPayments, requestQuery, sorters),
37
+ };
38
+
39
+ return response;
40
+ },
41
+ getSheets: () => [
42
+ {
43
+ id: 'balanceItemPayments',
44
+ name: $t(`%Ly`),
45
+ columns: getBalanceItemPaymentColumns(),
46
+ },
47
+ ],
48
+ });
49
+
50
+ /**
51
+ * Reads one page of balance item payments, joined to both parents so a filter can reach either of them.
52
+ */
53
+ async function fetchPage(requestQuery: LimitedFilteredRequest): Promise<BalanceItemPayment[]> {
54
+ const organization = Context.organization;
55
+
56
+ if (!organization) {
57
+ throw Context.auth.error();
58
+ }
59
+
60
+ if (!await Context.auth.canManagePayments(organization.id)) {
61
+ throw Context.auth.error();
62
+ }
63
+
64
+ const query = BalanceItemPayment.select()
65
+ .setMaxExecutionTime(15 * 1000)
66
+ .join(
67
+ SQL.join(SQL.table('payments')).where(
68
+ SQL.column('payments', 'id'),
69
+ SQL.column('balance_item_payments', 'paymentId'),
70
+ ),
71
+ )
72
+ .join(
73
+ SQL.join(SQL.table('balance_items')).where(
74
+ SQL.column('balance_items', 'id'),
75
+ SQL.column('balance_item_payments', 'balanceItemId'),
76
+ ),
77
+ );
78
+
79
+ query.where(await compileToSQLFilter({ organizationId: organization.id }, filterCompilers));
80
+
81
+ if (requestQuery.filter) {
82
+ query.where(await compileToSQLFilter(requestQuery.filter, filterCompilers));
83
+ }
84
+
85
+ if (requestQuery.search) {
86
+ // Searching selects the same payments as the list this export started from, but a row here is
87
+ // one balance item payment, so the filter has to reach the payment it belongs to
88
+ query.where(await compileToSQLFilter({ payment: getPaymentSearchFilter(requestQuery.search) }, filterCompilers));
89
+ }
90
+
91
+ if (requestQuery.pageFilter) {
92
+ query.where(await compileToSQLFilter(requestQuery.pageFilter, filterCompilers));
93
+ }
94
+
95
+ requestQuery.sort = assertSort(requestQuery.sort, [{ key: 'id' }]);
96
+ applySQLSorter(query, requestQuery.sort, sorters);
97
+ query.limit(requestQuery.limit);
98
+
99
+ try {
100
+ return await query.fetch();
101
+ }
102
+ catch (error) {
103
+ if (error.message.includes('ER_QUERY_TIMEOUT')) {
104
+ throw new SimpleError({
105
+ code: 'timeout',
106
+ message: 'Query took too long',
107
+ human: $t(`%Cv`),
108
+ });
109
+ }
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Pairs every balance item payment with the payment it came in with, keeping the order of the page so
116
+ * the next page picks up where this one stopped.
117
+ */
118
+ async function toRows(balanceItemPayments: BalanceItemPayment[]): Promise<PaymentWithItem[]> {
119
+ if (balanceItemPayments.length === 0) {
120
+ return [];
121
+ }
122
+
123
+ const paymentIds = Formatter.uniqueArray(balanceItemPayments.map(p => p.paymentId));
124
+ const payments = await AuthenticatedStructures.paymentsGeneral(await Payment.getByIDs(...paymentIds), true);
125
+
126
+ // One payment can pay for many rows, so it is only built once even though every row repeats it
127
+ const paymentsById = new Map<string, PaymentGeneralWithStripeAccount>(
128
+ payments.map(payment => [payment.id, PaymentGeneralWithStripeAccount.create(payment)]),
129
+ );
130
+
131
+ return balanceItemPayments.flatMap((balanceItemPayment) => {
132
+ const payment = paymentsById.get(balanceItemPayment.paymentId);
133
+ const detailed = payment?.balanceItemPayments.find(p => p.id === balanceItemPayment.id);
134
+
135
+ if (!payment || !detailed) {
136
+ // The payment was removed while the file was being written
137
+ return [];
138
+ }
139
+
140
+ return [{
141
+ payment,
142
+ balanceItemPayment: Object.assign(detailed, { customTitle: null }),
143
+ }];
144
+ });
145
+ }
@@ -4,4 +4,5 @@ import './organizations.js';
4
4
  import './receivable-balances.js';
5
5
  import './event-notifications.js';
6
6
  import './balance-items.js';
7
+ import './balance-item-payments.js';
7
8
  import './platform-memberships.js';
@@ -9,7 +9,7 @@ import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEn
9
9
  import { GetPaymentsEndpoint } from '../endpoints/organization/dashboard/payments/GetPaymentsEndpoint.js';
10
10
  import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper.js';
11
11
 
12
- type PaymentWithItem = {
12
+ export type PaymentWithItem = {
13
13
  payment: PaymentGeneralWithStripeAccount;
14
14
  balanceItemPayment: PaymentExportBalanceItemPayment;
15
15
  };
@@ -82,46 +82,54 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Payments, {
82
82
  payment: data,
83
83
  balanceItemPayment: p,
84
84
  })),
85
- columns: [
86
- ...getBalanceItemColumns(),
87
-
88
- // Repeating columns need to de-transform again
89
- ...[
90
- ...getGeneralColumns(),
91
- ...getInvoiceColumns(),
92
- ...getPayingOrganizationColumns(),
93
- ].map((c) => {
94
- if ('match' in c) {
95
- return {
96
- ...c,
97
- match: (id: string) => {
98
- const result = c.match(id);
99
- if (!result) {
100
- return result;
101
- }
102
-
103
- return result.map(cc => ({
104
- ...cc,
105
- getValue: (object: PaymentWithItem) => {
106
- return cc.getValue(object.payment);
107
- },
108
- }));
109
- },
110
- };
111
- }
112
-
113
- return {
114
- ...c,
115
- getValue: (object: PaymentWithItem) => {
116
- return c.getValue(object.payment);
117
- },
118
- };
119
- }),
120
- ],
85
+ columns: getBalanceItemPaymentColumns(),
121
86
  },
122
87
  ],
123
88
  });
124
89
 
90
+ /**
91
+ * What one payment paying one part of one balance item is written out as: what it was for, plus the
92
+ * payment it came in with.
93
+ */
94
+ export function getBalanceItemPaymentColumns(): XlsxTransformerColumn<PaymentWithItem>[] {
95
+ return [
96
+ ...getBalanceItemColumns(),
97
+
98
+ // Repeating columns need to de-transform again
99
+ ...[
100
+ ...getGeneralColumns(),
101
+ ...getInvoiceColumns(),
102
+ ...getPayingOrganizationColumns(),
103
+ ].map((c) => {
104
+ if ('match' in c) {
105
+ return {
106
+ ...c,
107
+ match: (id: string) => {
108
+ const result = c.match(id);
109
+ if (!result) {
110
+ return result;
111
+ }
112
+
113
+ return result.map(cc => ({
114
+ ...cc,
115
+ getValue: (object: PaymentWithItem) => {
116
+ return cc.getValue(object.payment);
117
+ },
118
+ }));
119
+ },
120
+ };
121
+ }
122
+
123
+ return {
124
+ ...c,
125
+ getValue: (object: PaymentWithItem) => {
126
+ return c.getValue(object.payment);
127
+ },
128
+ };
129
+ }),
130
+ ];
131
+ }
132
+
125
133
  export function expandPaymentBalanceItemPayments(
126
134
  payment: PaymentGeneral,
127
135
  orderMap: Map<string, PaymentExportOrder>,
@@ -317,7 +317,7 @@ export class StripeReportInvoicer {
317
317
  const balanceItemPayment = new BalanceItemPayment();
318
318
  balanceItemPayment.balanceItemId = balanceItem.id;
319
319
  balanceItemPayment.paymentId = payment.id;
320
- balanceItemPayment.organizationId = organization.id;
320
+ balanceItemPayment.organizationId = payment.organizationId;
321
321
  balanceItemPayment.price = balanceItem.priceWithVAT;
322
322
  await balanceItemPayment.save();
323
323
  }
@@ -1,4 +1,5 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
+ import type { I18n } from '@stamhoofd/backend-i18n/I18n';
2
3
  import type { User } from '@stamhoofd/models';
3
4
  import { MFARecoveryCode, MFATOTP, MFAToken, Organization, Platform, RateLimiter, Token, WebauthnCredential } from '@stamhoofd/models';
4
5
  import type { User as UserStruct } from '@stamhoofd/structures';
@@ -6,6 +7,7 @@ import { MFAChallengeResponse, MFAEnrollmentResult, MFAMethodType, MFASetupRespo
6
7
 
7
8
  import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
8
9
 
10
+ import { PasswordForgotService } from '../services/PasswordForgotService.js';
9
11
  import { RecoveryCodeHelper } from './RecoveryCodeHelper.js';
10
12
  import { WebauthnHelper } from './WebauthnHelper.js';
11
13
  import { Formatter, Sorter } from '@stamhoofd/utility';
@@ -29,6 +31,34 @@ export const mfaVerificationRateLimiter = new RateLimiter({
29
31
  ],
30
32
  });
31
33
 
34
+ /**
35
+ * An admin who has to enroll a second factor, but never did, first has to prove they still
36
+ * read the email address on the account when they were inactive for this long. A dormant
37
+ * account is the one most likely to have a leaked or reused password, and enrolling a
38
+ * factor with only that password would hand the account to whoever holds it — protected by
39
+ * the very second factor the organization asked for.
40
+ */
41
+ export const INACTIVE_ADMIN_ENROLLMENT_DAYS = 45;
42
+
43
+ /**
44
+ * The confirmation email is triggered by a login attempt, so cap how often one account can
45
+ * send it: whoever knows the password could otherwise flood the mailbox. Like every rate
46
+ * limiter here this counts per process and in a window shared by all keys, so it thins out
47
+ * a flood rather than enforcing an exact number of emails.
48
+ */
49
+ const enrollmentConfirmationRateLimiter = new RateLimiter({
50
+ limits: [
51
+ {
52
+ limit: 3,
53
+ duration: 60 * 60 * 1000,
54
+ },
55
+ {
56
+ limit: 10,
57
+ duration: 24 * 60 * 60 * 1000,
58
+ },
59
+ ],
60
+ });
61
+
32
62
  /**
33
63
  * What still has to happen before a session may be issued for a user whose primary
34
64
  * credential was just accepted.
@@ -36,7 +66,8 @@ export const mfaVerificationRateLimiter = new RateLimiter({
36
66
  export type SecondFactorRequirement
37
67
  = | { type: 'none' }
38
68
  | { type: 'challenge'; challenge: MFAChallengeResponse }
39
- | { type: 'setup'; setupToken: MFAToken };
69
+ | { type: 'setup'; setupToken: MFAToken }
70
+ | { type: 'confirm-email' };
40
71
 
41
72
  export class TwoFactorHelper {
42
73
  /**
@@ -87,21 +118,35 @@ export class TwoFactorHelper {
87
118
  return false;
88
119
  }
89
120
 
121
+ /**
122
+ * Whether the account was unused for long enough that its password alone is no longer
123
+ * enough to start a forced enrollment. Accounts that never signed in fall back to their
124
+ * creation date, so an admin who was just invited is not locked out right away.
125
+ */
126
+ static isInactiveForEnrollment(user: User): boolean {
127
+ const lastActiveAt = user.lastActiveAt ?? user.createdAt;
128
+ return lastActiveAt.getTime() < Date.now() - INACTIVE_ADMIN_ENROLLMENT_DAYS * 24 * 60 * 60 * 1000;
129
+ }
130
+
90
131
  /**
91
132
  * What still has to happen before a session may be handed out, after a primary
92
133
  * credential was accepted.
93
134
  *
94
135
  * `loginMethod` describes the credential that was just verified:
95
- * - 'password': a password, password token or email verification code. All of these
96
- * are single credentials owned by the user, so a required second factor must be
97
- * set up here if the user does not have one yet.
136
+ * - 'password': the account password. A single credential that says nothing about
137
+ * whether the user still reads the email address on the account, so a required
138
+ * second factor must be set up here if the user does not have one yet — and a
139
+ * long-inactive admin has to confirm their email address before they may.
140
+ * - 'email': a password token or email verification code. Also a single credential,
141
+ * but one that only reaches someone who reads the account's email, so it is the way
142
+ * out of that email confirmation.
98
143
  * - 'sso': an external identity provider already authenticated the user, and is
99
144
  * trusted to apply its own second factor. An enrolled factor is still verified
100
145
  * (the user asked us to protect their account), but we do not force enrollment —
101
146
  * unless the account ALSO has a password, because then the password remains a way
102
147
  * in that bypasses whatever the provider enforces.
103
148
  */
104
- static async getSecondFactorRequirement(user: User, organization: Organization | null, { loginMethod }: { loginMethod: 'password' | 'sso' }): Promise<SecondFactorRequirement> {
149
+ static async getSecondFactorRequirement(user: User, organization: Organization | null, { loginMethod }: { loginMethod: 'password' | 'email' | 'sso' }): Promise<SecondFactorRequirement> {
105
150
  if (await TwoFactorHelper.userHasFactors(user.id)) {
106
151
  return { type: 'challenge', challenge: await TwoFactorHelper.createLoginChallenge(user) };
107
152
  }
@@ -111,17 +156,43 @@ export class TwoFactorHelper {
111
156
  }
112
157
 
113
158
  if (await TwoFactorHelper.isTwoFactorRequired(user, organization)) {
159
+ if (loginMethod === 'password' && TwoFactorHelper.isInactiveForEnrollment(user)) {
160
+ return { type: 'confirm-email' };
161
+ }
114
162
  return { type: 'setup', setupToken: await MFAToken.createFor(user.id, 'setup') };
115
163
  }
116
164
 
117
165
  return { type: 'none' };
118
166
  }
119
167
 
168
+ /**
169
+ * Send the password recovery link a long-inactive admin needs to get back to enrolling
170
+ * their second factor. Never throws: the login is blocked either way, and turning a
171
+ * mail failure into a 500 would only hide why the user cannot sign in.
172
+ */
173
+ static async sendEnrollmentConfirmationEmail(user: User, organization: Organization | null, i18n: I18n): Promise<void> {
174
+ try {
175
+ enrollmentConfirmationRateLimiter.track(user.id);
176
+ }
177
+ catch {
178
+ // Sent often enough already.
179
+ return;
180
+ }
181
+
182
+ try {
183
+ await PasswordForgotService.sendPasswordRecoveryEmail(user, organization, i18n);
184
+ }
185
+ catch (e) {
186
+ console.error('Could not send the two-factor enrollment confirmation email', e);
187
+ }
188
+ }
189
+
120
190
  /**
121
191
  * Enforce the second-factor / forced-enrollment step after a successful primary
122
192
  * authentication (password login, password-reset token, email verification). Throws a
123
- * `require_mfa` or `require_mfa_setup` error when the user must still complete a second
124
- * factor before a session may be issued; returns normally when the login may proceed.
193
+ * `require_mfa`, `require_mfa_setup` or `require_email_confirmation` error when the user
194
+ * must still do something before a session may be issued; returns normally when the
195
+ * login may proceed. The last one also emails the user the link they need to get past it.
125
196
  *
126
197
  * This MUST be called from every path that mints a full session from a single primary
127
198
  * credential, otherwise that path becomes an MFA bypass. The SSO callback is a redirect
@@ -132,8 +203,19 @@ export class TwoFactorHelper {
132
203
  * whoever holds the link could enroll one and get a session anyway, and the client
133
204
  * needs a session to let the user choose a password before enrolling.
134
205
  */
135
- static async assertSecondFactorOrThrow(user: User, organization: Organization | null, version: number, { allowTemporarySession = false }: { allowTemporarySession?: boolean } = {}): Promise<void> {
136
- const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'password' });
206
+ static async assertSecondFactorOrThrow(user: User, organization: Organization | null, version: number, { loginMethod, i18n, allowTemporarySession = false }: { loginMethod: 'password' | 'email'; i18n: I18n; allowTemporarySession?: boolean }): Promise<void> {
207
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod });
208
+
209
+ if (requirement.type === 'confirm-email') {
210
+ await TwoFactorHelper.sendEnrollmentConfirmationEmail(user, organization, i18n);
211
+
212
+ throw new SimpleError({
213
+ code: 'require_email_confirmation',
214
+ message: 'Email confirmation required before two-factor authentication setup',
215
+ human: $t('%Zjm', { days: INACTIVE_ADMIN_ENROLLMENT_DAYS.toString() }),
216
+ statusCode: 403,
217
+ });
218
+ }
137
219
 
138
220
  if (requirement.type === 'challenge') {
139
221
  throw new SimpleError({