mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
2 Commits
modal-revi
...
endpoints/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
026b7d3e73 | ||
|
|
91ac87c934 |
@@ -10,6 +10,7 @@ class AuthentikEndpointsConnectorAgentAppConfig(ManagedAppConfig):
|
||||
label = "authentik_endpoints_connectors_agent"
|
||||
verbose_name = "authentik Endpoints.Connectors.Agent"
|
||||
default = True
|
||||
mountpoint = "endpoints/agent/"
|
||||
|
||||
def import_related(self):
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
from datetime import timedelta
|
||||
from hmac import compare_digest
|
||||
from plistlib import PlistFormat, dumps
|
||||
from uuid import uuid4
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring # nosec
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from jwt import PyJWTError, decode, encode
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, EnrollmentToken
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceToken, EnrollmentToken
|
||||
from authentik.endpoints.controller import BaseController
|
||||
from authentik.endpoints.facts import OSFamily
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import JWTAlgorithms
|
||||
|
||||
|
||||
def csp_create_replace_item(loc_uri, data_value) -> Element:
|
||||
@@ -36,14 +45,12 @@ def csp_create_replace_item(loc_uri, data_value) -> Element:
|
||||
|
||||
|
||||
class MDMConfigResponseSerializer(PassiveSerializer):
|
||||
|
||||
config = CharField(required=True)
|
||||
mime_type = CharField(required=True)
|
||||
filename = CharField(required=True)
|
||||
|
||||
|
||||
class AgentConnectorController(BaseController[AgentConnector]):
|
||||
|
||||
class AgentController(BaseController[AgentConnector]):
|
||||
@staticmethod
|
||||
def vendor_identifier() -> str:
|
||||
return "goauthentik.io/platform"
|
||||
@@ -51,6 +58,57 @@ class AgentConnectorController(BaseController[AgentConnector]):
|
||||
def supported_enrollment_methods(self):
|
||||
return []
|
||||
|
||||
def generate_device_challenge(self):
|
||||
keypair = CertificateKeyPair.objects.get(pk=self.connector.challenge_key_id)
|
||||
challenge_str = generate_id()
|
||||
iat = now()
|
||||
challenge = encode(
|
||||
{
|
||||
"atc": challenge_str,
|
||||
"iss": str(self.connector.pk),
|
||||
"iat": int(iat.timestamp()),
|
||||
"exp": int((iat + timedelta(minutes=5)).timestamp()),
|
||||
"goauthentik.io/device/check_in": self.connector.challenge_trigger_check_in,
|
||||
},
|
||||
headers={"kid": keypair.kid},
|
||||
key=keypair.private_key,
|
||||
algorithm=JWTAlgorithms.from_private_key(keypair.private_key),
|
||||
)
|
||||
return challenge
|
||||
|
||||
def validate_device_challenge(self, response: str, challenge: str):
|
||||
try:
|
||||
raw = decode(
|
||||
response,
|
||||
options={"verify_signature": False},
|
||||
audience="goauthentik.io/platform/endpoint",
|
||||
)
|
||||
except PyJWTError as exc:
|
||||
self.logger.warning("Could not parse response", exc=exc)
|
||||
raise ValidationError("Invalid challenge response") from None
|
||||
device = Device.filter_not_expired(identifier=raw["iss"]).first()
|
||||
if not device:
|
||||
self.logger.warning("Could not find device for challenge")
|
||||
raise ValidationError("Invalid challenge response")
|
||||
for token in DeviceToken.filter_not_expired(
|
||||
device__device=device, device__connector=self.connector
|
||||
).values_list("key", flat=True):
|
||||
try:
|
||||
decoded = decode(
|
||||
response,
|
||||
key=token,
|
||||
algorithms="HS512",
|
||||
issuer=device.identifier,
|
||||
audience="goauthentik.io/platform/endpoint",
|
||||
)
|
||||
if not compare_digest(decoded["atc"], challenge):
|
||||
self.logger.warning("mismatched challenge")
|
||||
raise ValidationError("Invalid challenge response")
|
||||
return device
|
||||
except PyJWTError as exc:
|
||||
self.logger.warning("failed to validate device challenge response", exc=exc)
|
||||
raise ValidationError("Invalid challenge response")
|
||||
|
||||
def generate_mdm_config(
|
||||
self, target_platform: OSFamily, request: HttpRequest, token: EnrollmentToken
|
||||
) -> MDMConfigResponseSerializer:
|
||||
|
||||
@@ -21,7 +21,7 @@ from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.endpoints.connectors.agent.controller import AgentConnectorController
|
||||
from authentik.endpoints.connectors.agent.controller import AgentController
|
||||
|
||||
|
||||
class AgentConnector(Connector):
|
||||
@@ -73,10 +73,10 @@ class AgentConnector(Connector):
|
||||
return AuthenticatorEndpointStageView
|
||||
|
||||
@property
|
||||
def controller(self) -> type[AgentConnectorController]:
|
||||
from authentik.endpoints.connectors.agent.controller import AgentConnectorController
|
||||
def controller(self) -> type[AgentController]:
|
||||
from authentik.endpoints.connectors.agent.controller import AgentController
|
||||
|
||||
return AgentConnectorController
|
||||
return AgentController
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
from datetime import timedelta
|
||||
from hashlib import sha256
|
||||
from hmac import compare_digest
|
||||
from typing import cast
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.utils.timezone import now
|
||||
from jwt import PyJWTError, decode, encode
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.connectors.agent.models import DeviceAuthenticationToken, DeviceToken
|
||||
from authentik.endpoints.connectors.agent.controller import AgentController
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
AgentConnector,
|
||||
DeviceAuthenticationToken,
|
||||
)
|
||||
from authentik.endpoints.models import Device, EndpointStage, StageMode
|
||||
from authentik.flows.challenge import (
|
||||
Challenge,
|
||||
@@ -17,9 +20,7 @@ from authentik.flows.challenge import (
|
||||
)
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.models import JWTAlgorithms
|
||||
|
||||
PLAN_CONTEXT_DEVICE_AUTH_TOKEN = "goauthentik.io/endpoints/device_auth_token" # nosec
|
||||
PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE = "goauthentik.io/endpoints/connectors/agent/challenge"
|
||||
@@ -31,8 +32,9 @@ class EndpointAgentChallenge(Challenge):
|
||||
"""Signed challenge for authentik agent to respond to"""
|
||||
|
||||
component = CharField(default="ak-stage-endpoint-agent")
|
||||
challenge = CharField()
|
||||
challenge = CharField(required=True)
|
||||
challenge_idle_timeout = IntegerField()
|
||||
frame_url = CharField(required=True)
|
||||
|
||||
|
||||
class EndpointAgentChallengeResponse(ChallengeResponse):
|
||||
@@ -44,47 +46,23 @@ class EndpointAgentChallengeResponse(ChallengeResponse):
|
||||
def validate_response(self, response: str | None) -> Device | None:
|
||||
if not response:
|
||||
return None
|
||||
try:
|
||||
raw = decode(
|
||||
response,
|
||||
options={"verify_signature": False},
|
||||
audience="goauthentik.io/platform/endpoint",
|
||||
)
|
||||
except PyJWTError as exc:
|
||||
self.stage.logger.warning("Could not parse response", exc=exc)
|
||||
raise ValidationError("Invalid challenge response") from None
|
||||
device = Device.filter_not_expired(identifier=raw["iss"]).first()
|
||||
if not device:
|
||||
self.stage.logger.warning("Could not find device for challenge")
|
||||
raise ValidationError("Invalid challenge response")
|
||||
for token in DeviceToken.filter_not_expired(
|
||||
device__device=device,
|
||||
device__connector=self.stage.executor.current_stage.connector,
|
||||
).values_list("key", flat=True):
|
||||
try:
|
||||
decoded = decode(
|
||||
response,
|
||||
key=token,
|
||||
algorithms="HS512",
|
||||
issuer=device.identifier,
|
||||
audience="goauthentik.io/platform/endpoint",
|
||||
)
|
||||
if not compare_digest(
|
||||
decoded["atc"],
|
||||
self.stage.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE],
|
||||
):
|
||||
self.stage.logger.warning("mismatched challenge")
|
||||
raise ValidationError("Invalid challenge response")
|
||||
return device
|
||||
except PyJWTError as exc:
|
||||
self.stage.logger.warning("failed to validate device challenge response", exc=exc)
|
||||
raise ValidationError("Invalid challenge response")
|
||||
return cast(AgentController, self.stage.controller).validate_device_challenge(
|
||||
response,
|
||||
self.stage.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE],
|
||||
)
|
||||
|
||||
|
||||
class AuthenticatorEndpointStageView(ChallengeStageView):
|
||||
"""Endpoint stage"""
|
||||
|
||||
response_class = EndpointAgentChallengeResponse
|
||||
controller: AgentController
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
connector: AgentConnector = stage.connector
|
||||
self.controller = connector.controller(connector)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
# Check if we're in a device interactive auth flow, in which case we use that
|
||||
@@ -119,21 +97,7 @@ class AuthenticatorEndpointStageView(ChallengeStageView):
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
stage: EndpointStage = self.executor.current_stage
|
||||
keypair = CertificateKeyPair.objects.get(pk=stage.connector.challenge_key_id)
|
||||
challenge_str = generate_id()
|
||||
iat = now()
|
||||
challenge = encode(
|
||||
{
|
||||
"atc": challenge_str,
|
||||
"iss": str(stage.pk),
|
||||
"iat": int(iat.timestamp()),
|
||||
"exp": int((iat + timedelta(minutes=5)).timestamp()),
|
||||
"goauthentik.io/device/check_in": stage.connector.challenge_trigger_check_in,
|
||||
},
|
||||
headers={"kid": keypair.kid},
|
||||
key=keypair.private_key,
|
||||
algorithm=JWTAlgorithms.from_private_key(keypair.private_key),
|
||||
)
|
||||
challenge = self.controller.generate_device_challenge()
|
||||
self.executor.plan.context[PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE] = challenge
|
||||
return EndpointAgentChallenge(
|
||||
data={
|
||||
@@ -142,6 +106,11 @@ class AuthenticatorEndpointStageView(ChallengeStageView):
|
||||
"challenge_idle_timeout": int(
|
||||
timedelta_from_string(stage.connector.challenge_idle_timeout).total_seconds()
|
||||
),
|
||||
"frame_url": self.request.build_absolute_uri(
|
||||
reverse("authentik_endpoints_connectors_agent:browser-backchannel")
|
||||
+ "?"
|
||||
+ urlencode({"xak-agent-challenge": challenge})
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from authentik.endpoints.connectors.agent.api.connectors import AgentConnectorViewSet
|
||||
from authentik.endpoints.connectors.agent.api.enrollment_tokens import EnrollmentTokenViewSet
|
||||
from authentik.endpoints.connectors.agent.views.browser_backchannel import BrowserBackchannel
|
||||
|
||||
urlpatterns = [
|
||||
path("browser-backchannel/", BrowserBackchannel.as_view(), name="browser-backchannel"),
|
||||
]
|
||||
|
||||
api_urlpatterns = [
|
||||
("endpoints/agents/connectors", AgentConnectorViewSet),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Any
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
|
||||
from django.template.response import TemplateResponse
|
||||
from django.views import View
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from authentik.endpoints.connectors.agent.controller import AgentController
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector
|
||||
from authentik.endpoints.connectors.agent.stage import PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE
|
||||
from authentik.endpoints.models import EndpointStage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE, FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
|
||||
|
||||
class BrowserBackchannel(View):
|
||||
def get_flow_plan(self) -> FlowPlan:
|
||||
flow_plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||
return flow_plan
|
||||
|
||||
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
|
||||
super().setup(request, *args, **kwargs)
|
||||
stage: EndpointStage = self.get_flow_plan().bindings[0].stage
|
||||
connector = AgentConnector.objects.filter(pk=stage.connector_id).first()
|
||||
if not connector:
|
||||
return HttpResponseBadRequest()
|
||||
self.controller: AgentController = connector.controller(connector)
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
response = request.GET.get("xak-agent-response")
|
||||
flow_plan = self.get_flow_plan()
|
||||
try:
|
||||
dev = self.controller.validate_device_challenge(
|
||||
response, flow_plan.context.get(PLAN_CONTEXT_AGENT_ENDPOINT_CHALLENGE)
|
||||
)
|
||||
flow_plan.context[PLAN_CONTEXT_DEVICE] = dev
|
||||
request.session[SESSION_KEY_PLAN] = flow_plan
|
||||
except ValidationError:
|
||||
return HttpResponseBadRequest()
|
||||
return TemplateResponse(request, "flows/frame-submit.html")
|
||||
@@ -38246,9 +38246,12 @@ components:
|
||||
type: string
|
||||
challenge_idle_timeout:
|
||||
type: integer
|
||||
frame_url:
|
||||
type: string
|
||||
required:
|
||||
- challenge
|
||||
- challenge_idle_timeout
|
||||
- frame_url
|
||||
EndpointAgentChallengeResponseRequest:
|
||||
type: object
|
||||
description: Response to signed challenge
|
||||
|
||||
@@ -7,7 +7,7 @@ import { BaseStage } from "#flow/stages/base";
|
||||
import { EndpointAgentChallenge, EndpointAgentChallengeResponseRequest } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
@@ -88,6 +88,12 @@ export class EndpointAgentStage extends BaseStage<
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<ak-flow-card .challenge=${this.challenge}>
|
||||
${this.challenge
|
||||
? html`<iframe
|
||||
style="width:0;height:0;position:absolute;"
|
||||
src=${this.challenge?.frameUrl}
|
||||
></iframe>`
|
||||
: nothing}
|
||||
${this.challenge?.responseErrors
|
||||
? html`
|
||||
<ak-empty-state icon="fa-times"
|
||||
|
||||
Reference in New Issue
Block a user