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.tools import saml2metadata as metadata
 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
+        self.binding = None
 
     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')
@@ -303,5 +319,10 @@ class AuthenticateRequest(ProviderPageBase):
             }
             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)
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.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
@@ -20,9 +22,54 @@ import os
 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):
 
+    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
@@ -33,6 +80,10 @@ class Redirect(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)
@@ -98,6 +149,7 @@ class SSO(ProviderPageBase):
         self.Redirect = Redirect(*args, **kwargs)
         self.POST = POSTAuth(*args, **kwargs)
         self.Continue = Continue(*args, **kwargs)
+        self.SOAP = SSO_SOAP(*args, **kwargs)
 
 
 class SLO(ProviderPageBase):
@@ -118,7 +170,7 @@ class Metadata(ProviderPageBase):
     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
@@ -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)
+        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(
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),
+    'sso-soap': ('SingleSignOnService',
+                 lasso.SAML2_METADATA_BINDING_SOAP),
     '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)