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:
parent
008ca3543b
commit
4f41161a47
5 changed files with 77 additions and 26 deletions
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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...",
|
||||||
|
|
|
@ -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();
|
||||||
userInputs.push(MatrixClientPeg.get().getUserIdLocalpart());
|
if (MatrixClientPeg.get()) {
|
||||||
|
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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue