more permissive verification process (#4317)

* style fixes 

* verification: find last request

* verification: don't request again if same input

* didRequestWithSameValues -> shallRequestAgain

* bugfixes 🐛, update SMS verification ABI

* verification: hasRequested -> accountHasRequested

* verification: hasIsVerified -> accountIsVerified

* verification: shallRequestAgain -> shallSkipRequest

* verification: show if unable to send req

* email verification: check if email already used

* address style grumbles 🎨
This commit is contained in:
Jannis Redmann 2017-02-02 16:01:37 +01:00 committed by Gav Wood
parent 8e82b2f631
commit 1547af191b
8 changed files with 214 additions and 80 deletions

View File

@ -20,23 +20,23 @@ export const checkIfVerified = (contract, account) => {
return contract.instance.certified.call({}, [account]); return contract.instance.certified.call({}, [account]);
}; };
export const checkIfRequested = (contract, account) => { export const findLastRequested = (contract, account) => {
let subId = null; let subId = null;
let resolved = false; let resolved = false;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
contract contract
.subscribe('Requested', { .subscribe('Requested', {
fromBlock: 0, toBlock: 'pending' fromBlock: 0,
toBlock: 'pending',
limit: 1,
topics: [account]
}, (err, logs) => { }, (err, logs) => {
if (err) { if (err) {
return reject(err); return reject(err);
} }
const e = logs.find((l) => {
return l.type === 'mined' && l.params.who && l.params.who.value === account;
});
resolve(e ? e.transactionHash : false); resolve(logs[0] || null);
resolved = true; resolved = true;
if (subId) { if (subId) {

View File

@ -30,6 +30,10 @@
margin-left: .5em; margin-left: .5em;
} }
.field {
margin-bottom: .5em;
}
.terms { .terms {
line-height: 1.3; line-height: 1.3;
opacity: .7; opacity: .7;

View File

@ -31,19 +31,22 @@ import emailTermsOfService from '~/3rdparty/email-verification/terms-of-service'
import { howSMSVerificationWorks, howEmailVerificationWorks } from '../how-it-works'; import { howSMSVerificationWorks, howEmailVerificationWorks } from '../how-it-works';
import styles from './gatherData.css'; import styles from './gatherData.css';
const boolOfError = PropTypes.oneOfType([ PropTypes.bool, PropTypes.instanceOf(Error) ]);
export default class GatherData extends Component { export default class GatherData extends Component {
static propTypes = { static propTypes = {
fee: React.PropTypes.instanceOf(BigNumber), fee: React.PropTypes.instanceOf(BigNumber),
fields: PropTypes.array.isRequired, fields: PropTypes.array.isRequired,
hasRequested: nullableProptype(PropTypes.bool.isRequired), accountHasRequested: nullableProptype(PropTypes.bool.isRequired),
isServerRunning: nullableProptype(PropTypes.bool.isRequired), isServerRunning: nullableProptype(PropTypes.bool.isRequired),
isVerified: nullableProptype(PropTypes.bool.isRequired), isAbleToRequest: nullableProptype(boolOfError.isRequired),
accountIsVerified: nullableProptype(PropTypes.bool.isRequired),
method: PropTypes.string.isRequired, method: PropTypes.string.isRequired,
setConsentGiven: PropTypes.func.isRequired setConsentGiven: PropTypes.func.isRequired
} }
render () { render () {
const { method, isVerified } = this.props; const { method, accountIsVerified } = this.props;
const termsOfService = method === 'email' ? emailTermsOfService : smsTermsOfService; const termsOfService = method === 'email' ? emailTermsOfService : smsTermsOfService;
const howItWorks = method === 'email' ? howEmailVerificationWorks : howSMSVerificationWorks; const howItWorks = method === 'email' ? howEmailVerificationWorks : howSMSVerificationWorks;
@ -55,6 +58,7 @@ export default class GatherData extends Component {
{ this.renderCertified() } { this.renderCertified() }
{ this.renderRequested() } { this.renderRequested() }
{ this.renderFields() } { this.renderFields() }
{ this.renderIfAbleToRequest() }
<Checkbox <Checkbox
className={ styles.spacing } className={ styles.spacing }
label={ label={
@ -63,7 +67,7 @@ export default class GatherData extends Component {
defaultMessage='I agree to the terms and conditions below.' defaultMessage='I agree to the terms and conditions below.'
/> />
} }
disabled={ isVerified } disabled={ accountIsVerified }
onCheck={ this.consentOnChange } onCheck={ this.consentOnChange }
/> />
<div className={ styles.terms }>{ termsOfService }</div> <div className={ styles.terms }>{ termsOfService }</div>
@ -145,27 +149,27 @@ export default class GatherData extends Component {
} }
renderCertified () { renderCertified () {
const { isVerified } = this.props; const { accountIsVerified } = this.props;
if (isVerified) { if (accountIsVerified) {
return ( return (
<div className={ styles.container }> <div className={ styles.container }>
<ErrorIcon /> <ErrorIcon />
<p className={ styles.message }> <p className={ styles.message }>
<FormattedMessage <FormattedMessage
id='ui.verification.gatherData.isVerified.true' id='ui.verification.gatherData.accountIsVerified.true'
defaultMessage='Your account is already verified.' defaultMessage='Your account is already verified.'
/> />
</p> </p>
</div> </div>
); );
} else if (isVerified === false) { } else if (accountIsVerified === false) {
return ( return (
<div className={ styles.container }> <div className={ styles.container }>
<SuccessIcon /> <SuccessIcon />
<p className={ styles.message }> <p className={ styles.message }>
<FormattedMessage <FormattedMessage
id='ui.verification.gatherData.isVerified.false' id='ui.verification.gatherData.accountIsVerified.false'
defaultMessage='Your account is not verified yet.' defaultMessage='Your account is not verified yet.'
/> />
</p> </p>
@ -175,7 +179,7 @@ export default class GatherData extends Component {
return ( return (
<p className={ styles.message }> <p className={ styles.message }>
<FormattedMessage <FormattedMessage
id='ui.verification.gatherData.isVerified.pending' id='ui.verification.gatherData.accountIsVerified.pending'
defaultMessage='Checking if your account is verified…' defaultMessage='Checking if your account is verified…'
/> />
</p> </p>
@ -183,33 +187,33 @@ export default class GatherData extends Component {
} }
renderRequested () { renderRequested () {
const { isVerified, hasRequested } = this.props; const { accountIsVerified, accountHasRequested } = this.props;
// If the account is verified, don't show that it has requested verification. // If the account is verified, don't show that it has requested verification.
if (isVerified) { if (accountIsVerified) {
return null; return null;
} }
if (hasRequested) { if (accountHasRequested) {
return ( return (
<div className={ styles.container }> <div className={ styles.container }>
<InfoIcon /> <InfoIcon />
<p className={ styles.message }> <p className={ styles.message }>
<FormattedMessage <FormattedMessage
id='ui.verification.gatherData.hasRequested.true' id='ui.verification.gatherData.accountHasRequested.true'
defaultMessage='You already requested verification.' defaultMessage='You already requested verification from this account.'
/> />
</p> </p>
</div> </div>
); );
} else if (hasRequested === false) { } else if (accountHasRequested === false) {
return ( return (
<div className={ styles.container }> <div className={ styles.container }>
<SuccessIcon /> <SuccessIcon />
<p className={ styles.message }> <p className={ styles.message }>
<FormattedMessage <FormattedMessage
id='ui.verification.gatherData.hasRequested.false' id='ui.verification.gatherData.accountHasRequested.false'
defaultMessage='You did not request verification yet.' defaultMessage='You did not request verification from this account yet.'
/> />
</p> </p>
</div> </div>
@ -218,7 +222,7 @@ export default class GatherData extends Component {
return ( return (
<p className={ styles.message }> <p className={ styles.message }>
<FormattedMessage <FormattedMessage
id='ui.verification.gatherData.hasRequested.pending' id='ui.verification.gatherData.accountHasRequested.pending'
defaultMessage='Checking if you requested verification…' defaultMessage='Checking if you requested verification…'
/> />
</p> </p>
@ -226,7 +230,7 @@ export default class GatherData extends Component {
} }
renderFields () { renderFields () {
const { isVerified, fields } = this.props; const { accountIsVerified, fields } = this.props;
const rendered = fields.map((field) => { const rendered = fields.map((field) => {
const onChange = (_, v) => { const onChange = (_, v) => {
@ -236,11 +240,12 @@ export default class GatherData extends Component {
return ( return (
<Input <Input
className={ styles.field }
key={ field.key } key={ field.key }
label={ field.label } label={ field.label }
hint={ field.hint } hint={ field.hint }
error={ field.error } error={ field.error }
disabled={ isVerified } disabled={ accountIsVerified }
onChange={ onChange } onChange={ onChange }
onSubmit={ onSubmit } onSubmit={ onSubmit }
/> />
@ -250,6 +255,36 @@ export default class GatherData extends Component {
return (<div>{rendered}</div>); return (<div>{rendered}</div>);
} }
renderIfAbleToRequest () {
const { accountIsVerified, isAbleToRequest } = this.props;
// If the account is verified, don't show a warning.
// If the client is able to send the request, don't show a warning
if (accountIsVerified || isAbleToRequest === true) {
return null;
}
if (isAbleToRequest === null) {
return (
<p className={ styles.message }>
<FormattedMessage
id='ui.verification.gatherData.isAbleToRequest.pending'
defaultMessage='Validating your input…'
/>
</p>
);
} else if (isAbleToRequest) {
return (
<div className={ styles.container }>
<ErrorIcon />
<p className={ styles.message }>
{ isAbleToRequest.message }
</p>
</div>
);
}
}
consentOnChange = (_, consentGiven) => { consentOnChange = (_, consentGiven) => {
this.props.setConsentGiven(consentGiven); this.props.setConsentGiven(consentGiven);
} }

View File

@ -16,6 +16,7 @@
import { observable, computed, action } from 'mobx'; import { observable, computed, action } from 'mobx';
import { sha3 } from '~/api/util/sha3'; import { sha3 } from '~/api/util/sha3';
import { bytesToHex } from '~/api/util/format';
import EmailVerificationABI from '~/contracts/abi/email-verification.json'; import EmailVerificationABI from '~/contracts/abi/email-verification.json';
import VerificationStore, { import VerificationStore, {
@ -23,6 +24,8 @@ import VerificationStore, {
} from './store'; } from './store';
import { isServerRunning, hasReceivedCode, postToServer } from '~/3rdparty/email-verification'; import { isServerRunning, hasReceivedCode, postToServer } from '~/3rdparty/email-verification';
const ZERO20 = '0x0000000000000000000000000000000000000000';
// name in the `BadgeReg.sol` contract // name in the `BadgeReg.sol` contract
const EMAIL_VERIFICATION = 'emailverification'; const EMAIL_VERIFICATION = 'emailverification';
@ -44,9 +47,9 @@ export default class EmailVerificationStore extends VerificationStore {
switch (this.step) { switch (this.step) {
case LOADING: case LOADING:
return this.contract && this.fee && this.isVerified !== null && this.hasRequested !== null; return this.contract && this.fee && this.accountIsVerified !== null && this.accountHasRequested !== null;
case QUERY_DATA: case QUERY_DATA:
return this.isEmailValid && this.consentGiven; return this.isEmailValid && this.consentGiven && this.isAbleToRequest === true;
case QUERY_CODE: case QUERY_CODE:
return this.requestTx && this.isCodeValid === true; return this.requestTx && this.isCodeValid === true;
case POSTED_CONFIRMATION: case POSTED_CONFIRMATION:
@ -68,8 +71,53 @@ export default class EmailVerificationStore extends VerificationStore {
return hasReceivedCode(this.email, this.account, this.isTestnet); return hasReceivedCode(this.email, this.account, this.isTestnet);
} }
// If the email has already been used for verification of another account,
// we prevent the user from wasting ETH to request another verification.
@action setIfAbleToRequest = () => {
const { isEmailValid } = this;
if (!isEmailValid) {
this.isAbleToRequest = true;
return;
}
const { contract, email } = this;
const emailHash = sha3.text(email);
this.isAbleToRequest = null;
contract
.instance.reverse
.call({}, [ emailHash ])
.then((address) => {
if (address === ZERO20) {
this.isAbleToRequest = true;
} else {
this.isAbleToRequest = new Error('Another account has been verified using this e-mail.');
}
})
.catch((err) => {
this.error = 'Failed to check if able to send request: ' + err.message;
});
}
// Determine the values relevant for checking if the last request contains
// the same data as the current one.
requestValues = () => [ sha3.text(this.email) ] requestValues = () => [ sha3.text(this.email) ]
shallSkipRequest = (currentValues) => {
const { accountHasRequested } = this;
const lastRequest = this.lastRequestValues;
if (!accountHasRequested) {
return Promise.resolve(false);
}
// If the last email verification `request` for the selected address contains
// the same email as the current one, don't send another request to save ETH.
const skip = currentValues[0] === bytesToHex(lastRequest.emailHash.value);
return Promise.resolve(skip);
}
@action setEmail = (email) => { @action setEmail = (email) => {
this.email = email; this.email = email;
} }

View File

@ -43,7 +43,7 @@ export default class SMSVerificationStore extends VerificationStore {
switch (this.step) { switch (this.step) {
case LOADING: case LOADING:
return this.contract && this.fee && this.isVerified !== null && this.hasRequested !== null; return this.contract && this.fee && this.accountIsVerified !== null && this.accountHasRequested !== null;
case QUERY_DATA: case QUERY_DATA:
return this.isNumberValid && this.consentGiven; return this.isNumberValid && this.consentGiven;
case QUERY_CODE: case QUERY_CODE:
@ -67,6 +67,18 @@ export default class SMSVerificationStore extends VerificationStore {
return hasReceivedCode(this.number, this.account, this.isTestnet); return hasReceivedCode(this.number, this.account, this.isTestnet);
} }
// SMS verification events don't contain the phone number, so we will have to
// send a new request every single time. See below.
@action setIfAbleToRequest = () => {
this.isAbleToRequest = true;
}
// SMS verification `request` & `confirm` transactions and events don't contain the
// phone number, so we will have to send a new request every single time. This may
// cost the user more money, but given that it fails otherwise, it seems like a
// reasonable tradeoff.
shallSkipRequest = () => Promise.resolve(false)
@action setNumber = (number) => { @action setNumber = (number) => {
this.number = number; this.number = number;
} }

View File

@ -19,7 +19,7 @@ import { sha3 } from '~/api/util/sha3';
import Contract from '~/api/contract'; import Contract from '~/api/contract';
import Contracts from '~/contracts'; import Contracts from '~/contracts';
import { checkIfVerified, checkIfRequested, awaitPuzzle } from '~/contracts/verification'; import { checkIfVerified, findLastRequested, awaitPuzzle } from '~/contracts/verification';
import { checkIfTxFailed, waitForConfirmations } from '~/util/tx'; import { checkIfTxFailed, waitForConfirmations } from '~/util/tx';
export const LOADING = 'fetching-contract'; export const LOADING = 'fetching-contract';
@ -38,8 +38,10 @@ export default class VerificationStore {
@observable contract = null; @observable contract = null;
@observable fee = null; @observable fee = null;
@observable isVerified = null; @observable accountIsVerified = null;
@observable hasRequested = null; @observable accountHasRequested = null;
@observable isAbleToRequest = null;
@observable lastRequestValues = null;
@observable isServerRunning = null; @observable isServerRunning = null;
@observable consentGiven = false; @observable consentGiven = false;
@observable requestTx = null; @observable requestTx = null;
@ -68,6 +70,14 @@ export default class VerificationStore {
console.error('verification: ' + this.error); console.error('verification: ' + this.error);
} }
}); });
autorun(() => {
if (this.step !== QUERY_DATA) {
return;
}
this.setIfAbleToRequest();
});
} }
@action load = () => { @action load = () => {
@ -91,19 +101,20 @@ export default class VerificationStore {
this.error = 'Failed to fetch the fee: ' + err.message; this.error = 'Failed to fetch the fee: ' + err.message;
}); });
const isVerified = checkIfVerified(contract, account) const accountIsVerified = checkIfVerified(contract, account)
.then((isVerified) => { .then((accountIsVerified) => {
this.isVerified = isVerified; this.accountIsVerified = accountIsVerified;
}) })
.catch((err) => { .catch((err) => {
this.error = 'Failed to check if verified: ' + err.message; this.error = 'Failed to check if verified: ' + err.message;
}); });
const hasRequested = checkIfRequested(contract, account) const accountHasRequested = findLastRequested(contract, account)
.then((txHash) => { .then((log) => {
this.hasRequested = !!txHash; this.accountHasRequested = !!log;
if (txHash) { if (log) {
this.requestTx = txHash; this.lastRequestValues = log.params;
this.requestTx = log.transactionHash;
} }
}) })
.catch((err) => { .catch((err) => {
@ -111,7 +122,7 @@ export default class VerificationStore {
}); });
Promise Promise
.all([ isServerRunning, fee, isVerified, hasRequested ]) .all([ isServerRunning, fee, accountIsVerified, accountHasRequested ])
.then(() => { .then(() => {
this.step = QUERY_DATA; this.step = QUERY_DATA;
}); });
@ -150,40 +161,41 @@ export default class VerificationStore {
requestValues = () => [] requestValues = () => []
@action sendRequest = () => { @action sendRequest = () => {
const { api, account, contract, fee, hasRequested } = this; const { api, account, contract, fee } = this;
const request = contract.functions.find((fn) => fn.name === 'request'); const request = contract.functions.find((fn) => fn.name === 'request');
const options = { from: account, value: fee.toString() }; const options = { from: account, value: fee.toString() };
const values = this.requestValues(); const values = this.requestValues();
let chain = Promise.resolve(); this.shallSkipRequest(values)
.then((skipRequest) => {
if (skipRequest) {
return;
}
if (!hasRequested) { this.step = POSTING_REQUEST;
this.step = POSTING_REQUEST; return request.estimateGas(options, values)
chain = request.estimateGas(options, values) .then((gas) => {
.then((gas) => { options.gas = gas.mul(1.2).toFixed(0);
options.gas = gas.mul(1.2).toFixed(0); return request.postTransaction(options, values);
return request.postTransaction(options, values); })
}) .then((handle) => {
.then((handle) => { // The "request rejected" error doesn't have any property to distinguish
// TODO: The "request rejected" error doesn't have any property to // it from other errors, so we can't give a meaningful error here.
// distinguish it from other errors, so we can't give a meaningful error here. return api.pollMethod('parity_checkRequest', handle);
return api.pollMethod('parity_checkRequest', handle); })
}) .then((txHash) => {
.then((txHash) => { this.requestTx = txHash;
this.requestTx = txHash; return checkIfTxFailed(api, txHash, options.gas)
return checkIfTxFailed(api, txHash, options.gas) .then((hasFailed) => {
.then((hasFailed) => { if (hasFailed) {
if (hasFailed) { throw new Error('Transaction failed, all gas used up.');
throw new Error('Transaction failed, all gas used up.'); }
} this.step = POSTED_REQUEST;
this.step = POSTED_REQUEST; return waitForConfirmations(api, txHash, 1);
return waitForConfirmations(api, txHash, 1); });
}); });
}); })
}
chain
.then(() => this.checkIfReceivedCode()) .then(() => this.checkIfReceivedCode())
.then((hasReceived) => { .then((hasReceived) => {
if (hasReceived) { if (hasReceived) {

View File

@ -15,6 +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 { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { observable } from 'mobx'; import { observable } from 'mobx';
@ -206,7 +207,7 @@ class Verification extends Component {
const { const {
step, step,
isServerRunning, fee, isVerified, hasRequested, isServerRunning, isAbleToRequest, fee, accountIsVerified, accountHasRequested,
requestTx, isCodeValid, confirmationTx, requestTx, isCodeValid, confirmationTx,
setCode setCode
} = this.store; } = this.store;
@ -223,17 +224,37 @@ class Verification extends Component {
if (method === 'sms') { if (method === 'sms') {
fields.push({ fields.push({
key: 'number', key: 'number',
label: 'phone number in international format', label: (
hint: 'the SMS will be sent to this number', <FormattedMessage
id='ui.verification.gatherData.phoneNumber.label'
defaultMessage='phone number in international format'
/>
),
hint: (
<FormattedMessage
id='ui.verification.gatherData.phoneNumber.hint'
defaultMessage='the SMS will be sent to this number'
/>
),
error: this.store.isNumberValid ? null : 'invalid number', error: this.store.isNumberValid ? null : 'invalid number',
onChange: this.store.setNumber onChange: this.store.setNumber
}); });
} else if (method === 'email') { } else if (method === 'email') {
fields.push({ fields.push({
key: 'email', key: 'email',
label: 'email address', label: (
hint: 'the code will be sent to this address', <FormattedMessage
error: this.store.isEmailValid ? null : 'invalid email', id='ui.verification.gatherData.email.label'
defaultMessage='e-mail address'
/>
),
hint: (
<FormattedMessage
id='ui.verification.gatherData.email.hint'
defaultMessage='the code will be sent to this address'
/>
),
error: this.store.isEmailValid ? null : 'invalid e-mail',
onChange: this.store.setEmail onChange: this.store.setEmail
}); });
} }
@ -241,10 +262,12 @@ class Verification extends Component {
return ( return (
<GatherData <GatherData
fee={ fee } fee={ fee }
hasRequested={ hasRequested } accountHasRequested={ accountHasRequested }
isServerRunning={ isServerRunning } isServerRunning={ isServerRunning }
isVerified={ isVerified } isAbleToRequest={ isAbleToRequest }
method={ method } fields={ fields } accountIsVerified={ accountIsVerified }
method={ method }
fields={ fields }
setConsentGiven={ setConsentGiven } setConsentGiven={ setConsentGiven }
/> />
); );