@stamhoofd/backend 2.71.0 → 2.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.71.0",
3
+ "version": "2.72.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -37,14 +37,14 @@
37
37
  "@simonbackx/simple-encoding": "2.19.0",
38
38
  "@simonbackx/simple-endpoints": "1.15.0",
39
39
  "@simonbackx/simple-logging": "^1.0.1",
40
- "@stamhoofd/backend-i18n": "2.71.0",
41
- "@stamhoofd/backend-middleware": "2.71.0",
42
- "@stamhoofd/email": "2.71.0",
43
- "@stamhoofd/models": "2.71.0",
44
- "@stamhoofd/queues": "2.71.0",
45
- "@stamhoofd/sql": "2.71.0",
46
- "@stamhoofd/structures": "2.71.0",
47
- "@stamhoofd/utility": "2.71.0",
40
+ "@stamhoofd/backend-i18n": "2.72.0",
41
+ "@stamhoofd/backend-middleware": "2.72.0",
42
+ "@stamhoofd/email": "2.72.0",
43
+ "@stamhoofd/models": "2.72.0",
44
+ "@stamhoofd/queues": "2.72.0",
45
+ "@stamhoofd/sql": "2.72.0",
46
+ "@stamhoofd/structures": "2.72.0",
47
+ "@stamhoofd/utility": "2.72.0",
48
48
  "archiver": "^7.0.1",
49
49
  "aws-sdk": "^2.885.0",
50
50
  "axios": "1.6.8",
@@ -64,5 +64,5 @@
64
64
  "publishConfig": {
65
65
  "access": "public"
66
66
  },
67
- "gitHead": "8b35d36e694303905aa27cf9bff5539777d6ace1"
67
+ "gitHead": "9ca2f0fff00eafff2a398dbfdbfb032367473d1c"
68
68
  }
@@ -3,7 +3,7 @@ import { AuditLogType } from '@stamhoofd/structures';
3
3
  import { getDefaultGenerator, ModelLogger } from './ModelLogger';
4
4
 
5
5
  export const OrganizationLogger = new ModelLogger(Organization, {
6
- skipKeys: ['searchIndex'],
6
+ skipKeys: ['searchIndex', 'serverMeta'],
7
7
  optionsGenerator: getDefaultGenerator({
8
8
  created: AuditLogType.OrganizationAdded,
9
9
  updated: AuditLogType.OrganizationEdited,
@@ -3,6 +3,7 @@ import { getDefaultGenerator, ModelLogger } from './ModelLogger';
3
3
  import { AuditLogType } from '@stamhoofd/structures';
4
4
 
5
5
  export const PlatformLogger = new ModelLogger(Platform, {
6
+ skipKeys: ['serverConfig'],
6
7
  optionsGenerator: getDefaultGenerator({
7
8
  updated: AuditLogType.PlatformSettingsChanged,
8
9
  }),
@@ -86,7 +86,8 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
86
86
 
87
87
  if (STAMHOOFD.userMode === 'platform') {
88
88
  const platform = await Platform.getShared();
89
- if (!platform.config.loginMethods.includes(LoginMethod.Password)) {
89
+ const config = platform.config.loginMethods.get(LoginMethod.Password);
90
+ if (!config) {
90
91
  throw new SimpleError({
91
92
  code: 'not_supported',
92
93
  message: 'This platform does not support password login',
@@ -94,6 +95,15 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
94
95
  statusCode: 400,
95
96
  });
96
97
  }
98
+
99
+ if (!config.isEnabledForEmail(request.body.username)) {
100
+ throw new SimpleError({
101
+ code: 'not_supported',
102
+ message: 'Login method not supported',
103
+ human: 'Je kan op dit account niet inloggen met een wachtwoord. Gebruik een andere methode om in te loggen.',
104
+ statusCode: 400,
105
+ });
106
+ }
97
107
  }
98
108
 
99
109
  const user = await User.login(organization?.id ?? null, request.body.username, request.body.password);
@@ -1,8 +1,9 @@
1
1
  import { Decoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
- import { PasswordToken, sendEmailTemplate, User } from '@stamhoofd/models';
4
- import { EmailTemplateType, ForgotPasswordRequest, Recipient, Replacement } from '@stamhoofd/structures';
3
+ import { PasswordToken, Platform, sendEmailTemplate, User } from '@stamhoofd/models';
4
+ import { EmailTemplateType, ForgotPasswordRequest, LoginMethod, Recipient, Replacement } from '@stamhoofd/structures';
5
5
 
6
+ import { SimpleError } from '@simonbackx/simple-errors';
6
7
  import { Context } from '../../helpers/Context';
7
8
 
8
9
  type Params = Record<string, never>;
@@ -28,6 +29,29 @@ export class ForgotPasswordEndpoint extends Endpoint<Params, Query, Body, Respon
28
29
 
29
30
  async handle(request: DecodedRequest<Params, Query, Body>) {
30
31
  const organization = await Context.setOptionalOrganizationScope();
32
+
33
+ if (STAMHOOFD.userMode === 'platform') {
34
+ const platform = await Platform.getShared();
35
+ const config = platform.config.loginMethods.get(LoginMethod.Password);
36
+ if (!config) {
37
+ throw new SimpleError({
38
+ code: 'not_supported',
39
+ message: 'This platform does not support password login',
40
+ human: 'Dit platform ondersteunt geen wachtwoord login',
41
+ statusCode: 400,
42
+ });
43
+ }
44
+
45
+ if (!config.isEnabledForEmail(request.body.email)) {
46
+ throw new SimpleError({
47
+ code: 'not_supported',
48
+ message: 'Login method not supported',
49
+ human: 'Je kan op dit account geen wachtwoord gebruiken om in te loggen.',
50
+ statusCode: 400,
51
+ });
52
+ }
53
+ }
54
+
31
55
  const user = await User.getForAuthentication(organization?.id ?? null, request.body.email, { allowWithoutAccount: true });
32
56
 
33
57
  if (!user) {
@@ -1,8 +1,8 @@
1
1
  import { AutoEncoderPatchType, Decoder, isPatch } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
- import { EmailVerificationCode, Member, PasswordToken, Token, User } from '@stamhoofd/models';
5
- import { NewUser, PermissionLevel, SignupResponse, UserPermissions, UserWithMembers } from '@stamhoofd/structures';
4
+ import { EmailVerificationCode, Member, PasswordToken, Platform, Token, User } from '@stamhoofd/models';
5
+ import { LoginMethod, NewUser, PermissionLevel, SignupResponse, UserPermissions, UserWithMembers } from '@stamhoofd/structures';
6
6
 
7
7
  import { Context } from '../../helpers/Context';
8
8
  import { MemberUserSyncer } from '../../helpers/MemberUserSyncer';
@@ -135,6 +135,28 @@ export class PatchUserEndpoint extends Endpoint<Params, Query, Body, ResponseBod
135
135
  }
136
136
 
137
137
  if (editUser.id === user.id && request.body.password) {
138
+ if (STAMHOOFD.userMode === 'platform') {
139
+ const platform = await Platform.getShared();
140
+ const config = platform.config.loginMethods.get(LoginMethod.Password);
141
+ if (!config) {
142
+ throw new SimpleError({
143
+ code: 'not_supported',
144
+ message: 'This platform does not support password login',
145
+ human: 'Dit platform ondersteunt geen wachtwoord login',
146
+ statusCode: 400,
147
+ });
148
+ }
149
+
150
+ if (!config.isEnabledForEmail(editUser.email)) {
151
+ throw new SimpleError({
152
+ code: 'not_supported',
153
+ message: 'Login method not supported',
154
+ human: 'Je kan op dit account geen wachtwoord gebruiken om in te loggen.',
155
+ statusCode: 400,
156
+ });
157
+ }
158
+ }
159
+
138
160
  // password changes
139
161
  await editUser.changePassword(request.body.password);
140
162
  await PasswordToken.clearFor(editUser.id);
@@ -32,7 +32,7 @@ export class SignupEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
32
32
 
33
33
  if (STAMHOOFD.userMode === 'platform') {
34
34
  const platform = await Platform.getShared();
35
- if (!platform.config.loginMethods.includes(LoginMethod.Password)) {
35
+ if (!platform.config.loginMethods.has(LoginMethod.Password)) {
36
36
  throw new SimpleError({
37
37
  code: 'not_supported',
38
38
  message: 'This platform does not support password login',
@@ -3,15 +3,15 @@ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
4
  import { Member, Platform } from '@stamhoofd/models';
5
5
  import { SQL, compileToSQLFilter, compileToSQLSorter } from '@stamhoofd/sql';
6
- import { CountFilteredRequest, Country, LimitedFilteredRequest, MembersBlob, PaginatedResponse, PermissionLevel, StamhoofdFilter, assertSort, getSortFilter } from '@stamhoofd/structures';
6
+ import { CountFilteredRequest, Country, CountryCode, LimitedFilteredRequest, MembersBlob, PaginatedResponse, PermissionLevel, StamhoofdFilter, assertSort, getSortFilter } from '@stamhoofd/structures';
7
7
  import { DataValidator } from '@stamhoofd/utility';
8
8
 
9
+ import { SQLResultNamespacedRow } from '@simonbackx/simple-database';
9
10
  import parsePhoneNumber from 'libphonenumber-js/max';
10
11
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
11
12
  import { Context } from '../../../helpers/Context';
12
13
  import { memberFilterCompilers } from '../../../sql-filters/members';
13
14
  import { memberSorters } from '../../../sql-sorters/members';
14
- import { SQLResultNamespacedRow } from '@simonbackx/simple-database';
15
15
 
16
16
  type Params = Record<string, never>;
17
17
  type Query = LimitedFilteredRequest;
@@ -141,7 +141,9 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
141
141
  if (!searchFilter && q.search.match(/^\+?[0-9\s-]+$/)) {
142
142
  // Try to format as phone so we have 1:1 space matches
143
143
  try {
144
- const phoneNumber = parsePhoneNumber(q.search, (Context.i18n.country as Country) || Country.Belgium);
144
+ const country = (Context.i18n.country as CountryCode) || Country.Belgium;
145
+
146
+ const phoneNumber = parsePhoneNumber(q.search, country);
145
147
  if (phoneNumber && phoneNumber.isValid()) {
146
148
  const formatted = phoneNumber.formatInternational();
147
149
  searchFilter = {
@@ -44,6 +44,13 @@ export class GetOrganizationSSOEndpoint extends Endpoint<Params, Query, Body, Re
44
44
  throw Context.auth.error();
45
45
  }
46
46
 
47
- return new Response(service.configuration);
47
+ const configuration = service.configuration.clone();
48
+
49
+ // Remove secret by placeholder asterisks
50
+ if (configuration.clientSecret.length > 0) {
51
+ configuration.clientSecret = OpenIDClientConfiguration.placeholderClientSecret;
52
+ }
53
+
54
+ return new Response(configuration);
48
55
  }
49
56
  }
@@ -41,6 +41,10 @@ export class SetOrganizationSSOEndpoint extends Endpoint<Params, Query, Body, Re
41
41
  throw Context.auth.error();
42
42
  }
43
43
 
44
+ if (request.body.clientSecret === OpenIDClientConfiguration.placeholderClientSecret) {
45
+ delete request.body.clientSecret;
46
+ }
47
+
44
48
  const newConfig: OpenIDClientConfiguration = service.configuration.patch(request.body);
45
49
  await service.setConfiguration(newConfig);
46
50
 
@@ -538,6 +538,10 @@ export class AdminPermissionChecker {
538
538
  }
539
539
 
540
540
  async canEditUserName(user: User) {
541
+ if (user.hasAccount() && !user.hasPasswordBasedAccount()) {
542
+ return false;
543
+ }
544
+
541
545
  if (user.id === this.user.id) {
542
546
  return true;
543
547
  }
@@ -556,9 +560,6 @@ export class AdminPermissionChecker {
556
560
  }
557
561
 
558
562
  async canEditUserEmail(user: User) {
559
- if (user.meta?.loginProviderIds?.size) {
560
- return false;
561
- }
562
563
  return this.canEditUserName(user);
563
564
  }
564
565
 
@@ -1,7 +1,7 @@
1
1
  import { DecodedRequest, Response } from '@simonbackx/simple-endpoints';
2
2
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
3
  import { Organization, Platform, Token, User, Webshop } from '@stamhoofd/models';
4
- import { LoginProviderType, OpenIDClientConfiguration, StartOpenIDFlowStruct, Token as TokenStruct } from '@stamhoofd/structures';
4
+ import { LoginMethod, LoginProviderType, OpenIDClientConfiguration, StartOpenIDFlowStruct, Token as TokenStruct } from '@stamhoofd/structures';
5
5
  import crypto from 'crypto';
6
6
  import { generators, Issuer } from 'openid-client';
7
7
  import { Context } from '../helpers/Context';
@@ -81,8 +81,8 @@ export class SSOService {
81
81
  const platform = await Platform.getShared();
82
82
 
83
83
  const service = new SSOService({ provider, platform, organization, user: Context.user });
84
- // Validate configuration
85
- const _ = service.configuration;
84
+ service.validate();
85
+
86
86
  return service;
87
87
  }
88
88
 
@@ -117,6 +117,51 @@ export class SSOService {
117
117
  return configuration;
118
118
  }
119
119
 
120
+ get loginConfiguration() {
121
+ if (this.organization) {
122
+ throw new SimpleError({
123
+ code: 'invalid_client',
124
+ message: 'Login configuration not yet supported for organization users',
125
+ statusCode: 400,
126
+ });
127
+ }
128
+
129
+ const loginConfiguration = this.platform.config.loginMethods.get(this.provider as unknown as LoginMethod);
130
+ if (!loginConfiguration) {
131
+ throw new SimpleError({
132
+ code: 'invalid_client',
133
+ message: 'SSO not configured (correctly)',
134
+ statusCode: 400,
135
+ });
136
+ }
137
+
138
+ return loginConfiguration;
139
+ }
140
+
141
+ validate() {
142
+ // Validate configuration exists
143
+ const _ = this.configuration;
144
+ const __ = this.loginConfiguration;
145
+
146
+ if (this.user) {
147
+ this.validateEmail(this.user.email);
148
+ }
149
+ }
150
+
151
+ validateEmail(email: string) {
152
+ // Validate configuration
153
+ const loginConfiguration = this.loginConfiguration;
154
+
155
+ if (!loginConfiguration.isEnabledForEmail(email)) {
156
+ throw new SimpleError({
157
+ code: 'invalid_user',
158
+ message: 'User not allowed to use this login method',
159
+ human: 'Je kan deze inlogmethode niet gebruiken',
160
+ statusCode: 400,
161
+ });
162
+ }
163
+ }
164
+
120
165
  async setConfiguration(configuration: OpenIDClientConfiguration) {
121
166
  if (this.provider === LoginProviderType.SSO) {
122
167
  if (this.organization) {
@@ -289,8 +334,15 @@ export class SSOService {
289
334
  const client = await this.getClient();
290
335
  await SSOService.storeSession(response, session);
291
336
 
337
+ const scopes = ['openid', 'email', 'profile'];
338
+
339
+ if (this.provider === LoginProviderType.SSO) {
340
+ // Google doesn't support this scope
341
+ scopes.push('offline_access');
342
+ }
343
+
292
344
  const redirect = client.authorizationUrl({
293
- scope: 'openid email profile',
345
+ scope: scopes.join(' '),
294
346
  code_challenge,
295
347
  code_challenge_method: 'S256',
296
348
  response_mode: 'form_post',
@@ -300,6 +352,9 @@ export class SSOService {
300
352
  prompt: prompt ?? undefined,
301
353
  login_hint: this.user?.email ?? undefined,
302
354
  redirect_uri: this.externalRedirectUri,
355
+
356
+ // Google has this instead of the offline_access scope
357
+ access_type: this.provider === LoginProviderType.Google ? 'offline' : undefined,
303
358
  });
304
359
 
305
360
  response.headers['location'] = redirect;
@@ -387,6 +442,13 @@ export class SSOServiceWithSession {
387
442
  }
388
443
  }
389
444
 
445
+ if (tokenSet.refresh_token) {
446
+ console.log('OK. Refresh token received!');
447
+ }
448
+ else {
449
+ console.log('No refresh token');
450
+ }
451
+
390
452
  if (!claims.email) {
391
453
  throw new SimpleError({
392
454
  code: 'invalid_user',
@@ -403,6 +465,8 @@ export class SSOServiceWithSession {
403
465
  });
404
466
  }
405
467
 
468
+ this.service.validateEmail(claims.email);
469
+
406
470
  // Get user from database
407
471
  let user = await User.getForRegister(this.service.organization?.id ?? null, claims.email);
408
472
  if (!user) {