Skip to content

Commit

Permalink
✨ Improved password validation rules (TryGhost#9171)
Browse files Browse the repository at this point in the history
refs TryGhost#9150 

- Moves the password length fn from `models/user` to `data/validation` where the other validator functions live.
- Added password validation rules. Password rules added:
   - Disallow obviously bad passwords: '1234567890', 'qwertyuiop', 'asdfghjkl;' and 'asdfghjklm' for example
   - Disallow passwords that contain the words 'password' or 'ghost'
   - Disallow passwords that match the user's email address
   - Disallow passwords that match the blog domain or blog title
   - Disallow passwords that include 50% or more of the same characters: 'aaaaaaaaaa', '1111111111' and 'ababababab' for example.
- Password validation returns an `Object` now, that includes an `isValid` and `message` property to differentiate between the two error messages (password too short or password insecure).
- Use a catch predicate in `api/authentication` on `passwordReset`, so the correct `ValidationError` will be thrown during the password reset flow rather then an `UnauthorizedError`.
- When in setup flow, the blog title is not available yet from `settingsCache`. We therefore supply it from the received form data in the user model `setup` method to have it accessible for the validation.
  • Loading branch information
aileen authored and kevinansfield committed Oct 26, 2017
1 parent 05729d2 commit c8cbbc4
Show file tree
Hide file tree
Showing 9 changed files with 257 additions and 32 deletions.
8 changes: 7 additions & 1 deletion core/server/api/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,14 @@ authentication = {
updatedUser.set('status', 'active');
return updatedUser.save(options);
})
.catch(errors.ValidationError, function (err) {
return Promise.reject(err);
})
.catch(function (err) {
throw new errors.UnauthorizedError({err: err});
if (errors.utils.isIgnitionError(err)) {
return Promise.reject(err);
}
return Promise.reject(new errors.UnauthorizedError({err: err}));
});
}

Expand Down
117 changes: 117 additions & 0 deletions core/server/data/validation/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ var schema = require('../schema').tables,
Promise = require('bluebird'),
errors = require('../../errors'),
i18n = require('../../i18n'),
settingsCache = require('../../settings/cache'),
utils = require('../../utils/url'),

validatePassword,
validateSchema,
validateSettings,
validate;
Expand All @@ -15,6 +18,41 @@ function assertString(input) {
assert(typeof input === 'string', 'Validator js validates strings only');
}

/**
* Counts repeated characters in a string. When 50% or more characters are the same,
* we return false and therefore invalidate the string.
* @param {String} stringToTest The password string to check.
* @return {Boolean}
*/
function characterOccurance(stringToTest) {
var chars = {},
allowedOccurancy,
valid = true;

stringToTest = _.toString(stringToTest);
allowedOccurancy = stringToTest.length / 2;

// Loop through string and accumulate character counts
_.each(stringToTest, function (char) {
if (!chars[char]) {
chars[char] = 1;
} else {
chars[char] += 1;
}
});

// check if any of the accumulated chars exceed the allowed occurancy
// of 50% of the words' length.
_.forIn(chars, function (charCount) {
if (charCount >= allowedOccurancy) {
valid = false;
return;
}
});

return valid;
}

// extends has been removed in validator >= 5.0.0, need to monkey-patch it back in
validator.extend = function (name, fn) {
validator[name] = function () {
Expand Down Expand Up @@ -45,6 +83,84 @@ validator.extend('isSlug', function isSlug(str) {
return validator.matches(str, /^[a-z0-9\-_]+$/);
});

/**
* Validation against simple password rules
* Returns false when validation fails and true for a valid password
* @param {String} password The password string to check.
* @param {String} email The users email address to validate agains password.
* @param {String} blogTitle Optional blogTitle value, when blog title is not set yet, e. g. in setup process.
* @return {Object} example for returned validation Object:
* invalid password: `validationResult: {isValid: false, message: 'Sorry, you cannot use an insecure password.'}`
* valid password: `validationResult: {isValid: true}`
*/
validatePassword = function validatePassword(password, email, blogTitle) {
var validationResult = {isValid: true},
disallowedPasswords = ['password', 'ghost', 'passw0rd'],
blogUrl = utils.urlFor('home', true),
badPasswords = [
'1234567890',
'qwertyuiop',
'qwertzuiop',
'asdfghjkl;',
'abcdefghij',
'0987654321',
'1q2w3e4r5t',
'12345asdfg'
];

blogTitle = blogTitle ? blogTitle : settingsCache.get('title');
blogUrl = blogUrl.replace(/^http(s?):\/\//, '');

// password must be longer than 10 characters
if (!validator.isLength(password, 10)) {
validationResult.isValid = false;
validationResult.message = i18n.t('errors.models.user.passwordDoesNotComplyLength', {minLength: 10});

return validationResult;
}

// dissallow password from badPasswords list (e. g. '1234567890')
_.each(badPasswords, function (badPassword) {
if (badPassword === password) {
validationResult.isValid = false;
}
});

// password must not match with users' email
if (email && email.toLowerCase() === password.toLowerCase()) {
validationResult.isValid = false;
}

// password must not contain the words 'ghost', 'password', or 'passw0rd'
_.each(disallowedPasswords, function (disallowedPassword) {
if (password.toLowerCase().indexOf(disallowedPassword) >= 0) {
validationResult.isValid = false;
}
});

// password must not match with blog title
if (blogTitle && blogTitle.toLowerCase() === password.toLowerCase()) {
validationResult.isValid = false;
}

// password must not match with blog URL (without protocol, with or without trailing slash)
if (blogUrl && (blogUrl.toLowerCase() === password.toLowerCase() || blogUrl.toLowerCase().replace(/\/$/, '') === password.toLowerCase())) {
validationResult.isValid = false;
}

// dissallow passwords where 50% or more of characters are the same
if (!characterOccurance(password)) {
validationResult.isValid = false;
}

// Generic error message for the rules where no dedicated error massage is set
if (!validationResult.isValid && !validationResult.message) {
validationResult.message = i18n.t('errors.models.user.passwordDoesNotComplySecurity');
}

return validationResult;
};

// Validation against schema attributes
// values are checked against the validation objects from schema.js
validateSchema = function validateSchema(tableName, model) {
Expand Down Expand Up @@ -174,6 +290,7 @@ validate = function validate(value, key, validations) {
module.exports = {
validate: validate,
validator: validator,
validatePassword: validatePassword,
validateSchema: validateSchema,
validateSettings: validateSettings
};
26 changes: 14 additions & 12 deletions core/server/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ var _ = require('lodash'),
User,
Users;

function validatePasswordLength(password) {
return validator.isLength(password, 10);
}

/**
* generate a random salt and then hash the password with that salt
*/
Expand Down Expand Up @@ -106,7 +102,8 @@ User = ghostBookshelf.Model.extend({
*/
onSaving: function onSaving(newPage, attr, options) {
var self = this,
tasks = [];
tasks = [],
passwordValidation = {};

ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);

Expand Down Expand Up @@ -169,11 +166,13 @@ User = ghostBookshelf.Model.extend({
if (this.get('status') !== 'inactive') {
this.set('status', 'locked');
}
}
} else {
// CASE: we're not importing data, run the validations
passwordValidation = validation.validatePassword(this.get('password'), this.get('email'));

// don't ever validate passwords when importing
if (!options.importing && !validatePasswordLength(this.get('password'))) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.models.user.passwordDoesNotComplyLength', {minLength: 10})}));
if (!passwordValidation.isValid) {
return Promise.reject(new errors.ValidationError({message: passwordValidation.message}));
}
}

tasks.hashPassword = (function hashPassword() {
Expand Down Expand Up @@ -563,10 +562,13 @@ User = ghostBookshelf.Model.extend({
*/
setup: function setup(data, options) {
var self = this,
userData = this.filterData(data);
userData = this.filterData(data),
passwordValidation = {};

passwordValidation = validation.validatePassword(userData.password, userData.email, data.blogTitle);

if (!validatePasswordLength(userData.password)) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.models.user.passwordDoesNotComplyLength', {minLength: 10})}));
if (!passwordValidation.isValid) {
return Promise.reject(new errors.ValidationError({message: passwordValidation.message}));
}

options = this.filterOptions(options, 'setup');
Expand Down
1 change: 1 addition & 0 deletions core/server/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@
"onlyOneRolePerUserSupported": "Only one role per user is supported at the moment.",
"methodDoesNotSupportOwnerRole": "This method does not support assigning the owner role",
"passwordDoesNotComplyLength": "Your password must be at least {minLength} characters long.",
"passwordDoesNotComplySecurity": "Sorry, you cannot use an insecure password.",
"notEnoughPermission": "You do not have permission to perform this action",
"noUserWithEnteredEmailAddr": "There is no user with that email address.",
"userIsInactive": "The user with that email address is inactive.",
Expand Down
4 changes: 2 additions & 2 deletions core/test/functional/routes/api/authentication_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,8 @@ describe('Authentication API', function () {
.send({
passwordreset: [{
token: token,
newPassword: 'abcdefghij',
ne2Password: 'abcdefghij'
newPassword: 'thisissupersafe',
ne2Password: 'thisissupersafe'
}]
})
.expect('Content-Type', /json/)
Expand Down
20 changes: 10 additions & 10 deletions core/test/integration/api/api_authentication_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('Authentication API', function () {
var setupData = {
name: 'test user',
email: '[email protected]',
password: 'areallygoodpassword',
password: 'thisissupersafe',
blogTitle: 'a test blog'
};

Expand Down Expand Up @@ -103,7 +103,7 @@ describe('Authentication API', function () {
var setupData = {
name: 'test user',
email: '[email protected]',
password: 'areallygoodpassword',
password: 'thisissupersafe',
blogTitle: 'a test blog'
};

Expand All @@ -128,7 +128,7 @@ describe('Authentication API', function () {
var setupData = {
name: 'test user',
email: '[email protected]',
password: 'areallygoodpassword'
password: 'thisissupersafe'
};

AuthAPI.setup({setup: [setupData]}).then(function (result) {
Expand Down Expand Up @@ -223,7 +223,7 @@ describe('Authentication API', function () {
var setupData = {
name: 'test user',
email: '[email protected]',
password: 'areallygoodpassword',
password: 'thisissupersafe',
blogTitle: 'a test blog'
};

Expand Down Expand Up @@ -273,7 +273,7 @@ describe('Authentication API', function () {
token: invite.get('token'),
email: invite.get('email'),
name: invite.get('email'),
password: 'eightcharacterslong'
password: 'tencharacterslong'
}
]
});
Expand Down Expand Up @@ -313,7 +313,7 @@ describe('Authentication API', function () {
token: invite.get('token'),
email: invite.get('email'),
name: invite.get('email'),
password: 'eightcharacterslong'
password: 'tencharacterslong'
}
]
});
Expand Down Expand Up @@ -409,7 +409,7 @@ describe('Authentication API', function () {
var user = {
name: 'uninvited user',
email: '[email protected]',
password: '1234567890',
password: 'thisissupersafe',
status: 'active'
},
options = {
Expand Down Expand Up @@ -507,7 +507,7 @@ describe('Authentication API', function () {
var setupData = {
name: 'test user',
email: '[email protected]',
password: 'areallygoodpassword',
password: 'thisissupersafe',
blogTitle: 'a test blog'
};

Expand Down Expand Up @@ -540,7 +540,7 @@ describe('Authentication API', function () {
var setupData = {
name: 'test user',
email: '[email protected]',
password: 'areallygoodpassword',
password: 'thisissupersafe',
blogTitle: 'a test blog'
};

Expand Down Expand Up @@ -573,7 +573,7 @@ describe('Authentication API', function () {
var setupData = {
name: 'test user',
email: '[email protected]',
password: 'areallygoodpassword',
password: 'thisissupersafe',
blogTitle: 'a test blog'
};

Expand Down
4 changes: 2 additions & 2 deletions core/test/integration/api/api_users_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -501,13 +501,13 @@ describe('Users API', function () {
{
users: [{
name: 'newname',
password: 'newpassword'
password: 'thisissupersafe'
}]
}, _.extend({}, context.author, {id: userIdFor.author})
).then(function () {
return models.User.findOne({id: userIdFor.author}).then(function (response) {
response.get('name').should.eql('newname');
response.get('password').should.not.eql('newpassword');
response.get('password').should.not.eql('thisissupersafe');
done();
});
}).catch(done);
Expand Down
Loading

0 comments on commit c8cbbc4

Please sign in to comment.