-
-
Notifications
You must be signed in to change notification settings - Fork 137
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1149 from FoalTS/password-iterations
Increase password hashing iterations
- Loading branch information
Showing
15 changed files
with
368 additions
and
57 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
107 changes: 107 additions & 0 deletions
107
...ntication-and-access-control/password-management/upgrading-old-password-haches.feature.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string> { | ||
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<any> { | ||
|
||
/* ======================= 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); | ||
}); | ||
|
||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
export { hashPassword } from './hash-password'; | ||
export { passwordHashNeedsToBeRefreshed } from './password-hash-needs-to-be-refreshed'; | ||
export { verifyPassword } from './verify-password'; |
25 changes: 25 additions & 0 deletions
25
packages/core/src/common/auth/passwords/password-hash-needs-to-be-refreshed.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); |
7 changes: 7 additions & 0 deletions
7
packages/core/src/common/auth/passwords/password-hash-needs-to-be-refreshed.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
86 changes: 86 additions & 0 deletions
86
packages/core/src/common/auth/passwords/utils/decompose-pbkdf2-password-hash.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<typeof decomposePbkdf2PasswordHash> = { | ||
derivedKey: derivedKeyBuffer, | ||
digestAlgorithm, | ||
iterations, | ||
keyLength, | ||
salt: saltBuffer, | ||
}; | ||
|
||
deepStrictEqual(actual, expected); | ||
}); | ||
|
||
}); |
Oops, something went wrong.