build(deps): bump webauthn from 0.4.7 to 1.0.0 (#1625)

* build(deps): bump webauthn from 0.4.7 to 1.0.0

Bumps [webauthn](https://github.com/duo-labs/py_webauthn) from 0.4.7 to 1.0.0.
- [Release notes](https://github.com/duo-labs/py_webauthn/releases)
- [Commits](https://github.com/duo-labs/py_webauthn/compare/v0.4.7...v1.0.0)

---
updated-dependencies:
- dependency-name: webauthn
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* stages/authenticator_webauthn: migrate to new library version

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* stages/authenticator_validate: migrate to new version

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* stages/authenticator_webauthn: add bytes_to_base64url_dict for json encoding

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* actually don't do that

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix missing response on web

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* more double json

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* more base64 stuff

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* working

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* ci: always sync

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
dependabot[bot] 2021-10-15 23:26:29 +02:00 committed by GitHub
parent 56a56ffdbf
commit 8040e2b6e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 169 additions and 210 deletions

View file

@ -150,7 +150,10 @@ jobs:
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
run: scripts/ci_prepare.sh run: |
scripts/ci_prepare.sh
# Sync anyways since stable will have different dependencies
pipenv sync --dev
- name: run migrations to stable - name: run migrations to stable
run: pipenv run python -m lifecycle.migrate run: pipenv run python -m lifecycle.migrate
- name: checkout current code - name: checkout current code

48
Pipfile.lock generated
View file

@ -80,6 +80,13 @@
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.6'",
"version": "==3.4.1" "version": "==3.4.1"
}, },
"asn1crypto": {
"hashes": [
"sha256:4bcdf33c861c7d40bdcd74d8e4dd7661aac320fcdf40b9a3f95b4ee12fde2fa8",
"sha256:f4f6e119474e58e04a2b1af817eb585b4fd72bdd89b998624712b5c99be7641c"
],
"version": "==1.4.0"
},
"async-timeout": { "async-timeout": {
"hashes": [ "hashes": [
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
@ -489,13 +496,6 @@
"index": "pypi", "index": "pypi",
"version": "==3.1.0" "version": "==3.1.0"
}, },
"future": {
"hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.18.2"
},
"geoip2": { "geoip2": {
"hashes": [ "hashes": [
"sha256:f150bed3190d543712a17467208388d31bd8ddb49b2226fba53db8aaedb8ba89", "sha256:f150bed3190d543712a17467208388d31bd8ddb49b2226fba53db8aaedb8ba89",
@ -991,6 +991,34 @@
"index": "pypi", "index": "pypi",
"version": "==3.11.0" "version": "==3.11.0"
}, },
"pydantic": {
"hashes": [
"sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd",
"sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739",
"sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f",
"sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840",
"sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23",
"sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287",
"sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62",
"sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b",
"sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb",
"sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820",
"sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3",
"sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b",
"sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e",
"sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3",
"sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316",
"sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b",
"sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4",
"sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20",
"sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e",
"sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505",
"sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1",
"sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==1.8.2"
},
"pyjwt": { "pyjwt": {
"hashes": [ "hashes": [
"sha256:a0b9a3b4e5ca5517cac9f1a6e9cd30bf1aa80be74fcdf4e28eded582ecfcfbae", "sha256:a0b9a3b4e5ca5517cac9f1a6e9cd30bf1aa80be74fcdf4e28eded582ecfcfbae",
@ -1301,11 +1329,11 @@
}, },
"webauthn": { "webauthn": {
"hashes": [ "hashes": [
"sha256:238391b2e2cc60fb51a2cd2d2d6be149920b9af6184651353d9f95856617a9e7", "sha256:1c068b93ab0f03fc6905e42e42e6ad24caa4f2632ff13ab846d5e2ef0bf1aa37",
"sha256:8ad9072ff1d6169f3be30d4dc8733ea563dd266962397bc58b40f674a6af74ac" "sha256:6710b8b3d846010fcf303d4fb96ca42de154aeeec379ead4e18cc582a11f9abc"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.4.7" "version": "==1.0.0"
}, },
"websocket-client": { "websocket-client": {
"hashes": [ "hashes": [

View file

@ -17,13 +17,10 @@ from django.views.generic import View
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, PolymorphicProxySerializer, extend_schema from drf_spectacular.utils import OpenApiParameter, PolymorphicProxySerializer, extend_schema
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
from authentik.api.throttle import SessionThrottle
from authentik.core.models import USER_ATTRIBUTE_DEBUG from authentik.core.models import USER_ATTRIBUTE_DEBUG
from authentik.events.models import Event, EventAction, cleanse_dict from authentik.events.models import Event, EventAction, cleanse_dict
from authentik.flows.challenge import ( from authentik.flows.challenge import (
@ -100,33 +97,10 @@ class InvalidStageError(SentryIgnoredException):
"""Error raised when a challenge from a stage is not valid""" """Error raised when a challenge from a stage is not valid"""
class FlowPendingUserThrottle(ScopedRateThrottle):
"""Custom throttle based on which user is pending"""
def get_cache_key(self, request: Request, view) -> str:
if SESSION_KEY_PLAN not in request._request.session:
return ""
if PLAN_CONTEXT_PENDING_USER not in request._request.session[SESSION_KEY_PLAN].context:
return ""
user = request._request.session[SESSION_KEY_PLAN].context[PLAN_CONTEXT_PENDING_USER]
return f"authentik-throttle-flow-pending-{user.uid}"
def allow_request(self, request: Request, view) -> bool:
if SESSION_KEY_PLAN not in request._request.session:
return True
if PLAN_CONTEXT_PENDING_USER not in request._request.session[SESSION_KEY_PLAN].context:
return True
if request._request.user.is_superuser:
return True
return super().allow_request(request, view)
@method_decorator(xframe_options_sameorigin, name="dispatch") @method_decorator(xframe_options_sameorigin, name="dispatch")
class FlowExecutorView(APIView): class FlowExecutorView(APIView):
"""Stage 1 Flow executor, passing requests to Stage Views""" """Stage 1 Flow executor, passing requests to Stage Views"""
throttle_classes = [SessionThrottle, FlowPendingUserThrottle]
throttle_scope = "flow_executor"
permission_classes = [AllowAny] permission_classes = [AllowAny]
flow: Flow flow: Flow

View file

@ -205,10 +205,6 @@ REST_FRAMEWORK = {
], ],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"TEST_REQUEST_DEFAULT_FORMAT": "json", "TEST_REQUEST_DEFAULT_FORMAT": "json",
"DEFAULT_THROTTLE_RATES": {
"anon": "100/day",
"flow_executor": "100/day",
},
} }
REDIS_PROTOCOL_PREFIX = "redis://" REDIS_PROTOCOL_PREFIX = "redis://"

View file

@ -1,4 +1,7 @@
"""Validation stage challenge checking""" """Validation stage challenge checking"""
from json import dumps, loads
from typing import Optional
from django.http import HttpRequest from django.http import HttpRequest
from django.http.response import Http404 from django.http.response import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -8,12 +11,10 @@ from django_otp.models import Device
from rest_framework.fields import CharField, JSONField from rest_framework.fields import CharField, JSONField
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser from webauthn import generate_authentication_options, verify_authentication_response
from webauthn.webauthn import ( from webauthn.helpers import base64url_to_bytes, options_to_json
AuthenticationRejectedException, from webauthn.helpers.exceptions import InvalidAuthenticationResponse
RegistrationRejectedException, from webauthn.helpers.structs import AuthenticationCredential
WebAuthnUserDataMissing,
)
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User from authentik.core.models import User
@ -21,7 +22,7 @@ from authentik.lib.utils.http import get_client_ip
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_sms.models import SMSDevice
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import generate_challenge, get_origin from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
LOGGER = get_logger() LOGGER = get_logger()
@ -42,40 +43,26 @@ def get_challenge_for_device(request: HttpRequest, device: Device) -> dict:
return {} return {}
def get_webauthn_challenge(request: HttpRequest, device: WebAuthnDevice) -> dict: def get_webauthn_challenge(request: HttpRequest, device: Optional[WebAuthnDevice] = None) -> dict:
"""Send the client a challenge that we'll check later""" """Send the client a challenge that we'll check later"""
request.session.pop("challenge", None) request.session.pop("challenge", None)
challenge = generate_challenge(32) allowed_credentials = []
# We strip the padding from the challenge stored in the session if device:
# for the reasons outlined in the comment in webauthn_begin_activate. # We want all the user's WebAuthn devices and merge their challenges
request.session["challenge"] = challenge.rstrip("=") for user_device in WebAuthnDevice.objects.filter(user=device.user).order_by("name"):
user_device: WebAuthnDevice
allowed_credentials.append(user_device.descriptor)
assertion = {} authentication_options = generate_authentication_options(
user = device.user rp_id=get_rp_id(request),
allow_credentials=allowed_credentials,
)
# We want all the user's WebAuthn devices and merge their challenges request.session["challenge"] = authentication_options.challenge
for user_device in WebAuthnDevice.objects.filter(user=device.user).order_by("name"):
webauthn_user = WebAuthnUser(
user.uid,
user.username,
user.name,
user.avatar,
user_device.credential_id,
user_device.public_key,
user_device.sign_count,
user_device.rp_id,
)
webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge)
if assertion == {}:
assertion = webauthn_assertion_options.assertion_dict
else:
assertion["allowCredentials"] += webauthn_assertion_options.assertion_dict.get(
"allowCredentials"
)
return assertion return loads(options_to_json(authentication_options))
def select_challenge(request: HttpRequest, device: Device): def select_challenge(request: HttpRequest, device: Device):
@ -99,45 +86,32 @@ def validate_challenge_code(code: str, request: HttpRequest, user: User) -> str:
return code return code
# pylint: disable=unused-argument
def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> dict: def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> dict:
"""Validate WebAuthn Challenge""" """Validate WebAuthn Challenge"""
challenge = request.session.get("challenge") challenge = request.session.get("challenge")
assertion_response = data credential_id = data.get("id")
credential_id = assertion_response.get("id")
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first() device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
if not device: if not device:
raise ValidationError("Device does not exist.") raise ValidationError("Device does not exist.")
webauthn_user = WebAuthnUser(
user.uid,
user.username,
user.name,
user.avatar,
device.credential_id,
device.public_key,
device.sign_count,
device.rp_id,
)
webauthn_assertion_response = WebAuthnAssertionResponse(
webauthn_user,
assertion_response,
challenge,
get_origin(request),
uv_required=False,
) # User Verification
try: try:
sign_count = webauthn_assertion_response.verify() authentication_verification = verify_authentication_response(
except ( credential=AuthenticationCredential.parse_raw(dumps(data)),
AuthenticationRejectedException, expected_challenge=challenge,
WebAuthnUserDataMissing, expected_rp_id=get_rp_id(request),
RegistrationRejectedException, expected_origin=get_origin(request),
) as exc: credential_public_key=base64url_to_bytes(device.public_key),
credential_current_sign_count=device.sign_count,
require_user_verification=False,
)
except (InvalidAuthenticationResponse) as exc:
LOGGER.warning("Assertion failed", exc=exc)
raise ValidationError("Assertion failed") from exc raise ValidationError("Assertion failed") from exc
device.set_sign_count(sign_count) device.set_sign_count(authentication_verification.new_sign_count)
return data return data

View file

@ -7,6 +7,7 @@ from django.utils.encoding import force_str
from django_otp.plugins.otp_totp.models import TOTPDevice from django_otp.plugins.otp_totp.models import TOTPDevice
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from webauthn.helpers import bytes_to_base64url
from authentik.core.models import User from authentik.core.models import User
from authentik.flows.challenge import ChallengeTypes from authentik.flows.challenge import ChallengeTypes
@ -101,8 +102,8 @@ class AuthenticatorValidateStageTests(APITestCase):
webauthn_device = WebAuthnDevice.objects.create( webauthn_device = WebAuthnDevice.objects.create(
user=self.user, user=self.user,
public_key="qwerqwerqre", public_key=bytes_to_base64url(b"qwerqwerqre"),
credential_id="foobarbaz", credential_id=bytes_to_base64url(b"foobarbaz"),
sign_count=0, sign_count=0,
rp_id="foo", rp_id="foo",
) )
@ -113,14 +114,13 @@ class AuthenticatorValidateStageTests(APITestCase):
{ {
"allowCredentials": [ "allowCredentials": [
{ {
"id": "foobarbaz", "id": "Zm9vYmFyYmF6",
"transports": ["usb", "nfc", "ble", "internal"],
"type": "public-key", "type": "public-key",
} }
], ],
"rpId": "foo", "rpId": "testserver",
"timeout": 60000, "timeout": 60000,
"userVerification": "discouraged", "userVerification": "preferred",
}, },
) )

View file

@ -8,6 +8,8 @@ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from django_otp.models import Device from django_otp.models import Device
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer
from webauthn.helpers.base64url_to_bytes import base64url_to_bytes
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
from authentik.core.types import UserSettingSerializer from authentik.core.types import UserSettingSerializer
from authentik.flows.models import ConfigurableStage, Stage from authentik.flows.models import ConfigurableStage, Stage
@ -64,6 +66,11 @@ class WebAuthnDevice(Device):
created_on = models.DateTimeField(auto_now_add=True) created_on = models.DateTimeField(auto_now_add=True)
last_used_on = models.DateTimeField(default=now) last_used_on = models.DateTimeField(default=now)
@property
def descriptor(self) -> PublicKeyCredentialDescriptor:
"""Get a publickeydescriptor for this device"""
return PublicKeyCredentialDescriptor(id=base64url_to_bytes(self.credential_id))
def set_sign_count(self, sign_count: int) -> None: def set_sign_count(self, sign_count: int) -> None:
"""Set the sign_count and update the last_used_on datetime.""" """Set the sign_count and update the last_used_on datetime."""
self.sign_count = sign_count self.sign_count = sign_count

View file

@ -1,16 +1,22 @@
"""WebAuthn stage""" """WebAuthn stage"""
from json import dumps, loads
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict from django.http.request import QueryDict
from rest_framework.fields import CharField, JSONField from rest_framework.fields import CharField, JSONField
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from webauthn.webauthn import ( from webauthn import generate_registration_options, options_to_json, verify_registration_response
RegistrationRejectedException, from webauthn.helpers import bytes_to_base64url
WebAuthnCredential, from webauthn.helpers.exceptions import InvalidRegistrationResponse
WebAuthnMakeCredentialOptions, from webauthn.helpers.structs import (
WebAuthnRegistrationResponse, AuthenticatorSelectionCriteria,
PublicKeyCredentialCreationOptions,
RegistrationCredential,
ResidentKeyRequirement,
UserVerificationRequirement,
) )
from webauthn.registration.verify_registration_response import VerifiedRegistration
from authentik.core.models import User from authentik.core.models import User
from authentik.flows.challenge import ( from authentik.flows.challenge import (
@ -22,7 +28,7 @@ from authentik.flows.challenge import (
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import generate_challenge, get_origin, get_rp_id from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
LOGGER = get_logger() LOGGER = get_logger()
@ -47,46 +53,29 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
def validate_response(self, response: dict) -> dict: def validate_response(self, response: dict) -> dict:
"""Validate webauthn challenge response""" """Validate webauthn challenge response"""
# pylint: disable=no-name-in-module
from pydantic.error_wrappers import ValidationError as PydanticValidationError
challenge = self.request.session["challenge"] challenge = self.request.session["challenge"]
trusted_attestation_cert_required = True
self_attestation_permitted = True
none_attestation_permitted = True
webauthn_registration_response = WebAuthnRegistrationResponse(
get_rp_id(self.request),
get_origin(self.request),
response,
challenge,
trusted_attestation_cert_required=trusted_attestation_cert_required,
self_attestation_permitted=self_attestation_permitted,
none_attestation_permitted=none_attestation_permitted,
uv_required=False,
) # User Verification
try: try:
webauthn_credential = webauthn_registration_response.verify() registration: VerifiedRegistration = verify_registration_response(
except RegistrationRejectedException as exc: credential=RegistrationCredential.parse_raw(dumps(response)),
expected_challenge=challenge,
expected_rp_id=get_rp_id(self.request),
expected_origin=get_origin(self.request),
)
except (InvalidRegistrationResponse, PydanticValidationError) as exc:
LOGGER.warning("registration failed", exc=exc) LOGGER.warning("registration failed", exc=exc)
raise ValidationError(f"Registration failed. Error: {exc}") raise ValidationError(f"Registration failed. Error: {exc}")
# Step 17.
#
# Check that the credentialId is not yet registered to any other user.
# If registration is requested for a credential that is already registered
# to a different user, the Relying Party SHOULD fail this registration
# ceremony, or it MAY decide to accept the registration, e.g. while deleting
# the older registration.
credential_id_exists = WebAuthnDevice.objects.filter( credential_id_exists = WebAuthnDevice.objects.filter(
credential_id=webauthn_credential.credential_id credential_id=bytes_to_base64url(registration.credential_id)
).first() ).first()
if credential_id_exists: if credential_id_exists:
raise ValidationError("Credential ID already exists.") raise ValidationError("Credential ID already exists.")
webauthn_credential.credential_id = str(webauthn_credential.credential_id, "utf-8") return registration
webauthn_credential.public_key = str(webauthn_credential.public_key, "utf-8")
return webauthn_credential
class AuthenticatorWebAuthnStageView(ChallengeStageView): class AuthenticatorWebAuthnStageView(ChallengeStageView):
@ -98,35 +87,26 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
# clear session variables prior to starting a new registration # clear session variables prior to starting a new registration
self.request.session.pop("challenge", None) self.request.session.pop("challenge", None)
challenge = generate_challenge(32)
# We strip the saved challenge of padding, so that we can do a byte
# comparison on the URL-safe-without-padding challenge we get back
# from the browser.
# We will still pass the padded version down to the browser so that the JS
# can decode the challenge into binary without too much trouble.
self.request.session["challenge"] = challenge.rstrip("=")
user = self.get_pending_user() user = self.get_pending_user()
make_credential_options = WebAuthnMakeCredentialOptions(
challenge, registration_options: PublicKeyCredentialCreationOptions = generate_registration_options(
self.request.tenant.branding_title, rp_id=get_rp_id(self.request),
get_rp_id(self.request), rp_name=self.request.tenant.branding_title,
user.uid, user_id=user.uid,
user.username, user_name=user.username,
user.name, user_display_name=user.name,
user.avatar, authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
) )
registration_options.user.id = user.uid
registration_dict = make_credential_options.registration_dict self.request.session["challenge"] = registration_options.challenge
registration_dict["authenticatorSelection"] = {
"requireResidentKey": False,
"userVerification": "preferred",
}
return AuthenticatorWebAuthnChallenge( return AuthenticatorWebAuthnChallenge(
data={ data={
"type": ChallengeTypes.NATIVE.value, "type": ChallengeTypes.NATIVE.value,
"registration": registration_dict, "registration": loads(options_to_json(registration_options)),
} }
) )
@ -145,15 +125,15 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
# Webauthn Challenge has already been validated # Webauthn Challenge has already been validated
webauthn_credential: WebAuthnCredential = response.validated_data["response"] webauthn_credential: VerifiedRegistration = response.validated_data["response"]
existing_device = WebAuthnDevice.objects.filter( existing_device = WebAuthnDevice.objects.filter(
credential_id=webauthn_credential.credential_id credential_id=bytes_to_base64url(webauthn_credential.credential_id)
).first() ).first()
if not existing_device: if not existing_device:
WebAuthnDevice.objects.create( WebAuthnDevice.objects.create(
user=self.get_pending_user(), user=self.get_pending_user(),
public_key=webauthn_credential.public_key, public_key=bytes_to_base64url(webauthn_credential.credential_public_key),
credential_id=webauthn_credential.credential_id, credential_id=bytes_to_base64url(webauthn_credential.credential_id),
sign_count=webauthn_credential.sign_count, sign_count=webauthn_credential.sign_count,
rp_id=get_rp_id(self.request), rp_id=get_rp_id(self.request),
) )

View file

@ -1,29 +1,7 @@
"""webauthn utils""" """webauthn utils"""
import base64
import os
from django.http import HttpRequest from django.http import HttpRequest
CHALLENGE_DEFAULT_BYTE_LEN = 32
def generate_challenge(challenge_len=CHALLENGE_DEFAULT_BYTE_LEN):
"""Generate a challenge of challenge_len bytes, Base64-encoded.
We use URL-safe base64, but we *don't* strip the padding, so that
the browser can decode it without too much hassle.
Note that if we are doing byte comparisons with the challenge in collectedClientData
later on, that value will not have padding, so we must remove the padding
before storing the value in the session.
"""
# If we know Python 3.6 or greater is available, we could replace this with one
# call to secrets.token_urlsafe
challenge_bytes = os.urandom(challenge_len)
challenge_base64 = base64.urlsafe_b64encode(challenge_bytes)
# Python 2/3 compatibility: b64encode returns bytes only in newer Python versions
if not isinstance(challenge_base64, str):
challenge_base64 = challenge_base64.decode("utf-8")
return challenge_base64
def get_rp_id(request: HttpRequest) -> str: def get_rp_id(request: HttpRequest) -> str:
"""Get hostname from http request, without port""" """Get hostname from http request, without port"""

View file

@ -1,7 +1,5 @@
import * as base64js from "base64-js"; import * as base64js from "base64-js";
import { hexEncode } from "../../../utils";
export function b64enc(buf: Uint8Array): string { export function b64enc(buf: Uint8Array): string {
return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
} }
@ -33,9 +31,11 @@ export interface Assertion {
id: string; id: string;
rawId: string; rawId: string;
type: string; type: string;
attObj: string;
clientData: string;
registrationClientExtensions: string; registrationClientExtensions: string;
response: {
clientDataJSON: string;
attestationObject: string;
};
} }
/** /**
@ -55,9 +55,11 @@ export function transformNewAssertionForServer(newAssertion: PublicKeyCredential
id: newAssertion.id, id: newAssertion.id,
rawId: b64enc(rawId), rawId: b64enc(rawId),
type: newAssertion.type, type: newAssertion.type,
attObj: b64enc(attObj),
clientData: b64enc(clientDataJSON),
registrationClientExtensions: JSON.stringify(registrationClientExtensions), registrationClientExtensions: JSON.stringify(registrationClientExtensions),
response: {
clientDataJSON: b64enc(clientDataJSON),
attestationObject: b64enc(attObj),
},
}; };
} }
@ -91,10 +93,13 @@ export interface AuthAssertion {
id: string; id: string;
rawId: string; rawId: string;
type: string; type: string;
clientData: string;
authData: string;
signature: string;
assertionClientExtensions: string; assertionClientExtensions: string;
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
} }
/** /**
@ -113,9 +118,13 @@ export function transformAssertionForServer(newAssertion: PublicKeyCredential):
id: newAssertion.id, id: newAssertion.id,
rawId: b64enc(rawId), rawId: b64enc(rawId),
type: newAssertion.type, type: newAssertion.type,
authData: b64RawEnc(authData),
clientData: b64RawEnc(clientDataJSON),
signature: hexEncode(sig),
assertionClientExtensions: JSON.stringify(assertionClientExtensions), assertionClientExtensions: JSON.stringify(assertionClientExtensions),
response: {
clientDataJSON: b64RawEnc(clientDataJSON),
signature: b64RawEnc(sig),
authenticatorData: b64RawEnc(authData),
userHandle: null,
},
}; };
} }

View file

@ -60,7 +60,9 @@ export class UserSettingsAuthenticatorDuo extends BaseUserSettings {
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">
${this.configureUrl ${this.configureUrl
? html`<a ? html`<a
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}" href="${this.configureUrl}?next=/${encodeURIComponent(
"#/settings;page-stages",
)}"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
>${t`Enable Duo authenticator`} >${t`Enable Duo authenticator`}
</a>` </a>`

View file

@ -60,7 +60,9 @@ export class UserSettingsAuthenticatorSMS extends BaseUserSettings {
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">
${this.configureUrl ${this.configureUrl
? html`<a ? html`<a
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}" href="${this.configureUrl}?next=/${encodeURIComponent(
"#/settings;page-stages",
)}"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
>${t`Enable SMS authenticator`} >${t`Enable SMS authenticator`}
</a>` </a>`

View file

@ -79,7 +79,9 @@ export class UserSettingsAuthenticatorStatic extends BaseUserSettings {
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">
${this.configureUrl ${this.configureUrl
? html`<a ? html`<a
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}" href="${this.configureUrl}?next=/${encodeURIComponent(
"#/settings;page-stages",
)}"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
>${t`Enable Static Tokens`} >${t`Enable Static Tokens`}
</a>` </a>`

View file

@ -60,7 +60,9 @@ export class UserSettingsAuthenticatorTOTP extends BaseUserSettings {
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">
${this.configureUrl ${this.configureUrl
? html`<a ? html`<a
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}" href="${this.configureUrl}?next=/${encodeURIComponent(
"#/settings;page-stages",
)}"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
>${t`Enable TOTP`} >${t`Enable TOTP`}
</a>` </a>`

View file

@ -119,7 +119,9 @@ export class UserSettingsAuthenticatorWebAuthn extends BaseUserSettings {
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">
${this.configureUrl ${this.configureUrl
? html`<a ? html`<a
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}" href="${this.configureUrl}?next=/${encodeURIComponent(
"#/settings;page-stages",
)}"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
>${t`Configure WebAuthn`} >${t`Configure WebAuthn`}
</a>` </a>`