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:
parent
56a56ffdbf
commit
8040e2b6e4
5
.github/workflows/ci-main.yml
vendored
5
.github/workflows/ci-main.yml
vendored
|
@ -150,7 +150,10 @@ jobs:
|
|||
- name: prepare
|
||||
env:
|
||||
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
|
||||
run: pipenv run python -m lifecycle.migrate
|
||||
- name: checkout current code
|
||||
|
|
48
Pipfile.lock
generated
48
Pipfile.lock
generated
|
@ -80,6 +80,13 @@
|
|||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.4.1"
|
||||
},
|
||||
"asn1crypto": {
|
||||
"hashes": [
|
||||
"sha256:4bcdf33c861c7d40bdcd74d8e4dd7661aac320fcdf40b9a3f95b4ee12fde2fa8",
|
||||
"sha256:f4f6e119474e58e04a2b1af817eb585b4fd72bdd89b998624712b5c99be7641c"
|
||||
],
|
||||
"version": "==1.4.0"
|
||||
},
|
||||
"async-timeout": {
|
||||
"hashes": [
|
||||
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
|
||||
|
@ -489,13 +496,6 @@
|
|||
"index": "pypi",
|
||||
"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": {
|
||||
"hashes": [
|
||||
"sha256:f150bed3190d543712a17467208388d31bd8ddb49b2226fba53db8aaedb8ba89",
|
||||
|
@ -991,6 +991,34 @@
|
|||
"index": "pypi",
|
||||
"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": {
|
||||
"hashes": [
|
||||
"sha256:a0b9a3b4e5ca5517cac9f1a6e9cd30bf1aa80be74fcdf4e28eded582ecfcfbae",
|
||||
|
@ -1301,11 +1329,11 @@
|
|||
},
|
||||
"webauthn": {
|
||||
"hashes": [
|
||||
"sha256:238391b2e2cc60fb51a2cd2d2d6be149920b9af6184651353d9f95856617a9e7",
|
||||
"sha256:8ad9072ff1d6169f3be30d4dc8733ea563dd266962397bc58b40f674a6af74ac"
|
||||
"sha256:1c068b93ab0f03fc6905e42e42e6ad24caa4f2632ff13ab846d5e2ef0bf1aa37",
|
||||
"sha256:6710b8b3d846010fcf303d4fb96ca42de154aeeec379ead4e18cc582a11f9abc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.4.7"
|
||||
"version": "==1.0.0"
|
||||
},
|
||||
"websocket-client": {
|
||||
"hashes": [
|
||||
|
|
|
@ -17,13 +17,10 @@ from django.views.generic import View
|
|||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, PolymorphicProxySerializer, extend_schema
|
||||
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 sentry_sdk import capture_exception
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.api.throttle import SessionThrottle
|
||||
from authentik.core.models import USER_ATTRIBUTE_DEBUG
|
||||
from authentik.events.models import Event, EventAction, cleanse_dict
|
||||
from authentik.flows.challenge import (
|
||||
|
@ -100,33 +97,10 @@ class InvalidStageError(SentryIgnoredException):
|
|||
"""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")
|
||||
class FlowExecutorView(APIView):
|
||||
"""Stage 1 Flow executor, passing requests to Stage Views"""
|
||||
|
||||
throttle_classes = [SessionThrottle, FlowPendingUserThrottle]
|
||||
throttle_scope = "flow_executor"
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
flow: Flow
|
||||
|
|
|
@ -205,10 +205,6 @@ REST_FRAMEWORK = {
|
|||
],
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"TEST_REQUEST_DEFAULT_FORMAT": "json",
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"anon": "100/day",
|
||||
"flow_executor": "100/day",
|
||||
},
|
||||
}
|
||||
|
||||
REDIS_PROTOCOL_PREFIX = "redis://"
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
"""Validation stage challenge checking"""
|
||||
from json import dumps, loads
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.http.response import Http404
|
||||
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.serializers import ValidationError
|
||||
from structlog.stdlib import get_logger
|
||||
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser
|
||||
from webauthn.webauthn import (
|
||||
AuthenticationRejectedException,
|
||||
RegistrationRejectedException,
|
||||
WebAuthnUserDataMissing,
|
||||
)
|
||||
from webauthn import generate_authentication_options, verify_authentication_response
|
||||
from webauthn.helpers import base64url_to_bytes, options_to_json
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
from webauthn.helpers.structs import AuthenticationCredential
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
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_sms.models import SMSDevice
|
||||
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()
|
||||
|
||||
|
@ -42,40 +43,26 @@ def get_challenge_for_device(request: HttpRequest, device: Device) -> dict:
|
|||
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"""
|
||||
request.session.pop("challenge", None)
|
||||
|
||||
challenge = generate_challenge(32)
|
||||
|
||||
# We strip the padding from the challenge stored in the session
|
||||
# for the reasons outlined in the comment in webauthn_begin_activate.
|
||||
request.session["challenge"] = challenge.rstrip("=")
|
||||
|
||||
assertion = {}
|
||||
user = device.user
|
||||
allowed_credentials = []
|
||||
|
||||
if device:
|
||||
# We want all the user's WebAuthn devices and merge their challenges
|
||||
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"
|
||||
user_device: WebAuthnDevice
|
||||
allowed_credentials.append(user_device.descriptor)
|
||||
|
||||
authentication_options = generate_authentication_options(
|
||||
rp_id=get_rp_id(request),
|
||||
allow_credentials=allowed_credentials,
|
||||
)
|
||||
|
||||
return assertion
|
||||
request.session["challenge"] = authentication_options.challenge
|
||||
|
||||
return loads(options_to_json(authentication_options))
|
||||
|
||||
|
||||
def select_challenge(request: HttpRequest, device: Device):
|
||||
|
@ -99,45 +86,32 @@ def validate_challenge_code(code: str, request: HttpRequest, user: User) -> str:
|
|||
return code
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> dict:
|
||||
"""Validate WebAuthn Challenge"""
|
||||
challenge = request.session.get("challenge")
|
||||
assertion_response = data
|
||||
credential_id = assertion_response.get("id")
|
||||
credential_id = data.get("id")
|
||||
|
||||
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
|
||||
if not device:
|
||||
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,
|
||||
try:
|
||||
authentication_verification = verify_authentication_response(
|
||||
credential=AuthenticationCredential.parse_raw(dumps(data)),
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=get_rp_id(request),
|
||||
expected_origin=get_origin(request),
|
||||
credential_public_key=base64url_to_bytes(device.public_key),
|
||||
credential_current_sign_count=device.sign_count,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
webauthn_assertion_response = WebAuthnAssertionResponse(
|
||||
webauthn_user,
|
||||
assertion_response,
|
||||
challenge,
|
||||
get_origin(request),
|
||||
uv_required=False,
|
||||
) # User Verification
|
||||
|
||||
try:
|
||||
sign_count = webauthn_assertion_response.verify()
|
||||
except (
|
||||
AuthenticationRejectedException,
|
||||
WebAuthnUserDataMissing,
|
||||
RegistrationRejectedException,
|
||||
) as exc:
|
||||
except (InvalidAuthenticationResponse) as exc:
|
||||
LOGGER.warning("Assertion failed", exc=exc)
|
||||
raise ValidationError("Assertion failed") from exc
|
||||
|
||||
device.set_sign_count(sign_count)
|
||||
device.set_sign_count(authentication_verification.new_sign_count)
|
||||
return data
|
||||
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ from django.utils.encoding import force_str
|
|||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.test import APITestCase
|
||||
from webauthn.helpers import bytes_to_base64url
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.challenge import ChallengeTypes
|
||||
|
@ -101,8 +102,8 @@ class AuthenticatorValidateStageTests(APITestCase):
|
|||
|
||||
webauthn_device = WebAuthnDevice.objects.create(
|
||||
user=self.user,
|
||||
public_key="qwerqwerqre",
|
||||
credential_id="foobarbaz",
|
||||
public_key=bytes_to_base64url(b"qwerqwerqre"),
|
||||
credential_id=bytes_to_base64url(b"foobarbaz"),
|
||||
sign_count=0,
|
||||
rp_id="foo",
|
||||
)
|
||||
|
@ -113,14 +114,13 @@ class AuthenticatorValidateStageTests(APITestCase):
|
|||
{
|
||||
"allowCredentials": [
|
||||
{
|
||||
"id": "foobarbaz",
|
||||
"transports": ["usb", "nfc", "ble", "internal"],
|
||||
"id": "Zm9vYmFyYmF6",
|
||||
"type": "public-key",
|
||||
}
|
||||
],
|
||||
"rpId": "foo",
|
||||
"rpId": "testserver",
|
||||
"timeout": 60000,
|
||||
"userVerification": "discouraged",
|
||||
"userVerification": "preferred",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
@ -8,6 +8,8 @@ from django.utils.translation import gettext_lazy as _
|
|||
from django.views import View
|
||||
from django_otp.models import Device
|
||||
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.flows.models import ConfigurableStage, Stage
|
||||
|
@ -64,6 +66,11 @@ class WebAuthnDevice(Device):
|
|||
created_on = models.DateTimeField(auto_now_add=True)
|
||||
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:
|
||||
"""Set the sign_count and update the last_used_on datetime."""
|
||||
self.sign_count = sign_count
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
"""WebAuthn stage"""
|
||||
from json import dumps, loads
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from rest_framework.fields import CharField, JSONField
|
||||
from rest_framework.serializers import ValidationError
|
||||
from structlog.stdlib import get_logger
|
||||
from webauthn.webauthn import (
|
||||
RegistrationRejectedException,
|
||||
WebAuthnCredential,
|
||||
WebAuthnMakeCredentialOptions,
|
||||
WebAuthnRegistrationResponse,
|
||||
from webauthn import generate_registration_options, options_to_json, verify_registration_response
|
||||
from webauthn.helpers import bytes_to_base64url
|
||||
from webauthn.helpers.exceptions import InvalidRegistrationResponse
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialCreationOptions,
|
||||
RegistrationCredential,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
from webauthn.registration.verify_registration_response import VerifiedRegistration
|
||||
|
||||
from authentik.core.models import User
|
||||
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.stage import ChallengeStageView
|
||||
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()
|
||||
|
||||
|
@ -47,46 +53,29 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
|
|||
|
||||
def validate_response(self, response: dict) -> dict:
|
||||
"""Validate webauthn challenge response"""
|
||||
# pylint: disable=no-name-in-module
|
||||
from pydantic.error_wrappers import ValidationError as PydanticValidationError
|
||||
|
||||
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:
|
||||
webauthn_credential = webauthn_registration_response.verify()
|
||||
except RegistrationRejectedException as exc:
|
||||
registration: VerifiedRegistration = verify_registration_response(
|
||||
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)
|
||||
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=webauthn_credential.credential_id
|
||||
credential_id=bytes_to_base64url(registration.credential_id)
|
||||
).first()
|
||||
if credential_id_exists:
|
||||
raise ValidationError("Credential ID already exists.")
|
||||
|
||||
webauthn_credential.credential_id = str(webauthn_credential.credential_id, "utf-8")
|
||||
webauthn_credential.public_key = str(webauthn_credential.public_key, "utf-8")
|
||||
|
||||
return webauthn_credential
|
||||
return registration
|
||||
|
||||
|
||||
class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
|
@ -98,35 +87,26 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
|||
# clear session variables prior to starting a new registration
|
||||
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()
|
||||
make_credential_options = WebAuthnMakeCredentialOptions(
|
||||
challenge,
|
||||
self.request.tenant.branding_title,
|
||||
get_rp_id(self.request),
|
||||
user.uid,
|
||||
user.username,
|
||||
user.name,
|
||||
user.avatar,
|
||||
|
||||
registration_options: PublicKeyCredentialCreationOptions = generate_registration_options(
|
||||
rp_id=get_rp_id(self.request),
|
||||
rp_name=self.request.tenant.branding_title,
|
||||
user_id=user.uid,
|
||||
user_name=user.username,
|
||||
user_display_name=user.name,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
)
|
||||
registration_options.user.id = user.uid
|
||||
|
||||
registration_dict = make_credential_options.registration_dict
|
||||
registration_dict["authenticatorSelection"] = {
|
||||
"requireResidentKey": False,
|
||||
"userVerification": "preferred",
|
||||
}
|
||||
|
||||
self.request.session["challenge"] = registration_options.challenge
|
||||
return AuthenticatorWebAuthnChallenge(
|
||||
data={
|
||||
"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:
|
||||
# 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(
|
||||
credential_id=webauthn_credential.credential_id
|
||||
credential_id=bytes_to_base64url(webauthn_credential.credential_id)
|
||||
).first()
|
||||
if not existing_device:
|
||||
WebAuthnDevice.objects.create(
|
||||
user=self.get_pending_user(),
|
||||
public_key=webauthn_credential.public_key,
|
||||
credential_id=webauthn_credential.credential_id,
|
||||
public_key=bytes_to_base64url(webauthn_credential.credential_public_key),
|
||||
credential_id=bytes_to_base64url(webauthn_credential.credential_id),
|
||||
sign_count=webauthn_credential.sign_count,
|
||||
rp_id=get_rp_id(self.request),
|
||||
)
|
||||
|
|
|
@ -1,29 +1,7 @@
|
|||
"""webauthn utils"""
|
||||
import base64
|
||||
import os
|
||||
|
||||
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:
|
||||
"""Get hostname from http request, without port"""
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import * as base64js from "base64-js";
|
||||
|
||||
import { hexEncode } from "../../../utils";
|
||||
|
||||
export function b64enc(buf: Uint8Array): string {
|
||||
return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
@ -33,9 +31,11 @@ export interface Assertion {
|
|||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
attObj: string;
|
||||
clientData: string;
|
||||
registrationClientExtensions: string;
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
attestationObject: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -55,9 +55,11 @@ export function transformNewAssertionForServer(newAssertion: PublicKeyCredential
|
|||
id: newAssertion.id,
|
||||
rawId: b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
attObj: b64enc(attObj),
|
||||
clientData: b64enc(clientDataJSON),
|
||||
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
|
||||
response: {
|
||||
clientDataJSON: b64enc(clientDataJSON),
|
||||
attestationObject: b64enc(attObj),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -91,10 +93,13 @@ export interface AuthAssertion {
|
|||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
clientData: string;
|
||||
authData: string;
|
||||
signature: 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,
|
||||
rawId: b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
authData: b64RawEnc(authData),
|
||||
clientData: b64RawEnc(clientDataJSON),
|
||||
signature: hexEncode(sig),
|
||||
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
|
||||
|
||||
response: {
|
||||
clientDataJSON: b64RawEnc(clientDataJSON),
|
||||
signature: b64RawEnc(sig),
|
||||
authenticatorData: b64RawEnc(authData),
|
||||
userHandle: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -60,7 +60,9 @@ export class UserSettingsAuthenticatorDuo extends BaseUserSettings {
|
|||
<div class="pf-c-card__footer">
|
||||
${this.configureUrl
|
||||
? html`<a
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}"
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent(
|
||||
"#/settings;page-stages",
|
||||
)}"
|
||||
class="pf-c-button pf-m-primary"
|
||||
>${t`Enable Duo authenticator`}
|
||||
</a>`
|
||||
|
|
|
@ -60,7 +60,9 @@ export class UserSettingsAuthenticatorSMS extends BaseUserSettings {
|
|||
<div class="pf-c-card__footer">
|
||||
${this.configureUrl
|
||||
? html`<a
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}"
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent(
|
||||
"#/settings;page-stages",
|
||||
)}"
|
||||
class="pf-c-button pf-m-primary"
|
||||
>${t`Enable SMS authenticator`}
|
||||
</a>`
|
||||
|
|
|
@ -79,7 +79,9 @@ export class UserSettingsAuthenticatorStatic extends BaseUserSettings {
|
|||
<div class="pf-c-card__footer">
|
||||
${this.configureUrl
|
||||
? html`<a
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}"
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent(
|
||||
"#/settings;page-stages",
|
||||
)}"
|
||||
class="pf-c-button pf-m-primary"
|
||||
>${t`Enable Static Tokens`}
|
||||
</a>`
|
||||
|
|
|
@ -60,7 +60,9 @@ export class UserSettingsAuthenticatorTOTP extends BaseUserSettings {
|
|||
<div class="pf-c-card__footer">
|
||||
${this.configureUrl
|
||||
? html`<a
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}"
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent(
|
||||
"#/settings;page-stages",
|
||||
)}"
|
||||
class="pf-c-button pf-m-primary"
|
||||
>${t`Enable TOTP`}
|
||||
</a>`
|
||||
|
|
|
@ -119,7 +119,9 @@ export class UserSettingsAuthenticatorWebAuthn extends BaseUserSettings {
|
|||
<div class="pf-c-card__footer">
|
||||
${this.configureUrl
|
||||
? html`<a
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent("#/settings")}"
|
||||
href="${this.configureUrl}?next=/${encodeURIComponent(
|
||||
"#/settings;page-stages",
|
||||
)}"
|
||||
class="pf-c-button pf-m-primary"
|
||||
>${t`Configure WebAuthn`}
|
||||
</a>`
|
||||
|
|
Reference in a new issue