providers/saml: rewrite SAML AuthNRequest Parser and Response Processor
This commit is contained in:
parent
1b0c013d8e
commit
2056b86ce7
|
@ -11,7 +11,6 @@ from e2e.utils import USER, SeleniumTestCase
|
|||
from passbook.core.models import Application
|
||||
from passbook.crypto.models import CertificateKeyPair
|
||||
from passbook.flows.models import Flow
|
||||
from passbook.lib.utils.reflection import class_to_path
|
||||
from passbook.policies.expression.models import ExpressionPolicy
|
||||
from passbook.policies.models import PolicyBinding
|
||||
from passbook.providers.saml.models import (
|
||||
|
@ -19,7 +18,6 @@ from passbook.providers.saml.models import (
|
|||
SAMLPropertyMapping,
|
||||
SAMLProvider,
|
||||
)
|
||||
from passbook.providers.saml.processors.generic import GenericProcessor
|
||||
|
||||
|
||||
class TestProviderSAML(SeleniumTestCase):
|
||||
|
@ -70,7 +68,6 @@ class TestProviderSAML(SeleniumTestCase):
|
|||
)
|
||||
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
name="saml-test",
|
||||
processor_path=class_to_path(GenericProcessor),
|
||||
acs_url="http://localhost:9009/saml/acs",
|
||||
audience="passbook-e2e",
|
||||
issuer="passbook-e2e",
|
||||
|
@ -104,7 +101,6 @@ class TestProviderSAML(SeleniumTestCase):
|
|||
)
|
||||
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
name="saml-test",
|
||||
processor_path=class_to_path(GenericProcessor),
|
||||
acs_url="http://localhost:9009/saml/acs",
|
||||
audience="passbook-e2e",
|
||||
issuer="passbook-e2e",
|
||||
|
@ -146,7 +142,6 @@ class TestProviderSAML(SeleniumTestCase):
|
|||
)
|
||||
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
name="saml-test",
|
||||
processor_path=class_to_path(GenericProcessor),
|
||||
acs_url="http://localhost:9009/saml/acs",
|
||||
audience="passbook-e2e",
|
||||
issuer="passbook-e2e",
|
||||
|
@ -188,7 +183,6 @@ class TestProviderSAML(SeleniumTestCase):
|
|||
)
|
||||
provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
name="saml-test",
|
||||
processor_path=class_to_path(GenericProcessor),
|
||||
acs_url="http://localhost:9009/saml/acs",
|
||||
audience="passbook-e2e",
|
||||
issuer="passbook-e2e",
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# Generated by Django 3.0.8 on 2020-07-11 00:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_saml", "0004_auto_20200620_1950"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="samlprovider", name="processor_path",),
|
||||
]
|
215
passbook/providers/saml/processors/assertion.py
Normal file
215
passbook/providers/saml/processors/assertion.py
Normal file
|
@ -0,0 +1,215 @@
|
|||
"""SAML Assertion generator"""
|
||||
from types import GeneratorType
|
||||
|
||||
from defusedxml import ElementTree
|
||||
from django.http import HttpRequest
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element, SubElement # nosec
|
||||
from signxml import XMLSigner, XMLVerifier
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.exceptions import PropertyMappingExpressionException
|
||||
from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from passbook.providers.saml.processors.request_parser import AuthNRequest
|
||||
from passbook.providers.saml.utils import get_random_id
|
||||
from passbook.providers.saml.utils.time import get_time_string, timedelta_from_string
|
||||
from passbook.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
NS_SIGNATURE,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class AssertionProcessor:
|
||||
"""Generate a SAML Response from an AuthNRequest"""
|
||||
|
||||
provider: SAMLProvider
|
||||
http_request: HttpRequest
|
||||
auth_n_request: AuthNRequest
|
||||
|
||||
_issue_instant: str
|
||||
_assertion_id: str
|
||||
|
||||
_valid_not_before: str
|
||||
_valid_not_on_or_after: str
|
||||
|
||||
def __init__(
|
||||
self, provider: SAMLProvider, request: HttpRequest, auth_n_request: AuthNRequest
|
||||
):
|
||||
self.provider = provider
|
||||
self.http_request = request
|
||||
self.auth_n_request = auth_n_request
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._assertion_id = get_random_id()
|
||||
|
||||
self._valid_not_before = get_time_string(
|
||||
timedelta_from_string(self.provider.assertion_valid_not_before)
|
||||
)
|
||||
self._valid_not_on_or_after = get_time_string(
|
||||
timedelta_from_string(self.provider.assertion_valid_not_on_or_after)
|
||||
)
|
||||
|
||||
def get_attributes(self) -> Element:
|
||||
"""Get AttributeStatement Element with Attributes from Property Mappings."""
|
||||
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
|
||||
attribute_statement = Element(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
|
||||
for mapping in self.provider.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, SAMLPropertyMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SAMLPropertyMapping
|
||||
value = mapping.evaluate(
|
||||
user=self.http_request.user,
|
||||
request=self.http_request,
|
||||
provider=self.provider,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute")
|
||||
attribute.attrib["FriendlyName"] = mapping.friendly_name
|
||||
attribute.attrib["Name"] = mapping.saml_name
|
||||
|
||||
if not isinstance(value, (list, GeneratorType)):
|
||||
value = [value]
|
||||
|
||||
for value_item in value:
|
||||
attribute_value = SubElement(
|
||||
attribute, f"{{{NS_SAML_ASSERTION}}}AttributeValue"
|
||||
)
|
||||
if not isinstance(value_item, str):
|
||||
value_item = str(value_item)
|
||||
attribute_value.text = value_item
|
||||
|
||||
attribute_statement.append(attribute)
|
||||
|
||||
except PropertyMappingExpressionException as exc:
|
||||
LOGGER.warning(exc)
|
||||
continue
|
||||
return attribute_statement
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer Element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer", nsmap=NS_MAP)
|
||||
issuer.text = self.provider.issuer
|
||||
return issuer
|
||||
|
||||
def get_assertion_auth_n_statement(self) -> Element:
|
||||
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
|
||||
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
||||
auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
|
||||
auth_n_statement.attrib["SessionIndex"] = self._assertion_id
|
||||
|
||||
auth_n_context = SubElement(
|
||||
auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext"
|
||||
)
|
||||
auth_n_context_class_ref = SubElement(
|
||||
auth_n_context, f"{{{NS_SAML_ASSERTION}}}AuthnContextClassRef"
|
||||
)
|
||||
auth_n_context_class_ref.text = (
|
||||
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
|
||||
)
|
||||
return auth_n_statement
|
||||
|
||||
def get_assertion_conditions(self) -> Element:
|
||||
"""Generate Conditions with AudienceRestriction and Audience Elements."""
|
||||
conditions = Element(f"{{{NS_SAML_ASSERTION}}}Conditions")
|
||||
conditions.attrib["NotBefore"] = self._valid_not_before
|
||||
conditions.attrib["NotOnOrAfter"] = self._valid_not_on_or_after
|
||||
audience_restriction = SubElement(
|
||||
conditions, f"{{{NS_SAML_ASSERTION}}}AudienceRestriction"
|
||||
)
|
||||
audience = SubElement(audience_restriction, f"{{{NS_SAML_ASSERTION}}}Audience")
|
||||
audience.text = self.provider.audience
|
||||
return conditions
|
||||
|
||||
def get_assertion_subject(self) -> Element:
|
||||
"""Generate Subject Element with NameID and SubjectConfirmation Objects"""
|
||||
subject = Element(f"{{{NS_SAML_ASSERTION}}}Subject")
|
||||
|
||||
name_id = SubElement(subject, f"{{{NS_SAML_ASSERTION}}}NameID")
|
||||
# TODO: Make this configurable
|
||||
name_id.attrib["Format"] = SAML_NAME_ID_FORMAT_EMAIL
|
||||
name_id.text = self.http_request.user.email
|
||||
|
||||
subject_confirmation = SubElement(
|
||||
subject, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmation"
|
||||
)
|
||||
subject_confirmation.attrib["Method"] = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
|
||||
|
||||
subject_confirmation_data = SubElement(
|
||||
subject_confirmation, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmationData"
|
||||
)
|
||||
if self.auth_n_request.id:
|
||||
subject_confirmation_data.attrib["InResponseTo"] = self.auth_n_request.id
|
||||
subject_confirmation_data.attrib["NotOnOrAfter"] = self._issue_instant
|
||||
subject_confirmation_data.attrib["Recipient"] = self.provider.acs_url
|
||||
return subject
|
||||
|
||||
def get_assertion(self) -> Element:
|
||||
"""Generate Main Assertion Element"""
|
||||
assertion = Element(f"{{{NS_SAML_ASSERTION}}}Assertion", nsmap=NS_MAP)
|
||||
assertion.attrib["Version"] = "2.0"
|
||||
assertion.attrib["ID"] = self._assertion_id
|
||||
assertion.attrib["IssueInstant"] = self._issue_instant
|
||||
assertion.append(self.get_issuer())
|
||||
|
||||
if self.provider.signing_kp:
|
||||
# We need a placeholder signature as SAML requires the signature to be between
|
||||
# Issuer and subject
|
||||
signature_placeholder = SubElement(
|
||||
assertion, f"{{{NS_SIGNATURE}}}Signature", nsmap=NS_MAP
|
||||
)
|
||||
signature_placeholder.attrib["Id"] = "placeholder"
|
||||
|
||||
assertion.append(self.get_assertion_subject())
|
||||
assertion.append(self.get_assertion_conditions())
|
||||
assertion.append(self.get_assertion_auth_n_statement())
|
||||
|
||||
assertion.append(self.get_attributes())
|
||||
return assertion
|
||||
|
||||
def get_response(self) -> Element:
|
||||
"""Generate Root response element"""
|
||||
response = Element(f"{{{NS_SAML_PROTOCOL}}}Response", nsmap=NS_MAP)
|
||||
response.attrib["Version"] = "2.0"
|
||||
response.attrib["IssueInstant"] = self._issue_instant
|
||||
response.attrib["Destination"] = self.provider.acs_url
|
||||
response.attrib["ID"] = get_random_id()
|
||||
if self.auth_n_request.id:
|
||||
response.attrib["InResponseTo"] = self.auth_n_request.id
|
||||
|
||||
response.append(self.get_issuer())
|
||||
|
||||
status = SubElement(response, f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
status_code = SubElement(status, f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
status_code.attrib["Value"] = "urn:oasis:names:tc:SAML:2.0:status:Success"
|
||||
|
||||
response.append(self.get_assertion())
|
||||
return response
|
||||
|
||||
def build_response(self) -> str:
|
||||
"""Build string XML Response and sign if signing is enabled."""
|
||||
root_response = self.get_response()
|
||||
if self.provider.signing_kp:
|
||||
signer = XMLSigner(
|
||||
c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
|
||||
signature_algorithm=self.provider.signature_algorithm,
|
||||
digest_algorithm=self.provider.digest_algorithm,
|
||||
)
|
||||
signed = signer.sign(
|
||||
root_response,
|
||||
key=self.provider.signing_kp.private_key,
|
||||
cert=[self.provider.signing_kp.certificate_data],
|
||||
reference_uri=self._assertion_id,
|
||||
)
|
||||
XMLVerifier().verify(
|
||||
signed, x509_cert=self.provider.signing_kp.certificate_data
|
||||
)
|
||||
return etree.tostring(signed).decode("utf-8") # nosec
|
||||
return ElementTree.tostring(root_response).decode()
|
|
@ -1,247 +0,0 @@
|
|||
"""Basic SAML Processor"""
|
||||
from types import GeneratorType
|
||||
from typing import TYPE_CHECKING, Dict, List, Union
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from defusedxml import ElementTree
|
||||
from django.http import HttpRequest
|
||||
from signxml import XMLVerifier
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.exceptions import PropertyMappingExpressionException
|
||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||
from passbook.providers.saml.processors.types import SAMLResponseParams
|
||||
from passbook.providers.saml.utils import get_random_id
|
||||
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate, nice64
|
||||
from passbook.providers.saml.utils.time import get_time_string, timedelta_from_string
|
||||
from passbook.providers.saml.utils.xml_render import get_assertion_xml, get_response_xml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class Processor:
|
||||
"""Base SAML 2.0 Auth-N-Request to Response Processor.
|
||||
Sub-classes should provide Service Provider-specific functionality."""
|
||||
|
||||
is_idp_initiated = False
|
||||
|
||||
_remote: "SAMLProvider"
|
||||
_http_request: HttpRequest
|
||||
|
||||
_assertion_xml: str
|
||||
_response_xml: str
|
||||
_saml_response: str
|
||||
|
||||
_relay_state: str
|
||||
_saml_request: str
|
||||
|
||||
_assertion_params: Dict[str, Union[str, List[Dict[str, str]]]]
|
||||
_request_params: Dict[str, str]
|
||||
_response_params: Dict[str, str]
|
||||
|
||||
@property
|
||||
def subject_format(self) -> str:
|
||||
"""Get subject Format"""
|
||||
return "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
||||
|
||||
def __init__(self, remote: "SAMLProvider"):
|
||||
self.name = remote.name
|
||||
self._remote = remote
|
||||
self._logger = get_logger()
|
||||
|
||||
def _build_assertion(self):
|
||||
"""Builds _assertion_params."""
|
||||
self._assertion_params = {
|
||||
"ASSERTION_ID": get_random_id(),
|
||||
"ASSERTION_SIGNATURE": "", # it's unsigned
|
||||
"AUDIENCE": self._remote.audience,
|
||||
"AUTH_INSTANT": get_time_string(),
|
||||
"ISSUE_INSTANT": get_time_string(),
|
||||
"NOT_BEFORE": get_time_string(
|
||||
timedelta_from_string(self._remote.assertion_valid_not_before)
|
||||
),
|
||||
"NOT_ON_OR_AFTER": get_time_string(
|
||||
timedelta_from_string(self._remote.assertion_valid_not_on_or_after)
|
||||
),
|
||||
"SESSION_INDEX": self._http_request.session.session_key,
|
||||
"SESSION_NOT_ON_OR_AFTER": get_time_string(
|
||||
timedelta_from_string(self._remote.session_valid_not_on_or_after)
|
||||
),
|
||||
"SP_NAME_QUALIFIER": self._remote.audience,
|
||||
"SUBJECT": self._http_request.user.email,
|
||||
"SUBJECT_FORMAT": self.subject_format,
|
||||
"ISSUER": self._remote.issuer,
|
||||
}
|
||||
self._assertion_params.update(self._request_params)
|
||||
|
||||
def _build_response(self):
|
||||
"""Builds _response_params."""
|
||||
self._response_params = {
|
||||
"ASSERTION": self._assertion_xml,
|
||||
"ISSUE_INSTANT": get_time_string(),
|
||||
"RESPONSE_ID": get_random_id(),
|
||||
"RESPONSE_SIGNATURE": "", # initially unsigned
|
||||
"ISSUER": self._remote.issuer,
|
||||
}
|
||||
self._response_params.update(self._request_params)
|
||||
|
||||
def _encode_response(self):
|
||||
"""Encodes _response_xml to _encoded_xml."""
|
||||
self._saml_response = nice64(str.encode(self._response_xml))
|
||||
|
||||
def _extract_saml_request(self):
|
||||
"""Retrieves the _saml_request AuthnRequest from the _http_request."""
|
||||
self._saml_request = self._http_request.session["SAMLRequest"]
|
||||
self._relay_state = self._http_request.session["RelayState"]
|
||||
|
||||
def _format_assertion(self):
|
||||
"""Formats _assertion_params as _assertion_xml."""
|
||||
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
|
||||
attributes = []
|
||||
from passbook.providers.saml.models import SAMLPropertyMapping
|
||||
|
||||
for mapping in self._remote.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, SAMLPropertyMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SAMLPropertyMapping
|
||||
value = mapping.evaluate(
|
||||
user=self._http_request.user,
|
||||
request=self._http_request,
|
||||
provider=self._remote,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
mapping_payload = {
|
||||
"Name": mapping.saml_name,
|
||||
"FriendlyName": mapping.friendly_name,
|
||||
}
|
||||
# Normal values and arrays need different dict keys as they are handeled
|
||||
# differently in the template
|
||||
if isinstance(value, list):
|
||||
mapping_payload["ValueArray"] = value
|
||||
elif isinstance(value, GeneratorType):
|
||||
mapping_payload["ValueArray"] = list(value)
|
||||
else:
|
||||
mapping_payload["Value"] = value
|
||||
attributes.append(mapping_payload)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
self._logger.warning(exc)
|
||||
continue
|
||||
self._assertion_params["ATTRIBUTES"] = attributes
|
||||
self._assertion_xml = get_assertion_xml(
|
||||
"providers/saml/xml/assertions/generic.xml",
|
||||
self._assertion_params,
|
||||
signed=True,
|
||||
)
|
||||
|
||||
def _format_response(self):
|
||||
"""Formats _response_params as _response_xml."""
|
||||
assertion_id = self._assertion_params["ASSERTION_ID"]
|
||||
self._response_xml = get_response_xml(
|
||||
self._response_params, saml_provider=self._remote, assertion_id=assertion_id
|
||||
)
|
||||
|
||||
def _get_saml_response_params(self) -> SAMLResponseParams:
|
||||
"""Returns a dictionary of parameters for the response template."""
|
||||
return SAMLResponseParams(
|
||||
acs_url=self._request_params["ACS_URL"],
|
||||
saml_response=self._saml_response,
|
||||
relay_state=self._relay_state,
|
||||
)
|
||||
|
||||
def _decode_and_parse_request(self):
|
||||
"""Parses various parameters from _request_xml into _request_params."""
|
||||
decoded_xml = decode_base64_and_inflate(self._saml_request)
|
||||
|
||||
if self._remote.require_signing and self._remote.signing_kp:
|
||||
self._logger.debug("Verifying Request signature")
|
||||
try:
|
||||
XMLVerifier().verify(
|
||||
decoded_xml, x509_cert=self._remote.signing_kp.certificate_data
|
||||
)
|
||||
except InvalidSignature as exc:
|
||||
raise CannotHandleAssertion("Failed to verify signature") from exc
|
||||
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
|
||||
params = {}
|
||||
params["ACS_URL"] = root.attrib.get(
|
||||
"AssertionConsumerServiceURL", self._remote.acs_url
|
||||
)
|
||||
params["REQUEST_ID"] = root.attrib["ID"]
|
||||
params["DESTINATION"] = root.attrib.get("Destination", "")
|
||||
params["PROVIDER_NAME"] = root.attrib.get("ProviderName", "")
|
||||
self._request_params = params
|
||||
|
||||
def _validate_request(self):
|
||||
"""
|
||||
Validates the SAML request against the SP configuration of this
|
||||
processor. Sub-classes should override this and raise a
|
||||
`CannotHandleAssertion` exception if the validation fails.
|
||||
|
||||
Raises:
|
||||
CannotHandleAssertion: if the ACS URL specified in the SAML request
|
||||
doesn't match the one specified in the processor config.
|
||||
"""
|
||||
request_acs_url = self._request_params["ACS_URL"]
|
||||
|
||||
if self._remote.acs_url != request_acs_url:
|
||||
msg = (
|
||||
f"ACS URL of {request_acs_url} doesn't match Provider "
|
||||
f"ACS URL of {self._remote.acs_url}."
|
||||
)
|
||||
self._logger.info(msg)
|
||||
raise CannotHandleAssertion(msg)
|
||||
|
||||
def can_handle(self, request: HttpRequest) -> bool:
|
||||
"""Returns true if this processor can handle this request."""
|
||||
self._http_request = request
|
||||
# Read the request.
|
||||
try:
|
||||
self._extract_saml_request()
|
||||
except KeyError:
|
||||
raise CannotHandleAssertion("Couldn't find SAML request in user session")
|
||||
|
||||
try:
|
||||
self._decode_and_parse_request()
|
||||
except Exception as exc:
|
||||
raise CannotHandleAssertion(f"Couldn't parse SAML request: {exc}") from exc
|
||||
|
||||
self._validate_request()
|
||||
return True
|
||||
|
||||
def generate_response(self) -> SAMLResponseParams:
|
||||
"""Processes request and returns template variables suitable for a response."""
|
||||
# Build the assertion and response.
|
||||
# Only call can_handle if SP initiated Request, otherwise we have no Request
|
||||
if not self.is_idp_initiated:
|
||||
self.can_handle(self._http_request)
|
||||
|
||||
self._build_assertion()
|
||||
self._format_assertion()
|
||||
self._build_response()
|
||||
self._format_response()
|
||||
self._encode_response()
|
||||
|
||||
# Return proper template params.
|
||||
return self._get_saml_response_params()
|
||||
|
||||
def init_deep_link(self, request: HttpRequest):
|
||||
"""Initialize this Processor to make an IdP-initiated call to the SP's
|
||||
deep-linked URL."""
|
||||
self._http_request = request
|
||||
acs_url = self._remote.acs_url
|
||||
# NOTE: The following request params are made up. Some are blank,
|
||||
# because they comes over in the AuthnRequest, but we don't have an
|
||||
# AuthnRequest in this case:
|
||||
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
|
||||
# - ProviderName: According to the spec, this is optional.
|
||||
self._request_params = {
|
||||
"ACS_URL": acs_url,
|
||||
"DESTINATION": "",
|
||||
"PROVIDER_NAME": "",
|
||||
}
|
||||
self._relay_state = ""
|
|
@ -1,7 +0,0 @@
|
|||
"""Generic Processor"""
|
||||
|
||||
from passbook.providers.saml.processors.base import Processor
|
||||
|
||||
|
||||
class GenericProcessor(Processor):
|
||||
"""Generic SAML2 Processor"""
|
111
passbook/providers/saml/processors/metadata.py
Normal file
111
passbook/providers/saml/processors/metadata.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
"""SAML Identity Provider Metadata Processor"""
|
||||
from typing import Iterator, Optional
|
||||
|
||||
from defusedxml import ElementTree
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from lxml.etree import Element, SubElement # nosec
|
||||
from signxml.util import strip_pem_header
|
||||
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
from passbook.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_METADATA,
|
||||
NS_SIGNATURE,
|
||||
SAML_BINDING_POST,
|
||||
SAML_BINDING_REDIRECT,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_PRESISTENT,
|
||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||
SAML_NAME_ID_FORMAT_WINDOWS,
|
||||
SAML_NAME_ID_FORMAT_X509,
|
||||
)
|
||||
|
||||
|
||||
class MetadataProcessor:
|
||||
"""SAML Identity Provider Metadata Processor"""
|
||||
|
||||
provider: SAMLProvider
|
||||
http_request: HttpRequest
|
||||
|
||||
def __init__(self, provider: SAMLProvider, request: HttpRequest):
|
||||
self.provider = provider
|
||||
self.http_request = request
|
||||
|
||||
def get_signing_key_descriptor(self) -> Optional[Element]:
|
||||
"""Get Singing KeyDescriptor, if enabled for the provider"""
|
||||
if self.provider.signing_kp:
|
||||
key_descriptor = Element(f"{{{NS_SAML_METADATA}}}KeyDescriptor")
|
||||
key_descriptor.attrib["use"] = "signing"
|
||||
key_info = SubElement(key_descriptor, f"{{{NS_SIGNATURE}}}KeyInfo")
|
||||
x509_data = SubElement(key_info, f"{{{NS_SIGNATURE}}}X509Data")
|
||||
x509_certificate = SubElement(
|
||||
x509_data, f"{{{NS_SIGNATURE}}}X509Certificate"
|
||||
)
|
||||
x509_certificate.text = strip_pem_header(
|
||||
self.provider.signing_kp.certificate_data.replace("\r", "")
|
||||
).replace("\n", "")
|
||||
return key_descriptor
|
||||
return None
|
||||
|
||||
def get_name_id_formats(self) -> Iterator[Element]:
|
||||
"""Get compatible NameID Formats"""
|
||||
formats = [
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_PRESISTENT,
|
||||
SAML_NAME_ID_FORMAT_X509,
|
||||
SAML_NAME_ID_FORMAT_WINDOWS,
|
||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||
]
|
||||
for name_id_format in formats:
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat")
|
||||
element.text = name_id_format
|
||||
yield element
|
||||
|
||||
def get_bindings(self) -> Iterator[Element]:
|
||||
"""Get all Bindings supported"""
|
||||
binding_url_map = {
|
||||
SAML_BINDING_POST: self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:sso-post",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
SAML_BINDING_REDIRECT: self.http_request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:sso-redirect",
|
||||
kwargs={"application_slug": self.provider.application.slug},
|
||||
)
|
||||
),
|
||||
}
|
||||
for binding, url in binding_url_map.items():
|
||||
element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService")
|
||||
element.attrib["Binding"] = binding
|
||||
element.attrib["Location"] = url
|
||||
yield element
|
||||
|
||||
def build_entity_descriptor(self) -> str:
|
||||
"""Build full EntityDescriptor"""
|
||||
entity_descriptor = Element(
|
||||
f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP
|
||||
)
|
||||
entity_descriptor.attrib["entityID"] = self.provider.issuer
|
||||
|
||||
idp_sso_descriptor = SubElement(
|
||||
entity_descriptor, f"{{{NS_SAML_METADATA}}}IDPSSODescriptor"
|
||||
)
|
||||
idp_sso_descriptor.attrib[
|
||||
"protocolSupportEnumeration"
|
||||
] = "urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
|
||||
signing_descriptor = self.get_signing_key_descriptor()
|
||||
if signing_descriptor is not None:
|
||||
idp_sso_descriptor.append(signing_descriptor)
|
||||
|
||||
for name_id_format in self.get_name_id_formats():
|
||||
idp_sso_descriptor.append(name_id_format)
|
||||
|
||||
for binding in self.get_bindings():
|
||||
idp_sso_descriptor.append(binding)
|
||||
|
||||
return ElementTree.tostring(entity_descriptor).decode()
|
66
passbook/providers/saml/processors/request_parser.py
Normal file
66
passbook/providers/saml/processors/request_parser.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
"""SAML AuthNRequest Parser and dataclass"""
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from defusedxml import ElementTree
|
||||
from signxml import XMLVerifier
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
from passbook.providers.saml.utils import get_random_id
|
||||
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthNRequest:
|
||||
"""AuthNRequest Dataclass"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
id: Optional[str] = None
|
||||
|
||||
relay_state: str = ""
|
||||
|
||||
|
||||
class AuthNRequestParser:
|
||||
"""AuthNRequest Parser"""
|
||||
|
||||
provider: SAMLProvider
|
||||
|
||||
def __init__(self, provider: SAMLProvider):
|
||||
self.provider = provider
|
||||
|
||||
def parse(self, saml_request: str, relay_state: str) -> AuthNRequest:
|
||||
"""Parses various parameters from _request_xml into _request_params."""
|
||||
|
||||
decoded_xml = decode_base64_and_inflate(saml_request)
|
||||
|
||||
if self.provider.require_signing and self.provider.signing_kp:
|
||||
try:
|
||||
XMLVerifier().verify(
|
||||
decoded_xml, x509_cert=self.provider.signing_kp.certificate_data
|
||||
)
|
||||
except InvalidSignature as exc:
|
||||
raise CannotHandleAssertion("Failed to verify signature") from exc
|
||||
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
|
||||
request_acs_url = root.attrib["AssertionConsumerServiceURL"]
|
||||
|
||||
if self.provider.acs_url != request_acs_url:
|
||||
msg = (
|
||||
f"ACS URL of {request_acs_url} doesn't match Provider "
|
||||
f"ACS URL of {self.provider.acs_url}."
|
||||
)
|
||||
LOGGER.info(msg)
|
||||
raise CannotHandleAssertion(msg)
|
||||
|
||||
auth_n_request = AuthNRequest(id=root.attrib["ID"], relay_state=relay_state)
|
||||
return auth_n_request
|
||||
|
||||
def idp_initiated(self) -> AuthNRequest:
|
||||
"""Create IdP Initiated AuthNRequest"""
|
||||
return AuthNRequest()
|
|
@ -1,16 +0,0 @@
|
|||
"""Salesforce Processor"""
|
||||
|
||||
from passbook.providers.saml.processors.generic import GenericProcessor
|
||||
from passbook.providers.saml.utils.xml_render import get_assertion_xml
|
||||
|
||||
|
||||
class SalesForceProcessor(GenericProcessor):
|
||||
"""SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor."""
|
||||
|
||||
def _format_assertion(self):
|
||||
super()._format_assertion()
|
||||
self._assertion_xml = get_assertion_xml(
|
||||
"providers/saml/xml/assertions/salesforce.xml",
|
||||
self._assertion_params,
|
||||
signed=True,
|
||||
)
|
|
@ -1,11 +0,0 @@
|
|||
"""passbook saml provider types"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SAMLResponseParams:
|
||||
"""Class to keep track of SAML Response Parameters"""
|
||||
|
||||
acs_url: str
|
||||
saml_response: str
|
||||
relay_state: str
|
|
@ -1,19 +0,0 @@
|
|||
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
ID="{{ ASSERTION_ID }}"
|
||||
IssueInstant="{{ ISSUE_INSTANT }}"
|
||||
Version="2.0">
|
||||
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
|
||||
{% include 'providers/saml/xml/signature.xml' %}
|
||||
{{ SUBJECT_STATEMENT }}
|
||||
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
|
||||
<saml:AudienceRestriction>
|
||||
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
|
||||
</saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="{{ NOT_BEFORE }}" SessionIndex="{{ ASSERTION_ID }}">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
{{ ATTRIBUTE_STATEMENT }}
|
||||
</saml:Assertion>
|
|
@ -1,15 +0,0 @@
|
|||
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
ID="{{ ASSERTION_ID }}"
|
||||
IssueInstant="{{ ISSUE_INSTANT }}"
|
||||
Version="2.0">
|
||||
<saml:Issuer>{{ ISSUER }}</saml:Issuer>
|
||||
{% include 'providers/saml/xml/signature.xml' %}
|
||||
{% include 'providers/saml/xml/subject.xml' %}
|
||||
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" />
|
||||
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
{{ ATTRIBUTE_STATEMENT }}
|
||||
</saml:Assertion>
|
|
@ -1,19 +0,0 @@
|
|||
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
ID="{{ ASSERTION_ID }}"
|
||||
IssueInstant="{{ ISSUE_INSTANT }}"
|
||||
Version="2.0">
|
||||
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
|
||||
{{ ASSERTION_SIGNATURE|safe }}
|
||||
{% include 'providers/saml/xml/subject.xml' %}
|
||||
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
|
||||
<saml:AudienceRestriction>
|
||||
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
|
||||
</saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
{{ ATTRIBUTE_STATEMENT|safe }}
|
||||
</saml:Assertion>
|
|
@ -1,14 +0,0 @@
|
|||
<saml:AttributeStatement>
|
||||
{% for attr in attributes %}
|
||||
<saml:Attribute {% if attr.FriendlyName %}FriendlyName="{{ attr.FriendlyName }}" {% endif %}Name="{{ attr.Name }}">
|
||||
{% if attr.Value %}
|
||||
<saml:AttributeValue>{{ attr.Value }}</saml:AttributeValue>
|
||||
{% endif %}
|
||||
{% if attr.ValueArray %}
|
||||
{% for value in attr.ValueArray %}
|
||||
<saml:AttributeValue>{{ value }}</saml:AttributeValue>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</saml:Attribute>
|
||||
{% endfor %}
|
||||
</saml:AttributeStatement>
|
|
@ -1,17 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{{ entity_id }}">
|
||||
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
{% if cert_public_key %}
|
||||
<md:KeyDescriptor use="signing">
|
||||
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>{{ cert_public_key }}</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
{% endif %}
|
||||
<md:NameIDFormat>{{ subject_format }}</md:NameIDFormat>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ saml_sso_binding_post }}"/>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ saml_sso_binding_redirect }}"/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
|
@ -1,14 +0,0 @@
|
|||
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
|
||||
Destination="{{ ACS_URL }}"
|
||||
ID="{{ RESPONSE_ID }}"
|
||||
{{ IN_RESPONSE_TO|safe }}
|
||||
IssueInstant="{{ ISSUE_INSTANT }}"
|
||||
Version="2.0">
|
||||
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
|
||||
{{ ASSERTION_SIGNATURE }}
|
||||
<samlp:Status>
|
||||
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
|
||||
</samlp:Status>
|
||||
{{ ASSERTION }}
|
||||
</samlp:Response>
|
|
@ -1 +0,0 @@
|
|||
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>
|
|
@ -1,6 +0,0 @@
|
|||
<saml:Subject>
|
||||
<saml:NameID Format="{{ SUBJECT_FORMAT }}">{{ SUBJECT }}</saml:NameID>
|
||||
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||
<saml:SubjectConfirmationData {{ IN_RESPONSE_TO|safe }} NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" Recipient="{{ ACS_URL }}" />
|
||||
</saml:SubjectConfirmation>
|
||||
</saml:Subject>
|
|
@ -1,94 +0,0 @@
|
|||
"""Functions for creating XML output."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.providers.saml.utils.xml_signing import (
|
||||
get_signature_xml,
|
||||
sign_with_signxml,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def _get_attribute_statement(params):
|
||||
"""Inserts AttributeStatement, if we have any attributes.
|
||||
Modifies the params dict.
|
||||
PRE-REQ: params['SUBJECT'] has already been created (usually by a call to
|
||||
_get_subject()."""
|
||||
attributes = params.get("ATTRIBUTES", [])
|
||||
if not attributes:
|
||||
params["ATTRIBUTE_STATEMENT"] = ""
|
||||
return
|
||||
# Build complete AttributeStatement.
|
||||
params["ATTRIBUTE_STATEMENT"] = render_to_string(
|
||||
"providers/saml/xml/attributes.xml", {"attributes": attributes}
|
||||
)
|
||||
|
||||
|
||||
def _get_in_response_to(params):
|
||||
"""Insert InResponseTo if we have a RequestID.
|
||||
Modifies the params dict."""
|
||||
# NOTE: I don't like this. We're mixing templating logic here, but the
|
||||
# current design requires this; maybe refactor using better templates, or
|
||||
# just bite the bullet and use elementtree to produce the XML; see comments
|
||||
# in xml_templates about Canonical XML.
|
||||
request_id = params.get("REQUEST_ID", None)
|
||||
if request_id:
|
||||
params["IN_RESPONSE_TO"] = 'InResponseTo="%s" ' % request_id
|
||||
else:
|
||||
params["IN_RESPONSE_TO"] = ""
|
||||
|
||||
|
||||
def _get_subject(params):
|
||||
"""Insert Subject. Modifies the params dict."""
|
||||
params["SUBJECT_STATEMENT"] = render_to_string(
|
||||
"providers/saml/xml/subject.xml", params
|
||||
)
|
||||
|
||||
|
||||
def get_assertion_xml(template, parameters, signed=False):
|
||||
"""Get XML for Assertion"""
|
||||
# Reset signature.
|
||||
params = {}
|
||||
params.update(parameters)
|
||||
params["ASSERTION_SIGNATURE"] = ""
|
||||
|
||||
_get_in_response_to(params)
|
||||
_get_subject(params) # must come before _get_attribute_statement()
|
||||
_get_attribute_statement(params)
|
||||
|
||||
unsigned = render_to_string(template, params)
|
||||
if not signed:
|
||||
return unsigned
|
||||
|
||||
# Sign it.
|
||||
signature_xml = get_signature_xml()
|
||||
params["ASSERTION_SIGNATURE"] = signature_xml
|
||||
return render_to_string(template, params)
|
||||
|
||||
|
||||
def get_response_xml(parameters, saml_provider: SAMLProvider, assertion_id=""):
|
||||
"""Returns XML for response, with signatures, if signed is True."""
|
||||
# Reset signatures.
|
||||
params = {}
|
||||
params.update(parameters)
|
||||
params["RESPONSE_SIGNATURE"] = ""
|
||||
_get_in_response_to(params)
|
||||
|
||||
raw_response = render_to_string("providers/saml/xml/response.xml", params)
|
||||
|
||||
if not saml_provider.signing_kp:
|
||||
return raw_response
|
||||
|
||||
signature_xml = get_signature_xml()
|
||||
params["RESPONSE_SIGNATURE"] = signature_xml
|
||||
|
||||
signed = sign_with_signxml(raw_response, saml_provider, reference_uri=assertion_id,)
|
||||
return signed
|
|
@ -1,38 +0,0 @@
|
|||
"""Signing code goes here."""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lxml import etree # nosec
|
||||
from signxml import XMLSigner, XMLVerifier
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def sign_with_signxml(data: str, provider: "SAMLProvider", reference_uri=None) -> str:
|
||||
"""Sign Data with signxml"""
|
||||
# defused XML is not used here because it messes up XML namespaces
|
||||
# Data is trusted, so lxml is ok
|
||||
root = etree.fromstring(data) # nosec
|
||||
signer = XMLSigner(
|
||||
c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
|
||||
signature_algorithm=provider.signature_algorithm,
|
||||
digest_algorithm=provider.digest_algorithm,
|
||||
)
|
||||
signed = signer.sign(
|
||||
root,
|
||||
key=provider.signing_kp.private_key,
|
||||
cert=[provider.signing_kp.certificate_data],
|
||||
reference_uri=reference_uri,
|
||||
)
|
||||
XMLVerifier().verify(signed, x509_cert=provider.signing_kp.certificate_data)
|
||||
return etree.tostring(signed).decode("utf-8") # nosec
|
||||
|
||||
|
||||
def get_signature_xml() -> str:
|
||||
"""Returns XML Signature for subject."""
|
||||
return render_to_string("providers/saml/xml/signature.xml", {})
|
|
@ -4,13 +4,12 @@ from typing import Optional
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from signxml.util import strip_pem_header
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
|
@ -23,13 +22,18 @@ from passbook.flows.planner import (
|
|||
)
|
||||
from passbook.flows.stage import StageView
|
||||
from passbook.flows.views import SESSION_KEY_PLAN
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.lib.utils.urls import redirect_with_qs
|
||||
from passbook.lib.views import bad_request_message
|
||||
from passbook.policies.mixins import PolicyAccessMixin
|
||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||
from passbook.providers.saml.models import SAMLBindings, SAMLProvider
|
||||
from passbook.providers.saml.processors.types import SAMLResponseParams
|
||||
from passbook.providers.saml.processors.assertion import AssertionProcessor
|
||||
from passbook.providers.saml.processors.metadata import MetadataProcessor
|
||||
from passbook.providers.saml.processors.request_parser import (
|
||||
AuthNRequest,
|
||||
AuthNRequestParser,
|
||||
)
|
||||
from passbook.providers.saml.utils.encoding import nice64
|
||||
from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
@ -37,7 +41,7 @@ URL_VALIDATOR = URLValidator(schemes=("http", "https"))
|
|||
SESSION_KEY_SAML_REQUEST = "SAMLRequest"
|
||||
SESSION_KEY_SAML_RESPONSE = "SAMLResponse"
|
||||
SESSION_KEY_RELAY_STATE = "RelayState"
|
||||
SESSION_KEY_PARAMS = "SAMLParams"
|
||||
SESSION_KEY_AUTH_N_REQUEST = "authn_request"
|
||||
|
||||
|
||||
class SAMLSSOView(LoginRequiredMixin, PolicyAccessMixin, View):
|
||||
|
@ -97,17 +101,12 @@ class SAMLSSOBindingRedirectView(SAMLSSOView):
|
|||
self.request, "The SAML request payload is missing."
|
||||
)
|
||||
|
||||
self.request.session[SESSION_KEY_SAML_REQUEST] = request.GET[
|
||||
SESSION_KEY_SAML_REQUEST
|
||||
]
|
||||
self.request.session[SESSION_KEY_RELAY_STATE] = request.GET.get(
|
||||
SESSION_KEY_RELAY_STATE, ""
|
||||
)
|
||||
|
||||
try:
|
||||
self.provider.processor.can_handle(self.request)
|
||||
params = self.provider.processor.generate_response()
|
||||
self.request.session[SESSION_KEY_PARAMS] = params
|
||||
auth_n_request = AuthNRequestParser(self.provider).parse(
|
||||
request.GET[SESSION_KEY_SAML_REQUEST],
|
||||
request.GET.get(SESSION_KEY_RELAY_STATE, ""),
|
||||
)
|
||||
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
|
||||
except CannotHandleAssertion as exc:
|
||||
LOGGER.info(exc)
|
||||
return bad_request_message(self.request, str(exc))
|
||||
|
@ -130,17 +129,12 @@ class SAMLSSOBindingPOSTView(SAMLSSOView):
|
|||
self.request, "The SAML request payload is missing."
|
||||
)
|
||||
|
||||
self.request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
|
||||
SESSION_KEY_SAML_REQUEST
|
||||
]
|
||||
self.request.session[SESSION_KEY_RELAY_STATE] = request.POST.get(
|
||||
SESSION_KEY_RELAY_STATE, ""
|
||||
)
|
||||
|
||||
try:
|
||||
self.provider.processor.can_handle(self.request)
|
||||
params = self.provider.processor.generate_response()
|
||||
self.request.session[SESSION_KEY_PARAMS] = params
|
||||
auth_n_request = AuthNRequestParser(self.provider).parse(
|
||||
request.POST[SESSION_KEY_SAML_REQUEST],
|
||||
request.POST.get(SESSION_KEY_RELAY_STATE, ""),
|
||||
)
|
||||
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
|
||||
except CannotHandleAssertion as exc:
|
||||
LOGGER.info(exc)
|
||||
return bad_request_message(self.request, str(exc))
|
||||
|
@ -154,14 +148,12 @@ class SAMLSSOBindingInitView(SAMLSSOView):
|
|||
def get(
|
||||
self, request: HttpRequest, application_slug: str
|
||||
) -> Optional[HttpResponse]:
|
||||
"""Create saml params from scratch"""
|
||||
"""Create SAML Response from scratch"""
|
||||
LOGGER.debug(
|
||||
"handle_saml_no_request: No SAML Request, using IdP-initiated flow."
|
||||
)
|
||||
self.provider.processor.is_idp_initiated = True
|
||||
self.provider.processor.init_deep_link(self.request)
|
||||
params = self.provider.processor.generate_response()
|
||||
self.request.session[SESSION_KEY_PARAMS] = params
|
||||
auth_n_request = AuthNRequestParser(self.provider).idp_initiated()
|
||||
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
|
||||
|
||||
|
||||
# This View doesn't have a URL on purpose, as its called by the FlowExecutor
|
||||
|
@ -184,32 +176,37 @@ class SAMLFlowFinalView(StageView):
|
|||
self.request.session.pop(SESSION_KEY_SAML_REQUEST, None)
|
||||
self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None)
|
||||
self.request.session.pop(SESSION_KEY_RELAY_STATE, None)
|
||||
if SESSION_KEY_PARAMS not in self.request.session:
|
||||
if SESSION_KEY_AUTH_N_REQUEST not in self.request.session:
|
||||
return self.executor.stage_invalid()
|
||||
response: SAMLResponseParams = self.request.session.pop(SESSION_KEY_PARAMS)
|
||||
auth_n_request: AuthNRequest = self.request.session.pop(
|
||||
SESSION_KEY_AUTH_N_REQUEST
|
||||
)
|
||||
response = AssertionProcessor(
|
||||
provider, request, auth_n_request
|
||||
).build_response()
|
||||
|
||||
if provider.sp_binding == SAMLBindings.POST:
|
||||
return render(
|
||||
self.request,
|
||||
"generic/autosubmit_form.html",
|
||||
{
|
||||
"url": response.acs_url,
|
||||
"url": provider.acs_url,
|
||||
"title": _("Redirecting to %(app)s..." % {"app": application.name}),
|
||||
"attrs": {
|
||||
"ACSUrl": response.acs_url,
|
||||
SESSION_KEY_SAML_RESPONSE: response.saml_response,
|
||||
SESSION_KEY_RELAY_STATE: response.relay_state,
|
||||
"ACSUrl": provider.acs_url,
|
||||
SESSION_KEY_SAML_RESPONSE: nice64(response.encode()),
|
||||
SESSION_KEY_RELAY_STATE: auth_n_request.relay_state,
|
||||
},
|
||||
},
|
||||
)
|
||||
if provider.sp_binding == SAMLBindings.REDIRECT:
|
||||
querystring = urlencode(
|
||||
{
|
||||
SESSION_KEY_SAML_RESPONSE: response.saml_response,
|
||||
SESSION_KEY_RELAY_STATE: response.relay_state,
|
||||
SESSION_KEY_SAML_RESPONSE: nice64(response.encode()),
|
||||
SESSION_KEY_RELAY_STATE: auth_n_request.relay_state,
|
||||
}
|
||||
)
|
||||
return redirect(f"{response.acs_url}?{querystring}")
|
||||
return redirect(f"{provider.acs_url}?{querystring}")
|
||||
return bad_request_message(request, "Invalid sp_binding specified")
|
||||
|
||||
|
||||
|
@ -219,31 +216,7 @@ class DescriptorDownloadView(View):
|
|||
@staticmethod
|
||||
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
|
||||
"""Return rendered XML Metadata"""
|
||||
entity_id = provider.issuer
|
||||
saml_sso_binding_post = request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:sso-post",
|
||||
kwargs={"application_slug": provider.application.slug},
|
||||
)
|
||||
)
|
||||
saml_sso_binding_redirect = request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:sso-redirect",
|
||||
kwargs={"application_slug": provider.application.slug},
|
||||
)
|
||||
)
|
||||
subject_format = provider.processor.subject_format
|
||||
ctx = {
|
||||
"saml_sso_binding_post": saml_sso_binding_post,
|
||||
"saml_sso_binding_redirect": saml_sso_binding_redirect,
|
||||
"entity_id": entity_id,
|
||||
"subject_format": subject_format,
|
||||
}
|
||||
if provider.signing_kp:
|
||||
ctx["cert_public_key"] = strip_pem_header(
|
||||
provider.signing_kp.certificate_data.replace("\r", "")
|
||||
).replace("\n", "")
|
||||
return render_to_string("providers/saml/xml/metadata.xml", ctx)
|
||||
return MetadataProcessor(provider, request).build_entity_descriptor()
|
||||
|
||||
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
|
|
Reference in a new issue