diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py new file mode 100644 index 000000000..244589682 --- /dev/null +++ b/authentik/stages/authenticator_validate/challenge.py @@ -0,0 +1,137 @@ +"""Validation stage challenge checking""" +from django.db.models import Model +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ +from django_otp import match_token +from django_otp.models import Device +from django_otp.plugins.otp_static.models import StaticDevice +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework.fields import CharField, JSONField +from rest_framework.serializers import Serializer, ValidationError +from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser +from webauthn.webauthn import ( + AuthenticationRejectedException, + RegistrationRejectedException, + WebAuthnUserDataMissing, +) + +from authentik.core.models import User +from authentik.lib.templatetags.authentik_utils import avatar +from authentik.stages.authenticator_validate.models import DeviceClasses +from authentik.stages.authenticator_webauthn.models import WebAuthnDevice +from authentik.stages.authenticator_webauthn.utils import generate_challenge + + +class DeviceChallenge(Serializer): + """Single device challenge""" + + device_class = CharField() + device_uid = CharField() + challenge = JSONField() + + def create(self, validated_data: dict) -> Model: + raise NotImplementedError + + def update(self, instance: Model, validated_data: dict) -> Model: + raise NotImplementedError + + +def get_challenge_for_device(request: HttpRequest, device: Device) -> dict: + """Generate challenge for a single device""" + if isinstance(device, (TOTPDevice, StaticDevice)): + # Code-based challenges have no hints + return {} + return get_webauthn_challenge(request, device) + + +def get_webauthn_challenge(request: HttpRequest, device: WebAuthnDevice) -> 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("=") + + webauthn_user = WebAuthnUser( + device.user.uid, + device.user.username, + device.user.name, + avatar(device.user), + device.credential_id, + device.public_key, + device.sign_count, + device.rp_id, + ) + + webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge) + + return webauthn_assertion_options.assertion_dict + + +def validate_challenge( + challenge: DeviceChallenge, request: HttpRequest, user: User +) -> DeviceChallenge: + """main entry point for challenge validation""" + if challenge.validated_data["device_class"] in ( + DeviceClasses.TOTP, + DeviceClasses.STATIC, + ): + return validate_challenge_code(challenge, request, user) + return validate_challenge_webauthn(challenge, request, user) + + +def validate_challenge_code( + challenge: DeviceChallenge, request: HttpRequest, user: User +) -> DeviceChallenge: + """Validate code-based challenges. We test against every device, on purpose, as + the user mustn't choose between totp and static devices.""" + device = match_token(user, challenge.validated_data["challenge"].get("code", None)) + if not device: + raise ValidationError(_("Invalid Token")) + return challenge + + +def validate_challenge_webauthn( + challenge: DeviceChallenge, request: HttpRequest, user: User +) -> DeviceChallenge: + """Validate WebAuthn Challenge""" + challenge = request.session.get("challenge") + assertion_response = challenge.validated_data["challenge"] + credential_id = assertion_response.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, + avatar(user), + device.credential_id, + device.public_key, + device.sign_count, + device.rp_id, + ) + + webauthn_assertion_response = WebAuthnAssertionResponse( + webauthn_user, + assertion_response, + challenge, + request.build_absolute_uri("/"), + uv_required=False, + ) # User Verification + + try: + sign_count = webauthn_assertion_response.verify() + except ( + AuthenticationRejectedException, + WebAuthnUserDataMissing, + RegistrationRejectedException, + ) as exc: + raise ValidationError("Assertion failed") from exc + + device.set_sign_count(sign_count) + return challenge diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index 01506d738..56b779524 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -1,9 +1,11 @@ """Authenticator Validation""" from django.http import HttpRequest, HttpResponse -from django_otp import devices_for_user, user_has_device -from rest_framework.fields import CharField, DictField, IntegerField, JSONField, ListField +from django.http.request import QueryDict +from django_otp import devices_for_user +from rest_framework.fields import ListField from structlog.stdlib import get_logger +from authentik.core.models import User from authentik.flows.challenge import ( ChallengeResponse, ChallengeTypes, @@ -12,6 +14,11 @@ from authentik.flows.challenge import ( from authentik.flows.models import NotConfiguredAction from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ChallengeStageView +from authentik.stages.authenticator_validate.challenge import ( + DeviceChallenge, + get_challenge_for_device, + validate_challenge, +) from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage LOGGER = get_logger() @@ -20,17 +27,20 @@ LOGGER = get_logger() class AuthenticatorChallenge(WithUserInfoChallenge): """Authenticator challenge""" - users_device_classes = ListField(child=CharField()) - class_challenges = DictField(JSONField()) + device_challenges = ListField(child=DeviceChallenge()) class AuthenticatorChallengeResponse(ChallengeResponse): """Challenge used for Code-based authenticators""" - device_challenges = DictField(JSONField()) + response = DeviceChallenge() - def validate_device_challenges(self, value: dict[str, dict]): - return value + request: HttpRequest + user: User + + def validate_response(self, value: DeviceChallenge): + """Validate response""" + return validate_challenge(value, self.request, self.user) class AuthenticatorValidateStageView(ChallengeStageView): @@ -38,7 +48,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): response_class = AuthenticatorChallengeResponse - allowed_device_classes: set[str] + challenges: list[DeviceChallenge] def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Check if a user is set, and check if the user has any devices @@ -47,22 +57,27 @@ class AuthenticatorValidateStageView(ChallengeStageView): if not user: LOGGER.debug("No pending user, continuing") return self.executor.stage_ok() - has_devices = user_has_device(user) stage: AuthenticatorValidateStage = self.executor.current_stage + self.challenges = [] user_devices = devices_for_user(self.get_pending_user()) - user_device_classes = set( - [ - device.__class__.__name__.lower().replace("device", "") - for device in user_devices - ] - ) - stage_device_classes = set(self.executor.current_stage.device_classes) - self.allowed_device_classes = user_device_classes.intersection(stage_device_classes) - # User has no devices, or the devices they have don't overlap with the allowed - # classes - if not has_devices or len(self.allowed_device_classes) < 1: + for device in user_devices: + device_class = device.__class__.__name__.lower().replace("device", "") + if device_class not in stage.device_classes: + continue + self.challenges.append( + DeviceChallenge( + data={ + "device_class": device_class, + "device_uid": device.pk, + "challenge": get_challenge_for_device(request, device), + } + ) + ) + + # No allowed devices + if len(self.challenges) < 1: if stage.not_configured_action == NotConfiguredAction.SKIP: LOGGER.debug("Authenticator not configured, skipping stage") return self.executor.stage_ok() @@ -76,10 +91,16 @@ class AuthenticatorValidateStageView(ChallengeStageView): data={ "type": ChallengeTypes.native, "component": "ak-stage-authenticator-validate", - "users_device_classes": self.allowed_device_classes, + "device_challenges": self.challenges, } ) + def get_response_instance(self, data: QueryDict) -> ChallengeResponse: + response: AuthenticatorChallengeResponse = super().get_response_instance(data) + response.request = self.request + response.user = self.get_pending_user() + return response + def challenge_valid( self, challenge: AuthenticatorChallengeResponse ) -> HttpResponse: diff --git a/authentik/stages/authenticator_validate/webauthn.py b/authentik/stages/authenticator_validate/webauthn.py deleted file mode 100644 index 76e24cb96..000000000 --- a/authentik/stages/authenticator_validate/webauthn.py +++ /dev/null @@ -1,83 +0,0 @@ -from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser -from webauthn.webauthn import ( - AuthenticationRejectedException, - RegistrationRejectedException, - WebAuthnUserDataMissing, -) - -class BeginAssertion(FlowUserRequiredView): - """Send the client a challenge that we'll check later""" - - def post(self, request: HttpRequest) -> HttpResponse: - """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("=") - - devices = WebAuthnDevice.objects.filter(user=self.user) - if not devices.exists(): - return HttpResponseBadRequest() - device: WebAuthnDevice = devices.first() - - webauthn_user = WebAuthnUser( - self.user.uid, - self.user.username, - self.user.name, - avatar(self.user), - device.credential_id, - device.public_key, - device.sign_count, - device.rp_id, - ) - - webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge) - - return JsonResponse(webauthn_assertion_options.assertion_dict) - - -class VerifyAssertion(FlowUserRequiredView): - """Verify assertion result that we've sent to the client""" - - def post(self, request: HttpRequest) -> HttpResponse: - """Verify assertion result that we've sent to the client""" - challenge = request.session.get("challenge") - assertion_response = request.POST - credential_id = assertion_response.get("id") - - device = WebAuthnDevice.objects.filter(credential_id=credential_id).first() - if not device: - return JsonResponse({"fail": "Device does not exist."}, status=401) - - webauthn_user = WebAuthnUser( - self.user.uid, - self.user.username, - self.user.name, - avatar(self.user), - device.credential_id, - device.public_key, - device.sign_count, - device.rp_id, - ) - - webauthn_assertion_response = WebAuthnAssertionResponse( - webauthn_user, assertion_response, challenge, ORIGIN, uv_required=False - ) # User Verification - - try: - sign_count = webauthn_assertion_response.verify() - except ( - AuthenticationRejectedException, - WebAuthnUserDataMissing, - RegistrationRejectedException, - ) as exc: - return JsonResponse({"fail": "Assertion failed. Error: {}".format(exc)}) - - device.set_sign_count(sign_count) - request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True - return JsonResponse( - {"success": "Successfully authenticated as {}".format(self.user.username)} - ) diff --git a/authentik/stages/authenticator_webauthn/stage.py b/authentik/stages/authenticator_webauthn/stage.py index 5bc5cf37a..1b3ff2bfb 100644 --- a/authentik/stages/authenticator_webauthn/stage.py +++ b/authentik/stages/authenticator_webauthn/stage.py @@ -20,9 +20,7 @@ from authentik.lib.templatetags.authentik_utils import avatar from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.authenticator_webauthn.utils import generate_challenge -RP_ID = "localhost" RP_NAME = "authentik" -ORIGIN = "http://localhost:8000" LOGGER = get_logger() @@ -54,8 +52,8 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): none_attestation_permitted = True webauthn_registration_response = WebAuthnRegistrationResponse( - RP_ID, - ORIGIN, + self.request.get_host(), + self.request.build_absolute_uri("/"), response, challenge, trusted_attestation_cert_required=trusted_attestation_cert_required, @@ -112,7 +110,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): make_credential_options = WebAuthnMakeCredentialOptions( challenge, RP_NAME, - RP_ID, + self.request.get_host(), user.uid, user.username, user.name, @@ -156,7 +154,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): public_key=webauthn_credential.public_key, credential_id=webauthn_credential.credential_id, sign_count=webauthn_credential.sign_count, - rp_id=RP_ID, + rp_id=self.request.get_host(), ) else: return self.executor.stage_invalid( diff --git a/authentik/stages/authenticator_webauthn/urls.py b/authentik/stages/authenticator_webauthn/urls.py index d5236a829..d163dcc39 100644 --- a/authentik/stages/authenticator_webauthn/urls.py +++ b/authentik/stages/authenticator_webauthn/urls.py @@ -1,10 +1,7 @@ """WebAuthn urls""" from django.urls import path -from django.views.decorators.csrf import csrf_exempt -from authentik.stages.authenticator_webauthn.views import ( - UserSettingsView, -) +from authentik.stages.authenticator_webauthn.views import UserSettingsView urlpatterns = [ path( diff --git a/authentik/stages/authenticator_webauthn/views.py b/authentik/stages/authenticator_webauthn/views.py index 65374616d..6881fb571 100644 --- a/authentik/stages/authenticator_webauthn/views.py +++ b/authentik/stages/authenticator_webauthn/views.py @@ -1,29 +1,12 @@ """webauthn views""" from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpRequest, HttpResponse, JsonResponse -from django.http.response import HttpResponseBadRequest from django.shortcuts import get_object_or_404 -from django.views import View from django.views.generic import TemplateView -from structlog.stdlib import get_logger -from authentik.core.models import User -from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER -from authentik.flows.views import SESSION_KEY_PLAN -from authentik.lib.templatetags.authentik_utils import avatar from authentik.stages.authenticator_webauthn.models import ( AuthenticateWebAuthnStage, WebAuthnDevice, ) -from authentik.stages.authenticator_webauthn.stage import ( - SESSION_KEY_WEBAUTHN_AUTHENTICATED, -) -from authentik.stages.authenticator_webauthn.utils import generate_challenge - -LOGGER = get_logger() -RP_ID = "localhost" -RP_NAME = "authentik" -ORIGIN = "http://localhost:8000" class UserSettingsView(LoginRequiredMixin, TemplateView): diff --git a/swagger.yaml b/swagger.yaml index 38bd48b94..2442a64c9 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -11048,6 +11048,7 @@ definitions: type: string enum: - skip + - deny device_classes: description: '' type: array diff --git a/web/src/elements/stages/authenticator_static/AuthenticatorStaticStage.ts b/web/src/elements/stages/authenticator_static/AuthenticatorStaticStage.ts index a91298ca3..ffd00c812 100644 --- a/web/src/elements/stages/authenticator_static/AuthenticatorStaticStage.ts +++ b/web/src/elements/stages/authenticator_static/AuthenticatorStaticStage.ts @@ -42,7 +42,7 @@ export class AuthenticatorStaticStage extends BaseStage {