Match client-side password validation to new server-side rules (#899)

refs TryGhost/Ghost#9150

- added a new validator for password validations that will take care of the rules client side
- Passwort rules added:
   - Disallow obviously bad passwords: 1234567890, qwertyuiop, asdfghjkl; and asdfghjklm
   - 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.
- When changing the own password, the old password is not affected by the new validations
- Validation are running on
   - setup
   - signup
   - password change in Team - User (only new passwords are validated)
   - passwort reset
This commit is contained in:
Aileen Nowak 2017-10-26 17:02:17 +07:00 committed by Kevin Ansfield
parent 0b86a72399
commit 4eff42bdac
13 changed files with 204 additions and 35 deletions

View File

@ -16,6 +16,7 @@ export default Controller.extend(ValidationEngine, {
notifications: injectService(),
session: injectService(),
ajax: injectService(),
config: injectService(),
email: computed('token', function () {
// The token base64 encodes the email (and some other stuff),

View File

@ -42,6 +42,7 @@ export default Model.extend(ValidationEngine, {
ajax: injectService(),
session: injectService(),
notifications: injectService(),
config: injectService(),
// TODO: Once client-side permissions are in place,
// remove the hard role check.

View File

@ -16,6 +16,7 @@ export default Route.extend(styleBody, UnauthenticatedRouteMixin, {
notifications: injectService(),
session: injectService(),
ajax: injectService(),
config: injectService(),
beforeModel() {
if (this.get('session.isAuthenticated')) {
@ -61,6 +62,9 @@ export default Route.extend(styleBody, UnauthenticatedRouteMixin, {
model.set('invitedBy', response.invitation[0].invitedBy);
// set blogTitle, so password validation has access to it
model.set('blogTitle', this.get('config.blogTitle'));
resolve(model);
}).catch(() => {
resolve(model);

View File

@ -1,6 +1,6 @@
import BaseValidator from './base';
import PasswordValidator from 'ghost-admin/validators/password';
export default BaseValidator.extend({
export default PasswordValidator.extend({
properties: ['name', 'email', 'password'],
name(model) {
@ -25,11 +25,6 @@ export default BaseValidator.extend({
},
password(model) {
let password = model.get('password');
if (!validator.isLength(password, 10)) {
model.get('errors').add('password', 'Password must be at least 10 characters long');
this.invalidate();
}
this.passwordValidation(model);
}
});

115
app/validators/password.js Normal file
View File

@ -0,0 +1,115 @@
import BaseValidator from './base';
const BAD_PASSWORDS = [
'1234567890',
'qwertyuiop',
'qwertzuiop',
'asdfghjkl;',
'abcdefghij',
'0987654321',
'1q2w3e4r5t',
'12345asdfg'
];
const DISALLOWED_PASSWORDS = ['ghost', 'password', 'passw0rd'];
export default BaseValidator.extend({
properties: ['passwordValidation'],
/**
* Counts repeated characters if 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}
*/
_characterOccurance(stringToTest) {
let chars = {};
let allowedOccurancy;
let valid = true;
allowedOccurancy = stringToTest.length / 2;
// Loop through string and accumulate character counts
for (let i = 0; i < stringToTest.length; i += 1) {
if (!chars[stringToTest[i]]) {
chars[stringToTest[i]] = 1;
} else {
chars[stringToTest[i]] += 1;
}
}
// check if any of the accumulated chars exceed the allowed occurancy
// of 50% of the words' length.
for (let charCount in chars) {
if (chars[charCount] >= allowedOccurancy) {
valid = false;
return valid;
}
}
return valid;
},
passwordValidation(model, password, errorTarget) {
let blogUrl = model.get('config.blogUrl') || window.location.host;
let blogTitle = model.get('blogTitle') || model.get('config.blogTitle');
let blogUrlWithSlash;
// the password that needs to be validated can differ from the password in the
// passed model, e. g. for password changes or reset.
password = password || model.get('password');
errorTarget = errorTarget || 'password';
blogUrl = blogUrl.replace(/^http(s?):\/\//, '');
blogUrlWithSlash = blogUrl.match(/\/$/) ? blogUrl : `${blogUrl}/`;
blogTitle = blogTitle ? blogTitle.trim().toLowerCase() : blogTitle;
// password must be longer than 10 characters
if (!validator.isLength(password, 10)) {
model.get('errors').add(errorTarget, 'Password must be at least 10 characters long');
return this.invalidate();
}
password = password.toString();
// dissallow password from badPasswords list (e. g. '1234567890')
BAD_PASSWORDS.map((badPassword) => {
if (badPassword === password) {
model.get('errors').add(errorTarget, 'Sorry, you cannot use an insecure password');
this.invalidate();
}
});
// password must not match with users' email
if (password.toLowerCase() === model.get('email').toLowerCase()) {
model.get('errors').add(errorTarget, 'Sorry, you cannot use an insecure password');
this.invalidate();
}
// password must not contain the words 'ghost', 'password', or 'passw0rd'
DISALLOWED_PASSWORDS.map((disallowedPassword) => {
if (password.toLowerCase().indexOf(disallowedPassword) >= 0) {
model.get('errors').add(errorTarget, 'Sorry, you cannot use an insecure password');
this.invalidate();
}
});
// password must not match with blog title
if (password.toLowerCase() === blogTitle) {
model.get('errors').add(errorTarget, 'Sorry, you cannot use an insecure password');
this.invalidate();
}
// password must not match with blog URL (without protocol, with or without trailing slash)
if (password.toLowerCase() === blogUrl || password.toLowerCase() === blogUrlWithSlash) {
model.get('errors').add(errorTarget, 'Sorry, you cannot use an insecure password');
this.invalidate();
}
// dissallow passwords where 50% or more of characters are the same
if (!this._characterOccurance(password)) {
model.get('errors').add(errorTarget, 'Sorry, you cannot use an insecure password');
this.invalidate();
}
}
});

View File

@ -1,6 +1,6 @@
import BaseValidator from './base';
import PasswordValidator from 'ghost-admin/validators/password';
export default BaseValidator.create({
export default PasswordValidator.create({
properties: ['newPassword'],
newPassword(model) {
@ -10,12 +10,11 @@ export default BaseValidator.create({
if (validator.empty(p1)) {
model.get('errors').add('newPassword', 'Please enter a password.');
this.invalidate();
} else if (!validator.isLength(p1, 10)) {
model.get('errors').add('newPassword', 'Password must be at least 10 characters long.');
this.invalidate();
} else if (!validator.equals(p1, p2)) {
model.get('errors').add('ne2Password', 'The two new passwords don\'t match.');
this.invalidate();
}
this.passwordValidation(model, p1, 'newPassword');
}
});

View File

@ -1,7 +1,7 @@
import BaseValidator from './base';
import PasswordValidator from 'ghost-admin/validators/password';
import {isBlank} from '@ember/utils';
export default BaseValidator.create({
export default PasswordValidator.create({
properties: ['name', 'bio', 'email', 'location', 'website', 'roles'],
isActive(model) {
@ -96,10 +96,7 @@ export default BaseValidator.create({
this.invalidate();
}
if (!validator.isLength(newPassword, 10)) {
model.get('errors').add('newPassword', 'Your password must be at least 10 characters long.');
this.invalidate();
}
this.passwordValidation(model, newPassword, 'newPassword');
}
},

View File

@ -113,7 +113,7 @@ describe('Acceptance: Setup', function () {
// enter valid details and submit
await fillIn('[data-test-email-input]', 'test@example.com');
await fillIn('[data-test-name-input]', 'Test User');
await fillIn('[data-test-password-input]', 'password99');
await fillIn('[data-test-password-input]', 'thisissupersafe');
await fillIn('[data-test-blog-title-input]', 'Blog Title');
await click('.gh-btn-green');
@ -180,7 +180,7 @@ describe('Acceptance: Setup', function () {
await fillIn('[data-test-email-input]', 'test@example.com');
await fillIn('[data-test-name-input]', 'Test User');
await fillIn('[data-test-password-input]', 'password99');
await fillIn('[data-test-password-input]', 'thisissupersafe');
await fillIn('[data-test-blog-title-input]', 'Blog Title');
// first post - simulated validation error
@ -218,7 +218,7 @@ describe('Acceptance: Setup', function () {
await visit('/setup/two');
await fillIn('[data-test-email-input]', 'test@example.com');
await fillIn('[data-test-name-input]', 'Test User');
await fillIn('[data-test-password-input]', 'password99');
await fillIn('[data-test-password-input]', 'thisissupersafe');
await fillIn('[data-test-blog-title-input]', 'Blog Title');
await click('.gh-btn-green');
@ -271,7 +271,7 @@ describe('Acceptance: Setup', function () {
await visit('/setup/two');
await fillIn('[data-test-email-input]', 'test@example.com');
await fillIn('[data-test-name-input]', 'Test User');
await fillIn('[data-test-password-input]', 'password99');
await fillIn('[data-test-password-input]', 'thisissupersafe');
await fillIn('[data-test-blog-title-input]', 'Blog Title');
await click('.gh-btn-green');

View File

@ -51,7 +51,7 @@ describe('Acceptance: Signin', function() {
expect(username, 'username').to.equal('test@example.com');
expect(clientId, 'client id').to.equal('ghost-admin');
if (password === 'testpass') {
if (password === 'thisissupersafe') {
return {
access_token: 'MirageAccessToken',
expires_in: 3600,
@ -109,7 +109,7 @@ describe('Acceptance: Signin', function() {
expect(currentURL(), 'current url').to.equal('/signin');
await fillIn('[name="identification"]', 'test@example.com');
await fillIn('[name="password"]', 'testpass');
await fillIn('[name="password"]', 'thisissupersafe');
await click('.gh-btn-blue');
expect(currentURL(), 'currentURL').to.equal('/');
});

View File

@ -31,7 +31,7 @@ describe('Acceptance: Signup', function() {
let params = JSON.parse(requestBody);
expect(params.invitation[0].name).to.equal('Test User');
expect(params.invitation[0].email).to.equal('kevin+test2@ghost.org');
expect(params.invitation[0].password).to.equal('ValidPassword');
expect(params.invitation[0].password).to.equal('thisissupersafe');
expect(params.invitation[0].token).to.equal('MTQ3MDM0NjAxNzkyOXxrZXZpbit0ZXN0MkBnaG9zdC5vcmd8MmNEblFjM2c3ZlFUajluTks0aUdQU0dmdm9ta0xkWGY2OEZ1V2dTNjZVZz0');
// ensure that `/users/me/` request returns a user
@ -89,7 +89,9 @@ describe('Acceptance: Signup', function() {
'name field error is removed after text input'
).to.equal('');
// focus out in Name field triggers inline error
// check password validation
// focus out in password field triggers inline error
// no password
await triggerEvent('input[name="password"]', 'blur');
expect(
@ -102,8 +104,44 @@ describe('Acceptance: Signup', function() {
'password field error text'
).to.match(/must be at least 10 characters/);
// password too short
await fillIn('input[name="password"]', 'short');
await triggerEvent('input[name="password"]', 'blur');
expect(
find('input[name="password"]').closest('.form-group').find('.response').text().trim(),
'password field error text'
).to.match(/must be at least 10 characters/);
// password must not be a bad password
await fillIn('input[name="password"]', '1234567890');
await triggerEvent('input[name="password"]', 'blur');
expect(
find('input[name="password"]').closest('.form-group').find('.response').text().trim(),
'password field error text'
).to.match(/you cannot use an insecure password/);
// password must not be a disallowed password
await fillIn('input[name="password"]', 'password99');
await triggerEvent('input[name="password"]', 'blur');
expect(
find('input[name="password"]').closest('.form-group').find('.response').text().trim(),
'password field error text'
).to.match(/you cannot use an insecure password/);
// password must not have repeating characters
await fillIn('input[name="password"]', '2222222222');
await triggerEvent('input[name="password"]', 'blur');
expect(
find('input[name="password"]').closest('.form-group').find('.response').text().trim(),
'password field error text'
).to.match(/you cannot use an insecure password/);
// entering valid text in Password field clears error
await fillIn('input[name="password"]', 'ValidPassword');
await fillIn('input[name="password"]', 'thisissupersafe');
await triggerEvent('input[name="password"]', 'blur');
expect(

View File

@ -658,8 +658,8 @@ describe('Acceptance: Team', function () {
).to.match(/can't be blank/);
// validates too short password (< 10 characters)
await fillIn('#user-password-new', 'password');
await fillIn('#user-new-password-verification', 'password');
await fillIn('#user-password-new', 'notlong');
await fillIn('#user-new-password-verification', 'notlong');
// enter key triggers action
await keyEvent('#user-password-new', 'keyup', 13);
@ -671,11 +671,28 @@ describe('Acceptance: Team', function () {
expect(
find('#user-password-new').siblings('.response').text(),
'confirm password error when it it\'s too short'
'confirm password error when it\'s too short'
).to.match(/at least 10 characters long/);
// validates unsafe password
await fillIn('#user-password-new', 'ghostisawesome');
await fillIn('#user-new-password-verification', 'ghostisawesome');
// enter key triggers action
await keyEvent('#user-password-new', 'keyup', 13);
expect(
find('#user-password-new').closest('.form-group').hasClass('error'),
'new password has error class when password is insecure'
).to.be.true;
expect(
find('#user-password-new').siblings('.response').text(),
'confirm password error when it\'s insecure'
).to.match(/you cannot use an insecure password/);
// typing in inputs clears validation
await fillIn('#user-password-new', 'password99');
await fillIn('#user-password-new', 'thisissupersafe');
await triggerEvent('#user-password-new', 'input');
expect(
@ -697,7 +714,7 @@ describe('Acceptance: Team', function () {
).to.match(/do not match/);
// submits with correct details
await fillIn('#user-new-password-verification', 'password99');
await fillIn('#user-new-password-verification', 'thisissupersafe');
await click('.button-change-password');
// hits the endpoint
@ -709,8 +726,8 @@ describe('Acceptance: Team', function () {
// eslint-disable-next-line camelcase
expect(params.password[0].user_id).to.equal(user.id.toString());
expect(params.password[0].newPassword).to.equal('password99');
expect(params.password[0].ne2Password).to.equal('password99');
expect(params.password[0].newPassword).to.equal('thisissupersafe');
expect(params.password[0].ne2Password).to.equal('thisissupersafe');
// clears the fields
expect(

View File

@ -9,6 +9,7 @@ describe('Unit: Model: user', function () {
'serializer:application',
'serializer:user',
'service:ajax',
'service:config',
'service:ghostPaths',
'service:notifications',
'service:session'

View File

@ -9,6 +9,7 @@ describe('Unit: Serializer: user', function() {
needs: [
'model:role',
'service:ajax',
'service:config',
'service:ghostPaths',
'service:notifications',
'service:session',