diff --git a/packages/security/README.md b/packages/security/README.md index bfa46df96..ff4de7de0 100644 --- a/packages/security/README.md +++ b/packages/security/README.md @@ -33,6 +33,32 @@ const isMatch = await security.password.compare('super-secret', hash); - `security.string`: safe slug-style string normalization. - `security.identifier`: legacy identifier helper (`uid`, deprecated). +### Password reset tokens + +`security.tokens.resetToken.generateHash()` and `compare()` require a +`passwordHash` option containing the user's existing stored password hash. +Do not pass a plaintext password. The stored hash invalidates reset tokens +after a password change; these helpers do not hash passwords for storage. + +```js +const token = security.tokens.resetToken.generateHash({ + expires, + email, + dbHash, + passwordHash: storedPasswordHash, +}); + +const result = security.tokens.resetToken.compare({ + token, + dbHash, + passwordHash: storedPasswordHash, +}); +``` + +This option was renamed from `password` to `passwordHash`. Callers must update +both methods when upgrading. The token format is unchanged, so existing reset +tokens remain valid with the same stored hash, site secret, and expiry. + # Copyright & License Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/packages/security/lib/tokens.js b/packages/security/lib/tokens.js index bff3f3286..d951641bf 100644 --- a/packages/security/lib/tokens.js +++ b/packages/security/lib/tokens.js @@ -40,15 +40,14 @@ module.exports.resetToken = { const expires = options.expires; const email = options.email; const dbHash = options.dbHash; - const password = options.password; + const passwordHash = options.passwordHash; let text = ''; hash.update(String(expires)); hash.update(email.toLocaleLowerCase()); // Reset tokens are not password storage; the current password hash is // mixed in only to invalidate old tokens after password changes. - // codeql[js/insufficient-password-hash] - hash.update(password); + hash.update(passwordHash); hash.update(String(dbHash)); text += [expires, email, hash.digest('base64')].join('|'); @@ -84,7 +83,7 @@ module.exports.resetToken = { const tokenToCompare = options.token; const parts = exports.resetToken.extract({ token: tokenToCompare }); const dbHash = options.dbHash; - const password = options.password; + const passwordHash = options.passwordHash; let generatedToken; let diff = 0; let i; @@ -108,7 +107,7 @@ module.exports.resetToken = { email: parts.email, expires: parts.expires, dbHash: dbHash, - password: password, + passwordHash: passwordHash, }); if (tokenToCompare.length !== generatedToken.length) { diff --git a/packages/security/test/tokens.test.js b/packages/security/test/tokens.test.js index b7e957337..abd0bfd29 100644 --- a/packages/security/test/tokens.test.js +++ b/packages/security/test/tokens.test.js @@ -2,6 +2,9 @@ const assert = require('assert/strict'); const crypto = require('crypto'); const security = require('../'); +const passwordHash = '$2a$10$t5dY1uRRdjvqfNlXhae3uuc0nuhi.Rd7/K/9JaHHwSkLm6UUa3NsW'; +const differentPasswordHash = '$2a$10$t5dY1uRRdjvqfNlXhae3uuc0nuhi.Rd7/K/9JaHHwSkLm6UUa3NsX'; + describe('Utils: tokens', function () { it('covers default options branches for token helpers', function () { assert.throws(() => security.tokens.generateFromContent()); @@ -45,7 +48,7 @@ describe('Utils: tokens', function () { token = security.tokens.resetToken.generateHash({ email: 'test1@ghost.org', expires: expires, - password: 'password', + passwordHash: passwordHash, dbHash: dbHash, }); @@ -53,6 +56,28 @@ describe('Utils: tokens', function () { assert.equal(token.length > 0, true); }); + it('preserves reset tokens generated before the option rename', function () { + const expires = 4102444800000; + const email = 'Mixed+Case@example.com'; + const dbHash = 'test-site-secret'; + // Generated by the previous API with the same stored password hash. + const existingToken = + 'NDEwMjQ0NDgwMDAwMHxNaXhlZCtDYXNlQGV4YW1wbGUuY29tfGxQbk0vVmpEQXJpdk5XSWZyU1JUTnRtK2ZEdG5TMTlkLzhvejQ4RWlsOFU9'; + + assert.equal( + security.tokens.resetToken.generateHash({ expires, email, dbHash, passwordHash }), + existingToken, + ); + assert.deepEqual( + security.tokens.resetToken.compare({ token: existingToken, dbHash, passwordHash }), + { correct: true }, + ); + assert.deepEqual(security.tokens.resetToken.extract({ token: existingToken }), { + expires, + email, + }); + }); + it('compare: success', function () { const expires = Date.now() + 60 * 1000; const dbHash = crypto.randomUUID(); @@ -62,21 +87,21 @@ describe('Utils: tokens', function () { token = security.tokens.resetToken.generateHash({ email: 'test1@ghost.org', expires: expires, - password: '12345678', + passwordHash: passwordHash, dbHash: dbHash, }); tokenIsCorrect = security.tokens.resetToken.compare({ token: token, dbHash: dbHash, - password: '12345678', + passwordHash: passwordHash, }); assert.equal(tokenIsCorrect.correct, true); assert.equal(tokenIsCorrect.reason, undefined); }); - it('compare: error from invalid password', function () { + it('compare: error from changed password hash', function () { const expires = Date.now() + 60 * 1000; const dbHash = crypto.randomUUID(); let token; @@ -85,14 +110,14 @@ describe('Utils: tokens', function () { token = security.tokens.resetToken.generateHash({ email: 'test1@ghost.org', expires: expires, - password: '12345678', + passwordHash: passwordHash, dbHash: dbHash, }); tokenIsCorrect = security.tokens.resetToken.compare({ token: token, dbHash: dbHash, - password: '123456', + passwordHash: differentPasswordHash, }); assert.equal(tokenIsCorrect.correct, false); @@ -108,14 +133,14 @@ describe('Utils: tokens', function () { token = security.tokens.resetToken.generateHash({ email: 'test1@ghost.org', expires: invalidDate, - password: '12345678', + passwordHash: passwordHash, dbHash: dbHash, }); tokenIsCorrect = security.tokens.resetToken.compare({ token: token, dbHash: dbHash, - password: '123456', + passwordHash: differentPasswordHash, }); assert.equal(tokenIsCorrect.correct, false); @@ -131,14 +156,14 @@ describe('Utils: tokens', function () { token = security.tokens.resetToken.generateHash({ email: 'test1@ghost.org', expires: dateInThePast, - password: '12345678', + passwordHash: passwordHash, dbHash: dbHash, }); tokenIsCorrect = security.tokens.resetToken.compare({ token: token, dbHash: dbHash, - password: '123456', + passwordHash: differentPasswordHash, }); assert.equal(tokenIsCorrect.correct, false); @@ -155,7 +180,7 @@ describe('Utils: tokens', function () { token = security.tokens.resetToken.generateHash({ email: email, expires: expires, - password: '12345678', + passwordHash: passwordHash, dbHash: dbHash, }); @@ -165,7 +190,7 @@ describe('Utils: tokens', function () { assert.equal(parts.email, email); assert.equal(parts.expires, expires); - assert.equal(parts.password, undefined); + assert.equal(parts.passwordHash, undefined); assert.equal(parts.dbHash, undefined); }); @@ -179,7 +204,7 @@ describe('Utils: tokens', function () { token = security.tokens.resetToken.generateHash({ email: email, expires: expires, - password: '$2a$10$t5dY1uRRdjvqfNlXhae3uuc0nuhi.Rd7/K/9JaHHwSkLm6UUa3NsW', + passwordHash: passwordHash, dbHash: dbHash, }); @@ -189,7 +214,7 @@ describe('Utils: tokens', function () { assert.equal(parts.email, email); assert.equal(parts.expires, expires); - assert.equal(parts.password, undefined); + assert.equal(parts.passwordHash, undefined); assert.equal(parts.dbHash, undefined); }); @@ -211,7 +236,7 @@ describe('Utils: tokens', function () { token = security.tokens.resetToken.generateHash({ email: email, expires: expires, - password: '12345678', + passwordHash: passwordHash, dbHash: dbHash, }); @@ -230,7 +255,7 @@ describe('Utils: tokens', function () { tokenIsCorrect = security.tokens.resetToken.compare({ token: token, dbHash: dbHash, - password: '12345678', + passwordHash: passwordHash, }); assert.equal(tokenIsCorrect.correct, true); @@ -243,7 +268,7 @@ describe('Utils: tokens', function () { const token = security.tokens.resetToken.generateHash({ email: 'test4@ghost.org', expires, - password: '12345678', + passwordHash: passwordHash, dbHash, }); @@ -252,7 +277,7 @@ describe('Utils: tokens', function () { const tokenIsCorrect = security.tokens.resetToken.compare({ token: mismatchedLengthToken, dbHash, - password: '12345678', + passwordHash: passwordHash, }); assert.equal(tokenIsCorrect.correct, false);