Check password complexity during registration

This adds a password complexity rule during registration to require strong
passwords. This is based on the `zxcvbn` module that we already use for key
backup passphrases.

In addition, this also tweaks validation more generally to allow rules to be
async functions.
This commit is contained in:
J. Ryan Stinnett 2019-04-23 14:55:57 +01:00
parent 008ca3543b
commit 4f41161a47
5 changed files with 77 additions and 26 deletions

View file

@ -33,6 +33,8 @@ const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password'; const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
/** /**
* A pure UI component which displays a registration form. * A pure UI component which displays a registration form.
*/ */
@ -75,13 +77,14 @@ module.exports = React.createClass({
phoneNumber: "", phoneNumber: "",
password: "", password: "",
passwordConfirm: "", passwordConfirm: "",
passwordComplexity: null,
}; };
}, },
onSubmit: function(ev) { onSubmit: async function(ev) {
ev.preventDefault(); ev.preventDefault();
const allFieldsValid = this.verifyFieldsBeforeSubmit(); const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) { if (!allFieldsValid) {
return; return;
} }
@ -126,7 +129,7 @@ module.exports = React.createClass({
} }
}, },
verifyFieldsBeforeSubmit() { async verifyFieldsBeforeSubmit() {
const fieldIDsInDisplayOrder = [ const fieldIDsInDisplayOrder = [
FIELD_USERNAME, FIELD_USERNAME,
FIELD_PASSWORD, FIELD_PASSWORD,
@ -145,6 +148,10 @@ module.exports = React.createClass({
field.validate({ allowEmpty: false }); field.validate({ allowEmpty: false });
} }
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) { if (this.allFieldsValid()) {
return true; return true;
} }
@ -198,8 +205,8 @@ module.exports = React.createClass({
}); });
}, },
onEmailValidate(fieldState) { async onEmailValidate(fieldState) {
const result = this.validateEmailRules(fieldState); const result = await this.validateEmailRules(fieldState);
this.markFieldValid(FIELD_EMAIL, result.valid); this.markFieldValid(FIELD_EMAIL, result.valid);
return result; return result;
}, },
@ -228,13 +235,21 @@ module.exports = React.createClass({
}); });
}, },
onPasswordValidate(fieldState) { async onPasswordValidate(fieldState) {
const result = this.validatePasswordRules(fieldState); const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(FIELD_PASSWORD, result.valid); this.markFieldValid(FIELD_PASSWORD, result.valid);
return result; return result;
}, },
validatePasswordRules: withValidation({ validatePasswordRules: withValidation({
description: function() {
const complexity = this.state.passwordComplexity;
const score = complexity ? complexity.score : 0;
return <progress
max={PASSWORD_MIN_SCORE}
value={score}
/>;
},
rules: [ rules: [
{ {
key: "required", key: "required",
@ -250,6 +265,29 @@ module.exports = React.createClass({
return _t("Too short (min %(length)s)", { length: this.props.minPasswordLength }); return _t("Too short (min %(length)s)", { length: this.props.minPasswordLength });
}, },
}, },
{
key: "complexity",
test: async function({ value }) {
if (!value) {
return false;
}
const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value);
this.setState({
passwordComplexity: complexity,
});
return complexity.score >= PASSWORD_MIN_SCORE;
},
valid: () => _t("Nice, strong password!"),
invalid: function() {
const complexity = this.state.passwordComplexity;
if (!complexity) {
return null;
}
const { feedback } = complexity;
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
},
},
], ],
}), }),
@ -259,8 +297,8 @@ module.exports = React.createClass({
}); });
}, },
onPasswordConfirmValidate(fieldState) { async onPasswordConfirmValidate(fieldState) {
const result = this.validatePasswordConfirmRules(fieldState); const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
return result; return result;
}, },
@ -295,8 +333,8 @@ module.exports = React.createClass({
}); });
}, },
onPhoneNumberValidate(fieldState) { async onPhoneNumberValidate(fieldState) {
const result = this.validatePhoneNumberRules(fieldState); const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
return result; return result;
}, },
@ -325,8 +363,8 @@ module.exports = React.createClass({
}); });
}, },
onUsernameValidate(fieldState) { async onUsernameValidate(fieldState) {
const result = this.validateUsernameRules(fieldState); const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(FIELD_USERNAME, result.valid); this.markFieldValid(FIELD_USERNAME, result.valid);
return result; return result;
}, },

View file

@ -87,12 +87,12 @@ export default class Field extends React.PureComponent {
this.input.focus(); this.input.focus();
} }
validate({ focused, allowEmpty = true }) { async validate({ focused, allowEmpty = true }) {
if (!this.props.onValidate) { if (!this.props.onValidate) {
return; return;
} }
const value = this.input ? this.input.value : null; const value = this.input ? this.input.value : null;
const { valid, feedback } = this.props.onValidate({ const { valid, feedback } = await this.props.onValidate({
value, value,
focused, focused,
allowEmpty, allowEmpty,

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
/* eslint-disable babel/no-invalid-this */
import classNames from 'classnames'; import classNames from 'classnames';
/** /**
@ -34,7 +36,7 @@ import classNames from 'classnames';
* the overall validity and a feedback UI that can be rendered for more detail. * the overall validity and a feedback UI that can be rendered for more detail.
*/ */
export default function withValidation({ description, rules }) { export default function withValidation({ description, rules }) {
return function onValidate({ value, focused, allowEmpty = true }) { return async function onValidate({ value, focused, allowEmpty = true }) {
// TODO: Re-run only after ~200ms of inactivity // TODO: Re-run only after ~200ms of inactivity
if (!value && allowEmpty) { if (!value && allowEmpty) {
return { return {
@ -50,29 +52,35 @@ export default function withValidation({ description, rules }) {
if (!rule.key || !rule.test) { if (!rule.key || !rule.test) {
continue; continue;
} }
// We're setting `this` to whichever component hold the validation // We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component. // function. That allows rules to access the state of the component.
/* eslint-disable babel/no-invalid-this */ const ruleValid = await rule.test.call(this, { value, allowEmpty });
const ruleValid = rule.test.call(this, { value, allowEmpty });
valid = valid && ruleValid; valid = valid && ruleValid;
if (ruleValid && rule.valid) { if (ruleValid && rule.valid) {
// If the rule's result is valid and has text to show for // If the rule's result is valid and has text to show for
// the valid state, show it. // the valid state, show it.
const text = rule.valid.call(this);
if (!text) {
continue;
}
results.push({ results.push({
key: rule.key, key: rule.key,
valid: true, valid: true,
text: rule.valid.call(this), text,
}); });
} else if (!ruleValid && rule.invalid) { } else if (!ruleValid && rule.invalid) {
// If the rule's result is invalid and has text to show for // If the rule's result is invalid and has text to show for
// the invalid state, show it. // the invalid state, show it.
const text = rule.invalid.call(this);
if (!text) {
continue;
}
results.push({ results.push({
key: rule.key, key: rule.key,
valid: false, valid: false,
text: rule.invalid.call(this), text,
}); });
} }
/* eslint-enable babel/no-invalid-this */
} }
} }
@ -102,7 +110,10 @@ export default function withValidation({ description, rules }) {
let summary; let summary;
if (description) { if (description) {
summary = <div className="mx_Validation_description">{description()}</div>; // We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const content = description.call(this);
summary = <div className="mx_Validation_description">{content}</div>;
} }
let feedback; let feedback;

View file

@ -1327,6 +1327,8 @@
"Doesn't look like a valid email address": "Doesn't look like a valid email address", "Doesn't look like a valid email address": "Doesn't look like a valid email address",
"Enter password": "Enter password", "Enter password": "Enter password",
"Too short (min %(length)s)": "Too short (min %(length)s)", "Too short (min %(length)s)": "Too short (min %(length)s)",
"Nice, strong password!": "Nice, strong password!",
"Keep going...": "Keep going...",
"Passwords don't match": "Passwords don't match", "Passwords don't match": "Passwords don't match",
"Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details", "Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details",
"Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)", "Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)",
@ -1528,7 +1530,6 @@
"Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.", "Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.",
"Unable to query for supported registration methods.": "Unable to query for supported registration methods.", "Unable to query for supported registration methods.": "Unable to query for supported registration methods.",
"This server does not support authentication with a phone number.": "This server does not support authentication with a phone number.", "This server does not support authentication with a phone number.": "This server does not support authentication with a phone number.",
"An unknown error occurred.": "An unknown error occurred.",
"Create your account": "Create your account", "Create your account": "Create your account",
"Commands": "Commands", "Commands": "Commands",
"Results from DuckDuckGo": "Results from DuckDuckGo", "Results from DuckDuckGo": "Results from DuckDuckGo",
@ -1567,7 +1568,6 @@
"File to import": "File to import", "File to import": "File to import",
"Import": "Import", "Import": "Import",
"Great! This passphrase looks strong enough.": "Great! This passphrase looks strong enough.", "Great! This passphrase looks strong enough.": "Great! This passphrase looks strong enough.",
"Keep going...": "Keep going...",
"We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.": "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.", "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.": "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.",
"For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", "For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.",
"Enter a passphrase...": "Enter a passphrase...", "Enter a passphrase...": "Enter a passphrase...",

View file

@ -67,7 +67,9 @@ export function scorePassword(password) {
if (password.length === 0) return null; if (password.length === 0) return null;
const userInputs = ZXCVBN_USER_INPUTS.slice(); const userInputs = ZXCVBN_USER_INPUTS.slice();
if (MatrixClientPeg.get()) {
userInputs.push(MatrixClientPeg.get().getUserIdLocalpart()); userInputs.push(MatrixClientPeg.get().getUserIdLocalpart());
}
let zxcvbnResult = zxcvbn(password, userInputs); let zxcvbnResult = zxcvbn(password, userInputs);
// Work around https://github.com/dropbox/zxcvbn/issues/216 // Work around https://github.com/dropbox/zxcvbn/issues/216