From 220c01ec4f7aafc97ed7559782fae3497ead16e4 Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Mon, 5 Apr 2021 16:49:12 -0300 Subject: [PATCH 1/6] Add password history setting and database entries --- app/lib/server/startup/settings.js | 16 ++++++++++++++++ app/models/server/models/Users.js | 6 ++++++ server/methods/saveUserProfile.js | 2 ++ 3 files changed, 24 insertions(+) diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index 90c53f3831e3..a7956842a6f8 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -596,6 +596,22 @@ settings.addGroup('Accounts', function() { enableQuery, }); }); + + this.section('Password_History', function() { + this.add('Accounts_Password_History_Enabled', false, { + type: 'boolean', + }); + + const enableQuery = { + _id: 'Accounts_Password_History_Enabled', + value: true, + }; + + this.add('Accounts_Password_History_Amount', 5, { + type: 'int', + enableQuery, + }); + }); }); settings.addGroup('OAuth', function() { diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index e6e60cd6a03d..60fb3e905cc5 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -1034,6 +1034,12 @@ export class Users extends Base { return this.update(_id, update); } + addPasswordToHistory(_id, password) { + if (settings.get('Accounts_Password_History_Enabled') === true) { + + } + } + setServiceId(_id, serviceName, serviceId) { const update = { $set: {} }; diff --git a/server/methods/saveUserProfile.js b/server/methods/saveUserProfile.js index 9888331914ee..6c693cfd624b 100644 --- a/server/methods/saveUserProfile.js +++ b/server/methods/saveUserProfile.js @@ -81,6 +81,8 @@ function saveUserProfile(settings, customFields) { logout: false, }); + Users.addPasswordToHistory(this.userId, user.services?.password.bcrypt); + try { Meteor.call('removeOtherTokens'); } catch (e) { From d11c8ebec73b7d1dcefa157d3bf2618d4e140cb7 Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Wed, 14 Apr 2021 13:23:51 -0300 Subject: [PATCH 2/6] Add passwordHistory entries and block user from reusing passwords stored in the history --- app/models/server/models/Users.js | 10 ++++- definition/IPassword.ts | 4 ++ definition/IUser.ts | 1 + ...UserPassword.js => compareUserPassword.ts} | 9 ++-- server/lib/compareUserPasswordHistory.ts | 44 +++++++++++++++++++ server/methods/saveUserProfile.js | 7 +++ 6 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 definition/IPassword.ts rename server/lib/{compareUserPassword.js => compareUserPassword.ts} (68%) create mode 100644 server/lib/compareUserPasswordHistory.ts diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 60fb3e905cc5..af9264163b8c 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -1036,7 +1036,15 @@ export class Users extends Base { addPasswordToHistory(_id, password) { if (settings.get('Accounts_Password_History_Enabled') === true) { - + const update = { + $push: { + 'services.passwordHistory': { + $each: [password], + $slice: -Number(settings.get('Accounts_Password_History_Amount')), + }, + }, + }; + return this.update(_id, update); } } diff --git a/definition/IPassword.ts b/definition/IPassword.ts new file mode 100644 index 000000000000..ce53cba6c255 --- /dev/null +++ b/definition/IPassword.ts @@ -0,0 +1,4 @@ +export interface IPassword { + plain?: string; + sha256?: string; +} diff --git a/definition/IUser.ts b/definition/IUser.ts index 9f5f24881344..bca60dba42ad 100644 --- a/definition/IUser.ts +++ b/definition/IUser.ts @@ -37,6 +37,7 @@ export interface IUserServices { password?: { bcrypt: string; }; + passwordHistory?: string[]; email?: { verificationTokens?: IUserEmailVerificationToken[]; }; diff --git a/server/lib/compareUserPassword.js b/server/lib/compareUserPassword.ts similarity index 68% rename from server/lib/compareUserPassword.js rename to server/lib/compareUserPassword.ts index ab50aed6be86..1bd21db1ead0 100644 --- a/server/lib/compareUserPassword.js +++ b/server/lib/compareUserPassword.ts @@ -1,11 +1,14 @@ import { Accounts } from 'meteor/accounts-base'; +import { IUser } from '../../definition/IUser'; +import { IPassword } from '../../definition/IPassword'; + /** * Check if a given password is the one user by given user or if the user doesn't have a password * @param {object} user User object * @param {object} pass Object with { plain: 'plain-test-password' } or { sha256: 'sha256password' } */ -export function compareUserPassword(user, pass) { +export function compareUserPassword(user: IUser, pass: IPassword): boolean { if (!user?.services?.password?.bcrypt?.trim()) { return false; } @@ -15,8 +18,8 @@ export function compareUserPassword(user, pass) { } const password = pass.plain || { - digest: pass.sha256.toLowerCase(), - algorithm: 'sha-256', + digest: pass.sha256 ? pass.sha256.toLowerCase() : '', + algorithm: 'sha-256' as const, }; const passCheck = Accounts._checkPassword(user, password); diff --git a/server/lib/compareUserPasswordHistory.ts b/server/lib/compareUserPasswordHistory.ts new file mode 100644 index 000000000000..8bae150c655b --- /dev/null +++ b/server/lib/compareUserPasswordHistory.ts @@ -0,0 +1,44 @@ +import { Accounts } from 'meteor/accounts-base'; + +import { IUser } from '../../definition/IUser'; +import { IPassword } from '../../definition/IPassword'; + +/** + * Check if a given password is the one user by given user or if the user doesn't have a password + * @param {object} user User object + * @param {object} pass Object with { plain: 'plain-test-password' } or { sha256: 'sha256password' } + */ +export function compareUserPasswordHistory(user: IUser, pass: IPassword): boolean { + if (!user?.services?.passwordHistory) { + return true; + } + + if (!pass || (!pass.plain && !pass.sha256) || !user?.services?.password?.bcrypt) { + return false; + } + + const currentPassword = user.services.password.bcrypt; + + for (const password of user.services.passwordHistory) { + if (!password.trim()) { + user.services.password.bcrypt = currentPassword; + return false; + } + user.services.password.bcrypt = password; + + const historyPassword = pass.plain || { + digest: pass.sha256 ? pass.sha256.toLowerCase() : '', + algorithm: 'sha-256' as const, + }; + + const passCheck = Accounts._checkPassword(user, historyPassword); + + if (!passCheck.error) { + user.services.password.bcrypt = currentPassword; + return false; + } + } + + user.services.password.bcrypt = currentPassword; + return true; +} diff --git a/server/methods/saveUserProfile.js b/server/methods/saveUserProfile.js index 6c693cfd624b..bb8c19c84944 100644 --- a/server/methods/saveUserProfile.js +++ b/server/methods/saveUserProfile.js @@ -8,6 +8,7 @@ import { settings as rcSettings } from '../../app/settings/server'; import { twoFactorRequired } from '../../app/2fa/server/twoFactorRequired'; import { saveUserIdentity } from '../../app/lib/server/functions/saveUserIdentity'; import { compareUserPassword } from '../lib/compareUserPassword'; +import { compareUserPasswordHistory } from '../lib/compareUserPasswordHistory'; function saveUserProfile(settings, customFields) { if (!rcSettings.get('Accounts_AllowUserProfileChange')) { @@ -75,6 +76,12 @@ function saveUserProfile(settings, customFields) { }); } + if (user.services?.passwordHistory && !compareUserPasswordHistory(user, { plain: settings.newPassword })) { + throw new Meteor.Error('error-password-in-history', 'Entered password in history', { + method: 'saveUserProfile', + }); + } + passwordPolicy.validate(settings.newPassword); Accounts.setPassword(this.userId, settings.newPassword, { From 3bfd74d17bf721b484708294b779972ce0787354 Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Thu, 15 Apr 2021 18:45:44 -0300 Subject: [PATCH 3/6] Update addPasswordToHistory to store passwords when the setting is disabled --- app/models/server/models/Users.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index af9264163b8c..6db328f9b48a 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -1035,17 +1035,15 @@ export class Users extends Base { } addPasswordToHistory(_id, password) { - if (settings.get('Accounts_Password_History_Enabled') === true) { - const update = { - $push: { - 'services.passwordHistory': { - $each: [password], - $slice: -Number(settings.get('Accounts_Password_History_Amount')), - }, + const update = { + $push: { + 'services.passwordHistory': { + $each: [password], + $slice: -Number(settings.get('Accounts_Password_History_Amount')), }, - }; - return this.update(_id, update); - } + }, + }; + return this.update(_id, update); } setServiceId(_id, serviceName, serviceId) { From ebb36d32f64b96caed8614126ab66f59e1dc9215 Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Thu, 15 Apr 2021 20:38:12 -0300 Subject: [PATCH 4/6] Update error messages and add translations --- app/lib/server/startup/settings.js | 4 ++++ packages/rocketchat-i18n/i18n/en.i18n.json | 6 ++++++ server/lib/compareUserPasswordHistory.ts | 6 ++++-- server/methods/saveUserProfile.js | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index a7956842a6f8..da6eb9185087 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -600,6 +600,8 @@ settings.addGroup('Accounts', function() { this.section('Password_History', function() { this.add('Accounts_Password_History_Enabled', false, { type: 'boolean', + i18nLabel: 'Enable_Password_History', + i18nDescription: 'Enable_Password_History_Description', }); const enableQuery = { @@ -610,6 +612,8 @@ settings.addGroup('Accounts', function() { this.add('Accounts_Password_History_Amount', 5, { type: 'int', enableQuery, + i18nLabel: 'Password_History_Amount', + i18nDescription: 'Password_History_Amount_Description', }); }); }); diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index f40add9cd0a9..8e54bb821137 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1527,6 +1527,8 @@ "Enable_Desktop_Notifications": "Enable Desktop Notifications", "Enable_inquiry_fetch_by_stream": "Enable inquiry data fetch from server using a stream", "Enable_omnichannel_auto_close_abandoned_rooms": "Enable automatic closing of rooms abandoned by the visitor", + "Enable_Password_History": "Enable Password History", + "Enable_Password_History_Description": "When enabled, users won't be able to update their passwords to some of their most recently used passwords.", "Enable_Svg_Favicon": "Enable SVG favicon", "Enable_two-factor_authentication": "Enable two-factor authentication via TOTP", "Enable_two-factor_authentication_email": "Enable two-factor authentication via Email", @@ -1664,6 +1666,7 @@ "error-not-allowed": "Not allowed", "error-not-authorized": "Not authorized", "error-office-hours-are-closed": "The office hours are closed.", + "error-password-in-history": "Entered password that has been previously used", "error-password-policy-not-met": "Password does not meet the server's policy", "error-password-policy-not-met-maxLength": "Password does not meet the server's policy of maximum length (password too long)", "error-password-policy-not-met-minLength": "Password does not meet the server's policy of minimum length (password too short)", @@ -3056,6 +3059,9 @@ "Password_Changed_Email_Subject": "[Site_Name] - Password Changed", "Password_changed_section": "Password Changed", "Password_changed_successfully": "Password changed successfully", + "Password_History": "Password History", + "Password_History_Amount": "Password History Length", + "Password_History_Amount_Description": "Amount of most recently used passwords to prevent users from reusing.", "Password_Policy": "Password Policy", "Password_to_access": "Password to access", "Passwords_do_not_match": "Passwords do not match", diff --git a/server/lib/compareUserPasswordHistory.ts b/server/lib/compareUserPasswordHistory.ts index 8bae150c655b..cb7dc6cc0de1 100644 --- a/server/lib/compareUserPasswordHistory.ts +++ b/server/lib/compareUserPasswordHistory.ts @@ -2,6 +2,7 @@ import { Accounts } from 'meteor/accounts-base'; import { IUser } from '../../definition/IUser'; import { IPassword } from '../../definition/IPassword'; +import { settings } from '../../app/settings/server'; /** * Check if a given password is the one user by given user or if the user doesn't have a password @@ -9,7 +10,7 @@ import { IPassword } from '../../definition/IPassword'; * @param {object} pass Object with { plain: 'plain-test-password' } or { sha256: 'sha256password' } */ export function compareUserPasswordHistory(user: IUser, pass: IPassword): boolean { - if (!user?.services?.passwordHistory) { + if (!user?.services?.passwordHistory || !settings.get('Accounts_Password_History_Enabled')) { return true; } @@ -18,8 +19,9 @@ export function compareUserPasswordHistory(user: IUser, pass: IPassword): boolea } const currentPassword = user.services.password.bcrypt; + const passwordHistory = user.services.passwordHistory.slice(-Number(settings.get('Accounts_Password_History_Amount'))); - for (const password of user.services.passwordHistory) { + for (const password of passwordHistory) { if (!password.trim()) { user.services.password.bcrypt = currentPassword; return false; diff --git a/server/methods/saveUserProfile.js b/server/methods/saveUserProfile.js index bb8c19c84944..adab2288be8d 100644 --- a/server/methods/saveUserProfile.js +++ b/server/methods/saveUserProfile.js @@ -77,7 +77,7 @@ function saveUserProfile(settings, customFields) { } if (user.services?.passwordHistory && !compareUserPasswordHistory(user, { plain: settings.newPassword })) { - throw new Meteor.Error('error-password-in-history', 'Entered password in history', { + throw new Meteor.Error('error-password-in-history', 'Entered password that has been previously used', { method: 'saveUserProfile', }); } From 5cb79df5ba0cfa64dcba29c33d596b3a7c637c3e Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Mon, 19 Apr 2021 15:40:48 -0300 Subject: [PATCH 5/6] Fix error message and update digest assignment --- packages/rocketchat-i18n/i18n/en.i18n.json | 2 +- server/lib/compareUserPassword.ts | 2 +- server/methods/saveUserProfile.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 8702ec4b59a9..602cdb46461f 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1666,7 +1666,7 @@ "error-not-allowed": "Not allowed", "error-not-authorized": "Not authorized", "error-office-hours-are-closed": "The office hours are closed.", - "error-password-in-history": "Entered password that has been previously used", + "error-password-in-history": "Entered password has been previously used", "error-password-policy-not-met": "Password does not meet the server's policy", "error-password-policy-not-met-maxLength": "Password does not meet the server's policy of maximum length (password too long)", "error-password-policy-not-met-minLength": "Password does not meet the server's policy of minimum length (password too short)", diff --git a/server/lib/compareUserPassword.ts b/server/lib/compareUserPassword.ts index 1bd21db1ead0..fd2c3fe47666 100644 --- a/server/lib/compareUserPassword.ts +++ b/server/lib/compareUserPassword.ts @@ -18,7 +18,7 @@ export function compareUserPassword(user: IUser, pass: IPassword): boolean { } const password = pass.plain || { - digest: pass.sha256 ? pass.sha256.toLowerCase() : '', + digest: pass.sha256.toLowerCase() || '', algorithm: 'sha-256' as const, }; diff --git a/server/methods/saveUserProfile.js b/server/methods/saveUserProfile.js index adab2288be8d..1cee9ceb76b7 100644 --- a/server/methods/saveUserProfile.js +++ b/server/methods/saveUserProfile.js @@ -77,7 +77,7 @@ function saveUserProfile(settings, customFields) { } if (user.services?.passwordHistory && !compareUserPasswordHistory(user, { plain: settings.newPassword })) { - throw new Meteor.Error('error-password-in-history', 'Entered password that has been previously used', { + throw new Meteor.Error('error-password-in-history', 'Entered password has been previously used', { method: 'saveUserProfile', }); } From faf296b5ba74b94e1068afe258d94b4d515edbc7 Mon Sep 17 00:00:00 2001 From: matheusbsilva137 Date: Mon, 19 Apr 2021 15:51:52 -0300 Subject: [PATCH 6/6] Update digest assignment --- server/lib/compareUserPassword.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/compareUserPassword.ts b/server/lib/compareUserPassword.ts index fd2c3fe47666..93c2dc3e2efa 100644 --- a/server/lib/compareUserPassword.ts +++ b/server/lib/compareUserPassword.ts @@ -18,7 +18,7 @@ export function compareUserPassword(user: IUser, pass: IPassword): boolean { } const password = pass.plain || { - digest: pass.sha256.toLowerCase() || '', + digest: pass.sha256?.toLowerCase() || '', algorithm: 'sha-256' as const, };