X-Git-Url: http://git.cascardo.info/?p=cascardo%2Fipsilon.git;a=blobdiff_plain;f=ipsilon%2Fproviders%2Fsaml2idp.py;h=11ba832edd83087f94006dedfd396aef42322a65;hp=c1e31dc043b834c03b5b96db2cd0f12f87685bad;hb=485baf6ee7a315d1af1086fe5b5da8cff6c4ba37;hpb=15ef3579e537523ea97714bf80c63f2f8f30d4bd diff --git a/ipsilon/providers/saml2idp.py b/ipsilon/providers/saml2idp.py old mode 100755 new mode 100644 index c1e31dc..11ba832 --- a/ipsilon/providers/saml2idp.py +++ b/ipsilon/providers/saml2idp.py @@ -1,38 +1,75 @@ -#!/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.common import FACILITY +# Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING + +from ipsilon.providers.common import ProviderBase, ProviderPageBase, \ + ProviderInstaller from ipsilon.providers.saml2.auth import AuthenticateRequest -from ipsilon.providers.saml2.admin import AdminPage -from ipsilon.providers.saml2.certs import Certificate -from ipsilon.providers.saml2 import metadata +from ipsilon.providers.saml2.logout import LogoutRequest +from ipsilon.providers.saml2.admin import Saml2AdminPage +from ipsilon.providers.saml2.rest import Saml2RestBase +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 import cherrypy +from datetime import timedelta import lasso -import pwd 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 @@ -43,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) @@ -59,35 +100,48 @@ class Continue(AuthenticateRequest): session = UserSession() user = session.get_user() - session.nuke_data('login', 'Return') - self.stage = session.get_data('saml2', 'stage') + transdata = self.trans.retrieve() + self.stage = transdata['saml2_stage'] if user.is_anonymous: - self._debug("User is marked 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) + self.debug('Continue auth for %s' % user.name) - dump = session.get_data('saml2', 'Request') - if not dump: - self._debug("Couldn't find Request dump?!") + if 'saml2_request' not in transdata: + self.debug("Couldn't find Request dump?!") # TODO: Return to SP with auth failed error raise cherrypy.HTTPError(400) + dump = transdata['saml2_request'] try: - login = lasso.Login.newFromDump(self.cfg.idp, dump) + login = self.cfg.idp.get_login_handler(dump) except Exception, e: # pylint: disable=broad-except - self._debug('Failed to load status from dump: %r' % e) + self.debug('Failed to load status from dump: %r' % e) if not login: - self._debug("Empty Request dump?!") + self.debug("Empty Request dump?!") # TODO: Return to SP with auth failed error raise cherrypy.HTTPError(400) return self.auth(login) +class RedirectLogout(LogoutRequest): + + def GET(self, *args, **kwargs): + query = cherrypy.request.query_string + + relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE) + response = kwargs.get(lasso.SAML2_FIELD_RESPONSE) + + return self.logout(query, + relaystate=relaystate, + samlresponse=response) + + class SSO(ProviderPageBase): def __init__(self, *args, **kwargs): @@ -95,96 +149,135 @@ 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 SAML2(ProviderPageBase): +class SLO(ProviderPageBase): def __init__(self, *args, **kwargs): - super(SAML2, self).__init__(*args, **kwargs) + super(SLO, self).__init__(*args, **kwargs) + self.debug('SLO init') + self.Redirect = RedirectLogout(*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)) +# one week +METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7 +# five years (approximately) +METADATA_DEFAULT_VALIDITY_PERIOD = 365 * 5 + + +class Metadata(ProviderPageBase): + def GET(self, *args, **kwargs): + + body = self._get_metadata() + cherrypy.response.headers["Content-Type"] = XML_MEDIA_TYPE + cherrypy.response.headers["Content-Disposition"] = \ + 'attachment; filename="metadata.xml"' + return body + + def _get_metadata(self): + if os.path.isfile(self.cfg.idp_metadata_file): + s = os.stat(self.cfg.idp_metadata_file) + if s.st_mtime > time.time() - METADATA_RENEW_INTERVAL: + with open(self.cfg.idp_metadata_file) as m: + return m.read() + + # Otherwise generate and save + idp_cert = Certificate() + idp_cert.import_cert(self.cfg.idp_certificate_file, + self.cfg.idp_key_file) + + validity = int(self.cfg.idp_metadata_validity) + meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert, + timedelta(validity)) + body = meta.output() + with open(self.cfg.idp_metadata_file, 'w+') as m: + m.write(body) + return body + +class SAML2(ProviderPageBase): + + def __init__(self, *args, **kwargs): + super(SAML2, self).__init__(*args, **kwargs) + self.metadata = Metadata(*args, **kwargs) self.SSO = SSO(*args, **kwargs) + self.SLO = SLO(*args, **kwargs) class IdpProvider(ProviderBase): - def __init__(self): - super(IdpProvider, self).__init__('saml2', 'saml2') + def __init__(self, *pargs): + super(IdpProvider, self).__init__('saml2', 'saml2', *pargs) + self.admin = None + self.rest = None self.page = None + self.idp = 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 - ], - 'default allowed nameids': [ - """Default Allowed NameIDs for Service Providers. """, - 'list', - ['persistent', 'transient', 'email', 'kerberos', 'x509'] - ], - 'default nameid': [ - """Default NameID used by Service Providers. """, - 'string', - 'persistent' - ], - 'default email domain': [ - """Default email domain, for users missing email property.""", - 'string', - 'example.com' - ] - } + self.new_config( + self.name, + pconfig.String( + 'idp storage path', + 'Path to data storage accessible by the IdP.', + '/var/lib/ipsilon/saml2'), + pconfig.String( + 'idp metadata file', + 'The IdP Metadata file generated at install time.', + 'metadata.xml'), + pconfig.String( + 'idp metadata validity', + 'The IdP Metadata validity period (in days) to use when ' + 'generating new metadata.', + METADATA_DEFAULT_VALIDITY_PERIOD), + pconfig.String( + 'idp certificate file', + 'The IdP PEM Certificate generated at install time.', + 'certificate.pem'), + pconfig.String( + 'idp key file', + 'The IdP Certificate Key generated at install time.', + 'certificate.key'), + pconfig.String( + 'idp nameid salt', + 'The salt used for persistent Name IDs.', + None), + pconfig.Condition( + 'allow self registration', + 'Allow authenticated users to register applications.', + True), + pconfig.Choice( + 'default allowed nameids', + 'Default Allowed NameIDs for Service Providers.', + metadata.SAML2_NAMEID_MAP.keys(), + ['unspecified', 'persistent', 'transient', 'email', + 'kerberos', 'x509']), + pconfig.Pick( + 'default nameid', + 'Default NameID used by Service Providers.', + metadata.SAML2_NAMEID_MAP.keys(), + 'unspecified'), + pconfig.String( + 'default email domain', + 'Used for users missing the email property.', + 'example.com'), + pconfig.MappingList( + 'default attribute mapping', + 'Defines how to map attributes before returning them to SPs', + [['*', '*']]), + pconfig.ComplexList( + 'default allowed attributes', + 'Defines a list of allowed attributes, applied after mapping', + ['*']), + ) + if cherrypy.config.get('debug', False): + import logging + import sys + logger = logging.getLogger('lasso') + lh = logging.StreamHandler(sys.stderr) + logger.addHandler(lh) + logger.setLevel(logging.DEBUG) @property def allow_self_registration(self): @@ -199,6 +292,10 @@ Provides SAML 2.0 authentication infrastructure. """ return os.path.join(self.idp_storage_path, self.get_config_value('idp metadata file')) + @property + def idp_metadata_validity(self): + return self.get_config_value('idp metadata validity') + @property def idp_certificate_file(self): return os.path.join(self.idp_storage_path, @@ -209,6 +306,10 @@ Provides SAML 2.0 authentication infrastructure. """ return os.path.join(self.idp_storage_path, self.get_config_value('idp key file')) + @property + def idp_nameid_salt(self): + return self.get_config_value('idp nameid salt') + @property def default_allowed_nameids(self): return self.get_config_value('default allowed nameids') @@ -221,31 +322,142 @@ Provides SAML 2.0 authentication infrastructure. """ def default_email_domain(self): return self.get_config_value('default email domain') + @property + def default_attribute_mapping(self): + return self.get_config_value('default attribute mapping') + + @property + def default_allowed_attributes(self): + return self.get_config_value('default allowed attributes') + def get_tree(self, site): + self.idp = self.init_idp() self.page = SAML2(site, self) - self.admin = AdminPage(site, self) + self.admin = Saml2AdminPage(site, self) + self.rest = Saml2RestBase(site, self) return self.page + def init_idp(self): + idp = None + # Init IDP data + try: + idp = IdentityProvider(self) + except Exception, e: # pylint: disable=broad-except + self.debug('Failed to init SAML2 provider: %r' % e) + return None -class Installer(object): + self._root.logout.add_handler(self.name, self.idp_initiated_logout) - def __init__(self): + # Import all known applications + data = self.get_data() + for idval in data: + sp = data[idval] + if 'type' not in sp or sp['type'] != 'SP': + continue + if 'name' not in sp or 'metadata' not in sp: + continue + try: + idp.add_provider(sp) + except Exception, e: # pylint: disable=broad-except + self.debug('Failed to add SP %s: %r' % (sp['name'], e)) + + return idp + + def on_enable(self): + super(IdpProvider, self).on_enable() + self.idp = self.init_idp() + if hasattr(self, 'admin'): + if self.admin: + self.admin.add_sps() + + def idp_initiated_logout(self): + """ + Logout all SP sessions when the logout comes from the IdP. + + For the current user only. + """ + self.debug("IdP-initiated SAML2 logout") + us = UserSession() + + saml_sessions = us.get_provider_data('saml2') + if saml_sessions is None: + self.debug("No SAML2 sessions to logout") + return + session = saml_sessions.get_next_logout(remove=False) + if session is None: + return + + # Add a fake session to indicate where the user should + # be redirected to when all SP's are logged out. + idpurl = self._root.instance_base_url() + saml_sessions.add_session("_idp_initiated_logout", + idpurl, + "") + init_session = saml_sessions.find_session_by_provider(idpurl) + init_session.set_logoutstate(idpurl, "idp_initiated_logout", None) + saml_sessions.start_logout(init_session) + + logout = self.idp.get_logout_handler() + logout.setSessionFromDump(session.session.dump()) + logout.initRequest(session.provider_id) + try: + logout.buildRequestMsg() + except lasso.Error, e: + self.error('failure to build logout request msg: %s' % e) + raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s ' + % e) + + raise cherrypy.HTTPRedirect(logout.msgUrl) + + +class IdpMetadataGenerator(object): + + def __init__(self, url, idp_cert, expiration=None): + self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration) + self.meta.set_entity_id('%s/saml2/metadata' % url) + self.meta.add_certs(idp_cert, idp_cert) + self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'], + '%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( + lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT) + self.meta.add_allowed_name_format( + lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT) + self.meta.add_allowed_name_format( + lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL) + + def output(self, path=None): + return self.meta.output(path) + + +class Installer(ProviderInstaller): + + def __init__(self, *pargs): + super(Installer, self).__init__() self.name = 'saml2' - self.ptype = 'provider' + self.pargs = pargs def install_args(self, group): group.add_argument('--saml2', choices=['yes', 'no'], default='yes', help='Configure SAML2 Provider') - group.add_argument('--saml2-storage', - default='/var/lib/ipsilon/saml2', - help='SAML2 Provider storage area') + group.add_argument('--saml2-metadata-validity', + default=METADATA_DEFAULT_VALIDITY_PERIOD, + help=('Metadata validity period in days ' + '(default - %d)' % + METADATA_DEFAULT_VALIDITY_PERIOD)) - def configure(self, opts): + def configure(self, opts, changes): if opts['saml2'] != 'yes': return # Check storage path is present or create it - path = opts['saml2_storage'] + path = os.path.join(opts['data_dir'], 'saml2') if not os.path.exists(path): os.makedirs(path, 0700) @@ -254,50 +466,35 @@ class Installer(object): cert.generate('idp', opts['hostname']) # Generate Idp Metadata - url = 'https://' + opts['hostname'] + '/idp/saml2' - meta = metadata.Metadata(metadata.IDP_ROLE) - meta.set_entity_id(url + '/metadata') - meta.add_certs(cert, cert) - meta.add_service(metadata.SSO_SERVICE, - lasso.SAML2_METADATA_BINDING_POST, - url + '/POST') - meta.add_service(metadata.SSO_SERVICE, - lasso.SAML2_METADATA_BINDING_REDIRECT, - url + '/Redirect') - - meta.add_allowed_name_format( - lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT) - meta.add_allowed_name_format( - lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT) - meta.add_allowed_name_format( - lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL) - if 'krb' in opts and opts['krb'] == 'yes': - meta.add_allowed_name_format( + proto = 'https' + if opts['secure'].lower() == 'no': + proto = 'http' + url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance']) + validity = int(opts['saml2_metadata_validity']) + meta = IdpMetadataGenerator(url, cert, + timedelta(validity)) + if 'gssapi' in opts and opts['gssapi'] == 'yes': + meta.meta.add_allowed_name_format( lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS) meta.output(os.path.join(path, 'metadata.xml')) # Add configuration data to database - po = PluginObject() + po = PluginObject(*self.pargs) po.name = 'saml2' po.wipe_data() - - po.wipe_config_values(FACILITY) + po.wipe_config_values() config = {'idp storage path': path, 'idp metadata file': 'metadata.xml', 'idp certificate file': cert.cert, - 'idp key file': cert.key} - po.set_config(config) - po.save_plugin_config(FACILITY) + 'idp key file': cert.key, + 'idp nameid salt': uuid.uuid4().hex, + 'idp metadata validity': opts['saml2_metadata_validity']} + po.save_plugin_config(config) + + # Update global config to add login plugin + po.is_enabled = True + po.save_enabled_state() # Fixup permissions so only the ipsilon user can read these files - pw = pwd.getpwnam(opts['system_user']) - for root, dirs, files in os.walk(path): - for name in dirs: - target = os.path.join(root, name) - os.chown(target, pw.pw_uid, pw.pw_gid) - os.chmod(target, 0700) - for name in files: - target = os.path.join(root, name) - os.chown(target, pw.pw_uid, pw.pw_gid) - os.chmod(target, 0600) + files.fix_user_dirs(path, opts['system_user'])