Skip to content

Commit 69eb715

Browse files
committed
Experimental support for 2FA
1 parent de8033d commit 69eb715

File tree

2 files changed

+49
-8
lines changed

2 files changed

+49
-8
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Changelog
1010
* Do not authenticate with ``/web/session/authenticate`` when
1111
protocol is ``jsonrpc`` or ``xmlrpc``. It cannot authenticate API keys.
1212

13+
* Experimental support for 2FA with Webclient session, with Odoo >= 15.0.
14+
1315
* Fix PyPI classifiers.
1416

1517
* Update documentation.

odooly.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,7 @@ def session_authenticate(self, login=None, password=None):
958958
'password': password or getpass(f"Password for {login!r}: "),
959959
}
960960
self.session_info = self.client._authenticate_session(**params)
961+
print(f'Session authenticated for {login!r}' if self.session_info['uid'] else 'Failed')
961962

962963
def session_destroy(self):
963964
"""Terminate current Webclient session."""
@@ -1124,9 +1125,44 @@ def _authenticate(self, db, login, password):
11241125
return info
11251126

11261127
def _authenticate_session(self, db, login, password):
1127-
info = self.web_session.authenticate(db=db, login=login, password=password)
1128+
try:
1129+
info = self.web_session.authenticate(db=db, login=login, password=password)
1130+
except ServerError as exc:
1131+
# Ignore: odoo.exceptions.AccessDenied
1132+
if exc.args[0]['code'] != 200:
1133+
raise
1134+
return {'uid': None}
1135+
if self.version_info > 14.0 and info['uid'] is None: # Is it 2FA?
1136+
info = self._authenticate_totp(db, login, password)
11281137
return info
11291138

1139+
def _authenticate_totp(self, db, login, password):
1140+
headers = {'User-Agent': 'Mozilla/5.0 (X11)'}
1141+
1142+
# 1. Get CSRF token
1143+
rv = self._post(f'{self.web._server}web', method='GET', headers=headers)
1144+
csrf = re.search(r'csrf_token: "(\w+)"', rv).group(1)
1145+
1146+
# 2. Login
1147+
params = {'csrf_token': csrf, 'db': db, 'login': login, 'password': password}
1148+
rv = self._post(f'{self.web._server}web/login', data=params, headers=headers)
1149+
1150+
for retry in range(4):
1151+
# 3. Parse 'session_info'
1152+
session_info = json.loads(re.search(r'odoo.__session_info__ = (.*);', rv).group(1))
1153+
if session_info['uid'] or 'totp_token' not in rv or retry == 3:
1154+
break
1155+
if retry:
1156+
print('Verification failed')
1157+
1158+
# 4. Ask TOTP code
1159+
token = getpass(f"Authentication Code for {login!r} (2FA 6-digits): ")
1160+
1161+
# 5. Submit TOTP
1162+
params = {'csrf_token': csrf, 'totp_token': token, 'remember': 1}
1163+
rv = self._post(f'{self.web._server}web/login/totp', data=params, headers=headers)
1164+
return session_info
1165+
11301166
def _login(self, user, password=None, database=None):
11311167
"""Switch `user` and (optionally) `database`.
11321168
@@ -1266,22 +1302,25 @@ def _call_kw(self, model, method, args, kw=None):
12661302
def _set_http_session(self):
12671303
self._http_session = requests.Session()
12681304

1269-
def _post(self, url, *, data=None, json=None, headers=None, **kw):
1270-
resp = self._http_session.post(url, data=data, json=json, headers=headers, **kw)
1271-
return resp.json() if json else resp.text
1305+
def _post(self, url, *, method='POST', data=None, json=None, headers=None, **kw):
1306+
resp = self._http_session.request(method, url, data=data, json=json, headers=headers, **kw)
1307+
return resp.text if json is None else resp.json()
12721308

12731309
else: # urllib.request
12741310
def _set_http_session(self):
12751311
self._http_session = build_opener(HTTPCookieProcessor(), HTTPSHandler(context=http_context))
12761312

1277-
def _post(self, url, *, data=None, json=None, headers=None, __json=json, **kw):
1313+
def _post(self, url, *, method='POST', data=None, json=None, headers=None, __json=json, **kw):
12781314
headers = dict(headers or ())
1279-
if json:
1315+
if json is not None:
12801316
headers.setdefault('Content-Type', 'application/json')
1281-
data = __json.dumps(json).encode('ascii') if json else urlencode(data).encode('utf-8')
1317+
if method != 'GET':
1318+
data = __json.dumps(json).encode('ascii') if json else urlencode(data).encode('utf-8')
1319+
elif data is not None:
1320+
url, data = f'{url}?{urlencode(data)}', None
12821321
request = Request(url, data=data, headers=headers)
12831322
resp = self._http_session.open(request)
1284-
return __json.load(resp) if json else resp.read()
1323+
return str(resp.read(), 'utf-8') if json is None else __json.load(resp)
12851324

12861325

12871326
class BaseModel:

0 commit comments

Comments
 (0)