Skip to content

Commit cb61ac2

Browse files
[NEW] Password history (RocketChat#21607)
1 parent 5fe1088 commit cb61ac2

8 files changed

Lines changed: 104 additions & 3 deletions

File tree

app/lib/server/startup/settings.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,26 @@ settings.addGroup('Accounts', function() {
600600
enableQuery,
601601
});
602602
});
603+
604+
this.section('Password_History', function() {
605+
this.add('Accounts_Password_History_Enabled', false, {
606+
type: 'boolean',
607+
i18nLabel: 'Enable_Password_History',
608+
i18nDescription: 'Enable_Password_History_Description',
609+
});
610+
611+
const enableQuery = {
612+
_id: 'Accounts_Password_History_Enabled',
613+
value: true,
614+
};
615+
616+
this.add('Accounts_Password_History_Amount', 5, {
617+
type: 'int',
618+
enableQuery,
619+
i18nLabel: 'Password_History_Amount',
620+
i18nDescription: 'Password_History_Amount_Description',
621+
});
622+
});
603623
});
604624

605625
settings.addGroup('OAuth', function() {

app/models/server/models/Users.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,18 @@ export class Users extends Base {
10341034
return this.update(_id, update);
10351035
}
10361036

1037+
addPasswordToHistory(_id, password) {
1038+
const update = {
1039+
$push: {
1040+
'services.passwordHistory': {
1041+
$each: [password],
1042+
$slice: -Number(settings.get('Accounts_Password_History_Amount')),
1043+
},
1044+
},
1045+
};
1046+
return this.update(_id, update);
1047+
}
1048+
10371049
setServiceId(_id, serviceName, serviceId) {
10381050
const update = { $set: {} };
10391051

definition/IPassword.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface IPassword {
2+
plain?: string;
3+
sha256?: string;
4+
}

definition/IUser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface IUserServices {
3737
password?: {
3838
bcrypt: string;
3939
};
40+
passwordHistory?: string[];
4041
email?: {
4142
verificationTokens?: IUserEmailVerificationToken[];
4243
};

packages/rocketchat-i18n/i18n/en.i18n.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,8 @@
15271527
"Enable_Desktop_Notifications": "Enable Desktop Notifications",
15281528
"Enable_inquiry_fetch_by_stream": "Enable inquiry data fetch from server using a stream",
15291529
"Enable_omnichannel_auto_close_abandoned_rooms": "Enable automatic closing of rooms abandoned by the visitor",
1530+
"Enable_Password_History": "Enable Password History",
1531+
"Enable_Password_History_Description": "When enabled, users won't be able to update their passwords to some of their most recently used passwords.",
15301532
"Enable_Svg_Favicon": "Enable SVG favicon",
15311533
"Enable_two-factor_authentication": "Enable two-factor authentication via TOTP",
15321534
"Enable_two-factor_authentication_email": "Enable two-factor authentication via Email",
@@ -1664,6 +1666,7 @@
16641666
"error-not-allowed": "Not allowed",
16651667
"error-not-authorized": "Not authorized",
16661668
"error-office-hours-are-closed": "The office hours are closed.",
1669+
"error-password-in-history": "Entered password has been previously used",
16671670
"error-password-policy-not-met": "Password does not meet the server's policy",
16681671
"error-password-policy-not-met-maxLength": "Password does not meet the server's policy of maximum length (password too long)",
16691672
"error-password-policy-not-met-minLength": "Password does not meet the server's policy of minimum length (password too short)",
@@ -3063,6 +3066,9 @@
30633066
"Password_Changed_Email_Subject": "[Site_Name] - Password Changed",
30643067
"Password_changed_section": "Password Changed",
30653068
"Password_changed_successfully": "Password changed successfully",
3069+
"Password_History": "Password History",
3070+
"Password_History_Amount": "Password History Length",
3071+
"Password_History_Amount_Description": "Amount of most recently used passwords to prevent users from reusing.",
30663072
"Password_Policy": "Password Policy",
30673073
"Password_to_access": "Password to access",
30683074
"Passwords_do_not_match": "Passwords do not match",
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Accounts } from 'meteor/accounts-base';
22

3+
import { IUser } from '../../definition/IUser';
4+
import { IPassword } from '../../definition/IPassword';
5+
36
/**
47
* Check if a given password is the one user by given user or if the user doesn't have a password
58
* @param {object} user User object
69
* @param {object} pass Object with { plain: 'plain-test-password' } or { sha256: 'sha256password' }
710
*/
8-
export function compareUserPassword(user, pass) {
11+
export function compareUserPassword(user: IUser, pass: IPassword): boolean {
912
if (!user?.services?.password?.bcrypt?.trim()) {
1013
return false;
1114
}
@@ -15,8 +18,8 @@ export function compareUserPassword(user, pass) {
1518
}
1619

1720
const password = pass.plain || {
18-
digest: pass.sha256.toLowerCase(),
19-
algorithm: 'sha-256',
21+
digest: pass.sha256?.toLowerCase() || '',
22+
algorithm: 'sha-256' as const,
2023
};
2124

2225
const passCheck = Accounts._checkPassword(user, password);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Accounts } from 'meteor/accounts-base';
2+
3+
import { IUser } from '../../definition/IUser';
4+
import { IPassword } from '../../definition/IPassword';
5+
import { settings } from '../../app/settings/server';
6+
7+
/**
8+
* Check if a given password is the one user by given user or if the user doesn't have a password
9+
* @param {object} user User object
10+
* @param {object} pass Object with { plain: 'plain-test-password' } or { sha256: 'sha256password' }
11+
*/
12+
export function compareUserPasswordHistory(user: IUser, pass: IPassword): boolean {
13+
if (!user?.services?.passwordHistory || !settings.get('Accounts_Password_History_Enabled')) {
14+
return true;
15+
}
16+
17+
if (!pass || (!pass.plain && !pass.sha256) || !user?.services?.password?.bcrypt) {
18+
return false;
19+
}
20+
21+
const currentPassword = user.services.password.bcrypt;
22+
const passwordHistory = user.services.passwordHistory.slice(-Number(settings.get('Accounts_Password_History_Amount')));
23+
24+
for (const password of passwordHistory) {
25+
if (!password.trim()) {
26+
user.services.password.bcrypt = currentPassword;
27+
return false;
28+
}
29+
user.services.password.bcrypt = password;
30+
31+
const historyPassword = pass.plain || {
32+
digest: pass.sha256 ? pass.sha256.toLowerCase() : '',
33+
algorithm: 'sha-256' as const,
34+
};
35+
36+
const passCheck = Accounts._checkPassword(user, historyPassword);
37+
38+
if (!passCheck.error) {
39+
user.services.password.bcrypt = currentPassword;
40+
return false;
41+
}
42+
}
43+
44+
user.services.password.bcrypt = currentPassword;
45+
return true;
46+
}

server/methods/saveUserProfile.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { settings as rcSettings } from '../../app/settings/server';
88
import { twoFactorRequired } from '../../app/2fa/server/twoFactorRequired';
99
import { saveUserIdentity } from '../../app/lib/server/functions/saveUserIdentity';
1010
import { compareUserPassword } from '../lib/compareUserPassword';
11+
import { compareUserPasswordHistory } from '../lib/compareUserPasswordHistory';
1112

1213
function saveUserProfile(settings, customFields) {
1314
if (!rcSettings.get('Accounts_AllowUserProfileChange')) {
@@ -75,12 +76,20 @@ function saveUserProfile(settings, customFields) {
7576
});
7677
}
7778

79+
if (user.services?.passwordHistory && !compareUserPasswordHistory(user, { plain: settings.newPassword })) {
80+
throw new Meteor.Error('error-password-in-history', 'Entered password has been previously used', {
81+
method: 'saveUserProfile',
82+
});
83+
}
84+
7885
passwordPolicy.validate(settings.newPassword);
7986

8087
Accounts.setPassword(this.userId, settings.newPassword, {
8188
logout: false,
8289
});
8390

91+
Users.addPasswordToHistory(this.userId, user.services?.password.bcrypt);
92+
8493
try {
8594
Meteor.call('removeOtherTokens');
8695
} catch (e) {

0 commit comments

Comments
 (0)