providers/saml: rewrite SAML AuthNRequest Parser and Response Processor

This commit is contained in:
Jens Langhammer 2020-07-11 13:28:48 +02:00
parent 1b0c013d8e
commit 2056b86ce7
20 changed files with 443 additions and 588 deletions

View File

@ -11,7 +11,6 @@ from e2e.utils import USER, SeleniumTestCase
from passbook.core.models import Application from passbook.core.models import Application
from passbook.crypto.models import CertificateKeyPair from passbook.crypto.models import CertificateKeyPair
from passbook.flows.models import Flow 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.expression.models import ExpressionPolicy
from passbook.policies.models import PolicyBinding from passbook.policies.models import PolicyBinding
from passbook.providers.saml.models import ( from passbook.providers.saml.models import (
@ -19,7 +18,6 @@ from passbook.providers.saml.models import (
SAMLPropertyMapping, SAMLPropertyMapping,
SAMLProvider, SAMLProvider,
) )
from passbook.providers.saml.processors.generic import GenericProcessor
class TestProviderSAML(SeleniumTestCase): class TestProviderSAML(SeleniumTestCase):
@ -70,7 +68,6 @@ class TestProviderSAML(SeleniumTestCase):
) )
provider: SAMLProvider = SAMLProvider.objects.create( provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test", name="saml-test",
processor_path=class_to_path(GenericProcessor),
acs_url="http://localhost:9009/saml/acs", acs_url="http://localhost:9009/saml/acs",
audience="passbook-e2e", audience="passbook-e2e",
issuer="passbook-e2e", issuer="passbook-e2e",
@ -104,7 +101,6 @@ class TestProviderSAML(SeleniumTestCase):
) )
provider: SAMLProvider = SAMLProvider.objects.create( provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test", name="saml-test",
processor_path=class_to_path(GenericProcessor),
acs_url="http://localhost:9009/saml/acs", acs_url="http://localhost:9009/saml/acs",
audience="passbook-e2e", audience="passbook-e2e",
issuer="passbook-e2e", issuer="passbook-e2e",
@ -146,7 +142,6 @@ class TestProviderSAML(SeleniumTestCase):
) )
provider: SAMLProvider = SAMLProvider.objects.create( provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test", name="saml-test",
processor_path=class_to_path(GenericProcessor),
acs_url="http://localhost:9009/saml/acs", acs_url="http://localhost:9009/saml/acs",
audience="passbook-e2e", audience="passbook-e2e",
issuer="passbook-e2e", issuer="passbook-e2e",
@ -188,7 +183,6 @@ class TestProviderSAML(SeleniumTestCase):
) )
provider: SAMLProvider = SAMLProvider.objects.create( provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test", name="saml-test",
processor_path=class_to_path(GenericProcessor),
acs_url="http://localhost:9009/saml/acs", acs_url="http://localhost:9009/saml/acs",
audience="passbook-e2e", audience="passbook-e2e",
issuer="passbook-e2e", issuer="passbook-e2e",

View File

@ -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",),
]

View 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()

View File

@ -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 = ""

View File

@ -1,7 +0,0 @@
"""Generic Processor"""
from passbook.providers.saml.processors.base import Processor
class GenericProcessor(Processor):
"""Generic SAML2 Processor"""

View 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()

View 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()

View File

@ -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,
)

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1 +0,0 @@
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>

View File

@ -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>

View File

@ -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

View File

@ -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", {})

View File

@ -4,13 +4,12 @@ from typing import Optional
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.http import HttpRequest, HttpResponse 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.decorators import method_decorator
from django.utils.http import urlencode from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event, EventAction 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.stage import StageView
from passbook.flows.views import SESSION_KEY_PLAN 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.utils.urls import redirect_with_qs
from passbook.lib.views import bad_request_message from passbook.lib.views import bad_request_message
from passbook.policies.mixins import PolicyAccessMixin from passbook.policies.mixins import PolicyAccessMixin
from passbook.providers.saml.exceptions import CannotHandleAssertion from passbook.providers.saml.exceptions import CannotHandleAssertion
from passbook.providers.saml.models import SAMLBindings, SAMLProvider 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 from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE
LOGGER = get_logger() LOGGER = get_logger()
@ -37,7 +41,7 @@ URL_VALIDATOR = URLValidator(schemes=("http", "https"))
SESSION_KEY_SAML_REQUEST = "SAMLRequest" SESSION_KEY_SAML_REQUEST = "SAMLRequest"
SESSION_KEY_SAML_RESPONSE = "SAMLResponse" SESSION_KEY_SAML_RESPONSE = "SAMLResponse"
SESSION_KEY_RELAY_STATE = "RelayState" SESSION_KEY_RELAY_STATE = "RelayState"
SESSION_KEY_PARAMS = "SAMLParams" SESSION_KEY_AUTH_N_REQUEST = "authn_request"
class SAMLSSOView(LoginRequiredMixin, PolicyAccessMixin, View): class SAMLSSOView(LoginRequiredMixin, PolicyAccessMixin, View):
@ -97,17 +101,12 @@ class SAMLSSOBindingRedirectView(SAMLSSOView):
self.request, "The SAML request payload is missing." 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: try:
self.provider.processor.can_handle(self.request) auth_n_request = AuthNRequestParser(self.provider).parse(
params = self.provider.processor.generate_response() request.GET[SESSION_KEY_SAML_REQUEST],
self.request.session[SESSION_KEY_PARAMS] = params request.GET.get(SESSION_KEY_RELAY_STATE, ""),
)
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
except CannotHandleAssertion as exc: except CannotHandleAssertion as exc:
LOGGER.info(exc) LOGGER.info(exc)
return bad_request_message(self.request, str(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, "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: try:
self.provider.processor.can_handle(self.request) auth_n_request = AuthNRequestParser(self.provider).parse(
params = self.provider.processor.generate_response() request.POST[SESSION_KEY_SAML_REQUEST],
self.request.session[SESSION_KEY_PARAMS] = params request.POST.get(SESSION_KEY_RELAY_STATE, ""),
)
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
except CannotHandleAssertion as exc: except CannotHandleAssertion as exc:
LOGGER.info(exc) LOGGER.info(exc)
return bad_request_message(self.request, str(exc)) return bad_request_message(self.request, str(exc))
@ -154,14 +148,12 @@ class SAMLSSOBindingInitView(SAMLSSOView):
def get( def get(
self, request: HttpRequest, application_slug: str self, request: HttpRequest, application_slug: str
) -> Optional[HttpResponse]: ) -> Optional[HttpResponse]:
"""Create saml params from scratch""" """Create SAML Response from scratch"""
LOGGER.debug( LOGGER.debug(
"handle_saml_no_request: No SAML Request, using IdP-initiated flow." "handle_saml_no_request: No SAML Request, using IdP-initiated flow."
) )
self.provider.processor.is_idp_initiated = True auth_n_request = AuthNRequestParser(self.provider).idp_initiated()
self.provider.processor.init_deep_link(self.request) self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
params = self.provider.processor.generate_response()
self.request.session[SESSION_KEY_PARAMS] = params
# This View doesn't have a URL on purpose, as its called by the FlowExecutor # 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_REQUEST, None)
self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None) self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None)
self.request.session.pop(SESSION_KEY_RELAY_STATE, 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() 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: if provider.sp_binding == SAMLBindings.POST:
return render( return render(
self.request, self.request,
"generic/autosubmit_form.html", "generic/autosubmit_form.html",
{ {
"url": response.acs_url, "url": provider.acs_url,
"title": _("Redirecting to %(app)s..." % {"app": application.name}), "title": _("Redirecting to %(app)s..." % {"app": application.name}),
"attrs": { "attrs": {
"ACSUrl": response.acs_url, "ACSUrl": provider.acs_url,
SESSION_KEY_SAML_RESPONSE: response.saml_response, SESSION_KEY_SAML_RESPONSE: nice64(response.encode()),
SESSION_KEY_RELAY_STATE: response.relay_state, SESSION_KEY_RELAY_STATE: auth_n_request.relay_state,
}, },
}, },
) )
if provider.sp_binding == SAMLBindings.REDIRECT: if provider.sp_binding == SAMLBindings.REDIRECT:
querystring = urlencode( querystring = urlencode(
{ {
SESSION_KEY_SAML_RESPONSE: response.saml_response, SESSION_KEY_SAML_RESPONSE: nice64(response.encode()),
SESSION_KEY_RELAY_STATE: response.relay_state, 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") return bad_request_message(request, "Invalid sp_binding specified")
@ -219,31 +216,7 @@ class DescriptorDownloadView(View):
@staticmethod @staticmethod
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str: def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
"""Return rendered XML Metadata""" """Return rendered XML Metadata"""
entity_id = provider.issuer return MetadataProcessor(provider, request).build_entity_descriptor()
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)
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""Replies with the XML Metadata IDSSODescriptor.""" """Replies with the XML Metadata IDSSODescriptor."""