Initial SAML2 provider
authorSimo Sorce <simo@redhat.com>
Sun, 23 Feb 2014 23:41:13 +0000 (18:41 -0500)
committerSimo Sorce <simo@redhat.com>
Tue, 25 Feb 2014 02:54:57 +0000 (21:54 -0500)
Signed-off-by: Simo Sorce <simo@redhat.com>
ipsilon/providers/saml2/__init__.py [new file with mode: 0644]
ipsilon/providers/saml2/auth.py [new file with mode: 0755]
ipsilon/providers/saml2idp.py [new file with mode: 0755]
ipsilon/util/errors.py
templates/saml2/post_response.html [new file with mode: 0644]

diff --git a/ipsilon/providers/saml2/__init__.py b/ipsilon/providers/saml2/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/ipsilon/providers/saml2/auth.py b/ipsilon/providers/saml2/auth.py
new file mode 100755 (executable)
index 0000000..e73a692
--- /dev/null
@@ -0,0 +1,168 @@
+#!/usr/bin/python
+#
+# Copyright (C) 2014  Simo Sorce <simo@redhat.com>
+#
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from ipsilon.providers.common import ProviderPageBase
+from ipsilon.util.user import UserSession
+import cherrypy
+import datetime
+import lasso
+
+
+class InvalidRequest(Exception):
+
+    def __init__(self, message):
+        super(InvalidRequest, self).__init__(message)
+        self.message = message
+
+    def __str__(self):
+        return repr(self.message)
+
+
+class AuthenticateRequest(ProviderPageBase):
+
+    def __init__(self, *args, **kwargs):
+        super(AuthenticateRequest, self).__init__(*args, **kwargs)
+        self.STAGE_INIT = 0
+        self.STAGE_AUTH = 1
+        self.stage = self.STAGE_INIT
+
+    def auth(self, login):
+        self.saml2checks(login)
+        self.saml2assertion(login)
+        return self.reply(login)
+
+    def _parse_request(self, message):
+
+        login = lasso.Login(self.cfg.idp)
+
+        try:
+            login.processAuthnRequestMsg(message)
+        except (lasso.ProfileInvalidMsgError,
+                lasso.ProfileMissingIssuerError), e:
+
+            msg = 'Malformed Request %r [%r]' % (e, message)
+            raise InvalidRequest(msg)
+
+        except (lasso.ProfileInvalidProtocolprofileError,
+                lasso.DsError), e:
+
+            msg = 'Invalid SAML Request: %r (%r [%r])' % (login.request,
+                                                          e, message)
+            raise InvalidRequest(msg)
+
+        except (lasso.ServerProviderNotFoundError,
+                lasso.ProfileUnknownProviderError), e:
+
+            msg = 'Invalid Service Provider (%r [%r])' % (e, message)
+            # TODO: return to SP anyway ?
+            raise InvalidRequest(msg)
+
+        return login
+
+    def saml2login(self, request):
+
+        if not request:
+            raise cherrypy.HTTPError(400,
+                                     'SAML request token missing or empty')
+
+        try:
+            login = self._parse_request(request)
+        except InvalidRequest, e:
+            self._debug(str(e))
+            raise cherrypy.HTTPError(400, 'Invalid SAML request token')
+        except Exception, e:  # pylint: disable=broad-except
+            self._debug(str(e))
+            raise cherrypy.HTTPError(500)
+
+        return login
+
+    def saml2checks(self, login):
+
+        session = UserSession()
+        user = session.get_user()
+        if user.is_anonymous:
+            if self.stage < self.STAGE_AUTH:
+                session.save_data('saml2', 'stage', self.STAGE_AUTH)
+                session.save_data('saml2', 'Request', login.dump())
+                session.save_data('login', 'Return',
+                                  '%s/saml2/SSO/Continue' % self.basepath)
+                raise cherrypy.HTTPRedirect('%s/login' % self.basepath)
+            else:
+                raise cherrypy.HTTPError(401)
+
+        self._audit("Logged in user: %s [%s]" % (user.name, user.fullname))
+
+        # TODO: check if this is the first time this user access this SP
+        # If required by user prefs, ask user for consent once and then
+        # record it
+        consent = True
+
+        # TODO: check Name-ID Policy
+
+        # TODO: check login.request.forceAuthn
+
+        login.validateRequestMsg(not user.is_anonymous, consent)
+
+    def saml2assertion(self, login):
+
+        authtime = datetime.datetime.utcnow()
+        skew = datetime.timedelta(0, 60)
+        authtime_notbefore = authtime - skew
+        authtime_notafter = authtime + skew
+
+        user = UserSession().get_user()
+
+        # TODO: get authentication type fnd name format from session
+        # need to save which login manager authenticated and map it to a
+        # saml2 authentication context
+        authn_context = lasso.SAML2_AUTHN_CONTEXT_UNSPECIFIED
+
+        timeformat = '%Y-%m-%dT%H:%M:%SZ'
+        login.buildAssertion(authn_context,
+                             authtime.strftime(timeformat),
+                             None,
+                             authtime_notbefore.strftime(timeformat),
+                             authtime_notafter.strftime(timeformat))
+        login.assertion.subject.nameId.format = \
+            lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT
+        login.assertion.subject.nameId.content = user.name
+
+        # TODO: add user attributes as policy requires taking from 'user'
+
+    def reply(self, login):
+        if login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_ART:
+            # TODO
+            raise cherrypy.HTTPError(501)
+        elif login.protocolProfile == lasso.LOGIN_PROTOCOL_PROFILE_BRWS_POST:
+            login.buildAuthnResponseMsg()
+            self._debug('POSTing back to SP [%s]' % (login.msgUrl))
+            context = {
+                "title": 'Redirecting back to the web application',
+                "action": login.msgUrl,
+                "fields": [
+                    [lasso.SAML2_FIELD_RESPONSE, login.msgBody],
+                    [lasso.SAML2_FIELD_RELAYSTATE, login.msgRelayState],
+                ],
+                "submit": 'Return to application',
+            }
+            # pylint: disable=star-args
+            return self._template('saml2/post_response.html', **context)
+
+        else:
+            raise cherrypy.HTTPError(500)
diff --git a/ipsilon/providers/saml2idp.py b/ipsilon/providers/saml2idp.py
new file mode 100755 (executable)
index 0000000..a22a1f4
--- /dev/null
@@ -0,0 +1,193 @@
+#!/usr/bin/python
+#
+# Copyright (C) 2014  Simo Sorce <simo@redhat.com>
+#
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from ipsilon.providers.common import ProviderBase, ProviderPageBase
+from ipsilon.providers.saml2.auth import AuthenticateRequest
+from ipsilon.util.user import UserSession
+import cherrypy
+import lasso
+import os
+
+
+class Redirect(AuthenticateRequest):
+
+    def GET(self, *args, **kwargs):
+
+        query = cherrypy.request.query_string
+
+        login = self.saml2login(query)
+        return self.auth(login)
+
+
+class POSTAuth(AuthenticateRequest):
+
+    def POST(self, *args, **kwargs):
+
+        request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
+        relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
+
+        login = self.saml2login(request)
+        login.set_msgRelayState(relaystate)
+        return self.auth(login)
+
+
+class Continue(AuthenticateRequest):
+
+    def GET(self, *args, **kwargs):
+
+        session = UserSession()
+        user = session.get_user()
+        session.nuke_data('login', 'Return')
+        self.stage = session.get_data('saml2', 'stage')
+
+        if user.is_anonymous:
+            self._debug("User is marked anonymous?!")
+            # TODO: Return to SP with auth failed error
+            raise cherrypy.HTTPError(401)
+
+        self._debug('Continue auth for %s' % user.name)
+
+        dump = session.get_data('saml2', 'Request')
+        if not dump:
+            self._debug("Couldn't find Request dump?!")
+            # TODO: Return to SP with auth failed error
+            raise cherrypy.HTTPError(400)
+
+        try:
+            login = lasso.Login.newFromDump(self.cfg.idp, dump)
+        except Exception, e:  # pylint: disable=broad-except
+            self._debug('Failed to load status from dump: %r' % e)
+
+        if not login:
+            self._debug("Empty Request dump?!")
+            # TODO: Return to SP with auth failed error
+            raise cherrypy.HTTPError(400)
+
+        return self.auth(login)
+
+
+class SSO(ProviderPageBase):
+
+    def __init__(self, *args, **kwargs):
+        super(SSO, self).__init__(*args, **kwargs)
+        self.Redirect = Redirect(*args, **kwargs)
+        self.POST = POSTAuth(*args, **kwargs)
+        self.Continue = Continue(*args, **kwargs)
+
+
+class SAML2(ProviderPageBase):
+
+    def __init__(self, *args, **kwargs):
+        super(SAML2, self).__init__(*args, **kwargs)
+
+        # Init IDP data
+        try:
+            self.cfg.idp = lasso.Server(self.cfg.idp_metadata_file,
+                                        self.cfg.idp_key_file,
+                                        None,
+                                        self.cfg.idp_certificate_file)
+            self.cfg.idp.role = lasso.PROVIDER_ROLE_IDP
+        except Exception, e:  # pylint: disable=broad-except
+            self._debug('Failed to enable SAML2 provider: %r' % e)
+            return
+
+        # Import all known applications
+        data = self.cfg.get_data()
+        for idval in data:
+            if 'type' not in data[idval] or data[idval]['type'] != 'SP':
+                continue
+            path = os.path.join(self.cfg.idp_storage_path, str(idval))
+            sp = data[idval]
+            if 'name' in sp:
+                name = sp['name']
+            else:
+                name = str(idval)
+            try:
+                meta = os.path.join(path, 'metadata.xml')
+                cert = os.path.join(path, 'certificate.pem')
+                self.cfg.idp.addProvider(lasso.PROVIDER_ROLE_SP, meta, cert)
+                self._debug('Added SP %s' % name)
+            except Exception, e:  # pylint: disable=broad-except
+                self._debug('Failed to add SP %s: %r' % (name, e))
+
+        self.SSO = SSO(*args, **kwargs)
+
+
+class IdpProvider(ProviderBase):
+
+    def __init__(self):
+        super(IdpProvider, self).__init__('saml2', 'saml2')
+        self.page = None
+        self.description = """
+Provides SAML 2.0 authentication infrastructure. """
+
+        self._options = {
+            'idp storage path': [
+                """ Path to data storage accessible by the IdP """,
+                'string',
+                '/var/lib/ipsilon/saml2'
+            ],
+            'idp metadata file': [
+                """ The IdP Metadata file genearated at install time. """,
+                'string',
+                'metadata.xml'
+            ],
+            'idp certificate file': [
+                """ The IdP PEM Certificate genearated at install time. """,
+                'string',
+                'certificate.pem'
+            ],
+            'idp key file': [
+                """ The IdP Certificate Key genearated at install time. """,
+                'string',
+                'certificate.key'
+            ],
+            'allow self registration': [
+                """ Allow authenticated users to register applications. """,
+                'boolean',
+                True
+            ]
+        }
+
+    @property
+    def allow_self_registration(self):
+        return self.get_config_value('allow self registration')
+
+    @property
+    def idp_storage_path(self):
+        return self.get_config_value('idp storage path')
+
+    @property
+    def idp_metadata_file(self):
+        return os.path.join(self.idp_storage_path,
+                            self.get_config_value('idp metadata file'))
+
+    @property
+    def idp_certificate_file(self):
+        return os.path.join(self.idp_storage_path,
+                            self.get_config_value('idp certificate file'))
+
+    @property
+    def idp_key_file(self):
+        return os.path.join(self.idp_storage_path,
+                            self.get_config_value('idp key file'))
+
+    def get_tree(self, site):
+        self.page = SAML2(site, self)
+        return self.page
index 16b7c70..3d7ea28 100755 (executable)
@@ -18,7 +18,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from ipsilon.util.page import Page
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from ipsilon.util.page import Page
-import cherrypy
+
 
 class Errors(Page):
 
 
 class Errors(Page):
 
@@ -34,8 +34,10 @@ class Errors(Page):
 
     def handler(self, status, message, traceback, version):
         self._debug(repr([status, message, traceback, version]))
 
     def handler(self, status, message, traceback, version):
         self._debug(repr([status, message, traceback, version]))
-        return self._error_template('internalerror.html', title='Internal Error')
+        return self._error_template('internalerror.html',
+                                    title='Internal Error')
 
 
+    # pylint: disable=W0221
     def __call__(self, status, message, traceback, version):
         return self.handler(status, message, traceback, version)
 
     def __call__(self, status, message, traceback, version):
         return self.handler(status, message, traceback, version)
 
@@ -46,6 +48,7 @@ class Error_400(Errors):
         return self._error_template('badrequest.html',
                                     title='Bad Request', message=message)
 
         return self._error_template('badrequest.html',
                                     title='Bad Request', message=message)
 
+
 class Error_401(Errors):
 
     def handler(self, status, message, traceback, version):
 class Error_401(Errors):
 
     def handler(self, status, message, traceback, version):
diff --git a/templates/saml2/post_response.html b/templates/saml2/post_response.html
new file mode 100644 (file)
index 0000000..f822410
--- /dev/null
@@ -0,0 +1,13 @@
+{% extends "master.html" %}
+{% block main %}
+<div class="col-sm-5 col-md-6 col-lg-7 details">
+  <form id="saml-response" method="post" action="{{ action }}">
+    {% for field in fields %}
+        <input type="hidden" name="{{ field[0] }}" value="{{ field[1] }}">
+        </input>
+    {% endfor %}
+    <button type="submit">{{ submit }}</button>
+  </form>
+  <script>document.getElementById("saml-response").submit();</script>
+</div>
+{% endblock %}