diff --git a/sandbox/templates/sandbox/login_passkey.html b/sandbox/templates/sandbox/login_passkey.html new file mode 100644 index 0000000..e2c1425 --- /dev/null +++ b/sandbox/templates/sandbox/login_passkey.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% load i18n otp_webauthn %} + +{% block title %}{% translate "Login using a Passkey" %}{% endblock %} + +{% block content %} +
+ +
+ + + + + +{% render_otp_webauthn_auth_scripts %} +{% endblock content %} diff --git a/sandbox/urls.py b/sandbox/urls.py index 8a72e8d..d4b20cf 100644 --- a/sandbox/urls.py +++ b/sandbox/urls.py @@ -2,10 +2,11 @@ from django.urls import include, path from .admin import admin_site -from .views import IndexView, SecondFactorVerificationView +from .views import IndexView, LoginWithPasskeyView, SecondFactorVerificationView urlpatterns = [ path("", IndexView.as_view(), name="index"), + path("login-passkey/", LoginWithPasskeyView.as_view(), name="login-passkey"), path( "verification/", SecondFactorVerificationView.as_view(), diff --git a/sandbox/views.py b/sandbox/views.py index db36126..a94cb12 100644 --- a/sandbox/views.py +++ b/sandbox/views.py @@ -19,6 +19,10 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: return ctx +class LoginWithPasskeyView(TemplateView): + template_name = "sandbox/login_passkey.html" + + class SecondFactorVerificationView(LoginRequiredMixin, TemplateView): template_name = "sandbox/second_factor_verification.html" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index d8729e5..e7dfbbf 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -5,7 +5,7 @@ from django.test.client import Client as DjangoTestClient from playwright.sync_api import CDPSession -from tests.e2e.fixtures import VirtualAuthenticator +from tests.e2e.fixtures import VirtualAuthenticator, VirtualCredential class FutureWrapper: @@ -30,7 +30,9 @@ def _event_waiter(): @pytest.fixture(scope="function") def cdpsession(page) -> CDPSession: - return page.context.new_cdp_session(page) + session = page.context.new_cdp_session(page) + session.send("WebAuthn.enable") + return session @pytest.fixture(autouse=True) @@ -79,7 +81,6 @@ def _return(): @pytest.fixture def virtual_authenticator(cdpsession): def _get_authenticator(authenticator: VirtualAuthenticator): - cdpsession.send("WebAuthn.enable") resp = cdpsession.send( "WebAuthn.addVirtualAuthenticator", { @@ -91,6 +92,19 @@ def _get_authenticator(authenticator: VirtualAuthenticator): return _get_authenticator +@pytest.fixture +def virtual_credential(cdpsession): + def _get_credential(authenticator_id: str, credential: VirtualCredential): + data = { + "authenticatorId": authenticator_id, + "credential": credential.as_cdp_options(), + } + resp = cdpsession.send("WebAuthn.addCredential", data) + return resp + + return _get_credential + + @pytest.fixture def playwright_force_login(live_server, context): """Fixture that forces the given user to be logged in by manipulating the session cookie.""" diff --git a/tests/e2e/fixtures.py b/tests/e2e/fixtures.py index 8aa6f43..345ce5d 100644 --- a/tests/e2e/fixtures.py +++ b/tests/e2e/fixtures.py @@ -1,6 +1,12 @@ import enum +import hashlib +from base64 import b64encode from dataclasses import dataclass +from webauthn.helpers import base64url_to_bytes + +from django_otp_webauthn.models import AbstractWebAuthnCredential + # Matches StatusEnum in types.ts class StatusEnum(enum.StrEnum): @@ -15,6 +21,73 @@ class StatusEnum(enum.StrEnum): BUSY = "busy" +KNOWN_INTERNAL_PRIVATE_KEY = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggJbdYAOvm/MOu47dJ034ggl4Miqx1WGrzxiX+A4WwnehRANCAARCCxh40Cwk4o3erCjJHjFIZkYc7BNAt3+UD5c6Y0I/V9ILewFU2lG388izmQMkrmMFFuZ4GuFTtphFSBl3XLdq" +KNOWN_INTERNAL_PUBLIC_KEY = "a5010203262001215820420b1878d02c24e28ddeac28c91e314866461cec1340b77f940f973a63423f57225820d20b7b0154da51b7f3c8b3990324ae630516e6781ae153b698454819775cb76a" + +KNOWN_U2F_CREDENTIAL_ID = "OuVTUj2NPvclahvg2GJZF3cLnQtnX8YhVGMPtkojEbU=" +KNOWN_U2F_PRIVATE_KEY = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgCMwjENBovsDoqXgR7K0QPBx7aIgNzpK3RYudN29uMFGhRANCAARIawncyJcuQBHRViZ5mNGWq6R4CnMIjEvcOfQQ1zqnmV0CGcVykWlPY2aqsWF1bN4W/+7zEkt0a67JsWFmh15N" +KNOWN_U2F_PUBLIC_KEY = "a5010203262001215820486b09dcc8972e4011d156267998d196aba4780a73088c4bdc39f410d73aa7992258205d0219c57291694f6366aab161756cde16ffeef3124b746baec9b16166875e4d" + + +@dataclass(frozen=True) +class VirtualCredential: + credential_id: str + is_resident_credential: bool + user_handle: str + private_key: str + backup_eligible: bool + backup_state: bool + sign_count: int = 1 + rp_id: str = "localhost" + + def as_cdp_options(self) -> dict: + return { + "credentialId": self.credential_id, + "userHandle": self.user_handle, + "isResidentCredential": self.is_resident_credential, + "privateKey": self.private_key, + "signCount": self.sign_count, + "backupEligible": self.backup_eligible, + "backupState": self.backup_state, + "rpId": self.rp_id, + } + + @classmethod + def from_model( + cls, credential: AbstractWebAuthnCredential, require_u2f: bool = False + ): + if require_u2f: + # U2F credentials are bit more involved, use values known to work + credential_id = KNOWN_U2F_CREDENTIAL_ID + private_key = KNOWN_U2F_PRIVATE_KEY + public_key = KNOWN_U2F_PUBLIC_KEY + else: + credential_id = b64encode(credential.credential_id).decode("utf-8") + private_key = KNOWN_INTERNAL_PRIVATE_KEY + public_key = KNOWN_INTERNAL_PUBLIC_KEY + + browser_credential = cls( + credential_id=credential_id, + user_handle=b64encode( + hashlib.sha256(bytes(credential.user.pk)).digest() + ).decode("utf-8"), + is_resident_credential=bool( + credential.discoverable + ), # If null (unknown), assume false + sign_count=credential.sign_count, + backup_eligible=bool(credential.backup_eligible), + backup_state=bool(credential.backup_state), + private_key=private_key, + ) + # We need to match what the browser sends us, update this credential to match + credential.public_key = bytes.fromhex(public_key) + credential.credential_id = base64url_to_bytes(credential_id) + # Clear hash, have it be recalculated + credential.credential_id_sha256 = None + credential.save() + return browser_credential + + @dataclass(frozen=True) class VirtualAuthenticator: transport: str diff --git a/tests/e2e/test_credential_authentication.py b/tests/e2e/test_credential_authentication.py new file mode 100644 index 0000000..5469f6e --- /dev/null +++ b/tests/e2e/test_credential_authentication.py @@ -0,0 +1,239 @@ +from django.urls import reverse +from playwright.sync_api import expect + +from tests.e2e.fixtures import StatusEnum, VirtualAuthenticator, VirtualCredential +from tests.factories import WebAuthnCredentialFactory + +JS_EVENT_VERIFICATION_START = "otp_webauthn.verification_start" +JS_EVENT_VERIFICATION_COMPLETE = "otp_webauthn.verification_complete" +JS_EVENT_VERIFICATION_FAILED = "otp_webauthn.verification_failed" + + +def test_authenticate_credential__internal_passwordless_manual( + live_server, + django_db_serialized_rollback, + page, + user, + virtual_authenticator, + virtual_credential, +): + """Verify authentication with an 'internal' authenticator credential works + by manually clicking the 'Authenticate with Passkey' button.""" + credential = WebAuthnCredentialFactory(user=user, discoverable=True) + authenticator = virtual_authenticator(VirtualAuthenticator.internal()) + authenticator_id = authenticator["authenticatorId"] + + # Create a virtual credential from our database model + virtual_credential(authenticator_id, VirtualCredential.from_model(credential)) + + # Go to the login with passkey page + page.goto(live_server.url + reverse("login-passkey")) + + login_button = page.locator("button#passkey-verification-button") + expect(login_button).to_be_visible() + + login_button.click() + + # We should navigate back to the index page + page.wait_for_url(live_server.url + reverse("index")) + + assert user.username in page.content() + + +def test_authenticate_credential__internal_passwordless_using_autofill( + live_server, + django_db_serialized_rollback, + page, + user, + virtual_authenticator, + virtual_credential, +): + """Verify authentication with an 'internal' authenticator credential works + by having the browser perform autofill on a username/password form.""" + credential = WebAuthnCredentialFactory(user=user, discoverable=True) + authenticator = virtual_authenticator(VirtualAuthenticator.internal()) + authenticator_id = authenticator["authenticatorId"] + + # Create a virtual credential from our database model + virtual_credential(authenticator_id, VirtualCredential.from_model(credential)) + + # Go to the login with passkey page + page.goto(live_server.url + reverse("auth:login")) + + # The browser should now prompt the user to autofill the passwordless credential. + # This prompt is immediately accepted, so we should be redirected back to the index page. + # We should navigate back to the index page + page.wait_for_url(live_server.url + reverse("index")) + + assert user.username in page.content() + + +def test_authenticate_credential__internal_second_factor_fails_when_credential_is_disabled( + live_server, + django_db_serialized_rollback, + page, + user, + virtual_authenticator, + virtual_credential, + wait_for_javascript_event, +): + credential = WebAuthnCredentialFactory( + user=user, discoverable=True, confirmed=False + ) + authenticator = virtual_authenticator(VirtualAuthenticator.internal()) + authenticator_id = authenticator["authenticatorId"] + + # Create a virtual credential from our database model + virtual_credential(authenticator_id, VirtualCredential.from_model(credential)) + + # Go to the login with passkey page + page.goto(live_server.url + reverse("login-passkey")) + + await_start_event = wait_for_javascript_event(JS_EVENT_VERIFICATION_START) + await_failure_event = wait_for_javascript_event(JS_EVENT_VERIFICATION_FAILED) + + login_button = page.locator("button#passkey-verification-button") + expect(login_button).to_be_visible() + + login_button.click() + + # Login fails, this credential is marked as disabled + status_message = page.locator( + f"#passkey-verification-status-message[data-status-enum='{StatusEnum.SERVER_ERROR}']" + ) + + expect(status_message).to_be_visible() + + assert "marked as disabled" in status_message.inner_text() + + # Did the right events fire? + await_start_event() + await_failure_event() + + +def test_authenticate_credential__u2f_passwordless_fails( + live_server, + django_db_serialized_rollback, + page, + user, + virtual_authenticator, + virtual_credential, + wait_for_javascript_event, +): + """Verify authentication with a U2F authenticator credential fails when not logged in yet.""" + credential = WebAuthnCredentialFactory(user=user, discoverable=False) + authenticator = virtual_authenticator(VirtualAuthenticator.u2f()) + authenticator_id = authenticator["authenticatorId"] + + # Create a virtual credential from our database model + virtual_credential(authenticator_id, VirtualCredential.from_model(credential)) + + # Go to the login with passkey page + page.goto(live_server.url + reverse("login-passkey")) + + await_start_event = wait_for_javascript_event(JS_EVENT_VERIFICATION_START) + await_failure_event = wait_for_javascript_event(JS_EVENT_VERIFICATION_FAILED) + + login_button = page.locator("button#passkey-verification-button") + expect(login_button).to_be_visible() + + login_button.click() + + # Login should fail, this is a U2F credential that requires the server to + # provide a list of credential ids which we are not willing to do, as the + # user is not authenticated. + # As a result, the login attempt fails. + page.wait_for_selector( + f"#passkey-verification-status-message[data-status-enum='{StatusEnum.NOT_ALLOWED_OR_ABORTED}']", + timeout=5000, + ) + + # Did the right events fire? + await_start_event() + + # Did we fail for the right reason? + res = await_failure_event() + assert res["fromAutofill"] is False + assert res["error"].name == "NotAllowedError" + + +def test_authenticate_credential__u2f_second_factor( + live_server, + django_db_serialized_rollback, + page, + user, + virtual_authenticator, + virtual_credential, + playwright_force_login, +): + """Verify authentication with a U2F authenticator credential works if used + by an already authenticated user as form of second factor.""" + # We must be authenticated already to use this credential, because the + # server will provide us with the credential id we need for U2F to function. + playwright_force_login(user) + credential = WebAuthnCredentialFactory(user=user, discoverable=False, transports=[]) + authenticator = virtual_authenticator(VirtualAuthenticator.u2f()) + authenticator_id = authenticator["authenticatorId"] + + credential_data = VirtualCredential.from_model(credential, require_u2f=True) + + # Create a virtual credential from our database model + virtual_credential(authenticator_id, credential_data) + + page.goto(live_server.url + reverse("second-factor-verification")) + + verify_button = page.locator("button#passkey-verification-button") + expect(verify_button).to_be_visible() + + verify_button.click() + + # Login should succeed and return us to the index page + page.wait_for_url(live_server.url + reverse("index")) + + +def test_authenticate_credential_second_factor_no_available_device( + live_server, + django_db_serialized_rollback, + page, + user, + virtual_authenticator, + virtual_credential, + playwright_force_login, + wait_for_javascript_event, +): + """Verify authentication with a U2F authenticator credential fails when no device is available.""" + playwright_force_login(user) + + # This credential belongs to someone else, we can't use it for authentication + credential = WebAuthnCredentialFactory(discoverable=False, transports=[]) + assert credential.user != user + + authenticator = virtual_authenticator(VirtualAuthenticator.u2f()) + authenticator_id = authenticator["authenticatorId"] + + credential_data = VirtualCredential.from_model(credential, require_u2f=True) + + # Create a virtual credential from our database model + virtual_credential(authenticator_id, credential_data) + + page.goto(live_server.url + reverse("second-factor-verification")) + await_start_event = wait_for_javascript_event(JS_EVENT_VERIFICATION_START) + await_failure_event = wait_for_javascript_event(JS_EVENT_VERIFICATION_FAILED) + + verify_button = page.locator("button#passkey-verification-button") + expect(verify_button).to_be_visible() + + verify_button.click() + + page.wait_for_selector( + f"#passkey-verification-status-message[data-status-enum='{StatusEnum.NOT_ALLOWED_OR_ABORTED}']", + timeout=5000, + ) + + # Did the right events fire? + await_start_event() + + # Did we fail for the right reason? + res = await_failure_event() + assert res["fromAutofill"] is False + assert res["error"].name == "NotAllowedError" diff --git a/tests/e2e/test_credential_registration.py b/tests/e2e/test_credential_registration.py index 02d9d6f..daebcbf 100644 --- a/tests/e2e/test_credential_registration.py +++ b/tests/e2e/test_credential_registration.py @@ -42,7 +42,8 @@ def test_register_credential__legacy_u2f_passwordless_setting_enabled( register_button.click() page.wait_for_selector( - f"#passkey-register-status-message[data-status-enum='{StatusEnum.NOT_ALLOWED_OR_ABORTED}']" + f"#passkey-register-status-message[data-status-enum='{StatusEnum.NOT_ALLOWED_OR_ABORTED}']", + timeout=2000, ) # Did the right events fire? @@ -92,7 +93,8 @@ def test_register_credential__legacy_u2f_passwordless_setting_disabled( register_button.click() page.wait_for_selector( - f"#passkey-register-status-message[data-status-enum='{StatusEnum.SUCCESS}']" + f"#passkey-register-status-message[data-status-enum='{StatusEnum.SUCCESS}']", + timeout=2000, ) browser_credential = get_credential() @@ -155,7 +157,8 @@ def test_register_credential__internal_success( # Wait for the page to display a success message, afterwards it is likely # safe to get the credential without being trapped waiting for eternity.. page.wait_for_selector( - f"#passkey-register-status-message[data-status-enum='{StatusEnum.SUCCESS}']" + f"#passkey-register-status-message[data-status-enum='{StatusEnum.SUCCESS}']", + timeout=2000, ) browser_credential = get_credential() @@ -214,7 +217,9 @@ def test_register_credential__fail_bad_user_presence( expect(register_button).to_be_visible() register_button.click() - page.wait_for_selector("#passkey-register-status-message[data-status-enum]") + page.wait_for_selector( + "#passkey-register-status-message[data-status-enum]", timeout=2000 + ) # Did the right events fire? await_start_event() await_failure_event() @@ -251,7 +256,8 @@ def test_register_credential__fail_bad_rpid( register_button.click() page.wait_for_selector( - f"#passkey-register-status-message[data-status-enum='{StatusEnum.SECURITY_ERROR}']" + f"#passkey-register-status-message[data-status-enum='{StatusEnum.SECURITY_ERROR}']", + timeout=2000, ) # Did the right events fire? await_start_event()