Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/lib/server/startup/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
12 changes: 12 additions & 0 deletions app/models/server/models/Users.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} };

Expand Down
4 changes: 4 additions & 0 deletions definition/IPassword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface IPassword {
plain?: string;
sha256?: string;
}
1 change: 1 addition & 0 deletions definition/IUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface IUserServices {
password?: {
bcrypt: string;
};
passwordHistory?: string[];
email?: {
verificationTokens?: IUserEmailVerificationToken[];
};
Expand Down
6 changes: 6 additions & 0 deletions packages/rocketchat-i18n/i18n/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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);
Expand Down
46 changes: 46 additions & 0 deletions server/lib/compareUserPasswordHistory.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
KevLehman marked this conversation as resolved.
}
}

user.services.password.bcrypt = currentPassword;
Comment thread
KevLehman marked this conversation as resolved.
return true;
}
9 changes: 9 additions & 0 deletions server/methods/saveUserProfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down Expand Up @@ -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) {
Expand Down