diff --git a/e2e/test_provider_saml.py b/e2e/test_provider_saml.py
index 14cace1ab..8151d24e8 100644
--- a/e2e/test_provider_saml.py
+++ b/e2e/test_provider_saml.py
@@ -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",
diff --git a/passbook/providers/saml/migrations/0005_remove_samlprovider_processor_path.py b/passbook/providers/saml/migrations/0005_remove_samlprovider_processor_path.py
new file mode 100644
index 000000000..556a189d1
--- /dev/null
+++ b/passbook/providers/saml/migrations/0005_remove_samlprovider_processor_path.py
@@ -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",),
+ ]
diff --git a/passbook/providers/saml/processors/assertion.py b/passbook/providers/saml/processors/assertion.py
new file mode 100644
index 000000000..ad263853b
--- /dev/null
+++ b/passbook/providers/saml/processors/assertion.py
@@ -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()
diff --git a/passbook/providers/saml/processors/base.py b/passbook/providers/saml/processors/base.py
deleted file mode 100644
index 05228a0b0..000000000
--- a/passbook/providers/saml/processors/base.py
+++ /dev/null
@@ -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 = ""
diff --git a/passbook/providers/saml/processors/generic.py b/passbook/providers/saml/processors/generic.py
deleted file mode 100644
index 0a6b5f857..000000000
--- a/passbook/providers/saml/processors/generic.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""Generic Processor"""
-
-from passbook.providers.saml.processors.base import Processor
-
-
-class GenericProcessor(Processor):
- """Generic SAML2 Processor"""
diff --git a/passbook/providers/saml/processors/metadata.py b/passbook/providers/saml/processors/metadata.py
new file mode 100644
index 000000000..071d41ec1
--- /dev/null
+++ b/passbook/providers/saml/processors/metadata.py
@@ -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()
diff --git a/passbook/providers/saml/processors/request_parser.py b/passbook/providers/saml/processors/request_parser.py
new file mode 100644
index 000000000..95daedbff
--- /dev/null
+++ b/passbook/providers/saml/processors/request_parser.py
@@ -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()
diff --git a/passbook/providers/saml/processors/salesforce.py b/passbook/providers/saml/processors/salesforce.py
deleted file mode 100644
index 715b93c7f..000000000
--- a/passbook/providers/saml/processors/salesforce.py
+++ /dev/null
@@ -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,
- )
diff --git a/passbook/providers/saml/processors/types.py b/passbook/providers/saml/processors/types.py
deleted file mode 100644
index a283ba06e..000000000
--- a/passbook/providers/saml/processors/types.py
+++ /dev/null
@@ -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
diff --git a/passbook/providers/saml/templates/providers/saml/xml/assertions/generic.xml b/passbook/providers/saml/templates/providers/saml/xml/assertions/generic.xml
deleted file mode 100644
index c44402aa6..000000000
--- a/passbook/providers/saml/templates/providers/saml/xml/assertions/generic.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
- {{ ISSUER }}
- {% include 'providers/saml/xml/signature.xml' %}
- {{ SUBJECT_STATEMENT }}
-
-
- {{ AUDIENCE }}
-
-
-
-
- urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
-
-
- {{ ATTRIBUTE_STATEMENT }}
-
diff --git a/passbook/providers/saml/templates/providers/saml/xml/assertions/google_apps.xml b/passbook/providers/saml/templates/providers/saml/xml/assertions/google_apps.xml
deleted file mode 100644
index 8072b9ee4..000000000
--- a/passbook/providers/saml/templates/providers/saml/xml/assertions/google_apps.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
- {{ ISSUER }}
- {% include 'providers/saml/xml/signature.xml' %}
- {% include 'providers/saml/xml/subject.xml' %}
-
-
-
- urn:oasis:names:tc:SAML:2.0:ac:classes:Password
-
-
- {{ ATTRIBUTE_STATEMENT }}
-
diff --git a/passbook/providers/saml/templates/providers/saml/xml/assertions/salesforce.xml b/passbook/providers/saml/templates/providers/saml/xml/assertions/salesforce.xml
deleted file mode 100644
index 8887714f2..000000000
--- a/passbook/providers/saml/templates/providers/saml/xml/assertions/salesforce.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
- {{ ISSUER }}
- {{ ASSERTION_SIGNATURE|safe }}
- {% include 'providers/saml/xml/subject.xml' %}
-
-
- {{ AUDIENCE }}
-
-
-
-
- urn:oasis:names:tc:SAML:2.0:ac:classes:Password
-
-
- {{ ATTRIBUTE_STATEMENT|safe }}
-
diff --git a/passbook/providers/saml/templates/providers/saml/xml/attributes.xml b/passbook/providers/saml/templates/providers/saml/xml/attributes.xml
deleted file mode 100644
index 36fd4f89c..000000000
--- a/passbook/providers/saml/templates/providers/saml/xml/attributes.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
- {% for attr in attributes %}
-
- {% if attr.Value %}
- {{ attr.Value }}
- {% endif %}
- {% if attr.ValueArray %}
- {% for value in attr.ValueArray %}
- {{ value }}
- {% endfor %}
- {% endif %}
-
- {% endfor %}
-
diff --git a/passbook/providers/saml/templates/providers/saml/xml/metadata.xml b/passbook/providers/saml/templates/providers/saml/xml/metadata.xml
deleted file mode 100644
index 3f480bb1e..000000000
--- a/passbook/providers/saml/templates/providers/saml/xml/metadata.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
- {% if cert_public_key %}
-
-
-
- {{ cert_public_key }}
-
-
-
- {% endif %}
- {{ subject_format }}
-
-
-
-
diff --git a/passbook/providers/saml/templates/providers/saml/xml/response.xml b/passbook/providers/saml/templates/providers/saml/xml/response.xml
deleted file mode 100644
index 88d620d96..000000000
--- a/passbook/providers/saml/templates/providers/saml/xml/response.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
- {{ ISSUER }}
- {{ ASSERTION_SIGNATURE }}
-
-
-
- {{ ASSERTION }}
-
diff --git a/passbook/providers/saml/templates/providers/saml/xml/signature.xml b/passbook/providers/saml/templates/providers/saml/xml/signature.xml
deleted file mode 100644
index 50dcac2b4..000000000
--- a/passbook/providers/saml/templates/providers/saml/xml/signature.xml
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/passbook/providers/saml/templates/providers/saml/xml/subject.xml b/passbook/providers/saml/templates/providers/saml/xml/subject.xml
deleted file mode 100644
index 5b731fb67..000000000
--- a/passbook/providers/saml/templates/providers/saml/xml/subject.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
- {{ SUBJECT }}
-
-
-
-
diff --git a/passbook/providers/saml/utils/xml_render.py b/passbook/providers/saml/utils/xml_render.py
deleted file mode 100644
index 55a2dfb16..000000000
--- a/passbook/providers/saml/utils/xml_render.py
+++ /dev/null
@@ -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
diff --git a/passbook/providers/saml/utils/xml_signing.py b/passbook/providers/saml/utils/xml_signing.py
deleted file mode 100644
index ba9c0a1f4..000000000
--- a/passbook/providers/saml/utils/xml_signing.py
+++ /dev/null
@@ -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", {})
diff --git a/passbook/providers/saml/views.py b/passbook/providers/saml/views.py
index ba74d31d2..2ec095148 100644
--- a/passbook/providers/saml/views.py
+++ b/passbook/providers/saml/views.py
@@ -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."""