From 448c8f874522985d3880bbf66bb42acb36de6f7b Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Fri, 26 Dec 2025 14:20:20 +0100 Subject: [PATCH] endpoints/devices: cleanup (#19047) * endpoints: make device token internally managed Signed-off-by: Jens Langhammer * fix text and defaults for agent Signed-off-by: Jens Langhammer * re-org some code Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- .../connectors/agent/api/connectors.py | 50 +++++++- authentik/endpoints/connectors/agent/auth.py | 110 ++++++++++++++++- .../endpoints/connectors/agent/models.py | 2 +- authentik/enterprise/api.py | 14 +++ .../connectors/agent/api/connectors.py | 56 +-------- .../endpoints/connectors/agent/auth.py | 113 ------------------ .../agent/tests/test_connector_auth_ia.py | 13 ++ .../agent/views/auth_interactive.py | 6 +- .../connectors/agent/AgentConnectorForm.ts | 13 +- 9 files changed, 200 insertions(+), 177 deletions(-) delete mode 100644 authentik/enterprise/endpoints/connectors/agent/auth.py diff --git a/authentik/endpoints/connectors/agent/api/connectors.py b/authentik/endpoints/connectors/agent/api/connectors.py index c8fe8ea83a..b77a60be77 100644 --- a/authentik/endpoints/connectors/agent/api/connectors.py +++ b/authentik/endpoints/connectors/agent/api/connectors.py @@ -1,11 +1,13 @@ from typing import cast +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.fields import ChoiceField +from rest_framework.permissions import IsAuthenticated from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.request import Request from rest_framework.response import Response @@ -22,6 +24,9 @@ from authentik.endpoints.connectors.agent.api.agent import ( from authentik.endpoints.connectors.agent.auth import ( AgentAuth, AgentEnrollmentAuth, + DeviceAuthFedAuthentication, + agent_auth_issue_token, + check_device_policies, ) from authentik.endpoints.connectors.agent.controller import MDMConfigResponseSerializer from authentik.endpoints.connectors.agent.models import ( @@ -32,7 +37,10 @@ from authentik.endpoints.connectors.agent.models import ( ) from authentik.endpoints.facts import DeviceFacts, OSFamily from authentik.endpoints.models import Device +from authentik.events.models import Event, EventAction +from authentik.flows.planner import PLAN_CONTEXT_DEVICE from authentik.lib.utils.reflection import ConditionalInheritance +from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS class AgentConnectorSerializer(ConnectorSerializer): @@ -163,3 +171,43 @@ class AgentConnectorViewSet( connection: AgentDeviceConnection = token.device connection.create_snapshot(data.validated_data) return Response(status=204) + + @extend_schema( + request=OpenApiTypes.NONE, + parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)], + responses={ + 200: AgentTokenResponseSerializer(), + 404: OpenApiResponse(description="Device not found"), + }, + ) + @action( + methods=["POST"], + detail=False, + pagination_class=None, + filter_backends=[], + permission_classes=[IsAuthenticated], + authentication_classes=[DeviceAuthFedAuthentication], + ) + def auth_fed(self, request: Request) -> Response: + federated_token, device, connector = request.auth + + policy_result = check_device_policies(device, federated_token.user, request._request) + if not policy_result.passing: + raise ValidationError( + {"policy_result": "Policy denied access", "policy_messages": policy_result.messages} + ) + + token, exp = agent_auth_issue_token(device, connector, federated_token.user) + rel_exp = int((exp - now()).total_seconds()) + Event.new( + EventAction.LOGIN, + **{ + PLAN_CONTEXT_METHOD: "jwt", + PLAN_CONTEXT_METHOD_ARGS: { + "jwt": federated_token, + "provider": federated_token.provider, + }, + PLAN_CONTEXT_DEVICE: device, + }, + ).from_http(request, user=federated_token.user) + return Response({"token": token, "expires_in": rel_exp}) diff --git a/authentik/endpoints/connectors/agent/auth.py b/authentik/endpoints/connectors/agent/auth.py index 5de787abc6..91349c0461 100644 --- a/authentik/endpoints/connectors/agent/auth.py +++ b/authentik/endpoints/connectors/agent/auth.py @@ -1,13 +1,28 @@ from typing import Any +from django.http import HttpRequest +from django.utils.timezone import now +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from jwt import PyJWTError, decode, encode from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request +from structlog.stdlib import get_logger from authentik.api.authentication import IPCUser, validate_auth from authentik.core.middleware import CTX_AUTH_VIA from authentik.core.models import User -from authentik.endpoints.connectors.agent.models import DeviceToken, EnrollmentToken +from authentik.crypto.apps import MANAGED_KEY +from authentik.crypto.models import CertificateKeyPair +from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceToken, EnrollmentToken +from authentik.endpoints.models import Device +from authentik.lib.utils.time import timedelta_from_string +from authentik.policies.engine import PolicyEngine +from authentik.policies.models import PolicyBindingModel +from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider + +LOGGER = get_logger() +PLATFORM_ISSUER = "goauthentik.io/platform" class DeviceUser(IPCUser): @@ -40,3 +55,96 @@ class AgentAuth(BaseAuthentication): raise PermissionDenied() CTX_AUTH_VIA.set("endpoint_token") return (DeviceUser(), device_token) + + +def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs): + kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first() + if not kp: + return None, None + exp = now() + timedelta_from_string(connector.auth_session_duration) + token = encode( + { + "iss": PLATFORM_ISSUER, + "aud": str(device.pk), + "iat": int(now().timestamp()), + "exp": int(exp.timestamp()), + "preferred_username": user.username, + **kwargs, + }, + kp.private_key, + headers={ + "kid": kp.kid, + }, + algorithm=JWTAlgorithms.from_private_key(kp.private_key), + ) + return token, exp + + +class DeviceAuthFedAuthentication(BaseAuthentication): + + def authenticate(self, request): + raw_token = validate_auth(get_authorization_header(request)) + if not raw_token: + LOGGER.warning("Missing token") + return None + device = Device.filter_not_expired(name=request.query_params.get("device")).first() + if not device: + LOGGER.warning("Couldn't find device") + return None + connectors_for_device = AgentConnector.objects.filter(device__in=[device]) + connector = connectors_for_device.first() + providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device) + federated_token = AccessToken.objects.filter( + token=raw_token, provider__in=providers + ).first() + if not federated_token: + LOGGER.warning("Couldn't lookup provider") + return None + _key, _alg = federated_token.provider.jwt_key + try: + decode( + raw_token, + _key.public_key(), + algorithms=[_alg], + options={ + "verify_aud": False, + }, + ) + LOGGER.info( + "successfully verified JWT with provider", provider=federated_token.provider.name + ) + return (federated_token.user, (federated_token, device, connector)) + except (PyJWTError, ValueError, TypeError, AttributeError) as exc: + LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name) + return None + + +class DeviceFederationAuthSchema(OpenApiAuthenticationExtension): + """Auth schema""" + + target_class = DeviceAuthFedAuthentication + name = "device_federation" + + def get_security_definition(self, auto_schema): + """Auth schema""" + return {"type": "http", "scheme": "bearer"} + + +def check_device_policies(device: Device, user: User, request: HttpRequest): + """Check policies bound to device group and device""" + if device.access_group: + result = check_pbm_policies(device.access_group, user, request) + if result.passing: + return result + return check_pbm_policies(device, user, request) + + +def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest): + policy_engine = PolicyEngine(pbm, user, request) + policy_engine.use_cache = False + policy_engine.empty_result = False + policy_engine.mode = pbm.policy_engine_mode + policy_engine.build() + result = policy_engine.result + LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk) + return result diff --git a/authentik/endpoints/connectors/agent/models.py b/authentik/endpoints/connectors/agent/models.py index a4e7dd7361..b34d446962 100644 --- a/authentik/endpoints/connectors/agent/models.py +++ b/authentik/endpoints/connectors/agent/models.py @@ -97,7 +97,7 @@ class AgentDeviceUserBinding(DeviceUserBinding): apple_enclave_key_id = models.TextField() -class DeviceToken(ExpiringModel): +class DeviceToken(InternallyManagedMixin, ExpiringModel): """Per-device token used for authentication.""" token_uuid = models.UUIDField(primary_key=True, default=uuid4) diff --git a/authentik/enterprise/api.py b/authentik/enterprise/api.py index 33b584a2a8..b748ba38cc 100644 --- a/authentik/enterprise/api.py +++ b/authentik/enterprise/api.py @@ -1,6 +1,8 @@ """Enterprise API Views""" +from collections.abc import Callable from datetime import timedelta +from functools import wraps from django.utils.timezone import now from django.utils.translation import gettext as _ @@ -35,6 +37,18 @@ class EnterpriseRequiredMixin: return super().validate(attrs) +def enterprise_action(func: Callable): + """Check permissions for a single custom action""" + + @wraps(func) + def wrapper(*args, **kwargs) -> Response: + if not LicenseKey.cached_summary().status.is_valid: + raise ValidationError(_("Enterprise is required to use this endpoint.")) + return func(*args, **kwargs) + + return wrapper + + class LicenseSerializer(ModelSerializer): """License Serializer""" diff --git a/authentik/enterprise/endpoints/connectors/agent/api/connectors.py b/authentik/enterprise/endpoints/connectors/agent/api/connectors.py index cb541ee19d..330a2abb13 100644 --- a/authentik/enterprise/endpoints/connectors/agent/api/connectors.py +++ b/authentik/enterprise/endpoints/connectors/agent/api/connectors.py @@ -1,31 +1,20 @@ from django.urls import reverse -from django.utils.timezone import now from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from drf_spectacular.utils import extend_schema from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError -from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from structlog.stdlib import get_logger from authentik.endpoints.connectors.agent.api.agent import ( AgentAuthenticationResponse, - AgentTokenResponseSerializer, ) from authentik.endpoints.connectors.agent.auth import AgentAuth from authentik.endpoints.connectors.agent.models import ( DeviceAuthenticationToken, DeviceToken, ) -from authentik.enterprise.endpoints.connectors.agent.auth import ( - DeviceAuthFedAuthentication, - agent_auth_issue_token, - check_device_policies, -) -from authentik.events.models import Event, EventAction -from authentik.flows.planner import PLAN_CONTEXT_DEVICE -from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS +from authentik.enterprise.api import enterprise_action LOGGER = get_logger() @@ -37,6 +26,7 @@ class AgentConnectorViewSetMixin: responses=AgentAuthenticationResponse(), ) @action(methods=["POST"], detail=False, authentication_classes=[AgentAuth]) + @enterprise_action def auth_ia(self, request: Request) -> Response: token: DeviceToken = request.auth auth_token = DeviceAuthenticationToken.objects.create( @@ -54,43 +44,3 @@ class AgentConnectorViewSetMixin: ), } ) - - @extend_schema( - request=OpenApiTypes.NONE, - parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)], - responses={ - 200: AgentTokenResponseSerializer(), - 404: OpenApiResponse(description="Device not found"), - }, - ) - @action( - methods=["POST"], - detail=False, - pagination_class=None, - filter_backends=[], - permission_classes=[IsAuthenticated], - authentication_classes=[DeviceAuthFedAuthentication], - ) - def auth_fed(self, request: Request) -> Response: - federated_token, device, connector = request.auth - - policy_result = check_device_policies(device, federated_token.user, request._request) - if not policy_result.passing: - raise ValidationError( - {"policy_result": "Policy denied access", "policy_messages": policy_result.messages} - ) - - token, exp = agent_auth_issue_token(device, connector, federated_token.user) - rel_exp = int((exp - now()).total_seconds()) - Event.new( - EventAction.LOGIN, - **{ - PLAN_CONTEXT_METHOD: "jwt", - PLAN_CONTEXT_METHOD_ARGS: { - "jwt": federated_token, - "provider": federated_token.provider, - }, - PLAN_CONTEXT_DEVICE: device, - }, - ).from_http(request, user=federated_token.user) - return Response({"token": token, "expires_in": rel_exp}) diff --git a/authentik/enterprise/endpoints/connectors/agent/auth.py b/authentik/enterprise/endpoints/connectors/agent/auth.py deleted file mode 100644 index 486783fb27..0000000000 --- a/authentik/enterprise/endpoints/connectors/agent/auth.py +++ /dev/null @@ -1,113 +0,0 @@ -from django.http import HttpRequest -from django.utils.timezone import now -from drf_spectacular.extensions import OpenApiAuthenticationExtension -from jwt import PyJWTError, decode, encode -from rest_framework.authentication import BaseAuthentication -from structlog.stdlib import get_logger - -from authentik.api.authentication import get_authorization_header, validate_auth -from authentik.core.models import User -from authentik.crypto.apps import MANAGED_KEY -from authentik.crypto.models import CertificateKeyPair -from authentik.endpoints.connectors.agent.models import AgentConnector -from authentik.endpoints.models import Device -from authentik.lib.utils.time import timedelta_from_string -from authentik.policies.engine import PolicyEngine -from authentik.policies.models import PolicyBindingModel -from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider - -LOGGER = get_logger() -PLATFORM_ISSUER = "goauthentik.io/platform" - - -def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs): - kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first() - if not kp: - return None, None - exp = now() + timedelta_from_string(connector.auth_session_duration) - token = encode( - { - "iss": PLATFORM_ISSUER, - "aud": str(device.pk), - "iat": int(now().timestamp()), - "exp": int(exp.timestamp()), - "preferred_username": user.username, - **kwargs, - }, - kp.private_key, - headers={ - "kid": kp.kid, - }, - algorithm=JWTAlgorithms.from_private_key(kp.private_key), - ) - return token, exp - - -class DeviceAuthFedAuthentication(BaseAuthentication): - - def authenticate(self, request): - raw_token = validate_auth(get_authorization_header(request)) - if not raw_token: - LOGGER.warning("Missing token") - return None - device = Device.filter_not_expired(name=request.query_params.get("device")).first() - if not device: - LOGGER.warning("Couldn't find device") - return None - connectors_for_device = AgentConnector.objects.filter(device__in=[device]) - connector = connectors_for_device.first() - providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device) - federated_token = AccessToken.objects.filter( - token=raw_token, provider__in=providers - ).first() - if not federated_token: - LOGGER.warning("Couldn't lookup provider") - return None - _key, _alg = federated_token.provider.jwt_key - try: - decode( - raw_token, - _key.public_key(), - algorithms=[_alg], - options={ - "verify_aud": False, - }, - ) - LOGGER.info( - "successfully verified JWT with provider", provider=federated_token.provider.name - ) - return (federated_token.user, (federated_token, device, connector)) - except (PyJWTError, ValueError, TypeError, AttributeError) as exc: - LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name) - return None - - -class DeviceFederationAuthSchema(OpenApiAuthenticationExtension): - """Auth schema""" - - target_class = DeviceAuthFedAuthentication - name = "device_federation" - - def get_security_definition(self, auto_schema): - """Auth schema""" - return {"type": "http", "scheme": "bearer"} - - -def check_device_policies(device: Device, user: User, request: HttpRequest): - """Check policies bound to device group and device""" - if device.access_group: - result = check_pbm_policies(device.access_group, user, request) - if result.passing: - return result - return check_pbm_policies(device, user, request) - - -def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest): - policy_engine = PolicyEngine(pbm, user, request) - policy_engine.use_cache = False - policy_engine.empty_result = False - policy_engine.mode = pbm.policy_engine_mode - policy_engine.build() - result = policy_engine.result - LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk) - return result diff --git a/authentik/enterprise/endpoints/connectors/agent/tests/test_connector_auth_ia.py b/authentik/enterprise/endpoints/connectors/agent/tests/test_connector_auth_ia.py index 12160534c2..a12557939b 100644 --- a/authentik/enterprise/endpoints/connectors/agent/tests/test_connector_auth_ia.py +++ b/authentik/enterprise/endpoints/connectors/agent/tests/test_connector_auth_ia.py @@ -63,8 +63,21 @@ class TestConnectorAuthIA(FlowTestCase): ) self.assertEqual(response.status_code, 200) + @patch( + "authentik.enterprise.license.LicenseKey.validate", + MagicMock( + return_value=LicenseKey( + aud="", + exp=expiry_valid, + name=generate_id(), + internal_users=100, + external_users=100, + ) + ), + ) @reconcile_app("authentik_crypto") def test_auth_ia_fulfill(self): + License.objects.create(key=generate_id()) self.client.force_login(self.user) response = self.client.post( reverse("authentik_api:agentconnector-auth-ia"), diff --git a/authentik/enterprise/endpoints/connectors/agent/views/auth_interactive.py b/authentik/enterprise/endpoints/connectors/agent/views/auth_interactive.py index 363f862770..e9baa57171 100644 --- a/authentik/enterprise/endpoints/connectors/agent/views/auth_interactive.py +++ b/authentik/enterprise/endpoints/connectors/agent/views/auth_interactive.py @@ -3,12 +3,12 @@ from hmac import compare_digest from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, QueryDict -from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken -from authentik.endpoints.models import Device -from authentik.enterprise.endpoints.connectors.agent.auth import ( +from authentik.endpoints.connectors.agent.auth import ( agent_auth_issue_token, check_device_policies, ) +from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken +from authentik.endpoints.models import Device from authentik.enterprise.policy import EnterprisePolicyAccessView from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.models import in_memory_stage diff --git a/web/src/admin/endpoints/connectors/agent/AgentConnectorForm.ts b/web/src/admin/endpoints/connectors/agent/AgentConnectorForm.ts index c9a2ecbb3e..ed251c6ffa 100644 --- a/web/src/admin/endpoints/connectors/agent/AgentConnectorForm.ts +++ b/web/src/admin/endpoints/connectors/agent/AgentConnectorForm.ts @@ -15,7 +15,6 @@ import { ModelForm } from "#elements/forms/ModelForm"; import { WithBrandConfig } from "#elements/mixins/branding"; import { ifPresent } from "#elements/utils/attributes"; -import { gidStartNumberHelp, uidStartNumberHelp } from "#admin/providers/ldap/LDAPOptionsAndHelp"; import { oauth2ProvidersProvider, oauth2ProvidersSelector, @@ -183,15 +182,19 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm `;