Skip to content

Commit

Permalink
Add authentication browser automation tests
Browse files Browse the repository at this point in the history
  • Loading branch information
Stormheg committed Dec 1, 2024
1 parent d4fcb52 commit e32901c
Show file tree
Hide file tree
Showing 3 changed files with 326 additions and 1 deletion.
15 changes: 14 additions & 1 deletion 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 Down Expand Up @@ -92,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
239 changes: 239 additions & 0 deletions tests/e2e/test_credential_authentication.py
Original file line number Diff line number Diff line change
@@ -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"

0 comments on commit e32901c

Please sign in to comment.