Add a password strength component (#4153)

* Added new PasswordStrength Component

* Added tests

* PR Grumbles
This commit is contained in:
Nicolas Gotchac 2017-01-13 15:52:42 +01:00 committed by Jaco Greeff
parent 57ce845e4c
commit 4a714d4a3e
10 changed files with 282 additions and 33 deletions

View File

@ -189,6 +189,7 @@
"valid-url": "1.0.9", "valid-url": "1.0.9",
"validator": "6.2.0", "validator": "6.2.0",
"web3": "0.17.0-beta", "web3": "0.17.0-beta",
"whatwg-fetch": "2.0.1" "whatwg-fetch": "2.0.1",
"zxcvbn": "4.4.1"
} }
} }

View File

@ -15,7 +15,7 @@
// along with Parity. If not, see <http://www.gnu.org/licenses/>. // along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import IconButton from 'material-ui/IconButton'; import { IconButton } from 'material-ui';
import { RadioButton, RadioButtonGroup } from 'material-ui/RadioButton'; import { RadioButton, RadioButtonGroup } from 'material-ui/RadioButton';
import ActionAutorenew from 'material-ui/svg-icons/action/autorenew'; import ActionAutorenew from 'material-ui/svg-icons/action/autorenew';

View File

@ -88,12 +88,13 @@ export default class RecoveryPhrase extends Component {
value={ password2 } value={ password2 }
onChange={ this.onEditPassword2 } /> onChange={ this.onEditPassword2 } />
</div> </div>
</div>
<Checkbox <Checkbox
className={ styles.checkbox } className={ styles.checkbox }
label='Key was created with Parity <1.4.5 on Windows' label='Key was created with Parity <1.4.5 on Windows'
checked={ windowsPhrase } checked={ windowsPhrase }
onCheck={ this.onToggleWindowsPhrase } /> onCheck={ this.onToggleWindowsPhrase }
</div> />
</Form> </Form>
); );
} }

View File

@ -16,6 +16,7 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ActionDone from 'material-ui/svg-icons/action/done'; import ActionDone from 'material-ui/svg-icons/action/done';
import ActionDoneAll from 'material-ui/svg-icons/action/done-all'; import ActionDoneAll from 'material-ui/svg-icons/action/done-all';
import ContentClear from 'material-ui/svg-icons/content/clear'; import ContentClear from 'material-ui/svg-icons/content/clear';
@ -100,44 +101,45 @@ export default class CreateAccount extends Component {
switch (stage) { switch (stage) {
case 0: case 0:
return ( return (
<CreationType <CreationType onChange={ this.onChangeType } />
onChange={ this.onChangeType } />
); );
case 1: case 1:
if (createType === 'fromNew') { if (createType === 'fromNew') {
return ( return (
<NewAccount <NewAccount onChange={ this.onChangeDetails } />
onChange={ this.onChangeDetails } />
); );
} else if (createType === 'fromGeth') { }
if (createType === 'fromGeth') {
return ( return (
<NewGeth <NewGeth
accounts={ accounts } accounts={ accounts }
onChange={ this.onChangeGeth } /> onChange={ this.onChangeGeth }
/>
); );
} else if (createType === 'fromPhrase') { }
if (createType === 'fromPhrase') {
return ( return (
<RecoveryPhrase <RecoveryPhrase onChange={ this.onChangeDetails } />
onChange={ this.onChangeDetails } />
); );
} else if (createType === 'fromRaw') { }
if (createType === 'fromRaw') {
return ( return (
<RawKey <RawKey onChange={ this.onChangeDetails } />
onChange={ this.onChangeDetails } />
); );
} }
return ( return (
<NewImport <NewImport onChange={ this.onChangeWallet } />
onChange={ this.onChangeWallet } />
); );
case 2: case 2:
if (createType === 'fromGeth') { if (createType === 'fromGeth') {
return ( return (
<AccountDetailsGeth <AccountDetailsGeth addresses={ this.state.gethAddresses } />
addresses={ this.state.gethAddresses } />
); );
} }
@ -145,7 +147,8 @@ export default class CreateAccount extends Component {
<AccountDetails <AccountDetails
address={ this.state.address } address={ this.state.address }
name={ this.state.name } name={ this.state.name }
phrase={ this.state.phrase } /> phrase={ this.state.phrase }
/>
); );
} }
} }
@ -210,11 +213,14 @@ export default class CreateAccount extends Component {
} }
return ( return (
<Warning warning={ <Warning
warning={
<FormattedMessage <FormattedMessage
id='createAccount.warning.insecurePassword' id='createAccount.warning.insecurePassword'
defaultMessage='It is recommended that a strong password be used to secure your accounts. Empty and trivial passwords are a security risk.' /> defaultMessage='It is recommended that a strong password be used to secure your accounts. Empty and trivial passwords are a security risk.'
} /> />
}
/>
); );
} }
@ -371,7 +377,7 @@ export default class CreateAccount extends Component {
} }
onChangeDetails = (canCreate, { name, passwordHint, address, password, phrase, rawKey, windowsPhrase }) => { onChangeDetails = (canCreate, { name, passwordHint, address, password, phrase, rawKey, windowsPhrase }) => {
this.setState({ const nextState = {
canCreate, canCreate,
name, name,
passwordHint, passwordHint,
@ -380,7 +386,9 @@ export default class CreateAccount extends Component {
phrase, phrase,
windowsPhrase: windowsPhrase || false, windowsPhrase: windowsPhrase || false,
rawKey rawKey
}); };
this.setState(nextState);
} }
onChangeRaw = (canCreate, rawKey) => { onChangeRaw = (canCreate, rawKey) => {

View File

@ -23,7 +23,7 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { newError, openSnackbar } from '~/redux/actions'; import { newError, openSnackbar } from '~/redux/actions';
import { Button, Modal, IdentityName, IdentityIcon } from '~/ui'; import { Button, Modal, IdentityName, IdentityIcon, PasswordStrength } from '~/ui';
import Form, { Input } from '~/ui/Form'; import Form, { Input } from '~/ui/Form';
import { CancelIcon, CheckIcon, SendIcon } from '~/ui/Icons'; import { CancelIcon, CheckIcon, SendIcon } from '~/ui/Icons';
@ -120,7 +120,7 @@ class PasswordManager extends Component {
} }
renderPage () { renderPage () {
const { busy, isRepeatValid, passwordHint } = this.store; const { busy, isRepeatValid, newPassword, passwordHint } = this.store;
return ( return (
<Tabs <Tabs
@ -236,6 +236,8 @@ class PasswordManager extends Component {
type='password' /> type='password' />
</div> </div>
</div> </div>
<PasswordStrength input={ newPassword } />
</div> </div>
</Form> </Form>
</Tab> </Tab>

View File

@ -0,0 +1,17 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './passwordStrength';

View File

@ -0,0 +1,31 @@
/* Copyright 2015, 2016 Parity Technologies (UK) Ltd.
/* This file is part of Parity.
/*
/* Parity is free software: you can redistribute it and/or modify
/* it under the terms of the GNU General Public License as published by
/* the Free Software Foundation, either version 3 of the License, or
/* (at your option) any later version.
/*
/* Parity is distributed in the hope that it will be useful,
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/* GNU General Public License for more details.
/*
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.strength {
margin-top: 1.25em;
}
.feedback {
font-size: 0.75em;
}
.label {
user-select: none;
line-height: 18px;
font-size: 12px;
color: rgba(255, 255, 255, 0.498039);
}

View File

@ -0,0 +1,125 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import { debounce } from 'lodash';
import { LinearProgress } from 'material-ui';
import { FormattedMessage } from 'react-intl';
import zxcvbn from 'zxcvbn';
import styles from './passwordStrength.css';
const BAR_STYLE = {
borderRadius: 1,
height: 7,
marginTop: '0.5em'
};
export default class PasswordStrength extends Component {
static propTypes = {
input: PropTypes.string.isRequired
};
state = {
strength: null
};
constructor (props) {
super(props);
this.updateStrength = debounce(this._updateStrength, 50, { leading: true });
}
componentWillMount () {
this.updateStrength(this.props.input);
}
componentWillReceiveProps (nextProps) {
if (nextProps.input !== this.props.input) {
this.updateStrength(nextProps.input);
}
}
_updateStrength (input = '') {
const strength = zxcvbn(input);
this.setState({ strength });
}
render () {
const { strength } = this.state;
if (!strength) {
return null;
}
const { score, feedback } = strength;
// Score is between 0 and 4
const value = score * 100 / 5 + 20;
const color = this.getStrengthBarColor(score);
return (
<div className={ styles.strength }>
<label className={ styles.label }>
<FormattedMessage
id='ui.passwordStrength.label'
defaultMessage='password strength'
/>
</label>
<LinearProgress
color={ color }
mode='determinate'
style={ BAR_STYLE }
value={ value }
/>
<div className={ styles.feedback }>
{ this.renderFeedback(feedback) }
</div>
</div>
);
}
// Note that the suggestions are in english, thus it wouldn't
// make sense to add translations to surrounding words
renderFeedback (feedback = {}) {
const { suggestions = [] } = feedback;
return (
<div>
<p>
{ suggestions.join(' ') }
</p>
</div>
);
}
getStrengthBarColor (score) {
switch (score) {
case 4:
case 3:
return 'lightgreen';
case 2:
return 'yellow';
case 1:
return 'orange';
default:
return 'red';
}
}
}

View File

@ -0,0 +1,62 @@
// Copyright 2015, 2016 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import { shallow } from 'enzyme';
import React from 'react';
import PasswordStrength from './passwordStrength';
const INPUT_A = 'l33t_test';
const INPUT_B = 'Fu£dk;s$%kdlaOe9)_';
const INPUT_NULL = '';
function render (props) {
return shallow(
<PasswordStrength { ...props } />
).shallow();
}
describe('ui/Form/PasswordStrength', () => {
describe('rendering', () => {
it('renders', () => {
expect(render({ input: INPUT_A })).to.be.ok;
});
it('renders a linear progress', () => {
expect(render({ input: INPUT_A }).find('LinearProgress')).to.be.ok;
});
describe('compute strength', () => {
it('has low score with empty input', () => {
expect(
render({ input: INPUT_NULL }).find('LinearProgress').props().value
).to.equal(20);
});
it('has medium score', () => {
expect(
render({ input: INPUT_A }).find('LinearProgress').props().value
).to.equal(60);
});
it('has high score', () => {
expect(
render({ input: INPUT_B }).find('LinearProgress').props().value
).to.equal(100);
});
});
});
});

View File

@ -43,6 +43,7 @@ import Modal, { Busy as BusyStep, Completed as CompletedStep } from './Modal';
import muiTheme from './Theme'; import muiTheme from './Theme';
import Page from './Page'; import Page from './Page';
import ParityBackground from './ParityBackground'; import ParityBackground from './ParityBackground';
import PasswordStrength from './Form/PasswordStrength';
import ShortenedHash from './ShortenedHash'; import ShortenedHash from './ShortenedHash';
import SignerIcon from './SignerIcon'; import SignerIcon from './SignerIcon';
import Tags from './Tags'; import Tags from './Tags';
@ -91,6 +92,7 @@ export {
muiTheme, muiTheme,
Page, Page,
ParityBackground, ParityBackground,
PasswordStrength,
RadioButtons, RadioButtons,
ShortenedHash, ShortenedHash,
Select, Select,