diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 7dde0ef47362..df63828c68bd 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -779,6 +779,14 @@ "error-no-tokens-for-this-user": "There are no tokens for this user", "error-not-allowed": "Not allowed", "error-not-authorized": "Not authorized", + "error-password-policy-not-met": "Password does not meet the server's policy", + "error-password-policy-not-met-minLength": "Password does not meet the server's policy of minimum length (password too short)", + "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-repeatingCharacters": "Password not not meet the server's policy of forbidden repeating characters (you have too many of the same characters next to each other)", + "error-password-policy-not-met-oneLowercase": "Password does not meet the server's policy of at least one lowercase character", + "error-password-policy-not-met-oneUppercase": "Password does not meet the server's policy of at least one uppercase character", + "error-password-policy-not-met-oneNumber": "Password does not meet the server's policy of at least one numerical character", + "error-password-policy-not-met-oneSpecial": "Password does not meet the server's policy of at least one special character", "error-push-disabled": "Push is disabled", "error-remove-last-owner": "This is the last owner. Please set a new owner before removing this one.", "error-role-in-use": "Cannot delete role because it's in use", @@ -1599,6 +1607,25 @@ "Password": "Password", "Password_Change_Disabled": "Your Rocket.Chat administrator has disabled the changing of passwords", "Password_changed_successfully": "Password changed successfully", + "Password_Policy": "Password Policy", + "Accounts_Password_Policy_Enabled": "Enable Password Policy", + "Accounts_Password_Policy_Enabled_Description": "When enabled, user passwords must adhere to the policies set forth. Note: this only applies to new passwords, not existing passwords.", + "Accounts_Password_Policy_MinLength": "Minimum Length", + "Accounts_Password_Policy_MinLength_Description": "Ensures that passwords must have at least this amount of characters. Use `-1` to disable.", + "Accounts_Password_Policy_MaxLength": "Maximum Length", + "Accounts_Password_Policy_MaxLength_Description": "Ensures that passwords do not have more than this amount of characters. Use `-1` to disable.", + "Accounts_Password_Policy_ForbidRepeatingCharacters": "Forbid Repeating Characters", + "Accounts_Password_Policy_ForbidRepeatingCharacters_Description": "Ensures passwords do not contain the same character repeating next to each other.", + "Accounts_Password_Policy_ForbidRepeatingCharactersCount": "Max Repeating Characters", + "Accounts_Password_Policy_ForbidRepeatingCharactersCount_Description": "The amount of times a character can be repeating before it is not allowed.", + "Accounts_Password_Policy_AtLeastOneLowercase": "At Least One Lowercase", + "Accounts_Password_Policy_AtLeastOneLowercase_Description": "Enforce that a password contain at least one lowercase character.", + "Accounts_Password_Policy_AtLeastOneUppercase": "At Least One Uppercase", + "Accounts_Password_Policy_AtLeastOneUppercase_Description": "Enforce that a password contain at least one lowercase character.", + "Accounts_Password_Policy_AtLeastOneNumber": "At Least One Number", + "Accounts_Password_Policy_AtLeastOneNumber_Description": "Enforce that a password contain at least one numerical character.", + "Accounts_Password_Policy_AtLeastOneSpecialCharacter": "At Least One Symbol", + "Accounts_Password_Policy_AtLeastOneSpecialCharacter_Description": "Enforce that a password contain at least one special character.", "Past_Chats": "Past Chats", "Payload": "Payload", "People": "People", diff --git a/packages/rocketchat-lib/package.js b/packages/rocketchat-lib/package.js index cfc092292f67..3e2f0a84b830 100644 --- a/packages/rocketchat-lib/package.js +++ b/packages/rocketchat-lib/package.js @@ -115,6 +115,7 @@ Package.onUse(function(api) { api.addFiles('server/lib/roomTypes.js', 'server'); api.addFiles('server/lib/sendNotificationsOnMessage.js', 'server'); api.addFiles('server/lib/validateEmailDomain.js', 'server'); + api.addFiles('server/lib/passwordPolicy.js', 'server'); // SERVER MODELS api.addFiles('server/models/_Base.js', 'server'); diff --git a/packages/rocketchat-lib/server/functions/saveUser.js b/packages/rocketchat-lib/server/functions/saveUser.js index c4d1b4089b77..598c7c53eefb 100644 --- a/packages/rocketchat-lib/server/functions/saveUser.js +++ b/packages/rocketchat-lib/server/functions/saveUser.js @@ -187,7 +187,7 @@ RocketChat.saveUser = function(userId, userData) { RocketChat.setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser); } - if (userData.password && userData.password.trim() && RocketChat.authz.hasPermission(userId, 'edit-other-user-password')) { + if (userData.password && userData.password.trim() && RocketChat.authz.hasPermission(userId, 'edit-other-user-password') && RocketChat.passwordPolicy.validate(userData.password)) { Accounts.setPassword(userData._id, userData.password.trim()); } diff --git a/packages/rocketchat-lib/server/lib/PasswordPolicyClass.js b/packages/rocketchat-lib/server/lib/PasswordPolicyClass.js new file mode 100644 index 000000000000..0cfee75ecfe2 --- /dev/null +++ b/packages/rocketchat-lib/server/lib/PasswordPolicyClass.js @@ -0,0 +1,91 @@ +class PasswordPolicy { + constructor({ + enabled = false, + minLength = -1, + maxLength = -1, + forbidRepeatingCharacters = false, + forbidRepeatingCharactersCount = 3, //the regex is this number minus one + mustContainAtLeastOneLowercase = false, // /[A-Z]{3,}/ could do this instead of at least one + mustContainAtLeastOneUppercase = false, + mustContainAtLeastOneNumber = false, + mustContainAtLeastOneSpecialCharacter = false, + throwError = true + } = {}) { + this.regex = { + mustContainAtLeastOneLowercase: new RegExp('[a-z]'), + mustContainAtLeastOneUppercase: new RegExp('[A-Z]'), + mustContainAtLeastOneNumber: new RegExp('[0-9]'), + mustContainAtLeastOneSpecialCharacter: new RegExp('[^A-Za-z0-9 ]') + }; + + this.enabled = enabled; + this.minLength = minLength; + this.maxLength = maxLength; + this.forbidRepeatingCharacters = forbidRepeatingCharacters; + this.forbidRepeatingCharactersCount = forbidRepeatingCharactersCount; + this.mustContainAtLeastOneLowercase = mustContainAtLeastOneLowercase; + this.mustContainAtLeastOneUppercase = mustContainAtLeastOneUppercase; + this.mustContainAtLeastOneNumber = mustContainAtLeastOneNumber; + this.mustContainAtLeastOneSpecialCharacter = mustContainAtLeastOneSpecialCharacter; + this.throwError = throwError; + } + + set forbidRepeatingCharactersCount(value) { + this._forbidRepeatingCharactersCount = value; + this.regex.forbiddingRepeatingCharacters = new RegExp(`(.)\\1{${ this.forbidRepeatingCharactersCount },}`); + } + + get forbidRepeatingCharactersCount() { + return this._forbidRepeatingCharactersCount; + } + + error(error, message) { + if (this.throwError) { + throw new Meteor.Error(error, message); + } + + return false; + } + + validate(password) { + if (!this.enabled) { + return true; + } + + if (!password || typeof password !== 'string' || !password.length) { + return this.error('error-password-policy-not-met', 'The password provided does not meet the server\'s password policy.'); + } + + if (this.minLength >= 1 && password.length < this.minLength) { + return this.error('error-password-policy-not-met-minLength', 'The password does not meet the minimum length password policy.'); + } + + if (this.maxLength >= 1 && password.length > this.maxLength) { + return this.error('error-password-policy-not-met-maxLength', 'The password does not meet the maximum length password policy.'); + } + + if (this.forbidRepeatingCharacters && this.regex.forbiddingRepeatingCharacters.test(password)) { + return this.error('error-password-policy-not-met-repeatingCharacters', 'The password contains repeating characters which is against the password policy.'); + } + + if (this.mustContainAtLeastOneLowercase && !this.regex.mustContainAtLeastOneLowercase.test(password)) { + return this.error('error-password-policy-not-met-oneLowercase', 'The password does not contain at least one lowercase character which is against the password policy.'); + } + + if (this.mustContainAtLeastOneUppercase && !this.regex.mustContainAtLeastOneUppercase.test(password)) { + return this.error('error-password-policy-not-met-oneUppercase', 'The password does not contain at least one uppercase character which is against the password policy.'); + } + + if (this.mustContainAtLeastOneNumber && !this.regex.mustContainAtLeastOneNumber.test(password)) { + return this.error('error-password-policy-not-met-oneNumber', 'The password does not contain at least one numerical character which is against the password policy.'); + } + + if (this.mustContainAtLeastOneSpecialCharacter && !this.regex.mustContainAtLeastOneSpecialCharacter.test(password)) { + return this.error('error-password-policy-not-met-oneSpecial', 'The password does not contain at least one special character which is against the password policy.'); + } + + return true; + } +} + +export default PasswordPolicy; diff --git a/packages/rocketchat-lib/server/lib/passwordPolicy.js b/packages/rocketchat-lib/server/lib/passwordPolicy.js new file mode 100644 index 000000000000..7ff442bc2ecc --- /dev/null +++ b/packages/rocketchat-lib/server/lib/passwordPolicy.js @@ -0,0 +1,13 @@ +import PasswordPolicy from './PasswordPolicyClass'; + +RocketChat.passwordPolicy = new PasswordPolicy(); + +RocketChat.settings.get('Accounts_Password_Policy_Enabled', (key, value) => RocketChat.passwordPolicy.enabled = value); +RocketChat.settings.get('Accounts_Password_Policy_MinLength', (key, value) => RocketChat.passwordPolicy.minLength = value); +RocketChat.settings.get('Accounts_Password_Policy_MaxLength', (key, value) => RocketChat.passwordPolicy.maxLength = value); +RocketChat.settings.get('Accounts_Password_Policy_ForbidRepeatingCharacters', (key, value) => RocketChat.passwordPolicy.forbidRepeatingCharacters = value); +RocketChat.settings.get('Accounts_Password_Policy_ForbidRepeatingCharactersCount', (key, value) => RocketChat.passwordPolicy.forbidRepeatingCharactersCount = value); +RocketChat.settings.get('Accounts_Password_Policy_AtLeastOneLowercase', (key, value) => RocketChat.passwordPolicy.mustContainAtLeastOneLowercase = value); +RocketChat.settings.get('Accounts_Password_Policy_AtLeastOneUppercase', (key, value) => RocketChat.passwordPolicy.mustContainAtLeastOneUppercase = value); +RocketChat.settings.get('Accounts_Password_Policy_AtLeastOneNumber', (key, value) => RocketChat.passwordPolicy.mustContainAtLeastOneNumber = value); +RocketChat.settings.get('Accounts_Password_Policy_AtLeastOneSpecialCharacter', (key, value) => RocketChat.passwordPolicy.mustContainAtLeastOneSpecialCharacter = value); diff --git a/packages/rocketchat-lib/server/startup/settings.js b/packages/rocketchat-lib/server/startup/settings.js index d5b96cc789ce..631baea30122 100644 --- a/packages/rocketchat-lib/server/startup/settings.js +++ b/packages/rocketchat-lib/server/startup/settings.js @@ -468,6 +468,57 @@ RocketChat.settings.addGroup('Accounts', function() { type: 'boolean' }); }); + + this.section('Password_Policy', function() { + this.add('Accounts_Password_Policy_Enabled', false, { + type: 'boolean' + }); + + const enableQuery = { + _id: 'Accounts_Password_Policy_Enabled', + value: true + }; + + this.add('Accounts_Password_Policy_MinLength', 7, { + type: 'int', + enableQuery + }); + + this.add('Accounts_Password_Policy_MaxLength', -1, { + type: 'int', + enableQuery + }); + + this.add('Accounts_Password_Policy_ForbidRepeatingCharacters', true, { + type: 'boolean', + enableQuery + }); + + this.add('Accounts_Password_Policy_ForbidRepeatingCharactersCount', 3, { + type: 'int', + enableQuery + }); + + this.add('Accounts_Password_Policy_AtLeastOneLowercase', true, { + type: 'boolean', + enableQuery + }); + + this.add('Accounts_Password_Policy_AtLeastOneUppercase', true, { + type: 'boolean', + enableQuery + }); + + this.add('Accounts_Password_Policy_AtLeastOneNumber', true, { + type: 'boolean', + enableQuery + }); + + this.add('Accounts_Password_Policy_AtLeastOneSpecialCharacter', true, { + type: 'boolean', + enableQuery + }); + }); }); RocketChat.settings.addGroup('OAuth', function() { diff --git a/packages/rocketchat-lib/tests/server.tests.js b/packages/rocketchat-lib/tests/server.tests.js new file mode 100644 index 000000000000..4d358b75a088 --- /dev/null +++ b/packages/rocketchat-lib/tests/server.tests.js @@ -0,0 +1,206 @@ +/* eslint-env mocha */ +import 'babel-polyfill'; +import assert from 'assert'; + +import PasswordPolicyClass from '../server/lib/PasswordPolicyClass'; + +describe('PasswordPolicyClass', () => { + describe('Default options', () => { + const passwordPolice = new PasswordPolicyClass(); + it('should be disabled', () => { + assert.equal(passwordPolice.enabled, false); + }); + it('should have minLength = -1', () => { + assert.equal(passwordPolice.minLength, -1); + }); + it('should have maxLength = -1', () => { + assert.equal(passwordPolice.maxLength, -1); + }); + it('should have forbidRepeatingCharacters = false', () => { + assert.equal(passwordPolice.forbidRepeatingCharacters, false); + }); + it('should have forbidRepeatingCharactersCount = 3', () => { + assert.equal(passwordPolice.forbidRepeatingCharactersCount, 3); + }); + it('should have mustContainAtLeastOneLowercase = false', () => { + assert.equal(passwordPolice.mustContainAtLeastOneLowercase, false); + }); + it('should have mustContainAtLeastOneUppercase = false', () => { + assert.equal(passwordPolice.mustContainAtLeastOneUppercase, false); + }); + it('should have mustContainAtLeastOneNumber = false', () => { + assert.equal(passwordPolice.mustContainAtLeastOneNumber, false); + }); + it('should have mustContainAtLeastOneSpecialCharacter = false', () => { + assert.equal(passwordPolice.mustContainAtLeastOneSpecialCharacter, false); + }); + + describe('Password tests with default options', () => { + it('should allow all passwords', () => { + const passwordPolice = new PasswordPolicyClass(); + assert.equal(passwordPolice.validate(), true); + assert.equal(passwordPolice.validate(''), true); + assert.equal(passwordPolice.validate('a'), true); + assert.equal(passwordPolice.validate('aaaaaaaaa'), true); + assert.equal(passwordPolice.validate(' '), true); + }); + }); + }); + + describe('Password tests with options', () => { + it('should not allow non string or empty', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + throwError: false + }); + + assert.equal(passwordPolice.validate(), false); + assert.equal(passwordPolice.validate(1), false); + assert.equal(passwordPolice.validate(true), false); + assert.equal(passwordPolice.validate(new Date), false); + assert.equal(passwordPolice.validate(new Function), false); + assert.equal(passwordPolice.validate(''), false); + }); + + it('should restrict by minLength', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + minLength: 5, + throwError: false + }); + + assert.equal(passwordPolice.validate('1'), false); + assert.equal(passwordPolice.validate('1234'), false); + assert.equal(passwordPolice.validate('12345'), true); + assert.equal(passwordPolice.validate(' '), true); + }); + + it('should restrict by maxLength', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + maxLength: 5, + throwError: false + }); + + assert.equal(passwordPolice.validate('1'), true); + assert.equal(passwordPolice.validate('12345'), true); + assert.equal(passwordPolice.validate('123456'), false); + assert.equal(passwordPolice.validate(' '), false); + }); + + it('should allow repeated characters', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + forbidRepeatingCharacters: false, + throwError: false + }); + + assert.equal(passwordPolice.validate('1'), true); + assert.equal(passwordPolice.validate('12345'), true); + assert.equal(passwordPolice.validate('123456'), true); + assert.equal(passwordPolice.validate(' '), true); + assert.equal(passwordPolice.validate('11111111111111'), true); + }); + + it('should restrict repeated characters', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + forbidRepeatingCharacters: true, + forbidRepeatingCharactersCount: 3, + throwError: false + }); + + assert.equal(passwordPolice.validate('1'), true); + assert.equal(passwordPolice.validate('11'), true); + assert.equal(passwordPolice.validate('111'), true); + assert.equal(passwordPolice.validate('1111'), false); + assert.equal(passwordPolice.validate(' '), false); + assert.equal(passwordPolice.validate('123456'), true); + }); + + it('should restrict repeated characters customized', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + forbidRepeatingCharacters: true, + forbidRepeatingCharactersCount: 5, + throwError: false + }); + + assert.equal(passwordPolice.validate('1'), true); + assert.equal(passwordPolice.validate('11'), true); + assert.equal(passwordPolice.validate('111'), true); + assert.equal(passwordPolice.validate('1111'), true); + assert.equal(passwordPolice.validate('11111'), true); + assert.equal(passwordPolice.validate('111111'), false); + assert.equal(passwordPolice.validate(' '), false); + assert.equal(passwordPolice.validate('123456'), true); + }); + + it('should contain one lowercase', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + mustContainAtLeastOneLowercase: true, + throwError: false + }); + + assert.equal(passwordPolice.validate('a'), true); + assert.equal(passwordPolice.validate('aa'), true); + assert.equal(passwordPolice.validate('A'), false); + assert.equal(passwordPolice.validate(' '), false); + assert.equal(passwordPolice.validate('123456'), false); + assert.equal(passwordPolice.validate('AAAAA'), false); + assert.equal(passwordPolice.validate('AAAaAAA'), true); + }); + + it('should contain one uppercase', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + mustContainAtLeastOneUppercase: true, + throwError: false + }); + + assert.equal(passwordPolice.validate('a'), false); + assert.equal(passwordPolice.validate('aa'), false); + assert.equal(passwordPolice.validate('A'), true); + assert.equal(passwordPolice.validate(' '), false); + assert.equal(passwordPolice.validate('123456'), false); + assert.equal(passwordPolice.validate('AAAAA'), true); + assert.equal(passwordPolice.validate('AAAaAAA'), true); + }); + + it('should contain one uppercase', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + mustContainAtLeastOneNumber: true, + throwError: false + }); + + assert.equal(passwordPolice.validate('a'), false); + assert.equal(passwordPolice.validate('aa'), false); + assert.equal(passwordPolice.validate('A'), false); + assert.equal(passwordPolice.validate(' '), false); + assert.equal(passwordPolice.validate('123456'), true); + assert.equal(passwordPolice.validate('AAAAA'), false); + assert.equal(passwordPolice.validate('AAAaAAA'), false); + assert.equal(passwordPolice.validate('AAAa1AAA'), true); + }); + + it('should contain one uppercase', () => { + const passwordPolice = new PasswordPolicyClass({ + enabled: true, + mustContainAtLeastOneSpecialCharacter: true, + throwError: false + }); + + assert.equal(passwordPolice.validate('a'), false); + assert.equal(passwordPolice.validate('aa'), false); + assert.equal(passwordPolice.validate('A'), false); + assert.equal(passwordPolice.validate(' '), false); + assert.equal(passwordPolice.validate('123456'), false); + assert.equal(passwordPolice.validate('AAAAA'), false); + assert.equal(passwordPolice.validate('AAAaAAA'), false); + assert.equal(passwordPolice.validate('AAAa1AAA'), false); + assert.equal(passwordPolice.validate('AAAa@AAA'), true); + }); + }); +}); diff --git a/server/methods/registerUser.js b/server/methods/registerUser.js index 392a2f794a5f..21c3c08a9b67 100644 --- a/server/methods/registerUser.js +++ b/server/methods/registerUser.js @@ -31,6 +31,8 @@ Meteor.methods({ throw new Meteor.Error ('error-user-registration-secret', 'User registration is only allowed via Secret URL', { method: 'registerUser' }); } + RocketChat.passwordPolicy.validate(formData.pass); + RocketChat.validateEmailDomain(formData.email); const userData = { diff --git a/server/methods/saveUserProfile.js b/server/methods/saveUserProfile.js index bc2ef91d9506..aa607cd4e4c4 100644 --- a/server/methods/saveUserProfile.js +++ b/server/methods/saveUserProfile.js @@ -58,6 +58,8 @@ Meteor.methods({ }); } + RocketChat.passwordPolicy.validate(settings.newPassword); + Accounts.setPassword(Meteor.userId(), settings.newPassword, { logout: false }); diff --git a/server/methods/setUserPassword.js b/server/methods/setUserPassword.js index 327e523770aa..70a8f0859646 100644 --- a/server/methods/setUserPassword.js +++ b/server/methods/setUserPassword.js @@ -18,6 +18,8 @@ Meteor.methods({ }); } + RocketChat.passwordPolicy.validate(password); + Accounts.setPassword(userId, password, { logout: false });