@stamhoofd/backend 2.144.0 → 2.145.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 (99) hide show
  1. package/package.json +19 -19
  2. package/src/crons/mollie-chargebacks.ts +31 -29
  3. package/src/crons/mollie-refunds.ts +61 -59
  4. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +16 -5
  5. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +210 -4
  6. package/src/endpoints/auth/CreateTokenEndpoint.ts +9 -20
  7. package/src/endpoints/auth/DeleteTokenEndpoint.test.ts +29 -0
  8. package/src/endpoints/auth/DeleteTokenEndpoint.ts +2 -1
  9. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -11
  10. package/src/endpoints/auth/MFA.security.test.ts +9 -7
  11. package/src/endpoints/auth/MFA.test.ts +42 -15
  12. package/src/endpoints/auth/PatchUserEndpoint.ts +3 -2
  13. package/src/endpoints/auth/VerifyEmailEndpoint.ts +5 -4
  14. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +4 -3
  15. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +6 -5
  16. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +5 -4
  17. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +5 -4
  18. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +2 -1
  19. package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +2 -1
  20. package/src/endpoints/global/files/UploadFile.test.ts +2 -1
  21. package/src/endpoints/global/files/UploadImage.test.ts +2 -1
  22. package/src/endpoints/global/files/upload-security.test.ts +3 -2
  23. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +4 -3
  24. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +26 -25
  25. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +57 -56
  26. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +4 -3
  27. package/src/endpoints/global/platform/GetPlatformEndpoint.test.ts +6 -4
  28. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +6 -6
  29. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +9 -8
  30. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +14 -13
  31. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +24 -23
  32. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +4 -3
  33. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +2 -1
  34. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +2 -1
  35. package/src/endpoints/global/webshops/GetWebshopsEndpoint.test.ts +20 -19
  36. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +2 -1
  37. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +2 -1
  38. package/src/endpoints/organization/dashboard/billing/ApplyRegisterCodeEndpoint.test.ts +3 -2
  39. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +4 -3
  40. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +5 -4
  41. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +5 -4
  42. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +2 -1
  43. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.test.ts +2 -1
  44. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +0 -1
  45. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +23 -22
  46. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +4 -4
  47. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +2 -1
  48. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +4 -3
  49. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +2 -2
  50. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +2 -1
  51. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +5 -4
  52. package/src/endpoints/organization/dashboard/registration-periods/MoveRegistrationPeriods.test.ts +7 -6
  53. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +9 -8
  54. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +3 -2
  55. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +2 -1
  56. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +5 -4
  57. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +2 -1
  58. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +3 -2
  59. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +6 -5
  60. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +16 -15
  61. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +2 -1
  62. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +2 -1
  63. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +2 -1
  64. package/src/endpoints/organization/dashboard/webshops/WebshopReservedPathSegments.test.ts +2 -1
  65. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +4 -3
  66. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +6 -5
  67. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +94 -8
  68. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +18 -18
  69. package/src/helpers/Context.ts +13 -6
  70. package/src/helpers/ContextStorage.ts +5 -0
  71. package/src/helpers/StripeSettlementSync.ts +10 -7
  72. package/src/helpers/TwoFactorHelper.ts +21 -22
  73. package/src/services/AdminSessionService.ts +5 -3
  74. package/src/services/InvoiceService.ts +2 -2
  75. package/src/services/MollieService.ts +168 -126
  76. package/src/services/PaymentService.ts +2 -2
  77. package/src/services/SSOService.test.ts +25 -1
  78. package/src/services/SSOService.ts +17 -6
  79. package/src/services/SessionService.test.ts +319 -0
  80. package/src/services/SessionService.ts +357 -0
  81. package/src/services/WebshopCrowdfundingService.test.ts +4 -3
  82. package/src/sql-filters/registrations.ts +59 -1
  83. package/tests/actions/patchUserMember.ts +2 -1
  84. package/tests/e2e/api-rate-limits.test.ts +2 -1
  85. package/tests/e2e/bundle-discounts.test.ts +4 -3
  86. package/tests/e2e/charge-members.test.ts +6 -5
  87. package/tests/e2e/documents.test.ts +4 -3
  88. package/tests/e2e/private-files.test.ts +9 -8
  89. package/tests/e2e/register.test.ts +4 -3
  90. package/tests/e2e/stock.test.ts +4 -3
  91. package/tests/e2e/tickets.test.ts +4 -3
  92. package/tests/helpers/ExportSlice.ts +2 -1
  93. package/tests/helpers/MollieMocker.ts +85 -23
  94. package/tests/init/initAdmin.ts +2 -1
  95. package/tests/init/initPlatformAdmin.ts +2 -1
  96. package/src/helpers/ViesHelper.test.ts +0 -401
  97. package/src/helpers/ViesHelper.ts +0 -222
  98. package/src/services/PeppolDirectoryService.test.ts +0 -139
  99. package/src/services/PeppolDirectoryService.ts +0 -111
@@ -0,0 +1,357 @@
1
+ import { Database } from '@simonbackx/simple-database';
2
+ import { SimpleError } from '@simonbackx/simple-errors';
3
+ import { Token, User, UserSession } from '@stamhoofd/models';
4
+ import type { SessionDurations, SessionType } from '@stamhoofd/models/constants/sessions.js';
5
+ import { ACCESS_TOKEN_DURATION, SESSION_DURATIONS, SSO_HANDOFF_TOKEN_DURATION } from '@stamhoofd/models/constants/sessions.js';
6
+ import { SQLWhereSign } from '@stamhoofd/sql';
7
+ import { SessionClientType, SessionDeviceType, SessionLoginMethod, SessionOS } from '@stamhoofd/structures';
8
+ import type { SessionMetaData } from '@stamhoofd/structures';
9
+ import crypto from 'crypto';
10
+ import { v4 as uuidv4 } from 'uuid';
11
+
12
+ import { contextStorage } from '../helpers/ContextStorage.js';
13
+
14
+ const DAY = 24 * 60 * 60 * 1000;
15
+ const MAX_SESSIONS = 15;
16
+ const API_TOKEN_DURATION = 5 * 365 * DAY;
17
+
18
+ async function randomBytes(size: number): Promise<Buffer> {
19
+ return await new Promise((resolve, reject) => {
20
+ crypto.randomBytes(size, (error, buffer) => error ? reject(error) : resolve(buffer));
21
+ });
22
+ }
23
+
24
+ export class SessionService {
25
+ /**
26
+ * @param authenticatedAt Pass the current date when a primary credential was verified
27
+ * as part of this request, so the session counts as "fresh" for sensitive actions.
28
+ */
29
+ static async createSession<U extends User>(user: U, { loginMethod = SessionLoginMethod.Password, authenticatedAt = null }: { loginMethod?: SessionLoginMethod; authenticatedAt?: Date | null } = {}): Promise<Token & { user: U }> {
30
+ const tokenId = uuidv4();
31
+ const session = await this.createUserSession(user, {
32
+ tokenId,
33
+ clientType: this.getClientType(),
34
+ loginMethod,
35
+ metaData: this.getMetaData(),
36
+ });
37
+ const token = await this.createToken(user, { session, tokenId });
38
+ token.authenticatedAt = authenticatedAt;
39
+ this.applyLimits({ token, session, user });
40
+ await token.save();
41
+ return token;
42
+ }
43
+
44
+ /**
45
+ * Used by the SSO callback: that is a browser redirect, so the only way to hand the
46
+ * session to the client is in the URL. Only the refresh token travels along, and the
47
+ * client exchanges it for a usable access token.
48
+ */
49
+ static async createSSOHandoff<U extends User>(user: U, { clientType = this.getClientType(), metaData = this.getMetaData() }: { clientType?: SessionClientType; metaData?: SessionMetaData } = {}): Promise<Token & { user: U }> {
50
+ const tokenId = uuidv4();
51
+ const session = await this.createUserSession(user, { tokenId, clientType, loginMethod: SessionLoginMethod.SSO, metaData });
52
+ const token = await this.createToken(user, { session, tokenId });
53
+
54
+ token.accessTokenValidUntil = new Date(Date.now() - 31 * DAY);
55
+ token.accessTokenValidUntil.setMilliseconds(0);
56
+ this.applyLimits({ token, session, user });
57
+ token.refreshTokenValidUntil = new Date(Math.min(token.refreshTokenValidUntil.getTime(), Date.now() + SSO_HANDOFF_TOKEN_DURATION));
58
+ token.refreshTokenValidUntil.setMilliseconds(0);
59
+ await token.save();
60
+ return token;
61
+ }
62
+
63
+ static async createApiSession<U extends User>(user: U): Promise<Token & { user: U }> {
64
+ const tokenId = uuidv4();
65
+ const session = await this.createUserSession(user, {
66
+ tokenId,
67
+ clientType: SessionClientType.Browser,
68
+ loginMethod: SessionLoginMethod.Password,
69
+ metaData: this.getFallbackMetaData(),
70
+ });
71
+ const token = await this.createToken(user, { session, tokenId });
72
+ token.accessTokenValidUntil = new Date(Date.now() + API_TOKEN_DURATION);
73
+ token.accessTokenValidUntil.setMilliseconds(0);
74
+ token.refreshTokenValidUntil = new Date(token.accessTokenValidUntil);
75
+ await token.save();
76
+ return token;
77
+ }
78
+
79
+ /**
80
+ * The new token continues the session of the old one, so it keeps its start date and
81
+ * cannot outlive it. Using the session is what postpones the inactivity limit.
82
+ */
83
+ static async rotateSession<U extends User>(oldToken: Token & { user: U }): Promise<Token & { user: U }> {
84
+ const session = await UserSession.getByID(oldToken.sessionId);
85
+ if (!session || session.userId !== oldToken.userId) {
86
+ throw this.invalidRefreshTokenError();
87
+ }
88
+
89
+ const durations = this.getDurations(session, oldToken.user);
90
+ if (durations.session !== null && session.startedAt.getTime() + durations.session <= Date.now()) {
91
+ await oldToken.delete();
92
+ throw this.invalidRefreshTokenError();
93
+ }
94
+
95
+ const isSSOHandoff = this.isSSOHandoff(oldToken, session);
96
+ const token = await this.createToken(oldToken.user, { session });
97
+
98
+ this.applyLimits({ token, session, user: oldToken.user });
99
+ await token.save();
100
+
101
+ const didRotate = isSSOHandoff
102
+ ? await this.consumeSSOHandoff({ sessionId: session.id, handoffTokenId: oldToken.id, tokenId: token.id })
103
+ : await this.replaceLastUsedToken({ sessionId: session.id, previousTokenId: oldToken.id, tokenId: token.id });
104
+ if (!didRotate) {
105
+ await token.delete();
106
+ throw this.invalidRefreshTokenError();
107
+ }
108
+
109
+ const metaData = this.getMetaData();
110
+ session.appVersion = metaData.appVersion ?? session.appVersion;
111
+ session.nativeAppVersion = metaData.nativeAppVersion ?? session.nativeAppVersion;
112
+ session.osVersion = metaData.osVersion ?? session.osVersion;
113
+ await session.save();
114
+ return token;
115
+ }
116
+
117
+ private static isSSOHandoff(token: Token, session: UserSession): boolean {
118
+ // Handoff access tokens expire before they are created; normal access tokens do not.
119
+ return session.loginMethod === SessionLoginMethod.SSO && token.accessTokenValidUntil < token.createdAt;
120
+ }
121
+
122
+ static async getByRefreshToken(refreshToken: string) {
123
+ const token = await Token.getByRefreshToken(refreshToken);
124
+ if (!token || token.user.isApiUser) {
125
+ return undefined;
126
+ }
127
+ if (token.refreshTokenValidUntil < new Date()) {
128
+ console.error('Detected an expired refresh token, deleting all sessions and tokens for user', token.userId);
129
+ await UserSession.delete().where('userId', token.userId).delete();
130
+ await Token.delete().where('userId', token.userId).delete();
131
+ return undefined;
132
+ }
133
+ return token;
134
+ }
135
+
136
+ static async activateToken(token: Token): Promise<boolean> {
137
+ const [result] = await Database.update(
138
+ 'UPDATE `user_sessions` SET `lastActiveTokenId` = ? WHERE `id` = ? AND `lastUsedTokenId` = ?',
139
+ [token.id, token.sessionId, token.id],
140
+ );
141
+ if (result.affectedRows > 0) {
142
+ await Database.delete('DELETE FROM `tokens` WHERE `sessionId` = ? AND `id` != ?', [token.sessionId, token.id]);
143
+ return true;
144
+ }
145
+
146
+ const [sessions] = await Database.select('SELECT `id` FROM `user_sessions` WHERE `id` = ? AND `lastActiveTokenId` = ? LIMIT 1', [token.sessionId, token.id]);
147
+ return sessions.length > 0;
148
+ }
149
+
150
+ static async endSession(token: Token): Promise<void> {
151
+ await UserSession.delete().where('id', token.sessionId).delete();
152
+ }
153
+
154
+ static async deleteOtherSessions({ userId, keepAccessToken }: { userId: string; keepAccessToken: string | null }): Promise<number> {
155
+ const user = await User.getByID(userId);
156
+ if (user?.isApiUser) {
157
+ return 0;
158
+ }
159
+ return await this.deleteForUsers({ userIds: [userId], keepAccessToken });
160
+ }
161
+
162
+ static async deleteForUsers({ userIds, keepAccessToken }: { userIds: string[]; keepAccessToken: string | null }): Promise<number> {
163
+ if (userIds.length === 0) {
164
+ return 0;
165
+ }
166
+ const query = Token.delete().where('userId', userIds);
167
+ if (keepAccessToken) {
168
+ query.where(Token.primary.name, SQLWhereSign.NotEqual, keepAccessToken);
169
+ }
170
+ const { affectedRows } = await query.delete();
171
+ return affectedRows;
172
+ }
173
+
174
+ static async clearFor({ userId, keepAccessToken }: { userId: string; keepAccessToken: string }): Promise<void> {
175
+ await Database.delete('DELETE FROM `tokens` WHERE `userId` = ? AND `accessToken` != ?', [userId, keepAccessToken]);
176
+ }
177
+
178
+ private static invalidRefreshTokenError() {
179
+ return new SimpleError({
180
+ code: 'invalid_refresh_token',
181
+ message: 'Invalid refresh token',
182
+ statusCode: 400,
183
+ });
184
+ }
185
+
186
+ private static async createToken<U extends User>(user: U, { session, tokenId = uuidv4() }: { session: UserSession; tokenId?: string }): Promise<Token & { user: U }> {
187
+ if (user.isSystemUser) {
188
+ throw new SimpleError({
189
+ code: 'internal_error',
190
+ message: 'Cannot create token for system user',
191
+ statusCode: 500,
192
+ });
193
+ }
194
+
195
+ const token = new Token().setRelation(Token.user, user);
196
+ token.id = tokenId;
197
+ token.sessionId = session.id;
198
+ token.accessTokenValidUntil = new Date(Date.now() + ACCESS_TOKEN_DURATION);
199
+ token.accessTokenValidUntil.setMilliseconds(0);
200
+ token.refreshTokenValidUntil = new Date(token.accessTokenValidUntil);
201
+ token.accessToken = (await randomBytes(192)).toString('base64').toUpperCase();
202
+ token.refreshToken = (await randomBytes(192)).toString('base64').toUpperCase();
203
+ return token;
204
+ }
205
+
206
+ private static async createUserSession(user: User, { tokenId, clientType, loginMethod, metaData }: { tokenId: string; clientType: SessionClientType; loginMethod: SessionLoginMethod; metaData: SessionMetaData }): Promise<UserSession> {
207
+ const session = new UserSession();
208
+ session.id = uuidv4();
209
+ session.userId = user.id;
210
+ session.clientType = clientType;
211
+ session.loginMethod = loginMethod;
212
+ session.startedAt = new Date();
213
+ session.startedAt.setMilliseconds(0);
214
+ session.lastUsedTokenId = session.lastActiveTokenId = tokenId;
215
+ this.applyMetaData(session, metaData);
216
+
217
+ await Database.delete(
218
+ 'DELETE FROM `user_sessions` WHERE `id` IN (SELECT `id` FROM (SELECT `id` FROM `user_sessions` WHERE `userId` = ? ORDER BY `startedAt` DESC LIMIT ? OFFSET ?) old_sessions)',
219
+ [user.id, MAX_SESSIONS - 1, MAX_SESSIONS - 1],
220
+ );
221
+ await session.save();
222
+ return session;
223
+ }
224
+
225
+ private static applyMetaData(session: UserSession, metaData: SessionMetaData): void {
226
+ session.deviceType = metaData.deviceType;
227
+ session.deviceName = metaData.deviceName;
228
+ session.osName = metaData.osName;
229
+ session.osVersion = metaData.osVersion;
230
+ session.appVersion = metaData.appVersion;
231
+ session.nativeAppVersion = metaData.nativeAppVersion;
232
+ session.browserName = metaData.browserName;
233
+ }
234
+
235
+ private static async replaceLastUsedToken({ sessionId, previousTokenId, tokenId }: { sessionId: string; previousTokenId: string; tokenId: string }): Promise<boolean> {
236
+ const [result] = await Database.update(
237
+ 'UPDATE `user_sessions` SET `lastUsedTokenId` = ? WHERE `id` = ? AND (`lastUsedTokenId` = ? OR `lastActiveTokenId` = ?)',
238
+ [tokenId, sessionId, previousTokenId, previousTokenId],
239
+ );
240
+ if (result.affectedRows === 0) {
241
+ return false;
242
+ }
243
+ await Database.delete('DELETE FROM `tokens` WHERE `sessionId` = ? AND `id` != ? AND `id` != (SELECT `lastActiveTokenId` FROM `user_sessions` WHERE `id` = ?)', [sessionId, tokenId, sessionId]);
244
+ return true;
245
+ }
246
+
247
+ private static async consumeSSOHandoff({ sessionId, handoffTokenId, tokenId }: { sessionId: string; handoffTokenId: string; tokenId: string }): Promise<boolean> {
248
+ const [result] = await Database.update(
249
+ 'UPDATE `user_sessions` SET `lastUsedTokenId` = ? WHERE `id` = ? AND `lastUsedTokenId` = ?',
250
+ [tokenId, sessionId, handoffTokenId],
251
+ );
252
+ if (result.affectedRows === 0) {
253
+ return false;
254
+ }
255
+ await Database.delete('DELETE FROM `tokens` WHERE `sessionId` = ? AND `id` != ?', [sessionId, tokenId]);
256
+ return true;
257
+ }
258
+
259
+ /**
260
+ * Only read when a session is created: it is a client provided header, and a rotation
261
+ * has to keep the value of the login instead of trusting the header again.
262
+ */
263
+ private static getClientType(): SessionClientType {
264
+ const platform = contextStorage.getStore()?.request.headers['x-platform'];
265
+ if (platform === 'ios') {
266
+ return SessionClientType.iOS;
267
+ }
268
+ if (platform === 'android') {
269
+ return SessionClientType.Android;
270
+ }
271
+ return SessionClientType.Browser;
272
+ }
273
+
274
+ /**
275
+ * Recalculated on every rotation, so a user that becomes an administrator during a
276
+ * session is limited from that moment on (counted from their login, as if they had the
277
+ * permissions all along).
278
+ */
279
+ private static getDurations(session: UserSession, user: User): SessionDurations {
280
+ return SESSION_DURATIONS[this.getSessionType(session, user)][session.clientType];
281
+ }
282
+
283
+ private static getSessionType(session: UserSession, user: User): SessionType {
284
+ // Before the permissions: whoever signs in through the identity provider stays
285
+ // dependent on it, member or administrator.
286
+ if (session.loginMethod === SessionLoginMethod.SSO) {
287
+ return 'sso';
288
+ }
289
+
290
+ const permissions = user.permissions;
291
+ if (!permissions || permissions.isEmpty) {
292
+ return 'user';
293
+ }
294
+
295
+ if (permissions.globalPermissions !== null) {
296
+ return 'platformAdmin';
297
+ }
298
+
299
+ return 'admin';
300
+ }
301
+
302
+ private static applyLimits({ token, session, user }: { token: Token; session: UserSession; user: User }) {
303
+ const durations = this.getDurations(session, user);
304
+ const sessionEndsAt = durations.session !== null ? (session.startedAt.getTime() + durations.session) : (Date.now() + durations.refreshToken);
305
+
306
+ token.refreshTokenValidUntil = new Date(Math.min(Date.now() + durations.refreshToken, sessionEndsAt));
307
+ token.refreshTokenValidUntil.setMilliseconds(0);
308
+
309
+ token.accessTokenValidUntil = new Date(Math.min(token.accessTokenValidUntil.getTime(), sessionEndsAt));
310
+ token.accessTokenValidUntil.setMilliseconds(0);
311
+ }
312
+
313
+ static getMetaData(): SessionMetaData {
314
+ const value = contextStorage.getStore()?.request.headers['x-session-metadata'];
315
+ return this.parseMetaData(typeof value === 'string' ? value : null);
316
+ }
317
+
318
+ static parseMetaData(value: string | null): SessionMetaData {
319
+ const fallback = this.getFallbackMetaData();
320
+ if (typeof value !== 'string' || value.length > 4096) {
321
+ return fallback;
322
+ }
323
+
324
+ try {
325
+ let parsed: Partial<SessionMetaData>;
326
+ try {
327
+ parsed = JSON.parse(value) as Partial<SessionMetaData>;
328
+ } catch {
329
+ parsed = JSON.parse(decodeURIComponent(value)) as Partial<SessionMetaData>;
330
+ }
331
+ const string = (candidate: unknown, max: number): string | null => typeof candidate === 'string' && candidate.length > 0 ? candidate.slice(0, max) : null;
332
+ return {
333
+ deviceType: Object.values(SessionDeviceType).includes(parsed.deviceType as SessionDeviceType) ? parsed.deviceType as SessionDeviceType : fallback.deviceType,
334
+ deviceName: string(parsed.deviceName, 255),
335
+ osName: Object.values(SessionOS).includes(parsed.osName as SessionOS) ? parsed.osName as SessionOS : null,
336
+ osVersion: string(parsed.osVersion, 64),
337
+ appVersion: string(parsed.appVersion, 64),
338
+ nativeAppVersion: string(parsed.nativeAppVersion, 64),
339
+ browserName: string(parsed.browserName, 64),
340
+ };
341
+ } catch {
342
+ return fallback;
343
+ }
344
+ }
345
+
346
+ private static getFallbackMetaData(): SessionMetaData {
347
+ return {
348
+ deviceType: SessionDeviceType.Desktop,
349
+ deviceName: null,
350
+ osName: null,
351
+ osVersion: null,
352
+ appVersion: null,
353
+ nativeAppVersion: null,
354
+ browserName: null,
355
+ };
356
+ }
357
+ }
@@ -1,11 +1,12 @@
1
1
  import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
2
2
  import { PatchableArray } from '@simonbackx/simple-encoding';
3
3
  import { Request } from '@simonbackx/simple-endpoints';
4
- import type { Organization, StripeAccount } from '@stamhoofd/models';
5
- import { BalanceItem, BalanceItemFactory, BalanceItemPayment, Order, OrderFactory, OrganizationFactory, Payment, Token, UserFactory, Webshop, WebshopFactory } from '@stamhoofd/models';
4
+ import type { Organization, StripeAccount, Token } from '@stamhoofd/models';
5
+ import { BalanceItem, BalanceItemFactory, BalanceItemPayment, Order, OrderFactory, OrganizationFactory, Payment, UserFactory, Webshop, WebshopFactory } from '@stamhoofd/models';
6
6
  import type { OrderResponse } from '@stamhoofd/structures';
7
7
  import { BalanceItemStatus, Cart, CartItem, Customer, OrderData, OrderStatus, PaymentConfiguration, PaymentMethod, PaymentStatus, PermissionLevel, Permissions, PrivateOrder, PrivatePaymentConfiguration, PrivateWebshop, Product, ProductPrice, TransferSettings, WebshopCrowdfunding, WebshopMetaData, WebshopPrivateMetaData } from '@stamhoofd/structures';
8
8
  import { v4 as uuidv4 } from 'uuid';
9
+ import { SessionService } from './SessionService.js';
9
10
 
10
11
  import type { StripeObject } from '../../tests/helpers/StripeMocker.js';
11
12
  import { StripeMocker } from '../../tests/helpers/StripeMocker.js';
@@ -234,7 +235,7 @@ describe('WebshopCrowdfundingService', () => {
234
235
  level: PermissionLevel.Full,
235
236
  }),
236
237
  }).create();
237
- token = await Token.createToken(user);
238
+ token = await SessionService.createSession(user);
238
239
  });
239
240
 
240
241
  afterAll(() => {
@@ -1,6 +1,6 @@
1
1
  import { Group, Member, Organization, Registration } from '@stamhoofd/models';
2
2
  import type { SQLFilterDefinitions } from '@stamhoofd/sql';
3
- import { baseSQLFilterCompilers, createColumnFilter, createJoinedRelationFilter, SQL, SQLValueType } from '@stamhoofd/sql';
3
+ import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, createJoinedRelationFilter, SQL, SQLValueType } from '@stamhoofd/sql';
4
4
  import { SQLTranslatedString } from '../helpers/SQLTranslatedString.js';
5
5
  import { baseRegistrationFilterCompilers } from './base-registration-filter-compilers.js';
6
6
  import { memberFilterCompilers } from './members.js';
@@ -55,6 +55,64 @@ export const registrationFilterCompilers: SQLFilterDefinitions = {
55
55
  }),
56
56
  },
57
57
  ),
58
+ /**
59
+ * There is a bug in MySQL 8 that is fixed in 9.3
60
+ * where EXISTS (select * from json_table(...)) does not work
61
+ * To fix this, we do a double select with join inside the select
62
+ * It is a bit slower, but it works for now.
63
+ */
64
+ options: createExistsFilter(
65
+ SQL.select()
66
+ .from(SQL.table('registrations', 'innerRegistrations'))
67
+ .join(
68
+ SQL.join(
69
+ SQL.jsonTable(
70
+ SQL.jsonExtract(SQL.column('innerRegistrations', 'options'), '$.value'),
71
+ 'options',
72
+ )
73
+ .addColumn(
74
+ 'amount',
75
+ 'INT',
76
+ '$.amount',
77
+ )
78
+ .addColumn(
79
+ 'optionId',
80
+ 'TEXT',
81
+ '$.option.id',
82
+ )
83
+ .addColumn(
84
+ 'optionMenuId',
85
+ 'TEXT',
86
+ '$.optionMenu.id',
87
+ ),
88
+ ),
89
+ )
90
+ .where(SQL.column('innerRegistrations', 'id'), SQL.column('registrations', 'id')),
91
+ {
92
+ ...baseSQLFilterCompilers,
93
+ amount: createColumnFilter({
94
+ expression: SQL.column('options', 'amount'),
95
+ type: SQLValueType.Number,
96
+ nullable: false,
97
+ }),
98
+ option: {
99
+ ...baseSQLFilterCompilers,
100
+ id: createColumnFilter({
101
+ expression: SQL.column('options', 'optionId'),
102
+ type: SQLValueType.String,
103
+ nullable: false,
104
+ }),
105
+ },
106
+ optionMenu: {
107
+ ...baseSQLFilterCompilers,
108
+ id: createColumnFilter({
109
+ expression: SQL.column('options', 'optionMenuId'),
110
+ type: SQLValueType.String,
111
+ nullable: false,
112
+ }),
113
+ },
114
+ },
115
+ ),
58
116
  organization: createJoinedRelationFilter(
59
117
  organizationJoin,
60
118
  organizationFilterCompilers,
@@ -6,11 +6,12 @@ import { Token } from '@stamhoofd/models';
6
6
  import type { MemberWithRegistrationsBlob } from '@stamhoofd/structures';
7
7
  import { PatchUserMembersEndpoint } from '../../src/endpoints/global/registration/PatchUserMembersEndpoint.js';
8
8
  import { testServer } from '../helpers/TestServer.js';
9
+ import { SessionService } from '../../src/services/SessionService.js';
9
10
 
10
11
  export async function patchUserMember({ patch, organization, user }: { patch: AutoEncoderPatchType<MemberWithRegistrationsBlob>; organization: Organization; user: User }) {
11
12
  expect(patch.id).toEqual(expect.any(String));
12
13
 
13
- const token = await Token.createToken(user);
14
+ const token = await SessionService.createSession(user);
14
15
 
15
16
  const arr = new PatchableArray();
16
17
  arr.addPatch(patch);
@@ -1,6 +1,7 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import type { Organization } from '@stamhoofd/models';
3
3
  import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
4
+ import { SessionService } from '../../src/services/SessionService.js';
4
5
 
5
6
  import { ApiUser, ApiUserRateLimits, PermissionLevel, Permissions, UserMeta, UserPermissions } from '@stamhoofd/structures';
6
7
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
@@ -29,7 +30,7 @@ describe('E2E.APIRateLimits', () => {
29
30
  level: PermissionLevel.Full,
30
31
  }),
31
32
  }).create();
32
- const token = await Token.createToken(user, new Date());
33
+ const token = await SessionService.createSession(user, { authenticatedAt: new Date() });
33
34
 
34
35
  const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
35
36
  permissions: UserPermissions.create({
@@ -1,6 +1,6 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
- import type { BalanceItem, Organization, RegistrationPeriod } from '@stamhoofd/models';
3
- import { BalanceItemFactory, GroupFactory, MemberFactory, OrganizationFactory, OrganizationRegistrationPeriodFactory, Registration, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
2
+ import type { BalanceItem, Organization, RegistrationPeriod, Token } from '@stamhoofd/models';
3
+ import { BalanceItemFactory, GroupFactory, MemberFactory, OrganizationFactory, OrganizationRegistrationPeriodFactory, Registration, RegistrationFactory, RegistrationPeriodFactory, UserFactory } from '@stamhoofd/models';
4
4
  import { AccessRight, AppliedRegistrationDiscount, BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItemType, BooleanStatus, GroupPriceDiscount, GroupPriceDiscountType, IDRegisterCart, IDRegisterCheckout, IDRegisterItem, PaymentMethod, PermissionLevel, Permissions, PermissionsResourceType, ReduceablePrice, ResourcePermissions } from '@stamhoofd/structures';
5
5
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
6
6
  import { RegisterMembersEndpoint } from '../../src/endpoints/global/registration/RegisterMembersEndpoint.js';
@@ -12,6 +12,7 @@ import { initBundleDiscount } from '../init/initBundleDiscount.js';
12
12
  import { initPermissionRole } from '../init/initPermissionRole.js';
13
13
  import { initStripe } from '../init/initStripe.js';
14
14
  import { initMembershipOrganization } from '../init/initMembershipOrganization.js';
15
+ import { SessionService } from '../../src/services/SessionService.js';
15
16
 
16
17
  const baseUrl = `/members/register`;
17
18
 
@@ -64,7 +65,7 @@ describe('E2E.Bundle Discounts', () => {
64
65
  permissions: null,
65
66
  }).create();
66
67
 
67
- const token = await Token.createToken(user);
68
+ const token = await SessionService.createSession(user);
68
69
 
69
70
  const member = await new MemberFactory({ organization, user })
70
71
  .create();
@@ -1,12 +1,13 @@
1
1
  import { Request, Response } from '@simonbackx/simple-endpoints';
2
- import type { Organization, RegistrationPeriod } from '@stamhoofd/models';
3
- import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
2
+ import type { Organization, RegistrationPeriod, Token } from '@stamhoofd/models';
3
+ import { GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationPeriodFactory, UserFactory } from '@stamhoofd/models';
4
4
  import type { BalanceItemWithPayments, StamhoofdFilter } from '@stamhoofd/structures';
5
5
  import { AccessRight, ChargeRequest, LimitedFilteredRequest, PermissionLevel, PermissionRoleDetailed, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
6
6
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
7
7
  import { ChargeMembersEndpoint } from '../../src/endpoints/admin/members/ChargeMembersEndpoint.js';
8
8
  import { testServer } from '../helpers/TestServer.js';
9
9
  import { GetReceivableBalanceEndpoint } from '../../src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.js';
10
+ import { SessionService } from '../../src/services/SessionService.js';
10
11
 
11
12
  describe('E2E.ChargeMembers', () => {
12
13
  const chargeMembersEndpoint = new ChargeMembersEndpoint();
@@ -45,7 +46,7 @@ describe('E2E.ChargeMembers', () => {
45
46
  })
46
47
  .create();
47
48
 
48
- const token = await Token.createToken(user);
49
+ const token = await SessionService.createSession(user);
49
50
 
50
51
  return { organization, user, token };
51
52
  };
@@ -148,7 +149,7 @@ describe('E2E.ChargeMembers', () => {
148
149
  for (const userFactory of testUserFactories) {
149
150
  const user = await userFactory.create();
150
151
 
151
- const token = await Token.createToken(user);
152
+ const token = await SessionService.createSession(user);
152
153
 
153
154
  await expect(postCharge(filter, organization, body, token))
154
155
  .rejects
@@ -234,7 +235,7 @@ describe('E2E.ChargeMembers', () => {
234
235
  })
235
236
  .create();
236
237
 
237
- const otherFinancialDirectorToken = await Token.createToken(otherFinancialDirector);
238
+ const otherFinancialDirectorToken = await SessionService.createSession(otherFinancialDirector);
238
239
 
239
240
  const body = ChargeRequest.create({
240
241
  description: 'test description',
@@ -1,6 +1,6 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
- import type { Group, Member, Organization, RegistrationPeriod } from '@stamhoofd/models';
3
- import { Document, DocumentTemplateFactory, GroupFactory, MemberFactory, OrganizationFactory, OrganizationRegistrationPeriodFactory, Registration, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
2
+ import type { Group, Member, Organization, RegistrationPeriod, Token } from '@stamhoofd/models';
3
+ import { Document, DocumentTemplateFactory, GroupFactory, MemberFactory, OrganizationFactory, OrganizationRegistrationPeriodFactory, Registration, RegistrationPeriodFactory, UserFactory } from '@stamhoofd/models';
4
4
  import { IDRegisterCart, IDRegisterCheckout, IDRegisterItem, MemberDetails, MemberWithRegistrationsBlob, PaymentMethod } from '@stamhoofd/structures';
5
5
  import { TestUtils } from '@stamhoofd/test-utils';
6
6
  import { Formatter } from '@stamhoofd/utility';
@@ -14,6 +14,7 @@ import { initStripe } from '../init/initStripe.js';
14
14
  import { registrationUpdateQueue } from '../../src/services/BalanceItemService.js';
15
15
  import { DocumentRenderService } from '../../src/services/DocumentRenderService.js';
16
16
  import { initMembershipOrganization } from '../init/initMembershipOrganization.js';
17
+ import { SessionService } from '../../src/services/SessionService.js';
17
18
 
18
19
  const baseUrl = `/members/register`;
19
20
 
@@ -73,7 +74,7 @@ describe('E2E.Documents', () => {
73
74
  permissions: null,
74
75
  }).create();
75
76
 
76
- const token = await Token.createToken(user);
77
+ const token = await SessionService.createSession(user);
77
78
 
78
79
  const member = await new MemberFactory({ organization, user })
79
80
  .create();
@@ -1,4 +1,5 @@
1
1
  import { File, MemberDetails, MemberWithRegistrationsBlob, RecordCategory, RecordFileAnswer, RecordSettings, RecordType, TranslatedString, Version } from '@stamhoofd/structures';
2
+ import { SessionService } from '../../src/services/SessionService.js';
2
3
 
3
4
  import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
4
5
  import { encodeObject, EncodeMedium, PatchableArray } from '@simonbackx/simple-encoding';
@@ -48,7 +49,7 @@ describe('E2E.PrivateFiles', () => {
48
49
  level: PermissionLevel.Full,
49
50
  }),
50
51
  }).create();
51
- const token = await Token.createToken(user);
52
+ const token = await SessionService.createSession(user);
52
53
 
53
54
  // Create a user member
54
55
  const member = MemberWithRegistrationsBlob.create({
@@ -91,7 +92,7 @@ describe('E2E.PrivateFiles', () => {
91
92
  level: PermissionLevel.Full,
92
93
  }),
93
94
  }).create();
94
- const token = await Token.createToken(user);
95
+ const token = await SessionService.createSession(user);
95
96
 
96
97
  // Create a user member
97
98
  const member = MemberWithRegistrationsBlob.create({
@@ -135,7 +136,7 @@ describe('E2E.PrivateFiles', () => {
135
136
  level: PermissionLevel.Full,
136
137
  }),
137
138
  }).create();
138
- const token = await Token.createToken(user);
139
+ const token = await SessionService.createSession(user);
139
140
 
140
141
  // Create a user member
141
142
  const member = MemberWithRegistrationsBlob.create({
@@ -197,7 +198,7 @@ describe('E2E.PrivateFiles', () => {
197
198
  level: PermissionLevel.Full,
198
199
  }),
199
200
  }).create();
200
- const token = await Token.createToken(user);
201
+ const token = await SessionService.createSession(user);
201
202
 
202
203
  // Public files are not signed, so a malicious user can put anything in them. The url of a file ends up
203
204
  // in a link (and in the source of an image), so a javascript: url would run on our own domain.
@@ -260,7 +261,7 @@ describe('E2E.PrivateFiles', () => {
260
261
  level: PermissionLevel.Full,
261
262
  }),
262
263
  }).create();
263
- const token = await Token.createToken(user);
264
+ const token = await SessionService.createSession(user);
264
265
 
265
266
  // Create a user member
266
267
  const member = MemberWithRegistrationsBlob.create({
@@ -324,7 +325,7 @@ describe('E2E.PrivateFiles', () => {
324
325
  level: PermissionLevel.Full,
325
326
  }),
326
327
  }).create();
327
- const token = await Token.createToken(user);
328
+ const token = await SessionService.createSession(user);
328
329
 
329
330
  // Create a user member
330
331
  const member = MemberWithRegistrationsBlob.create({
@@ -404,7 +405,7 @@ describe('E2E.PrivateFiles', () => {
404
405
  level: PermissionLevel.Full,
405
406
  }),
406
407
  }).create();
407
- const token = await Token.createToken(user);
408
+ const token = await SessionService.createSession(user);
408
409
 
409
410
  // Create a user member
410
411
  const member = MemberWithRegistrationsBlob.create({
@@ -662,7 +663,7 @@ describe('E2E.PrivateFiles', () => {
662
663
  level: PermissionLevel.Full,
663
664
  }),
664
665
  }).create();
665
- const token = await Token.createToken(user);
666
+ const token = await SessionService.createSession(user);
666
667
 
667
668
  // Create a user member
668
669
  const member = MemberWithRegistrationsBlob.create({