diff --git a/passbook/providers/saml/utils/encoding.py b/passbook/providers/saml/utils/encoding.py
index 3445024b4..387e0a947 100644
--- a/passbook/providers/saml/utils/encoding.py
+++ b/passbook/providers/saml/utils/encoding.py
@@ -12,9 +12,9 @@ def decode_base64_and_inflate(encoded: str, encoding="utf-8") -> str:
return decoded_data.decode(encoding)
-def deflate_and_base64_encode(inflated: bytes, encoding="utf-8"):
+def deflate_and_base64_encode(inflated: str, encoding="utf-8"):
"""Base64 and ZLib Compress b64string"""
- zlibbed_str = zlib.compress(inflated)
+ zlibbed_str = zlib.compress(inflated.encode())
compressed_string = zlibbed_str[2:-4]
return base64.b64encode(compressed_string).decode(encoding)
diff --git a/passbook/sources/saml/models.py b/passbook/sources/saml/models.py
index 1f308b70f..4cbdf512f 100644
--- a/passbook/sources/saml/models.py
+++ b/passbook/sources/saml/models.py
@@ -1,5 +1,7 @@
"""saml sp models"""
from django.db import models
+from django.http import HttpRequest
+from django.shortcuts import reverse
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
@@ -93,6 +95,18 @@ class SAMLSource(Source):
form = "passbook.sources.saml.forms.SAMLSourceForm"
+ def get_issuer(self, request: HttpRequest) -> str:
+ """Get Source's Issuer, falling back to our Metadata URL if none is set"""
+ if self.issuer is None:
+ return self.build_full_url(request, view="metadata")
+ return self.issuer
+
+ def build_full_url(self, request: HttpRequest, view: str = "acs") -> str:
+ """Build Full ACS URL to be used in IDP"""
+ return request.build_absolute_uri(
+ reverse(f"passbook_sources_saml:{view}", kwargs={"source_slug": self.slug})
+ )
+
@property
def ui_login_button(self) -> UILoginButton:
return UILoginButton(
diff --git a/passbook/sources/saml/processors/constants.py b/passbook/sources/saml/processors/constants.py
index 1c74b04ac..e301a795f 100644
--- a/passbook/sources/saml/processors/constants.py
+++ b/passbook/sources/saml/processors/constants.py
@@ -1,4 +1,16 @@
"""SAML Source processor constants"""
+NS_SAML_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
+NS_SAML_ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
+NS_SAML_METADATA = "urn:oasis:names:tc:SAML:2.0:metadata"
+NS_SIGNATURE = "http://www.w3.org/2000/09/xmldsig#"
+
+NS_MAP = {
+ "samlp": NS_SAML_PROTOCOL,
+ "saml": NS_SAML_ASSERTION,
+ "ds": NS_SIGNATURE,
+ "md": NS_SAML_METADATA,
+}
+
SAML_NAME_ID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
SAML_NAME_ID_FORMAT_PRESISTENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
SAML_NAME_ID_FORMAT_X509 = "urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName"
@@ -6,3 +18,6 @@ SAML_NAME_ID_FORMAT_WINDOWS = (
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName"
)
SAML_NAME_ID_FORMAT_TRANSIENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
+
+SAML_BINDING_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+SAML_BINDING_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
diff --git a/passbook/sources/saml/processors/metadata.py b/passbook/sources/saml/processors/metadata.py
new file mode 100644
index 000000000..0f2cfa257
--- /dev/null
+++ b/passbook/sources/saml/processors/metadata.py
@@ -0,0 +1,94 @@
+"""SAML Service Provider Metadata Processor"""
+from typing import Iterator, Optional
+
+from defusedxml import ElementTree
+from django.http import HttpRequest
+from lxml.etree import Element, SubElement # nosec
+from signxml.util import strip_pem_header
+
+from passbook.sources.saml.models import SAMLSource
+from passbook.sources.saml.processors.constants import (
+ NS_MAP,
+ NS_SAML_METADATA,
+ NS_SIGNATURE,
+ SAML_BINDING_POST,
+ 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 Service Provider Metadata Processor"""
+
+ source: SAMLSource
+ http_request: HttpRequest
+
+ def __init__(self, source: SAMLSource, request: HttpRequest):
+ self.source = source
+ self.http_request = request
+
+ def get_signing_key_descriptor(self) -> Optional[Element]:
+ """Get Singing KeyDescriptor, if enabled for the source"""
+ if self.source.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.source.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 build_entity_descriptor(self) -> str:
+ """Build full EntityDescriptor"""
+ entity_descriptor = Element(
+ f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP
+ )
+ entity_descriptor.attrib["entityID"] = self.source.get_issuer(self.http_request)
+
+ sp_sso_descriptor = SubElement(
+ entity_descriptor, f"{{{NS_SAML_METADATA}}}SPSSODescriptor"
+ )
+ sp_sso_descriptor.attrib[
+ "protocolSupportEnumeration"
+ ] = "urn:oasis:names:tc:SAML:2.0:protocol"
+
+ signing_descriptor = self.get_signing_key_descriptor()
+ if signing_descriptor:
+ sp_sso_descriptor.append(signing_descriptor)
+
+ for name_id_format in self.get_name_id_formats():
+ sp_sso_descriptor.append(name_id_format)
+
+ assertion_consumer_service = SubElement(
+ sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}"
+ )
+ assertion_consumer_service.attrib["isDefault"] = True
+ assertion_consumer_service.attrib["index"] = 0
+ assertion_consumer_service.attrib["Binding"] = SAML_BINDING_POST
+ assertion_consumer_service.attrib["Location"] = self.source.build_full_url(
+ self.http_request
+ )
+
+ return ElementTree.tostring(entity_descriptor).decode()
diff --git a/passbook/sources/saml/processors/request.py b/passbook/sources/saml/processors/request.py
new file mode 100644
index 000000000..e0983cd71
--- /dev/null
+++ b/passbook/sources/saml/processors/request.py
@@ -0,0 +1,53 @@
+"""SAML AuthnRequest Processor"""
+from defusedxml import ElementTree
+from django.http import HttpRequest
+from lxml.etree import Element # nosec
+
+from passbook.providers.saml.utils import get_random_id
+from passbook.providers.saml.utils.time import get_time_string
+from passbook.sources.saml.models import SAMLSource
+from passbook.sources.saml.processors.constants import (
+ NS_MAP,
+ NS_SAML_ASSERTION,
+ NS_SAML_PROTOCOL,
+)
+
+
+class RequestProcessor:
+ """SAML AuthnRequest Processor"""
+
+ source: SAMLSource
+ http_request: HttpRequest
+
+ def __init__(self, source: SAMLSource, request: HttpRequest):
+ self.source = source
+ self.http_request = request
+
+ def get_issuer(self) -> Element:
+ """Get Issuer Element"""
+ issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
+ issuer.text = self.source.get_issuer(self.http_request)
+ return issuer
+
+ def get_name_id_policy(self) -> Element:
+ """Get NameID Policy Element"""
+ name_id_policy = Element(f"{{{NS_SAML_PROTOCOL}}}NameIDPolicy")
+ name_id_policy.text = self.source.name_id_policy
+ return name_id_policy
+
+ def build_auth_n(self) -> str:
+ """Get full AuthnRequest"""
+ auth_n_request = Element(f"{{{NS_SAML_PROTOCOL}}}AuthnRequest", nsmap=NS_MAP)
+ auth_n_request.attrib[
+ "AssertionConsumerServiceURL"
+ ] = self.source.build_full_url(self.http_request)
+ auth_n_request.attrib["Destination"] = self.source.sso_url
+ auth_n_request.attrib["ID"] = get_random_id()
+ auth_n_request.attrib["IssueInstant"] = get_time_string()
+ auth_n_request.attrib["ProtocolBinding"] = self.source.binding_type
+ auth_n_request.attrib["Version"] = "2.0"
+ # Create issuer object
+ auth_n_request.append(self.get_issuer())
+ # Create NameID Policy Object
+ auth_n_request.append(self.get_name_id_policy())
+ return ElementTree.tostring(auth_n_request).decode()
diff --git a/passbook/sources/saml/processors/base.py b/passbook/sources/saml/processors/response.py
similarity index 99%
rename from passbook/sources/saml/processors/base.py
rename to passbook/sources/saml/processors/response.py
index 34f6b7e45..afc8c5b44 100644
--- a/passbook/sources/saml/processors/base.py
+++ b/passbook/sources/saml/processors/response.py
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend"
-class Processor:
+class ResponseProcessor:
"""SAML Response Processor"""
_source: SAMLSource
diff --git a/passbook/sources/saml/templates/saml/sp/xml/authn_request.xml b/passbook/sources/saml/templates/saml/sp/xml/authn_request.xml
deleted file mode 100644
index 64b7c454b..000000000
--- a/passbook/sources/saml/templates/saml/sp/xml/authn_request.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
- {{ ISSUER }}
- {{ AUTHN_REQUEST_SIGNATURE }}
-
-
diff --git a/passbook/sources/saml/templates/saml/sp/xml/signature.xml b/passbook/sources/saml/templates/saml/sp/xml/signature.xml
deleted file mode 100644
index 8da07dddc..000000000
--- a/passbook/sources/saml/templates/saml/sp/xml/signature.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
- {{ SIGNED_INFO }}
- {{ RSA_SIGNATURE }}
-
-
- {{ CERTIFICATE }}
-
-
-
diff --git a/passbook/sources/saml/templates/saml/sp/xml/signed_info.xml b/passbook/sources/saml/templates/saml/sp/xml/signed_info.xml
deleted file mode 100644
index d57858fe6..000000000
--- a/passbook/sources/saml/templates/saml/sp/xml/signed_info.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ SUBJECT_DIGEST }}
-
-
diff --git a/passbook/sources/saml/templates/saml/sp/xml/sp_sso_descriptor.xml b/passbook/sources/saml/templates/saml/sp/xml/sp_sso_descriptor.xml
deleted file mode 100644
index f702c2654..000000000
--- a/passbook/sources/saml/templates/saml/sp/xml/sp_sso_descriptor.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
- {{ cert_public_key }}
-
-
-
-
-
-
- {{ cert_public_key }}
-
-
-
- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
-
-
-
diff --git a/passbook/sources/saml/utils.py b/passbook/sources/saml/utils.py
deleted file mode 100644
index 139c556b4..000000000
--- a/passbook/sources/saml/utils.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""saml sp helpers"""
-from django.http import HttpRequest
-from django.shortcuts import reverse
-
-from passbook.sources.saml.models import SAMLSource
-
-
-def get_issuer(request: HttpRequest, source: SAMLSource) -> str:
- """Get Source's Issuer, falling back to our Metadata URL if none is set"""
- issuer = source.issuer
- if issuer is None:
- return build_full_url("metadata", request, source)
- return issuer
-
-
-def build_full_url(view: str, request: HttpRequest, source: SAMLSource) -> str:
- """Build Full ACS URL to be used in IDP"""
- return request.build_absolute_uri(
- reverse(f"passbook_sources_saml:{view}", kwargs={"source_slug": source.slug})
- )
diff --git a/passbook/sources/saml/views.py b/passbook/sources/saml/views.py
index 105e99a7e..714e4c667 100644
--- a/passbook/sources/saml/views.py
+++ b/passbook/sources/saml/views.py
@@ -9,20 +9,17 @@ from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from signxml import InvalidSignature
-from signxml.util import strip_pem_header
from passbook.lib.views import bad_request_message
-from passbook.providers.saml.utils import get_random_id, render_xml
from passbook.providers.saml.utils.encoding import deflate_and_base64_encode, nice64
-from passbook.providers.saml.utils.time import get_time_string
from passbook.sources.saml.exceptions import (
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
from passbook.sources.saml.models import SAMLBindingTypes, SAMLSource
-from passbook.sources.saml.processors.base import Processor
-from passbook.sources.saml.utils import build_full_url, get_issuer
-from passbook.sources.saml.xml_render import get_authnrequest_xml
+from passbook.sources.saml.processors.metadata import MetadataProcessor
+from passbook.sources.saml.processors.request import RequestProcessor
+from passbook.sources.saml.processors.response import ResponseProcessor
class InitiateView(View):
@@ -35,29 +32,23 @@ class InitiateView(View):
raise Http404
relay_state = request.GET.get("next", "")
request.session["sso_destination"] = relay_state
- parameters = {
- "ACS_URL": build_full_url("acs", request, source),
- "DESTINATION": source.sso_url,
- "AUTHN_REQUEST_ID": get_random_id(),
- "ISSUE_INSTANT": get_time_string(),
- "ISSUER": get_issuer(request, source),
- "NAME_ID_POLICY": source.name_id_policy,
- }
- authn_req = get_authnrequest_xml(parameters, signed=False)
+ auth_n_req = RequestProcessor(source, request).build_auth_n()
# If the source is configured for Redirect bindings, we can just redirect there
if source.binding_type == SAMLBindingTypes.Redirect:
- _request = deflate_and_base64_encode(authn_req.encode())
- url_args = urlencode({"SAMLRequest": _request, "RelayState": relay_state})
+ saml_request = deflate_and_base64_encode(auth_n_req)
+ url_args = urlencode(
+ {"SAMLRequest": saml_request, "RelayState": relay_state}
+ )
return redirect(f"{source.sso_url}?{url_args}")
# As POST Binding we show a form
- _request = nice64(authn_req.encode())
+ saml_request = nice64(auth_n_req)
if source.binding_type == SAMLBindingTypes.POST:
return render(
request,
"saml/sp/login.html",
{
"request_url": source.sso_url,
- "request": _request,
+ "request": saml_request,
"relay_state": relay_state,
"source": source,
},
@@ -69,7 +60,7 @@ class InitiateView(View):
"generic/autosubmit_form.html",
{
"title": _("Redirecting to %(app)s..." % {"app": source.name}),
- "attrs": {"SAMLRequest": _request, "RelayState": relay_state},
+ "attrs": {"SAMLRequest": saml_request, "RelayState": relay_state},
"url": source.sso_url,
},
)
@@ -85,7 +76,7 @@ class ACSView(View):
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
if not source.enabled:
raise Http404
- processor = Processor(source)
+ processor = ResponseProcessor(source)
try:
processor.parse(request)
except MissingSAMLResponse as exc:
@@ -122,16 +113,5 @@ class MetadataView(View):
def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
"""Replies with the XML Metadata SPSSODescriptor."""
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
- issuer = get_issuer(request, source)
- cert_stripped = strip_pem_header(
- source.signing_kp.certificate_data.replace("\r", "")
- ).replace("\n", "")
- return render_xml(
- request,
- "saml/sp/xml/sp_sso_descriptor.xml",
- {
- "acs_url": build_full_url("acs", request, source),
- "issuer": issuer,
- "cert_public_key": cert_stripped,
- },
- )
+ metadata = MetadataProcessor(source, request).build_entity_descriptor()
+ return HttpResponse(metadata, content_type="text/xml")
diff --git a/passbook/sources/saml/xml_render.py b/passbook/sources/saml/xml_render.py
deleted file mode 100644
index 6d6726b14..000000000
--- a/passbook/sources/saml/xml_render.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""Functions for creating XML output."""
-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
-
-LOGGER = get_logger()
-
-
-def get_authnrequest_xml(parameters, signed=False):
- """Get AuthN Request XML"""
- # Reset signature.
- params = {}
- params.update(parameters)
- params["AUTHN_REQUEST_SIGNATURE"] = ""
-
- unsigned = render_to_string("saml/sp/xml/authn_request.xml", params)
- if not signed:
- return unsigned
-
- # Sign it.
- signature_xml = get_signature_xml()
- params["AUTHN_REQUEST_SIGNATURE"] = signature_xml
- signed = render_to_string("saml/sp/xml/authn_request.xml", params)
-
- return signed