diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index 90c53f3831e33..da6eb9185087e 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -596,6 +596,26 @@ settings.addGroup('Accounts', function() { enableQuery, }); }); + + 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 = { + _id: 'Accounts_Password_History_Enabled', + value: true, + }; + + this.add('Accounts_Password_History_Amount', 5, { + type: 'int', + enableQuery, + i18nLabel: 'Password_History_Amount', + i18nDescription: 'Password_History_Amount_Description', + }); + }); }); settings.addGroup('OAuth', function() { diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index e6e60cd6a03d7..6db328f9b48a0 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -1034,6 +1034,18 @@ export class Users extends Base { return this.update(_id, update); } + addPasswordToHistory(_id, password) { + const update = { + $push: { + 'services.passwordHistory': { + $each: [password], + $slice: -Number(settings.get('Accounts_Password_History_Amount')), + }, + }, + }; + return this.update(_id, update); + } + setServiceId(_id, serviceName, serviceId) { const update = { $set: {} }; diff --git a/definition/IPassword.ts b/definition/IPassword.ts new file mode 100644 index 0000000000000..ce53cba6c255a --- /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 cc510695b486a..37a0626da64d1 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/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 1d96efcad7ac1..602cdb46461f6 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 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)", @@ -3057,6 +3060,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/compareUserPassword.js b/server/lib/compareUserPassword.ts similarity index 69% rename from server/lib/compareUserPassword.js rename to server/lib/compareUserPassword.ts index ab50aed6be862..93c2dc3e2efa5 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?.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 0000000000000..cb7dc6cc0de17 --- /dev/null +++ b/server/lib/compareUserPasswordHistory.ts @@ -0,0 +1,46 @@ +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 + * @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 || !settings.get('Accounts_Password_History_Enabled')) { + return true; + } + + if (!pass || (!pass.plain && !pass.sha256) || !user?.services?.password?.bcrypt) { + return false; + } + + const currentPassword = user.services.password.bcrypt; + const passwordHistory = user.services.passwordHistory.slice(-Number(settings.get('Accounts_Password_History_Amount'))); + + for (const password of 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 9888331914ee9..1cee9ceb76b71 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,12 +76,20 @@ function saveUserProfile(settings, customFields) { }); } + if (user.services?.passwordHistory && !compareUserPasswordHistory(user, { plain: settings.newPassword })) { + throw new Meteor.Error('error-password-in-history', 'Entered password has been previously used', { + method: 'saveUserProfile', + }); + } + passwordPolicy.validate(settings.newPassword); Accounts.setPassword(this.userId, settings.newPassword, { logout: false, }); + Users.addPasswordToHistory(this.userId, user.services?.password.bcrypt); + try { Meteor.call('removeOtherTokens'); } catch (e) {