439 lines
26 KiB
Python
439 lines
26 KiB
Python
|
import json
|
||
|
|
||
|
from contextlib import contextmanager
|
||
|
from lxml import etree
|
||
|
from unittest.mock import patch
|
||
|
|
||
|
from odoo.http import request
|
||
|
from odoo.tools import SQL, mute_logger
|
||
|
|
||
|
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
|
||
|
|
||
|
|
||
|
class PasskeyTest(HttpCaseWithUserDemo):
|
||
|
@classmethod
|
||
|
def setUpClass(self):
|
||
|
super().setUpClass()
|
||
|
|
||
|
self.admin_user = self.env.ref('base.user_admin')
|
||
|
|
||
|
# Hard-coded webauthn keys, challenges and responses, used in the below unit tests.
|
||
|
self.passkeys = {
|
||
|
'test-yubikey': {
|
||
|
'user': self.admin_user,
|
||
|
'credential_identifier': 'L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB',
|
||
|
'public_key': 'pQECAyYgASFYIC9qeo73FrgjE0ZpGRwxLIG50L4kNlhj2DIyqSc_YiRSIlgg2q6bL2-IoJ6j_GkVTdfPKyx8RF5e8wzX9-Zk37AykM8=',
|
||
|
'host': 'http://localhost:8069',
|
||
|
'registration': {
|
||
|
'challenge': 'Uoa6M5jEP7I3ToyK9QA0vf8IcsezfeJk0rgs1pLUWrMgF9vd0-7Dv5iV3xW7r70-YqkweRXhACmDPmhHKtAIeQ',
|
||
|
'response': {
|
||
|
"id": "L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB",
|
||
|
"rawId": "L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB",
|
||
|
"response": {
|
||
|
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjCSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2PFAAAAAgAAAAAAAAAAAAAAAAAAAAAAMC9qeo73FrgjE0ZpGRyqqr0G89A4ZNyWyTsdRfHIvfZ0jY5X8d2e55IoDHVAAi4IAaUBAgMmIAEhWCAvanqO9xa4IxNGaRkcMSyBudC-JDZYY9gyMqknP2IkUiJYINqumy9viKCeo_xpFU3XzyssfEReXvMM1_fmZN-wMpDPoWtjcmVkUHJvdGVjdAI",
|
||
|
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVW9hNk01akVQN0kzVG95SzlRQTB2ZjhJY3NlemZlSmswcmdzMXBMVVdyTWdGOXZkMC03RHY1aVYzeFc3cjcwLVlxa3dlUlhoQUNtRFBtaEhLdEFJZVEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwNjkiLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
|
||
|
"transports": [
|
||
|
"nfc",
|
||
|
"usb",
|
||
|
],
|
||
|
"publicKeyAlgorithm": -7,
|
||
|
"publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEL2p6jvcWuCMTRmkZHDEsgbnQviQ2WGPYMjKpJz9iJFLarpsvb4ignqP8aRVN188rLHxEXl7zDNf35mTfsDKQzw",
|
||
|
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2PFAAAAAgAAAAAAAAAAAAAAAAAAAAAAMC9qeo73FrgjE0ZpGRyqqr0G89A4ZNyWyTsdRfHIvfZ0jY5X8d2e55IoDHVAAi4IAaUBAgMmIAEhWCAvanqO9xa4IxNGaRkcMSyBudC-JDZYY9gyMqknP2IkUiJYINqumy9viKCeo_xpFU3XzyssfEReXvMM1_fmZN-wMpDPoWtjcmVkUHJvdGVjdAI"
|
||
|
},
|
||
|
"type": "public-key",
|
||
|
"clientExtensionResults": {},
|
||
|
"authenticatorAttachment": "cross-platform"
|
||
|
}
|
||
|
},
|
||
|
'auth': {
|
||
|
'challenge': 'DKrw5IeFiL0w_y5oB0RATPrqG1eFOC2P7yEiXCstBRuZYbSBBkAWfhAIInkMqWjYIN8vbKn7J3PMX_ThznHpqg',
|
||
|
'response': {
|
||
|
"id": "L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB",
|
||
|
"rawId": "L2p6jvcWuCMTRmkZHKqqvQbz0Dhk3JbJOx1F8ci99nSNjlfx3Z7nkigMdUACLggB",
|
||
|
"response": {
|
||
|
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAw",
|
||
|
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiREtydzVJZUZpTDB3X3k1b0IwUkFUUHJxRzFlRk9DMlA3eUVpWENzdEJSdVpZYlNCQmtBV2ZoQUlJbmtNcVdqWUlOOHZiS243SjNQTVhfVGh6bkhwcWciLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwNjkiLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
|
||
|
"signature": "MEUCIQD5iaPp48QMS3amx4PS89kv_EBAo3bBkaWnLzWlSgFSXgIgLWKEv9xR_ZwVXZbw2zx459RKbrQuAcd-UqD4gJw1lWY",
|
||
|
"userHandle": "Mg"
|
||
|
},
|
||
|
"type": "public-key",
|
||
|
"clientExtensionResults": {},
|
||
|
"authenticatorAttachment": "cross-platform",
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
'test-yubikey-nano': {
|
||
|
'user': self.admin_user,
|
||
|
'credential_identifier': 'wtw0u7D8rp7nq7WBWFCt_FRhEHpU6EHvEgTn3BBid5N-UE5a9XCzS8NaVuh7ydFz',
|
||
|
'public_key': 'pQECAyYgASFYIMLcNLuw_K6e56u1gVioLcAJF8v8eUw7kfqTOqDdl7nFIlggFSs_nZWewd_JqzeWzXmJ6Wmn_nKuo82rCdoOZ-oewOU=',
|
||
|
'host': 'http://localhost:8069',
|
||
|
'auth': {
|
||
|
'challenge': 'oj09zruUyqUMIFO0ol5UltUd955Qqw9iche5w_g9k6jByR69ioWtnC-RWLRie_8sqHO_T2bICJplaQNPRxfpeA',
|
||
|
'response': {
|
||
|
"id": "wtw0u7D8rp7nq7WBWFCt_FRhEHpU6EHvEgTn3BBid5N-UE5a9XCzS8NaVuh7ydFz",
|
||
|
"rawId": "wtw0u7D8rp7nq7WBWFCt_FRhEHpU6EHvEgTn3BBid5N-UE5a9XCzS8NaVuh7ydFz",
|
||
|
"response": {
|
||
|
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAABg",
|
||
|
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoib2owOXpydVV5cVVNSUZPMG9sNVVsdFVkOTU1UXF3OWljaGU1d19nOWs2akJ5UjY5aW9XdG5DLVJXTFJpZV84c3FIT19UMmJJQ0pwbGFRTlBSeGZwZUEiLCJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwNjkiLCJjcm9zc09yaWdpbiI6ZmFsc2V9",
|
||
|
"signature": "MEUCIDj-tI1yRGqnqd6uZeuInPaGY0yNYwC-5W4d024zwUs0AiEApJAst0t7G40ZRp1_TIKbftD-p9BkmafTPZBBe4Ps0P0",
|
||
|
"userHandle": "Mg"
|
||
|
},
|
||
|
"type": "public-key",
|
||
|
"clientExtensionResults": {},
|
||
|
"authenticatorAttachment": "cross-platform",
|
||
|
}
|
||
|
},
|
||
|
},
|
||
|
'test-keepassxc': {
|
||
|
'user': self.user_demo,
|
||
|
'credential_identifier': 'y6aJVJsvvSSkbwTeGZ1FbQP_jCDho7EBPwZq-3lAjQ0',
|
||
|
'public_key': 'pQECAyYgASFYICjw-NoCHMkYYbRo8Q4SgJ4tZc8BSEmuEI0XmA6hUqR_IlggjtuBgyhwnr7PqABF2o8vCniMVa7_mTG6_l9Pc4eI4mo=',
|
||
|
'host': 'https://localhost:8888',
|
||
|
'supports_sign_count': False, # keepassxc doesn't support sign_count
|
||
|
'auth': {
|
||
|
'challenge': 'LNpV0dPIMtmpSwGenIH_h1VycQuAgFgQRJ9TPKBoNayScNAErS-rsnaU19n7_AaXzeiYRg3nGI3yuH0ai6UPXA',
|
||
|
'response': {
|
||
|
"id": "y6aJVJsvvSSkbwTeGZ1FbQP_jCDho7EBPwZq-3lAjQ0",
|
||
|
"rawId": "y6aJVJsvvSSkbwTeGZ1FbQP_jCDho7EBPwZq-3lAjQ0",
|
||
|
"response": {
|
||
|
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA",
|
||
|
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiJMTnBWMGRQSU10bXBTd0dlbklIX2gxVnljUXVBZ0ZnUVJKOVRQS0JvTmF5U2NOQUVyUy1yc25hVTE5bjdfQWFYemVpWVJnM25HSTN5dUgwYWk2VVBYQSIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3JpZ2luIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6ODg4OCIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ",
|
||
|
"signature": "MEYCIQCqkh2NBQQao5uDTaBKyNhiEpnk4jgbH-PjdLAul9-d0gIhAMObtNTbaEMUILdNgCT01BKNN4NHRzkzsGaDN2Ozu0WX",
|
||
|
"userHandle": "Ng"
|
||
|
},
|
||
|
"type": "public-key",
|
||
|
"clientExtensionResults": {},
|
||
|
"authenticatorAttachment": "platform",
|
||
|
}
|
||
|
},
|
||
|
},
|
||
|
'test-user-verification': {
|
||
|
'user': self.user_demo,
|
||
|
'credential_identifier': '723TCjL_RdQHFk3Ysp-HUymcWoazFi3ZdfZ1bIn6MYC5bAXvI6B-j8G-UA1taMO0',
|
||
|
'public_key': 'pQECAyYgASFYIO9t0woy_0XUBxZN2LKpzFmzmauPpdgt7B1EnoVXHL56IlggUJWIu-UCOAFOCAMUXDXb36pJ49aWNI9Z7njiLQt7amw=',
|
||
|
'host': 'http://localhost:8069',
|
||
|
'auth': {
|
||
|
'challenge': 'MTIzNDU',
|
||
|
'response': {
|
||
|
'id': '723TCjL_RdQHFk3Ysp-HUymcWoazFi3ZdfZ1bIn6MYC5bAXvI6B-j8G-UA1taMO0',
|
||
|
'rawId': '723TCjL_RdQHFk3Ysp-HUymcWoazFi3ZdfZ1bIn6MYC5bAXvI6B-j8G-UA1taMO0',
|
||
|
'response': {
|
||
|
'authenticatorData': 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAADg',
|
||
|
'clientDataJSON': 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTVRJek5EVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA2OSIsImNyb3NzT3JpZ2luIjpmYWxzZX0',
|
||
|
'signature': 'MEQCIFYAdM82D9otAfX2s6WY4CyH8i733Km-3TZSYcfwDmbqAiB6OXGuoaMgX13v6LWCIdkCRY9ZTYhNzhXFTs1Wp7-zkQ',
|
||
|
'userHandle': 'Mg'
|
||
|
},
|
||
|
'type': 'public-key',
|
||
|
'clientExtensionResults': {},
|
||
|
'authenticatorAttachment': 'cross-platform'
|
||
|
}
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
for key, values in self.passkeys.items():
|
||
|
self.cr.execute(SQL(
|
||
|
"""
|
||
|
INSERT INTO auth_passkey_key (name, credential_identifier, public_key, create_uid, write_date, create_date)
|
||
|
VALUES (%s, %s, %s, %s, NOW() AT TIME ZONE 'UTC', NOW() AT TIME ZONE 'UTC')
|
||
|
RETURNING id
|
||
|
""", key, values['credential_identifier'], values['public_key'], values['user'].id,
|
||
|
))
|
||
|
passkey_id = self.cr.fetchone()
|
||
|
values['passkey'] = self.env['auth.passkey.key'].browse(passkey_id)
|
||
|
|
||
|
def rpc(self, model, method, *args, **kwargs):
|
||
|
return self.url_open('/web/dataset/call_kw', headers={"Content-Type": "application/json"}, data=json.dumps({
|
||
|
"params": {
|
||
|
'model': model,
|
||
|
'method': method,
|
||
|
'args': args,
|
||
|
'kwargs': kwargs,
|
||
|
},
|
||
|
})).json()
|
||
|
|
||
|
@contextmanager
|
||
|
def patch_start_auth(self, challenge):
|
||
|
"""Force the webauthn challenge for unit tests testing the authentication"""
|
||
|
origin_start_auth = self.env.registry['auth.passkey.key']._start_auth
|
||
|
|
||
|
def _start_auth(self):
|
||
|
res = origin_start_auth(self)
|
||
|
res['challenge'] = request.session['webauthn_challenge'] = challenge
|
||
|
return res
|
||
|
|
||
|
with patch.object(self.env.registry['auth.passkey.key'], '_start_auth', _start_auth):
|
||
|
yield
|
||
|
|
||
|
@contextmanager
|
||
|
def patch_start_registration(self, challenge):
|
||
|
"""Force the webauthn challenge for unit tests testing the registration"""
|
||
|
origin_start_registration = self.env.registry['auth.passkey.key']._start_registration
|
||
|
|
||
|
def _start_registration(self):
|
||
|
res = origin_start_registration(self)
|
||
|
res['challenge'] = request.session['webauthn_challenge'] = challenge
|
||
|
return res
|
||
|
|
||
|
with patch.object(self.env.registry['auth.passkey.key'], '_start_registration', _start_registration):
|
||
|
yield
|
||
|
|
||
|
def test_registration(self):
|
||
|
passkey = self.passkeys['test-yubikey']
|
||
|
registration = passkey['registration']
|
||
|
webauthn_challenge, webauthn_response = registration['challenge'], registration['response']
|
||
|
self.env['ir.config_parameter'].sudo().set_param('web.base.url', passkey['host'])
|
||
|
with self.patch_start_registration(webauthn_challenge):
|
||
|
# Remove existing user passkeys so the check identity ask for a password authentication by default.
|
||
|
# To mimic the behavior when a user has no passkeys set yet.
|
||
|
self.admin_user.auth_passkey_key_ids.unlink()
|
||
|
|
||
|
# Authenticate the user, as the goal here is to create a new passkey for an already authenticated user.
|
||
|
self.authenticate(self.admin_user.login, self.admin_user.login)
|
||
|
|
||
|
# Click the "Add Passkey" button.
|
||
|
wizard_id = self.rpc('res.users', 'action_create_passkey', self.admin_user.id)['result']['res_id']
|
||
|
|
||
|
# Adding a passkey triggers an identity check. Confirm using the password and run the check.
|
||
|
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': self.admin_user.login})
|
||
|
action = self.rpc('res.users.identitycheck', 'run_check', wizard_id)['result']
|
||
|
|
||
|
# Create the passkey creation wizard and set a name for the key
|
||
|
wizard_id = self.rpc(action['res_model'], 'create', {'name': 'test-yubikey'})['result']
|
||
|
|
||
|
# Make the key with the webauthn response
|
||
|
response = self.rpc(action['res_model'], 'make_key', wizard_id, webauthn_response)
|
||
|
|
||
|
# Assert the passkey registration is successful
|
||
|
self.assertTrue(response.get('result'))
|
||
|
self.assertFalse(response.get('error'))
|
||
|
self.assertEqual(len(self.admin_user.auth_passkey_key_ids), 1)
|
||
|
self.assertEqual(self.admin_user.auth_passkey_key_ids.name, 'test-yubikey')
|
||
|
self.assertEqual(self.admin_user.auth_passkey_key_ids.sign_count, 0)
|
||
|
|
||
|
def test_authentication(self):
|
||
|
for key in ['test-yubikey', 'test-yubikey-nano', 'test-keepassxc']:
|
||
|
passkey = self.passkeys[key]
|
||
|
auth = passkey['auth']
|
||
|
webauthn_challenge, webauthn_response = auth['challenge'], auth['response']
|
||
|
self.env['ir.config_parameter'].sudo().set_param('web.base.url', passkey['host'])
|
||
|
sign_count = passkey['passkey'].sign_count
|
||
|
|
||
|
with self.patch_start_auth(webauthn_challenge):
|
||
|
# Mimic a user login process
|
||
|
|
||
|
# 1. Open the /web/login page, and get the csrf token which is use to protect the POST request
|
||
|
csrf_token = etree.fromstring(
|
||
|
self.url_open('/web/login').content
|
||
|
).xpath('//input[@name="csrf_token"]')[0].get('value')
|
||
|
|
||
|
# 2. Call the below route to start the webauthn authentication process
|
||
|
# which is done in the web interface when clicking on "Login with a passkey"
|
||
|
# It sets the webauthn challenge in the session
|
||
|
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
|
||
|
|
||
|
# 3. POST the login using the webauthn response
|
||
|
response = self.url_open('/web/login', data={
|
||
|
'type': 'webauthn',
|
||
|
'webauthn_response': json.dumps(webauthn_response),
|
||
|
'csrf_token': csrf_token,
|
||
|
'password': '', # Currently mandatory because of `if request.params['password'] != 'admin':`
|
||
|
})
|
||
|
|
||
|
# Assert the login is successful
|
||
|
self.assertEqual(response.status_code, 200)
|
||
|
self.assertTrue(response.headers.get('Set-Cookie'))
|
||
|
if passkey.get('supports_sign_count', True):
|
||
|
# If the passkey supports sign counts, the sign count increases
|
||
|
self.assertGreater(passkey['passkey'].sign_count, sign_count)
|
||
|
else:
|
||
|
# Otherwise it doesn't
|
||
|
self.assertEqual(passkey['passkey'].sign_count, sign_count)
|
||
|
|
||
|
# Replay attacks raises an error
|
||
|
csrf_token = etree.fromstring(
|
||
|
self.url_open('/web/login').content
|
||
|
).xpath('//input[@name="csrf_token"]')[0].get('value')
|
||
|
response = self.url_open('/web/login', data={
|
||
|
'type': 'webauthn',
|
||
|
'webauthn_response': json.dumps(webauthn_response),
|
||
|
'csrf_token': csrf_token,
|
||
|
})
|
||
|
self.assertEqual(response.status_code, 200)
|
||
|
error = etree.fromstring(response.content).xpath('//p[@class="alert alert-danger"]')[0].text.strip()
|
||
|
self.assertEqual(error, 'Cannot find a challenge for this session')
|
||
|
|
||
|
def test_check_identity(self):
|
||
|
for key in ['test-yubikey', 'test-yubikey-nano', 'test-keepassxc']:
|
||
|
passkey = self.passkeys[key]
|
||
|
user, auth = passkey['user'], passkey['auth']
|
||
|
webauthn_challenge, webauthn_response = auth['challenge'], auth['response']
|
||
|
self.env['ir.config_parameter'].sudo().set_param('web.base.url', passkey['host'])
|
||
|
sign_count = passkey['passkey'].sign_count
|
||
|
|
||
|
with self.patch_start_auth(webauthn_challenge):
|
||
|
# Authenticate the user, as the goal here is to assert the `check_identity`
|
||
|
self.authenticate(user.login, user.login)
|
||
|
|
||
|
# Call a method which triggers an identity check
|
||
|
wizard_id = self.rpc('res.users', 'preference_change_password', user.id)['result']['res_id']
|
||
|
|
||
|
# Mimic what the Javascript code is doing when clicking on the button "Use a passkey"
|
||
|
|
||
|
# 1. Call the below route to start the webauthn authentication process
|
||
|
# It sets the webauthn challenge in the session
|
||
|
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
|
||
|
|
||
|
# 2. Set the webauthn response in the password field
|
||
|
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': json.dumps(webauthn_response)})
|
||
|
|
||
|
# 3. Call the check method, which if successful returns the action to run following the identity check
|
||
|
response = self.rpc('res.users.identitycheck', 'run_check', wizard_id)
|
||
|
|
||
|
# Assert the identity check is successful
|
||
|
self.assertTrue(response.get('result'))
|
||
|
self.assertFalse(response.get('error'))
|
||
|
if passkey.get('supports_sign_count', True):
|
||
|
self.assertGreater(passkey['passkey'].sign_count, sign_count)
|
||
|
sign_count = passkey['passkey'].sign_count
|
||
|
else:
|
||
|
self.assertEqual(passkey['passkey'].sign_count, sign_count)
|
||
|
|
||
|
# 4. Attempt a replay attack, without reseting the challenge
|
||
|
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': json.dumps(webauthn_response)})
|
||
|
with mute_logger('odoo.http'):
|
||
|
response = self.rpc('res.users.identitycheck', 'run_check', wizard_id)
|
||
|
|
||
|
# Assert the authentication failed
|
||
|
self.assertFalse(response.get('result'))
|
||
|
self.assertTrue(response.get('error'))
|
||
|
self.assertEqual(response['error']['data']['name'], 'odoo.exceptions.UserError')
|
||
|
self.assertEqual(
|
||
|
response['error']['data']['message'],
|
||
|
'Incorrect Passkey. Please provide a valid passkey or use a different authentication method.'
|
||
|
)
|
||
|
# The authentication fail, hence the sign count doesn't increase
|
||
|
self.assertEqual(passkey['passkey'].sign_count, sign_count)
|
||
|
|
||
|
# 5. Do a second authentication with the same challenge and same response
|
||
|
|
||
|
# Reset the challenge, which is forced to the same challenge with a mock patch above
|
||
|
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
|
||
|
|
||
|
# Write the same webauthn response
|
||
|
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': json.dumps(webauthn_response)})
|
||
|
with mute_logger('odoo.http'):
|
||
|
response = self.rpc('res.users.identitycheck', 'run_check', wizard_id)
|
||
|
|
||
|
if passkey.get('supports_sign_count', True):
|
||
|
# If the passkey supports sign_count, a replay attack with the same challenge must fail
|
||
|
self.assertFalse(response.get('result'))
|
||
|
self.assertTrue(response.get('error'))
|
||
|
else:
|
||
|
# If the passkey doesn't support sign_count, such as keepassxc, then it should success
|
||
|
self.assertTrue(response.get('result'))
|
||
|
self.assertFalse(response.get('error'))
|
||
|
|
||
|
self.assertEqual(passkey['passkey'].sign_count, sign_count)
|
||
|
|
||
|
# 6. Do a third authentication, with another challenge but the same reponse
|
||
|
# This block is outside the block `with self.patch_start_auth(webauthn_challenge):`
|
||
|
# hence it will generate a random challenge.
|
||
|
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
|
||
|
self.rpc('res.users.identitycheck', 'write', wizard_id, {'password': json.dumps(webauthn_response)})
|
||
|
with mute_logger('odoo.http'):
|
||
|
response = self.rpc('res.users.identitycheck', 'run_check', wizard_id)
|
||
|
self.assertFalse(response.get('result'))
|
||
|
self.assertTrue(response.get('error'))
|
||
|
self.assertEqual(passkey['passkey'].sign_count, sign_count)
|
||
|
|
||
|
def test_check_user_verification(self):
|
||
|
"""Asserts authenticating without user verification (not entering the PIN code of the passkey) is prevented.
|
||
|
|
||
|
In addition to ask the browser to require the user verification
|
||
|
during the preparation of the webauthn authentication options,
|
||
|
the fact the user verification actually happened must be verified, server-side.
|
||
|
|
||
|
In the webauthn protocol, the fact the user verification happened
|
||
|
is stored by the browser in the `authenticatorData`,
|
||
|
in the 33rd byte "flags", in the 2nd bit "User Verified (UV)".
|
||
|
https://www.w3.org/TR/webauthn-1/#sec-authenticator-data
|
||
|
|
||
|
In the webauthn response provided in the setup class above, the `authenticatorData` provided
|
||
|
does not have the user verification flag.
|
||
|
This response should therefore not be allowed for authentication,
|
||
|
as we want to require the user to enter his PIN code (User Verification, UV)
|
||
|
in addition to touching the key (User Presence, UP) to act as a 2 factor authentication:
|
||
|
Something you have + Something you know.
|
||
|
|
||
|
Then, we replay the same authentication, with the same challenge,
|
||
|
but this time with an authenticator data with the user verified,
|
||
|
and an updated signature with this new authenticator data,
|
||
|
and then the authentication can be allowed.
|
||
|
"""
|
||
|
passkey = self.passkeys['test-user-verification']
|
||
|
webauthn_challenge, webauthn_response = passkey['auth']['challenge'], passkey['auth']['response']
|
||
|
self.env['ir.config_parameter'].sudo().set_param('web.base.url', passkey['host'])
|
||
|
|
||
|
with self.patch_start_auth(webauthn_challenge):
|
||
|
csrf_token = etree.fromstring(
|
||
|
self.url_open('/web/login').content
|
||
|
).xpath('//input[@name="csrf_token"]')[0].get('value')
|
||
|
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
|
||
|
response = self.url_open('/web/login', data={
|
||
|
'type': 'webauthn',
|
||
|
'webauthn_response': json.dumps(webauthn_response),
|
||
|
'csrf_token': csrf_token,
|
||
|
'password': '',
|
||
|
})
|
||
|
# Login unsuccessful, redirected back to /web/login
|
||
|
self.assertTrue(response.url.endswith('/web/login'))
|
||
|
# with the error message
|
||
|
error = etree.fromstring(response.content).xpath('//p[@class="alert alert-danger"]')[0].text.strip()
|
||
|
self.assertEqual(error, 'User verification is required but user was not verified during authentication')
|
||
|
|
||
|
# New authenticator data with the user verification bit turned on (+ counter increased)
|
||
|
# Previous authenticator data without user verified: SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAADg
|
||
|
# New authenticator data with user verified: SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAFQ
|
||
|
# Only the end changes, as:
|
||
|
# - An Authenticator Data is 37 bytes long
|
||
|
# - The user verified (UV) is on the 33rd byte
|
||
|
# - The counter is from the 34th byte to the 37th byte
|
||
|
# https://www.w3.org/TR/webauthn-1/#sec-authenticator-data
|
||
|
# To see the 33rd byte "flags" change:
|
||
|
# ```py
|
||
|
# import base64
|
||
|
# flags = base64.urlsafe_b64decode(authenticator_data + '==')[32:33]
|
||
|
# print(f'{flags[0]:08b}')
|
||
|
# ```
|
||
|
# Without the User Verified authenticator data, the above code prints `00000001`
|
||
|
# With the User Verified authenticator data, the above code prints `00000101`
|
||
|
# bit 0 is the least significant bit:
|
||
|
# - Bit 0: User Present (UP)
|
||
|
# - Bit 2: User Verified (UV)
|
||
|
# The counter is from byte 34 to 37. To get the counter:
|
||
|
# `int.from_bytes(base64.urlsafe_b64decode(authenticator_data + '==')[33:37])`
|
||
|
# In the case of the authenticator data without user verified, the counter is 14
|
||
|
# In the case of the authenticator data with user verified, the counter is 21
|
||
|
# The response with the invalid authenticator data, without the UV flag
|
||
|
# must be played before the response with the valid authenticator data,
|
||
|
# as its counter is lower. Otherwise you would have another error in addition to the missing UV flag:
|
||
|
# `Response sign count of 14 was not greater than current count of 21`
|
||
|
webauthn_response['response']['authenticatorData'] = 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAFQ'
|
||
|
# Signature changes as the authenticator data changed.
|
||
|
webauthn_response['response']['signature'] = 'MEQCIAdcWwNtQVrklYo70p5eHjVdSkA4Pgk6hbCCT6O8-V0BAiBBVKgroyNNOqN5xwO6Rr4yJV61J1TGWoOyUsoUftjypw'
|
||
|
|
||
|
csrf_token = etree.fromstring(
|
||
|
self.url_open('/web/login').content
|
||
|
).xpath('//input[@name="csrf_token"]')[0].get('value')
|
||
|
self.url_open('/auth/passkey/start-auth', '{}', headers={"Content-Type": "application/json"})
|
||
|
response = self.url_open('/web/login', data={
|
||
|
'type': 'webauthn',
|
||
|
'webauthn_response': json.dumps(webauthn_response),
|
||
|
'csrf_token': csrf_token,
|
||
|
'password': '',
|
||
|
})
|
||
|
|
||
|
# Login successful, redirected to /odoo
|
||
|
self.assertTrue(response.url.endswith('/odoo'))
|