From: Simo Sorce Date: Sun, 23 Feb 2014 23:41:13 +0000 (-0500) Subject: Initial SAML2 provider X-Git-Tag: v0.2.2~91 X-Git-Url: http://git.cascardo.info/?p=cascardo%2Fipsilon.git;a=commitdiff_plain;h=953a4e418b1bdcbfddaf52d27a4cba9e9d8062e5 Initial SAML2 provider Signed-off-by: Simo Sorce --- diff --git a/ipsilon/providers/saml2/__init__.py b/ipsilon/providers/saml2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ipsilon/providers/saml2/auth.py b/ipsilon/providers/saml2/auth.py new file mode 100755 index 0000000..e73a692 --- /dev/null +++ b/ipsilon/providers/saml2/auth.py @@ -0,0 +1,168 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 Simo Sorce +# +# 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 . + +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 index 0000000..a22a1f4 --- /dev/null +++ b/ipsilon/providers/saml2idp.py @@ -0,0 +1,193 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 Simo Sorce +# +# 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 . + +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 diff --git a/ipsilon/util/errors.py b/ipsilon/util/errors.py index 16b7c70..3d7ea28 100755 --- a/ipsilon/util/errors.py +++ b/ipsilon/util/errors.py @@ -18,7 +18,7 @@ # along with this program. If not, see . from ipsilon.util.page import Page -import cherrypy + class Errors(Page): @@ -34,8 +34,10 @@ class Errors(Page): 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) @@ -46,6 +48,7 @@ class Error_400(Errors): return self._error_template('badrequest.html', title='Bad Request', message=message) + 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 index 0000000..f822410 --- /dev/null +++ b/templates/saml2/post_response.html @@ -0,0 +1,13 @@ +{% extends "master.html" %} +{% block main %} +
+
+ {% for field in fields %} + + + {% endfor %} + +
+ +
+{% endblock %}