From ecf1041ccecf0c6dbb27087409d0a49baf547076 Mon Sep 17 00:00:00 2001 From: Vilsol Date: Tue, 12 Nov 2024 04:19:24 +0200 Subject: [PATCH] feat: password history --- cfg/config.json | 7 +++++-- package-lock.json | 8 ++++---- package.json | 2 +- src/service.ts | 22 ++++++++++++++++++++++ test/cfg/config.json | 5 ++++- test/service.spec.ts | 37 +++++++++++++++++++++++++++++++++++++ 6 files changed, 73 insertions(+), 8 deletions(-) diff --git a/cfg/config.json b/cfg/config.json index beabfc5..29eef89 100644 --- a/cfg/config.json +++ b/cfg/config.json @@ -25,7 +25,10 @@ "minUsernameLength": 8, "maxUsernameLength": 40, "passwordComplexityMinScore": 3, - "passwordMinLength": 12 + "passwordMinLength": 12, + "passwordHistoryEnabled": true, + "passwordHistorySize": 3, + "passwordHistoryEnforcement": false }, "logger": { "console": { @@ -423,4 +426,4 @@ "users": "./data/seed_data/seed-accounts.json", "roles": "./data/seed_data/seed-roles.json" } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index c5fa90b..a7aadd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@restorecommerce/grpc-client": "^2.2.5", "@restorecommerce/kafka-client": "1.2.18", "@restorecommerce/logger": "^1.3.2", - "@restorecommerce/rc-grpc-clients": "5.1.40", + "@restorecommerce/rc-grpc-clients": "5.1.41", "@restorecommerce/resource-base-interface": "^1.6.2", "@restorecommerce/scs-jobs": "0.1.45", "@restorecommerce/service-config": "^1.0.16", @@ -2212,9 +2212,9 @@ "license": "MIT" }, "node_modules/@restorecommerce/rc-grpc-clients": { - "version": "5.1.40", - "resolved": "https://registry.npmjs.org/@restorecommerce/rc-grpc-clients/-/rc-grpc-clients-5.1.40.tgz", - "integrity": "sha512-Q8qXiPGtIqq/GuWwWq8aaZ2fnnKXIuPCUzqBJHLTnTDNbcIffGeqy47+kglo4a9qgZA1BD86YznmPzwU3HIIfw==", + "version": "5.1.41", + "resolved": "https://registry.npmjs.org/@restorecommerce/rc-grpc-clients/-/rc-grpc-clients-5.1.41.tgz", + "integrity": "sha512-ba0AcdRxN2Rc2UnZUsIweASAMg5w7oW1Uo2qDW92NE8UIUrKDJJdktCUdGlPofZ+subBrSZJUXUqgb8KkIyhGQ==", "license": "MIT", "dependencies": { "@grpc/grpc-js": "^1.12.2", diff --git a/package.json b/package.json index 66bd8d4..43bca5e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@restorecommerce/grpc-client": "^2.2.5", "@restorecommerce/kafka-client": "1.2.18", "@restorecommerce/logger": "^1.3.2", - "@restorecommerce/rc-grpc-clients": "5.1.40", + "@restorecommerce/rc-grpc-clients": "5.1.41", "@restorecommerce/resource-base-interface": "^1.6.2", "@restorecommerce/scs-jobs": "0.1.45", "@restorecommerce/service-config": "^1.0.16", diff --git a/src/service.ts b/src/service.ts index df839a6..3f6bd3d 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1596,6 +1596,28 @@ export class UserService extends ServiceBase impleme return returnOperationStatus(400, `Password is too weak The password score is ${resultPasswordChecker.score}/4, minimum score is ${minScore}. Suggestions: ${resultPasswordChecker.feedback.suggestions} & ${resultPasswordChecker.feedback.warning} User ID ${user.id}`); } + if (this.cfg.get('service:passwordHistoryEnabled')) { + if (!('password_hash_history' in user)) { + user.password_hash_history = []; + } + + if (this.cfg.get('service:passwordHistoryEnforcement')) { + for (const old_hash of user.password_hash_history) { + if (!password.verify(old_hash, newPw)) { + logger.error(`This password has recently been used. User ID:`, user.id); + return returnOperationStatus(400, `This password has recently been used. User ID ${user.id}`); + } + } + } + + user.password_hash_history.unshift(user.password_hash); + + const limit = this.cfg.get('service:passwordHistorySize'); + if (limit > 0) { + user.password_hash_history = user.password_hash_history.slice(0, limit); + } + } + user.password_hash = password.hash(newPw); const updateStatus = await super.update(UserList.fromPartial({ items: [user] diff --git a/test/cfg/config.json b/test/cfg/config.json index 811f603..aad3b1a 100644 --- a/test/cfg/config.json +++ b/test/cfg/config.json @@ -26,7 +26,10 @@ "passwordMinLength": 12, "data": { "url": "https://www.google.com/" - } + }, + "passwordHistoryEnabled": true, + "passwordHistorySize": 3, + "passwordHistoryEnforcement": true }, "logger": { "console": { diff --git a/test/service.spec.ts b/test/service.spec.ts index bbc0f19..9479fbd 100644 --- a/test/service.spec.ts +++ b/test/service.spec.ts @@ -1187,6 +1187,43 @@ describe('testing identity-srv', () => { upUser.activation_code!.should.be.empty(); upUser.password_hash!.should.not.equal(pwHashA); }); + + it('should fail to change the password if it was used before', async function changePasswordFail(): Promise { + // store token to Redis as passwordChange looks up the user based on token (as this operation is for logged in user) + let expires_in = new Date(); // set expires_in to +1 day + expires_in.setDate(expires_in.getDate() + 1); + let userWithToken = { + name: 'test.user1', // user registered initially, storing with token in DB + first_name: 'test', + last_name: 'user', + password: 'CNQJrH%43KAayeDpf3h', + email: 'test@ms.restorecommerce.io', + token: 'user-token', + tokens: [{ + token: 'user-token', + expires_in + }] + }; + const redisConfig = cfg.get('redis'); + // for findByToken + redisConfig.database = cfg.get('redis:db-indexes:db-findByToken') || 0; + tokenRedisClient = RedisCreateClient(redisConfig); + tokenRedisClient.on('error', (err) => logger.error('Redis client error in token cache store', err)); + await tokenRedisClient.connect(); + // store user with tokens and role associations to Redis index `db-findByToken` + await tokenRedisClient.set('user-token', JSON.stringify(userWithToken)); + + this.timeout(30000); + const changeResult = await (userService.changePassword({ + password: 'CNQJrH%44KAayeDpf3h', + new_password: 'CNQJrH%43KAayeDpf3h', + subject: { token: 'user-token' } + })); + should.exist(changeResult); + should.exist(changeResult.operation_status); + changeResult.operation_status!.code!.should.equal(400); + changeResult.operation_status!.message!.should.match(/This password has recently been used. User ID .*/); + }); }); describe('calling changeEmail', function changeEmailId(): void {