Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NEW] Password history #21607

Merged
merged 7 commits into from
Apr 21, 2021
Merged
Show file tree
Hide file tree
Changes from 5 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 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)",
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 ? pass.sha256.toLowerCase() : '',
matheusbsilva137 marked this conversation as resolved.
Show resolved Hide resolved
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;
KevLehman marked this conversation as resolved.
Show resolved Hide resolved
}
}

user.services.password.bcrypt = currentPassword;
KevLehman marked this conversation as resolved.
Show resolved Hide 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 that has been previously used', {
matheusbsilva137 marked this conversation as resolved.
Show resolved Hide resolved
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