@nymphjs/tilmeld 1.0.0-beta.3 → 1.0.0-beta.31

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/src/User.ts CHANGED
@@ -5,15 +5,17 @@ import {
5
5
  Selector,
6
6
  SerializedEntityData,
7
7
  } from '@nymphjs/nymph';
8
+ import { humanSecret, nanoid } from '@nymphjs/guid';
8
9
  import { EmailOptions } from 'email-templates';
9
10
  import strtotime from 'locutus/php/datetime/strtotime';
10
11
  import Base64 from 'crypto-js/enc-base64';
11
12
  import sha256 from 'crypto-js/sha256';
12
13
  import md5 from 'crypto-js/md5';
13
14
  import { difference } from 'lodash';
14
- import { humanSecret, nanoid } from '@nymphjs/guid';
15
+ import { TOTP, Secret } from 'otpauth';
16
+ import { toDataURL } from 'qrcode';
15
17
 
16
- import type Tilmeld from './Tilmeld';
18
+ import { enforceTilmeld } from './enforceTilmeld';
17
19
  import AbleObject from './AbleObject';
18
20
  import Group, { GroupData } from './Group';
19
21
  import {
@@ -24,12 +26,22 @@ import {
24
26
  } from './errors';
25
27
 
26
28
  export type EventType =
29
+ | 'checkUsername'
27
30
  | 'beforeRegister'
28
31
  | 'afterRegister'
29
32
  | 'beforeLogin'
30
33
  | 'afterLogin'
31
34
  | 'beforeLogout'
32
35
  | 'afterLogout';
36
+ /**
37
+ * This is run when the user has entered an otherwise valid username into the
38
+ * signup form. It should return a result, which when false will stop the
39
+ * process and return the included message, disallowing the username.
40
+ */
41
+ export type TilmeldCheckUsernameCallback = (
42
+ user: User & UserData,
43
+ data: { username: string }
44
+ ) => Promise<{ result: boolean; message?: string }>;
33
45
  /**
34
46
  * Theses are run before the user data checks, so the only checks before are
35
47
  * whether registration is allowed and whether the user is already registered.
@@ -135,7 +147,8 @@ export type UserData = {
135
147
  */
136
148
  newEmailSecret?: string;
137
149
  /**
138
- * The new email address.
150
+ * If the user has changed their email address, this is the new one, awaiting
151
+ * verification.
139
152
  */
140
153
  newEmailAddress?: string;
141
154
  /**
@@ -167,6 +180,15 @@ export type UserData = {
167
180
  * database.
168
181
  */
169
182
  passwordTemp?: string;
183
+ /**
184
+ * If set, this timestamp is the cutoff point for JWT issue dates. Any token
185
+ * issued before this date will not authenticate the user.
186
+ */
187
+ revokeTokenDate?: number;
188
+ /**
189
+ * Two factor auth secret.
190
+ */
191
+ totpSecret?: string;
170
192
  };
171
193
 
172
194
  /**
@@ -182,9 +204,9 @@ export default class User extends AbleObject<UserData> {
182
204
  /**
183
205
  * The instance of Tilmeld to use for queries.
184
206
  */
185
- static tilmeld: Tilmeld;
186
207
  static ETYPE = 'tilmeld_user';
187
208
  static class = 'User';
209
+ private static checkUsernameCallbacks: TilmeldCheckUsernameCallback[] = [];
188
210
  private static beforeRegisterCallbacks: TilmeldBeforeRegisterCallback[] = [];
189
211
  private static afterRegisterCallbacks: TilmeldAfterRegisterCallback[] = [];
190
212
  private static beforeLoginCallbacks: TilmeldBeforeLoginCallback[] = [];
@@ -200,23 +222,33 @@ export default class User extends AbleObject<UserData> {
200
222
  '$register',
201
223
  ];
202
224
  private static DEFAULT_PRIVATE_DATA = [
225
+ // The things that are commented are there to show what is _not_ private.
226
+ 'abilities',
227
+ // 'username',
228
+ // 'nameFirst',
229
+ // 'nameMiddle',
230
+ // 'nameLast',
231
+ // 'name',
203
232
  'email',
204
- 'originalEmail',
233
+ // 'avatar',
205
234
  'phone',
206
235
  'group',
207
236
  'groups',
208
- 'abilities',
209
237
  'inheritAbilities',
210
- 'recoverSecret',
211
- 'recoverSecretDate',
212
- 'password',
213
- 'salt',
238
+ 'enabled',
214
239
  'secret',
215
- 'newEmailAddress',
240
+ 'emailChangeDate',
216
241
  'newEmailSecret',
217
- 'cancelEmailAddress',
242
+ 'newEmailAddress',
218
243
  'cancelEmailSecret',
219
- 'emailChangeDate',
244
+ 'cancelEmailAddress',
245
+ 'recoverSecret',
246
+ 'recoverSecretDate',
247
+ 'salt',
248
+ 'password',
249
+ 'passwordTemp',
250
+ 'revokeTokenDate',
251
+ 'totpSecret',
220
252
  ];
221
253
  private static DEFAULT_ALLOWLIST_DATA: string[] = [];
222
254
 
@@ -304,10 +336,11 @@ export default class User extends AbleObject<UserData> {
304
336
  public static current(
305
337
  returnObjectIfNotExist?: boolean
306
338
  ): (User & UserData) | null {
307
- if (this.tilmeld.currentUser == null) {
339
+ const tilmeld = enforceTilmeld(this);
340
+ if (tilmeld.currentUser == null) {
308
341
  return returnObjectIfNotExist ? this.factorySync() : null;
309
342
  }
310
- return this.tilmeld.currentUser;
343
+ return tilmeld.currentUser;
311
344
  }
312
345
 
313
346
  /**
@@ -320,7 +353,8 @@ export default class User extends AbleObject<UserData> {
320
353
  recoveryType: 'username' | 'password';
321
354
  account: string;
322
355
  }): Promise<{ result: boolean; message: string }> {
323
- if (!this.tilmeld.config.pwRecovery) {
356
+ const tilmeld = enforceTilmeld(this);
357
+ if (!tilmeld.config.pwRecovery) {
324
358
  return {
325
359
  result: false,
326
360
  message: 'Account recovery is not allowed.',
@@ -330,14 +364,11 @@ export default class User extends AbleObject<UserData> {
330
364
  let user: User & UserData;
331
365
  const options: EmailOptions = {};
332
366
 
333
- if (
334
- !this.tilmeld.config.emailUsernames &&
335
- data.recoveryType === 'username'
336
- ) {
367
+ if (!tilmeld.config.emailUsernames && data.recoveryType === 'username') {
337
368
  // Create a username recovery email.
338
369
 
339
370
  const getUser = await this.nymph.getEntity(
340
- { class: this.tilmeld.User, skipAc: true },
371
+ { class: tilmeld.User, skipAc: true },
341
372
  {
342
373
  type: '&',
343
374
  ilike: ['email', data.account.replace(/([\\%_])/g, (s) => `\\${s}`)],
@@ -364,7 +395,7 @@ export default class User extends AbleObject<UserData> {
364
395
  } else if (data.recoveryType === 'password') {
365
396
  // Create a password recovery email.
366
397
 
367
- const getUser = await this.tilmeld.User.factoryUsername(data.account);
398
+ const getUser = await tilmeld.User.factoryUsername(data.account);
368
399
 
369
400
  if (getUser.guid == null) {
370
401
  return {
@@ -391,14 +422,14 @@ export default class User extends AbleObject<UserData> {
391
422
  };
392
423
  options.locals = {
393
424
  recoverCode: getUser.recoverSecret,
394
- timeLimit: this.tilmeld.config.pwRecoveryTimeLimit,
425
+ timeLimit: tilmeld.config.pwRecoveryTimeLimit,
395
426
  };
396
427
  } else {
397
428
  return { result: false, message: 'Invalid recovery type.' };
398
429
  }
399
430
 
400
431
  // Send the email.
401
- if (await this.tilmeld.config.sendEmail(this.tilmeld, options, user)) {
432
+ if (await tilmeld.config.sendEmail(tilmeld, options, user)) {
402
433
  return {
403
434
  result: true,
404
435
  message:
@@ -421,21 +452,22 @@ export default class User extends AbleObject<UserData> {
421
452
  secret: string;
422
453
  password: string;
423
454
  }): Promise<{ result: boolean; message: string }> {
424
- if (!this.tilmeld.config.pwRecovery) {
455
+ const tilmeld = enforceTilmeld(this);
456
+ if (!tilmeld.config.pwRecovery) {
425
457
  return {
426
458
  result: false,
427
459
  message: 'Account recovery is not allowed.',
428
460
  };
429
461
  }
430
462
 
431
- const user = await this.tilmeld.User.factoryUsername(data.username);
463
+ const user = await tilmeld.User.factoryUsername(data.username);
432
464
 
433
465
  if (
434
466
  user.guid == null ||
435
467
  user.recoverSecret == null ||
436
468
  data.secret !== user.recoverSecret ||
437
469
  strtotime(
438
- '+' + this.tilmeld.config.pwRecoveryTimeLimit,
470
+ '+' + tilmeld.config.pwRecoveryTimeLimit,
439
471
  Math.floor((user.recoverSecretDate ?? 0) / 1000)
440
472
  ) *
441
473
  1000 <
@@ -466,27 +498,30 @@ export default class User extends AbleObject<UserData> {
466
498
  }
467
499
 
468
500
  public static getClientConfig() {
501
+ const tilmeld = enforceTilmeld(this);
469
502
  return {
470
- regFields: this.tilmeld.config.regFields,
471
- userFields: this.tilmeld.config.userFields,
472
- emailUsernames: this.tilmeld.config.emailUsernames,
473
- allowRegistration: this.tilmeld.config.allowRegistration,
474
- allowUsernameChange: this.tilmeld.config.allowUsernameChange,
475
- pwRecovery: this.tilmeld.config.pwRecovery,
476
- verifyEmail: this.tilmeld.config.verifyEmail,
477
- unverifiedAccess: this.tilmeld.config.unverifiedAccess,
503
+ regFields: tilmeld.config.regFields,
504
+ userFields: tilmeld.config.userFields,
505
+ emailUsernames: tilmeld.config.emailUsernames,
506
+ allowRegistration: tilmeld.config.allowRegistration,
507
+ allowUsernameChange: tilmeld.config.allowUsernameChange,
508
+ pwRecovery: tilmeld.config.pwRecovery,
509
+ verifyEmail: tilmeld.config.verifyEmail,
510
+ unverifiedAccess: tilmeld.config.unverifiedAccess,
478
511
  };
479
512
  }
480
513
 
481
514
  public static async loginUser(data: {
482
515
  username: string;
483
516
  password: string;
517
+ code?: string;
484
518
  additionalData?: { [k: string]: any };
485
519
  }) {
520
+ const tilmeld = enforceTilmeld(this);
486
521
  if (!('username' in data) || !data.username.length) {
487
522
  return { result: false, message: 'Incorrect login/password.' };
488
523
  }
489
- const user = await this.tilmeld.User.factoryUsername(data.username);
524
+ const user = await tilmeld.User.factoryUsername(data.username);
490
525
  const result: { result: boolean; message: string; user?: User & UserData } =
491
526
  await user.$login(data);
492
527
  if (result.result) {
@@ -499,9 +534,10 @@ export default class User extends AbleObject<UserData> {
499
534
  public async $login(data: {
500
535
  username: string;
501
536
  password: string;
537
+ code?: string;
502
538
  additionalData?: { [k: string]: any };
503
539
  }) {
504
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
540
+ const tilmeld = enforceTilmeld(this);
505
541
  if (this.guid == null) {
506
542
  return { result: false, message: 'Incorrect login/password.' };
507
543
  }
@@ -515,6 +551,24 @@ export default class User extends AbleObject<UserData> {
515
551
  return { result: false, message: 'Incorrect login/password.' };
516
552
  }
517
553
 
554
+ if (this.$data.totpSecret != null) {
555
+ if (
556
+ data.code == null ||
557
+ typeof data.code !== 'string' ||
558
+ data.code.length !== 6
559
+ ) {
560
+ return {
561
+ result: false,
562
+ needTOTP: true,
563
+ message: 'You need to provide a 2FA code.',
564
+ };
565
+ }
566
+
567
+ if (!this.$checkTOTPCode(data.code)) {
568
+ return { result: false, message: 'Incorrect 2FA code.' };
569
+ }
570
+ }
571
+
518
572
  try {
519
573
  for (let callback of (this.constructor as typeof User)
520
574
  .beforeLoginCallbacks) {
@@ -545,12 +599,84 @@ export default class User extends AbleObject<UserData> {
545
599
  return { result: true, message: 'You are logged in.' };
546
600
  }
547
601
 
602
+ public async $switchUser(data?: { additionalData?: { [k: string]: any } }) {
603
+ const tilmeld = enforceTilmeld(this);
604
+ if (this.guid == null) {
605
+ return { result: false, message: 'Incorrect login/password.' };
606
+ }
607
+
608
+ let user = tilmeld.User.current();
609
+
610
+ if (!user || user.guid == null) {
611
+ return { result: false, message: "You're not logged in." };
612
+ }
613
+
614
+ const isCurrentUser = user != null && user.$is(this);
615
+
616
+ if (isCurrentUser) {
617
+ return { result: false, message: "You can't switch to yourself." };
618
+ }
619
+
620
+ const toAbilities = this.$data.abilities || [];
621
+ const fromAbilities = user.abilities || [];
622
+
623
+ if (
624
+ fromAbilities.indexOf('tilmeld/switch') === -1 &&
625
+ fromAbilities.indexOf('system/admin') === -1
626
+ ) {
627
+ return {
628
+ result: false,
629
+ message: "You don't have permission to switch users.",
630
+ };
631
+ }
632
+
633
+ if (
634
+ toAbilities.indexOf('system/admin') !== -1 ||
635
+ toAbilities.indexOf('tilmeld/admin') !== -1
636
+ ) {
637
+ return { result: false, message: "You can't switch to an admin." };
638
+ }
639
+
640
+ try {
641
+ for (let callback of (this.constructor as typeof User)
642
+ .beforeLoginCallbacks) {
643
+ if (callback) {
644
+ await callback(this, {
645
+ username: this.$data.username || '',
646
+ password: '',
647
+ ...(data || {}),
648
+ });
649
+ }
650
+ }
651
+ } catch (e: any) {
652
+ return {
653
+ result: false,
654
+ message: e.message,
655
+ };
656
+ }
657
+
658
+ // Authentication was successful, attempt to login.
659
+ if (!tilmeld.loginSwitch(this, true)) {
660
+ return { result: false, message: 'Incorrect login/password.' };
661
+ }
662
+
663
+ for (let callback of (this.constructor as typeof User)
664
+ .afterLoginCallbacks) {
665
+ if (callback) {
666
+ await callback(this);
667
+ }
668
+ }
669
+
670
+ // Login was successful.
671
+ return { result: true, message: 'You are logged in.' };
672
+ }
673
+
548
674
  /**
549
675
  * Log a user out of the system.
550
676
  * @returns An object with a boolean 'result' entry and a 'message' entry.
551
677
  */
552
678
  public async $logout() {
553
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
679
+ const tilmeld = enforceTilmeld(this);
554
680
 
555
681
  try {
556
682
  for (let callback of (this.constructor as typeof User)
@@ -629,17 +755,39 @@ export default class User extends AbleObject<UserData> {
629
755
  }
630
756
 
631
757
  public $jsonAcceptData(input: EntityJson, allowConflict = false) {
632
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
758
+ const tilmeld = enforceTilmeld(this);
633
759
  this.$referenceWake();
634
760
 
635
761
  if (
636
- input.data.abilities?.indexOf('system/admin') !== -1 &&
637
- this.$data.abilities?.indexOf('system/admin') === -1 &&
638
- tilmeld.gatekeeper('tilmeld/admin') &&
762
+ 'abilities' in input.data &&
763
+ input.data.abilities?.includes('system/admin') !==
764
+ this.$data.abilities?.includes('system/admin') &&
639
765
  !tilmeld.gatekeeper('system/admin')
640
766
  ) {
641
767
  throw new BadDataError(
642
- "You don't have the authority to make this user a system admin."
768
+ "You don't have the authority to grant or revoke system/admin."
769
+ );
770
+ }
771
+
772
+ if (
773
+ 'abilities' in input.data &&
774
+ input.data.abilities?.includes('tilmeld/admin') !==
775
+ this.$data.abilities?.includes('tilmeld/admin') &&
776
+ !tilmeld.gatekeeper('system/admin')
777
+ ) {
778
+ throw new BadDataError(
779
+ "You don't have the authority to grant or revoke tilmeld/admin."
780
+ );
781
+ }
782
+
783
+ if (
784
+ 'abilities' in input.data &&
785
+ input.data.abilities?.includes('tilmeld/switch') !==
786
+ this.$data.abilities?.includes('tilmeld/switch') &&
787
+ !tilmeld.gatekeeper('system/admin')
788
+ ) {
789
+ throw new BadDataError(
790
+ "You don't have the authority to grant or revoke tilmeld/switch."
643
791
  );
644
792
  }
645
793
 
@@ -648,17 +796,39 @@ export default class User extends AbleObject<UserData> {
648
796
  }
649
797
 
650
798
  public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
651
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
799
+ const tilmeld = enforceTilmeld(this);
652
800
  this.$referenceWake();
653
801
 
654
802
  if (
655
- patch.set.abilities?.indexOf('system/admin') !== -1 &&
656
- this.$data.abilities?.indexOf('system/admin') === -1 &&
657
- tilmeld.gatekeeper('tilmeld/admin') &&
803
+ 'abilities' in patch.set &&
804
+ patch.set.abilities?.includes('system/admin') !==
805
+ this.$data.abilities?.includes('system/admin') &&
806
+ !tilmeld.gatekeeper('system/admin')
807
+ ) {
808
+ throw new BadDataError(
809
+ "You don't have the authority to grant or revoke system/admin."
810
+ );
811
+ }
812
+
813
+ if (
814
+ 'abilities' in patch.set &&
815
+ patch.set.abilities?.includes('tilmeld/admin') !==
816
+ this.$data.abilities?.includes('tilmeld/admin') &&
658
817
  !tilmeld.gatekeeper('system/admin')
659
818
  ) {
660
819
  throw new BadDataError(
661
- "You don't have the authority to make this user a system admin."
820
+ "You don't have the authority to grant or revoke tilmeld/admin."
821
+ );
822
+ }
823
+
824
+ if (
825
+ 'abilities' in patch.set &&
826
+ patch.set.abilities?.includes('tilmeld/switch') !==
827
+ this.$data.abilities?.includes('tilmeld/switch') &&
828
+ !tilmeld.gatekeeper('system/admin')
829
+ ) {
830
+ throw new BadDataError(
831
+ "You don't have the authority to grant or revoke tilmeld/switch."
662
832
  );
663
833
  }
664
834
 
@@ -677,7 +847,7 @@ export default class User extends AbleObject<UserData> {
677
847
  * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
678
848
  */
679
849
  public $updateDataProtection(givenUser?: User & UserData) {
680
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
850
+ const tilmeld = enforceTilmeld(this);
681
851
  let user = givenUser ?? tilmeld.User.current();
682
852
 
683
853
  this.$clientEnabledMethods = [...User.DEFAULT_CLIENT_ENABLED_METHODS];
@@ -701,8 +871,13 @@ export default class User extends AbleObject<UserData> {
701
871
  // Users can check to see what abilities they have.
702
872
  this.$clientEnabledMethods.push('$gatekeeper');
703
873
  this.$clientEnabledMethods.push('$changePassword');
874
+ this.$clientEnabledMethods.push('$revokeCurrentTokens');
704
875
  this.$clientEnabledMethods.push('$logout');
705
876
  this.$clientEnabledMethods.push('$sendEmailVerification');
877
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
878
+ this.$clientEnabledMethods.push('$getNewTOTPSecret');
879
+ this.$clientEnabledMethods.push('$saveTOTPSecret');
880
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
706
881
  }
707
882
 
708
883
  if (
@@ -711,7 +886,7 @@ export default class User extends AbleObject<UserData> {
711
886
  abilities.indexOf('system/admin') !== -1)
712
887
  ) {
713
888
  // Users who can edit other users can see most of their data.
714
- this.$privateData = ['password', 'salt'];
889
+ this.$privateData = ['password', 'salt', 'totpSecret'];
715
890
  this.$allowlistData = undefined;
716
891
  } else if (isCurrentUser || isNewUser) {
717
892
  // Users can see their own data, and edit some of it.
@@ -732,17 +907,45 @@ export default class User extends AbleObject<UserData> {
732
907
  this.$allowlistData.push('phone');
733
908
  }
734
909
  this.$privateData = [
910
+ // The things that are commented are there to show what is _not_ private.
911
+ // 'abilities',
912
+ // 'username',
913
+ // 'nameFirst',
914
+ // 'nameMiddle',
915
+ // 'nameLast',
916
+ // 'name',
917
+ // 'email',
918
+ // 'avatar',
919
+ // 'phone',
920
+ // 'group',
921
+ // 'groups',
922
+ // 'inheritAbilities',
923
+ 'enabled',
735
924
  'secret',
925
+ 'emailChangeDate',
736
926
  'newEmailSecret',
737
- 'cancelEmailAddress',
927
+ // 'newEmailAddress',
738
928
  'cancelEmailSecret',
739
- 'emailChangeDate',
929
+ 'cancelEmailAddress',
740
930
  'recoverSecret',
741
931
  'recoverSecretDate',
742
- 'password',
743
932
  'salt',
933
+ 'password',
934
+ 'passwordTemp',
935
+ // 'revokeTokenDate',
936
+ 'totpSecret',
744
937
  ];
745
938
  }
939
+
940
+ if (
941
+ user != null &&
942
+ !isCurrentUser &&
943
+ (abilities.indexOf('tilmeld/switch') !== -1 ||
944
+ abilities.indexOf('system/admin') !== -1)
945
+ ) {
946
+ // Users with this ability can switch to other users.
947
+ this.$clientEnabledMethods.push('$switchUser');
948
+ }
746
949
  }
747
950
 
748
951
  /**
@@ -760,7 +963,7 @@ export default class User extends AbleObject<UserData> {
760
963
  * @returns True or false.
761
964
  */
762
965
  public $gatekeeper(ability?: string): boolean {
763
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
966
+ const tilmeld = enforceTilmeld(this);
764
967
  if (ability == null) {
765
968
  return tilmeld.User.current()?.$is(this) ?? false;
766
969
  }
@@ -787,7 +990,7 @@ export default class User extends AbleObject<UserData> {
787
990
  }
788
991
  return (
789
992
  (ability in this.$gatekeeperCache && !!this.$gatekeeperCache[ability]) ||
790
- !!this.$gatekeeperCache['system/admin']
993
+ !!this.$data.abilities?.includes('system/admin')
791
994
  );
792
995
  }
793
996
 
@@ -804,7 +1007,7 @@ export default class User extends AbleObject<UserData> {
804
1007
  * @returns True on success, false on failure.
805
1008
  */
806
1009
  public async $sendEmailVerification() {
807
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1010
+ const tilmeld = enforceTilmeld(this);
808
1011
  if (this.guid == null) {
809
1012
  return false;
810
1013
  }
@@ -917,7 +1120,7 @@ export default class User extends AbleObject<UserData> {
917
1120
  * @returns True if the passwords match, otherwise false.
918
1121
  */
919
1122
  public $checkPassword(password: string) {
920
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1123
+ const tilmeld = enforceTilmeld(this);
921
1124
  switch (tilmeld.config.pwMethod) {
922
1125
  case 'plain':
923
1126
  return this.$data.password === password;
@@ -1006,6 +1209,7 @@ export default class User extends AbleObject<UserData> {
1006
1209
  public async $changePassword(data: {
1007
1210
  newPassword: string;
1008
1211
  currentPassword: string;
1212
+ revokeCurrentTokens?: boolean;
1009
1213
  }): Promise<{ result: boolean; message: string }> {
1010
1214
  if (!('newPassword' in data) || !data.newPassword.length) {
1011
1215
  return { result: false, message: 'Please specify a password.' };
@@ -1014,7 +1218,14 @@ export default class User extends AbleObject<UserData> {
1014
1218
  return { result: false, message: 'Incorrect password.' };
1015
1219
  }
1016
1220
  this.$data.passwordTemp = data.newPassword;
1221
+ if (data.revokeCurrentTokens) {
1222
+ this.$data.revokeTokenDate = Date.now();
1223
+ }
1017
1224
  if (await this.$save()) {
1225
+ if (data.revokeCurrentTokens) {
1226
+ const tilmeld = enforceTilmeld(this);
1227
+ tilmeld.login(this, true);
1228
+ }
1018
1229
  return { result: true, message: 'Your password has been changed.' };
1019
1230
  } else {
1020
1231
  return { result: false, message: "Couldn't save new password." };
@@ -1028,7 +1239,7 @@ export default class User extends AbleObject<UserData> {
1028
1239
  * @returns The resulting password or hash which is stored in the entity.
1029
1240
  */
1030
1241
  public $password(password: string) {
1031
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1242
+ const tilmeld = enforceTilmeld(this);
1032
1243
  switch (tilmeld.config.pwMethod) {
1033
1244
  case 'plain':
1034
1245
  delete this.$data.salt;
@@ -1049,13 +1260,216 @@ export default class User extends AbleObject<UserData> {
1049
1260
  return this.$data.password;
1050
1261
  }
1051
1262
 
1263
+ /**
1264
+ * A frontend accessible method to revoke all currently issued tokens.
1265
+ *
1266
+ * @param data The input data from the client.
1267
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1268
+ */
1269
+ public async $revokeCurrentTokens(data: {
1270
+ password: string;
1271
+ }): Promise<{ result: boolean; message: string }> {
1272
+ if (!this.$checkPassword(data.password ?? '')) {
1273
+ return { result: false, message: 'Incorrect password.' };
1274
+ }
1275
+
1276
+ this.$data.revokeTokenDate = Date.now();
1277
+
1278
+ if (await this.$save()) {
1279
+ const tilmeld = enforceTilmeld(this);
1280
+ tilmeld.login(this, true);
1281
+ return {
1282
+ result: true,
1283
+ message: 'You have logged out of all other sessions.',
1284
+ };
1285
+ } else {
1286
+ return { result: false, message: "Couldn't save revocation date." };
1287
+ }
1288
+ }
1289
+
1290
+ public async $hasTOTPSecret() {
1291
+ return this.$data.totpSecret != null;
1292
+ }
1293
+
1294
+ /**
1295
+ * Check the given code against the user's TOTP secret.
1296
+ *
1297
+ * @param code The code in question.
1298
+ * @returns True if the code is valid, otherwise false.
1299
+ */
1300
+ public $checkTOTPCode(code: string) {
1301
+ if (this.$data.totpSecret == null) {
1302
+ return false;
1303
+ }
1304
+
1305
+ const tilmeld = enforceTilmeld(this);
1306
+
1307
+ const totp = new TOTP({
1308
+ issuer: tilmeld.config.appName,
1309
+ label: this.$data.username,
1310
+ algorithm: 'SHA1',
1311
+ digits: 6,
1312
+ period: 30,
1313
+ secret: this.$data.totpSecret,
1314
+ });
1315
+
1316
+ return totp.validate({ token: code, window: 2 }) != null;
1317
+ }
1318
+
1319
+ /**
1320
+ * A frontend accessible method to generate a new TOTP secret.
1321
+ *
1322
+ * @returns An object with 'uri', 'qrcode', and 'secret'.
1323
+ */
1324
+ public async $getNewTOTPSecret() {
1325
+ const tilmeld = enforceTilmeld(this);
1326
+
1327
+ if (this.$data.totpSecret != null) {
1328
+ throw new BadDataError('You already have a 2FA secret.');
1329
+ }
1330
+
1331
+ const secret = new Secret();
1332
+
1333
+ const totp = new TOTP({
1334
+ issuer: tilmeld.config.appName,
1335
+ label: this.$data.username,
1336
+ algorithm: 'SHA1',
1337
+ digits: 6,
1338
+ period: 30,
1339
+ secret,
1340
+ });
1341
+
1342
+ const uri = totp.toString();
1343
+
1344
+ const qrcode = await toDataURL(uri);
1345
+
1346
+ return {
1347
+ uri,
1348
+ qrcode,
1349
+ secret: secret.base32,
1350
+ };
1351
+ }
1352
+
1353
+ /**
1354
+ * A frontend accessible method to save a TOTP secret into the user's account.
1355
+ *
1356
+ * @param data The input data from the client.
1357
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1358
+ */
1359
+ public async $saveTOTPSecret(data: {
1360
+ password: string;
1361
+ secret: string;
1362
+ code: string;
1363
+ }): Promise<{ result: boolean; message: string }> {
1364
+ if (!this.$checkPassword(data.password ?? '')) {
1365
+ return { result: false, message: 'Incorrect password.' };
1366
+ }
1367
+
1368
+ if (this.$data.totpSecret != null) {
1369
+ return { result: false, message: 'You already have a 2FA secret.' };
1370
+ }
1371
+
1372
+ if (
1373
+ data.secret == null ||
1374
+ typeof data.secret !== 'string' ||
1375
+ data.secret === ''
1376
+ ) {
1377
+ return { result: false, message: '2FA secret is invalid.' };
1378
+ }
1379
+
1380
+ if (
1381
+ data.code == null ||
1382
+ typeof data.code !== 'string' ||
1383
+ data.code.length !== 6
1384
+ ) {
1385
+ return { result: false, message: '2FA code is invalid.' };
1386
+ }
1387
+
1388
+ const tilmeld = enforceTilmeld(this);
1389
+
1390
+ const totp = new TOTP({
1391
+ issuer: tilmeld.config.appName,
1392
+ label: this.$data.username,
1393
+ algorithm: 'SHA1',
1394
+ digits: 6,
1395
+ period: 30,
1396
+ secret: data.secret,
1397
+ });
1398
+
1399
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1400
+ return { result: false, message: '2FA code is incorrect.' };
1401
+ }
1402
+
1403
+ this.$data.totpSecret = data.secret;
1404
+
1405
+ if (await this.$save()) {
1406
+ return { result: true, message: 'Your two factor secret is now set.' };
1407
+ } else {
1408
+ return { result: false, message: "Couldn't save two factor secret." };
1409
+ }
1410
+ }
1411
+
1412
+ /**
1413
+ * A frontend accessible method to remove the TOTP secret from the user's
1414
+ * account.
1415
+ *
1416
+ * @param data The input data from the client.
1417
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1418
+ */
1419
+ public async $removeTOTPSecret(data: {
1420
+ password: string;
1421
+ code: string;
1422
+ }): Promise<{ result: boolean; message: string }> {
1423
+ if (!this.$checkPassword(data.password ?? '')) {
1424
+ return { result: false, message: 'Incorrect password.' };
1425
+ }
1426
+
1427
+ if (this.$data.totpSecret == null) {
1428
+ return { result: false, message: "You don't have a 2FA secret." };
1429
+ }
1430
+
1431
+ if (
1432
+ data.code == null ||
1433
+ typeof data.code !== 'string' ||
1434
+ data.code.length !== 6
1435
+ ) {
1436
+ return { result: false, message: '2FA code is invalid.' };
1437
+ }
1438
+
1439
+ const tilmeld = enforceTilmeld(this);
1440
+
1441
+ const totp = new TOTP({
1442
+ issuer: tilmeld.config.appName,
1443
+ label: this.$data.username,
1444
+ algorithm: 'SHA1',
1445
+ digits: 6,
1446
+ period: 30,
1447
+ secret: this.$data.totpSecret,
1448
+ });
1449
+
1450
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1451
+ return { result: false, message: '2FA code is incorrect.' };
1452
+ }
1453
+
1454
+ delete this.$data.totpSecret;
1455
+
1456
+ if (await this.$save()) {
1457
+ return {
1458
+ result: true,
1459
+ message: 'Your two factor secret has been removed.',
1460
+ };
1461
+ } else {
1462
+ return { result: false, message: "Couldn't remove two factor secret." };
1463
+ }
1464
+ }
1465
+
1052
1466
  /**
1053
1467
  * Check that a username is valid.
1054
1468
  *
1055
1469
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1056
1470
  */
1057
1471
  public async $checkUsername(): Promise<{ result: boolean; message: string }> {
1058
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1472
+ const tilmeld = enforceTilmeld(this);
1059
1473
  if (!tilmeld.config.emailUsernames) {
1060
1474
  if (this.$data.username == null || !this.$data.username.length) {
1061
1475
  return { result: false, message: 'Please specify a username.' };
@@ -1109,6 +1523,18 @@ export default class User extends AbleObject<UserData> {
1109
1523
  return { result: false, message: 'That username is taken.' };
1110
1524
  }
1111
1525
 
1526
+ for (let callback of (this.constructor as typeof User)
1527
+ .checkUsernameCallbacks) {
1528
+ if (callback) {
1529
+ const result = await callback(this, {
1530
+ username: this.$data.username,
1531
+ });
1532
+ if (!result.result) {
1533
+ return { message: 'Username is not available.', ...result };
1534
+ }
1535
+ }
1536
+ }
1537
+
1112
1538
  return {
1113
1539
  result: true,
1114
1540
  message:
@@ -1142,7 +1568,7 @@ export default class User extends AbleObject<UserData> {
1142
1568
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1143
1569
  */
1144
1570
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
1145
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1571
+ const tilmeld = enforceTilmeld(this);
1146
1572
  if (this.$data.email == null || !this.$data.email.length) {
1147
1573
  if (tilmeld.config.verifyEmail) {
1148
1574
  return { result: false, message: 'Please specify an email.' };
@@ -1187,7 +1613,7 @@ export default class User extends AbleObject<UserData> {
1187
1613
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1188
1614
  */
1189
1615
  public async $checkPhone(): Promise<{ result: boolean; message: string }> {
1190
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1616
+ const tilmeld = enforceTilmeld(this);
1191
1617
  if (this.$data.phone == null || !this.$data.phone.length) {
1192
1618
  return { result: false, message: 'Please specify a phone number.' };
1193
1619
  }
@@ -1226,7 +1652,7 @@ export default class User extends AbleObject<UserData> {
1226
1652
  password: string;
1227
1653
  additionalData?: { [k: string]: any };
1228
1654
  }): Promise<{ result: boolean; loggedin: boolean; message: string }> {
1229
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
1655
+ let tilmeld = enforceTilmeld(this);
1230
1656
  if (!tilmeld.config.allowRegistration) {
1231
1657
  return {
1232
1658
  result: false,
@@ -1288,7 +1714,7 @@ export default class User extends AbleObject<UserData> {
1288
1714
  const nymph = this.$nymph;
1289
1715
  const tnymph = await this.$nymph.startTransaction(transaction);
1290
1716
  this.$nymph = tnymph;
1291
- tilmeld = this.$nymph.tilmeld as Tilmeld;
1717
+ tilmeld = enforceTilmeld(this);
1292
1718
 
1293
1719
  try {
1294
1720
  // Add primary group.
@@ -1493,7 +1919,7 @@ export default class User extends AbleObject<UserData> {
1493
1919
  }
1494
1920
 
1495
1921
  public async $save() {
1496
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
1922
+ let tilmeld = enforceTilmeld(this);
1497
1923
  if (this.$data.username == null || !this.$data.username.trim().length) {
1498
1924
  return false;
1499
1925
  }
@@ -1501,7 +1927,7 @@ export default class User extends AbleObject<UserData> {
1501
1927
  if (
1502
1928
  tilmeld.gatekeeper('tilmeld/admin') &&
1503
1929
  !tilmeld.gatekeeper('system/admin') &&
1504
- this.$gatekeeper('system/admin')
1930
+ this.$data.abilities?.includes('system/admin')
1505
1931
  ) {
1506
1932
  throw new BadDataError(
1507
1933
  "You don't have the authority to modify system admins."
@@ -1691,7 +2117,7 @@ export default class User extends AbleObject<UserData> {
1691
2117
  const nymph = this.$nymph;
1692
2118
  const tnymph = await this.$nymph.startTransaction(transaction);
1693
2119
  this.$nymph = tnymph;
1694
- tilmeld = this.$nymph.tilmeld as Tilmeld;
2120
+ tilmeld = enforceTilmeld(this);
1695
2121
 
1696
2122
  if (
1697
2123
  this.$data.group != null &&
@@ -1742,9 +2168,9 @@ export default class User extends AbleObject<UserData> {
1742
2168
  this.$gatekeeperCache = undefined;
1743
2169
  await tnymph.commit(transaction);
1744
2170
 
1745
- if ((nymph.tilmeld as Tilmeld).User.current(true).$is(this)) {
2171
+ if (enforceTilmeld(nymph).User.current(true).$is(this)) {
1746
2172
  // Update the user in the session cache.
1747
- (nymph.tilmeld as Tilmeld).fillSession(this);
2173
+ enforceTilmeld(nymph).fillSession(this);
1748
2174
  }
1749
2175
  } else {
1750
2176
  await tnymph.rollback(transaction);
@@ -1770,13 +2196,13 @@ export default class User extends AbleObject<UserData> {
1770
2196
  }
1771
2197
 
1772
2198
  public async $delete() {
1773
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
2199
+ const tilmeld = enforceTilmeld(this);
1774
2200
  if (!tilmeld.gatekeeper('tilmeld/admin')) {
1775
2201
  throw new BadDataError("You don't have the authority to delete users.");
1776
2202
  }
1777
2203
  if (
1778
2204
  !tilmeld.gatekeeper('system/admin') &&
1779
- this.$gatekeeper('system/admin')
2205
+ this.$data.abilities?.includes('system/admin')
1780
2206
  ) {
1781
2207
  throw new BadDataError(
1782
2208
  "You don't have the authority to delete system admins."
@@ -1790,7 +2216,9 @@ export default class User extends AbleObject<UserData> {
1790
2216
 
1791
2217
  public static on<T extends EventType>(
1792
2218
  event: T,
1793
- callback: T extends 'beforeRegister'
2219
+ callback: T extends 'checkUsername'
2220
+ ? TilmeldCheckUsernameCallback
2221
+ : T extends 'beforeRegister'
1794
2222
  ? TilmeldBeforeRegisterCallback
1795
2223
  : T extends 'afterRegister'
1796
2224
  ? TilmeldAfterRegisterCallback
@@ -1804,7 +2232,9 @@ export default class User extends AbleObject<UserData> {
1804
2232
  ? TilmeldAfterLogoutCallback
1805
2233
  : never
1806
2234
  ) {
1807
- const prop = (event + 'Callbacks') as T extends 'beforeRegister'
2235
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2236
+ ? 'checkUsernameCallbacks'
2237
+ : T extends 'beforeRegister'
1808
2238
  ? 'beforeRegisterCallbacks'
1809
2239
  : T extends 'afterRegister'
1810
2240
  ? 'afterRegisterCallbacks'
@@ -1827,7 +2257,9 @@ export default class User extends AbleObject<UserData> {
1827
2257
 
1828
2258
  public static off<T extends EventType>(
1829
2259
  event: T,
1830
- callback: T extends 'beforeRegister'
2260
+ callback: T extends 'checkUsername'
2261
+ ? TilmeldCheckUsernameCallback
2262
+ : T extends 'beforeRegister'
1831
2263
  ? TilmeldBeforeRegisterCallback
1832
2264
  : T extends 'afterRegister'
1833
2265
  ? TilmeldAfterRegisterCallback
@@ -1841,7 +2273,9 @@ export default class User extends AbleObject<UserData> {
1841
2273
  ? TilmeldAfterLogoutCallback
1842
2274
  : never
1843
2275
  ) {
1844
- const prop = (event + 'Callbacks') as T extends 'beforeRegister'
2276
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2277
+ ? 'checkUsernameCallbacks'
2278
+ : T extends 'beforeRegister'
1845
2279
  ? 'beforeRegisterCallbacks'
1846
2280
  : T extends 'afterRegister'
1847
2281
  ? 'afterRegisterCallbacks'