@nymphjs/tilmeld 1.0.0-beta.2 → 1.0.0-beta.21

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,18 @@ 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
18
  import type Tilmeld from './Tilmeld';
19
+ import { enforceTilmeld } from './enforceTilmeld';
17
20
  import AbleObject from './AbleObject';
18
21
  import Group, { GroupData } from './Group';
19
22
  import {
@@ -24,12 +27,22 @@ import {
24
27
  } from './errors';
25
28
 
26
29
  export type EventType =
30
+ | 'checkUsername'
27
31
  | 'beforeRegister'
28
32
  | 'afterRegister'
29
33
  | 'beforeLogin'
30
34
  | 'afterLogin'
31
35
  | 'beforeLogout'
32
36
  | 'afterLogout';
37
+ /**
38
+ * This is run when the user has entered an otherwise valid username into the
39
+ * signup form. It should return a result, which when false will stop the
40
+ * process and return the included message, disallowing the username.
41
+ */
42
+ export type TilmeldCheckUsernameCallback = (
43
+ user: User & UserData,
44
+ data: { username: string }
45
+ ) => Promise<{ result: boolean; message?: string }>;
33
46
  /**
34
47
  * Theses are run before the user data checks, so the only checks before are
35
48
  * whether registration is allowed and whether the user is already registered.
@@ -135,7 +148,8 @@ export type UserData = {
135
148
  */
136
149
  newEmailSecret?: string;
137
150
  /**
138
- * The new email address.
151
+ * If the user has changed their email address, this is the new one, awaiting
152
+ * verification.
139
153
  */
140
154
  newEmailAddress?: string;
141
155
  /**
@@ -167,6 +181,15 @@ export type UserData = {
167
181
  * database.
168
182
  */
169
183
  passwordTemp?: string;
184
+ /**
185
+ * If set, this timestamp is the cutoff point for JWT issue dates. Any token
186
+ * issued before this date will not authenticate the user.
187
+ */
188
+ revokeTokenDate?: number;
189
+ /**
190
+ * Two factor auth secret.
191
+ */
192
+ totpSecret?: string;
170
193
  };
171
194
 
172
195
  /**
@@ -185,6 +208,7 @@ export default class User extends AbleObject<UserData> {
185
208
  static tilmeld: Tilmeld;
186
209
  static ETYPE = 'tilmeld_user';
187
210
  static class = 'User';
211
+ private static checkUsernameCallbacks: TilmeldCheckUsernameCallback[] = [];
188
212
  private static beforeRegisterCallbacks: TilmeldBeforeRegisterCallback[] = [];
189
213
  private static afterRegisterCallbacks: TilmeldAfterRegisterCallback[] = [];
190
214
  private static beforeLoginCallbacks: TilmeldBeforeLoginCallback[] = [];
@@ -200,23 +224,33 @@ export default class User extends AbleObject<UserData> {
200
224
  '$register',
201
225
  ];
202
226
  private static DEFAULT_PRIVATE_DATA = [
227
+ // The things that are commented are there to show what is _not_ private.
228
+ 'abilities',
229
+ // 'username',
230
+ // 'nameFirst',
231
+ // 'nameMiddle',
232
+ // 'nameLast',
233
+ // 'name',
203
234
  'email',
204
- 'originalEmail',
235
+ // 'avatar',
205
236
  'phone',
206
237
  'group',
207
238
  'groups',
208
- 'abilities',
209
239
  'inheritAbilities',
210
- 'recoverSecret',
211
- 'recoverSecretDate',
212
- 'password',
213
- 'salt',
240
+ 'enabled',
214
241
  'secret',
215
- 'newEmailAddress',
242
+ 'emailChangeDate',
216
243
  'newEmailSecret',
217
- 'cancelEmailAddress',
244
+ 'newEmailAddress',
218
245
  'cancelEmailSecret',
219
- 'emailChangeDate',
246
+ 'cancelEmailAddress',
247
+ 'recoverSecret',
248
+ 'recoverSecretDate',
249
+ 'salt',
250
+ 'password',
251
+ 'passwordTemp',
252
+ 'revokeTokenDate',
253
+ 'totpSecret',
220
254
  ];
221
255
  private static DEFAULT_ALLOWLIST_DATA: string[] = [];
222
256
 
@@ -481,6 +515,7 @@ export default class User extends AbleObject<UserData> {
481
515
  public static async loginUser(data: {
482
516
  username: string;
483
517
  password: string;
518
+ code?: string;
484
519
  additionalData?: { [k: string]: any };
485
520
  }) {
486
521
  if (!('username' in data) || !data.username.length) {
@@ -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') &&
765
+ !tilmeld.gatekeeper('system/admin')
766
+ ) {
767
+ throw new BadDataError(
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') &&
639
776
  !tilmeld.gatekeeper('system/admin')
640
777
  ) {
641
778
  throw new BadDataError(
642
- "You don't have the authority to make this user a system admin."
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'