Implement ECP in Ipsilon
authorJohn Dennis <jdennis@redhat.com>
Mon, 26 Jan 2015 21:04:40 +0000 (16:04 -0500)
committerRob Crittenden <rcritten@redhat.com>
Fri, 8 May 2015 15:17:02 +0000 (11:17 -0400)
* add saml2/SSO/SOAP endpoint.
* add check for lasso version, ECP endpoint only exposed in metadata
  if lasso has full ECP support.
* add SSO_SOAP soap authentication handler (used for ECP).
* add SAML binding to transaction so we can determine if cookies
  and other HTTP concepts are expected. Each handler is responsible
  for setting the binding.
* add some constants needed for ECP

https://fedorahosted.org/ipsilon/ticket/4

Signed-off-by: John Dennis <jdennis@redhat.com>
Reviewed-by: Rob Crittenden <rcritten@redhat.com>
ipsilon/providers/saml2/auth.py
ipsilon/providers/saml2idp.py
ipsilon/tools/saml2metadata.py
ipsilon/util/constants.py [new file with mode: 0644]
ipsilon/util/http.py [new file with mode: 0644]

index 5c00e97..611c9bf 100644 (file)
@@ -6,6 +6,7 @@ from ipsilon.providers.saml2.provider import ServiceProvider
 from ipsilon.providers.saml2.provider import InvalidProviderId
 from ipsilon.providers.saml2.provider import NameIdNotAllowed
 from ipsilon.providers.saml2.sessions import SAMLSessionsContainer
 from ipsilon.providers.saml2.provider import InvalidProviderId
 from ipsilon.providers.saml2.provider import NameIdNotAllowed
 from ipsilon.providers.saml2.sessions import SAMLSessionsContainer
+from ipsilon.tools import saml2metadata as metadata
 from ipsilon.util.policy import Policy
 from ipsilon.util.user import UserSession
 from ipsilon.util.trans import Transaction
 from ipsilon.util.policy import Policy
 from ipsilon.util.user import UserSession
 from ipsilon.util.trans import Transaction
@@ -29,14 +30,29 @@ class AuthenticateRequest(ProviderPageBase):
         super(AuthenticateRequest, self).__init__(*args, **kwargs)
         self.stage = 'init'
         self.trans = None
         super(AuthenticateRequest, self).__init__(*args, **kwargs)
         self.stage = 'init'
         self.trans = None
+        self.binding = None
 
     def _preop(self, *args, **kwargs):
         try:
             # generate a new id or get current one
             self.trans = Transaction('saml2', **kwargs)
 
     def _preop(self, *args, **kwargs):
         try:
             # generate a new id or get current one
             self.trans = Transaction('saml2', **kwargs)
-            if self.trans.cookie.value != self.trans.provider:
-                self.debug('Invalid transaction, %s != %s' % (
-                           self.trans.cookie.value, self.trans.provider))
+
+            self.debug('self.binding=%s, transdata=%s' %
+                       (self.binding, self.trans.retrieve()))
+            if self.binding is None:
+                # SAML binding is unknown, try to get it from transaction
+                transdata = self.trans.retrieve()
+                self.binding = transdata.get('saml2_binding')
+            else:
+                # SAML binding known, store in transaction
+                data = {'saml2_binding': self.binding}
+                self.trans.store(data)
+
+            # Only check for cookie for those bindings which use one
+            if self.binding not in (metadata.SAML2_SERVICE_MAP['sso-soap'][1]):
+                if self.trans.cookie.value != self.trans.provider:
+                    self.debug('Invalid transaction, %s != %s' % (
+                        self.trans.cookie.value, self.trans.provider))
         except Exception, e:  # pylint: disable=broad-except
             self.debug('Transaction initialization failed: %s' % repr(e))
             raise cherrypy.HTTPError(400, 'Invalid transaction id')
         except Exception, e:  # pylint: disable=broad-except
             self.debug('Transaction initialization failed: %s' % repr(e))
             raise cherrypy.HTTPError(400, 'Invalid transaction id')
@@ -303,5 +319,10 @@ class AuthenticateRequest(ProviderPageBase):
             }
             return self._template('saml2/post_response.html', **context)
 
             }
             return self._template('saml2/post_response.html', **context)
 
+        elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_LECP:
+            login.buildResponseMsg()
+            self.debug("Returning ECP: %s" % login.msgBody)
+            return login.msgBody
+
         else:
             raise cherrypy.HTTPError(500)
         else:
             raise cherrypy.HTTPError(500)
index ef31f36..6dfb03a 100644 (file)
@@ -10,6 +10,8 @@ from ipsilon.providers.saml2.provider import IdentityProvider
 from ipsilon.tools.certs import Certificate
 from ipsilon.tools import saml2metadata as metadata
 from ipsilon.tools import files
 from ipsilon.tools.certs import Certificate
 from ipsilon.tools import saml2metadata as metadata
 from ipsilon.tools import files
+from ipsilon.util.http import require_content_type
+from ipsilon.util.constants import SOAP_MEDIA_TYPE, XML_MEDIA_TYPE
 from ipsilon.util.user import UserSession
 from ipsilon.util.plugin import PluginObject
 from ipsilon.util import config as pconfig
 from ipsilon.util.user import UserSession
 from ipsilon.util.plugin import PluginObject
 from ipsilon.util import config as pconfig
@@ -20,9 +22,54 @@ import os
 import time
 import uuid
 
 import time
 import uuid
 
+cherrypy.tools.require_content_type = cherrypy.Tool('before_request_body',
+                                                    require_content_type)
+
+
+def is_lasso_ecp_enabled():
+    # Full ECP support appeared in lasso version 2.4.2
+    return lasso.checkVersion(2, 4, 2, lasso.CHECK_VERSION_NUMERIC)
+
+
+class SSO_SOAP(AuthenticateRequest):
+
+    def __init__(self, *args, **kwargs):
+        super(SSO_SOAP, self).__init__(*args, **kwargs)
+        self.binding = metadata.SAML2_SERVICE_MAP['sso-soap'][1]
+
+    @cherrypy.tools.require_content_type(
+        required=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
+    @cherrypy.tools.accept(media=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
+    @cherrypy.tools.response_headers(
+        headers=[('Content-Type', 'SOAP_MEDIA_TYPE')])
+    def POST(self, *args, **kwargs):
+        self.debug("SSO_SOAP.POST() begin")
+
+        self.debug("SSO_SOAP transaction provider=%s id=%s" %
+                   (self.trans.provider, self.trans.transaction_id))
+
+        us = UserSession()
+        us.remote_login()
+        user = us.get_user()
+        self.debug("SSO_SOAP user=%s" % (user.name))
+
+        if not user:
+            raise cherrypy.HTTPError(403, 'No user specified for SSO_SOAP')
+
+        soap_xml_doc = cherrypy.request.rfile.read()
+        soap_xml_doc = soap_xml_doc.strip()
+        self.debug("SSO_SOAP soap_xml_doc=%s" % soap_xml_doc)
+        login = self.saml2login(soap_xml_doc)
+
+        return self.auth(login)
+
 
 class Redirect(AuthenticateRequest):
 
 
 class Redirect(AuthenticateRequest):
 
+    def __init__(self, *args, **kwargs):
+        super(Redirect, self).__init__(*args, **kwargs)
+        self.binding = metadata.SAML2_SERVICE_MAP['sso-redirect'][1]
+
     def GET(self, *args, **kwargs):
 
         query = cherrypy.request.query_string
     def GET(self, *args, **kwargs):
 
         query = cherrypy.request.query_string
@@ -33,6 +80,10 @@ class Redirect(AuthenticateRequest):
 
 class POSTAuth(AuthenticateRequest):
 
 
 class POSTAuth(AuthenticateRequest):
 
+    def __init__(self, *args, **kwargs):
+        super(POSTAuth, self).__init__(*args, **kwargs)
+        self.binding = metadata.SAML2_SERVICE_MAP['sso-post'][1]
+
     def POST(self, *args, **kwargs):
 
         request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
     def POST(self, *args, **kwargs):
 
         request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
@@ -98,6 +149,7 @@ class SSO(ProviderPageBase):
         self.Redirect = Redirect(*args, **kwargs)
         self.POST = POSTAuth(*args, **kwargs)
         self.Continue = Continue(*args, **kwargs)
         self.Redirect = Redirect(*args, **kwargs)
         self.POST = POSTAuth(*args, **kwargs)
         self.Continue = Continue(*args, **kwargs)
+        self.SOAP = SSO_SOAP(*args, **kwargs)
 
 
 class SLO(ProviderPageBase):
 
 
 class SLO(ProviderPageBase):
@@ -118,7 +170,7 @@ class Metadata(ProviderPageBase):
     def GET(self, *args, **kwargs):
 
         body = self._get_metadata()
     def GET(self, *args, **kwargs):
 
         body = self._get_metadata()
-        cherrypy.response.headers["Content-Type"] = "text/xml"
+        cherrypy.response.headers["Content-Type"] = XML_MEDIA_TYPE
         cherrypy.response.headers["Content-Disposition"] = \
             'attachment; filename="metadata.xml"'
         return body
         cherrypy.response.headers["Content-Disposition"] = \
             'attachment; filename="metadata.xml"'
         return body
@@ -368,6 +420,9 @@ class IdpMetadataGenerator(object):
                               '%s/saml2/SSO/POST' % url)
         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
                               '%s/saml2/SSO/Redirect' % url)
                               '%s/saml2/SSO/POST' % url)
         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
                               '%s/saml2/SSO/Redirect' % url)
+        if is_lasso_ecp_enabled():
+            self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
+                                  '%s/saml2/SSO/SOAP' % url)
         self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
                               '%s/saml2/SLO/Redirect' % url)
         self.meta.add_allowed_name_format(
         self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
                               '%s/saml2/SLO/Redirect' % url)
         self.meta.add_allowed_name_format(
index 3891b6f..99857bf 100755 (executable)
@@ -25,6 +25,8 @@ SAML2_SERVICE_MAP = {
                  lasso.SAML2_METADATA_BINDING_POST),
     'sso-redirect': ('SingleSignOnService',
                      lasso.SAML2_METADATA_BINDING_REDIRECT),
                  lasso.SAML2_METADATA_BINDING_POST),
     'sso-redirect': ('SingleSignOnService',
                      lasso.SAML2_METADATA_BINDING_REDIRECT),
+    'sso-soap': ('SingleSignOnService',
+                 lasso.SAML2_METADATA_BINDING_SOAP),
     'logout-redirect': ('SingleLogoutService',
                         lasso.SAML2_METADATA_BINDING_REDIRECT),
     'response-post': ('AssertionConsumerService',
     'logout-redirect': ('SingleLogoutService',
                         lasso.SAML2_METADATA_BINDING_REDIRECT),
     'response-post': ('AssertionConsumerService',
diff --git a/ipsilon/util/constants.py b/ipsilon/util/constants.py
new file mode 100644 (file)
index 0000000..5de4cf8
--- /dev/null
@@ -0,0 +1,4 @@
+# Copyright (C) 2015 Ipsilon project Contributors, for license see COPYING
+
+SOAP_MEDIA_TYPE = 'application/soap+xml'
+XML_MEDIA_TYPE = 'text/xml'
diff --git a/ipsilon/util/http.py b/ipsilon/util/http.py
new file mode 100644 (file)
index 0000000..fa9d725
--- /dev/null
@@ -0,0 +1,68 @@
+# Copyright (C) 2015 Ipsilon project Contributors, for license see COPYING
+
+import cherrypy
+import fnmatch
+
+
+def require_content_type(required=None, absent_ok=True, debug=False):
+    '''CherryPy Tool that validates request Content-Type.
+
+    This is a CherryPy Tool that checks the Content-Type in a request and
+    raises HTTP Error 415 "Unsupported Media Type" if it does not match.
+
+    The tool accepts a glob style pattern or list of patterns (see fnmatch)
+    and verifies the Content-Type in the request matches at least one of
+    the patterns, if not a HTTP Error 415 "Unsupported Media Type" is raised.
+
+    If absent_ok is False and if the request does not contain a
+    Content-Type header a HTTP Error 415 "Unsupported Media Type" is
+    raised.
+
+    The tool may be deployed use any of the standard methods for
+    invoking CherryPy tools, for example as a decorator:
+
+    @cherrypy.tools.require_content_type(required='text/xml')
+    def POST(self, *args, **kwargs):
+        pass
+
+    :param required: May be a single string or a list of strings. Each
+           string is interpreted as a glob style pattern (see fnmatch).
+           The Content-Type must match at least one pattern.
+
+    :param absent_ok: Boolean specifying if the Content-Type header
+           must be present or if it is OK to be absent.
+
+    '''
+    if required is None:
+        return
+
+    if isinstance(required, basestring):
+        required = [required]
+
+    content_type = cherrypy.request.body.content_type.value
+    pattern = None
+    match = False
+    if content_type:
+        for pattern in required:
+            if fnmatch.fnmatch(content_type, pattern):
+                match = True
+                break
+    else:
+        if absent_ok:
+            return
+
+    if debug:
+        cherrypy.log('require_content_type: required=%s, absent_ok=%s '
+                     'content_type=%s match=%s pattern=%s' %
+                     required, absent_ok, content_type, match, pattern)
+
+    if not match:
+        acceptable = ', '.join(['"%s"' % x for x in required])
+        if content_type:
+            content_type = '"%s"' % content_type
+        else:
+            content_type = 'not specified'
+        message = ('Content-Type must match one of following patterns [%s], '
+                   'but the Content-Type was %s' %
+                   (acceptable, content_type))
+        raise cherrypy.HTTPError(415, message=message)