[Python-modules-commits] [python-social-auth] 01/15: SAML2 backend using OneLogin's python-saml

Wolfgang Borgert debacle at moszumanska.debian.org
Sat Dec 24 15:13:33 UTC 2016


This is an automated email from the git hooks/post-receive script.

debacle pushed a commit to tag v0.2.11
in repository python-social-auth.

commit 9fe88e6fe7d968beb5940b886792c8114ccc7e50
Author: Braden MacDonald <braden at opencraft.com>
Date:   Thu Apr 30 17:42:08 2015 -0700

    SAML2 backend using OneLogin's python-saml
---
 social/backends/saml.py              | 277 +++++++++++++++++++++++++++++++++++
 social/strategies/base.py            |  20 +++
 social/strategies/django_strategy.py |  20 +++
 3 files changed, 317 insertions(+)

diff --git a/social/backends/saml.py b/social/backends/saml.py
new file mode 100644
index 0000000..135cf40
--- /dev/null
+++ b/social/backends/saml.py
@@ -0,0 +1,277 @@
+"""
+Backend for SAML 2.0 support
+
+Terminology:
+
+"Service Provider" (SP): Your web app
+"Identity Provider" (IdP): The third-party site that is authenticating users via SAML
+"""
+from onelogin.saml2.auth import OneLogin_Saml2_Auth
+from onelogin.saml2.settings import OneLogin_Saml2_Settings
+from social.backends.base import BaseAuth
+from social.exceptions import AuthFailed
+
+# Helpful constants:
+OID_COMMON_NAME = "urn:oid:2.5.4.3"
+OID_EDU_PERSON_PRINCIPAL_NAME = "urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
+OID_GIVEN_NAME = "urn:oid:2.5.4.42"
+OID_MAIL = "urn:oid:0.9.2342.19200300.100.1.3"
+OID_SURNAME = "urn:oid:2.5.4.4"
+OID_USERID = "urn:oid:0.9.2342.19200300.100.1.1"
+
+
+class SAMLIdentityProvider(object):
+    """
+    Wrapper around configuration for a SAML Identity provider
+    """
+
+    def __init__(self, name, **kwargs):
+        """ Load and parse configuration """
+        self.name = name
+        assert self.name.isalnum()  # If 'name' contained a colon, it would affect our UID mangling
+        self.conf = kwargs
+
+    def get_user_permanent_id(self, attributes):
+        """
+        The most important method: Get a permanent, unique identifier for this user from the
+        attributes supplied by the IdP.
+
+        If you want to use the NameID, it's available via attributes['name_id']
+        """
+        return attributes[self.conf.get('user_permanent_id', OID_USERID)][0]
+
+    # Attributes processing:
+    def get_user_details(self, attributes):
+        """
+        Given the SAML attributes extracted from the SSO response, get the user data like name.
+        """
+        return {
+            'fullname': self.get_attr(attributes, 'attr_full_name', OID_COMMON_NAME),
+            'first_name': self.get_attr(attributes, 'attr_first_name', OID_GIVEN_NAME),
+            'last_name': self.get_attr(attributes, 'attr_last_name', OID_SURNAME),
+            'username': self.get_attr(attributes, 'attr_username', OID_USERID),
+            'email': self.get_attr(attributes, 'attr_email', OID_MAIL),
+        }
+
+    def get_attr(self, attributes, conf_key, default_attribute):
+        """
+        Internal helper method.
+        Get the attribute 'default_attribute' out of the attributes, unless self.conf[conf_key]
+        overrides the default by specifying another attribute to use.
+        """
+        key = self.conf.get(conf_key, default_attribute)
+        return attributes[key][0] if key in attributes else None
+
+    @property
+    def entity_id(self):
+        """ Get the entity ID for this IdP """
+        return self.conf['entity_id']  # Required. e.g. "https://idp.testshib.org/idp/shibboleth"
+
+    @property
+    def sso_url(self):
+        """ Get the SSO URL for this IdP """
+        return self.conf['url']  # Required. e.g. "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO"
+
+    @property
+    def sso_binding(self):
+        """ Get the method used to submit our request to the SSO URL """
+        return self.conf.get('binding', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect')
+
+    @property
+    def x509cert(self):
+        """ X.509 Public Key Certificate for this IdP """
+        return self.conf['x509cert']
+
+    @property
+    def saml_config_dict(self):
+        """ Get the IdP configuration dict in the format required by python-saml """
+        return {
+            "entityId": self.entity_id,
+            "singleSignOnService": {
+                "url": self.sso_url,
+                "binding": self.sso_binding,
+            },
+            "x509cert": self.x509cert,
+        }
+
+
+class DummySAMLIdentityProvider(SAMLIdentityProvider):
+    """
+    A placeholder IdP used when we must specify something, e.g. when generating SP metadata.
+
+    If OneLogin_Saml2_Auth is modified to not always require IdP config, this can be removed.
+    """
+    def __init__(self):
+        super(DummySAMLIdentityProvider, self).__init__(
+            "dummy",
+            entity_id="https://dummy.none/saml2",
+            url="https://dummy.none/SSO",
+            x509cert='',
+        )
+
+
+class SAMLAuth(BaseAuth):
+    """
+    PSA Backend that implements SAML 2.0 Service Provider (SP) functionality.
+
+    Unlike all of the other backends, this one can be configured to work with
+    many identity providers (IdPs). For example, a University that belongs to a
+    Shibboleth federation may support authentication via ~100 partner
+    universities. Also, the IdP configuration can be changed at runtime if you
+    require that functionality - just subclass this and override `get_idp()`.
+
+    Several settings are required. Here's an example:
+
+    SOCIAL_AUTH_SAML_SP_ENTITY_ID = "https://saml.example.com/"
+    SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = "... X.509 certificate string ..."
+    SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = "... private key ..."
+    SOCIAL_AUTH_SAML_ORG_INFO = {
+        "en-US": {"name": "example", "displayname": "Example Inc.", "url": "http://example.com", },
+    }
+    SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = {"givenName": "Tech Gal", "emailAddress": "technical at example.com", }
+    SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {"givenName": "Support Guy", "emailAddress": "support at example.com", }
+    SOCIAL_AUTH_SAML_ENABLED_IDPS = {
+        "testshib": {
+            "entity_id": "https://idp.testshib.org/idp/shibboleth",
+            "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
+            "x509cert": "MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0B ... 8Bbnl+ev0peYzxFyF5sQA==",
+        }
+    }
+
+    Optional settings:
+    SOCIAL_AUTH_SAML_SP_EXTRA = {}
+    SOCIAL_AUTH_SAML_SECURITY_CONFIG = {}
+    SOCIAL_AUTH_SAML_SP_NAMEID_FORMATS = []
+    """
+    name = "saml"
+
+    def get_idp(self, idp_name):
+        """ Given the name of an IdP, get a SAMLIdentityProvider instance """
+        idp_config = self.setting("ENABLED_IDPS")[idp_name]
+        return SAMLIdentityProvider(idp_name, **idp_config)
+
+    def generate_saml_config(self, idp):
+        """
+        Generate the configuration required to instantiate OneLogin_Saml2_Auth
+        """
+        # The shared absolute URL that all IdPs redirect back to - this is specified in our metadata.xml:
+        abs_completion_url = self.redirect_uri
+
+        config = {
+            "contactPerson": {
+                "technical": self.setting("TECHNICAL_CONTACT"),
+                "support": self.setting("SUPPORT_CONTACT"),
+            },
+            "debug": True,
+            "idp": idp.saml_config_dict,
+            "organization": self.setting("ORG_INFO"),
+            "security": {
+                'metadataValidUntil': '',
+                'metadataCacheDuration': 'P10D',  # metadata valid for ten days
+            },
+            "sp": {
+                "assertionConsumerService": {
+                    "url": abs_completion_url,
+                    "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
+                },
+                "entityId": self.setting("SP_ENTITY_ID"),
+                "NameIDFormats": self.setting("SP_NAMEID_FORMATS", []),
+                "x509cert": self.setting("SP_PUBLIC_CERT"),
+                "privateKey": self.setting("SP_PRIVATE_KEY"),
+            },
+            "strict": True,  # We must force strict mode - for security
+        }
+        config["security"].update(self.setting("SECURITY_CONFIG", {}))
+        config["sp"].update(self.setting("SP_EXTRA", {}))
+        return config
+
+    def generate_metadata_xml(self):
+        """
+        Helper method that can be used from your web app to generate the XML metadata required
+        to link your web app as a Service Provider with each IdP you wish to use.
+
+        Returns (metadata XML string, list of errors)
+
+        Example usage (Django):
+            from social.apps.django_app.utils import load_strategy, load_backend
+            def saml_metadata_view(request):
+                complete_url = reverse('social:complete', args=("saml", ))
+                saml_backend = load_backend(load_strategy(request), "saml", complete_url)
+                metadata, errors = saml_backend.generate_metadata_xml()
+                if not errors:
+                    return HttpResponse(content=metadata, content_type='text/xml')
+                return HttpResponseServerError(content=', '.join(errors))
+        """
+        idp = DummySAMLIdentityProvider()  # python-saml requires us to specify something here even though it's not used
+        config = self.generate_saml_config(idp)
+        saml_settings = OneLogin_Saml2_Settings(config)
+        metadata = saml_settings.get_sp_metadata()
+        errors = saml_settings.validate_metadata(metadata)
+        return metadata, errors
+
+    def _create_saml_auth(self, idp_name):
+        """
+        Get an instance of OneLogin_Saml2_Auth
+        """
+        config = self.generate_saml_config(idp=self.get_idp(idp_name))
+        request_info = {
+            'https': 'on' if self.strategy.request_is_secure() else 'off',
+            'http_host': self.strategy.request_host(),
+            'script_name': self.strategy.request_path(),
+            'server_port': self.strategy.request_port(),
+            'get_data': self.strategy.request_get(),
+            'post_data': self.strategy.request_post(),
+        }
+        return OneLogin_Saml2_Auth(request_info, config)
+
+    def auth_url(self):
+        """ Get the URL to which we must redirect in order to authenticate the user """
+        idp_name = self.strategy.request_data()['idp']
+        auth = self._create_saml_auth(idp_name)
+        # Below, return_to sets the RelayState, which can contain arbitrary data.
+        # We use it to store the specific SAML IdP backend name, since we combine
+        # many backends to a single URL.
+        return auth.login(return_to=idp_name)
+
+    def get_user_details(self, response):
+        """
+        Get user details like full name, email, etc. from the response - see auth_complete
+        """
+        idp = self.get_idp(response['idp_name'])
+        return idp.get_user_details(response['attributes'])
+
+    def get_user_id(self, details, response):
+        """
+        Get the permanent ID for this user from the response.
+        We prefix each ID with the name of the IdP so that we can connect multiple IdPs to this
+        user.
+        """
+        idp = self.get_idp(response['idp_name'])
+        uid = idp.get_user_permanent_id(response['attributes'])
+        return '{}:{}'.format(idp.name, uid)
+
+    def auth_complete(self, *args, **kwargs):
+        """
+        The user has been redirected back from the IdP and we should now log them in, if
+        everything checks out.
+        """
+        idp_name = self.strategy.request_data()['RelayState']
+        auth = self._create_saml_auth(idp_name)
+        auth.process_response()
+        errors = auth.get_errors()
+        if errors or not auth.is_authenticated():
+            reason = auth.get_last_error_reason()
+            raise AuthFailed(self, 'SAML login failed: {} ({})'.format(errors, reason))
+
+        attributes = auth.get_attributes()
+        attributes['name_id'] = auth.get_nameid()
+
+        response = {
+            'idp_name': idp_name,
+            'attributes': attributes,
+            'session_index': auth.get_session_index(),
+        }
+
+        kwargs.update({'response': response, 'backend': self})
+
+        return self.strategy.authenticate(*args, **kwargs)
diff --git a/social/strategies/base.py b/social/strategies/base.py
index f2273b9..09ef9fa 100644
--- a/social/strategies/base.py
+++ b/social/strategies/base.py
@@ -188,3 +188,23 @@ class BaseStrategy(object):
     def build_absolute_uri(self, path=None):
         """Build absolute URI with given (optional) path"""
         raise NotImplementedError('Implement in subclass')
+
+    def request_is_secure(self):
+        """ Is the request using HTTPS? """
+        raise NotImplementedError('Implement in subclass')
+
+    def request_path(self):
+        """ path of the current request """
+        raise NotImplementedError('Implement in subclass')
+
+    def request_port(self):
+        """ Port in use for this request """
+        raise NotImplementedError('Implement in subclass')
+
+    def request_get(self):
+        """ Request GET data """
+        raise NotImplementedError('Implement in subclass')
+
+    def request_post(self):
+        """ Request POST data """
+        raise NotImplementedError('Implement in subclass')
diff --git a/social/strategies/django_strategy.py b/social/strategies/django_strategy.py
index 7e80f03..b3b66b7 100644
--- a/social/strategies/django_strategy.py
+++ b/social/strategies/django_strategy.py
@@ -53,6 +53,26 @@ class DjangoStrategy(BaseStrategy):
         if self.request:
             return self.request.get_host()
 
+    def request_is_secure(self):
+        """ Is the request using HTTPS? """
+        return self.request.is_secure()
+
+    def request_path(self):
+        """ path of the current request """
+        return self.request.path
+
+    def request_port(self):
+        """ Port in use for this request """
+        return self.request.META['SERVER_PORT']
+
+    def request_get(self):
+        """ Request GET data """
+        return self.request.GET.copy()
+
+    def request_post(self):
+        """ Request POST data """
+        return self.request.POST.copy()
+
     def redirect(self, url):
         return redirect(url)
 

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/python-modules/packages/python-social-auth.git



More information about the Python-modules-commits mailing list