@nymphjs/tilmeld 1.0.0-beta.138 → 1.0.0-beta.139

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
@@ -518,7 +518,7 @@ export class User extends AbleObject<UserData> {
518
518
  `User account recovery for "${user.username}".`,
519
519
  );
520
520
 
521
- user.$password(data.password);
521
+ await user.$password(data.password);
522
522
  delete user.recoverSecret;
523
523
  delete user.recoverSecretDate;
524
524
  try {
@@ -637,6 +637,10 @@ export class User extends AbleObject<UserData> {
637
637
  async $getUniques(): Promise<string[]> {
638
638
  const tilmeld = enforceTilmeld(this);
639
639
  const uniques = [`u:${this.username}`];
640
+ if (this.abilities?.includes('system/autogranted')) {
641
+ // Prevent multiple users from ever being granted admin automatically.
642
+ uniques.push('a:autogranted');
643
+ }
640
644
  if (
641
645
  !tilmeld.config.emailUsernames &&
642
646
  tilmeld.config.userFields.includes('email')
@@ -661,7 +665,7 @@ export class User extends AbleObject<UserData> {
661
665
  await tilmeld.config.failedLoginAttempt(null, data);
662
666
  return { result: false, message: 'Incorrect login/password.' };
663
667
  }
664
- if (!this.$checkPassword(data.password)) {
668
+ if (!(await this.$checkPassword(data.password))) {
665
669
  await tilmeld.config.failedLoginAttempt(this, data);
666
670
  return { result: false, message: 'Incorrect login/password.' };
667
671
  }
@@ -719,6 +723,17 @@ export class User extends AbleObject<UserData> {
719
723
  }
720
724
  }
721
725
 
726
+ // Check if the password uses the old hash method.
727
+ if (
728
+ tilmeld.config.pwMethod === 'salt' &&
729
+ !this.password?.startsWith('scrypt$')
730
+ ) {
731
+ // Update the password hash with one using the new method.
732
+ await this.$refresh();
733
+ await this.$password(data.password);
734
+ await this.$save();
735
+ }
736
+
722
737
  // Login was successful.
723
738
  return { result: true, message: 'You are logged in.' };
724
739
  }
@@ -784,7 +799,7 @@ export class User extends AbleObject<UserData> {
784
799
  );
785
800
 
786
801
  // Authentication was successful, attempt to login.
787
- if (!(await tilmeld.loginSwitch(this, true))) {
802
+ if (!(await tilmeld.loginSwitch(user, this, true))) {
788
803
  return { result: false, message: 'Incorrect login/password.' };
789
804
  }
790
805
 
@@ -894,6 +909,12 @@ export class User extends AbleObject<UserData> {
894
909
 
895
910
  if ('abilities' in input.data) {
896
911
  this.$checkIncomingAbilities(input.data.abilities);
912
+ } else if (
913
+ (this.$allowlistData == null ||
914
+ this.$allowlistData.includes('abilities')) &&
915
+ (this.$privateData == null || !this.$privateData.includes('abilities'))
916
+ ) {
917
+ this.$checkIncomingAbilities(undefined);
897
918
  }
898
919
 
899
920
  // If the user is a new domain user, the data protection needs to be updated
@@ -939,6 +960,10 @@ export class User extends AbleObject<UserData> {
939
960
  this.$checkIncomingAbilities(patch.set.abilities);
940
961
  }
941
962
 
963
+ if (patch.unset.includes('abilities')) {
964
+ this.$checkIncomingAbilities(undefined);
965
+ }
966
+
942
967
  super.$jsonAcceptPatch(patch, allowConflict);
943
968
  }
944
969
 
@@ -963,6 +988,20 @@ export class User extends AbleObject<UserData> {
963
988
  );
964
989
  }
965
990
 
991
+ if (
992
+ xor(
993
+ incoming.filter((ability) => ability.startsWith('system/')),
994
+ this.abilities?.filter((ability) => ability.startsWith('system/')) ??
995
+ [],
996
+ ).length &&
997
+ (!tilmeld.currentUser ||
998
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
999
+ ) {
1000
+ throw new BadDataError(
1001
+ "You don't have the authority to grant or revoke system abilities.",
1002
+ );
1003
+ }
1004
+
966
1005
  if (
967
1006
  incoming.includes('tilmeld/admin') !==
968
1007
  this.abilities?.includes('tilmeld/admin') &&
@@ -987,10 +1026,9 @@ export class User extends AbleObject<UserData> {
987
1026
 
988
1027
  if (
989
1028
  xor(
990
- incoming.filter((ability: string) => ability.startsWith('tilmeld/')),
991
- this.abilities?.filter((ability: string) =>
992
- ability.startsWith('tilmeld/'),
993
- ) ?? [],
1029
+ incoming.filter((ability) => ability.startsWith('tilmeld/')),
1030
+ this.abilities?.filter((ability) => ability.startsWith('tilmeld/')) ??
1031
+ [],
994
1032
  ).length &&
995
1033
  (!tilmeld.currentUser ||
996
1034
  (!tilmeld.currentUser.abilities?.includes('system/admin') &&
@@ -1392,25 +1430,55 @@ export class User extends AbleObject<UserData> {
1392
1430
  * @param password The password in question.
1393
1431
  * @returns True if the passwords match, otherwise false.
1394
1432
  */
1395
- public $checkPassword(password: string) {
1433
+ public async $checkPassword(password: string) {
1396
1434
  const tilmeld = enforceTilmeld(this);
1397
1435
  switch (tilmeld.config.pwMethod) {
1398
1436
  case 'plain':
1399
- return this.password === password;
1437
+ return crypto.timingSafeEqual(
1438
+ Buffer.from(this.password ?? ''),
1439
+ Buffer.from(password),
1440
+ );
1400
1441
  case 'digest':
1401
- return (
1402
- this.password ===
1403
- crypto.createHash('sha256').update(password).digest('base64')
1442
+ return crypto.timingSafeEqual(
1443
+ Buffer.from(this.password ?? '', 'base64'),
1444
+ crypto.createHash('sha256').update(password).digest(),
1404
1445
  );
1405
1446
  case 'salt':
1406
1447
  default:
1407
- return (
1408
- this.password ===
1409
- crypto
1410
- .createHash('sha256')
1411
- .update(password + this.salt)
1412
- .digest('base64')
1413
- );
1448
+ if (!this.password?.startsWith('scrypt$')) {
1449
+ // Check using the old hash method.
1450
+ return crypto.timingSafeEqual(
1451
+ Buffer.from(this.password ?? '', 'base64'),
1452
+ crypto
1453
+ .createHash('sha256')
1454
+ .update(password + this.salt)
1455
+ .digest(),
1456
+ );
1457
+ }
1458
+ // Check using the new hash method.
1459
+ const [alg, n, rr, pp, salt, keyB64] = (this.password ?? '').split('$');
1460
+ if (alg !== 'scrypt') {
1461
+ return false;
1462
+ }
1463
+ const expected = Buffer.from(keyB64, 'base64');
1464
+ const N = Number(n),
1465
+ r = Number(rr),
1466
+ p = Number(pp);
1467
+ const key = await new Promise<Buffer>((resolve, reject) => {
1468
+ crypto.scrypt(
1469
+ password,
1470
+ salt,
1471
+ expected.length,
1472
+ { N, r, p, maxmem: 256 * N * r },
1473
+ (err, key) => {
1474
+ if (err) {
1475
+ return reject(err);
1476
+ }
1477
+ resolve(key);
1478
+ },
1479
+ );
1480
+ });
1481
+ return crypto.timingSafeEqual(key, expected);
1414
1482
  }
1415
1483
  }
1416
1484
 
@@ -1493,6 +1561,10 @@ export class User extends AbleObject<UserData> {
1493
1561
  currentPassword: string;
1494
1562
  revokeCurrentTokens?: boolean;
1495
1563
  }): Promise<{ result: boolean; message: string }> {
1564
+ const tilmeld = enforceTilmeld(this);
1565
+ if (!tilmeld.gatekeeper()) {
1566
+ return { result: false, message: 'You must be logged in.' };
1567
+ }
1496
1568
  this.$nymph.config.debugLog(
1497
1569
  'tilmeld',
1498
1570
  `Password change request for "${this.username}".`,
@@ -1500,7 +1572,7 @@ export class User extends AbleObject<UserData> {
1500
1572
  if (!('newPassword' in data) || !data.newPassword.length) {
1501
1573
  return { result: false, message: 'Please specify a password.' };
1502
1574
  }
1503
- if (!this.$checkPassword(data.currentPassword ?? '')) {
1575
+ if (!(await this.$checkPassword(data.currentPassword ?? ''))) {
1504
1576
  this.$nymph.config.debugLog(
1505
1577
  'tilmeld',
1506
1578
  `Incorrect password for change request for "${this.username}".`,
@@ -1513,11 +1585,15 @@ export class User extends AbleObject<UserData> {
1513
1585
  }
1514
1586
  try {
1515
1587
  await this.$save();
1516
- if (data.revokeCurrentTokens) {
1517
- const tilmeld = enforceTilmeld(this);
1588
+ if (data.revokeCurrentTokens && this.$is(tilmeld.currentUser)) {
1518
1589
  await tilmeld.login(this, true);
1519
1590
  }
1520
- return { result: true, message: 'Your password has been changed.' };
1591
+ return {
1592
+ result: true,
1593
+ message: this.$is(tilmeld.currentUser)
1594
+ ? 'Your password has been changed.'
1595
+ : "User's password has been changed.",
1596
+ };
1521
1597
  } catch (e: any) {
1522
1598
  return { result: false, message: "Couldn't save new password." };
1523
1599
  }
@@ -1529,7 +1605,7 @@ export class User extends AbleObject<UserData> {
1529
1605
  * @param password The new password.
1530
1606
  * @returns The resulting password or hash which is stored in the entity.
1531
1607
  */
1532
- public $password(password: string) {
1608
+ public async $password(password: string) {
1533
1609
  const tilmeld = enforceTilmeld(this);
1534
1610
  switch (tilmeld.config.pwMethod) {
1535
1611
  case 'plain':
@@ -1546,10 +1622,20 @@ export class User extends AbleObject<UserData> {
1546
1622
  case 'salt':
1547
1623
  default:
1548
1624
  this.salt = nanoid();
1549
- this.password = crypto
1550
- .createHash('sha256')
1551
- .update(password + this.salt)
1552
- .digest('base64');
1625
+ const N = 131072;
1626
+ const r = 8;
1627
+ const p = 1;
1628
+ const KEYLEN = 64;
1629
+ const opts = { N, r, p, maxmem: 256 * N * r };
1630
+ const key = await new Promise<Buffer>((resolve, reject) => {
1631
+ crypto.scrypt(password, this.salt ?? '', KEYLEN, opts, (err, key) => {
1632
+ if (err) {
1633
+ return reject(err);
1634
+ }
1635
+ resolve(key);
1636
+ });
1637
+ });
1638
+ this.password = `scrypt$${N}$${r}$${p}$${this.salt}$${key.toString('base64')}`;
1553
1639
  break;
1554
1640
  }
1555
1641
  return this.password;
@@ -1564,7 +1650,11 @@ export class User extends AbleObject<UserData> {
1564
1650
  public async $revokeCurrentTokens(data: {
1565
1651
  password: string;
1566
1652
  }): Promise<{ result: boolean; message: string }> {
1567
- if (!this.$checkPassword(data.password ?? '')) {
1653
+ const tilmeld = enforceTilmeld(this);
1654
+ if (!tilmeld.gatekeeper()) {
1655
+ return { result: false, message: 'You must be logged in.' };
1656
+ }
1657
+ if (!(await this.$checkPassword(data.password ?? ''))) {
1568
1658
  return { result: false, message: 'Incorrect password.' };
1569
1659
  }
1570
1660
 
@@ -1572,12 +1662,18 @@ export class User extends AbleObject<UserData> {
1572
1662
 
1573
1663
  try {
1574
1664
  await this.$save();
1575
- const tilmeld = enforceTilmeld(this);
1576
- await tilmeld.login(this, true);
1577
- return {
1578
- result: true,
1579
- message: 'You have logged out of all other sessions.',
1580
- };
1665
+ if (this.$is(tilmeld.currentUser)) {
1666
+ await tilmeld.login(this, true);
1667
+ return {
1668
+ result: true,
1669
+ message: 'You have logged out of all other sessions.',
1670
+ };
1671
+ } else {
1672
+ return {
1673
+ result: true,
1674
+ message: 'The user has been logged out of all sessions.',
1675
+ };
1676
+ }
1581
1677
  } catch (e: any) {
1582
1678
  return { result: false, message: "Couldn't save revocation date." };
1583
1679
  }
@@ -1657,7 +1753,7 @@ export class User extends AbleObject<UserData> {
1657
1753
  secret: string;
1658
1754
  code: string;
1659
1755
  }): Promise<{ result: boolean; message: string }> {
1660
- if (!this.$checkPassword(data.password ?? '')) {
1756
+ if (!(await this.$checkPassword(data.password ?? ''))) {
1661
1757
  return { result: false, message: 'Incorrect password.' };
1662
1758
  }
1663
1759
 
@@ -1720,7 +1816,7 @@ export class User extends AbleObject<UserData> {
1720
1816
  const tilmeld = enforceTilmeld(this);
1721
1817
 
1722
1818
  if (data) {
1723
- if (!this.$checkPassword(data.password ?? '')) {
1819
+ if (!(await this.$checkPassword(data.password ?? ''))) {
1724
1820
  return { result: false, message: 'Incorrect password.' };
1725
1821
  }
1726
1822
 
@@ -2029,7 +2125,7 @@ export class User extends AbleObject<UserData> {
2029
2125
  return { ...unCheck, loggedin: false };
2030
2126
  }
2031
2127
 
2032
- this.$password(data.password);
2128
+ await this.$password(data.password);
2033
2129
  if (tilmeld.config.regFields.includes('name')) {
2034
2130
  this.name =
2035
2131
  this.nameFirst +
@@ -2089,6 +2185,7 @@ export class User extends AbleObject<UserData> {
2089
2185
  }
2090
2186
  if (!otherUsers.length) {
2091
2187
  this.$grant('system/admin');
2188
+ this.$grant('system/autogranted');
2092
2189
  this.enabled = true;
2093
2190
  madeAdmin = true;
2094
2191
  }
@@ -2452,12 +2549,13 @@ export class User extends AbleObject<UserData> {
2452
2549
  // be able to cancel the email change from their old address.
2453
2550
  this.cancelEmailAddress = this.$originalEmail;
2454
2551
  this.cancelEmailSecret = nanoid();
2552
+ this.emailChangeDate = Date.now();
2455
2553
  sendVerification = true;
2456
2554
  }
2457
2555
  }
2458
2556
 
2459
2557
  if (this.passwordTemp != null) {
2460
- this.$password(this.passwordTemp);
2558
+ await this.$password(this.passwordTemp);
2461
2559
  }
2462
2560
  delete this.passwordTemp;
2463
2561
 
@@ -61,21 +61,32 @@ export default {
61
61
  jwtExpire: 60 * 60 * 24 * 7 * 24, // 24 weeks(ish) (6 months)
62
62
  jwtSwitchExpire: 60 * 60 * 6, // 6 hours
63
63
  jwtRenew: 60 * 60 * 24 * 7 * 12, // 12 weeks(ish) (3 months)
64
- jwtBuilder: (config, user, switchToken) => {
64
+ jwtBuilder: (config, user, switchToken, switchForUser) => {
65
65
  const secret = config.jwtSecret;
66
66
  if (secret === '') {
67
67
  throw new Error('JWT secret is not configured.');
68
68
  }
69
69
 
70
+ if (switchToken && (!switchForUser || switchForUser.guid == null)) {
71
+ throw new Error(
72
+ "Issuing a switch token requires providing the user it's for.",
73
+ );
74
+ }
75
+
70
76
  return jwt.sign(
71
77
  {
72
78
  iss: config.appUrl,
79
+ sub: switchForUser ? switchForUser.guid : user.guid,
73
80
  nbf: Math.floor(Date.now() / 1000),
74
81
  exp:
75
82
  Math.floor(Date.now() / 1000) +
76
83
  (switchToken ? config.jwtSwitchExpire : config.jwtExpire),
84
+ // TODO: remove this guid claim in favor of sub claim
85
+ // this needs to be done long enough after Nov 1 2026, when sub added
86
+ // also don't forget to change how switchForUser guid is stored.
77
87
  guid: user.guid,
78
88
  xsrfToken: 'TILMELDXSRF-' + nanoid(),
89
+ ...(switchToken ? { switch: true } : {}),
79
90
  },
80
91
  secret,
81
92
  );
@@ -96,7 +107,14 @@ export default {
96
107
  return null;
97
108
  }
98
109
 
99
- const { guid, xsrfToken: jwtXsrfToken, exp, nbf } = payload;
110
+ const {
111
+ sub,
112
+ guid,
113
+ xsrfToken: jwtXsrfToken,
114
+ switch: switchBool,
115
+ exp,
116
+ nbf,
117
+ } = payload;
100
118
 
101
119
  if (xsrfToken != null && xsrfToken !== jwtXsrfToken) {
102
120
  return null;
@@ -118,7 +136,7 @@ export default {
118
136
 
119
137
  const expire = new Date(exp * 1000);
120
138
 
121
- return { guid, issued, expire };
139
+ return { sub, guid, issued, expire, switch: switchBool };
122
140
  } catch (e: any) {
123
141
  return null;
124
142
  }
package/src/conf/index.ts CHANGED
@@ -164,12 +164,18 @@ export interface Config {
164
164
  */
165
165
  pwRecoveryTimeLimit: string;
166
166
  /**
167
- * Method used to store passwords. Salt is more secure if the database is
168
- * compromised. Plain: store the password in plaintext. Digest: store the
169
- * password's digest (hash). Salt: store the password's digest using a
170
- * complex, unique salt.
167
+ * Method used to store passwords. Salt is the most secure if the database is
168
+ * compromised.
171
169
  *
172
- * Digests are SHA-256, so a salt probably isn't necessary, but who knows.
170
+ * - Plain: Store the password in plain text. A very bad idea.
171
+ * - Digest: Store the password's digest (hash) using SHA-256. The minimum
172
+ * level of security, but not enough for modern apps.
173
+ * - Salt: Store the password's digest with a complex, unique salt using
174
+ * the scrypt algorithm. Modern, secure password storage.
175
+ *
176
+ * Really the only reason to use anything other than "salt" is if you need
177
+ * logins to be so incredibly fast you can't even perceive a delay. So, not in
178
+ * a web app.
173
179
  *
174
180
  * Options are: "plain", "digest", "salt"
175
181
  */
@@ -265,7 +271,12 @@ export interface Config {
265
271
  /**
266
272
  * Function to build the JWT for user sessions.
267
273
  */
268
- jwtBuilder: (config: Config, user: User, switchToken?: boolean) => string;
274
+ jwtBuilder: (
275
+ config: Config,
276
+ user: User,
277
+ switchToken?: boolean,
278
+ switchForUser?: User,
279
+ ) => string;
269
280
  /**
270
281
  * Function to verify that a JWT was signed with the secret key, validate its
271
282
  * data, validate the XSRF token, and extract the GUID and dates.
@@ -279,7 +290,13 @@ export interface Config {
279
290
  config: Config,
280
291
  token: string,
281
292
  xsrfToken?: string,
282
- ) => { guid: string; issued: Date; expire: Date } | null;
293
+ ) => {
294
+ sub: string;
295
+ guid: string;
296
+ issued: Date;
297
+ expire: Date;
298
+ switch?: true;
299
+ } | null;
283
300
  /**
284
301
  * The absolute path to the email template directory. Used by the default
285
302
  * email sender.
@@ -17,6 +17,7 @@ import { GroupTest } from './Group.testArtifacts.js';
17
17
 
18
18
  export interface TestModelData extends AccessControlData {
19
19
  name?: string;
20
+ another?: TestModel;
20
21
  }
21
22
 
22
23
  export interface TestModel extends TestModelData {}
@@ -147,7 +148,7 @@ export function TilmeldTest(
147
148
  admin.nameFirst = 'Admin';
148
149
  admin.nameLast = 'User';
149
150
  admin.name = 'Admin User';
150
- admin.$password('password');
151
+ await admin.$password('password');
151
152
  admin.$grant('system/admin');
152
153
  await admin.$saveSkipAC();
153
154
  }
@@ -165,7 +166,7 @@ export function TilmeldTest(
165
166
  bob.nameFirst = 'Bob';
166
167
  bob.nameLast = 'User';
167
168
  bob.name = 'Bob User';
168
- bob.$password('password');
169
+ await bob.$password('password');
169
170
  bob.$addGroup(abgroup);
170
171
  await bob.$saveSkipAC();
171
172
  }
@@ -176,7 +177,7 @@ export function TilmeldTest(
176
177
  alice.nameFirst = 'Alice';
177
178
  alice.nameLast = 'User';
178
179
  alice.name = 'Alice User';
179
- alice.$password('password');
180
+ await alice.$password('password');
180
181
  alice.$addGroup(abgroup);
181
182
  await alice.$saveSkipAC();
182
183
  }
@@ -1223,6 +1224,53 @@ export function TilmeldTest(
1223
1224
  expect(verifyEntityAlice).toBeNull();
1224
1225
  });
1225
1226
 
1227
+ it("alice can't find an entity by qref using an entity she doesn't have permission for", async () => {
1228
+ const { bob, alice } = await makeUsers();
1229
+
1230
+ await tilmeld.fillSession(bob);
1231
+
1232
+ const bobsEntity = await TestModel.factory();
1233
+ bobsEntity.name = "Bob's Entity";
1234
+ await bobsEntity.$save();
1235
+
1236
+ expect(bobsEntity.guid).not.toBeNull();
1237
+ expect(bob.$is(bobsEntity.user)).toEqual(true);
1238
+
1239
+ const testEntityBob = await nymph.getEntity(
1240
+ { class: TestModel },
1241
+ { type: '&', guid: bobsEntity.guid || '' },
1242
+ );
1243
+
1244
+ expect(testEntityBob).not.toBeNull();
1245
+ expect(testEntityBob?.guid).toEqual(bobsEntity.guid);
1246
+
1247
+ tilmeld.clearSession();
1248
+ await tilmeld.fillSession(alice);
1249
+
1250
+ const alicesEntity = await TestModel.factory();
1251
+ alicesEntity.name = "Alice's Entity";
1252
+ await alicesEntity.$save();
1253
+
1254
+ expect(alicesEntity.guid).not.toBeNull();
1255
+ expect(alice.$is(alicesEntity.user)).toEqual(true);
1256
+
1257
+ alicesEntity.another = bobsEntity;
1258
+ await alicesEntity.$save();
1259
+
1260
+ const testEntityAlice = await nymph.getEntity(
1261
+ { class: TestModel },
1262
+ {
1263
+ type: '&',
1264
+ qref: [
1265
+ 'another',
1266
+ [{ class: TestModel }, { type: '&', guid: bobsEntity.guid || '' }],
1267
+ ],
1268
+ },
1269
+ );
1270
+
1271
+ expect(testEntityAlice).toBeNull();
1272
+ });
1273
+
1226
1274
  it('a clear session can delete an entity everyone has full permission for', async () => {
1227
1275
  const { bob } = await makeUsers();
1228
1276