diff --git a/docs/blog/version-2.11-release-notes.md b/docs/blog/version-2.11-release-notes.md index aa6457fed6..53b1dc9031 100644 --- a/docs/blog/version-2.11-release-notes.md +++ b/docs/blog/version-2.11-release-notes.md @@ -12,4 +12,38 @@ tags: [release] Version 2.11 of [Foal](https://foalts.org/) is out! Here are the improvements that it brings: - \ No newline at end of file + + +## Number of Iterations on Password Hashing Has Been Increased + +The PBKDF2 algorithm (used for password hashing) uses a number of iterations to hash passwords. This work factor is deliberate and slows down potential attackers, making attacks against hashed passwords more difficult. + +As computing power increases, the number of iterations must also increase. This is why, starting with version 2.11, the number of iterations has been increased to 310,000. + +To check that a password hash is using the latest recommended number of iterations, you can use the `passwordHashNeedsToBeRefreshed` function. + +The example below shows how to perform this check during a login and how to upgrade the password hash if the number of iterations turns out to be too low. + +```typescript +const { email, password } = ctx.request.body; + +const user = await User.findOne({ email }); + +if (!user) { + return new HttpResponseUnauthorized(); +} + +if (!await verifyPassword(password, user.password)) { + return new HttpResponseUnauthorized(); +} + +// highlight-start +// This line must be after the password verification. +if (passwordHashNeedsToBeRefreshed(user.password)) { + user.password = await hashPassword(password); + await user.save(); +} +// highlight-end + +// Log the user in. +``` \ No newline at end of file diff --git a/docs/docs/authentication-and-access-control/password-management.md b/docs/docs/authentication-and-access-control/password-management.md index c2909588ef..6baabe40c2 100644 --- a/docs/docs/authentication-and-access-control/password-management.md +++ b/docs/docs/authentication-and-access-control/password-management.md @@ -3,29 +3,62 @@ title: Password Management sidebar_label: Passwords --- +Passwords must never be stored in the database in plain text. If they were and attackers were to gain access to the database, all passwords would be compromised. To prevent this, they must be hashed and salted and their hashes stored. Foal provides two functions for this purpose. -Every application must store passwords using a cryptographic technique. FoalTS provides two functions to hash and verify passwords. +## Hashing and Salting Passwords -## Hash and Salt Passwords - -The `hashPassword` utility uses the [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) algorithm with a SHA256 hash. It takes as parameters the password in plain text and an optional `options` object. It returns a promise which value is a password hash. - -> The function generates a unique cryptographically-strong random salt for each password. This salt is returned by the function beside the password hash. +The `hashPassword` function hashes and salts a plain text password and returns a password hash. The returned value is meant to be stored in the database and used by the `verifyPassword` function. ```typescript const passwordHash = await hashPassword(plainTextPassword); ``` -## Verify Passwords +> *Note: password hashes are generated using [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) and HMAC-SHA256.* + +## Verifying Passwords -The `verifyPassword` takes three arguments: -- the password to check in plain text, -- and the password hash (usually fetched from the database). +In order to verify that a password is correct when logging in, the `verifyPassword` function can be used. It takes as parameters the plaintext password that is being tested and the hash of the password stored in the database. It returns a promise whose value is a boolean. ```typescript const isEqual = await verifyPassword(plainTextPassword, passwordHash); ``` +## Password Upgrading + +> *This feature is available from version 2.11 onwards.* + +The PBKDF2 algorithm uses a number of iterations to hash passwords. This work factor is deliberate and slows down potential attackers, making attacks against hashed passwords more difficult. + +As computing power increases, the number of iterations must also increase. This is why the latest versions of Foal use a higher number of iterations. + +To check that a password hash is using the latest recommended number of iterations, you can use the `passwordHashNeedsToBeRefreshed` function. + +The example below shows how to perform this check during a login and how to upgrade the password hash if the number of iterations turns out to be too low. + +```typescript +const { email, password } = ctx.request.body; + +const user = await User.findOne({ email }); + +if (!user) { + return new HttpResponseUnauthorized(); +} + +if (!await verifyPassword(password, user.password)) { + return new HttpResponseUnauthorized(); +} + +// highlight-start +// This line must be after the password verification. +if (passwordHashNeedsToBeRefreshed(user.password)) { + user.password = await hashPassword(password); + await user.save(); +} +// highlight-end + +// Log the user in. +``` + ## Forbid Overly Common Passwords ``` diff --git a/packages/acceptance-tests/src/docs/authentication-and-access-control/password-management/upgrading-old-password-haches.feature.ts b/packages/acceptance-tests/src/docs/authentication-and-access-control/password-management/upgrading-old-password-haches.feature.ts new file mode 100644 index 0000000000..c7195ea621 --- /dev/null +++ b/packages/acceptance-tests/src/docs/authentication-and-access-control/password-management/upgrading-old-password-haches.feature.ts @@ -0,0 +1,107 @@ +// std +import { notStrictEqual, strictEqual} from 'assert'; +import { pbkdf2, randomBytes } from 'crypto'; +import { promisify } from 'util'; + +// FoalTS +import { hashPassword, HttpResponseOK, HttpResponseUnauthorized, isHttpResponseOK, passwordHashNeedsToBeRefreshed, verifyPassword } from '@foal/core'; +import { BaseEntity, Column, Connection, Entity, PrimaryGeneratedColumn } from '@foal/typeorm/node_modules/typeorm'; +import { createTestConnection } from '../../../common'; + +describe('Feature: Upgrading passwords', () => { + + let connection: Connection; + + afterEach(async () => { + if (connection) { + await connection.close(); + } + }); + + it('Example: simple example.', async () => { + + async function hashPasswordWithOldNumberOfIterations(plainTextPassword: string): Promise { + const saltBuffer = await promisify(randomBytes)(16); + const iterations = 150000; + const keylen = 32; + const digest = 'sha256'; + const derivedKeyBuffer = await promisify(pbkdf2)(plainTextPassword, saltBuffer, iterations, keylen, digest); + + const salt = saltBuffer.toString('base64'); + const derivedKey = derivedKeyBuffer.toString('base64'); + return `pbkdf2_${digest}$${iterations}$${salt}$${derivedKey}`; + } + + @Entity() + class User extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + email: string; + + @Column() + password: string; + } + + async function login(email: string, password: string): Promise { + + /* ======================= DOCUMENTATION BEGIN ======================= */ + + const user = await User.findOne({ email }); + + if (!user) { + return new HttpResponseUnauthorized(); + } + + if (!await verifyPassword(password, user.password)) { + return new HttpResponseUnauthorized(); + } + + // highlight-start + // This line must be after the password verification. + if (passwordHashNeedsToBeRefreshed(user.password)) { + user.password = await hashPassword(password); + await user.save(); + } + // highlight-end + + // Log the user in. + + /* ======================= DOCUMENTATION END ========================= */ + + return new HttpResponseOK(); + } + + connection = await createTestConnection([ User ]); + + const plainTextPassword = 'password'; + + const user = new User(); + user.email = 'foo@foalts.org'; + user.password = await hashPasswordWithOldNumberOfIterations(plainTextPassword); + await user.save(); + + const passwordHash = user.password; + strictEqual(user.password.split('$')[1], '150000'); + + const result = await login(user.email, plainTextPassword); + if (!isHttpResponseOK(result)) { + throw new Error('The function should have returned a HttpResponseOK.'); + } + + await user.reload(); + notStrictEqual(user.password, passwordHash); + strictEqual(user.password.split('$')[1], '310000'); + + const passwordHash2 = user.password; + const result2 = await login(user.email, plainTextPassword); + if (!isHttpResponseOK(result2)) { + throw new Error('The function should have returned a HttpResponseOK.'); + } + + await user.reload(); + strictEqual(user.password, passwordHash2); + }); + +}); diff --git a/packages/core/src/common/auth/passwords/hash-password.spec.ts b/packages/core/src/common/auth/passwords/hash-password.spec.ts index 841d1fb113..bc9a75ea70 100644 --- a/packages/core/src/common/auth/passwords/hash-password.spec.ts +++ b/packages/core/src/common/auth/passwords/hash-password.spec.ts @@ -8,18 +8,18 @@ import { hashPassword } from './hash-password'; describe('hashPassword', () => { it('should hash the plain password into a 32-byte derived key with PBKDF2/SHA256,' - + ' 150 000 iterations and a 16-byte random salt.', async () => { + + ' 310 000 iterations and a 16-byte random salt.', async () => { const plainPassword = 'hello world'; const actual = await hashPassword(plainPassword); const [ algorithm, iterations, salt, derivedKey ] = actual.split('$'); strictEqual(algorithm, 'pbkdf2_sha256'); - strictEqual(parseInt(iterations, 10), 150000); + strictEqual(parseInt(iterations, 10), 310000); strictEqual(Buffer.from(salt, 'base64').length, 16); strictEqual(Buffer.from(derivedKey, 'base64').length, 32); - const expectedBuffer = await pbkdf2Sync(plainPassword, Buffer.from(salt, 'base64'), 150000, 32, 'sha256'); + const expectedBuffer = await pbkdf2Sync(plainPassword, Buffer.from(salt, 'base64'), 310000, 32, 'sha256'); strictEqual(derivedKey, expectedBuffer.toString('base64')); }); diff --git a/packages/core/src/common/auth/passwords/hash-password.ts b/packages/core/src/common/auth/passwords/hash-password.ts index 3eca22385d..356db96a72 100644 --- a/packages/core/src/common/auth/passwords/hash-password.ts +++ b/packages/core/src/common/auth/passwords/hash-password.ts @@ -1,6 +1,8 @@ import { pbkdf2, randomBytes } from 'crypto'; import { promisify } from 'util'; +export const PASSWORD_ITERATIONS = 310000; + /** * Hash a password using the PBKDF2 algorithm. * @@ -8,7 +10,7 @@ import { promisify } from 'util'; * The result is a 64 byte binary string. * * The random salt is 16 bytes long. - * The number of iterations is 150000. + * The number of iterations is 310000. * The length key is 32 bytes long. * * @export @@ -17,7 +19,7 @@ import { promisify } from 'util'; */ export async function hashPassword(plainTextPassword: string): Promise { const saltBuffer = await promisify(randomBytes)(16); - const iterations = 150000; + const iterations = PASSWORD_ITERATIONS; const keylen = 32; const digest = 'sha256'; const derivedKeyBuffer = await promisify(pbkdf2)(plainTextPassword, saltBuffer, iterations, keylen, digest); diff --git a/packages/core/src/common/auth/passwords/index.ts b/packages/core/src/common/auth/passwords/index.ts index c94c915197..0d2cb3838a 100644 --- a/packages/core/src/common/auth/passwords/index.ts +++ b/packages/core/src/common/auth/passwords/index.ts @@ -1,2 +1,3 @@ export { hashPassword } from './hash-password'; +export { passwordHashNeedsToBeRefreshed } from './password-hash-needs-to-be-refreshed'; export { verifyPassword } from './verify-password'; \ No newline at end of file diff --git a/packages/core/src/common/auth/passwords/password-hash-needs-to-be-refreshed.spec.ts b/packages/core/src/common/auth/passwords/password-hash-needs-to-be-refreshed.spec.ts new file mode 100644 index 0000000000..106ab7651a --- /dev/null +++ b/packages/core/src/common/auth/passwords/password-hash-needs-to-be-refreshed.spec.ts @@ -0,0 +1,25 @@ +// std +import { strictEqual } from 'assert'; + +// FoalTS +import { PASSWORD_ITERATIONS } from './hash-password'; +import { passwordHashNeedsToBeRefreshed } from './password-hash-needs-to-be-refreshed'; + +describe('passwordHashNeedsToBeRefreshed', () => { + function createPasswordHash(iterations: number): string { + return `pbkdf2_sha256$${iterations}$salt$derivedKey`; + } + + it('should return true if the number of iterations of the password hash is less than the current number of iterations of "hashPassword".', () => { + const passwordHash = createPasswordHash(PASSWORD_ITERATIONS - 1); + strictEqual(passwordHashNeedsToBeRefreshed(passwordHash), true); + }); + it('should return false if the number of iterations of the password hash is greater than the current number of iterations of "hashPassword".', () => { + const passwordHash = createPasswordHash(PASSWORD_ITERATIONS + 1); + strictEqual(passwordHashNeedsToBeRefreshed(passwordHash), false); + }); + it('should return false if the number of iterations of the password hash is equal to the current number of iterations of "hashPassword".', () => { + const passwordHash = createPasswordHash(PASSWORD_ITERATIONS); + strictEqual(passwordHashNeedsToBeRefreshed(passwordHash), false); + }); +}); \ No newline at end of file diff --git a/packages/core/src/common/auth/passwords/password-hash-needs-to-be-refreshed.ts b/packages/core/src/common/auth/passwords/password-hash-needs-to-be-refreshed.ts new file mode 100644 index 0000000000..3ef96ab12a --- /dev/null +++ b/packages/core/src/common/auth/passwords/password-hash-needs-to-be-refreshed.ts @@ -0,0 +1,7 @@ +import { PASSWORD_ITERATIONS } from './hash-password'; +import { decomposePbkdf2PasswordHash } from './utils'; + +export function passwordHashNeedsToBeRefreshed(passwordHash: string): boolean { + const { iterations } = decomposePbkdf2PasswordHash(passwordHash); + return iterations < PASSWORD_ITERATIONS; +} \ No newline at end of file diff --git a/packages/core/src/common/auth/passwords/utils/decompose-pbkdf2-password-hash.spec.ts b/packages/core/src/common/auth/passwords/utils/decompose-pbkdf2-password-hash.spec.ts new file mode 100644 index 0000000000..8f4d57a98d --- /dev/null +++ b/packages/core/src/common/auth/passwords/utils/decompose-pbkdf2-password-hash.spec.ts @@ -0,0 +1,86 @@ +// std +import { deepStrictEqual, throws } from 'assert'; + +// FoalTS +import { decomposePbkdf2PasswordHash } from './decompose-pbkdf2-password-hash'; + +describe('decomposePbkdf2PasswordHash', () => { + + it('should throw an error if the password hash was NOT generated with PBKDF2.', () => { + const passwordHash = 'foobar'; + + throws( + () => decomposePbkdf2PasswordHash(passwordHash), + new Error('Invalid algorithm.') + ); + }); + + it('should throw an error if the digest algorithm is NOT SHA256.', () => { + const passwordHash = 'pbkdf2_sha1'; + + throws( + () => decomposePbkdf2PasswordHash(passwordHash), + new Error('Invalid algorithm.') + ); + }); + + it('should throw an error if no iterations is found.', () => { + const passwordHash = 'pbkdf2_sha256'; + + throws( + () => decomposePbkdf2PasswordHash(passwordHash), + new Error('Invalid password format.') + ); + }); + + it('should throw an error if the given iterations are NOT a number.', () => { + const passwordHash = 'pbkdf2_sha256$foobar'; + + throws( + () => decomposePbkdf2PasswordHash(passwordHash), + new Error('Invalid password format.') + ); + }); + + it('should throw an error if no salt is found.', () => { + const passwordHash = 'pbkdf2_sha256$100000'; + + throws( + () => decomposePbkdf2PasswordHash(passwordHash), + new Error('Invalid password format.') + ); + }); + + it('should throw an error if no derived key is found.', () => { + const passwordHash = 'pbkdf2_sha256$100000$xxxx'; + + throws( + () => decomposePbkdf2PasswordHash(passwordHash), + new Error('Invalid password format.') + ); + }); + + it('should return the derivedKey, the digest algorithm, the iterations, the salt and the key length.', () => { + const digestAlgorithm = 'sha256'; + const iterations = 90000; + const saltInBase64 = 'hello salt'; + const saltBuffer = Buffer.from(saltInBase64, 'base64'); + const derivedKeyInBase64 = 'hello derived key'; + const derivedKeyBuffer = Buffer.from(derivedKeyInBase64, 'base64'); + const keyLength = derivedKeyBuffer.length; + + const passwordHash = `pbkdf2_${digestAlgorithm}$${iterations}$${saltInBase64}$${derivedKeyInBase64}`; + + const actual = decomposePbkdf2PasswordHash(passwordHash); + const expected: ReturnType = { + derivedKey: derivedKeyBuffer, + digestAlgorithm, + iterations, + keyLength, + salt: saltBuffer, + }; + + deepStrictEqual(actual, expected); + }); + +}); diff --git a/packages/core/src/common/auth/passwords/utils/decompose-pbkdf2-password-hash.ts b/packages/core/src/common/auth/passwords/utils/decompose-pbkdf2-password-hash.ts new file mode 100644 index 0000000000..9564c19072 --- /dev/null +++ b/packages/core/src/common/auth/passwords/utils/decompose-pbkdf2-password-hash.ts @@ -0,0 +1,46 @@ +export function decomposePbkdf2PasswordHash(passwordHash: string): { + digestAlgorithm: 'sha256', + iterations: number, + salt: Buffer, + derivedKey: Buffer, + keyLength: number, +} { + const [ algorithm, iterationsAsString, saltInBase64, derivedKeyInBase64 ] = passwordHash.split('$'); + + if (!algorithm.startsWith('pbkdf2_')) { + throw new Error('Invalid algorithm.'); + } + + const digestAlgorithm = algorithm.split('pbkdf2_')[1]; + if (digestAlgorithm !== 'sha256') { + throw new Error('Invalid algorithm.'); + } + + if (!iterationsAsString) { + throw new Error('Invalid password format.'); + } + + const iterations = parseInt(iterationsAsString, 10); + if (isNaN(iterations)) { + throw new Error('Invalid password format.'); + } + + if (!saltInBase64) { + throw new Error('Invalid password format.'); + } + + if (!derivedKeyInBase64) { + throw new Error('Invalid password format.'); + } + + const derivedKey = Buffer.from(derivedKeyInBase64, 'base64'); + const salt = Buffer.from(saltInBase64, 'base64'); + + return { + digestAlgorithm, + iterations, + salt, + derivedKey, + keyLength: derivedKey.length, + }; +} \ No newline at end of file diff --git a/packages/core/src/common/auth/passwords/utils/index.ts b/packages/core/src/common/auth/passwords/utils/index.ts new file mode 100644 index 0000000000..da5c16e083 --- /dev/null +++ b/packages/core/src/common/auth/passwords/utils/index.ts @@ -0,0 +1 @@ +export { decomposePbkdf2PasswordHash } from './decompose-pbkdf2-password-hash'; \ No newline at end of file diff --git a/packages/core/src/common/auth/passwords/verify-password.spec.ts b/packages/core/src/common/auth/passwords/verify-password.spec.ts index e8891e2662..3aede98ca4 100644 --- a/packages/core/src/common/auth/passwords/verify-password.spec.ts +++ b/packages/core/src/common/auth/passwords/verify-password.spec.ts @@ -1,5 +1,5 @@ // std -import { fail, ok, strictEqual } from 'assert'; +import { ok, strictEqual } from 'assert'; import { pbkdf2Sync } from 'crypto'; // FoalTS @@ -8,28 +8,6 @@ import { verifyPassword } from './verify-password'; describe('verifyPassword', () => { - it('should reject an Error if the password hash format is invalid.', async () => { - return Promise.all([ - verifyPassword('hello world', 'pbkdf256') - .then(() => fail('This promise should be rejected.')) - .catch(error => strictEqual(error.message, 'Invalid algorithm.')), - - verifyPassword('hello world', 'pbkdf2_sha256') - .then(() => fail('This promise should be rejected.')) - .catch(error => strictEqual(error.message, 'Invalid password format.')), - verifyPassword('hello world', 'pbkdf2_sha256$3') - .then(() => fail('This promise should be rejected.')) - .catch(error => strictEqual(error.message, 'Invalid password format.')), - verifyPassword('hello world', 'pbkdf2_sha256$3$aaaa') - .then(() => fail('This promise should be rejected.')) - .catch(error => strictEqual(error.message, 'Invalid password format.')), - - verifyPassword('hello world', 'pbkdf2_sha256$aaa$aaaa$xxx') - .then(() => fail('This promise should be rejected.')) - .catch(error => strictEqual(error.message, 'Invalid password format.')), - ]); - }); - it('should verify passwords based on the specified algorithm, iterations and salt.', async () => { const plainPassword = 'hello world'; diff --git a/packages/core/src/common/auth/passwords/verify-password.ts b/packages/core/src/common/auth/passwords/verify-password.ts index 06a68f156f..2c476537cd 100644 --- a/packages/core/src/common/auth/passwords/verify-password.ts +++ b/packages/core/src/common/auth/passwords/verify-password.ts @@ -1,6 +1,6 @@ -import { strictEqual } from 'assert'; import { pbkdf2, timingSafeEqual } from 'crypto'; import { promisify } from 'util'; +import { decomposePbkdf2PasswordHash } from './utils'; /** * Compare a plain text password and a hash to see if they match. @@ -11,24 +11,14 @@ import { promisify } from 'util'; * @returns {Promise} True if the hash and the password match. False otherwise. */ export async function verifyPassword(plainTextPassword: string, passwordHash: string): Promise { - const [ algorithm, iterations, salt, derivedKey ] = passwordHash.split('$'); + const { digestAlgorithm, iterations, salt, derivedKey, keyLength } = decomposePbkdf2PasswordHash(passwordHash); - strictEqual(algorithm, 'pbkdf2_sha256', 'Invalid algorithm.'); - - strictEqual(typeof iterations, 'string', 'Invalid password format.'); - strictEqual(typeof salt, 'string', 'Invalid password format.'); - strictEqual(typeof derivedKey, 'string', 'Invalid password format.'); - strictEqual(isNaN(parseInt(iterations, 10)), false, 'Invalid password format.'); - - const saltBuffer = Buffer.from(salt, 'base64'); - const derivedKeyBuffer = Buffer.from(derivedKey, 'base64'); - const digest = 'sha256'; // TODO: depends on the algorthim var const password = await promisify(pbkdf2)( plainTextPassword, - saltBuffer, - parseInt(iterations, 10), - derivedKeyBuffer.length, - digest + salt, + iterations, + keyLength, + digestAlgorithm ); - return timingSafeEqual(password, derivedKeyBuffer); + return timingSafeEqual(password, derivedKey); } diff --git a/packages/core/src/core/routes/convert-error-to-response.ts b/packages/core/src/core/routes/convert-error-to-response.ts index 1c12cae08c..04311fd7d2 100644 --- a/packages/core/src/core/routes/convert-error-to-response.ts +++ b/packages/core/src/core/routes/convert-error-to-response.ts @@ -13,7 +13,7 @@ export async function convertErrorToResponse( if (appController.handleError) { try { return await appController.handleError(error, ctx); - } catch (error2) { + } catch (error2: any) { return renderError(error2, ctx); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b892caea4c..083b50df80 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ export { generateToken, getAjvInstance, hashPassword, + passwordHashNeedsToBeRefreshed, isInFile, render, renderToString,