@stamhoofd/backend 2.137.5 → 2.138.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +20 -17
- package/src/boot.ts +5 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/delete-expired-mfa-tokens.ts +35 -0
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +2 -1
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +133 -6
- package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
- package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
- package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -1
- package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
- package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
- package/src/endpoints/auth/MFA.security.test.ts +688 -0
- package/src/endpoints/auth/MFA.test.ts +1398 -0
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
- package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +13 -2
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +3 -2
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +3 -2
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +2 -1
- package/src/endpoints/global/files/UploadFile.ts +1 -1
- package/src/endpoints/global/files/UploadImage.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/helpers/AuthenticatedStructures.ts +9 -1
- package/src/helpers/Context.ts +114 -4
- package/src/helpers/EmailBuilder.test.ts +287 -0
- package/src/helpers/EmailBuilder.ts +811 -0
- package/src/helpers/EmailResumer.ts +2 -1
- package/src/helpers/ForwardHandler.ts +2 -1
- package/src/helpers/MFAEncryption.ts +34 -0
- package/src/helpers/RecoveryCodeHelper.ts +81 -0
- package/src/helpers/TOTPHelper.ts +79 -0
- package/src/helpers/TenantContext.test.ts +96 -0
- package/src/helpers/TenantContext.ts +67 -0
- package/src/helpers/TwoFactorHelper.ts +356 -0
- package/src/helpers/WebauthnHelper.test.ts +144 -0
- package/src/helpers/WebauthnHelper.ts +239 -0
- package/src/helpers/data/aaguids.json +1006 -0
- package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
- package/src/middleware/TenantScopeMiddleware.ts +24 -0
- package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
- package/src/services/AdminSessionService.ts +41 -0
- package/src/services/EmailPreviewService.ts +1 -1
- package/src/services/EmailSendService.test.ts +1218 -0
- package/src/services/EmailSendService.ts +705 -0
- package/src/services/EventNotificationService.ts +2 -1
- package/src/services/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.ts +2 -1
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationEmailService.ts +3 -2
- package/src/services/PaymentService.ts +5 -3
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +2 -1
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.ts +4 -2
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.ts +2 -1
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- package/tests/helpers/MFATestHelper.ts +42 -0
- package/tests/helpers/TestServer.ts +4 -0
- package/tests/helpers/index.ts +1 -0
- package/tsconfig.build.json +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.138.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -64,21 +64,23 @@
|
|
|
64
64
|
"@simonbackx/simple-endpoints": "1.22.0",
|
|
65
65
|
"@simonbackx/simple-errors": "1.5.0",
|
|
66
66
|
"@simonbackx/simple-logging": "1.0.1",
|
|
67
|
-
"@
|
|
68
|
-
"@stamhoofd/backend-
|
|
69
|
-
"@stamhoofd/backend-
|
|
70
|
-
"@stamhoofd/
|
|
71
|
-
"@stamhoofd/
|
|
72
|
-
"@stamhoofd/
|
|
73
|
-
"@stamhoofd/
|
|
74
|
-
"@stamhoofd/
|
|
75
|
-
"@stamhoofd/
|
|
76
|
-
"@stamhoofd/
|
|
77
|
-
"@stamhoofd/
|
|
78
|
-
"@stamhoofd/
|
|
79
|
-
"@stamhoofd/
|
|
80
|
-
"@stamhoofd/
|
|
67
|
+
"@simplewebauthn/server": "13.3.2",
|
|
68
|
+
"@stamhoofd/backend-env": "2.138.1",
|
|
69
|
+
"@stamhoofd/backend-i18n": "2.138.1",
|
|
70
|
+
"@stamhoofd/backend-middleware": "2.138.1",
|
|
71
|
+
"@stamhoofd/crons": "2.138.1",
|
|
72
|
+
"@stamhoofd/email": "2.138.1",
|
|
73
|
+
"@stamhoofd/excel-writer": "2.138.1",
|
|
74
|
+
"@stamhoofd/logging": "2.138.1",
|
|
75
|
+
"@stamhoofd/models": "2.138.1",
|
|
76
|
+
"@stamhoofd/object-differ": "2.138.1",
|
|
77
|
+
"@stamhoofd/queues": "2.138.1",
|
|
78
|
+
"@stamhoofd/sql": "2.138.1",
|
|
79
|
+
"@stamhoofd/structures": "2.138.1",
|
|
80
|
+
"@stamhoofd/types": "2.138.1",
|
|
81
|
+
"@stamhoofd/utility": "2.138.1",
|
|
81
82
|
"archiver": "7.0.1",
|
|
83
|
+
"argon2": "0.44.0",
|
|
82
84
|
"axios": "1.18.1",
|
|
83
85
|
"base-x": "3.0.11",
|
|
84
86
|
"chalk": "5.6.2",
|
|
@@ -95,11 +97,12 @@
|
|
|
95
97
|
"mysql2": "3.23.1",
|
|
96
98
|
"node-rsa": "2.0.0",
|
|
97
99
|
"openid-client": "5.7.1",
|
|
100
|
+
"otplib": "12.0.1",
|
|
98
101
|
"stripe": "16.12.0",
|
|
99
102
|
"uuid": "14.0.1"
|
|
100
103
|
},
|
|
101
104
|
"devDependencies": {
|
|
102
|
-
"@stamhoofd/test-utils": "2.
|
|
105
|
+
"@stamhoofd/test-utils": "2.138.1",
|
|
103
106
|
"@types/cookie": "0.6.0",
|
|
104
107
|
"@types/luxon": "3.7.2",
|
|
105
108
|
"@types/mailparser": "3.4.6",
|
|
@@ -114,5 +117,5 @@
|
|
|
114
117
|
"publishConfig": {
|
|
115
118
|
"access": "public"
|
|
116
119
|
},
|
|
117
|
-
"gitHead": "
|
|
120
|
+
"gitHead": "ea8e0dcbc22e86969883fa09e920931896110781"
|
|
118
121
|
}
|
package/src/boot.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { resumeEmails } from './helpers/EmailResumer.js';
|
|
|
18
18
|
import { GlobalHelper } from './helpers/GlobalHelper.js';
|
|
19
19
|
import { SetupStepUpdater } from './helpers/SetupStepUpdater.js';
|
|
20
20
|
import { ContextMiddleware } from './middleware/ContextMiddleware.js';
|
|
21
|
+
import { TenantScopeMiddleware } from './middleware/TenantScopeMiddleware.js';
|
|
21
22
|
import { AuditLogService } from './services/AuditLogService.js';
|
|
22
23
|
import { BalanceItemService } from './services/BalanceItemService.js';
|
|
23
24
|
import { BootChecksService } from './services/BootChecksService.js';
|
|
@@ -137,6 +138,10 @@ export const boot = async (options: { killProcess: boolean }) => {
|
|
|
137
138
|
// Contexts
|
|
138
139
|
routerServer.addRequestMiddleware(ContextMiddleware);
|
|
139
140
|
|
|
141
|
+
// After ContextMiddleware on purpose: the last registered wrapRun is the outermost, and the
|
|
142
|
+
// tenant has to be in scope before the context that reads it.
|
|
143
|
+
routerServer.addRequestMiddleware(TenantScopeMiddleware);
|
|
144
|
+
|
|
140
145
|
// Add version headers and minimum version
|
|
141
146
|
const versionMiddleware = new VersionMiddleware({
|
|
142
147
|
latestVersions: {
|
|
@@ -6,6 +6,7 @@ import type { OrganizationEmail, StamhoofdFilter } from '@stamhoofd/structures';
|
|
|
6
6
|
import { EmailRecipientFilter, EmailRecipientSubfilter, EmailTemplateType, ReceivableBalanceType } from '@stamhoofd/structures';
|
|
7
7
|
import { ContextInstance } from '../helpers/Context.js';
|
|
8
8
|
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
9
|
+
import { EmailSendService } from '../services/EmailSendService.js';
|
|
9
10
|
|
|
10
11
|
registerCron('balanceEmails', balanceEmails);
|
|
11
12
|
|
|
@@ -201,7 +202,7 @@ async function sendTemplate({
|
|
|
201
202
|
|
|
202
203
|
try {
|
|
203
204
|
const upToDate = await ContextInstance.startForUser(systemUser, organization, async () => {
|
|
204
|
-
return await
|
|
205
|
+
return await EmailSendService.queueForSending(model, true);
|
|
205
206
|
});
|
|
206
207
|
|
|
207
208
|
if (!upToDate) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { registerCron } from '@stamhoofd/crons';
|
|
2
|
+
import { MFAToken, WebauthnChallenge } from '@stamhoofd/models';
|
|
3
|
+
|
|
4
|
+
let lastRunDate: number | null = null;
|
|
5
|
+
|
|
6
|
+
registerCron('deleteExpiredMFATokens', deleteExpiredMFATokens);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sweep the short-lived tokens of the two-factor flows.
|
|
10
|
+
*
|
|
11
|
+
* Both are also cleaned up while they are used (a new challenge supersedes the previous
|
|
12
|
+
* one, and an expired token is dropped when it is looked up), but a token of a login that
|
|
13
|
+
* was simply abandoned has nothing left to trigger that. Run every night at 5 AM.
|
|
14
|
+
*/
|
|
15
|
+
export async function deleteExpiredMFATokens() {
|
|
16
|
+
const now = new Date();
|
|
17
|
+
|
|
18
|
+
if (now.getDate() === lastRunDate) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hour = now.getHours();
|
|
23
|
+
|
|
24
|
+
// between 5 and 6 AM
|
|
25
|
+
if (hour !== 5 && STAMHOOFD.environment !== 'development') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tokens = await MFAToken.deleteExpired();
|
|
30
|
+
const challenges = await WebauthnChallenge.deleteExpired();
|
|
31
|
+
|
|
32
|
+
console.log(`Deleted ${tokens} expired MFA tokens and ${challenges} expired WebAuthn challenges.`);
|
|
33
|
+
|
|
34
|
+
lastRunDate = now.getDate();
|
|
35
|
+
}
|
package/src/crons/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import './update-cached-balances.js';
|
|
|
5
5
|
import './cleanup-orphaned-cached-balances.js';
|
|
6
6
|
import './balance-emails.js';
|
|
7
7
|
import './delete-old-email-drafts.js';
|
|
8
|
+
import './delete-expired-mfa-tokens.js';
|
|
8
9
|
import './delete-archived-data.js';
|
|
9
10
|
import './mollie-chargebacks.js';
|
|
10
11
|
import './mollie-refunds.js';
|
package/src/crons/invoices.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { isSimpleError, isSimpleErrors } from '@simonbackx/simple-errors';
|
|
2
2
|
import { registerCron } from '@stamhoofd/crons';
|
|
3
3
|
import type { Invoice } from '@stamhoofd/models';
|
|
4
|
-
import { Organization, Payment
|
|
4
|
+
import { Organization, Payment } from '@stamhoofd/models';
|
|
5
|
+
import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
|
|
5
6
|
import { EmailTemplateType, InvoiceStruct, PaymentStatus, Replacement } from '@stamhoofd/structures';
|
|
6
7
|
import { Formatter, Sorter } from '@stamhoofd/utility';
|
|
7
8
|
import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
|
|
8
9
|
import { InvoiceService } from '../services/InvoiceService.js';
|
|
9
10
|
import { useSavedIterator } from './helpers/useSavedIterator.js';
|
|
10
11
|
import { isOutside } from './helpers/isOutside.js';
|
|
12
|
+
import { OrganizationAdminService } from '../services/OrganizationAdminService.js';
|
|
11
13
|
|
|
12
14
|
registerCron('invoices', invoices);
|
|
13
15
|
|
|
@@ -50,7 +52,7 @@ async function createInvoicesFor(organization: Organization) {
|
|
|
50
52
|
|
|
51
53
|
// Belgian rules: allowed to invoice up to the 15th day of the next month. We extend it with one month to fix mistakes.
|
|
52
54
|
const today = Formatter.luxon();
|
|
53
|
-
const startDate = today.day <= 15 ? today.minus({ month:
|
|
55
|
+
const startDate = today.day <= 15 ? today.minus({ month: 3 }).startOf('month') : today.minus({ month: 2 }).startOf('month');
|
|
54
56
|
|
|
55
57
|
// Don't invoice below 4 euro - unless we reached the timeout date for invoices (end of month + 15 days - 5 days margin) OR + 15 day offset
|
|
56
58
|
const invoiceLimit = STAMHOOFD.environment === 'development' ? 0 : 4_0000;
|
|
@@ -147,7 +149,7 @@ async function createInvoicesFor(organization: Organization) {
|
|
|
147
149
|
template: {
|
|
148
150
|
type: EmailTemplateType.InvoiceGenerationErrors,
|
|
149
151
|
},
|
|
150
|
-
recipients: await
|
|
152
|
+
recipients: await OrganizationAdminService.getAdminRecipients(organization),
|
|
151
153
|
type: 'transactional',
|
|
152
154
|
fromStamhoofd: true,
|
|
153
155
|
defaultReplacements: [
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Email,
|
|
1
|
+
import { Email, Webshop } from '@stamhoofd/models';
|
|
2
|
+
import { runWithRecipientLocale } from '../helpers/EmailBuilder.js';
|
|
2
3
|
import type { EmailRecipient, LimitedFilteredRequest, WebshopPreview } from '@stamhoofd/structures';
|
|
3
4
|
import { mergeFilters, PaginatedResponse } from '@stamhoofd/structures';
|
|
4
5
|
import { GetWebshopOrdersEndpoint } from '../endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.js';
|
|
@@ -6,6 +6,7 @@ import { baseInMemoryFilterCompilers, compileToInMemoryFilter, createInMemoryFil
|
|
|
6
6
|
import { PaginatedResponse } from '@stamhoofd/structures';
|
|
7
7
|
import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
|
|
8
8
|
import { GetOrganizationsEndpoint } from '../endpoints/admin/organizations/GetOrganizationsEndpoint.js';
|
|
9
|
+
import { OrganizationAdminService } from '../services/OrganizationAdminService.js';
|
|
9
10
|
|
|
10
11
|
function userToFilterableAdmin(user: User, platform: PlatformStruct, organization: Organization) {
|
|
11
12
|
return {
|
|
@@ -49,14 +50,14 @@ async function fetchRecipients(query: LimitedFilteredRequest, subfilter: Stamhoo
|
|
|
49
50
|
const recipients: EmailRecipient[] = [];
|
|
50
51
|
for (const organization of result.results) {
|
|
51
52
|
// todo: filter admins
|
|
52
|
-
const users = await
|
|
53
|
+
const users = await OrganizationAdminService.getAdmins(organization);
|
|
53
54
|
const filteredUsers = users.filter((user) => {
|
|
54
55
|
const filterable = userToFilterableAdmin(user, platform, organization);
|
|
55
56
|
return compiledFilter(filterable);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
recipients.push(
|
|
59
|
-
...
|
|
60
|
+
...OrganizationAdminService.adminsToRecipients(filteredUsers).map((r) => {
|
|
60
61
|
return EmailRecipient.create({
|
|
61
62
|
...r,
|
|
62
63
|
replacements: [
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Decoder } from '@simonbackx/simple-encoding';
|
|
2
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
5
|
+
import { MFATOTP } from '@stamhoofd/models';
|
|
6
|
+
import type { MFAEnrollmentResult } from '@stamhoofd/structures';
|
|
7
|
+
import { MFAMethodType, TOTPConfirmRequest } from '@stamhoofd/structures';
|
|
8
|
+
|
|
9
|
+
import { Context } from '../../helpers/Context.js';
|
|
10
|
+
import { TOTPHelper } from '../../helpers/TOTPHelper.js';
|
|
11
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
12
|
+
import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
|
|
13
|
+
|
|
14
|
+
type Params = { id: string };
|
|
15
|
+
type Query = undefined;
|
|
16
|
+
type Body = TOTPConfirmRequest;
|
|
17
|
+
type ResponseBody = MFAEnrollmentResult;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Confirm a pending TOTP authenticator by verifying the first code. On the user's first
|
|
21
|
+
* factor this also returns recovery codes, and during forced enrollment it issues a
|
|
22
|
+
* full session token.
|
|
23
|
+
*/
|
|
24
|
+
export class ConfirmTOTPEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
25
|
+
bodyDecoder = TOTPConfirmRequest as Decoder<TOTPConfirmRequest>;
|
|
26
|
+
|
|
27
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
28
|
+
if (request.method !== 'POST') {
|
|
29
|
+
return [false];
|
|
30
|
+
}
|
|
31
|
+
const params = Endpoint.parseParameters(request.url, '/mfa/totp/@id/confirm', { id: String });
|
|
32
|
+
if (params) {
|
|
33
|
+
return [true, params as Params];
|
|
34
|
+
}
|
|
35
|
+
return [false];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
39
|
+
await Context.setOptionalOrganizationScope();
|
|
40
|
+
const { user, setupToken, token } = await Context.authenticateMFAEnrollment();
|
|
41
|
+
|
|
42
|
+
const totp = await MFATOTP.getByID(request.params.id);
|
|
43
|
+
if (!totp || totp.userId !== user.id) {
|
|
44
|
+
throw new SimpleError({
|
|
45
|
+
code: 'not_found',
|
|
46
|
+
message: 'TOTP authenticator not found',
|
|
47
|
+
human: $t('%ZgJ'),
|
|
48
|
+
statusCode: 404,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (totp.confirmedAt) {
|
|
53
|
+
throw new SimpleError({
|
|
54
|
+
code: 'already_confirmed',
|
|
55
|
+
message: 'This authenticator is already confirmed',
|
|
56
|
+
human: $t('%ZgM'),
|
|
57
|
+
statusCode: 400,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const counter = TOTPHelper.verify(request.body.code, totp.secret);
|
|
62
|
+
if (counter === null) {
|
|
63
|
+
throw new SimpleError({
|
|
64
|
+
code: 'invalid_mfa_code',
|
|
65
|
+
message: 'Invalid code',
|
|
66
|
+
human: $t('%Zgp'),
|
|
67
|
+
field: 'code',
|
|
68
|
+
statusCode: 400,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const wasFirstFactor = !(await TwoFactorHelper.userHasFactors(user.id));
|
|
73
|
+
|
|
74
|
+
totp.confirmedAt = new Date();
|
|
75
|
+
totp.name = request.body.name.trim() || $t('%ZiG');
|
|
76
|
+
// Record the counter so the confirmation code can't be replayed as a login code.
|
|
77
|
+
totp.lastUsedCounter = counter;
|
|
78
|
+
await totp.save();
|
|
79
|
+
|
|
80
|
+
await TwoFactorAuditLogService.logMethodAdded(user, MFAMethodType.TOTP, totp.name);
|
|
81
|
+
|
|
82
|
+
return new Response(await TwoFactorHelper.completeEnrollment(user, setupToken, wasFirstFactor, token));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -2,7 +2,8 @@ import type { Decoder } from '@simonbackx/simple-encoding';
|
|
|
2
2
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
3
3
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
5
|
-
import { Platform,
|
|
5
|
+
import { Platform, User } from '@stamhoofd/models';
|
|
6
|
+
import { sendEmailTemplate } from '../../helpers/EmailBuilder.js';
|
|
6
7
|
import type { UserWithMembers } from '@stamhoofd/structures';
|
|
7
8
|
import { EmailTemplateType, Recipient, Replacement, UserPermissions, User as UserStruct } from '@stamhoofd/structures';
|
|
8
9
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
-
import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
2
|
+
import { OrganizationFactory, Token, User, UserFactory } from '@stamhoofd/models';
|
|
3
3
|
import { Token as TokenStruct } from '@stamhoofd/structures';
|
|
4
4
|
|
|
5
5
|
import { testServer } from '../../../tests/helpers/TestServer.js';
|
|
@@ -65,4 +65,64 @@ describe('Endpoint.CreateToken', () => {
|
|
|
65
65
|
const byRefresh = await Token.getByRefreshToken(response.body.refreshToken);
|
|
66
66
|
expect(byRefresh).toBeDefined();
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
describe('last activity', () => {
|
|
70
|
+
const password = 'test-password-1234';
|
|
71
|
+
|
|
72
|
+
async function getLastActiveAt(user: User): Promise<Date | null> {
|
|
73
|
+
const updated = await User.getByID(user.id);
|
|
74
|
+
return updated!.lastActiveAt;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test('a password login marks the user as active', async () => {
|
|
78
|
+
const organization = await new OrganizationFactory({}).create();
|
|
79
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
80
|
+
expect(user.lastActiveAt).toBeNull();
|
|
81
|
+
|
|
82
|
+
const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
|
|
83
|
+
grant_type: 'password',
|
|
84
|
+
username: user.email,
|
|
85
|
+
password,
|
|
86
|
+
});
|
|
87
|
+
await testServer.test(endpoint, r);
|
|
88
|
+
|
|
89
|
+
const lastActiveAt = await getLastActiveAt(user);
|
|
90
|
+
expect(lastActiveAt).not.toBeNull();
|
|
91
|
+
expect(lastActiveAt!.getTime()).toBeGreaterThan(Date.now() - 60 * 1000);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('refreshing a token marks the user as active again', async () => {
|
|
95
|
+
const organization = await new OrganizationFactory({}).create();
|
|
96
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
97
|
+
const token = await Token.createToken(user);
|
|
98
|
+
|
|
99
|
+
const monthsAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
100
|
+
monthsAgo.setMilliseconds(0);
|
|
101
|
+
user.lastActiveAt = monthsAgo;
|
|
102
|
+
await user.save();
|
|
103
|
+
|
|
104
|
+
const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
|
|
105
|
+
grant_type: 'refresh_token',
|
|
106
|
+
refresh_token: token.refreshToken,
|
|
107
|
+
});
|
|
108
|
+
await testServer.test(endpoint, r);
|
|
109
|
+
|
|
110
|
+
const lastActiveAt = await getLastActiveAt(user);
|
|
111
|
+
expect(lastActiveAt!.getTime()).toBeGreaterThan(Date.now() - 60 * 1000);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a failed login does not mark the user as active', async () => {
|
|
115
|
+
const organization = await new OrganizationFactory({}).create();
|
|
116
|
+
const user = await new UserFactory({ organization, password }).create();
|
|
117
|
+
|
|
118
|
+
const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
|
|
119
|
+
grant_type: 'password',
|
|
120
|
+
username: user.email,
|
|
121
|
+
password: 'wrong-password',
|
|
122
|
+
});
|
|
123
|
+
await expect(testServer.test(endpoint, r)).rejects.toThrow();
|
|
124
|
+
|
|
125
|
+
expect(await getLastActiveAt(user)).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
68
128
|
});
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
3
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
|
-
import { EmailVerificationCode, PasswordToken, Platform, Token, User } from '@stamhoofd/models';
|
|
5
|
-
import type { ChallengeGrantStruct, PasswordGrantStruct, PasswordTokenGrantStruct, RefreshTokenGrantStruct, RequestChallengeGrantStruct } from '@stamhoofd/structures';
|
|
6
|
-
import { CreateTokenStruct, LoginMethod, SignupResponse, Token as TokenStruct } from '@stamhoofd/structures';
|
|
4
|
+
import { EmailVerificationCode, MFATOTP, MFAToken, PasswordToken, Platform, Token, User, WebauthnCredential } from '@stamhoofd/models';
|
|
5
|
+
import type { ChallengeGrantStruct, MFAGrantStruct, PasswordGrantStruct, PasswordTokenGrantStruct, RefreshTokenGrantStruct, RequestChallengeGrantStruct } from '@stamhoofd/structures';
|
|
6
|
+
import { CreateTokenStruct, LoginMethod, MFAMethodType, SignupResponse, Token as TokenStruct } from '@stamhoofd/structures';
|
|
7
7
|
|
|
8
8
|
import { Context } from '../../helpers/Context.js';
|
|
9
|
+
import { RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
|
|
10
|
+
import { TOTPHelper } from '../../helpers/TOTPHelper.js';
|
|
11
|
+
import { mfaVerificationRateLimiter, TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
12
|
+
import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
|
|
9
13
|
import { VerificationCodeService } from '../../services/VerificationCodeService.js';
|
|
10
14
|
|
|
11
15
|
type Params = Record<string, never>;
|
|
12
16
|
type Query = undefined;
|
|
13
|
-
type Body = RequestChallengeGrantStruct | ChallengeGrantStruct | RefreshTokenGrantStruct | PasswordTokenGrantStruct | PasswordGrantStruct;
|
|
17
|
+
type Body = RequestChallengeGrantStruct | ChallengeGrantStruct | RefreshTokenGrantStruct | PasswordTokenGrantStruct | PasswordGrantStruct | MFAGrantStruct;
|
|
14
18
|
type ResponseBody = TokenStruct;
|
|
15
19
|
|
|
16
20
|
export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
@@ -82,6 +86,8 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
82
86
|
});
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
await oldToken.user.markActive();
|
|
90
|
+
|
|
85
91
|
const st = new TokenStruct(token);
|
|
86
92
|
return new Response(st);
|
|
87
93
|
}
|
|
@@ -141,7 +147,12 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
141
147
|
});
|
|
142
148
|
}
|
|
143
149
|
|
|
144
|
-
|
|
150
|
+
// Second factor / forced enrollment: block the session until the user
|
|
151
|
+
// completes (or first sets up) a second factor. Shared with every other
|
|
152
|
+
// grant that mints a session from a single primary credential.
|
|
153
|
+
await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion());
|
|
154
|
+
|
|
155
|
+
const token = await Token.createToken(user, new Date());
|
|
145
156
|
|
|
146
157
|
if (!token) {
|
|
147
158
|
throw new SimpleError({
|
|
@@ -152,6 +163,110 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
152
163
|
});
|
|
153
164
|
}
|
|
154
165
|
|
|
166
|
+
await user.markActive();
|
|
167
|
+
|
|
168
|
+
const st = new TokenStruct(token);
|
|
169
|
+
return new Response(st);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'mfa': {
|
|
173
|
+
const mfaToken = await MFAToken.getValid(request.body.mfaToken, 'login');
|
|
174
|
+
if (!mfaToken) {
|
|
175
|
+
throw new SimpleError({
|
|
176
|
+
code: 'invalid_mfa_token',
|
|
177
|
+
message: 'The MFA session is invalid or expired',
|
|
178
|
+
human: $t('%ZhJ'),
|
|
179
|
+
statusCode: 400,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const user = await User.getByID(mfaToken.userId);
|
|
184
|
+
if (!user) {
|
|
185
|
+
await mfaToken.consume();
|
|
186
|
+
throw new SimpleError({
|
|
187
|
+
code: 'invalid_mfa_token',
|
|
188
|
+
message: 'The MFA session is invalid or expired',
|
|
189
|
+
human: $t('%ZhJ'),
|
|
190
|
+
statusCode: 400,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Scope check (same rules as refresh/password_token)
|
|
195
|
+
if ((organization && user.organizationId && user.organizationId !== organization.id) || (!organization && user.organizationId)) {
|
|
196
|
+
throw new SimpleError({
|
|
197
|
+
code: 'invalid_mfa_token',
|
|
198
|
+
message: 'The MFA session is invalid or expired',
|
|
199
|
+
human: $t('%ZhJ'),
|
|
200
|
+
statusCode: 400,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Aggregate brute-force protection across ALL of this user's MFA tokens
|
|
205
|
+
// (the per-token counter alone can be bypassed by minting new tokens).
|
|
206
|
+
// This MUST run before verification so that, once the limit is hit, even a
|
|
207
|
+
// correct code is rejected — otherwise a lucky/leaked code would still let
|
|
208
|
+
// an attacker through after unlimited guesses.
|
|
209
|
+
mfaVerificationRateLimiter.track(user.id);
|
|
210
|
+
|
|
211
|
+
let verified = false;
|
|
212
|
+
if (request.body.method === MFAMethodType.TOTP) {
|
|
213
|
+
const code = request.body.code ?? '';
|
|
214
|
+
for (const totp of await MFATOTP.getConfirmedForUser(user.id)) {
|
|
215
|
+
const counter = TOTPHelper.verify(code, totp.secret);
|
|
216
|
+
if (counter === null) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Claiming the step counter both rejects a still-valid code that was
|
|
220
|
+
// accepted before (replay) and settles two requests that submit the
|
|
221
|
+
// same code at the same time: only one of them gets the claim.
|
|
222
|
+
if (await totp.claimCounter(counter)) {
|
|
223
|
+
verified = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} else if (request.body.method === MFAMethodType.RecoveryCode) {
|
|
228
|
+
verified = await RecoveryCodeHelper.consume(user.id, request.body.code ?? '');
|
|
229
|
+
} else if (request.body.method === MFAMethodType.Passkey) {
|
|
230
|
+
const assertion = request.body.assertion;
|
|
231
|
+
const credentialId = assertion?.id;
|
|
232
|
+
if (mfaToken.webauthnChallenge && assertion && credentialId) {
|
|
233
|
+
const credential = await WebauthnCredential.getByCredentialId(credentialId);
|
|
234
|
+
if (credential && credential.userId === user.id) {
|
|
235
|
+
const newCounter = await WebauthnHelper.verifyAuthentication(assertion, mfaToken.webauthnChallenge, credential);
|
|
236
|
+
if (newCounter !== null) {
|
|
237
|
+
credential.counter = newCounter;
|
|
238
|
+
credential.lastUsedAt = new Date();
|
|
239
|
+
await credential.save();
|
|
240
|
+
verified = true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!verified) {
|
|
247
|
+
const locked = await mfaToken.registerFailedAttempt();
|
|
248
|
+
throw new SimpleError({
|
|
249
|
+
code: locked ? 'too_many_attempts' : 'invalid_mfa_code',
|
|
250
|
+
message: locked ? 'Too many attempts' : 'Invalid code',
|
|
251
|
+
human: locked ? $t('%ZhT') : $t('%Zgp'),
|
|
252
|
+
statusCode: locked ? 429 : 400,
|
|
253
|
+
field: 'code',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!await mfaToken.consume()) {
|
|
258
|
+
// Another request already completed this challenge.
|
|
259
|
+
throw new SimpleError({
|
|
260
|
+
code: 'invalid_mfa_token',
|
|
261
|
+
message: 'The MFA session is invalid or expired',
|
|
262
|
+
human: $t('%ZhJ'),
|
|
263
|
+
statusCode: 400,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const token = await Token.createToken(user, new Date());
|
|
268
|
+
await user.markActive();
|
|
269
|
+
|
|
155
270
|
const st = new TokenStruct(token);
|
|
156
271
|
return new Response(st);
|
|
157
272
|
}
|
|
@@ -188,8 +303,18 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
188
303
|
});
|
|
189
304
|
}
|
|
190
305
|
|
|
306
|
+
// A valid password-reset/magic-link token is a single primary credential:
|
|
307
|
+
// enforce the same second-factor gate as the password grant, otherwise
|
|
308
|
+
// anyone with a reset link (i.e. email access) could bypass the user's 2FA
|
|
309
|
+
// and even wipe their factors with the resulting fresh session.
|
|
310
|
+
//
|
|
311
|
+
// Forced enrollment (the user has no factor yet) additionally returns a
|
|
312
|
+
// temporary session token: this flow is also used to choose a first
|
|
313
|
+
// password (invites), which the client can only do with a session.
|
|
314
|
+
await TwoFactorHelper.assertSecondFactorOrThrow(passwordToken.user, organization, request.request.getVersion(), { allowTemporarySession: true });
|
|
315
|
+
|
|
191
316
|
// Important to create a new token before adjusting the old token
|
|
192
|
-
const token = await Token.createToken(passwordToken.user);
|
|
317
|
+
const token = await Token.createToken(passwordToken.user, new Date());
|
|
193
318
|
|
|
194
319
|
// TODO: make token short lived until renewal
|
|
195
320
|
|
|
@@ -211,6 +336,8 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
211
336
|
await token.user.save();
|
|
212
337
|
}
|
|
213
338
|
|
|
339
|
+
await token.user.markActive();
|
|
340
|
+
|
|
214
341
|
const st = new TokenStruct(token);
|
|
215
342
|
return new Response(st);
|
|
216
343
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
|
+
import { MFATOTP, WebauthnCredential } from '@stamhoofd/models';
|
|
5
|
+
import type { MFAStatus } from '@stamhoofd/structures';
|
|
6
|
+
import { MFAMethodType } from '@stamhoofd/structures';
|
|
7
|
+
|
|
8
|
+
import { Context } from '../../helpers/Context.js';
|
|
9
|
+
import { TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
|
|
10
|
+
import { TwoFactorAuditLogService } from '../../services/TwoFactorAuditLogService.js';
|
|
11
|
+
|
|
12
|
+
type Params = { id: string };
|
|
13
|
+
type Query = undefined;
|
|
14
|
+
type Body = undefined;
|
|
15
|
+
type ResponseBody = MFAStatus;
|
|
16
|
+
|
|
17
|
+
export class DeletePasskeyEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
18
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
19
|
+
if (request.method !== 'DELETE') {
|
|
20
|
+
return [false];
|
|
21
|
+
}
|
|
22
|
+
const params = Endpoint.parseParameters(request.url, '/mfa/passkeys/@id', { id: String });
|
|
23
|
+
if (params) {
|
|
24
|
+
return [true, params as Params];
|
|
25
|
+
}
|
|
26
|
+
return [false];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
30
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
31
|
+
const { user, token } = await Context.authenticateFresh({ allowWithoutAccount: true, allowUnscoped: true });
|
|
32
|
+
|
|
33
|
+
const credential = await WebauthnCredential.getByID(request.params.id);
|
|
34
|
+
if (!credential || credential.userId !== user.id) {
|
|
35
|
+
throw new SimpleError({
|
|
36
|
+
code: 'not_found',
|
|
37
|
+
message: 'Passkey not found',
|
|
38
|
+
human: $t('%Zho'),
|
|
39
|
+
statusCode: 404,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const confirmedTotp = await MFATOTP.getConfirmedForUser(user.id);
|
|
44
|
+
const passkeys = await WebauthnCredential.getForUser(user.id);
|
|
45
|
+
const remaining = confirmedTotp.length + passkeys.filter(p => p.id !== credential.id).length;
|
|
46
|
+
if (remaining === 0 && await TwoFactorHelper.isTwoFactorRequired(user, organization ?? Context.organization ?? null)) {
|
|
47
|
+
throw new SimpleError({
|
|
48
|
+
code: 'cannot_remove_last_factor',
|
|
49
|
+
message: 'Cannot remove the last two-factor method',
|
|
50
|
+
human: $t('%Zh4'),
|
|
51
|
+
statusCode: 400,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await credential.delete();
|
|
56
|
+
|
|
57
|
+
await TwoFactorAuditLogService.logMethodDeleted(user, MFAMethodType.Passkey, credential.name || credential.providerName || '');
|
|
58
|
+
|
|
59
|
+
return new Response(await TwoFactorHelper.completeRemoval(user, token));
|
|
60
|
+
}
|
|
61
|
+
}
|