1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439
|
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.tests.common import HttpCase
class PasskeyTest(HttpCase):
@classmethod
def setUpClass(self):
super().setUpClass()
self.admin_user = self.env.ref('base.user_admin')
self.demo_user = self.env.ref('base.user_demo')
# 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.demo_user,
'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.demo_user,
'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'))
|