137 lines
5.1 KiB
Python
137 lines
5.1 KiB
Python
|
import logging
|
||
|
import json
|
||
|
import time
|
||
|
from xmlrpc.client import Fault
|
||
|
|
||
|
from passlib.totp import TOTP
|
||
|
|
||
|
from odoo import http
|
||
|
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
|
||
|
from odoo.exceptions import AccessDenied
|
||
|
from odoo.service import common as auth, model
|
||
|
from odoo.tests import tagged, get_db_name, loaded_demo_data
|
||
|
from odoo.tools import mute_logger
|
||
|
|
||
|
from ..controllers.home import Home
|
||
|
|
||
|
_logger = logging.getLogger(__name__)
|
||
|
|
||
|
class TestTOTPMixin:
|
||
|
def install_totphook(self):
|
||
|
totp = None
|
||
|
# might be possible to do client-side using `crypto.subtle` instead of
|
||
|
# this horror show, but requires working on 64b integers, & BigInt is
|
||
|
# significantly less well supported than crypto
|
||
|
def totp_hook(self, secret=None):
|
||
|
nonlocal totp
|
||
|
if totp is None:
|
||
|
totp = TOTP(secret)
|
||
|
if secret:
|
||
|
return totp.generate().token
|
||
|
else:
|
||
|
# on check, take advantage of window because previous token has been
|
||
|
# "burned" so we can't generate the same, but tour is so fast
|
||
|
# we're pretty certainly within the same 30s
|
||
|
return totp.generate(time.time() + 30).token
|
||
|
# because not preprocessed by ControllerType metaclass
|
||
|
totp_hook.routing_type = 'json'
|
||
|
self.env.registry.clear_cache('routing')
|
||
|
# patch Home to add test endpoint
|
||
|
Home.totp_hook = http.route('/totphook', type='json', auth='none')(totp_hook)
|
||
|
# remove endpoint and destroy routing map
|
||
|
@self.addCleanup
|
||
|
def _cleanup():
|
||
|
del Home.totp_hook
|
||
|
self.env.registry.clear_cache('routing')
|
||
|
|
||
|
|
||
|
@tagged('post_install', '-at_install')
|
||
|
class TestTOTP(HttpCaseWithUserDemo, TestTOTPMixin):
|
||
|
def setUp(self):
|
||
|
super().setUp()
|
||
|
self.install_totphook()
|
||
|
|
||
|
def test_totp(self):
|
||
|
# TODO: Make this work if no demo data + hr installed
|
||
|
if not loaded_demo_data(self.env):
|
||
|
_logger.warning("This test relies on demo data. To be rewritten independently of demo data for accurate and reliable results.")
|
||
|
return
|
||
|
# 1. Enable 2FA
|
||
|
self.start_tour('/web', 'totp_tour_setup', login='demo')
|
||
|
|
||
|
# 2. Verify that RPC is blocked because 2FA is on.
|
||
|
self.assertFalse(
|
||
|
self.xmlrpc_common.authenticate(get_db_name(), 'demo', 'demo', {}),
|
||
|
"Should not have returned a uid"
|
||
|
)
|
||
|
self.assertFalse(
|
||
|
self.xmlrpc_common.authenticate(get_db_name(), 'demo', 'demo', {'interactive': True}),
|
||
|
'Trying to fake the auth type should not work'
|
||
|
)
|
||
|
uid = self.user_demo.id
|
||
|
with self.assertRaisesRegex(Fault, r'Access Denied'):
|
||
|
self.xmlrpc_object.execute_kw(
|
||
|
get_db_name(), uid, 'demo',
|
||
|
'res.users', 'read', [uid, ['login']]
|
||
|
)
|
||
|
|
||
|
# 3. Check 2FA is required
|
||
|
self.start_tour('/', 'totp_login_enabled', login=None)
|
||
|
|
||
|
# 4. Check 2FA is not requested on saved device and disable it
|
||
|
self.start_tour('/', 'totp_login_device', login=None)
|
||
|
|
||
|
# 5. Finally, check that 2FA is in fact disabled
|
||
|
self.start_tour('/', 'totp_login_disabled', login=None)
|
||
|
|
||
|
# 6. Check that rpc is now re-allowed
|
||
|
uid = self.xmlrpc_common.authenticate(get_db_name(), 'demo', 'demo', {})
|
||
|
self.assertEqual(uid, self.user_demo.id)
|
||
|
[r] = self.xmlrpc_object.execute_kw(
|
||
|
get_db_name(), uid, 'demo',
|
||
|
'res.users', 'read', [uid, ['login']]
|
||
|
)
|
||
|
self.assertEqual(r['login'], 'demo')
|
||
|
|
||
|
|
||
|
def test_totp_administration(self):
|
||
|
# TODO: Make this work if no demo data + hr installed
|
||
|
if not loaded_demo_data(self.env):
|
||
|
_logger.warning("This test relies on demo data. To be rewritten independently of demo data for accurate and reliable results.")
|
||
|
return
|
||
|
self.start_tour('/web', 'totp_tour_setup', login='demo')
|
||
|
self.start_tour('/web', 'totp_admin_disables', login='admin')
|
||
|
self.start_tour('/', 'totp_login_disabled', login=None)
|
||
|
|
||
|
@mute_logger('odoo.http')
|
||
|
def test_totp_authenticate(self):
|
||
|
"""
|
||
|
Ensure we don't leak the session info from an half-logged-in
|
||
|
user.
|
||
|
"""
|
||
|
# TODO: Make this work if no demo data + hr installed
|
||
|
if not loaded_demo_data(self.env):
|
||
|
_logger.warning("This test relies on demo data. To be rewritten independently of demo data for accurate and reliable results.")
|
||
|
return
|
||
|
|
||
|
self.start_tour('/web', 'totp_tour_setup', login='demo')
|
||
|
self.url_open('/web/session/logout')
|
||
|
|
||
|
headers = {
|
||
|
"Content-Type": "application/json",
|
||
|
}
|
||
|
|
||
|
payload = {
|
||
|
"jsonrpc": "2.0",
|
||
|
"method": "call",
|
||
|
"id": 0,
|
||
|
"params": {
|
||
|
"db": get_db_name(),
|
||
|
"login": "demo",
|
||
|
"password": "demo",
|
||
|
},
|
||
|
}
|
||
|
response = self.url_open("/web/session/authenticate", data=json.dumps(payload), headers=headers)
|
||
|
data = response.json()
|
||
|
self.assertEqual(data['result']['uid'], None)
|