Skip to content
This repository was archived by the owner on Sep 9, 2022. It is now read-only.

Commit 178fd81

Browse files
author
Thibault Ducret
authored
Merge pull request #26 from cosmincc/master
Add biometric (selfie) login step for 3rd factor authentication.
2 parents be0d680 + c7ce92b commit 178fd81

File tree

3 files changed

+97
-63
lines changed

3 files changed

+97
-63
lines changed

revolut/__init__.py

Lines changed: 62 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111

1212
__version__ = '0.1.4.dev0' # Should be the same in setup.py
1313

14-
_URL_GET_ACCOUNTS = "https://api.revolut.com/user/current/wallet"
15-
_URL_GET_TRANSACTIONS = 'https://api.revolut.com/user/current/transactions'
16-
_URL_QUOTE = "https://api.revolut.com/quote/"
17-
_URL_EXCHANGE = "https://api.revolut.com/exchange"
18-
_URL_GET_TOKEN_STEP1 = "https://api.revolut.com/signin"
19-
_URL_GET_TOKEN_STEP2 = "https://api.revolut.com/signin/confirm"
14+
API_BASE = "https://api.revolut.com"
15+
_URL_GET_ACCOUNTS = API_BASE + "/user/current/wallet"
16+
_URL_GET_TRANSACTIONS_LAST = API_BASE + "/user/current/transactions/last"
17+
_URL_QUOTE = API_BASE + "/quote/"
18+
_URL_EXCHANGE = API_BASE + "/exchange"
19+
_URL_GET_TOKEN_STEP1 = API_BASE + "/signin"
20+
_URL_GET_TOKEN_STEP2 = API_BASE + "/signin/confirm"
2021

2122
_DEFAULT_TOKEN_FOR_SIGNIN = "QXBwOlM5V1VuU0ZCeTY3Z1dhbjc="
2223

@@ -132,7 +133,7 @@ class Client:
132133
""" Do the requests with the Revolut servers """
133134
def __init__(self, token, device_id):
134135
self.session = requests.session()
135-
self.headers = {
136+
self.session.headers = {
136137
'Host': 'api.revolut.com',
137138
'X-Api-Version': '1',
138139
'X-Client-Version': '6.34.3',
@@ -141,22 +142,20 @@ def __init__(self, token, device_id):
141142
'Authorization': 'Basic '+token,
142143
}
143144

144-
def _get(self, url, expected_status_code=200):
145-
ret = self.session.get(url=url, headers=self.headers)
145+
def _get(self, url, *, expected_status_code=200, **kwargs):
146+
ret = self.session.get(url=url, **kwargs)
146147
if ret.status_code != expected_status_code:
147148
raise ConnectionError(
148-
'Status code {status} for url {url}\n{content}'.format(
149-
status=ret.status_code, url=url, content=ret.text))
149+
'Status code {} for url {}\n{}'.format(
150+
ret.status_code, url, ret.text))
150151
return ret
151152

152-
def _post(self, url, post_data, expected_status_code=200):
153-
ret = self.session.post(url=url,
154-
headers=self.headers,
155-
json=post_data)
153+
def _post(self, url, *, expected_status_code=200, **kwargs):
154+
ret = self.session.post(url=url, **kwargs)
156155
if ret.status_code != expected_status_code:
157156
raise ConnectionError(
158-
'Status code {status} for url {url}\n{content}'.format(
159-
status=ret.status_code, url=url, content=ret.text))
157+
'Status code {} for url {}\n{}'.format(
158+
ret.status_code, url, ret.text))
160159
return ret
161160

162161

@@ -168,7 +167,7 @@ def get_account_balances(self):
168167
""" Get the account balance for each currency
169168
and returns it as a dict {"balance":XXXX, "currency":XXXX} """
170169
ret = self.client._get(_URL_GET_ACCOUNTS)
171-
raw_accounts = json.loads(ret.text)
170+
raw_accounts = ret.json()
172171

173172
account_balances = []
174173
for raw_account in raw_accounts.get("pockets"):
@@ -183,22 +182,29 @@ def get_account_balances(self):
183182
self.account_balances = Accounts(account_balances)
184183
return self.account_balances
185184

186-
def get_account_transactions(self, from_date):
187-
""" Get the account transactions and return as json """
188-
from_date_ts = from_date.timestamp()
189-
path = _URL_GET_TRANSACTIONS + '?from={from_date_ts}&walletId={wallet_id}'.format(
190-
from_date_ts=int(from_date_ts) * 1000,
191-
wallet_id=self.get_wallet_id()
192-
)
193-
ret = self.client._get(path)
194-
raw_transactions = json.loads(ret.text)
195-
transactions = AccountTransactions(raw_transactions)
196-
return transactions
185+
def get_account_transactions(self, from_date=None, to_date=None):
186+
"""Get the account transactions."""
187+
raw_transactions = []
188+
params = {}
189+
if to_date:
190+
params['to'] = int(to_date.timestamp()) * 1000
191+
if from_date:
192+
params['from'] = int(from_date.timestamp()) * 1000
193+
194+
while True:
195+
ret = self.client._get(_URL_GET_TRANSACTIONS_LAST, params=params)
196+
ret_transactions = ret.json()
197+
if not ret_transactions:
198+
break
199+
params['to'] = ret_transactions[-1]['startedDate']
200+
raw_transactions.extend(ret_transactions)
201+
202+
return AccountTransactions(raw_transactions)
197203

198204
def get_wallet_id(self):
199205
""" Get the main wallet_id """
200206
ret = self.client._get(_URL_GET_ACCOUNTS)
201-
raw = json.loads(ret.text)
207+
raw = ret.json()
202208
return raw.get('id')
203209

204210
def quote(self, from_amount, to_currency):
@@ -213,7 +219,7 @@ def quote(self, from_amount, to_currency):
213219
to_currency,
214220
from_amount.revolut_amount))
215221
ret = self.client._get(url_quote)
216-
raw_quote = json.loads(ret.text)
222+
raw_quote = ret.json()
217223
quote_obj = Amount(revolut_amount=raw_quote["to"]["amount"],
218224
currency=to_currency)
219225
return quote_obj
@@ -257,8 +263,8 @@ def exchange(self, from_amount, to_currency, simulate=False):
257263
"updatedDate":123456789}]'
258264
raw_exchange = json.loads(simu)
259265
else:
260-
ret = self.client._post(url=_URL_EXCHANGE, post_data=data)
261-
raw_exchange = json.loads(ret.text)
266+
ret = self.client._post(_URL_EXCHANGE, json=data)
267+
raw_exchange = ret.json()
262268

263269
if raw_exchange[0]["state"] == "COMPLETED":
264270
amount = raw_exchange[0]["counterpart"]["amount"]
@@ -462,15 +468,12 @@ def csv(self, lang="fr", reverse=False):
462468

463469
def get_token_step1(device_id, phone, password, simulate=False):
464470
""" Function to obtain a Revolut token (step 1 : send a code by sms/email) """
465-
if not simulate:
466-
c = Client(device_id=device_id, token=_DEFAULT_TOKEN_FOR_SIGNIN)
467-
data = {"phone": phone, "password": password}
468-
ret = c._post(url=_URL_GET_TOKEN_STEP1,
469-
post_data=data,
470-
expected_status_code=200)
471-
channel = ret.json().get('channel')
472-
else:
473-
channel = "SMS"
471+
if simulate:
472+
return "SMS"
473+
c = Client(device_id=device_id, token=_DEFAULT_TOKEN_FOR_SIGNIN)
474+
data = {"phone": phone, "password": password}
475+
ret = c._post(_URL_GET_TOKEN_STEP1, json=data)
476+
channel = ret.json().get("channel")
474477
return channel
475478

476479

@@ -497,18 +500,25 @@ def get_token_step2(device_id, phone, code, simulate=False):
497500
c = Client(device_id=device_id, token=_DEFAULT_TOKEN_FOR_SIGNIN)
498501
code = code.replace("-", "") # If the user would put -
499502
data = {"phone": phone, "code": code}
500-
ret = c._post(url=_URL_GET_TOKEN_STEP2, post_data=data)
503+
ret = c._post(_URL_GET_TOKEN_STEP2, json=data)
501504
raw_get_token = ret.json()
505+
return raw_get_token
502506

503-
if raw_get_token.get("thirdFactorAuthAccessToken"):
504-
raise KeyError(
505-
"Token generation with a third factor authentication (selfie) "
506-
"is not currently supported by this package"
507-
)
508507

509-
user_id = raw_get_token["user"]["id"]
510-
access_token = raw_get_token["accessToken"]
511-
token_to_encode = '{}:{}'.format(user_id, access_token).encode('ascii')
508+
def extract_token(json_response):
509+
user_id = json_response["user"]["id"]
510+
access_token = json_response["accessToken"]
511+
token_to_encode = "{}:{}".format(user_id, access_token).encode("ascii")
512512
# Ascii encoding required by b64encode function : 8 bits char as input
513513
token = base64.b64encode(token_to_encode)
514-
return token.decode('ascii')
514+
return token.decode("ascii")
515+
516+
517+
def signin_biometric(device_id, phone, access_token, selfie_filepath):
518+
files = {"selfie": open(selfie_filepath, "rb")}
519+
c = Client(device_id=device_id, token=_DEFAULT_TOKEN_FOR_SIGNIN)
520+
c.session.auth = (phone, access_token)
521+
res = c._post(API_BASE + "/biometric-signin/selfie", files=files)
522+
biometric_id = res.json()["id"]
523+
res = c._post(API_BASE + "/biometric-signin/confirm/" + biometric_id)
524+
return res.json()

revolut_cli.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from getpass import getpass
66
import sys
77

8-
from revolut import Revolut, __version__, get_token_step1, get_token_step2
8+
from revolut import Revolut, __version__, get_token_step1, get_token_step2, signin_biometric, extract_token
99

1010
# Usage : revolut_cli.py --help
1111

@@ -78,11 +78,22 @@ def get_token():
7878
"[ex : 123456] : ".format(verification_channel)
7979
)
8080

81-
token = get_token_step2(
81+
response = get_token_step2(
8282
device_id=_CLI_DEVICE_ID,
8383
phone=phone,
8484
code=code,
8585
)
86+
87+
if "thirdFactorAuthAccessToken" in response:
88+
access_token = response["thirdFactorAuthAccessToken"]
89+
print()
90+
print("Selfie 3rd factor authentication was requested.")
91+
selfie_filepath = input(
92+
"Provide a selfie image file path (800x600) [ex : selfie.png] ")
93+
response = signin_biometric(
94+
_CLI_DEVICE_ID, phone, access_token, selfie_filepath)
95+
96+
token = extract_token(response)
8697
token_str = "Your token is {}".format(token)
8798

8899
dashes = len(token_str) * "-"

revolut_transactions.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,14 @@
33

44
import click
55
import json
6-
import sys
76

87
from datetime import datetime
98
from datetime import timedelta
10-
from getpass import getpass
119

12-
from revolut import Revolut, __version__, get_token_step1, get_token_step2
10+
from revolut import Revolut, __version__
1311

1412

1513
_CLI_DEVICE_ID = 'revolut_cli'
16-
_URL_GET_TRANSACTIONS = 'https://api.revolut.com/user/current/transactions'
1714

1815

1916
@click.command()
@@ -25,8 +22,8 @@
2522
)
2623
@click.option(
2724
'--language', '-l',
28-
type=str,
29-
help='language ("fr" or "en"), for the csv header and separator',
25+
type=click.Choice(['en', 'fr']),
26+
help='language for the csv header and separator',
3027
default='fr'
3128
)
3229
@click.option(
@@ -35,20 +32,36 @@
3532
help='transactions lookback date in YYYY-MM-DD format (ex: "2019-10-26"). Default 30 days back',
3633
default=(datetime.now()-timedelta(days=30)).strftime("%Y-%m-%d")
3734
)
35+
@click.option(
36+
'--output_format', '-fmt',
37+
type=click.Choice(['csv', 'json']),
38+
help="output format",
39+
default='csv',
40+
)
3841
@click.option(
3942
'--reverse', '-r',
4043
is_flag=True,
4144
help='reverse the order of the transactions displayed',
4245
)
43-
def main(token, language, from_date, reverse):
46+
def main(token, language, from_date, output_format, reverse):
4447
""" Get the account balances on Revolut """
4548
if token is None:
4649
print("You don't seem to have a Revolut token. Use 'revolut_cli' to obtain one")
47-
sys.exit()
50+
exit(1)
4851

4952
rev = Revolut(device_id=_CLI_DEVICE_ID, token=token)
5053
account_transactions = rev.get_account_transactions(from_date)
51-
print(account_transactions.csv(lang=language, reverse=reverse))
54+
if output_format == 'csv':
55+
print(account_transactions.csv(lang=language, reverse=reverse))
56+
elif output_format == 'json':
57+
transactions = account_transactions.raw_list
58+
if reverse:
59+
transactions = reversed(transactions)
60+
print(json.dumps(transactions))
61+
else:
62+
print("output format {!r} not implemented".format(output_format))
63+
exit(1)
64+
5265

5366
if __name__ == "__main__":
5467
main()

0 commit comments

Comments
 (0)