stages/authenticator_webauthn: add MDS support (#9114)

* web: align style to show current user for webauthn enroll

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ask for aaguid

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* initial MDS import

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add API

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add restriction

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix api, add actual restriction

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* default authenticator name based on aaguid

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* connect device with device type

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix typo in webauthn stage name

this typo has been around for 3 years https://github.com/goauthentik/authentik/commit/8708e487aebb0f131ee50af3a5088da927457941#diff-bb4aee4a37f4b95c8daa7beb6bf6251d8d2b6deb8c16dce0cd7cb0d6cd71900aR16

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add fido2 dep

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add CI pipeline to automate updating blob

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests, include device type

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* exclude icon for now

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add passkeys aaguid

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make special unknown device type work, add docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L
2024-04-08 12:21:26 +02:00
committed by GitHub
parent 919a190971
commit 9f6dca1170
31 changed files with 1139 additions and 208 deletions
@@ -0,0 +1,37 @@
name: authentik-gen-update-webauthn-mds
on:
workflow_dispatch:
schedule:
- cron: '30 1 1,15 * *'
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
- run: poetry run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@v6
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}
branch: update-fido-mds-client
commit-message: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs"
title: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs"
body: "stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs"
delete-branch: true
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ steps.generate_token.outputs.token }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: squash
+2
View File
@@ -51,6 +51,7 @@ from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMGroup, SCIMUser
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.tenants.models import Tenant
# Context set when the serializer is created in a blueprint context
@@ -95,6 +96,7 @@ def excluded_models() -> list[type[Model]]:
AccessToken,
RefreshToken,
Reputation,
WebAuthnDeviceType,
)
@@ -163,6 +163,7 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -
stage=stage_view.executor.current_stage,
device=device,
device_class=DeviceClasses.WEBAUTHN.value,
device_type=device.device_type,
)
raise ValidationError("Assertion failed") from exc
@@ -398,5 +398,6 @@ class AuthenticatorValidateStageView(ChallengeStageView):
self.executor.plan.context[PLAN_CONTEXT_METHOD] = "auth_webauthn_pwl"
self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS] = {
"device": webauthn_device,
"device_type": webauthn_device.device_type,
}
return self.set_valid_mfa_cookie(response.device)
@@ -0,0 +1,27 @@
"""WebAuthnDeviceType API Views"""
from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.flows.api.stages import StageSerializer
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
class WebAuthnDeviceTypeSerializer(StageSerializer):
"""WebAuthnDeviceType Serializer"""
class Meta:
model = WebAuthnDeviceType
fields = [
"aaguid",
"description",
]
class WebAuthnDeviceTypeViewSet(ReadOnlyModelViewSet):
"""WebAuthnDeviceType Viewset"""
queryset = WebAuthnDeviceType.objects.all()
serializer_class = WebAuthnDeviceTypeSerializer
filterset_fields = "__all__"
ordering = ["description"]
search_fields = ["description", "aaguid"]
@@ -1,4 +1,4 @@
"""AuthenticateWebAuthnStage API Views"""
"""AuthenticatorWebAuthnStage API Views"""
from django_filters.rest_framework.backends import DjangoFilterBackend
from rest_framework import mixins
@@ -9,41 +9,18 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet
from authentik.api.authorization import OwnerFilter, OwnerPermissions
from authentik.core.api.used_by import UsedByMixin
from authentik.flows.api.stages import StageSerializer
from authentik.stages.authenticator_webauthn.models import AuthenticateWebAuthnStage, WebAuthnDevice
class AuthenticateWebAuthnStageSerializer(StageSerializer):
"""AuthenticateWebAuthnStage Serializer"""
class Meta:
model = AuthenticateWebAuthnStage
fields = StageSerializer.Meta.fields + [
"configure_flow",
"friendly_name",
"user_verification",
"authenticator_attachment",
"resident_key_requirement",
]
class AuthenticateWebAuthnStageViewSet(UsedByMixin, ModelViewSet):
"""AuthenticateWebAuthnStage Viewset"""
queryset = AuthenticateWebAuthnStage.objects.all()
serializer_class = AuthenticateWebAuthnStageSerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]
from authentik.stages.authenticator_webauthn.api.device_types import WebAuthnDeviceTypeSerializer
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
class WebAuthnDeviceSerializer(ModelSerializer):
"""Serializer for WebAuthn authenticator devices"""
device_type = WebAuthnDeviceTypeSerializer(read_only=True, allow_null=True)
class Meta:
model = WebAuthnDevice
fields = ["pk", "name", "created_on"]
depth = 2
fields = ["pk", "name", "created_on", "device_type"]
class WebAuthnDeviceViewSet(
@@ -0,0 +1,38 @@
"""AuthenticatorWebAuthnStage API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.flows.api.stages import StageSerializer
from authentik.stages.authenticator_webauthn.api.device_types import WebAuthnDeviceTypeSerializer
from authentik.stages.authenticator_webauthn.models import AuthenticatorWebAuthnStage
class AuthenticatorWebAuthnStageSerializer(StageSerializer):
"""AuthenticatorWebAuthnStage Serializer"""
device_type_restrictions_obj = WebAuthnDeviceTypeSerializer(
source="device_type_restrictions", many=True, read_only=True
)
class Meta:
model = AuthenticatorWebAuthnStage
fields = StageSerializer.Meta.fields + [
"configure_flow",
"friendly_name",
"user_verification",
"authenticator_attachment",
"resident_key_requirement",
"device_type_restrictions",
"device_type_restrictions_obj",
]
class AuthenticatorWebAuthnStageViewSet(UsedByMixin, ModelViewSet):
"""AuthenticatorWebAuthnStage Viewset"""
queryset = AuthenticatorWebAuthnStage.objects.all()
serializer_class = AuthenticatorWebAuthnStageSerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]
@@ -1,11 +1,22 @@
"""authentik webauthn app config"""
from django.apps import AppConfig
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikStageAuthenticatorWebAuthnConfig(AppConfig):
class AuthentikStageAuthenticatorWebAuthnConfig(ManagedAppConfig):
"""authentik webauthn config"""
name = "authentik.stages.authenticator_webauthn"
label = "authentik_stages_authenticator_webauthn"
verbose_name = "authentik Stages.Authenticator.WebAuthn"
default = True
@ManagedAppConfig.reconcile_tenant
def webauthn_device_types(self):
from authentik.stages.authenticator_webauthn.tasks import (
webauthn_aaguid_import,
webauthn_mds_import,
)
webauthn_mds_import.delay()
webauthn_aaguid_import.delay()
@@ -0,0 +1,34 @@
from django.core.management.base import BaseCommand
from fido2.mds3 import parse_blob
from structlog.stdlib import get_logger
from authentik.lib.utils.http import get_http_session
from authentik.stages.authenticator_webauthn.tasks import AAGUID_BLOB_PATH, MDS_BLOB_PATH, mds_ca
MDS3_URL = "https://mds3.fidoalliance.org/"
AAGUID_URL = "https://passkeydeveloper.github.io/passkey-authenticator-aaguids/aaguid.json"
class Command(BaseCommand):
"""Update FIDO Alliances' MDS3 blob and validate it."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.logger = get_logger()
def update_fido_mds(self):
with open(MDS_BLOB_PATH, "w", encoding="utf-8") as _raw_file:
_raw_file.write(get_http_session().get(MDS3_URL).text)
self.logger.info("Updated MDS blob")
with open(MDS_BLOB_PATH, mode="rb") as _raw_blob:
parse_blob(_raw_blob.read(), mds_ca())
self.logger.info("Successfully validated MDS blob")
def update_passkey_aaguids(self):
with open(AAGUID_BLOB_PATH, "w", encoding="utf-8") as _raw_file:
_raw_file.write(get_http_session().get(AAGUID_URL).text)
self.logger.info("Updated AAGUID blob")
def handle(self, *args, **options):
self.update_fido_mds()
self.update_passkey_aaguids()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
# Generated by Django 5.0.3 on 2024-04-02 23:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0027_auto_20231028_1424"),
("authentik_stages_authenticator_webauthn", "0009_authenticatewebauthnstage_friendly_name"),
]
operations = [
migrations.CreateModel(
name="WebAuthnDeviceType",
fields=[
("aaguid", models.UUIDField(primary_key=True, serialize=False, unique=True)),
("description", models.TextField()),
("icon", models.TextField(null=True)),
],
options={
"verbose_name": "WebAuthn Device type",
"verbose_name_plural": "WebAuthn Device types",
},
),
migrations.RenameModel("AuthenticateWebAuthnStage", "AuthenticatorWebAuthnStage"),
migrations.AddField(
model_name="webauthndevice",
name="device_type",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_stages_authenticator_webauthn.webauthndevicetype",
),
),
migrations.AddField(
model_name="authenticatorwebauthnstage",
name="device_type_restrictions",
field=models.ManyToManyField(
blank=True, to="authentik_stages_authenticator_webauthn.webauthndevicetype"
),
),
]
@@ -14,6 +14,8 @@ from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
from authentik.lib.models import SerializerModel
from authentik.stages.authenticator.models import Device
UNKNOWN_DEVICE_TYPE_AAGUID = "00000000-0000-0000-0000-000000000000"
class UserVerification(models.TextChoices):
"""The degree to which the Relying Party wishes to verify a user's identity.
@@ -65,7 +67,7 @@ class AuthenticatorAttachment(models.TextChoices):
CROSS_PLATFORM = "cross-platform"
class AuthenticateWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
"""WebAuthn stage"""
user_verification = models.TextField(
@@ -80,11 +82,15 @@ class AuthenticateWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
choices=AuthenticatorAttachment.choices, default=None, null=True
)
device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.stages.authenticator_webauthn.api import AuthenticateWebAuthnStageSerializer
from authentik.stages.authenticator_webauthn.api.stages import (
AuthenticatorWebAuthnStageSerializer,
)
return AuthenticateWebAuthnStageSerializer
return AuthenticatorWebAuthnStageSerializer
@property
def view(self) -> type[View]:
@@ -126,6 +132,10 @@ class WebAuthnDevice(SerializerModel, Device):
created_on = models.DateTimeField(auto_now_add=True)
last_t = models.DateTimeField(default=now)
device_type = models.ForeignKey(
"WebAuthnDeviceType", on_delete=models.SET_DEFAULT, null=True, default=None
)
@property
def descriptor(self) -> PublicKeyCredentialDescriptor:
"""Get a publickeydescriptor for this device"""
@@ -139,7 +149,7 @@ class WebAuthnDevice(SerializerModel, Device):
@property
def serializer(self) -> Serializer:
from authentik.stages.authenticator_webauthn.api import WebAuthnDeviceSerializer
from authentik.stages.authenticator_webauthn.api.devices import WebAuthnDeviceSerializer
return WebAuthnDeviceSerializer
@@ -149,3 +159,27 @@ class WebAuthnDevice(SerializerModel, Device):
class Meta:
verbose_name = _("WebAuthn Device")
verbose_name_plural = _("WebAuthn Devices")
class WebAuthnDeviceType(SerializerModel):
"""WebAuthn device type, used to restrict which device types are allowed"""
aaguid = models.UUIDField(primary_key=True, unique=True)
description = models.TextField()
icon = models.TextField(null=True)
@property
def serializer(self) -> Serializer:
from authentik.stages.authenticator_webauthn.api.device_types import (
WebAuthnDeviceTypeSerializer,
)
return WebAuthnDeviceTypeSerializer
class Meta:
verbose_name = _("WebAuthn Device type")
verbose_name_plural = _("WebAuthn Device types")
def __str__(self) -> str:
return f"WebAuthn device type {self.description} ({self.aaguid})"
@@ -1,15 +1,18 @@
"""WebAuthn stage"""
from json import loads
from uuid import UUID
from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
from django.utils.translation import gettext_lazy as _
from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError
from webauthn import options_to_json
from webauthn.helpers.bytes_to_base64url import bytes_to_base64url
from webauthn.helpers.exceptions import InvalidRegistrationResponse
from webauthn.helpers.structs import (
AttestationConveyancePreference,
AuthenticatorAttachment,
AuthenticatorSelectionCriteria,
PublicKeyCredentialCreationOptions,
@@ -31,7 +34,12 @@ from authentik.flows.challenge import (
WithUserInfoChallenge,
)
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_webauthn.models import AuthenticateWebAuthnStage, WebAuthnDevice
from authentik.stages.authenticator_webauthn.models import (
UNKNOWN_DEVICE_TYPE_AAGUID,
AuthenticatorWebAuthnStage,
WebAuthnDevice,
WebAuthnDeviceType,
)
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge"
@@ -74,6 +82,30 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
if credential_id_exists:
raise ValidationError("Credential ID already exists.")
stage: AuthenticatorWebAuthnStage = self.stage.executor.current_stage
aaguid = registration.aaguid
allowed_aaguids = stage.device_type_restrictions.values_list("aaguid", flat=True)
if allowed_aaguids.exists():
invalid_error = ValidationError(
_(
"Invalid device type. Contact your {brand} administrator for help.".format(
brand=self.stage.request.brand.branding_title
)
)
)
# If there are any restrictions set and we didn't get an aaguid, invalid
if not aaguid:
raise invalid_error
# If one of the restrictions is the "special" unknown device type UUID
# but we do have a device type for the given aaguid, invalid
if (
UUID(UNKNOWN_DEVICE_TYPE_AAGUID) in allowed_aaguids
and not WebAuthnDeviceType.objects.filter(aaguid=aaguid).exists()
):
return registration
# Otherwise just check if the given aaguid is in the allowed aaguids
if UUID(aaguid) not in allowed_aaguids:
raise invalid_error
return registration
@@ -85,7 +117,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
def get_challenge(self, *args, **kwargs) -> Challenge:
# clear session variables prior to starting a new registration
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
stage: AuthenticateWebAuthnStage = self.executor.current_stage
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
user = self.get_pending_user()
# library accepts none so we store null in the database, but if there is a value
@@ -105,6 +137,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
user_verification=UserVerificationRequirement(str(stage.user_verification)),
authenticator_attachment=authenticator_attachment,
),
attestation=AttestationConveyancePreference.DIRECT,
)
self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = registration_options.challenge
@@ -129,13 +162,20 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
credential_id=bytes_to_base64url(webauthn_credential.credential_id)
).first()
if not existing_device:
name = "WebAuthn Device"
device_type = WebAuthnDeviceType.objects.filter(
aaguid=webauthn_credential.aaguid
).first()
if device_type and device_type.description:
name = device_type.description
WebAuthnDevice.objects.create(
name=name,
user=self.get_pending_user(),
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),
name="WebAuthn Device",
device_type=device_type,
)
else:
return self.executor.stage_invalid("Device with Credential ID already exists.")
@@ -0,0 +1,69 @@
"""MDS Helpers"""
from functools import lru_cache
from json import loads
from pathlib import Path
from django.core.cache import cache
from django.db.transaction import atomic
from fido2.mds3 import filter_revoked, parse_blob
from authentik.root.celery import CELERY_APP
from authentik.stages.authenticator_webauthn.models import (
UNKNOWN_DEVICE_TYPE_AAGUID,
WebAuthnDeviceType,
)
CACHE_KEY_MDS_NO = "goauthentik.io/stages/authenticator_webauthn/mds_no"
AAGUID_BLOB_PATH = Path(__file__).parent / "mds" / "aaguid.json"
MDS_BLOB_PATH = Path(__file__).parent / "mds" / "blob.jwt"
MDS_CA_PATH = Path(__file__).parent / "mds" / "root-r3.crt"
@lru_cache
def mds_ca() -> bytes:
"""Cache MDS Signature CA, GlobalSign Root CA - R3"""
with open(MDS_CA_PATH, mode="rb") as _raw_root:
return _raw_root.read()
@CELERY_APP.task()
def webauthn_mds_import(force=False):
"""Background task to import FIDO Alliance MDS blob into database"""
with open(MDS_BLOB_PATH, mode="rb") as _raw_blob:
blob = parse_blob(_raw_blob.read(), mds_ca())
with atomic():
WebAuthnDeviceType.objects.update_or_create(
aaguid=UNKNOWN_DEVICE_TYPE_AAGUID,
defaults={
"description": "authentik: Unknown devices",
},
)
if cache.get(CACHE_KEY_MDS_NO) == blob.no and not force:
return
for entry in blob.entries:
aaguid = entry.aaguid
if not aaguid:
continue
if not filter_revoked(entry):
WebAuthnDeviceType.objects.filter(aaguid=str(aaguid)).delete()
continue
metadata = entry.metadata_statement
WebAuthnDeviceType.objects.update_or_create(
aaguid=str(aaguid),
defaults={"description": metadata.description, "icon": metadata.icon},
)
cache.set(CACHE_KEY_MDS_NO, blob.no)
@CELERY_APP.task()
def webauthn_aaguid_import(force=False):
"""Background task to import AAGUIDs into database"""
with open(AAGUID_BLOB_PATH, mode="rb") as _raw_blob:
entries = loads(_raw_blob.read())
with atomic():
for aaguid, details in entries.items():
WebAuthnDeviceType.objects.update_or_create(
aaguid=str(aaguid),
defaults={"description": details.get("name"), "icon": details.get("icon_light")},
)
@@ -12,15 +12,24 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.stages.authenticator_webauthn.models import AuthenticateWebAuthnStage, WebAuthnDevice
from authentik.stages.authenticator_webauthn.models import (
UNKNOWN_DEVICE_TYPE_AAGUID,
AuthenticatorWebAuthnStage,
WebAuthnDevice,
WebAuthnDeviceType,
)
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.tasks import (
webauthn_aaguid_import,
webauthn_mds_import,
)
class TestAuthenticatorWebAuthnStage(FlowTestCase):
"""Test WebAuthn API"""
def setUp(self) -> None:
self.stage = AuthenticateWebAuthnStage.objects.create(
self.stage = AuthenticatorWebAuthnStage.objects.create(
name=generate_id(),
)
self.flow = create_test_flow()
@@ -46,10 +55,6 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"o90Yh1osqW3mjGift+6WclWOya5lcdff/G0mqueN3hChacMUz"
b"V4mxiDafuQ0x0e1d/fcPai0fx/jMBZ8/nG2qQ=="
)
session.save()
response = self.client.get(
@@ -87,6 +92,218 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
"requireResidentKey": False,
"userVerification": "preferred",
},
"attestation": "none",
"attestation": "direct",
},
)
def test_register(self):
"""Test registration"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session.save()
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
def test_register_restricted_device_type_deny(self):
"""Test registration with restricted devices (fail)"""
webauthn_mds_import(force=True)
webauthn_aaguid_import()
self.stage.device_type_restrictions.set(
WebAuthnDeviceType.objects.filter(
description="Android Authenticator with SafetyNet Attestation"
)
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session.save()
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-authenticator-webauthn",
response_errors={
"response": [
{
"string": (
"Invalid device type. Contact your authentik administrator for help."
),
"code": "invalid",
}
]
},
)
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
def test_register_restricted_device_type_allow(self):
"""Test registration with restricted devices (allow)"""
webauthn_mds_import(force=True)
webauthn_aaguid_import()
self.stage.device_type_restrictions.set(
WebAuthnDeviceType.objects.filter(description="iCloud Keychain")
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session.save()
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
def test_register_restricted_device_type_allow_unknown(self):
"""Test registration with restricted devices (allow, unknown device type)"""
webauthn_mds_import(force=True)
webauthn_aaguid_import()
WebAuthnDeviceType.objects.filter(description="iCloud Keychain").delete()
self.stage.device_type_restrictions.set(
WebAuthnDeviceType.objects.filter(aaguid=UNKNOWN_DEVICE_TYPE_AAGUID)
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session.save()
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmFsc2V9"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
print(response.content)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
@@ -1,13 +1,15 @@
"""API URLs"""
from authentik.stages.authenticator_webauthn.api import (
AuthenticateWebAuthnStageViewSet,
from authentik.stages.authenticator_webauthn.api.device_types import WebAuthnDeviceTypeViewSet
from authentik.stages.authenticator_webauthn.api.devices import (
WebAuthnAdminDeviceViewSet,
WebAuthnDeviceViewSet,
)
from authentik.stages.authenticator_webauthn.api.stages import AuthenticatorWebAuthnStageViewSet
api_urlpatterns = [
("stages/authenticator/webauthn", AuthenticateWebAuthnStageViewSet),
("stages/authenticator/webauthn", AuthenticatorWebAuthnStageViewSet),
("stages/authenticator/webauthn_device_types", WebAuthnDeviceTypeViewSet),
(
"authenticators/admin/webauthn",
WebAuthnAdminDeviceViewSet,
@@ -17,7 +17,7 @@ entries:
identifiers:
name: default-authenticator-webauthn-setup
id: default-authenticator-webauthn-setup
model: authentik_stages_authenticator_webauthn.authenticatewebauthnstage
model: authentik_stages_authenticator_webauthn.authenticatorwebauthnstage
- identifiers:
order: 0
stage: !KeyOf default-authenticator-webauthn-setup
+12 -5
View File
@@ -1566,7 +1566,7 @@
],
"properties": {
"model": {
"const": "authentik_stages_authenticator_webauthn.authenticatewebauthnstage"
"const": "authentik_stages_authenticator_webauthn.authenticatorwebauthnstage"
},
"id": {
"type": "string"
@@ -1588,10 +1588,10 @@
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_stages_authenticator_webauthn.authenticatewebauthnstage"
"$ref": "#/$defs/model_authentik_stages_authenticator_webauthn.authenticatorwebauthnstage"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_stages_authenticator_webauthn.authenticatewebauthnstage"
"$ref": "#/$defs/model_authentik_stages_authenticator_webauthn.authenticatorwebauthnstage"
}
}
},
@@ -3354,7 +3354,7 @@
"authentik_stages_authenticator_totp.authenticatortotpstage",
"authentik_stages_authenticator_totp.totpdevice",
"authentik_stages_authenticator_validate.authenticatorvalidatestage",
"authentik_stages_authenticator_webauthn.authenticatewebauthnstage",
"authentik_stages_authenticator_webauthn.authenticatorwebauthnstage",
"authentik_stages_authenticator_webauthn.webauthndevice",
"authentik_stages_captcha.captchastage",
"authentik_stages_consent.consentstage",
@@ -5645,7 +5645,7 @@
},
"required": []
},
"model_authentik_stages_authenticator_webauthn.authenticatewebauthnstage": {
"model_authentik_stages_authenticator_webauthn.authenticatorwebauthnstage": {
"type": "object",
"properties": {
"name": {
@@ -5778,6 +5778,13 @@
"required"
],
"title": "Resident key requirement"
},
"device_type_restrictions": {
"type": "array",
"items": {
"type": "integer"
},
"title": "Device type restrictions"
}
},
"required": []
Generated
+18 -1
View File
@@ -1431,6 +1431,23 @@ files = [
[package.dependencies]
requests = "*"
[[package]]
name = "fido2"
version = "1.1.3"
description = "FIDO2/WebAuthn library for implementing clients and servers."
optional = false
python-versions = ">=3.8,<4.0"
files = [
{file = "fido2-1.1.3-py3-none-any.whl", hash = "sha256:6be34c0b9fe85e4911fd2d103cce7ae8ce2f064384a7a2a3bd970b3ef7702931"},
{file = "fido2-1.1.3.tar.gz", hash = "sha256:26100f226d12ced621ca6198528ce17edf67b78df4287aee1285fee3cd5aa9fc"},
]
[package.dependencies]
cryptography = ">=2.6,<35 || >35,<45"
[package.extras]
pcsc = ["pyscard (>=1.9,<3)"]
[[package]]
name = "flower"
version = "2.0.1"
@@ -4636,4 +4653,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "~3.12"
content-hash = "c5a36b528980277b07f80200da251a2bea31cc2b7d5438250706f23a825f3628"
content-hash = "4544b2a0b0065aa9e13d9a3b5a951fb5212921fe72f0fe259069e2e9205e9830"
+1
View File
@@ -108,6 +108,7 @@ drf-spectacular = "*"
dumb-init = "*"
duo-client = "*"
facebook-sdk = "*"
fido2 = "*"
flower = "*"
geoip2 = "*"
gunicorn = "*"
+317 -144
View File
@@ -18405,7 +18405,7 @@ paths:
- authentik_stages_authenticator_totp.authenticatortotpstage
- authentik_stages_authenticator_totp.totpdevice
- authentik_stages_authenticator_validate.authenticatorvalidatestage
- authentik_stages_authenticator_webauthn.authenticatewebauthnstage
- authentik_stages_authenticator_webauthn.authenticatorwebauthnstage
- authentik_stages_authenticator_webauthn.webauthndevice
- authentik_stages_captcha.captchastage
- authentik_stages_consent.consentstage
@@ -18619,7 +18619,7 @@ paths:
- authentik_stages_authenticator_totp.authenticatortotpstage
- authentik_stages_authenticator_totp.totpdevice
- authentik_stages_authenticator_validate.authenticatorvalidatestage
- authentik_stages_authenticator_webauthn.authenticatewebauthnstage
- authentik_stages_authenticator_webauthn.authenticatorwebauthnstage
- authentik_stages_authenticator_webauthn.webauthndevice
- authentik_stages_captcha.captchastage
- authentik_stages_consent.consentstage
@@ -24007,7 +24007,7 @@ paths:
/stages/authenticator/webauthn/:
get:
operationId: stages_authenticator_webauthn_list
description: AuthenticateWebAuthnStage Viewset
description: AuthenticatorWebAuthnStage Viewset
parameters:
- in: query
name: authenticator_attachment
@@ -24022,6 +24022,15 @@ paths:
schema:
type: string
format: uuid
- in: query
name: device_type_restrictions
schema:
type: array
items:
type: string
format: uuid
explode: true
style: form
- in: query
name: friendly_name
schema:
@@ -24084,7 +24093,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedAuthenticateWebAuthnStageList'
$ref: '#/components/schemas/PaginatedAuthenticatorWebAuthnStageList'
description: ''
'400':
content:
@@ -24100,14 +24109,14 @@ paths:
description: ''
post:
operationId: stages_authenticator_webauthn_create
description: AuthenticateWebAuthnStage Viewset
description: AuthenticatorWebAuthnStage Viewset
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthenticateWebAuthnStageRequest'
$ref: '#/components/schemas/AuthenticatorWebAuthnStageRequest'
required: true
security:
- authentik: []
@@ -24116,7 +24125,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthenticateWebAuthnStage'
$ref: '#/components/schemas/AuthenticatorWebAuthnStage'
description: ''
'400':
content:
@@ -24133,7 +24142,7 @@ paths:
/stages/authenticator/webauthn/{stage_uuid}/:
get:
operationId: stages_authenticator_webauthn_retrieve
description: AuthenticateWebAuthnStage Viewset
description: AuthenticatorWebAuthnStage Viewset
parameters:
- in: path
name: stage_uuid
@@ -24151,7 +24160,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthenticateWebAuthnStage'
$ref: '#/components/schemas/AuthenticatorWebAuthnStage'
description: ''
'400':
content:
@@ -24167,7 +24176,7 @@ paths:
description: ''
put:
operationId: stages_authenticator_webauthn_update
description: AuthenticateWebAuthnStage Viewset
description: AuthenticatorWebAuthnStage Viewset
parameters:
- in: path
name: stage_uuid
@@ -24182,7 +24191,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthenticateWebAuthnStageRequest'
$ref: '#/components/schemas/AuthenticatorWebAuthnStageRequest'
required: true
security:
- authentik: []
@@ -24191,7 +24200,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthenticateWebAuthnStage'
$ref: '#/components/schemas/AuthenticatorWebAuthnStage'
description: ''
'400':
content:
@@ -24207,7 +24216,7 @@ paths:
description: ''
patch:
operationId: stages_authenticator_webauthn_partial_update
description: AuthenticateWebAuthnStage Viewset
description: AuthenticatorWebAuthnStage Viewset
parameters:
- in: path
name: stage_uuid
@@ -24222,7 +24231,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedAuthenticateWebAuthnStageRequest'
$ref: '#/components/schemas/PatchedAuthenticatorWebAuthnStageRequest'
security:
- authentik: []
responses:
@@ -24230,7 +24239,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthenticateWebAuthnStage'
$ref: '#/components/schemas/AuthenticatorWebAuthnStage'
description: ''
'400':
content:
@@ -24246,7 +24255,7 @@ paths:
description: ''
delete:
operationId: stages_authenticator_webauthn_destroy
description: AuthenticateWebAuthnStage Viewset
description: AuthenticatorWebAuthnStage Viewset
parameters:
- in: path
name: stage_uuid
@@ -24311,6 +24320,106 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/authenticator/webauthn_device_types/:
get:
operationId: stages_authenticator_webauthn_device_types_list
description: WebAuthnDeviceType Viewset
parameters:
- in: query
name: aaguid
schema:
type: string
format: uuid
- in: query
name: description
schema:
type: string
- in: query
name: icon
schema:
type: string
- name: ordering
required: false
in: query
description: Which field to use when ordering the results.
schema:
type: string
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: search
required: false
in: query
description: A search term.
schema:
type: string
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedWebAuthnDeviceTypeList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/authenticator/webauthn_device_types/{aaguid}/:
get:
operationId: stages_authenticator_webauthn_device_types_retrieve
description: WebAuthnDeviceType Viewset
parameters:
- in: path
name: aaguid
schema:
type: string
format: uuid
description: A UUID string identifying this WebAuthn Device type.
required: true
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/WebAuthnDeviceType'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/captcha/:
get:
operationId: stages_captcha_list
@@ -29738,92 +29847,6 @@ components:
- basic
- bearer
type: string
AuthenticateWebAuthnStage:
type: object
description: AuthenticateWebAuthnStage Serializer
properties:
pk:
type: string
format: uuid
readOnly: true
title: Stage uuid
name:
type: string
component:
type: string
description: Get object type so that we know how to edit the object
readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
readOnly: true
verbose_name_plural:
type: string
description: Return object's plural verbose_name
readOnly: true
meta_model_name:
type: string
description: Return internal model name
readOnly: true
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSet'
configure_flow:
type: string
format: uuid
nullable: true
description: Flow used by an authenticated user to configure this Stage.
If empty, user will not be able to configure this stage.
friendly_name:
type: string
nullable: true
user_verification:
$ref: '#/components/schemas/UserVerificationEnum'
authenticator_attachment:
allOf:
- $ref: '#/components/schemas/AuthenticatorAttachmentEnum'
nullable: true
resident_key_requirement:
$ref: '#/components/schemas/ResidentKeyRequirementEnum'
required:
- component
- meta_model_name
- name
- pk
- verbose_name
- verbose_name_plural
AuthenticateWebAuthnStageRequest:
type: object
description: AuthenticateWebAuthnStage Serializer
properties:
name:
type: string
minLength: 1
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSetRequest'
configure_flow:
type: string
format: uuid
nullable: true
description: Flow used by an authenticated user to configure this Stage.
If empty, user will not be able to configure this stage.
friendly_name:
type: string
nullable: true
minLength: 1
user_verification:
$ref: '#/components/schemas/UserVerificationEnum'
authenticator_attachment:
allOf:
- $ref: '#/components/schemas/AuthenticatorAttachmentEnum'
nullable: true
resident_key_requirement:
$ref: '#/components/schemas/ResidentKeyRequirementEnum'
required:
- name
AuthenticatedSession:
type: object
description: AuthenticatedSession Serializer
@@ -30738,6 +30761,108 @@ components:
additionalProperties: {}
required:
- response
AuthenticatorWebAuthnStage:
type: object
description: AuthenticatorWebAuthnStage Serializer
properties:
pk:
type: string
format: uuid
readOnly: true
title: Stage uuid
name:
type: string
component:
type: string
description: Get object type so that we know how to edit the object
readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
readOnly: true
verbose_name_plural:
type: string
description: Return object's plural verbose_name
readOnly: true
meta_model_name:
type: string
description: Return internal model name
readOnly: true
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSet'
configure_flow:
type: string
format: uuid
nullable: true
description: Flow used by an authenticated user to configure this Stage.
If empty, user will not be able to configure this stage.
friendly_name:
type: string
nullable: true
user_verification:
$ref: '#/components/schemas/UserVerificationEnum'
authenticator_attachment:
allOf:
- $ref: '#/components/schemas/AuthenticatorAttachmentEnum'
nullable: true
resident_key_requirement:
$ref: '#/components/schemas/ResidentKeyRequirementEnum'
device_type_restrictions:
type: array
items:
type: string
format: uuid
device_type_restrictions_obj:
type: array
items:
$ref: '#/components/schemas/WebAuthnDeviceType'
readOnly: true
required:
- component
- device_type_restrictions_obj
- meta_model_name
- name
- pk
- verbose_name
- verbose_name_plural
AuthenticatorWebAuthnStageRequest:
type: object
description: AuthenticatorWebAuthnStage Serializer
properties:
name:
type: string
minLength: 1
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSetRequest'
configure_flow:
type: string
format: uuid
nullable: true
description: Flow used by an authenticated user to configure this Stage.
If empty, user will not be able to configure this stage.
friendly_name:
type: string
nullable: true
minLength: 1
user_verification:
$ref: '#/components/schemas/UserVerificationEnum'
authenticator_attachment:
allOf:
- $ref: '#/components/schemas/AuthenticatorAttachmentEnum'
nullable: true
resident_key_requirement:
$ref: '#/components/schemas/ResidentKeyRequirementEnum'
device_type_restrictions:
type: array
items:
type: string
format: uuid
required:
- name
AutoSubmitChallengeResponseRequest:
type: object
description: Pseudo class for autosubmit response
@@ -34706,7 +34831,7 @@ components:
- authentik_stages_authenticator_totp.authenticatortotpstage
- authentik_stages_authenticator_totp.totpdevice
- authentik_stages_authenticator_validate.authenticatorvalidatestage
- authentik_stages_authenticator_webauthn.authenticatewebauthnstage
- authentik_stages_authenticator_webauthn.authenticatorwebauthnstage
- authentik_stages_authenticator_webauthn.webauthndevice
- authentik_stages_captcha.captchastage
- authentik_stages_consent.consentstage
@@ -35673,18 +35798,6 @@ components:
required:
- pagination
- results
PaginatedAuthenticateWebAuthnStageList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/AuthenticateWebAuthnStage'
required:
- pagination
- results
PaginatedAuthenticatedSessionList:
type: object
properties:
@@ -35757,6 +35870,18 @@ components:
required:
- pagination
- results
PaginatedAuthenticatorWebAuthnStageList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/AuthenticatorWebAuthnStage'
required:
- pagination
- results
PaginatedBlueprintInstanceList:
type: object
properties:
@@ -36825,6 +36950,18 @@ components:
required:
- pagination
- results
PaginatedWebAuthnDeviceTypeList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/WebAuthnDeviceType'
required:
- pagination
- results
Pagination:
type: object
properties:
@@ -37230,35 +37367,6 @@ components:
$ref: '#/components/schemas/PolicyEngineMode'
group:
type: string
PatchedAuthenticateWebAuthnStageRequest:
type: object
description: AuthenticateWebAuthnStage Serializer
properties:
name:
type: string
minLength: 1
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSetRequest'
configure_flow:
type: string
format: uuid
nullable: true
description: Flow used by an authenticated user to configure this Stage.
If empty, user will not be able to configure this stage.
friendly_name:
type: string
nullable: true
minLength: 1
user_verification:
$ref: '#/components/schemas/UserVerificationEnum'
authenticator_attachment:
allOf:
- $ref: '#/components/schemas/AuthenticatorAttachmentEnum'
nullable: true
resident_key_requirement:
$ref: '#/components/schemas/ResidentKeyRequirementEnum'
PatchedAuthenticatorDuoStageRequest:
type: object
description: AuthenticatorDuoStage Serializer
@@ -37428,6 +37536,40 @@ components:
allOf:
- $ref: '#/components/schemas/UserVerificationEnum'
description: Enforce user verification for WebAuthn devices.
PatchedAuthenticatorWebAuthnStageRequest:
type: object
description: AuthenticatorWebAuthnStage Serializer
properties:
name:
type: string
minLength: 1
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSetRequest'
configure_flow:
type: string
format: uuid
nullable: true
description: Flow used by an authenticated user to configure this Stage.
If empty, user will not be able to configure this stage.
friendly_name:
type: string
nullable: true
minLength: 1
user_verification:
$ref: '#/components/schemas/UserVerificationEnum'
authenticator_attachment:
allOf:
- $ref: '#/components/schemas/AuthenticatorAttachmentEnum'
nullable: true
resident_key_requirement:
$ref: '#/components/schemas/ResidentKeyRequirementEnum'
device_type_restrictions:
type: array
items:
type: string
format: uuid
PatchedBlueprintInstanceRequest:
type: object
description: Info about a single blueprint instance file
@@ -44133,8 +44275,14 @@ components:
type: string
format: date-time
readOnly: true
device_type:
allOf:
- $ref: '#/components/schemas/WebAuthnDeviceType'
readOnly: true
nullable: true
required:
- created_on
- device_type
- name
- pk
WebAuthnDeviceRequest:
@@ -44147,6 +44295,31 @@ components:
maxLength: 200
required:
- name
WebAuthnDeviceType:
type: object
description: WebAuthnDeviceType Serializer
properties:
aaguid:
type: string
format: uuid
description:
type: string
required:
- aaguid
- description
WebAuthnDeviceTypeRequest:
type: object
description: WebAuthnDeviceType Serializer
properties:
aaguid:
type: string
format: uuid
description:
type: string
minLength: 1
required:
- aaguid
- description
Workers:
type: object
properties:
+1 -1
View File
@@ -5,7 +5,7 @@ import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm";
import "@goauthentik/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm";
import "@goauthentik/admin/stages/authenticator_validate/AuthenticatorValidateStageForm";
import "@goauthentik/admin/stages/authenticator_webauthn/AuthenticateWebAuthnStageForm";
import "@goauthentik/admin/stages/authenticator_webauthn/AuthenticatorWebAuthnStageForm";
import "@goauthentik/admin/stages/captcha/CaptchaStageForm";
import "@goauthentik/admin/stages/consent/ConsentStageForm";
import "@goauthentik/admin/stages/deny/DenyStageForm";
+1 -1
View File
@@ -5,7 +5,7 @@ import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm";
import "@goauthentik/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm";
import "@goauthentik/admin/stages/authenticator_validate/AuthenticatorValidateStageForm";
import "@goauthentik/admin/stages/authenticator_webauthn/AuthenticateWebAuthnStageForm";
import "@goauthentik/admin/stages/authenticator_webauthn/AuthenticatorWebAuthnStageForm";
import "@goauthentik/admin/stages/captcha/CaptchaStageForm";
import "@goauthentik/admin/stages/consent/ConsentStageForm";
import "@goauthentik/admin/stages/deny/DenyStageForm";
@@ -1,7 +1,12 @@
import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import {
DataProvision,
DualSelectPair,
} from "@goauthentik/authentik/elements/ak-dual-select/types";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/Radio";
import "@goauthentik/elements/forms/SearchSelect";
@@ -11,8 +16,8 @@ import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import {
AuthenticateWebAuthnStage,
AuthenticatorAttachmentEnum,
AuthenticatorWebAuthnStage,
Flow,
FlowsApi,
FlowsInstancesListDesignationEnum,
@@ -20,28 +25,39 @@ import {
ResidentKeyRequirementEnum,
StagesApi,
UserVerificationEnum,
WebAuthnDeviceType,
} from "@goauthentik/api";
@customElement("ak-stage-authenticator-webauthn-form")
export class AuthenticateWebAuthnStageForm extends BaseStageForm<AuthenticateWebAuthnStage> {
loadInstance(pk: string): Promise<AuthenticateWebAuthnStage> {
export class AuthenticatorWebAuthnStageForm extends BaseStageForm<AuthenticatorWebAuthnStage> {
deviceTypeRestrictionPair(item: WebAuthnDeviceType): DualSelectPair {
const label = item.description ? item.description : item.aaguid;
return [
item.aaguid,
html`<div class="selection-main">${label}</div>
<div class="selection-desc">${item.aaguid}</div>`,
label,
];
}
loadInstance(pk: string): Promise<AuthenticatorWebAuthnStage> {
return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorWebauthnRetrieve({
stageUuid: pk,
});
}
async send(data: AuthenticateWebAuthnStage): Promise<AuthenticateWebAuthnStage> {
async send(data: AuthenticatorWebAuthnStage): Promise<AuthenticatorWebAuthnStage> {
if (data.authenticatorAttachment?.toString() === "") {
data.authenticatorAttachment = null;
}
if (this.instance) {
return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorWebauthnUpdate({
stageUuid: this.instance.pk || "",
authenticateWebAuthnStageRequest: data,
authenticatorWebAuthnStageRequest: data,
});
} else {
return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorWebauthnCreate({
authenticateWebAuthnStageRequest: data,
authenticatorWebAuthnStageRequest: data,
});
}
}
@@ -164,6 +180,38 @@ export class AuthenticateWebAuthnStageForm extends BaseStageForm<AuthenticateWeb
>
</ak-radio>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Device type restrictions")}
name="deviceTypeRestrictions"
>
<ak-dual-select-provider
.provider=${(page: number, search?: string): Promise<DataProvision> => {
return new StagesApi(DEFAULT_CONFIG)
.stagesAuthenticatorWebauthnDeviceTypesList({
page: page,
search: search,
})
.then((results) => {
return {
pagination: results.pagination,
options: results.results.map(
this.deviceTypeRestrictionPair,
),
};
});
}}
.selected=${(this.instance?.deviceTypeRestrictionsObj ?? []).map(
this.deviceTypeRestrictionPair,
)}
available-label="${msg("Available Device types")}"
selected-label="${msg("Selected Device types")}"
></ak-dual-select-provider>
<p class="pf-c-form__helper-text">
${msg(
"Optionally restrict which WebAuthn device types may be used. When no device types are selected, all devices are allowed.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Configuration flow")}
name="configureFlow"
@@ -10,6 +10,7 @@ import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@@ -130,6 +131,17 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form">
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
<ak-empty-state
?loading="${this.registerRunning}"
header=${this.registerRunning
@@ -4,4 +4,26 @@ title: WebAuthn authenticator setup stage
This stage configures a WebAuthn-based Authenticator. This can either be a browser, biometrics or a Security stick like a YubiKey.
There are no stage-specific settings.
### `User verification`
Configure if authentik should require, prefer or discourage user verification for the authenticator. For example when using a virtual authenticator like Windows Hello, this setting controls if a PIN is required.
### `Resident key requirement`
Configure if the created authenticator is stored in the encrypted memory on the device or in persistent memory. When configuring [passwordless login](../identification/index.md#passwordless-flow), this should be set to either _Preferred_ or _Required_, otherwise the authenticator cannot be used for passwordless authentication.
### `Authenticator Attachment`
Configure if authentik will require either a removable device (like a YubiKey, Google Titan, etc) or a non-removable device (like Windows Hello, TouchID or password managers), or not send a requirement.
### `Device type restrictions`
:::info
Requires authentik 2024.4
:::
Optionally restrict the types of devices allowed to be enrolled. This option can be used to ensure users are only able to enroll FIPS-compliant devices for example.
When no restrictions are selected, all device types are allowed.
As authentik does not know of all possible device types, it is possible to select the special option `authentik: Unknown devices` to allow unknown devices.