diff --git a/passbook/providers/samlv2/__init__.py b/passbook/providers/samlv2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/providers/samlv2/apps.py b/passbook/providers/samlv2/apps.py new file mode 100644 index 000000000..ada8dd6ec --- /dev/null +++ b/passbook/providers/samlv2/apps.py @@ -0,0 +1,11 @@ +"""passbook saml provider app config""" +from django.apps import AppConfig + + +class PassbookProviderSAMLv2Config(AppConfig): + """passbook samlv2 provider app config""" + + name = "passbook.providers.samlv2" + label = "passbook_providers_samlv2" + verbose_name = "passbook Providers.SAMLv2" + mountpoint = "application/samlv2/" diff --git a/passbook/providers/samlv2/models.py b/passbook/providers/samlv2/models.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/providers/samlv2/saml/__init__.py b/passbook/providers/samlv2/saml/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/providers/samlv2/saml/constants.py b/passbook/providers/samlv2/saml/constants.py new file mode 100644 index 000000000..daab431f8 --- /dev/null +++ b/passbook/providers/samlv2/saml/constants.py @@ -0,0 +1,10 @@ +"""SAML-related constants""" +NS_SAML_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol" +NS_SAML_ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion" +NS_SIGNATURE = "http://www.w3.org/2000/09/xmldsig#" + +SAML_ATTRIB_ACS_URL = "AssertionConsumerServiceURL" +SAML_ATTRIB_DESTINATION = "Destination" +SAML_ATTRIB_ID = "ID" +SAML_ATTRIB_ISSUE_INSTANT = "IssueInstant" +SAML_ATTRIB_PROTOCOL_BINDING = "ProtocolBinding" diff --git a/passbook/providers/samlv2/saml/parser.py b/passbook/providers/samlv2/saml/parser.py new file mode 100644 index 000000000..1ffa04a69 --- /dev/null +++ b/passbook/providers/samlv2/saml/parser.py @@ -0,0 +1,83 @@ +"""SAML Request Parse/builder""" +from typing import TYPE_CHECKING, Optional + +from defusedxml import ElementTree +from signxml import XMLVerifier + +from passbook.crypto.models import CertificateKeyPair +from passbook.providers.samlv2.saml.constants import ( + NS_SAML_ASSERTION, + NS_SAML_PROTOCOL, + SAML_ATTRIB_ACS_URL, + SAML_ATTRIB_DESTINATION, + SAML_ATTRIB_ID, + SAML_ATTRIB_ISSUE_INSTANT, + SAML_ATTRIB_PROTOCOL_BINDING, +) +from passbook.providers.samlv2.saml.utils import decode_base64_and_inflate + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element # nosec + + +# pylint: disable=too-many-instance-attributes +class SAMLRequest: + """SAML Request data class, parse raw base64-encoded data, checks signature and more""" + + _root: "Element" + + acs_url: str + destination: str + id: str + issue_instant: str + protocol_binding: str + + issuer: str + + is_signed: bool + _detached_signature: str + + def __init__(self): + self.acs_url = "" + self.destination = "" + # pylint: disable=invalid-name + self.id = "" + self.issue_instant = "" + self.protocol_binding = "" + + @staticmethod + def parse(raw: str, detached_signature: Optional[str] = None) -> "SAMLRequest": + """Prase SAML request from raw string, which can be base64 encoded and deflated. + Optionally accepts a detached_signature, as from a REDIRECT request.""" + decoded_xml = decode_base64_and_inflate(raw) + root = ElementTree.fromstring(decoded_xml) + req = SAMLRequest() + req._root = root # pylint: disable=protected-access + # Verify the root element's tag + _expected_tag = f"{{{NS_SAML_PROTOCOL}}}AuthnRequest" + if root.tag != _expected_tag: + raise ValueError( + f"Invalid root tag, got '{root.tag}', expected '{_expected_tag}." + ) + req.acs_url = root.attrib[SAML_ATTRIB_ACS_URL] + req.destination = root.attrib[SAML_ATTRIB_DESTINATION] + req.id = root.attrib[SAML_ATTRIB_ID] + req.issue_instant = root.attrib[SAML_ATTRIB_ISSUE_INSTANT] + req.protocol_binding = root.attrib[SAML_ATTRIB_PROTOCOL_BINDING] + req.issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer").text + # Check if this Request is signed + if detached_signature: + # pylint: disable=protected-access + req._detached_signature = detached_signature + return req + + def verify_signature(self, keypair: CertificateKeyPair): + """Verify signature of SAML Request. + Raises `cryptography.exceptions.InvalidSignature` on validaton failure.""" + verifier = XMLVerifier() + if self._detached_signature: + verifier.verify( + self._detached_signature, x509_cert=keypair.certificate_data + ) + else: + verifier.verify(self._root, x509_cert=keypair.certificate_data) diff --git a/passbook/providers/samlv2/saml/utils.py b/passbook/providers/samlv2/saml/utils.py new file mode 100644 index 000000000..9aac5246a --- /dev/null +++ b/passbook/providers/samlv2/saml/utils.py @@ -0,0 +1,24 @@ +"""Wrappers to de/encode and de/inflate strings""" +import base64 +import zlib + + +def decode_base64_and_inflate(encoded: str, encoding="utf-8") -> str: + """Base64 decode and ZLib decompress b64string""" + decoded_data = base64.b64decode(encoded) + try: + return zlib.decompress(decoded_data, -15).decode(encoding) + except zlib.error: + return decoded_data.decode(encoding) + + +def deflate_and_base64_encode(inflated: bytes, encoding="utf-8"): + """Base64 and ZLib Compress b64string""" + zlibbed_str = zlib.compress(inflated) + compressed_string = zlibbed_str[2:-4] + return base64.b64encode(compressed_string).decode(encoding) + + +def nice64(src): + """ Returns src base64-encoded and formatted nicely for our XML. """ + return base64.b64encode(src).decode("utf-8").replace("\n", "") diff --git a/passbook/providers/samlv2/urls.py b/passbook/providers/samlv2/urls.py new file mode 100644 index 000000000..48315d8a4 --- /dev/null +++ b/passbook/providers/samlv2/urls.py @@ -0,0 +1,32 @@ +"""passbook samlv2 URLs""" +from django.urls import path + +from passbook.providers.samlv2.views import idp_initiated, slo, sso + +urlpatterns = [ + path( + "/sso/redirect/", + sso.SAMLRedirectBindingView.as_view(), + name="sso-redirect", + ), + path( + "/sso/post/", + sso.SAMLPostBindingView.as_view(), + name="sso-post", + ), + path( + "/slo/redirect/", + slo.SAMLRedirectBindingView.as_view(), + name="slo-redirect", + ), + path( + "/slo/redirect/", + slo.SAMLPostBindingView.as_view(), + name="slo-post", + ), + path( + "/initiate/", + idp_initiated.IDPInitiatedView.as_view(), + name="initiate", + ), +] diff --git a/passbook/providers/samlv2/views/__init__.py b/passbook/providers/samlv2/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/providers/samlv2/views/idp_initiated.py b/passbook/providers/samlv2/views/idp_initiated.py new file mode 100644 index 000000000..1568f6eae --- /dev/null +++ b/passbook/providers/samlv2/views/idp_initiated.py @@ -0,0 +1,6 @@ +"""IDP-Initiated Views""" +from django.views import View + + +class IDPInitiatedView(View): + """IDP-initiated Handler""" diff --git a/passbook/providers/samlv2/views/slo.py b/passbook/providers/samlv2/views/slo.py new file mode 100644 index 000000000..f74730db9 --- /dev/null +++ b/passbook/providers/samlv2/views/slo.py @@ -0,0 +1,10 @@ +"""Single Logout Views""" +from django.views import View + + +class SAMLPostBindingView(View): + """Handle SAML POST-type Requests""" + + +class SAMLRedirectBindingView(View): + """Handle SAML Redirect-type Requests""" diff --git a/passbook/providers/samlv2/views/sso.py b/passbook/providers/samlv2/views/sso.py new file mode 100644 index 000000000..b5502b14f --- /dev/null +++ b/passbook/providers/samlv2/views/sso.py @@ -0,0 +1,10 @@ +"""Single Signon Views""" +from django.views import View + + +class SAMLPostBindingView(View): + """Handle SAML POST-type Requests""" + + +class SAMLRedirectBindingView(View): + """Handle SAML Redirect-type Requests""" diff --git a/passbook/root/settings.py b/passbook/root/settings.py index fcd8b0d98..3f4ad769a 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -93,6 +93,7 @@ INSTALLED_APPS = [ "passbook.providers.oauth.apps.PassbookProviderOAuthConfig", "passbook.providers.oidc.apps.PassbookProviderOIDCConfig", "passbook.providers.saml.apps.PassbookProviderSAMLConfig", + "passbook.providers.samlv2.apps.PassbookProviderSAMLv2Config", "passbook.factors.otp.apps.PassbookFactorOTPConfig", "passbook.factors.captcha.apps.PassbookFactorCaptchaConfig", "passbook.factors.password.apps.PassbookFactorPasswordConfig",