From be55bdf7ee36ad38b25b5f79fc4b82edb2557148 Mon Sep 17 00:00:00 2001 From: John Dennis Date: Mon, 26 Jan 2015 16:04:40 -0500 Subject: [PATCH] Implement ECP in Ipsilon * 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 Reviewed-by: Rob Crittenden --- ipsilon/providers/saml2/auth.py | 27 +++++++++++-- ipsilon/providers/saml2idp.py | 57 ++++++++++++++++++++++++++- ipsilon/tools/saml2metadata.py | 2 + ipsilon/util/constants.py | 4 ++ ipsilon/util/http.py | 68 +++++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 ipsilon/util/constants.py create mode 100644 ipsilon/util/http.py diff --git a/ipsilon/providers/saml2/auth.py b/ipsilon/providers/saml2/auth.py index 5c00e97..611c9bf 100644 --- a/ipsilon/providers/saml2/auth.py +++ b/ipsilon/providers/saml2/auth.py @@ -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) diff --git a/ipsilon/providers/saml2idp.py b/ipsilon/providers/saml2idp.py index ef31f36..6dfb03a 100644 --- a/ipsilon/providers/saml2idp.py +++ b/ipsilon/providers/saml2idp.py @@ -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( diff --git a/ipsilon/tools/saml2metadata.py b/ipsilon/tools/saml2metadata.py index 3891b6f..99857bf 100755 --- a/ipsilon/tools/saml2metadata.py +++ b/ipsilon/tools/saml2metadata.py @@ -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 index 0000000..5de4cf8 --- /dev/null +++ b/ipsilon/util/constants.py @@ -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 index 0000000..fa9d725 --- /dev/null +++ b/ipsilon/util/http.py @@ -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) -- 2.20.1