Skip to content

Commit 73d465e

Browse files
committed
feat: Add authentication using certificates (Same flow as the mobile app)
1 parent 6f3b436 commit 73d465e

15 files changed

+559
-197
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,6 @@ ENV/
106106

107107
# OSX Files
108108
.DS_Store
109+
110+
*.p12
111+
*.json

pynubank/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
from .nubank import Nubank, NuException
1+
from .exception import NuRequestException, NuException
2+
from .nubank import Nubank

pynubank/cli.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import os
2+
import random
3+
import string
4+
from getpass import getpass
5+
6+
from colorama import init, Fore, Style
7+
8+
from pynubank import NuException
9+
from pynubank.utils.certificate_generator import CertificateGenerator
10+
11+
12+
def generate_random_id() -> str:
13+
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=12))
14+
15+
16+
def log(message, color=Fore.BLUE):
17+
print(f'{color}{Style.DIM}[*] {Style.NORMAL}{Fore.LIGHTBLUE_EX}{message}')
18+
19+
20+
def save_cert(cert, name):
21+
path = os.path.join(os.getcwd(), name)
22+
with open(path, 'wb') as cert_file:
23+
cert_file.write(cert.export())
24+
25+
26+
def main():
27+
init()
28+
29+
log(f'Starting {Fore.MAGENTA}{Style.DIM}PyNubank{Style.NORMAL}{Fore.LIGHTBLUE_EX} context creation.')
30+
31+
device_id = generate_random_id()
32+
33+
log(f'Generated random id: {device_id}')
34+
35+
cpf = input(f'[>] Enter your CPF(Numbers only): ')
36+
password = getpass('[>] Enter your password (Used on the app/website): ')
37+
38+
generator = CertificateGenerator(cpf, password, device_id)
39+
40+
log('Requesting e-mail code')
41+
try:
42+
email = generator.request_code()
43+
except NuException:
44+
log(f'{Fore.RED}Failed to request code. Check your credentials!', Fore.RED)
45+
exit(1)
46+
47+
log(f'Email sent to {Fore.LIGHTBLACK_EX}{email}{Fore.LIGHTBLUE_EX}')
48+
code = input('[>] Type the code received by email: ')
49+
50+
cert1, cert2 = generator.exchange_certs(code)
51+
52+
save_cert(cert1, 'cert.p12')
53+
save_cert(cert2, 'cert_crypto.p12')
54+
55+
print(f'{Fore.GREEN}Certificates generated successfully. (cert.pem and cert_crypto.pem)')
56+
print(f'{Fore.YELLOW}Warning, keep these certificates safe (Do not share or version in git)')
57+
58+
59+
if __name__ == '__main__':
60+
main()

pynubank/exception.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from requests import Response
2+
3+
4+
class NuException(Exception):
5+
6+
def __init__(self, message):
7+
super().__init__(message)
8+
9+
10+
class NuRequestException(NuException):
11+
def __init__(self, response: Response):
12+
super().__init__(f'The request made failed with HTTP status code {response.status_code}')
13+
self.url = response.url
14+
self.status_code = response.status_code
15+
self.response = response

pynubank/nubank.py

Lines changed: 51 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import json
21
import os
32
import uuid
43
from typing import Tuple
54

6-
import requests
75
from qrcode import QRCode
8-
from requests import Response
6+
7+
from pynubank.utils.discovery import Discovery
8+
from pynubank.utils.http import HttpClient
99

1010
PAYMENT_EVENT_TYPES = (
1111
'TransferOutEvent',
@@ -17,32 +17,14 @@
1717
)
1818

1919

20-
class NuException(Exception):
21-
def __init__(self, status_code, response, url):
22-
super().__init__(f'The request made failed with HTTP status code {status_code}')
23-
self.url = url
24-
self.status_code = status_code
25-
self.response = response
26-
27-
2820
class Nubank:
29-
DISCOVERY_URL = 'https://prod-s0-webapp-proxy.nubank.com.br/api/discovery'
30-
DISCOVERY_APP_URL = 'https://prod-s0-webapp-proxy.nubank.com.br/api/app/discovery'
31-
auth_url = None
3221
feed_url = None
33-
proxy_list_url = None
34-
proxy_list_app_url = None
3522
query_url = None
3623
bills_url = None
3724

3825
def __init__(self):
39-
self.headers = {
40-
'Content-Type': 'application/json',
41-
'X-Correlation-Id': 'WEB-APP.pewW9',
42-
'User-Agent': 'pynubank Client - https://github.com/andreroggeri/pynubank',
43-
}
44-
self._update_proxy_urls()
45-
self.auth_url = self.proxy_list_url['login']
26+
self.client = HttpClient()
27+
self.discovery = Discovery(self.client)
4628

4729
@staticmethod
4830
def _get_query(query_name):
@@ -52,19 +34,11 @@ def _get_query(query_name):
5234
with open(path) as gql:
5335
return gql.read()
5436

55-
def _update_proxy_urls(self):
56-
request = requests.get(self.DISCOVERY_URL, headers=self.headers)
57-
self.proxy_list_url = json.loads(request.content.decode('utf-8'))
58-
request = requests.get(self.DISCOVERY_APP_URL, headers=self.headers)
59-
self.proxy_list_app_url = json.loads(request.content.decode('utf-8'))
60-
6137
def _make_graphql_request(self, graphql_object):
6238
body = {
6339
'query': self._get_query(graphql_object)
6440
}
65-
response = requests.post(self.query_url, json=body, headers=self.headers)
66-
67-
return self._handle_response(response)
41+
return self.client.post(self.query_url, json=body)
6842

6943
def _password_auth(self, cpf: str, password: str):
7044
payload = {
@@ -74,15 +48,13 @@ def _password_auth(self, cpf: str, password: str):
7448
"client_id": "other.conta",
7549
"client_secret": "yQPeLzoHuJzlMMSAjC-LgNUJdUecx8XO"
7650
}
77-
response = requests.post(self.auth_url, json=payload, headers=self.headers)
78-
data = self._handle_response(response)
79-
return data
80-
81-
def _handle_response(self, response: Response) -> dict:
82-
if response.status_code != 200:
83-
raise NuException(response.status_code, response.json(), response.url)
51+
return self.client.post(self.discovery.get_url('login'), json=payload)
8452

85-
return response.json()
53+
def _save_auth_data(self, auth_data: dict) -> None:
54+
self.client.set_header('Authorization', f'Bearer {auth_data["access_token"]}')
55+
self.feed_url = auth_data['_links']['events']['href']
56+
self.query_url = auth_data['_links']['ghostflame']['href']
57+
self.bills_url = auth_data['_links']['bills_summary']['href']
8658

8759
def get_qr_code(self) -> Tuple[str, QRCode]:
8860
content = str(uuid.uuid4())
@@ -92,36 +64,62 @@ def get_qr_code(self) -> Tuple[str, QRCode]:
9264

9365
def authenticate_with_qr_code(self, cpf: str, password, uuid: str):
9466
auth_data = self._password_auth(cpf, password)
95-
self.headers['Authorization'] = f'Bearer {auth_data["access_token"]}'
67+
self.client.set_header('Authorization', f'Bearer {auth_data["access_token"]}')
9668

9769
payload = {
9870
'qr_code_id': uuid,
9971
'type': 'login-webapp'
10072
}
10173

102-
response = requests.post(self.proxy_list_app_url['lift'], json=payload, headers=self.headers)
74+
response = self.client.post(self.discovery.get_app_url('lift'), json=payload)
10375

104-
auth_data = self._handle_response(response)
105-
self.headers['Authorization'] = f'Bearer {auth_data["access_token"]}'
106-
self.feed_url = auth_data['_links']['events']['href']
107-
self.query_url = auth_data['_links']['ghostflame']['href']
108-
self.bills_url = auth_data['_links']['bills_summary']['href']
76+
self._save_auth_data(response)
77+
78+
def authenticate_with_cert(self, cpf: str, password: str, cert_path: str):
79+
self.client.set_cert(cert_path)
80+
url = self.discovery.get_app_url('token')
81+
payload = {
82+
'grant_type': 'password',
83+
'client_id': 'legacy_client_id',
84+
'client_secret': 'legacy_client_secret',
85+
'login': cpf,
86+
'password': password
87+
}
88+
89+
response = self.client.post(url, json=payload)
90+
91+
self._save_auth_data(response)
92+
93+
return response.get('refresh_token')
94+
95+
def authenticate_with_refresh_token(self, refresh_token: str, cert_path: str):
96+
self.client.set_cert(cert_path)
97+
98+
url = self.discovery.get_app_url('token')
99+
payload = {
100+
'grant_type': 'refresh_token',
101+
'client_id': 'legacy_client_id',
102+
'client_secret': 'legacy_client_secret',
103+
'refresh_token': refresh_token,
104+
}
105+
106+
response = self.client.post(url, json=payload)
107+
108+
self._save_auth_data(response)
109109

110110
def get_card_feed(self):
111-
request = requests.get(self.feed_url, headers=self.headers)
112-
return json.loads(request.content.decode('utf-8'))
111+
return self.client.get(self.feed_url)
113112

114113
def get_card_statements(self):
115114
feed = self.get_card_feed()
116115
return list(filter(lambda x: x['category'] == 'transaction', feed['events']))
117116

118117
def get_bills(self):
119-
request = requests.get(self.bills_url, headers=self.headers)
120-
return json.loads(request.content.decode('utf-8'))['bills']
118+
request = self.client.get(self.bills_url)
119+
return request['bills']
121120

122121
def get_bill_details(self, bill):
123-
request = requests.get(bill['_links']['self']['href'], headers=self.headers)
124-
return json.loads(request.content.decode('utf-8'))
122+
return self.client.get(bill['_links']['self']['href'])
125123

126124
def get_account_feed(self):
127125
data = self._make_graphql_request('account_feed')

pynubank/utils/__init__.py

Whitespace-only changes.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import OpenSSL
2+
import requests
3+
from OpenSSL.crypto import X509
4+
5+
from pynubank import NuException, NuRequestException
6+
from pynubank.utils.discovery import Discovery
7+
from pynubank.utils.http import HttpClient
8+
9+
10+
class CertificateGenerator:
11+
12+
def __init__(self, login, password, device_id):
13+
self.login = login
14+
self.password = password
15+
self.device_id = device_id
16+
self.encrypted_code = None
17+
self.key1 = self._generate_key()
18+
self.key2 = self._generate_key()
19+
discovery = Discovery(HttpClient())
20+
self.url = discovery.get_app_url('gen_certificate')
21+
22+
def request_code(self) -> str:
23+
response = requests.post(self.url, json=self._get_payload())
24+
25+
if response.status_code != 401:
26+
raise NuException('Authentication code request failure.')
27+
28+
parsed = self._parse_authenticate_headers(response.headers.get('WWW-Authenticate'))
29+
self.encrypted_code = parsed.get('device-authorization_encrypted-code')
30+
31+
return parsed['sent-to']
32+
33+
def exchange_certs(self, code: str):
34+
if not self.encrypted_code:
35+
raise NuException('No encrypted code found. Did you call `request_code` before exchanging certs ?')
36+
37+
payload = self._get_payload()
38+
payload['code'] = code
39+
payload['encrypted-code'] = self.encrypted_code
40+
41+
response = requests.post(self.url, json=payload)
42+
43+
if response.status_code != 200:
44+
raise NuRequestException(response)
45+
46+
data = response.json()
47+
48+
cert1 = self._parse_cert(data['certificate'])
49+
cert2 = self._parse_cert(data['certificate_crypto'])
50+
51+
return self._gen_cert(self.key1, cert1), self._gen_cert(self.key2, cert2)
52+
53+
def _get_payload(self):
54+
return {
55+
'login': self.login,
56+
'password': self.password,
57+
'public_key': self._get_public_key(self.key1),
58+
'public_key_crypto': self._get_public_key(self.key2),
59+
'model': 'PyNubank Client',
60+
'device_id': self.device_id
61+
}
62+
63+
def _parse_cert(self, content: str) -> X509:
64+
return OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, content.encode())
65+
66+
def _gen_cert(self, key, cert):
67+
p12 = OpenSSL.crypto.PKCS12()
68+
p12.set_privatekey(key)
69+
p12.set_certificate(cert)
70+
71+
return p12
72+
73+
def _generate_key(self):
74+
key = OpenSSL.crypto.PKey()
75+
key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
76+
77+
return key
78+
79+
def _get_public_key(self, key) -> str:
80+
return OpenSSL.crypto.dump_publickey(OpenSSL.crypto.FILETYPE_PEM, key).decode()
81+
82+
def _parse_authenticate_headers(self, header_content: str) -> dict:
83+
chunks = header_content.split(',')
84+
parsed = {}
85+
for chunk in chunks:
86+
key, value = chunk.split('=')
87+
key = key.strip().replace(' ', '_')
88+
value = value.replace('"', '')
89+
parsed[key] = value
90+
91+
return parsed

pynubank/utils/discovery.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from pynubank.exception import NuException
2+
from pynubank.utils.http import HttpClient
3+
4+
DISCOVERY_URL = 'https://prod-s0-webapp-proxy.nubank.com.br/api/discovery'
5+
DISCOVERY_APP_URL = 'https://prod-s0-webapp-proxy.nubank.com.br/api/app/discovery'
6+
7+
8+
class Discovery:
9+
_headers = {
10+
'Content-Type': 'application/json'
11+
}
12+
13+
proxy_list_url: dict
14+
proxy_list_app_url: dict
15+
16+
def __init__(self, client: HttpClient):
17+
self.client = client
18+
self._update_proxy_urls()
19+
20+
def get_url(self, name: str) -> str:
21+
return self._get_url(name, self.proxy_list_url)
22+
23+
def get_app_url(self, name: str) -> str:
24+
return self._get_url(name, self.proxy_list_app_url)
25+
26+
def _update_proxy_urls(self):
27+
self.proxy_list_url = self.client.get(DISCOVERY_URL)
28+
self.proxy_list_app_url = self.client.get(DISCOVERY_APP_URL)
29+
30+
def _get_url(self, name: str, target: dict) -> str:
31+
url = target.get(name)
32+
if not url:
33+
raise NuException(f'There is no URL discovered for {name}')
34+
35+
return url

0 commit comments

Comments
 (0)