mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
1 Commits
a11y-modal
...
saml-logou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75bce9a97 |
@@ -126,6 +126,10 @@ class SessionEndChallenge(WithUserInfoChallenge):
|
||||
invalidation_flow_url = CharField(required=False)
|
||||
brand_name = CharField(required=True)
|
||||
|
||||
sls_url = CharField(required=False)
|
||||
sls_binding = CharField(required=False)
|
||||
logout_response = CharField(required=False)
|
||||
|
||||
|
||||
class PermissionDict(TypedDict):
|
||||
"""Consent Permission"""
|
||||
|
||||
@@ -33,6 +33,7 @@ from authentik.lib.utils.reflection import class_to_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
|
||||
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
|
||||
HIST_FLOWS_STAGE_TIME = Histogram(
|
||||
@@ -292,7 +293,11 @@ class SessionEndStage(ChallengeStageView):
|
||||
"to": reverse("authentik_core:root-redirect"),
|
||||
},
|
||||
)
|
||||
|
||||
application: Application | None = self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION)
|
||||
provider: SAMLProvider | None = self.executor.plan.context.get("provider")
|
||||
logout_response: str | None = self.executor.plan.context.get("logout_response")
|
||||
|
||||
data = {
|
||||
"component": "ak-stage-session-end",
|
||||
"brand_name": self.request.brand.branding_title,
|
||||
@@ -300,6 +305,10 @@ class SessionEndStage(ChallengeStageView):
|
||||
if application:
|
||||
data["application_name"] = application.name
|
||||
data["application_launch_url"] = application.get_launch_url(self.get_pending_user())
|
||||
if logout_response:
|
||||
data["logout_response"] = logout_response
|
||||
data["sls_url"] = provider.sls_url
|
||||
data["sls_binding"] = provider.sls_binding
|
||||
if self.request.brand.flow_invalidation:
|
||||
data["invalidation_flow_url"] = reverse(
|
||||
"authentik_core:if-flow",
|
||||
|
||||
115
authentik/providers/saml/processors/logout_response_processor.py
Normal file
115
authentik/providers/saml/processors/logout_response_processor.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""LogoutResponse processor"""
|
||||
|
||||
import xmlsec
|
||||
from lxml import etree
|
||||
from lxml.etree import Element, SubElement
|
||||
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
SIGN_ALGORITHM_TRANSFORM_MAP,
|
||||
)
|
||||
|
||||
|
||||
class LogoutResponseProcessor:
|
||||
"""Generate a SAML LogoutResponse"""
|
||||
|
||||
provider: SAMLProvider
|
||||
logout_request: LogoutRequest
|
||||
_issue_instant: str
|
||||
_response_id: str
|
||||
|
||||
def __init__(self, provider: SAMLProvider, logout_request: LogoutRequest):
|
||||
self.provider = provider
|
||||
self.logout_request = logout_request
|
||||
self._issue_instant = get_time_string()
|
||||
self._response_id = get_random_id()
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.provider.issuer
|
||||
return issuer
|
||||
|
||||
def get_response(self, status: str = "Success", destination: str | None = None) -> Element:
|
||||
"""Generate LogoutResponse XML"""
|
||||
response = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutResponse", nsmap=NS_MAP)
|
||||
response.attrib["Version"] = "2.0"
|
||||
response.attrib["IssueInstant"] = self._issue_instant
|
||||
response.attrib["ID"] = self._response_id
|
||||
|
||||
if destination:
|
||||
response.attrib["Destination"] = destination
|
||||
|
||||
if self.logout_request.id:
|
||||
response.attrib["InResponseTo"] = self.logout_request.id
|
||||
|
||||
response.append(self.get_issuer())
|
||||
|
||||
# Add Status element
|
||||
status_element = SubElement(response, f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
status_code = SubElement(status_element, f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
status_code.attrib["Value"] = f"urn:oasis:names:tc:SAML:2.0:status:{status}"
|
||||
|
||||
# Add signature if configured
|
||||
if self.provider.signing_kp:
|
||||
self._add_signature(response)
|
||||
|
||||
return response
|
||||
|
||||
def _add_signature(self, element: Element):
|
||||
"""Add signature placeholder to element"""
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
signature = xmlsec.template.create(
|
||||
element,
|
||||
xmlsec.constants.TransformExclC14N,
|
||||
sign_algorithm_transform,
|
||||
ns=xmlsec.constants.DSigNs,
|
||||
)
|
||||
element.insert(1, signature) # Insert after Issuer
|
||||
|
||||
def build_response(self, status: str = "Success", destination: str | None = None) -> str:
|
||||
"""Build and sign the response, return as string"""
|
||||
response = self.get_response(status, destination)
|
||||
|
||||
if self.provider.signing_kp:
|
||||
self._sign_response(response)
|
||||
|
||||
return etree.tostring(response, encoding="unicode", pretty_print=False)
|
||||
|
||||
def _sign_response(self, response: Element):
|
||||
"""Sign the response element"""
|
||||
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
|
||||
self.provider.digest_algorithm, xmlsec.constants.TransformSha1
|
||||
)
|
||||
|
||||
xmlsec.tree.add_ids(response, ["ID"])
|
||||
signature_node = xmlsec.tree.find_node(response, xmlsec.constants.NodeSignature)
|
||||
|
||||
ref = xmlsec.template.add_reference(
|
||||
signature_node,
|
||||
digest_algorithm_transform,
|
||||
uri="#" + response.attrib["ID"],
|
||||
)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
|
||||
key_info = xmlsec.template.ensure_key_info(signature_node)
|
||||
xmlsec.template.add_x509_data(key_info)
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
ctx.key = xmlsec.Key.from_memory(
|
||||
self.provider.signing_kp.key_data, # Use key_data for the private key
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
)
|
||||
ctx.key.load_cert_from_memory(
|
||||
self.provider.signing_kp.certificate_data, xmlsec.constants.KeyDataFormatPem
|
||||
)
|
||||
ctx.sign(signature_node)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""logout response tests"""
|
||||
|
||||
from defusedxml import ElementTree
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.sources.saml.processors.constants import NS_SAML_ASSERTION, NS_SAML_PROTOCOL
|
||||
|
||||
|
||||
class TestLogoutResponse(TestCase):
|
||||
"""Test LogoutResponse processor"""
|
||||
|
||||
@apply_blueprint("system/providers-saml.yaml")
|
||||
def setUp(self):
|
||||
cert = create_test_cert()
|
||||
self.provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
authorization_flow=create_test_flow(),
|
||||
acs_url="http://testserver/source/saml/provider/acs/",
|
||||
sls_url="http://testserver/source/saml/provider/sls/",
|
||||
signing_kp=cert,
|
||||
verification_kp=cert,
|
||||
)
|
||||
self.provider.property_mappings.set(SAMLPropertyMapping.objects.all())
|
||||
self.provider.save()
|
||||
|
||||
def test_build_response(self):
|
||||
"""Test building a LogoutResponse"""
|
||||
logout_request = LogoutRequest(
|
||||
id="test-request-id",
|
||||
issuer="test-sp",
|
||||
relay_state="test-relay-state",
|
||||
)
|
||||
|
||||
processor = LogoutResponseProcessor(self.provider, logout_request)
|
||||
response_xml = processor.build_response(status="Success", destination=self.provider.sls_url)
|
||||
|
||||
# Parse and verify
|
||||
root = ElementTree.fromstring(response_xml)
|
||||
self.assertEqual(root.tag, f"{{{NS_SAML_PROTOCOL}}}LogoutResponse")
|
||||
self.assertEqual(root.attrib["Version"], "2.0")
|
||||
self.assertEqual(root.attrib["Destination"], self.provider.sls_url)
|
||||
self.assertEqual(root.attrib["InResponseTo"], "test-request-id")
|
||||
|
||||
# Check Issuer
|
||||
issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
self.assertEqual(issuer.text, self.provider.issuer)
|
||||
|
||||
# Check Status
|
||||
status = root.find(f".//{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
self.assertEqual(status.attrib["Value"], "urn:oasis:names:tc:SAML:2.0:status:Success")
|
||||
@@ -16,8 +16,10 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider, SAMLSession
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider, SAMLSession
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode, nice64
|
||||
from authentik.providers.saml.views.flows import (
|
||||
PLAN_CONTEXT_SAML_LOGOUT_REQUEST,
|
||||
PLAN_CONTEXT_SAML_RELAY_STATE,
|
||||
@@ -68,6 +70,21 @@ class SPInitiatedSLOView(PolicyAccessView):
|
||||
**self.plan_context,
|
||||
},
|
||||
)
|
||||
processor = LogoutResponseProcessor(
|
||||
self.provider, self.plan_context.get(PLAN_CONTEXT_SAML_LOGOUT_REQUEST)
|
||||
)
|
||||
response_xml = processor.build_response(status="Success", destination=self.provider.sls_url)
|
||||
|
||||
# Encode the logout response based on binding type
|
||||
if self.provider.sls_binding == SAMLBindings.REDIRECT:
|
||||
# For redirect binding, deflate and base64 encode
|
||||
encoded_response = deflate_and_base64_encode(response_xml)
|
||||
else:
|
||||
# For POST binding, just base64 encode
|
||||
encoded_response = nice64(response_xml)
|
||||
|
||||
plan.context["provider"] = self.provider
|
||||
plan.context["logout_response"] = encoded_response
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
|
||||
# Remove samlsession from database
|
||||
|
||||
@@ -50272,6 +50272,12 @@ components:
|
||||
type: string
|
||||
brand_name:
|
||||
type: string
|
||||
sls_url:
|
||||
type: string
|
||||
sls_binding:
|
||||
type: string
|
||||
logout_response:
|
||||
type: string
|
||||
required:
|
||||
- brand_name
|
||||
- pending_user
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BaseStage } from "#flow/stages/base";
|
||||
import { SessionEndChallenge } from "@goauthentik/api";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@@ -23,6 +23,47 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
export class SessionEnd extends BaseStage<SessionEndChallenge, unknown> {
|
||||
static styles: CSSResult[] = [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
|
||||
|
||||
firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.sendLogoutResponse();
|
||||
}
|
||||
|
||||
protected sendLogoutResponse(): void {
|
||||
if (!this.challenge.slsUrl || !this.challenge.logoutResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const slsUrl = this.challenge.slsUrl;
|
||||
const logoutResponse = this.challenge.logoutResponse;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.display = "none";
|
||||
iframe.name = "saml-logout-response";
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
if (this.challenge.slsBinding === "redirect") {
|
||||
const params = new URLSearchParams();
|
||||
params.set("SAMLResponse", logoutResponse);
|
||||
iframe.src = `${slsUrl}?${params.toString()}`;
|
||||
} else {
|
||||
// For POST binding, create form that targets the iframe
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = slsUrl;
|
||||
form.target = iframe.name;
|
||||
|
||||
const samlInput = document.createElement("input");
|
||||
samlInput.type = "hidden";
|
||||
samlInput.name = "SAMLResponse";
|
||||
samlInput.value = logoutResponse;
|
||||
form.appendChild(samlInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
}
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<ak-flow-card .challenge=${this.challenge}>
|
||||
<form class="pf-c-form">
|
||||
|
||||
Reference in New Issue
Block a user