update SMS verification (#3579)

* add isTestnet helper

* sms verification: use different port on testnet

* subscribeToEvent helper

* sms verification: await Puzzled event

* sms verification: bugfixes 🐛, move awaitPuzzle

* sms verification: check upfront if code is valid

* sms verification: more helpful phone input label

* isTestnet helper -> redux state
This commit is contained in:
Jannis Redmann 2016-11-28 17:39:55 +01:00 committed by Jaco Greeff
parent 2b178d8233
commit 5f570edf3b
11 changed files with 177 additions and 23 deletions

View File

@ -128,6 +128,7 @@
"es6-error": "^4.0.0", "es6-error": "^4.0.0",
"es6-promise": "^3.2.1", "es6-promise": "^3.2.1",
"ethereumjs-tx": "^1.1.2", "ethereumjs-tx": "^1.1.2",
"eventemitter3": "^2.0.2",
"file-saver": "^1.3.3", "file-saver": "^1.3.3",
"format-json": "^1.0.3", "format-json": "^1.0.3",
"format-number": "^2.0.1", "format-number": "^2.0.1",

View File

@ -27,9 +27,10 @@ export const termsOfService = (
</ul> </ul>
); );
export const postToServer = (query) => { export const postToServer = (query, isTestnet = false) => {
const port = isTestnet ? 8443 : 443;
query = stringify(query); query = stringify(query);
return fetch('https://sms-verification.parity.io/?' + query, { return fetch(`https://sms-verification.parity.io:${port}/?` + query, {
method: 'POST', mode: 'cors', cache: 'no-store' method: 'POST', mode: 'cors', cache: 'no-store'
}) })
.then((res) => { .then((res) => {

View File

@ -1 +1 @@
[{"constant":false,"inputs":[{"name":"_new","type":"address"}],"name":"setOwner","outputs":[],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_who","type":"address"}],"name":"certify","outputs":[],"payable":false,"type":"function"},{"constant":false,"inputs":[],"name":"request","outputs":[],"payable":true,"type":"function"},{"constant":false,"inputs":[{"name":"_who","type":"address"},{"name":"_puzzle","type":"bytes32"}],"name":"puzzle","outputs":[],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"_who","type":"address"},{"name":"_field","type":"string"}],"name":"getAddress","outputs":[{"name":"","type":"address"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_new","type":"uint256"}],"name":"setFee","outputs":[],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_who","type":"address"}],"name":"revoke","outputs":[],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_code","type":"bytes32"}],"name":"confirm","outputs":[],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"owner","outputs":[{"name":"","type":"address"}],"payable":false,"type":"function"},{"constant":false,"inputs":[],"name":"drain","outputs":[],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"delegate","outputs":[{"name":"","type":"address"}],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"_who","type":"address"},{"name":"_field","type":"string"}],"name":"getUint","outputs":[{"name":"","type":"uint256"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_new","type":"address"}],"name":"setDelegate","outputs":[],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"_who","type":"address"}],"name":"certified","outputs":[{"name":"","type":"bool"}],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"fee","outputs":[{"name":"","type":"uint256"}],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"_who","type":"address"},{"name":"_field","type":"string"}],"name":"get","outputs":[{"name":"","type":"bytes32"}],"payable":false,"type":"function"},{"anonymous":false,"inputs":[{"indexed":true,"name":"who","type":"address"}],"name":"Requested","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"who","type":"address"},{"indexed":false,"name":"puzzle","type":"bytes32"}],"name":"Puzzled","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"who","type":"address"}],"name":"Confirmed","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"who","type":"address"}],"name":"Revoked","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"old","type":"address"},{"indexed":true,"name":"current","type":"address"}],"name":"NewOwner","type":"event"}] [{"constant":false,"inputs":[{"name":"_new","type":"address"}],"name":"setOwner","outputs":[],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_who","type":"address"}],"name":"certify","outputs":[],"payable":false,"type":"function"},{"constant":false,"inputs":[],"name":"request","outputs":[],"payable":true,"type":"function"},{"constant":false,"inputs":[{"name":"_who","type":"address"},{"name":"_puzzle","type":"bytes32"}],"name":"puzzle","outputs":[],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"_who","type":"address"},{"name":"_field","type":"string"}],"name":"getAddress","outputs":[{"name":"","type":"address"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_new","type":"uint256"}],"name":"setFee","outputs":[],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_who","type":"address"}],"name":"revoke","outputs":[],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_code","type":"bytes32"}],"name":"confirm","outputs":[{"name":"","type":"bool"}],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"owner","outputs":[{"name":"","type":"address"}],"payable":false,"type":"function"},{"constant":false,"inputs":[],"name":"drain","outputs":[],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"delegate","outputs":[{"name":"","type":"address"}],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"_who","type":"address"},{"name":"_field","type":"string"}],"name":"getUint","outputs":[{"name":"","type":"uint256"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"_new","type":"address"}],"name":"setDelegate","outputs":[],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"_who","type":"address"}],"name":"certified","outputs":[{"name":"","type":"bool"}],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"fee","outputs":[{"name":"","type":"uint256"}],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"_who","type":"address"},{"name":"_field","type":"string"}],"name":"get","outputs":[{"name":"","type":"bytes32"}],"payable":false,"type":"function"},{"anonymous":false,"inputs":[{"indexed":true,"name":"who","type":"address"}],"name":"Requested","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"who","type":"address"},{"indexed":false,"name":"puzzle","type":"bytes32"}],"name":"Puzzled","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"who","type":"address"}],"name":"Confirmed","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"who","type":"address"}],"name":"Revoked","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"old","type":"address"},{"indexed":true,"name":"current","type":"address"}],"name":"NewOwner","type":"event"}]

View File

@ -14,6 +14,8 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>. // along with Parity. If not, see <http://www.gnu.org/licenses/>.
import subscribeToEvent from '../util/subscribe-to-event';
export const checkIfVerified = (contract, account) => { export const checkIfVerified = (contract, account) => {
return contract.instance.certified.call({}, [account]); return contract.instance.certified.call({}, [account]);
}; };
@ -50,3 +52,36 @@ export const checkIfRequested = (contract, account) => {
}); });
}); });
}; };
const blockNumber = (api) => {
return new Promise((resolve, reject) => {
api.subscribe('eth_blockNumber', (err, block) => {
if (err) {
return reject(err);
}
resolve(block);
})
.then((subscription) => {
api.unsubscribe(subscription);
})
.catch(reject);
});
};
export const awaitPuzzle = (api, contract, account) => {
return blockNumber(api)
.then((block) => {
return new Promise((resolve, reject) => {
const subscription = subscribeToEvent(contract, 'Puzzled', {
from: block.toNumber(),
filter: (log) => log.params.who.value === account
});
subscription.once('error', reject);
subscription.once('log', subscription.unsubscribe);
subscription.once('log', resolve);
subscription.once('timeout', () => {
reject(new Error('Timed out waiting for the puzzle.'));
});
});
});
};

View File

@ -53,7 +53,7 @@ export default class GatherData extends Component {
{ this.renderCertified() } { this.renderCertified() }
{ this.renderRequested() } { this.renderRequested() }
<Input <Input
label={ 'phone number' } label={ 'phone number in international format' }
hint={ 'the SMS will be sent to this number' } hint={ 'the SMS will be sent to this number' }
error={ isNumberValid ? null : 'invalid number' } error={ isNumberValid ? null : 'invalid number' }
disabled={ isVerified } disabled={ isVerified }

View File

@ -25,7 +25,7 @@ import {
LOADING, LOADING,
QUERY_DATA, QUERY_DATA,
POSTING_REQUEST, POSTED_REQUEST, POSTING_REQUEST, POSTED_REQUEST,
REQUESTING_SMS, REQUESTED_SMS, REQUESTING_SMS, QUERY_CODE,
POSTING_CONFIRMATION, POSTED_CONFIRMATION, POSTING_CONFIRMATION, POSTED_CONFIRMATION,
DONE DONE
} from './store'; } from './store';
@ -48,7 +48,7 @@ export default class SMSVerification extends Component {
[LOADING]: 0, [LOADING]: 0,
[QUERY_DATA]: 1, [QUERY_DATA]: 1,
[POSTING_REQUEST]: 2, [POSTED_REQUEST]: 2, [REQUESTING_SMS]: 2, [POSTING_REQUEST]: 2, [POSTED_REQUEST]: 2, [REQUESTING_SMS]: 2,
[REQUESTED_SMS]: 3, [QUERY_CODE]: 3,
[POSTING_CONFIRMATION]: 4, [POSTED_CONFIRMATION]: 4, [POSTING_CONFIRMATION]: 4, [POSTED_CONFIRMATION]: 4,
[DONE]: 5 [DONE]: 5
} }

View File

@ -47,7 +47,7 @@ export default class SendRequest extends Component {
case REQUESTING_SMS: case REQUESTING_SMS:
return ( return (
<p>Requesting an SMS from the Parity server.</p> <p>Requesting an SMS from the Parity server and waiting for the puzzle to be put into the contract.</p>
); );
default: default:

View File

@ -20,19 +20,16 @@ import { sha3 } from '../../api/util/sha3';
import Contracts from '../../contracts'; import Contracts from '../../contracts';
import { checkIfVerified, checkIfRequested } from '../../contracts/sms-verification'; import { checkIfVerified, checkIfRequested, awaitPuzzle } from '../../contracts/sms-verification';
import { postToServer } from '../../3rdparty/sms-verification'; import { postToServer } from '../../3rdparty/sms-verification';
import checkIfTxFailed from '../../util/check-if-tx-failed'; import checkIfTxFailed from '../../util/check-if-tx-failed';
import waitForConfirmations from '../../util/wait-for-block-confirmations'; import waitForConfirmations from '../../util/wait-for-block-confirmations';
const validCode = /^[A-Z\s]+$/i;
export const LOADING = 'fetching-contract'; export const LOADING = 'fetching-contract';
export const QUERY_DATA = 'query-data'; export const QUERY_DATA = 'query-data';
export const POSTING_REQUEST = 'posting-request'; export const POSTING_REQUEST = 'posting-request';
export const POSTED_REQUEST = 'posted-request'; export const POSTED_REQUEST = 'posted-request';
export const REQUESTING_SMS = 'requesting-sms'; export const REQUESTING_SMS = 'requesting-sms';
export const REQUESTED_SMS = 'requested-sms';
export const QUERY_CODE = 'query-code'; export const QUERY_CODE = 'query-code';
export const POSTING_CONFIRMATION = 'posting-confirmation'; export const POSTING_CONFIRMATION = 'posting-confirmation';
export const POSTED_CONFIRMATION = 'posted-confirmation'; export const POSTED_CONFIRMATION = 'posted-confirmation';
@ -50,11 +47,9 @@ export default class VerificationStore {
@observable number = ''; @observable number = '';
@observable requestTx = null; @observable requestTx = null;
@observable code = ''; @observable code = '';
@observable isCodeValid = null;
@observable confirmationTx = null; @observable confirmationTx = null;
@computed get isCodeValid () {
return validCode.test(this.code);
}
@computed get isNumberValid () { @computed get isNumberValid () {
return phone.isValidNumber(this.number); return phone.isValidNumber(this.number);
} }
@ -72,20 +67,19 @@ export default class VerificationStore {
return this.contract && this.fee && this.isVerified !== null && this.hasRequested !== null; return this.contract && this.fee && this.isVerified !== null && this.hasRequested !== null;
case QUERY_DATA: case QUERY_DATA:
return this.isNumberValid && this.consentGiven; return this.isNumberValid && this.consentGiven;
case REQUESTED_SMS:
return this.requestTx;
case QUERY_CODE: case QUERY_CODE:
return this.isCodeValid; return this.requestTx && this.isCodeValid === true;
case POSTED_CONFIRMATION: case POSTED_CONFIRMATION:
return this.confirmationTx; return !!this.confirmationTx;
default: default:
return false; return false;
} }
} }
constructor (api, account) { constructor (api, account, isTestnet) {
this.api = api; this.api = api;
this.account = account; this.account = account;
this.isTestnet = isTestnet;
this.step = LOADING; this.step = LOADING;
Contracts.create(api).registry.getContract('smsverification') Contracts.create(api).registry.getContract('smsverification')
@ -151,7 +145,26 @@ export default class VerificationStore {
} }
@action setCode = (code) => { @action setCode = (code) => {
const { contract, account } = this;
if (!contract || !account || code.length === 0) return;
const confirm = contract.functions.find((fn) => fn.name === 'confirm');
const options = { from: account };
const values = [ sha3(code) ];
this.code = code; this.code = code;
this.isCodeValid = null;
confirm.estimateGas(options, values)
.then((gas) => {
options.gas = gas.mul(1.2).toFixed(0);
return confirm.call(options, values);
})
.then((result) => {
this.isCodeValid = result === true;
})
.catch((err) => {
this.error = 'Failed to check if the code is valid: ' + err.message;
});
} }
@action sendRequest = () => { @action sendRequest = () => {
@ -188,11 +201,15 @@ export default class VerificationStore {
chain chain
.then(() => { .then(() => {
this.step = REQUESTING_SMS; return api.parity.netChain();
return postToServer({ number, address: account });
}) })
.then((chain) => {
this.step = REQUESTING_SMS;
return postToServer({ number, address: account }, this.isTestnet);
})
.then(() => awaitPuzzle(api, contract, account))
.then(() => { .then(() => {
this.step = REQUESTED_SMS; this.step = QUERY_CODE;
}) })
.catch((err) => { .catch((err) => {
this.error = 'Failed to request a confirmation SMS: ' + err.message; this.error = 'Failed to request a confirmation SMS: ' + err.message;

19
js/src/util/is-testnet.js Normal file
View File

@ -0,0 +1,19 @@
// Copyright 2015, 2016 Ethcore (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 (chain) => {
return chain === 'morden' || chain === 'ropsten' || chain === 'testnet';
};

View File

@ -0,0 +1,77 @@
// Copyright 2015, 2016 Ethcore (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 EventEmitter from 'eventemitter3';
const defaults = {
from: 0, // TODO
to: 'latest',
timeout: null,
filter: () => true
};
const subscribeToEvent = (contract, name, opt = {}) => {
opt = Object.assign({}, defaults, opt);
let subscription = null;
let timeout = null;
const unsubscribe = () => {
if (subscription) {
contract.unsubscribe(subscription);
subscription = null;
}
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
const emitter = new EventEmitter();
emitter.unsubscribe = unsubscribe;
if (typeof opt.timeout === 'number') {
timeout = setTimeout(() => {
unsubscribe();
emitter.emit('timeout');
}, opt.timeout);
}
const callback = (err, logs) => {
if (err) {
return emitter.emit('error', err);
}
for (let log of logs) {
if (opt.filter(log)) {
emitter.emit('log', log);
}
}
};
contract.subscribe(name, {
fromBlock: opt.from, toBlock: opt.to
}, callback)
.then((_subscription) => {
subscription = _subscription;
})
.catch((err) => {
emitter.emit('error', err);
});
return emitter;
};
export default subscribeToEvent;

View File

@ -47,6 +47,7 @@ class Account extends Component {
params: PropTypes.object, params: PropTypes.object,
accounts: PropTypes.object, accounts: PropTypes.object,
isTestnet: PropTypes.bool,
balances: PropTypes.object balances: PropTypes.object
} }
@ -65,8 +66,9 @@ class Account extends Component {
componentDidMount () { componentDidMount () {
const { api } = this.context; const { api } = this.context;
const { address } = this.props.params; const { address } = this.props.params;
const { isTestnet } = this.props;
const verificationStore = new VerificationStore(api, address); const verificationStore = new VerificationStore(api, address, isTestnet);
this.setState({ verificationStore }); this.setState({ verificationStore });
this.setVisibleAccounts(); this.setVisibleAccounts();
} }
@ -326,11 +328,13 @@ class Account extends Component {
function mapStateToProps (state) { function mapStateToProps (state) {
const { accounts } = state.personal; const { accounts } = state.personal;
const { isTest } = state.nodeStatus;
const { balances } = state.balances; const { balances } = state.balances;
const { images } = state; const { images } = state;
return { return {
accounts, accounts,
isTestnet: isTest,
balances, balances,
images images
}; };