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."""