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

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