From fbd837f014e349431572e4b4b53b9f3bb6acec6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Thu, 22 Sep 2022 16:47:26 +0200 Subject: [PATCH 01/14] Update webauthn dependency version. --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d45bbc4..0b8898f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,9 +28,9 @@ classifiers = [ [tool.poetry.dependencies] Django = ">= 2.2" -python = ">= 3.7, < 4.0" +python = ">= 3.8, < 4.0" qrcode = ">= 6.1, < 8.0" -webauthn = "^0.4" +webauthn = "^1.6.0" [tool.poetry.dev-dependencies] black = "^23.3" From d69b232932fdf087b5ff70522bc53bc225efa157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Thu, 22 Sep 2022 18:26:03 +0200 Subject: [PATCH 02/14] Port kagi to webauthn 1.6.0 --- kagi/settings.py | 1 - kagi/tests/test_util.py | 2 +- kagi/tests/test_webauthn.py | 192 +++++++++++++++++ kagi/tests/test_webauthn_keys.py | 339 +------------------------------ kagi/util.py | 47 ----- kagi/utils/__init__.py | 23 +++ kagi/utils/webauthn.py | 181 +++++++++++++++++ kagi/views/api.py | 155 +++++--------- pyproject.toml | 3 + testproj/testproj/settings.py | 1 - 10 files changed, 454 insertions(+), 490 deletions(-) create mode 100644 kagi/tests/test_webauthn.py delete mode 100644 kagi/util.py create mode 100644 kagi/utils/__init__.py create mode 100644 kagi/utils/webauthn.py diff --git a/kagi/settings.py b/kagi/settings.py index 0ca06f1..18096a5 100644 --- a/kagi/settings.py +++ b/kagi/settings.py @@ -7,7 +7,6 @@ RELYING_PARTY_ID = getattr(settings, "RELYING_PARTY_ID", "localhost") RELYING_PARTY_NAME = getattr(settings, "RELYING_PARTY_NAME", "Kagi Test Project") -WEBAUTHN_ICON_URL = getattr(settings, "WEBAUTHN_ICON_URL", None) WEBAUTHN_TRUSTED_CERTIFICATES = getattr( settings, "WEBAUTHN_TRUSTED_CERTIFICATES", diff --git a/kagi/tests/test_util.py b/kagi/tests/test_util.py index 25bbf12..b9bfcc6 100644 --- a/kagi/tests/test_util.py +++ b/kagi/tests/test_util.py @@ -1,4 +1,4 @@ -from ..util import get_origin +from ..utils import get_origin def test_get_origin(rf): diff --git a/kagi/tests/test_webauthn.py b/kagi/tests/test_webauthn.py new file mode 100644 index 0000000..5cc2dfe --- /dev/null +++ b/kagi/tests/test_webauthn.py @@ -0,0 +1,192 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pretend +import pytest +import webauthn as pywebauthn + +from webauthn.authentication.verify_authentication_response import ( + VerifiedAuthentication, +) +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url +from webauthn.helpers.structs import ( + AttestationFormat, + AuthenticationCredential, + AuthenticatorAssertionResponse, + AuthenticatorAttestationResponse, + PublicKeyCredentialType, + RegistrationCredential, +) +from webauthn.registration.verify_registration_response import VerifiedRegistration + +import kagi.utils.webauthn as webauthn + + +def test_generate_webauthn_challenge(): + challenge = webauthn.generate_webauthn_challenge() + + assert isinstance(challenge, bytes) + assert challenge == base64url_to_bytes(bytes_to_base64url(challenge)) + + +def test_verify_registration_response(monkeypatch): + fake_verified_registration = VerifiedRegistration( + credential_id=b"foo", + credential_public_key=b"bar", + sign_count=0, + aaguid="wutang", + fmt=AttestationFormat.NONE, + credential_type=PublicKeyCredentialType.PUBLIC_KEY, + user_verified=False, + attestation_object=b"foobar", + credential_device_type="single_device", + credential_backed_up=False, + ) + mock_verify_registration_response = pretend.call_recorder( + lambda *a, **kw: fake_verified_registration + ) + monkeypatch.setattr( + pywebauthn, "verify_registration_response", mock_verify_registration_response + ) + + resp = webauthn.verify_registration_response( + ( + '{"id": "foo", "rawId": "foo", "response": ' + '{"attestationObject": "foo", "clientDataJSON": "bar"}}' + ), + b"not_a_real_challenge", + rp_id="fake_rp_id", + origin="fake_origin", + ) + + assert mock_verify_registration_response.calls == [ + pretend.call( + credential=RegistrationCredential( + id="foo", + raw_id=b"~\x8a", + response=AuthenticatorAttestationResponse( + client_data_json=b"m\xaa", attestation_object=b"~\x8a" + ), + transports=None, + type=PublicKeyCredentialType.PUBLIC_KEY, + ), + expected_challenge=bytes_to_base64url(b"not_a_real_challenge").encode(), + expected_rp_id="fake_rp_id", + expected_origin="fake_origin", + require_user_verification=False, + ) + ] + assert resp == fake_verified_registration + + +def test_verify_registration_response_failure(monkeypatch): + monkeypatch.setattr( + pywebauthn, + "verify_registration_response", + pretend.raiser(pywebauthn.helpers.exceptions.InvalidRegistrationResponse), + ) + + with pytest.raises(webauthn.RegistrationRejectedError): + webauthn.verify_registration_response( + ( + '{"id": "foo", "rawId": "foo", "response": ' + '{"attestationObject": "foo", "clientDataJSON": "bar"}}' + ), + b"not_a_real_challenge", + rp_id="fake_rp_id", + origin="fake_origin", + ) + + +def test_verify_assertion_response(monkeypatch): + fake_verified_authentication = VerifiedAuthentication( + credential_id=b"a credential id", + new_sign_count=69, + credential_device_type="single_device", + credential_backed_up=False, + ) + mock_verify_authentication_response = pretend.call_recorder( + lambda *a, **kw: fake_verified_authentication + ) + monkeypatch.setattr( + pywebauthn, + "verify_authentication_response", + mock_verify_authentication_response, + ) + + not_a_real_user = pretend.stub( + webauthn_keys=[ + pretend.stub( + public_key=bytes_to_base64url(b"fake public key"), sign_count=68 + ) + ] + ) + resp = webauthn.verify_assertion_response( + ( + '{"id": "foo", "rawId": "foo", "response": ' + '{"authenticatorData": "foo", "clientDataJSON": "bar", ' + '"signature": "wutang"}}' + ), + challenge=b"not_a_real_challenge", + user=not_a_real_user, + origin="fake_origin", + rp_id="fake_rp_id", + ) + + assert mock_verify_authentication_response.calls == [ + pretend.call( + credential=AuthenticationCredential( + id="foo", + raw_id=b"~\x8a", + response=AuthenticatorAssertionResponse( + client_data_json=b"m\xaa", + authenticator_data=b"~\x8a", + signature=b"\xc2\xebZ\x9e", + user_handle=None, + ), + type=PublicKeyCredentialType.PUBLIC_KEY, + ), + expected_challenge=b"bm90X2FfcmVhbF9jaGFsbGVuZ2U", + expected_rp_id="fake_rp_id", + expected_origin="fake_origin", + credential_public_key=b"fake public key", + credential_current_sign_count=68, + require_user_verification=False, + ) + ] + assert resp == fake_verified_authentication + + +def test_verify_assertion_response_failure(monkeypatch): + monkeypatch.setattr( + pywebauthn, + "verify_authentication_response", + pretend.raiser(pywebauthn.helpers.exceptions.InvalidAuthenticationResponse), + ) + + get_webauthn_users = pretend.call_recorder( + lambda *a, **kw: [(b"not a public key", 0)] + ) + monkeypatch.setattr(webauthn, "_get_webauthn_user_public_keys", get_webauthn_users) + + with pytest.raises(webauthn.AuthenticationRejectedError): + webauthn.verify_assertion_response( + ( + '{"id": "foo", "rawId": "foo", "response": ' + '{"authenticatorData": "foo", "clientDataJSON": "bar", ' + '"signature": "wutang"}}' + ), + challenge=b"not_a_real_challenge", + user=pretend.stub(), + origin="fake_origin", + rp_id="fake_rp_id", + ) diff --git a/kagi/tests/test_webauthn_keys.py b/kagi/tests/test_webauthn_keys.py index fdb67a7..fae8a96 100644 --- a/kagi/tests/test_webauthn_keys.py +++ b/kagi/tests/test_webauthn_keys.py @@ -1,9 +1,13 @@ +import json from unittest import mock from django.contrib.auth.models import User from django.urls import reverse import pytest +from webauthn.helpers import bytes_to_base64url +from webauthn.helpers.structs import AttestationFormat, PublicKeyCredentialType +from webauthn.registration.verify_registration_response import VerifiedRegistration from .. import settings from ..forms import KeyRegistrationForm @@ -41,338 +45,3 @@ def test_totp_device_deletion_works(admin_client): assert response.status_code == 302 assert response.url == reverse("kagi:webauthn-keys") assert WebAuthnKey.objects.count() == 0 - - -# Testing view begin activate -def test_begin_activate_return_user_credential_options(admin_client): - ukey = "Q3sM6zbLYAssRO7g5BM7" - with mock.patch("kagi.views.api.util.generate_ukey", return_value=ukey): - response = admin_client.post( - reverse("kagi:begin-activate"), {"key_name": "SoloKey"} - ) - assert response.status_code == 200 - credential_options = response.json() - assert "challenge" in credential_options - assert credential_options["rp"] == { - "name": settings.RELYING_PARTY_NAME, - "id": settings.RELYING_PARTY_ID, - } - assert credential_options["user"] == { - "id": ukey, - "name": "admin", - "displayName": "", - "icon": settings.WEBAUTHN_ICON_URL, - } - - assert "pubKeyCredParams" in credential_options - assert credential_options["extensions"] == {"webauthn.loc": True} - - -def test_begin_activate_fails_if_key_name_is_missing(admin_client): - response = admin_client.post(reverse("kagi:begin-activate"), {"key_name": ""}) - assert response.status_code == 400 - assert response.json() == {"errors": {"key_name": ["This field is required."]}} - - -# Testing view verify credential info -def test_webauthn_verify_credential_info(admin_client): - # Setup the session - response = admin_client.post( - reverse("kagi:begin-activate"), {"key_name": "SoloKey"} - ) - credential_options = response.json() - challenge = credential_options["challenge"] - - trusted_attestation_cert_required = ( - settings.WEBAUTHN_TRUSTED_ATTESTATION_CERT_REQUIRED - ) - self_attestation_permitted = settings.WEBAUTHN_SELF_ATTESTATION_PERMITTED - none_attestation_permitted = settings.WEBAUTHN_NONE_ATTESTATION_PERMITTED - - with mock.patch("kagi.views.api.webauthn") as mocked_webauthn: - webauthn_registration_response = ( - mocked_webauthn.WebAuthnRegistrationResponse.return_value - ) - verify = webauthn_registration_response.verify.return_value - verify.public_key.decode.return_value = "public-key" - verify.credential_id.decode.return_value = "credential-id" - verify.sign_count = 0 - - response = admin_client.post( - reverse("kagi:verify-credential-info"), {"registration": "payload"} - ) - mocked_webauthn.WebAuthnRegistrationResponse.assert_called_with( - settings.RELYING_PARTY_ID, - "http://testserver", - {"registration": ["payload"]}, - challenge, - settings.WEBAUTHN_TRUSTED_CERTIFICATES, - trusted_attestation_cert_required, - self_attestation_permitted, - none_attestation_permitted, - uv_required=False, # User validation - ) - - webauthn_registration_response.verify.assert_called_once() - - assert response.status_code == 200 - assert response.json() == {"success": "User successfully registered."} - - -def test_webauthn_verify_credential_info_fails_if_registration_is_invalid(admin_client): - # Setup the session - response = admin_client.post( - reverse("kagi:begin-activate"), {"key_name": "SoloKey"} - ) - - with mock.patch("kagi.views.api.webauthn") as mocked_webauthn: - webauthn_registration_response = ( - mocked_webauthn.WebAuthnRegistrationResponse.return_value - ) - verify = webauthn_registration_response.verify - verify.side_effect = ValueError("An error occurred") - - response = admin_client.post( - reverse("kagi:verify-credential-info"), {"registration": "payload"} - ) - - assert response.status_code == 400 - assert response.json() == {"fail": "Registration failed. Error: An error occurred"} - - -def test_webauthn_verify_credential_info_fails_if_credential_id_already_exists( - admin_client, -): - # Setup the session - response = admin_client.post( - reverse("kagi:begin-activate"), {"key_name": "SoloKey"} - ) - - # Create the WebAuthnKey - user = User.objects.get(pk=1) - user.webauthn_keys.create( - key_name="SoloKey", sign_count=0, credential_id="credential-id" - ) - - with mock.patch("kagi.views.api.webauthn") as mocked_webauthn: - webauthn_registration_response = ( - mocked_webauthn.WebAuthnRegistrationResponse.return_value - ) - verify = webauthn_registration_response.verify.return_value - verify.credential_id.decode.return_value = "credential-id" - - response = admin_client.post( - reverse("kagi:verify-credential-info"), {"registration": "payload"} - ) - - assert response.status_code == 400 - assert response.json() == {"fail": "Credential ID already exists."} - - -# Testing view begin assertion -@pytest.mark.django_db -def test_begin_assertion_return_user_credential_options(client): - # We need to create a couple of WebAuthnKey for our user. - user = User.objects.create_user("admin", "john.doe@kagi.com", "admin") - user.webauthn_keys.create( - key_name="SoloKey 1", - sign_count=0, - credential_id="credential-id-1", - ukey="abcd", - public_key="pubkey1", - ) - user.webauthn_keys.create( - key_name="SoloKey 2", - sign_count=0, - credential_id="credential-id-2", - ukey="efgh", - public_key="pubkey2", - ) - - ukey = "Q3sM6zbLYAssRO7g5BM7" - challenge = "k31d65xGDFb0VUq4MEMXmWpuWkzPs889" - - with mock.patch("kagi.views.api.util.generate_ukey", return_value=ukey): - with mock.patch( - "kagi.views.api.util.generate_challenge", return_value=challenge - ): - # We authenticate with username/password - response = client.post( - reverse("kagi:login"), {"username": "admin", "password": "admin"} - ) - assert response.status_code == 302 - assert response.url == reverse("kagi:verify-second-factor") - - with mock.patch("kagi.views.api.webauthn") as mocked_webauthn: - assertion_dict = { - "challenge": "tOOk7MPjGWlezrP6o6tGOXSH0ZesUREO", - "allowCredentials": [ - { - "type": "public-key", - "id": "ePqP9Mi...512GSYg", - "transports": ["usb", "nfc", "ble", "internal"], - }, - { - "type": "public-key", - "id": "qhibXokRKbPA...O1WW7nF", - "transports": ["usb", "nfc", "ble", "internal"], - }, - ], - "rpId": "localhost", - "timeout": 60000, - } - mocked_webauthn.WebAuthnAssertionOptions.return_value.assertion_dict = ( - assertion_dict - ) - response = client.post(reverse("kagi:begin-assertion")) - - assert response.status_code == 200 - assert response.json() == assertion_dict - - -# Testing view verify assertion -@pytest.mark.django_db -def test_verify_assertion_validates_the_user_webauthn_key(client): - # We need to create a couple of WebAuthnKey for our user. - user = User.objects.create_user("admin", "john.doe@kagi.com", "admin") - user.webauthn_keys.create( - key_name="SoloKey", - sign_count=0, - credential_id="credential-id", - ukey="abcd", - public_key="pubkey", - ) - response = client.post( - reverse("kagi:login"), {"username": "admin", "password": "admin"} - ) - assert response.status_code == 302 - assert response.url == reverse("kagi:verify-second-factor") - - # We authenticate with username/password - challenge = "k31d65xGDFb0VUq4MEMXmWpuWkzPs889" - - with mock.patch("kagi.views.api.util.generate_challenge", return_value=challenge): - response = client.post(reverse("kagi:begin-assertion")) - - with mock.patch("kagi.views.api.webauthn") as mocked_webauthn: - webauthn_assertion_response = ( - mocked_webauthn.WebAuthnAssertionResponse.return_value - ) - verify = webauthn_assertion_response.verify - verify.return_value = 1 - - response = client.post( - reverse("kagi:verify-assertion"), - {"id": "credential-id", "assertion": "payload"}, - ) - mocked_webauthn.WebAuthnUser.assert_called_with( - "abcd", - "admin", - "", - settings.WEBAUTHN_ICON_URL, - "credential-id", - "pubkey", - 0, - settings.RELYING_PARTY_ID, - ) - - webauthn_user = mocked_webauthn.WebAuthnUser.return_value - webauthn_assertion_response = mocked_webauthn.WebAuthnAssertionResponse - webauthn_assertion_response.assert_called_with( - webauthn_user, - {"id": ["credential-id"], "assertion": ["payload"]}, - challenge, - "http://testserver", - uv_required=False, - ) - - assert response.status_code == 200 - assert response.json() == { - "success": "Successfully authenticated as admin", - "redirect_to": reverse("kagi:two-factor-settings"), - } - - # Are we truly logged in? - response = client.get(reverse("kagi:two-factor-settings")) - assert response.status_code == 200 - - -# Testing view verify assertion -@pytest.mark.django_db -def test_verify_assertion_fails_if_missing_user_webauthn_key(client): - # We need to create a couple of WebAuthnKey for our user. - user = User.objects.create_user("admin", "john.doe@kagi.com", "admin") - user.webauthn_keys.create( - key_name="SoloKey", - sign_count=0, - credential_id="wrong-id", - ukey="abcd", - public_key="pubkey", - ) - response = client.post( - reverse("kagi:login"), {"username": "admin", "password": "admin"} - ) - assert response.status_code == 302 - assert response.url == reverse("kagi:verify-second-factor") - - # We authenticate with username/password - challenge = "k31d65xGDFb0VUq4MEMXmWpuWkzPs889" - - with mock.patch("kagi.views.api.util.generate_challenge", return_value=challenge): - response = client.post(reverse("kagi:begin-assertion")) - - with mock.patch("kagi.views.api.webauthn") as mocked_webauthn: - webauthn_assertion_response = ( - mocked_webauthn.WebAuthnAssertionResponse.return_value - ) - verify = webauthn_assertion_response.verify - verify.return_value = 1 - - response = client.post( - reverse("kagi:verify-assertion"), - {"id": "credential-id", "assertion": "payload"}, - ) - assert response.status_code == 400 - assert response.json() == {"fail": "Key does not exist."} - - -@pytest.mark.django_db -def test_verify_assertion_validates_the_assertion(client): - # We need to create a couple of WebAuthnKey for our user. - user = User.objects.create_user("admin", "john.doe@kagi.com", "admin") - user.webauthn_keys.create( - key_name="SoloKey", - sign_count=0, - credential_id="credential-id", - ukey="abcd", - public_key="pubkey", - ) - response = client.post( - reverse("kagi:login"), {"username": "admin", "password": "admin"} - ) - assert response.status_code == 302 - assert response.url == reverse("kagi:verify-second-factor") - - # We authenticate with username/password - challenge = "k31d65xGDFb0VUq4MEMXmWpuWkzPs889" - - response = client.get(reverse("kagi:verify-second-factor")) - assert response.status_code == 200 - - with mock.patch("kagi.views.api.util.generate_challenge", return_value=challenge): - response = client.post(reverse("kagi:begin-assertion")) - - with mock.patch("kagi.views.api.webauthn") as mocked_webauthn: - webauthn_assertion_response = ( - mocked_webauthn.WebAuthnAssertionResponse.return_value - ) - verify = webauthn_assertion_response.verify - verify.side_effect = ValueError("An error occurred") - - response = client.post( - reverse("kagi:verify-assertion"), - {"id": "credential-id", "assertion": "payload"}, - ) - - assert response.status_code == 400 - assert response.json() == {"fail": "Assertion failed. Error: An error occurred"} diff --git a/kagi/util.py b/kagi/util.py deleted file mode 100644 index b9da18d..0000000 --- a/kagi/util.py +++ /dev/null @@ -1,47 +0,0 @@ -import random -import string - -from django.conf import settings -from django.contrib.auth import load_backend - - -def generate_challenge(challenge_len): - return "".join( - [ - random.SystemRandom().choice(string.ascii_letters + string.digits) - for i in range(challenge_len) - ] - ) - - -def generate_ukey(): - """Its value's ID member is required, and contains an identifier - for the account, specified by the Relying Party. This is not meant - to be displayed to the user, but is used by the Relying Party to - control the number of credentials -- an authenticator will never - contain more than one credential for a given Relying Party under - the same ID. - - A unique identifier for the entity. For a relying party entity, - sets the RP ID. For a user account entity, this will be an - arbitrary string specified by the relying party. - """ - return generate_challenge(20) - - -def get_origin(request): - return f"{request.scheme}://{request.get_host()}" - - -def get_user(request): - try: - user_id = request.session["kagi_pre_verify_user_pk"] - backend_path = request.session["kagi_pre_verify_user_backend"] - assert backend_path in settings.AUTHENTICATION_BACKENDS - backend = load_backend(backend_path) - user = backend.get_user(user_id) - if user is not None: - user.backend = backend_path - return user - except (KeyError, AssertionError): # pragma: no cover - return None diff --git a/kagi/utils/__init__.py b/kagi/utils/__init__.py new file mode 100644 index 0000000..41c5c32 --- /dev/null +++ b/kagi/utils/__init__.py @@ -0,0 +1,23 @@ +import random +import string + +from django.conf import settings +from django.contrib.auth import load_backend + + +def get_origin(request): + return f"{request.scheme}://{request.get_host()}" + + +def get_user(request): + try: + user_id = request.session["kagi_pre_verify_user_pk"] + backend_path = request.session["kagi_pre_verify_user_backend"] + assert backend_path in settings.AUTHENTICATION_BACKENDS + backend = load_backend(backend_path) + user = backend.get_user(user_id) + if user is not None: + user.backend = backend_path + return user + except (KeyError, AssertionError): # pragma: no cover + return None diff --git a/kagi/utils/webauthn.py b/kagi/utils/webauthn.py new file mode 100644 index 0000000..7f3dda5 --- /dev/null +++ b/kagi/utils/webauthn.py @@ -0,0 +1,181 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import json + +import webauthn as pywebauthn + +from webauthn.helpers import base64url_to_bytes, generate_challenge +from webauthn.helpers.exceptions import ( + InvalidAuthenticationResponse, + InvalidRegistrationResponse, +) +from webauthn.helpers.options_to_json import options_to_json +from webauthn.helpers.structs import ( + AttestationConveyancePreference, + AuthenticationCredential, + AuthenticatorSelectionCriteria, + AuthenticatorTransport, + PublicKeyCredentialDescriptor, + RegistrationCredential, + UserVerificationRequirement, +) + + +class AuthenticationRejectedError(Exception): + pass + + +class RegistrationRejectedError(Exception): + pass + + +def _get_webauthn_user_public_key_credential_descriptors(user, *, rp_id): + """ + Returns a webauthn.WebAuthnUser instance corresponding + to the given user model, with properties suitable for + usage within the webauthn API. + """ + return [ + PublicKeyCredentialDescriptor( + id=base64url_to_bytes(credential.credential_id), + transports=[ + AuthenticatorTransport.USB, + AuthenticatorTransport.NFC, + AuthenticatorTransport.BLE, + AuthenticatorTransport.INTERNAL, + ], + ) + for credential in user.webauthn_keys + ] + + +def _get_webauthn_user_public_keys(user, *, rp_id): + return [ + ( + base64url_to_bytes(credential.public_key), + credential.sign_count, + ) + for credential in user.webauthn_keys + ] + + +def _webauthn_b64encode(source): + return base64.urlsafe_b64encode(source).rstrip(b"=") + + +def generate_webauthn_challenge(): + """ + Returns a random challenge suitable for use within + Webauthn's credential and configuration option objects. + + See: https://w3c.github.io/webauthn/#cryptographic-challenges + """ + return generate_challenge() + + +def get_credential_options(user, *, challenge, rp_name, rp_id): + """ + Returns a dictionary of options for credential creation + on the client side. + """ + _authenticator_selection = AuthenticatorSelectionCriteria() + _authenticator_selection.user_verification = UserVerificationRequirement.DISCOURAGED + options = pywebauthn.generate_registration_options( + rp_id=rp_id, + rp_name=rp_name, + user_id=str(user.id), + user_name=user.get_username(), + user_display_name=user.get_full_name(), + challenge=challenge, + attestation=AttestationConveyancePreference.NONE, + authenticator_selection=_authenticator_selection, + ) + return json.loads(options_to_json(options)) + + +def get_assertion_options(user, *, challenge, rp_id): + """ + Returns a dictionary of options for assertion retrieval + on the client side. + """ + options = pywebauthn.generate_authentication_options( + rp_id=rp_id, + challenge=challenge, + allow_credentials=_get_webauthn_user_public_key_credential_descriptors( + user, rp_id=rp_id + ), + user_verification=UserVerificationRequirement.DISCOURAGED, + ) + return json.loads(options_to_json(options)) + + +def verify_registration_response(response, challenge, *, rp_id, origin): + """ + Validates the challenge and attestation information + sent from the client during device registration. + + Returns a WebAuthnCredential on success. + Raises RegistrationRejectedError on failire. + """ + # NOTE: We re-encode the challenge below, because our + # response's clientData.challenge is encoded twice: + # first for the entire clientData payload, and then again + # for the individual challenge. + encoded_challenge = _webauthn_b64encode(challenge) + try: + _credential = RegistrationCredential.parse_raw(response) + return pywebauthn.verify_registration_response( + credential=_credential, + expected_challenge=encoded_challenge, + expected_rp_id=rp_id, + expected_origin=origin, + require_user_verification=False, + ) + except InvalidRegistrationResponse as e: + raise RegistrationRejectedError(str(e)) + + +def verify_assertion_response(assertion, *, challenge, user, origin, rp_id): + """ + Validates the challenge and assertion information + sent from the client during authentication. + + Returns an updated signage count on success. + Raises AuthenticationRejectedError on failure. + """ + # NOTE: We re-encode the challenge below, because our + # response's clientData.challenge is encoded twice: + # first for the entire clientData payload, and then again + # for the individual challenge. + encoded_challenge = _webauthn_b64encode(challenge) + webauthn_user_public_keys = _get_webauthn_user_public_keys(user, rp_id=rp_id) + + for public_key, current_sign_count in webauthn_user_public_keys: + try: + _credential = AuthenticationCredential.parse_raw(assertion) + return pywebauthn.verify_authentication_response( + credential=_credential, + expected_challenge=encoded_challenge, + expected_rp_id=rp_id, + expected_origin=origin, + credential_public_key=public_key, + credential_current_sign_count=current_sign_count, + require_user_verification=False, + ) + except InvalidAuthenticationResponse: + pass + + # If we exit the loop, then we've failed to verify the assertion against + # any of the user's WebAuthn credentials. Fail. + raise AuthenticationRejectedError("Invalid WebAuthn credential") diff --git a/kagi/views/api.py b/kagi/views/api.py index e61505e..3238997 100644 --- a/kagi/views/api.py +++ b/kagi/views/api.py @@ -8,9 +8,10 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -import webauthn - -from .. import settings, util +from webauthn.helpers import bytes_to_base64url, base64url_to_bytes +import json +from .. import settings, utils +from ..utils import webauthn from ..forms import KeyRegistrationForm from ..models import WebAuthnKey @@ -25,59 +26,38 @@ def webauthn_begin_activate(request): if not form.is_valid(): return JsonResponse({"errors": form.errors}, status=400) - username = request.user.get_username() - display_name = request.user.get_full_name() - - challenge = util.generate_challenge(32) - ukey = util.generate_ukey() + challenge = webauthn.generate_webauthn_challenge() request.session["key_name"] = form.cleaned_data["key_name"] - request.session["challenge"] = challenge - request.session["register_ukey"] = ukey - - make_credential_options = webauthn.WebAuthnMakeCredentialOptions( - challenge, - settings.RELYING_PARTY_NAME, - settings.RELYING_PARTY_ID, - ukey, - username, - display_name, - settings.WEBAUTHN_ICON_URL, + request.session["challenge"] = bytes_to_base64url(challenge) + request.session["register_ukey"] = str(request.user.id) + + credential_options = webauthn.get_credential_options( + request.user, + challenge=challenge, + rp_name=settings.RELYING_PARTY_NAME, + rp_id=settings.RELYING_PARTY_ID, ) - return JsonResponse(make_credential_options.registration_dict) + return JsonResponse(credential_options) @login_required @csrf_exempt @require_http_methods(["POST"]) def webauthn_verify_credential_info(request): - challenge = request.session["challenge"] + challenge = base64url_to_bytes(request.session["challenge"]) ukey = request.session["register_ukey"] - - registration_response = request.POST - trust_anchor_dir = settings.WEBAUTHN_TRUSTED_CERTIFICATES - trusted_attestation_cert_required = ( - settings.WEBAUTHN_TRUSTED_ATTESTATION_CERT_REQUIRED - ) - self_attestation_permitted = settings.WEBAUTHN_SELF_ATTESTATION_PERMITTED - none_attestation_permitted = settings.WEBAUTHN_NONE_ATTESTATION_PERMITTED - - webauthn_registration_response = webauthn.WebAuthnRegistrationResponse( - settings.RELYING_PARTY_ID, - util.get_origin(request), - registration_response, - challenge, - trust_anchor_dir, - trusted_attestation_cert_required, - self_attestation_permitted, - none_attestation_permitted, - uv_required=False, # User validation - ) + credentials = request.POST["credentials"] try: - webauthn_credential = webauthn_registration_response.verify() - except Exception as e: + webauthn_registration_response = webauthn.verify_registration_response( + credentials, + rp_id=settings.RELYING_PARTY_ID, + origin=utils.get_origin(request), + challenge=challenge, + ) + except webauthn.RegistrationRejectedError as e: return JsonResponse({"fail": f"Registration failed. Error: {e}"}, status=400) # W3C spec. Step 17. @@ -97,8 +77,8 @@ def webauthn_verify_credential_info(request): user=request.user, key_name=request.session.get("key_name", ""), ukey=ukey, - public_key=webauthn_credential.public_key.decode("utf-8"), - credential_id=webauthn_credential.credential_id.decode("utf-8"), + public_key=bytes_to_base64url(webauthn_credential.public_key), + credential_id=bytes_to_base64url(webauthn_credential.credential_id), sign_count=webauthn_credential.sign_count, ) @@ -115,80 +95,45 @@ def webauthn_verify_credential_info(request): # Login @require_http_methods(["POST"]) def webauthn_begin_assertion(request): - challenge = util.generate_challenge(32) - request.session["challenge"] = challenge - - user = util.get_user(request) - - username = user.get_username() - display_name = user.get_full_name() - - keys = WebAuthnKey.objects.filter(user=user) - - webauthn_users = [] - for key in keys: - webauthn_users.append( - webauthn.WebAuthnUser( - key.ukey, - username, - display_name, - settings.WEBAUTHN_ICON_URL, - key.credential_id, - key.public_key, - key.sign_count, - settings.RELYING_PARTY_ID, - ) - ) + challenge = webauthn.generate_webauthn_challenge() + request.session["challenge"] = bytes_to_base64url(challenge) - webauthn_assertion_options = webauthn.WebAuthnAssertionOptions( - webauthn_users, challenge + user = utils.get_user(request) + + webauthn_assertion_options = webauthn.get_assertion_options( + user, challenge=challenge, rp_id=settings.RELYING_PARTY_ID ) - return JsonResponse(webauthn_assertion_options.assertion_dict) + return JsonResponse(webauthn_assertion_options) @csrf_exempt @require_http_methods(["POST"]) def webauthn_verify_assertion(request): - challenge = request.session.get("challenge") - assertion_response = request.POST - credential_id = assertion_response.get("id") - - user = util.get_user(request) - - username = user.get_username() - display_name = user.get_full_name() - - key = WebAuthnKey.objects.filter(credential_id=credential_id, user=user).first() - if not key: - return JsonResponse({"fail": "Key does not exist."}, status=400) - - webauthn_user = webauthn.WebAuthnUser( - key.ukey, - username, - display_name, - settings.WEBAUTHN_ICON_URL, - key.credential_id, - key.public_key, - key.sign_count, - settings.RELYING_PARTY_ID, - ) + challenge = base64url_to_bytes(request.session.get("challenge")) - webauthn_assertion_response = webauthn.WebAuthnAssertionResponse( - webauthn_user, - assertion_response, - challenge, - util.get_origin(request), - uv_required=False, # User Verification - ) + try: + credentials = json.loads(request.POST["credentials"]) + except json.JSONDecodeError: + return JsonResponse( + {"fail": "Invalid WebAuthn assertion: Bad payload"}, status=400 + ) + + user = utils.get_user(request) try: - sign_count = webauthn_assertion_response.verify() - except Exception as e: + webauthn_assertion_response = webauthn.verify_webauthn_assertion( + assertion, + challenge=challenge, + user=user, + origin=utils.get_origin(request), + rp_id=settings.RELYING_PARTY_ID, + ) + except webauthn.AuthenticationRejectedError as e: return JsonResponse({"fail": f"Assertion failed. Error: {e}"}, status=400) # Update counter. - key.sign_count = sign_count + key.sign_count = webauthn_assertion_response.new_sign_count key.last_used = now() key.save() diff --git a/pyproject.toml b/pyproject.toml index 0b8898f..ff8d587 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,9 @@ pytest-xdist = "^2.1" sphinx = "^4.0" Werkzeug = "^2.0" +[tool.poetry.group.dev.dependencies] +pretend = "^1.0.9" + [tool.autopub] project-name = "Kagi" git-username = "botpub" diff --git a/testproj/testproj/settings.py b/testproj/testproj/settings.py index 3152b9a..3af3fe7 100644 --- a/testproj/testproj/settings.py +++ b/testproj/testproj/settings.py @@ -124,7 +124,6 @@ RELYING_PARTY_ID = "localhost" RELYING_PARTY_NAME = "Kagi Test Project" -WEBAUTHN_ICON_URL = "https://via.placeholder.com/150" # WEBAUTHN_TRUSTED_CERTIFICATES = os.path.join( # BASE_DIR, "..", "trusted_attestation_roots" From 2c3e96a3ab74bfa2831b533d9a095def2e065e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Thu, 22 Sep 2022 18:50:45 +0200 Subject: [PATCH 03/14] Add registration webauthn endpoints tests. --- kagi/tests/test_webauthn_keys.py | 113 +++++++++++++++++++++++++++++++ kagi/views/api.py | 8 +-- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/kagi/tests/test_webauthn_keys.py b/kagi/tests/test_webauthn_keys.py index fae8a96..f33c4db 100644 --- a/kagi/tests/test_webauthn_keys.py +++ b/kagi/tests/test_webauthn_keys.py @@ -5,6 +5,7 @@ from django.urls import reverse import pytest +from kagi.utils import webauthn from webauthn.helpers import bytes_to_base64url from webauthn.helpers.structs import AttestationFormat, PublicKeyCredentialType from webauthn.registration.verify_registration_response import VerifiedRegistration @@ -45,3 +46,115 @@ def test_totp_device_deletion_works(admin_client): assert response.status_code == 302 assert response.url == reverse("kagi:webauthn-keys") assert WebAuthnKey.objects.count() == 0 + + +# Testing view begin activate +def test_begin_activate_return_user_credential_options(admin_client): + response = admin_client.post( + reverse("kagi:begin-activate"), {"key_name": "SoloKey"} + ) + + assert response.status_code == 200 + credential_options = response.json() + assert "challenge" in credential_options + assert credential_options["rp"] == { + "name": settings.RELYING_PARTY_NAME, + "id": settings.RELYING_PARTY_ID, + } + assert credential_options["user"] == { + "id": bytes_to_base64url(b"1"), + "name": "admin", + "displayName": "admin", + } + + assert "pubKeyCredParams" in credential_options + + +def test_begin_activate_fails_if_key_name_is_missing(admin_client): + response = admin_client.post(reverse("kagi:begin-activate"), {"key_name": ""}) + assert response.status_code == 400 + assert response.json() == {"errors": {"key_name": ["This field is required."]}} + + +# Testing view verify credential info +def test_webauthn_verify_credential_info(admin_client): + # Setup the session + response = admin_client.post( + reverse("kagi:begin-activate"), {"key_name": "SoloKey"} + ) + credential_options = response.json() + challenge = credential_options["challenge"] + + fake_validated_credential = VerifiedRegistration( + credential_id=b"foo", + credential_public_key=b"bar", + sign_count=0, + aaguid="wutang", + fmt=AttestationFormat.NONE, + credential_type=PublicKeyCredentialType.PUBLIC_KEY, + user_verified=False, + attestation_object=b"foobar", + credential_device_type="single_device", + credential_backed_up=False, + ) + with mock.patch("kagi.views.api.webauthn.verify_registration_response", return_value = fake_validated_credential) as mocked_verify_registration_response: + response = admin_client.post( + reverse("kagi:verify-credential-info"), {"credentials": "fake_payload"} + ) + + assert mocked_verify_registration_response.called_once + + assert response.status_code == 200 + assert response.json() == {"success": "User successfully registered."} + + +def test_webauthn_verify_credential_info_fails_if_registration_is_invalid(admin_client): + # Setup the session + response = admin_client.post( + reverse("kagi:begin-activate"), {"key_name": "SoloKey"} + ) + + with mock.patch("kagi.views.api.webauthn.verify_registration_response") as mocked_verify_registration_response: + mocked_verify_registration_response.side_effect = webauthn.RegistrationRejectedError("An error occurred") + + response = admin_client.post( + reverse("kagi:verify-credential-info"), {"credentials": "payload"} + ) + + assert response.status_code == 400 + assert response.json() == {"fail": "Registration failed. Error: An error occurred"} + + +def test_webauthn_verify_credential_info_fails_if_credential_id_already_exists( + admin_client, +): + # Setup the session + response = admin_client.post( + reverse("kagi:begin-activate"), {"key_name": "SoloKey"} + ) + + # Create the WebAuthnKey + user = User.objects.get(pk=1) + user.webauthn_keys.create( + key_name="SoloKey", sign_count=0, credential_id=bytes_to_base64url(b"foo") + ) + + fake_validated_credential = VerifiedRegistration( + credential_id=b"foo", + credential_public_key=b"bar", + sign_count=0, + aaguid="wutang", + fmt=AttestationFormat.NONE, + credential_type=PublicKeyCredentialType.PUBLIC_KEY, + user_verified=False, + attestation_object=b"foobar", + credential_device_type="single_device", + credential_backed_up=False, + ) + with mock.patch("kagi.views.api.webauthn.verify_registration_response", return_value = fake_validated_credential) as mocked_verify_registration_response: + response = admin_client.post( + reverse("kagi:verify-credential-info"), {"credentials": "fake_payload"} + ) + + assert response.status_code == 400 + assert response.json() == {"fail": "Credential ID already exists."} diff --git a/kagi/views/api.py b/kagi/views/api.py index 3238997..37b7298 100644 --- a/kagi/views/api.py +++ b/kagi/views/api.py @@ -68,7 +68,7 @@ def webauthn_verify_credential_info(request): # ceremony, or it MAY decide to accept the registration, e.g. while deleting # the older registration. credential_id_exists = WebAuthnKey.objects.filter( - credential_id=webauthn_credential.credential_id.decode("utf-8") + credential_id=bytes_to_base64url(webauthn_registration_response.credential_id) ).first() if credential_id_exists: return JsonResponse({"fail": "Credential ID already exists."}, status=400) @@ -77,9 +77,9 @@ def webauthn_verify_credential_info(request): user=request.user, key_name=request.session.get("key_name", ""), ukey=ukey, - public_key=bytes_to_base64url(webauthn_credential.public_key), - credential_id=bytes_to_base64url(webauthn_credential.credential_id), - sign_count=webauthn_credential.sign_count, + public_key=bytes_to_base64url(webauthn_registration_response.credential_public_key), + credential_id=bytes_to_base64url(webauthn_registration_response.credential_id), + sign_count=webauthn_registration_response.sign_count, ) try: From 7abf3eaafb7428317c75229cb627c3cd37da1d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Fri, 23 Sep 2022 12:23:00 +0200 Subject: [PATCH 04/14] Add begin_assertion tests. --- .../0002_remove_webauthnkey_ukey.py | 17 +++++ kagi/models.py | 1 - kagi/tests/test_webauthn.py | 12 ++-- kagi/tests/test_webauthn_keys.py | 71 +++++++++++++++++-- kagi/utils/webauthn.py | 4 +- kagi/views/api.py | 8 +-- 6 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 kagi/migrations/0002_remove_webauthnkey_ukey.py diff --git a/kagi/migrations/0002_remove_webauthnkey_ukey.py b/kagi/migrations/0002_remove_webauthnkey_ukey.py new file mode 100644 index 0000000..bbbab3e --- /dev/null +++ b/kagi/migrations/0002_remove_webauthnkey_ukey.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.1 on 2022-09-23 10:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("kagi", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="webauthnkey", + name="ukey", + ), + ] diff --git a/kagi/models.py b/kagi/models.py index 7d76a11..995dfe2 100644 --- a/kagi/models.py +++ b/kagi/models.py @@ -19,7 +19,6 @@ class WebAuthnKey(models.Model): key_name = models.CharField(max_length=64) public_key = models.TextField(unique=True) - ukey = models.TextField(unique=True) credential_id = models.TextField(unique=True) sign_count = models.IntegerField() diff --git a/kagi/tests/test_webauthn.py b/kagi/tests/test_webauthn.py index 5cc2dfe..462f28b 100644 --- a/kagi/tests/test_webauthn.py +++ b/kagi/tests/test_webauthn.py @@ -124,11 +124,13 @@ def test_verify_assertion_response(monkeypatch): ) not_a_real_user = pretend.stub( - webauthn_keys=[ - pretend.stub( - public_key=bytes_to_base64url(b"fake public key"), sign_count=68 - ) - ] + webauthn_keys=pretend.stub( + all=lambda: [ + pretend.stub( + public_key=bytes_to_base64url(b"fake public key"), sign_count=68 + ) + ] + ) ) resp = webauthn.verify_assertion_response( ( diff --git a/kagi/tests/test_webauthn_keys.py b/kagi/tests/test_webauthn_keys.py index f33c4db..f26890b 100644 --- a/kagi/tests/test_webauthn_keys.py +++ b/kagi/tests/test_webauthn_keys.py @@ -97,7 +97,10 @@ def test_webauthn_verify_credential_info(admin_client): credential_device_type="single_device", credential_backed_up=False, ) - with mock.patch("kagi.views.api.webauthn.verify_registration_response", return_value = fake_validated_credential) as mocked_verify_registration_response: + with mock.patch( + "kagi.views.api.webauthn.verify_registration_response", + return_value=fake_validated_credential, + ) as mocked_verify_registration_response: response = admin_client.post( reverse("kagi:verify-credential-info"), {"credentials": "fake_payload"} ) @@ -114,8 +117,12 @@ def test_webauthn_verify_credential_info_fails_if_registration_is_invalid(admin_ reverse("kagi:begin-activate"), {"key_name": "SoloKey"} ) - with mock.patch("kagi.views.api.webauthn.verify_registration_response") as mocked_verify_registration_response: - mocked_verify_registration_response.side_effect = webauthn.RegistrationRejectedError("An error occurred") + with mock.patch( + "kagi.views.api.webauthn.verify_registration_response" + ) as mocked_verify_registration_response: + mocked_verify_registration_response.side_effect = ( + webauthn.RegistrationRejectedError("An error occurred") + ) response = admin_client.post( reverse("kagi:verify-credential-info"), {"credentials": "payload"} @@ -151,10 +158,66 @@ def test_webauthn_verify_credential_info_fails_if_credential_id_already_exists( credential_device_type="single_device", credential_backed_up=False, ) - with mock.patch("kagi.views.api.webauthn.verify_registration_response", return_value = fake_validated_credential) as mocked_verify_registration_response: + with mock.patch( + "kagi.views.api.webauthn.verify_registration_response", + return_value=fake_validated_credential, + ) as mocked_verify_registration_response: response = admin_client.post( reverse("kagi:verify-credential-info"), {"credentials": "fake_payload"} ) assert response.status_code == 400 assert response.json() == {"fail": "Credential ID already exists."} + + +# Testing view begin assertion +@pytest.mark.django_db +def test_begin_assertion_return_user_credential_options(client): + # We need to create a couple of WebAuthnKey for our user. + user = User.objects.create_user("admin", "john.doe@kagi.com", "admin") + user.webauthn_keys.create( + key_name="SoloKey 1", + sign_count=0, + credential_id=bytes_to_base64url(b"credential-id-1"), + public_key=bytes_to_base64url(b"pubkey1"), + ) + user.webauthn_keys.create( + key_name="SoloKey 2", + sign_count=0, + credential_id=bytes_to_base64url(b"credential-id-2"), + public_key=bytes_to_base64url(b"pubkey2"), + ) + + challenge = b"k31d65xGDFb0VUq4MEMXmWpuWkzPs889" + + assertion_dict = { + "challenge": bytes_to_base64url(challenge), + "timeout": 60000, + "rpId": "localhost", + "allowCredentials": [ + { + "id": bytes_to_base64url(b"credential-id-1"), + "type": "public-key", + "transports": ["usb", "nfc", "ble", "internal"], + }, + { + "id": bytes_to_base64url(b"credential-id-2"), + "type": "public-key", + "transports": ["usb", "nfc", "ble", "internal"], + }, + ], + "userVerification": "discouraged", + } + + # We authenticate with username/password + response = client.post( + reverse("kagi:login"), {"username": "admin", "password": "admin"} + ) + assert response.status_code == 302 + assert response.url == reverse("kagi:verify-second-factor") + + with mock.patch("kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge): + response = client.post(reverse("kagi:begin-assertion")) + + assert response.status_code == 200 + assert response.json() == assertion_dict diff --git a/kagi/utils/webauthn.py b/kagi/utils/webauthn.py index 7f3dda5..54a2186 100644 --- a/kagi/utils/webauthn.py +++ b/kagi/utils/webauthn.py @@ -56,7 +56,7 @@ def _get_webauthn_user_public_key_credential_descriptors(user, *, rp_id): AuthenticatorTransport.INTERNAL, ], ) - for credential in user.webauthn_keys + for credential in user.webauthn_keys.all() ] @@ -66,7 +66,7 @@ def _get_webauthn_user_public_keys(user, *, rp_id): base64url_to_bytes(credential.public_key), credential.sign_count, ) - for credential in user.webauthn_keys + for credential in user.webauthn_keys.all() ] diff --git a/kagi/views/api.py b/kagi/views/api.py index 37b7298..3087a77 100644 --- a/kagi/views/api.py +++ b/kagi/views/api.py @@ -30,7 +30,6 @@ def webauthn_begin_activate(request): request.session["key_name"] = form.cleaned_data["key_name"] request.session["challenge"] = bytes_to_base64url(challenge) - request.session["register_ukey"] = str(request.user.id) credential_options = webauthn.get_credential_options( request.user, @@ -47,7 +46,6 @@ def webauthn_begin_activate(request): @require_http_methods(["POST"]) def webauthn_verify_credential_info(request): challenge = base64url_to_bytes(request.session["challenge"]) - ukey = request.session["register_ukey"] credentials = request.POST["credentials"] try: @@ -76,15 +74,15 @@ def webauthn_verify_credential_info(request): WebAuthnKey.objects.create( user=request.user, key_name=request.session.get("key_name", ""), - ukey=ukey, - public_key=bytes_to_base64url(webauthn_registration_response.credential_public_key), + public_key=bytes_to_base64url( + webauthn_registration_response.credential_public_key + ), credential_id=bytes_to_base64url(webauthn_registration_response.credential_id), sign_count=webauthn_registration_response.sign_count, ) try: del request.session["challenge"] - del request.session["register_ukey"] del request.session["key_name"] except KeyError: # pragma: no cover pass From 04d5c3ffc21eb8f72f8132236552ebfd005dbf3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Fri, 23 Sep 2022 12:53:56 +0200 Subject: [PATCH 05/14] Validate login webauthn assertion. --- kagi/tests/test_webauthn_keys.py | 100 +++++++++++++++++++++++++++++++ kagi/views/api.py | 14 ++--- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/kagi/tests/test_webauthn_keys.py b/kagi/tests/test_webauthn_keys.py index f26890b..d37bd96 100644 --- a/kagi/tests/test_webauthn_keys.py +++ b/kagi/tests/test_webauthn_keys.py @@ -6,9 +6,21 @@ import pytest from kagi.utils import webauthn +from webauthn.authentication.verify_authentication_response import ( + VerifiedAuthentication, +) from webauthn.helpers import bytes_to_base64url from webauthn.helpers.structs import AttestationFormat, PublicKeyCredentialType from webauthn.registration.verify_registration_response import VerifiedRegistration +from webauthn.helpers.structs import ( + AttestationFormat, + AuthenticationCredential, + AuthenticatorAssertionResponse, + AuthenticatorAttestationResponse, + PublicKeyCredentialType, + RegistrationCredential, +) +from webauthn.registration.verify_registration_response import VerifiedRegistration from .. import settings from ..forms import KeyRegistrationForm @@ -221,3 +233,91 @@ def test_begin_assertion_return_user_credential_options(client): assert response.status_code == 200 assert response.json() == assertion_dict + + +# Testing view verify assertion +@pytest.mark.django_db +def test_verify_assertion_validates_the_user_webauthn_key(client): + # We need to create a couple of WebAuthnKey for our user. + user = User.objects.create_user("admin", "john.doe@kagi.com", "admin") + user.webauthn_keys.create( + key_name="SoloKey", + sign_count=0, + credential_id=bytes_to_base64url(b"credential-id"), + public_key=bytes_to_base64url(b"pubkey"), + ) + response = client.post( + reverse("kagi:login"), {"username": "admin", "password": "admin"} + ) + assert response.status_code == 302 + assert response.url == reverse("kagi:verify-second-factor") + + response = client.get(reverse("kagi:verify-second-factor")) + assert response.status_code == 200 + + # We authenticate with username/password + challenge = b"k31d65xGDFb0VUq4MEMXmWpuWkzPs889" + + with mock.patch("kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge): + response = client.post(reverse("kagi:begin-assertion")) + + fake_verified_authentication = VerifiedAuthentication( + credential_id=b"credential-id", + new_sign_count=69, + credential_device_type="single_device", + credential_backed_up=False, + ) + with mock.patch("kagi.views.api.webauthn.verify_assertion_response", return_value=fake_verified_authentication): + + response = client.post( + reverse("kagi:verify-assertion"), + {"credentials": json.dumps({"fake": "payload"})}, + ) + + assert response.status_code == 200 + assert response.json() == { + "success": "Successfully authenticated as admin", + "redirect_to": reverse("kagi:two-factor-settings"), + } + + +@pytest.mark.django_db +def test_verify_assertion_validates_the_assertion(client): + # We need to create a couple of WebAuthnKey for our user. + user = User.objects.create_user("admin", "john.doe@kagi.com", "admin") + user.webauthn_keys.create( + key_name="SoloKey", + sign_count=0, + credential_id=bytes_to_base64url(b"credential-id"), + public_key=bytes_to_base64url(b"pubkey"), + ) + response = client.post( + reverse("kagi:login"), {"username": "admin", "password": "admin"} + ) + assert response.status_code == 302 + assert response.url == reverse("kagi:verify-second-factor") + + # We authenticate with username/password + challenge = b"k31d65xGDFb0VUq4MEMXmWpuWkzPs889" + + with mock.patch("kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge): + response = client.post(reverse("kagi:begin-assertion")) + + with mock.patch("kagi.views.api.webauthn.AuthenticationCredential.parse_raw", return_value=AuthenticationCredential( + id="foo", + raw_id=b"~\x8a", + response=AuthenticatorAssertionResponse( + client_data_json=b'{"type": "webauthn.get", "challenge": "", "origin": "localhost"}', + authenticator_data=b"~\x8a", + signature=b"\xc2\xebZ\x9e", + user_handle=None, + ), + type=PublicKeyCredentialType.PUBLIC_KEY, + )): + response = client.post( + reverse("kagi:verify-assertion"), + {"credentials": json.dumps({"fake": "payload"})}, + ) + + assert response.status_code == 400 + assert response.json() == {'fail': 'Assertion failed. Error: Invalid WebAuthn credential'} diff --git a/kagi/views/api.py b/kagi/views/api.py index 3087a77..abdc263 100644 --- a/kagi/views/api.py +++ b/kagi/views/api.py @@ -110,18 +110,11 @@ def webauthn_begin_assertion(request): def webauthn_verify_assertion(request): challenge = base64url_to_bytes(request.session.get("challenge")) - try: - credentials = json.loads(request.POST["credentials"]) - except json.JSONDecodeError: - return JsonResponse( - {"fail": "Invalid WebAuthn assertion: Bad payload"}, status=400 - ) - user = utils.get_user(request) try: - webauthn_assertion_response = webauthn.verify_webauthn_assertion( - assertion, + webauthn_assertion_response = webauthn.verify_assertion_response( + request.POST["credentials"], challenge=challenge, user=user, origin=utils.get_origin(request), @@ -131,6 +124,7 @@ def webauthn_verify_assertion(request): return JsonResponse({"fail": f"Assertion failed. Error: {e}"}, status=400) # Update counter. + key = user.webauthn_keys.get(credential_id=bytes_to_base64url(webauthn_assertion_response.credential_id)) key.sign_count = webauthn_assertion_response.new_sign_count key.last_used = now() key.save() @@ -154,7 +148,7 @@ def webauthn_verify_assertion(request): return JsonResponse( { - "success": f"Successfully authenticated as {username}", + "success": f"Successfully authenticated as {user.get_username()}", "redirect_to": redirect_to, } ) From 528e947a20aaff0bbe99665e9c7a6b128e5fb0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Fri, 23 Sep 2022 13:05:24 +0200 Subject: [PATCH 06/14] Fix linting. --- kagi/tests/test_webauthn.py | 1 - kagi/tests/test_webauthn_keys.py | 39 ++++++++++++++++++++------------ kagi/utils/__init__.py | 3 --- kagi/utils/webauthn.py | 1 - kagi/views/api.py | 10 ++++---- 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/kagi/tests/test_webauthn.py b/kagi/tests/test_webauthn.py index 462f28b..673137a 100644 --- a/kagi/tests/test_webauthn.py +++ b/kagi/tests/test_webauthn.py @@ -13,7 +13,6 @@ import pretend import pytest import webauthn as pywebauthn - from webauthn.authentication.verify_authentication_response import ( VerifiedAuthentication, ) diff --git a/kagi/tests/test_webauthn_keys.py b/kagi/tests/test_webauthn_keys.py index d37bd96..7e8210d 100644 --- a/kagi/tests/test_webauthn_keys.py +++ b/kagi/tests/test_webauthn_keys.py @@ -5,23 +5,20 @@ from django.urls import reverse import pytest -from kagi.utils import webauthn from webauthn.authentication.verify_authentication_response import ( VerifiedAuthentication, ) from webauthn.helpers import bytes_to_base64url -from webauthn.helpers.structs import AttestationFormat, PublicKeyCredentialType -from webauthn.registration.verify_registration_response import VerifiedRegistration from webauthn.helpers.structs import ( AttestationFormat, AuthenticationCredential, AuthenticatorAssertionResponse, - AuthenticatorAttestationResponse, PublicKeyCredentialType, - RegistrationCredential, ) from webauthn.registration.verify_registration_response import VerifiedRegistration +from kagi.utils import webauthn + from .. import settings from ..forms import KeyRegistrationForm from ..models import WebAuthnKey @@ -94,8 +91,6 @@ def test_webauthn_verify_credential_info(admin_client): response = admin_client.post( reverse("kagi:begin-activate"), {"key_name": "SoloKey"} ) - credential_options = response.json() - challenge = credential_options["challenge"] fake_validated_credential = VerifiedRegistration( credential_id=b"foo", @@ -173,7 +168,7 @@ def test_webauthn_verify_credential_info_fails_if_credential_id_already_exists( with mock.patch( "kagi.views.api.webauthn.verify_registration_response", return_value=fake_validated_credential, - ) as mocked_verify_registration_response: + ): response = admin_client.post( reverse("kagi:verify-credential-info"), {"credentials": "fake_payload"} ) @@ -228,7 +223,9 @@ def test_begin_assertion_return_user_credential_options(client): assert response.status_code == 302 assert response.url == reverse("kagi:verify-second-factor") - with mock.patch("kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge): + with mock.patch( + "kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge + ): response = client.post(reverse("kagi:begin-assertion")) assert response.status_code == 200 @@ -258,7 +255,9 @@ def test_verify_assertion_validates_the_user_webauthn_key(client): # We authenticate with username/password challenge = b"k31d65xGDFb0VUq4MEMXmWpuWkzPs889" - with mock.patch("kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge): + with mock.patch( + "kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge + ): response = client.post(reverse("kagi:begin-assertion")) fake_verified_authentication = VerifiedAuthentication( @@ -267,7 +266,10 @@ def test_verify_assertion_validates_the_user_webauthn_key(client): credential_device_type="single_device", credential_backed_up=False, ) - with mock.patch("kagi.views.api.webauthn.verify_assertion_response", return_value=fake_verified_authentication): + with mock.patch( + "kagi.views.api.webauthn.verify_assertion_response", + return_value=fake_verified_authentication, + ): response = client.post( reverse("kagi:verify-assertion"), @@ -300,10 +302,14 @@ def test_verify_assertion_validates_the_assertion(client): # We authenticate with username/password challenge = b"k31d65xGDFb0VUq4MEMXmWpuWkzPs889" - with mock.patch("kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge): + with mock.patch( + "kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge + ): response = client.post(reverse("kagi:begin-assertion")) - with mock.patch("kagi.views.api.webauthn.AuthenticationCredential.parse_raw", return_value=AuthenticationCredential( + with mock.patch( + "kagi.views.api.webauthn.AuthenticationCredential.parse_raw", + return_value=AuthenticationCredential( id="foo", raw_id=b"~\x8a", response=AuthenticatorAssertionResponse( @@ -313,11 +319,14 @@ def test_verify_assertion_validates_the_assertion(client): user_handle=None, ), type=PublicKeyCredentialType.PUBLIC_KEY, - )): + ), + ): response = client.post( reverse("kagi:verify-assertion"), {"credentials": json.dumps({"fake": "payload"})}, ) assert response.status_code == 400 - assert response.json() == {'fail': 'Assertion failed. Error: Invalid WebAuthn credential'} + assert response.json() == { + "fail": "Assertion failed. Error: Invalid WebAuthn credential" + } diff --git a/kagi/utils/__init__.py b/kagi/utils/__init__.py index 41c5c32..6874a1c 100644 --- a/kagi/utils/__init__.py +++ b/kagi/utils/__init__.py @@ -1,6 +1,3 @@ -import random -import string - from django.conf import settings from django.contrib.auth import load_backend diff --git a/kagi/utils/webauthn.py b/kagi/utils/webauthn.py index 54a2186..721bc33 100644 --- a/kagi/utils/webauthn.py +++ b/kagi/utils/webauthn.py @@ -14,7 +14,6 @@ import json import webauthn as pywebauthn - from webauthn.helpers import base64url_to_bytes, generate_challenge from webauthn.helpers.exceptions import ( InvalidAuthenticationResponse, diff --git a/kagi/views/api.py b/kagi/views/api.py index abdc263..ae72770 100644 --- a/kagi/views/api.py +++ b/kagi/views/api.py @@ -8,12 +8,12 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from webauthn.helpers import bytes_to_base64url, base64url_to_bytes -import json +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url + from .. import settings, utils -from ..utils import webauthn from ..forms import KeyRegistrationForm from ..models import WebAuthnKey +from ..utils import webauthn # Registration @@ -124,7 +124,9 @@ def webauthn_verify_assertion(request): return JsonResponse({"fail": f"Assertion failed. Error: {e}"}, status=400) # Update counter. - key = user.webauthn_keys.get(credential_id=bytes_to_base64url(webauthn_assertion_response.credential_id)) + key = user.webauthn_keys.get( + credential_id=bytes_to_base64url(webauthn_assertion_response.credential_id) + ) key.sign_count = webauthn_assertion_response.new_sign_count key.last_used = now() key.save() From 1cd01e9112cec811f6ce72ef98b37f3752f627d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20HUBSCHER?= Date: Fri, 23 Sep 2022 18:57:24 +0200 Subject: [PATCH 07/14] Add new webauthn javascript code. --- kagi/static/kagi/webauthn.js | 479 ++++++++---------- kagi/templates/kagi/add_key.html | 4 +- kagi/templates/kagi/verify_second_factor.html | 4 +- kagi/tests/test_webauthn_keys.py | 51 +- kagi/views/api.py | 17 +- 5 files changed, 249 insertions(+), 306 deletions(-) diff --git a/kagi/static/kagi/webauthn.js b/kagi/static/kagi/webauthn.js index 514d2e8..5979904 100644 --- a/kagi/static/kagi/webauthn.js +++ b/kagi/static/kagi/webauthn.js @@ -1,320 +1,261 @@ -function b64enc(buf) { - return base64js.fromByteArray(buf) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); -} - -function b64RawEnc(buf) { - return base64js.fromByteArray(buf) - .replace(/\+/g, "-") - .replace(/\//g, "_"); -} - -function hexEncode(buf) { - return Array.from(buf) - .map(function(x) { - return ("0" + x.toString(16)).substr(-2); - }) - .join(""); -} - -async function fetch_json(url, options) { - const response = await fetch(url, options); - const body = await response.json(); - if (body.fail) - throw body.fail; - return body; -} - -if (typeof window.Kagi === 'undefined') { - let Kagi = window.Kagi || { - begin_activate: '/kagi/api/begin-activate/', - begin_assertion: '/kagi/api/begin-assertion/', - verify_credential_info: '/kagi/api/verify-credential-info/', - verify_assertion: '/kagi/api/verify-assertion/', - keys_list: '/kagi/keys/', - }; - console.error("window.Kagi is not defined, falling back to default URLs", Kagi); +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ -} +const populateWebAuthnErrorList = (errors) => { + const errorList = document.getElementById("webauthn-errors"); + if (errorList === null) { + return; + } -/** - * REGISTRATION FUNCTIONS - */ + /* NOTE: We only set the alert role once we actually have errors to present, + * to avoid hijacking screenreaders unnecessarily. + */ + errorList.setAttribute("role", "alert"); -/** - * Callback after the registration form is submitted. - * @param {Event} e - */ -const didClickRegister = async (e) => { - e.preventDefault(); - - // gather the data in the form - const form = document.querySelector('#register-form'); - const formData = new FormData(form); - - // post the data to the server to generate the PublicKeyCredentialCreateOptions - let credentialCreateOptionsFromServer; - try { - credentialCreateOptionsFromServer = await getCredentialCreateOptionsFromServer(formData); - } catch (err) { - return console.error("Failed to generate credential request options:", credentialCreateOptionsFromServer) - } + errors.forEach((error) => { + const errorItem = document.createElement("li"); + errorItem.appendChild(document.createTextNode(error)); + errorList.appendChild(errorItem); + }); +}; - // convert certain members of the PublicKeyCredentialCreateOptions into - // byte arrays as expected by the spec. - const publicKeyCredentialCreateOptions = transformCredentialCreateOptions(credentialCreateOptionsFromServer); - - // request the authenticator(s) to create a new credential keypair. - let credential; - try { - credential = await navigator.credentials.create({ - publicKey: publicKeyCredentialCreateOptions - }); - } catch (err) { - return console.error("Error creating credential:", err); - } +const doWebAuthn = (formId, func) => { + if (!window.PublicKeyCredential) { + return; + } - // we now have a new credential! We now need to encode the byte arrays - // in the credential into strings, for posting to our server. - const newAssertionForServer = transformNewAssertionForServer(credential); - - // post the transformed credential data to the server for validation - // and storing the public key - let assertionValidationResponse; - try { - assertionValidationResponse = await postNewAssertionToServer(newAssertionForServer); - } catch (err) { - return console.error("Server validation of credential failed:", err); - } + const webAuthnForm = document.getElementById(formId); + if (webAuthnForm === null) { + return null; + } - // reload the page after a successful result - window.location.href = Kagi.keys_list; -} + const webAuthnButton = webAuthnForm.querySelector("button[type=submit]"); + webAuthnButton.disabled = false; -/** - * Get PublicKeyCredentialRequestOptions for this user from the server - * formData of the registration form - * @param {FormData} formData - */ -const getCredentialRequestOptionsFromServer = async (formData) => { - return await fetch_json( - Kagi.begin_assertion, - { - method: "POST", - body: formData - } - ); -} + webAuthnForm.addEventListener("submit", async() => { + func(webAuthnButton.value); + event.preventDefault(); + }); +}; -const transformCredentialRequestOptions = (credentialRequestOptionsFromServer) => { - let {challenge, allowCredentials} = credentialRequestOptionsFromServer; - challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0)); +const webAuthnBtoA = (encoded) => { + return btoa(encoded).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +}; +const webAuthnBase64Normalize = (encoded) => { + return encoded.replace(/_/g, "/").replace(/-/g, "+"); +}; + +const transformAssertionOptions = (assertionOptions) => { + let {challenge, allowCredentials} = assertionOptions; + + challenge = Uint8Array.from(challenge, c => c.charCodeAt(0)); allowCredentials = allowCredentials.map(credentialDescriptor => { let {id} = credentialDescriptor; - id = id.replace(/\_/g, "/").replace(/\-/g, "+"); + id = webAuthnBase64Normalize(id); id = Uint8Array.from(atob(id), c => c.charCodeAt(0)); return Object.assign({}, credentialDescriptor, {id}); }); - const transformedCredentialRequestOptions = Object.assign( + const transformedOptions = Object.assign( {}, - credentialRequestOptionsFromServer, + assertionOptions, {challenge, allowCredentials}); - return transformedCredentialRequestOptions; + return transformedOptions; }; +const transformAssertion = (assertion) => { + const authData = new Uint8Array(assertion.response.authenticatorData); + const clientDataJSON = new Uint8Array(assertion.response.clientDataJSON); + const rawId = new Uint8Array(assertion.rawId); + const sig = new Uint8Array(assertion.response.signature); + const assertionClientExtensions = assertion.getClientExtensionResults(); + + return { + id: assertion.id, + rawId: webAuthnBtoA(String.fromCharCode(...rawId)), + response: { + authenticatorData: webAuthnBtoA(String.fromCharCode(...authData)), + clientDataJSON: webAuthnBtoA(String.fromCharCode(...clientDataJSON)), + signature: webAuthnBtoA(String.fromCharCode(...sig)), + }, + type: assertion.type, + assertionClientExtensions: JSON.stringify(assertionClientExtensions), + }; +}; -/** - * Get PublicKeyCredentialRequestOptions for this user from the server - * formData of the registration form - * @param {FormData} formData - */ -const getCredentialCreateOptionsFromServer = async (formData) => { - return await fetch_json( - Kagi.begin_activate, - { - method: "POST", - body: formData - } - ); -} - -/** - * Transforms items in the credentialCreateOptions generated on the server - * into byte arrays expected by the navigator.credentials.create() call - * @param {Object} credentialCreateOptionsFromServer - */ -const transformCredentialCreateOptions = (credentialCreateOptionsFromServer) => { - let {challenge, user} = credentialCreateOptionsFromServer; - user.id = Uint8Array.from( - atob(credentialCreateOptionsFromServer.user.id), c => c.charCodeAt(0)); +const transformCredentialOptions = (credentialOptions) => { + let {challenge, user} = credentialOptions; + user.id = Uint8Array.from(credentialOptions.user.id, c => c.charCodeAt(0)); + challenge = Uint8Array.from(credentialOptions.challenge, c => c.charCodeAt(0)); - challenge = Uint8Array.from( - atob(credentialCreateOptionsFromServer.challenge), c => c.charCodeAt(0)); + const transformedOptions = Object.assign({}, credentialOptions, {challenge, user}); - const transformedCredentialCreateOptions = Object.assign( - {}, credentialCreateOptionsFromServer, - {challenge, user}); + return transformedOptions; +}; - return transformedCredentialCreateOptions; -} +const transformCredential = (credential) => { + const attObj = new Uint8Array(credential.response.attestationObject); + const clientDataJSON = new Uint8Array(credential.response.clientDataJSON); + const rawId = new Uint8Array(credential.rawId); + const registrationClientExtensions = credential.getClientExtensionResults(); + + return { + id: credential.id, + rawId: webAuthnBtoA(String.fromCharCode(...rawId)), + type: credential.type, + response: { + attestationObject: webAuthnBtoA(String.fromCharCode(...attObj)), + clientDataJSON: webAuthnBtoA(String.fromCharCode(...clientDataJSON)), + }, + registrationClientExtensions: JSON.stringify(registrationClientExtensions), + }; +}; +const postCredential = async (keyName, credential, token) => { + const formData = new FormData(); + formData.set("key_name", keyName); + formData.set("credentials", JSON.stringify(credential)); + formData.set("csrf_token", token); + + const resp = await fetch( + "/kagi/api/verify-credential-info/", { + method: "POST", + cache: "no-cache", + body: formData, + credentials: "same-origin", + } + ); + return await resp.json(); +}; -/** - * AUTHENTICATION FUNCTIONS - */ +const postAssertion = async (assertion, token) => { + const formData = new FormData(); + formData.set("credentials", JSON.stringify(assertion)); + formData.set("csrf_token", token); + + const resp = await fetch( + "/kagi/api/verify-assertion/" + window.location.search, { + method: "POST", + cache: "no-cache", + body: formData, + credentials: "same-origin", + } + ); + return await resp.json(); +}; -/** - * Callback executed after submitting login form - * @param {Event} e - */ -const didClickLogin = async (e) => { - console.log("Login clicked"); - document.getElementById("webauthn-error").innerHTML = ""; - e.preventDefault(); - // gather the data in the form - const form = document.querySelector('#login-form'); - const formData = new FormData(form); - - // post the login data to the server to retrieve the PublicKeyCredentialRequestOptions - let credentialCreateOptionsFromServer; - try { - credentialRequestOptionsFromServer = await getCredentialRequestOptionsFromServer(formData); - } catch (err) { - return console.error("Error when getting request options from server:", err); +const GuardWebAuthn = () => { + if (!window.PublicKeyCredential) { + let webauthn_button = document.getElementById("webauthn-button"); + if (webauthn_button) { + webauthn_button.className += " button--disabled"; } - // convert certain members of the PublicKeyCredentialRequestOptions into - // byte arrays as expected by the spec. - const transformedCredentialRequestOptions = transformCredentialRequestOptions( - credentialRequestOptionsFromServer); - - // request the authenticator to create an assertion signature using the - // credential private key - let assertion; - try { - assertion = await navigator.credentials.get({ - publicKey: transformedCredentialRequestOptions, - }); - } catch (err) { - document.getElementById("webauthn-error").innerHTML = "Connection failed during credential creation."; - return console.error("Error when creating credential:", err); - } - // we now have an authentication assertion! encode the byte arrays contained - // in the assertion data as strings for posting to the server - const transformedAssertionForServer = transformAssertionForServer(assertion); - - // post the assertion to the server for verification. - let response; - try { - response = await postAssertionToServer(transformedAssertionForServer); - } catch (err) { - document.getElementById("webauthn-error").innerHTML = "Error when validating assertion on server."; - return console.error("Error when validating assertion on server:", err); + let webauthn_error = document.getElementById("webauthn-browser-support"); + if (webauthn_error) { + webauthn_error.style.display = "block"; } - window.location.href = response["redirect_to"]; + let webauthn_label = document.getElementById("webauthn-provision-label"); + if (webauthn_label) { + webauthn_label.disabled = true; + } + } }; -/** - * Transforms the binary data in the credential into base64 strings - * for posting to the server. - * @param {PublicKeyCredential} newAssertion - */ -const transformNewAssertionForServer = (newAssertion) => { - const attObj = new Uint8Array( - newAssertion.response.attestationObject); - const clientDataJSON = new Uint8Array( - newAssertion.response.clientDataJSON); - const rawId = new Uint8Array( - newAssertion.rawId); - - const registrationClientExtensions = newAssertion.getClientExtensionResults(); - - return { - id: newAssertion.id, - rawId: b64enc(rawId), - type: newAssertion.type, - attObj: b64enc(attObj), - clientData: b64enc(clientDataJSON), - registrationClientExtensions: JSON.stringify(registrationClientExtensions) - }; -} - -/** - * Posts the new credential data to the server for validation and storage. - * @param {Object} credentialDataForServer - */ -const postNewAssertionToServer = async (credentialDataForServer) => { - const formData = new FormData(); - Object.entries(credentialDataForServer).forEach(([key, value]) => { - formData.set(key, value); - }); +const ProvisionWebAuthn = () => { + doWebAuthn("webauthn-provision-form", async (csrfToken) => { + const label = document.getElementById("id_key_name").value; - return await fetch_json( - Kagi.verify_credential_info, { - method: "POST", - body: formData - }); -} + const resp = await fetch( + "/kagi/api/begin-activate/", { + cache: "no-cache", + credentials: "same-origin", + } + ); -/** - * Encodes the binary data in the assertion into strings for posting to the server. - * @param {PublicKeyCredential} newAssertion - */ -const transformAssertionForServer = (newAssertion) => { - const authData = new Uint8Array(newAssertion.response.authenticatorData); - const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON); - const rawId = new Uint8Array(newAssertion.rawId); - const sig = new Uint8Array(newAssertion.response.signature); - const assertionClientExtensions = newAssertion.getClientExtensionResults(); - - return { - id: newAssertion.id, - rawId: b64enc(rawId), - type: newAssertion.type, - authData: b64RawEnc(authData), - clientData: b64RawEnc(clientDataJSON), - signature: hexEncode(sig), - assertionClientExtensions: JSON.stringify(assertionClientExtensions) - }; + const credentialOptions = await resp.json(); + const transformedOptions = transformCredentialOptions(credentialOptions); + await navigator.credentials.create({ + publicKey: transformedOptions, + }).then(async (credential) => { + const transformedCredential = transformCredential(credential); + + const status = await postCredential(label, transformedCredential, csrfToken); + if (status.fail) { + populateWebAuthnErrorList(status.fail.errors); + return; + } + + window.location.replace("/kagi/keys/"); + }).catch((error) => { + console.log(error); + populateWebAuthnErrorList([error.message]); + return; + }); + }); }; -/** - * Post the assertion to the server for validation and logging the user in. - * @param {Object} assertionDataForServer - */ -const postAssertionToServer = async (assertionDataForServer) => { - const form = document.querySelector('#login-form'); - const formData = new FormData(form); - Object.entries(assertionDataForServer).forEach(([key, value]) => { - formData.set(key, value); - }); +const AuthenticateWebAuthn = () => { + doWebAuthn("webauthn-auth-form", async (csrfToken) => { + const resp = await fetch( + "/kagi/api/begin-assertion/" + window.location.search, { + cache: "no-cache", + credentials: "same-origin", + } + ); + + const assertionOptions = await resp.json(); + if (assertionOptions.fail) { + window.location.replace("/account/login"); + return; + } - return await fetch_json( - Kagi.verify_assertion, { - method: "POST", - body: formData + const transformedOptions = transformAssertionOptions(assertionOptions); + await navigator.credentials.get({ + publicKey: transformedOptions, + }).then(async (assertion) => { + const transformedAssertion = transformAssertion(assertion); + + const status = await postAssertion(transformedAssertion, csrfToken); + if (status.fail) { + populateWebAuthnErrorList(status.fail.errors); + return; + } + + window.location.replace(status.redirect_to); + }).catch((error) => { + populateWebAuthnErrorList([error.message]); + return; }); -} + }); +}; document.addEventListener("DOMContentLoaded", e => { - const registerElement = document.querySelector('#register'); + const registerElement = document.querySelector('#webauthn-provision-form'); if (registerElement) { - registerElement.addEventListener('click', didClickRegister); + ProvisionWebAuthn(); } - const loginElement = document.querySelector('#login'); - if (loginElement) { - loginElement.addEventListener('click', didClickLogin); + const loginElement = document.querySelector('#webauthn-auth-form'); + if (loginElement) { + AuthenticateWebAuthn(); } // If browser doesn't support WebAuthn, hide related elements and show warning if (typeof(PublicKeyCredential) == "undefined") { diff --git a/kagi/templates/kagi/add_key.html b/kagi/templates/kagi/add_key.html index ac1900f..3e8cc89 100644 --- a/kagi/templates/kagi/add_key.html +++ b/kagi/templates/kagi/add_key.html @@ -6,10 +6,10 @@ {{ block.super }}

{% trans 'To add a security key to your account, insert it, tap the button below, and accept the browser prompt to add the key.' %}

-
+ {% csrf_token %} {{ form }} - +
diff --git a/kagi/templates/kagi/verify_second_factor.html b/kagi/templates/kagi/verify_second_factor.html index 38ea971..d9d5c36 100644 --- a/kagi/templates/kagi/verify_second_factor.html +++ b/kagi/templates/kagi/verify_second_factor.html @@ -11,11 +11,11 @@ {% if forms.webauthn %} -
+ {% csrf_token %}
-
diff --git a/kagi/tests/test_webauthn_keys.py b/kagi/tests/test_webauthn_keys.py index 7e8210d..ea5dca9 100644 --- a/kagi/tests/test_webauthn_keys.py +++ b/kagi/tests/test_webauthn_keys.py @@ -59,9 +59,7 @@ def test_totp_device_deletion_works(admin_client): # Testing view begin activate def test_begin_activate_return_user_credential_options(admin_client): - response = admin_client.post( - reverse("kagi:begin-activate"), {"key_name": "SoloKey"} - ) + response = admin_client.get(reverse("kagi:begin-activate")) assert response.status_code == 200 credential_options = response.json() @@ -79,18 +77,10 @@ def test_begin_activate_return_user_credential_options(admin_client): assert "pubKeyCredParams" in credential_options -def test_begin_activate_fails_if_key_name_is_missing(admin_client): - response = admin_client.post(reverse("kagi:begin-activate"), {"key_name": ""}) - assert response.status_code == 400 - assert response.json() == {"errors": {"key_name": ["This field is required."]}} - - # Testing view verify credential info def test_webauthn_verify_credential_info(admin_client): # Setup the session - response = admin_client.post( - reverse("kagi:begin-activate"), {"key_name": "SoloKey"} - ) + response = admin_client.get(reverse("kagi:begin-activate")) fake_validated_credential = VerifiedRegistration( credential_id=b"foo", @@ -109,7 +99,8 @@ def test_webauthn_verify_credential_info(admin_client): return_value=fake_validated_credential, ) as mocked_verify_registration_response: response = admin_client.post( - reverse("kagi:verify-credential-info"), {"credentials": "fake_payload"} + reverse("kagi:verify-credential-info"), + {"credentials": "fake_payload", "key_name": "SoloKey"}, ) assert mocked_verify_registration_response.called_once @@ -120,9 +111,7 @@ def test_webauthn_verify_credential_info(admin_client): def test_webauthn_verify_credential_info_fails_if_registration_is_invalid(admin_client): # Setup the session - response = admin_client.post( - reverse("kagi:begin-activate"), {"key_name": "SoloKey"} - ) + response = admin_client.get(reverse("kagi:begin-activate")) with mock.patch( "kagi.views.api.webauthn.verify_registration_response" @@ -132,7 +121,8 @@ def test_webauthn_verify_credential_info_fails_if_registration_is_invalid(admin_ ) response = admin_client.post( - reverse("kagi:verify-credential-info"), {"credentials": "payload"} + reverse("kagi:verify-credential-info"), + {"credentials": "payload", "key_name": "SoloKey"}, ) assert response.status_code == 400 @@ -143,9 +133,7 @@ def test_webauthn_verify_credential_info_fails_if_credential_id_already_exists( admin_client, ): # Setup the session - response = admin_client.post( - reverse("kagi:begin-activate"), {"key_name": "SoloKey"} - ) + response = admin_client.get(reverse("kagi:begin-activate")) # Create the WebAuthnKey user = User.objects.get(pk=1) @@ -170,13 +158,28 @@ def test_webauthn_verify_credential_info_fails_if_credential_id_already_exists( return_value=fake_validated_credential, ): response = admin_client.post( - reverse("kagi:verify-credential-info"), {"credentials": "fake_payload"} + reverse("kagi:verify-credential-info"), + {"credentials": "fake_payload", "key_name": "Solo key"}, ) assert response.status_code == 400 assert response.json() == {"fail": "Credential ID already exists."} +def test_webauthn_verify_credential_info_fails_if_key_name_is_missing( + admin_client, +): + # Setup the session + response = admin_client.get(reverse("kagi:begin-activate")) + + response = admin_client.post( + reverse("kagi:verify-credential-info"), {"credentials": "fake_payload"} + ) + + assert response.status_code == 400 + assert response.json() == {"errors": {"key_name": ["This field is required."]}} + + # Testing view begin assertion @pytest.mark.django_db def test_begin_assertion_return_user_credential_options(client): @@ -226,7 +229,7 @@ def test_begin_assertion_return_user_credential_options(client): with mock.patch( "kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge ): - response = client.post(reverse("kagi:begin-assertion")) + response = client.get(reverse("kagi:begin-assertion")) assert response.status_code == 200 assert response.json() == assertion_dict @@ -258,7 +261,7 @@ def test_verify_assertion_validates_the_user_webauthn_key(client): with mock.patch( "kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge ): - response = client.post(reverse("kagi:begin-assertion")) + response = client.get(reverse("kagi:begin-assertion")) fake_verified_authentication = VerifiedAuthentication( credential_id=b"credential-id", @@ -305,7 +308,7 @@ def test_verify_assertion_validates_the_assertion(client): with mock.patch( "kagi.views.api.webauthn.generate_webauthn_challenge", return_value=challenge ): - response = client.post(reverse("kagi:begin-assertion")) + response = client.get(reverse("kagi:begin-assertion")) with mock.patch( "kagi.views.api.webauthn.AuthenticationCredential.parse_raw", diff --git a/kagi/views/api.py b/kagi/views/api.py index ae72770..52f0d71 100644 --- a/kagi/views/api.py +++ b/kagi/views/api.py @@ -19,16 +19,10 @@ @login_required -@require_http_methods(["POST"]) +@require_http_methods(["GET"]) def webauthn_begin_activate(request): - form = KeyRegistrationForm(request.POST) - - if not form.is_valid(): - return JsonResponse({"errors": form.errors}, status=400) - challenge = webauthn.generate_webauthn_challenge() - request.session["key_name"] = form.cleaned_data["key_name"] request.session["challenge"] = bytes_to_base64url(challenge) credential_options = webauthn.get_credential_options( @@ -48,6 +42,11 @@ def webauthn_verify_credential_info(request): challenge = base64url_to_bytes(request.session["challenge"]) credentials = request.POST["credentials"] + form = KeyRegistrationForm(request.POST) + + if not form.is_valid(): + return JsonResponse({"errors": form.errors}, status=400) + try: webauthn_registration_response = webauthn.verify_registration_response( credentials, @@ -73,7 +72,7 @@ def webauthn_verify_credential_info(request): WebAuthnKey.objects.create( user=request.user, - key_name=request.session.get("key_name", ""), + key_name=form.cleaned_data["key_name"], public_key=bytes_to_base64url( webauthn_registration_response.credential_public_key ), @@ -91,7 +90,7 @@ def webauthn_verify_credential_info(request): # Login -@require_http_methods(["POST"]) +@require_http_methods(["GET"]) def webauthn_begin_assertion(request): challenge = webauthn.generate_webauthn_challenge() request.session["challenge"] = bytes_to_base64url(challenge) From 679c4194fb09a454fcdaa89014a8794c68fee84c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Stefanini?= Date: Thu, 1 Jun 2023 09:30:13 +0300 Subject: [PATCH 08/14] Update webauthn.js Replace hardcoded pathes --- kagi/static/kagi/webauthn.js | 185 +++++++++++++++++++---------------- 1 file changed, 98 insertions(+), 87 deletions(-) diff --git a/kagi/static/kagi/webauthn.js b/kagi/static/kagi/webauthn.js index 5979904..9e4a9da 100644 --- a/kagi/static/kagi/webauthn.js +++ b/kagi/static/kagi/webauthn.js @@ -11,7 +11,6 @@ * limitations under the License. */ - const populateWebAuthnErrorList = (errors) => { const errorList = document.getElementById("webauthn-errors"); if (errorList === null) { @@ -43,14 +42,17 @@ const doWebAuthn = (formId, func) => { const webAuthnButton = webAuthnForm.querySelector("button[type=submit]"); webAuthnButton.disabled = false; - webAuthnForm.addEventListener("submit", async() => { + webAuthnForm.addEventListener("submit", async () => { func(webAuthnButton.value); event.preventDefault(); }); }; const webAuthnBtoA = (encoded) => { - return btoa(encoded).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + return btoa(encoded) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); }; const webAuthnBase64Normalize = (encoded) => { @@ -58,20 +60,20 @@ const webAuthnBase64Normalize = (encoded) => { }; const transformAssertionOptions = (assertionOptions) => { - let {challenge, allowCredentials} = assertionOptions; + let { challenge, allowCredentials } = assertionOptions; - challenge = Uint8Array.from(challenge, c => c.charCodeAt(0)); - allowCredentials = allowCredentials.map(credentialDescriptor => { - let {id} = credentialDescriptor; + challenge = Uint8Array.from(challenge, (c) => c.charCodeAt(0)); + allowCredentials = allowCredentials.map((credentialDescriptor) => { + let { id } = credentialDescriptor; id = webAuthnBase64Normalize(id); - id = Uint8Array.from(atob(id), c => c.charCodeAt(0)); - return Object.assign({}, credentialDescriptor, {id}); + id = Uint8Array.from(atob(id), (c) => c.charCodeAt(0)); + return Object.assign({}, credentialDescriptor, { id }); }); - const transformedOptions = Object.assign( - {}, - assertionOptions, - {challenge, allowCredentials}); + const transformedOptions = Object.assign({}, assertionOptions, { + challenge, + allowCredentials, + }); return transformedOptions; }; @@ -97,11 +99,16 @@ const transformAssertion = (assertion) => { }; const transformCredentialOptions = (credentialOptions) => { - let {challenge, user} = credentialOptions; - user.id = Uint8Array.from(credentialOptions.user.id, c => c.charCodeAt(0)); - challenge = Uint8Array.from(credentialOptions.challenge, c => c.charCodeAt(0)); + let { challenge, user } = credentialOptions; + user.id = Uint8Array.from(credentialOptions.user.id, (c) => c.charCodeAt(0)); + challenge = Uint8Array.from(credentialOptions.challenge, (c) => + c.charCodeAt(0) + ); - const transformedOptions = Object.assign({}, credentialOptions, {challenge, user}); + const transformedOptions = Object.assign({}, credentialOptions, { + challenge, + user, + }); return transformedOptions; }; @@ -130,14 +137,12 @@ const postCredential = async (keyName, credential, token) => { formData.set("credentials", JSON.stringify(credential)); formData.set("csrf_token", token); - const resp = await fetch( - "/kagi/api/verify-credential-info/", { - method: "POST", - cache: "no-cache", - body: formData, - credentials: "same-origin", - } - ); + const resp = await fetch(Kagi.verify_credential_info, { + method: "POST", + cache: "no-cache", + body: formData, + credentials: "same-origin", + }); return await resp.json(); }; @@ -147,14 +152,12 @@ const postAssertion = async (assertion, token) => { formData.set("credentials", JSON.stringify(assertion)); formData.set("csrf_token", token); - const resp = await fetch( - "/kagi/api/verify-assertion/" + window.location.search, { - method: "POST", - cache: "no-cache", - body: formData, - credentials: "same-origin", - } - ); + const resp = await fetch(Kagi.verify_assertion + window.location.search, { + method: "POST", + cache: "no-cache", + body: formData, + credentials: "same-origin", + }); return await resp.json(); }; @@ -182,89 +185,97 @@ const ProvisionWebAuthn = () => { doWebAuthn("webauthn-provision-form", async (csrfToken) => { const label = document.getElementById("id_key_name").value; - const resp = await fetch( - "/kagi/api/begin-activate/", { - cache: "no-cache", - credentials: "same-origin", - } - ); + const resp = await fetch(Kagi.begin_activate, { + cache: "no-cache", + credentials: "same-origin", + }); const credentialOptions = await resp.json(); const transformedOptions = transformCredentialOptions(credentialOptions); - await navigator.credentials.create({ - publicKey: transformedOptions, - }).then(async (credential) => { - const transformedCredential = transformCredential(credential); - - const status = await postCredential(label, transformedCredential, csrfToken); - if (status.fail) { - populateWebAuthnErrorList(status.fail.errors); - return; - } - - window.location.replace("/kagi/keys/"); - }).catch((error) => { + await navigator.credentials + .create({ + publicKey: transformedOptions, + }) + .then(async (credential) => { + const transformedCredential = transformCredential(credential); + + const status = await postCredential( + label, + transformedCredential, + csrfToken + ); + if (status.fail) { + populateWebAuthnErrorList(status.fail.errors); + return; + } + + window.location.replace(Kagi.keys_list); + }) + .catch((error) => { console.log(error); - populateWebAuthnErrorList([error.message]); - return; - }); + populateWebAuthnErrorList([error.message]); + return; + }); }); }; const AuthenticateWebAuthn = () => { doWebAuthn("webauthn-auth-form", async (csrfToken) => { - const resp = await fetch( - "/kagi/api/begin-assertion/" + window.location.search, { - cache: "no-cache", - credentials: "same-origin", - } - ); + const resp = await fetch(Kagi.begin_assertion + window.location.search, { + cache: "no-cache", + credentials: "same-origin", + }); const assertionOptions = await resp.json(); if (assertionOptions.fail) { - window.location.replace("/account/login"); + window.location.replace("/account/"); return; } const transformedOptions = transformAssertionOptions(assertionOptions); - await navigator.credentials.get({ - publicKey: transformedOptions, - }).then(async (assertion) => { - const transformedAssertion = transformAssertion(assertion); - - const status = await postAssertion(transformedAssertion, csrfToken); - if (status.fail) { - populateWebAuthnErrorList(status.fail.errors); + await navigator.credentials + .get({ + publicKey: transformedOptions, + }) + .then(async (assertion) => { + const transformedAssertion = transformAssertion(assertion); + + const status = await postAssertion(transformedAssertion, csrfToken); + if (status.fail) { + populateWebAuthnErrorList(status.fail.errors); + return; + } + + window.location.replace(status.redirect_to); + }) + .catch((error) => { + populateWebAuthnErrorList([error.message]); return; - } - - window.location.replace(status.redirect_to); - }).catch((error) => { - populateWebAuthnErrorList([error.message]); - return; - }); + }); }); }; - -document.addEventListener("DOMContentLoaded", e => { - const registerElement = document.querySelector('#webauthn-provision-form'); +document.addEventListener("DOMContentLoaded", (e) => { + const registerElement = document.querySelector("#webauthn-provision-form"); if (registerElement) { - ProvisionWebAuthn(); + ProvisionWebAuthn(); } - const loginElement = document.querySelector('#webauthn-auth-form'); - if (loginElement) { - AuthenticateWebAuthn(); + const loginElement = document.querySelector("#webauthn-auth-form"); + if (loginElement) { + AuthenticateWebAuthn(); } // If browser doesn't support WebAuthn, hide related elements and show warning - if (typeof(PublicKeyCredential) == "undefined") { + if (typeof PublicKeyCredential == "undefined") { var webAuthnFeature = document.getElementById("webauthn-feature"); if (webAuthnFeature) { webAuthnFeature.style.display = "none"; } - var webAuthnUndefinedError = document.getElementById("webauthn-undefined-error"); + var webAuthnUndefinedError = document.getElementById( + "webauthn-undefined-error" + ); if (webAuthnUndefinedError) { - webAuthnUndefinedError.style.display = "block"; } + webAuthnUndefinedError.style.display = "block"; } + } }); From 8d3311f2f54ee936fb9c0b7c2c44fcc9d63efb90 Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Thu, 1 Jun 2023 14:12:17 +0200 Subject: [PATCH 09/14] fixup! Update webauthn.js --- kagi/migrations/0002_remove_webauthnkey_ukey.py | 1 - kagi/tests/test_webauthn_keys.py | 1 - 2 files changed, 2 deletions(-) diff --git a/kagi/migrations/0002_remove_webauthnkey_ukey.py b/kagi/migrations/0002_remove_webauthnkey_ukey.py index bbbab3e..72ee037 100644 --- a/kagi/migrations/0002_remove_webauthnkey_ukey.py +++ b/kagi/migrations/0002_remove_webauthnkey_ukey.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("kagi", "0001_initial"), ] diff --git a/kagi/tests/test_webauthn_keys.py b/kagi/tests/test_webauthn_keys.py index ea5dca9..99e638f 100644 --- a/kagi/tests/test_webauthn_keys.py +++ b/kagi/tests/test_webauthn_keys.py @@ -273,7 +273,6 @@ def test_verify_assertion_validates_the_user_webauthn_key(client): "kagi.views.api.webauthn.verify_assertion_response", return_value=fake_verified_authentication, ): - response = client.post( reverse("kagi:verify-assertion"), {"credentials": json.dumps({"fake": "payload"})}, From c8d0a27030eb43da2f9e88ee0adb2ddecd5747bf Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Thu, 1 Jun 2023 15:08:52 +0200 Subject: [PATCH 10/14] fix dev dependencies in poetry deps definition --- pyproject.toml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ff8d587..f538c03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,10 @@ name = "kagi" version = "0.3.0" description = "Django app for WebAuthn and TOTP-based multi-factor authentication" -authors = ["Justin Mayer ", "Rémy Hubscher "] +authors = [ + "Justin Mayer ", + "Rémy Hubscher ", +] license = "BSD-2-Clause" readme = "README.rst" keywords = ["Django", "WebAuthn", "authentication", "MFA", "2FA"] @@ -32,7 +35,7 @@ python = ">= 3.8, < 4.0" qrcode = ">= 6.1, < 8.0" webauthn = "^1.6.0" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] black = "^23.3" flake8 = "^5.0" Flake8-pyproject = "^1.2.3" @@ -40,7 +43,8 @@ furo = "2022.04.07" invoke = "^1.3" isort = "^5.11" livereload = "^2.6" -psutil = {version = "^5.7", optional = true} +pretend = "^1.0.9" +psutil = { version = "^5.7", optional = true } pyOpenSSL = "^22.0" pytest = "^7.1" pytest-cov = "^3.0" @@ -50,9 +54,6 @@ pytest-xdist = "^2.1" sphinx = "^4.0" Werkzeug = "^2.0" -[tool.poetry.group.dev.dependencies] -pretend = "^1.0.9" - [tool.autopub] project-name = "Kagi" git-username = "botpub" @@ -80,7 +81,10 @@ requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] -filterwarnings = ['ignore::DeprecationWarning:invoke.loader', 'ignore::DeprecationWarning:invoke.tasks'] +filterwarnings = [ + 'ignore::DeprecationWarning:invoke.loader', + 'ignore::DeprecationWarning:invoke.tasks', +] pythonpath = 'testproj' DJANGO_SETTINGS_MODULE = 'testproj.settings' From 9f7281abd8d3c41d504d669216e7b332c8d1545a Mon Sep 17 00:00:00 2001 From: Justin Mayer Date: Fri, 2 Jun 2023 10:19:03 +0200 Subject: [PATCH 11/14] fixup! Add new webauthn javascript code. --- kagi/static/kagi/webauthn.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kagi/static/kagi/webauthn.js b/kagi/static/kagi/webauthn.js index 9e4a9da..a0003a1 100644 --- a/kagi/static/kagi/webauthn.js +++ b/kagi/static/kagi/webauthn.js @@ -9,6 +9,8 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * Origin: https://github.com/pypi/warehouse */ const populateWebAuthnErrorList = (errors) => { From 4101e13bf199bfad1b60071bf4505236e5d3ba7f Mon Sep 17 00:00:00 2001 From: Justin Mayer Date: Fri, 2 Jun 2023 13:59:30 +0200 Subject: [PATCH 12/14] fixup! Port kagi to webauthn 1.6.0 --- kagi/tests/test_webauthn.py | 2 ++ kagi/utils/webauthn.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/kagi/tests/test_webauthn.py b/kagi/tests/test_webauthn.py index 673137a..739ef6a 100644 --- a/kagi/tests/test_webauthn.py +++ b/kagi/tests/test_webauthn.py @@ -9,6 +9,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# +# Origin: https://github.com/pypi/warehouse import pretend import pytest diff --git a/kagi/utils/webauthn.py b/kagi/utils/webauthn.py index 721bc33..ba2fee7 100644 --- a/kagi/utils/webauthn.py +++ b/kagi/utils/webauthn.py @@ -9,6 +9,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# +# Origin: https://github.com/pypi/warehouse import base64 import json From 74f052921875f4e08e82ddf8b2017f9d22f564c0 Mon Sep 17 00:00:00 2001 From: Justin Mayer Date: Tue, 14 May 2024 16:08:32 +0200 Subject: [PATCH 13/14] Adjust supported Python and Django versions - Add support for Django 5.0 - Drop support for Django 3.2 and 4.1 - Drop support for Python 3.7 and 3.8 --- .github/workflows/main.yml | 19 ++++--------------- pyproject.toml | 3 +-- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e0d00f4..8436bda 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,27 +14,16 @@ jobs: fail-fast: false matrix: python-version: - - "3.7" - - "3.8" - "3.9" - "3.10" - "3.11" django-version: - - "3.2" - - "4.1" - "4.2" + - "5.0" exclude: - # Django 3.2 is compatible with Python <= 3.10 - - python-version: "3.11" - django-version: "3.2" - - # Django 4.1 is compatible with Python >= 3.8 - - python-version: "3.7" - django-version: "4.1" - - # Django 4.2 is compatible with Python >= 3.8 - - python-version: "3.7" - django-version: "4.2" + # Django 5.0 is compatible with Python >= 3.10 + - python-version: "3.9" + django-version: "5.0" steps: - uses: actions/checkout@v3 diff --git a/pyproject.toml b/pyproject.toml index bc92203..18cea1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,8 @@ classifiers = [ "Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: Django", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", From d919efbe5af441229a728ed4217c6300a3db1d16 Mon Sep 17 00:00:00 2001 From: Justin Mayer Date: Tue, 14 May 2024 16:14:34 +0200 Subject: [PATCH 14/14] Upgrade docs to Sphinx 6.x & Furo theme 2024.04.27 --- docs/_templates/page.html | 34 +++++++++++++++++----------------- docs/requirements.txt | 4 ++-- pyproject.toml | 4 ++-- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/_templates/page.html b/docs/_templates/page.html index 8f6fb93..6447336 100644 --- a/docs/_templates/page.html +++ b/docs/_templates/page.html @@ -13,6 +13,12 @@
Hide table of contents sidebar
+ + {%- trans -%} + Skip to content + {%- endtrans -%} + + {% if theme_announcement -%}
{{ next.title }}
- + {%- endif %} {% if prev -%} - +
{{ _("Previous") }} @@ -153,6 +151,7 @@ {%- endif %}
+ {% if theme_footer_icons or READTHEDOCS -%}
{% if theme_footer_icons -%} {% for icon_dict in theme_footer_icons -%} @@ -179,6 +178,7 @@ {%- endif -%} {%- endif %}
+ {%- endif %}
{% endblock footer %} @@ -190,7 +190,7 @@
- {{ _("Contents") }} + {{ _("On this page") }}
diff --git a/docs/requirements.txt b/docs/requirements.txt index dfa1434..3e2d227 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -furo==2022.04.07 -sphinx==4.5.0 +furo==2024.04.27 +sphinx==6.2.1 diff --git a/pyproject.toml b/pyproject.toml index 18cea1e..41f35c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ webauthn = "^1.6.0" black = "^23.3" flake8 = "^5.0" Flake8-pyproject = "^1.2.3" -furo = "2022.04.07" +furo = "2024.04.27" invoke = "^2.0" isort = "^5.11" livereload = "^2.6" @@ -53,7 +53,7 @@ pytest-cov = "^3.0" pytest-django = "^4.0" pytest-sugar = "^0.9" pytest-xdist = "^2.1" -sphinx = "^4.0" +sphinx = "^6.0" Werkzeug = "^2.0" [tool.autopub]