Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Browser automation tests, test authentication #43

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions sandbox/templates/sandbox/login_passkey.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% extends "base.html" %}

{% load i18n otp_webauthn %}

{% block title %}{% translate "Login using a Passkey" %}{% endblock %}

{% block content %}
<div>
<span id="passkey-verification-placeholder"></span>
</div>

<template id="passkey-verification-available-template">
<button class="button" type="button" id="passkey-verification-button">{% translate "Login using a Passkey" %}</button>
<div id="passkey-verification-status-message"></div>
</template>

<template id="passkey-verification-unavailable-template">
<p>{% translate "Sorry, your browser has no Passkey support" %}</p>
</template>

{% render_otp_webauthn_auth_scripts %}
{% endblock content %}
3 changes: 2 additions & 1 deletion sandbox/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions sandbox/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
20 changes: 17 additions & 3 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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",
{
Expand All @@ -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."""
Expand Down
73 changes: 73 additions & 0 deletions tests/e2e/fixtures.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand Down
Loading