mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-24 23:48:13 -05:00
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:
parent
4676e9aa0a
commit
8f2dc2ff02
13 changed files with 204 additions and 35 deletions
|
@ -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),
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
ghost/admin/app/validators/password.js
Normal file
115
ghost/admin/app/validators/password.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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('/');
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -9,6 +9,7 @@ describe('Unit: Model: user', function () {
|
|||
'serializer:application',
|
||||
'serializer:user',
|
||||
'service:ajax',
|
||||
'service:config',
|
||||
'service:ghostPaths',
|
||||
'service:notifications',
|
||||
'service:session'
|
||||
|
|
|
@ -9,6 +9,7 @@ describe('Unit: Serializer: user', function() {
|
|||
needs: [
|
||||
'model:role',
|
||||
'service:ajax',
|
||||
'service:config',
|
||||
'service:ghostPaths',
|
||||
'service:notifications',
|
||||
'service:session',
|
||||
|
|
Loading…
Add table
Reference in a new issue