1 # Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING
3 from ipsilon.providers.common import ProviderBase, ProviderPageBase, \
5 from ipsilon.providers.saml2.auth import AuthenticateRequest
6 from ipsilon.providers.saml2.logout import LogoutRequest
7 from ipsilon.providers.saml2.admin import Saml2AdminPage
8 from ipsilon.providers.saml2.rest import Saml2RestBase
9 from ipsilon.providers.saml2.provider import IdentityProvider
10 from ipsilon.providers.saml2.sessions import SAMLSessionFactory
11 from ipsilon.util.data import SAML2SessionStore
12 from ipsilon.tools.certs import Certificate
13 from ipsilon.tools import saml2metadata as metadata
14 from ipsilon.tools import files
15 from ipsilon.util.http import require_content_type
16 from ipsilon.util.constants import SOAP_MEDIA_TYPE, XML_MEDIA_TYPE
17 from ipsilon.util.user import UserSession
18 from ipsilon.util.plugin import PluginObject
19 from ipsilon.util import config as pconfig
21 from datetime import timedelta
27 cherrypy.tools.require_content_type = cherrypy.Tool('before_request_body',
31 def is_lasso_ecp_enabled():
32 # Full ECP support appeared in lasso version 2.4.2
33 return lasso.checkVersion(2, 4, 2, lasso.CHECK_VERSION_NUMERIC)
36 class SSO_SOAP(AuthenticateRequest):
38 def __init__(self, *args, **kwargs):
39 super(SSO_SOAP, self).__init__(*args, **kwargs)
40 self.binding = metadata.SAML2_SERVICE_MAP['sso-soap'][1]
42 @cherrypy.tools.require_content_type(
43 required=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
44 @cherrypy.tools.accept(media=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
45 @cherrypy.tools.response_headers(
46 headers=[('Content-Type', 'SOAP_MEDIA_TYPE')])
47 def POST(self, *args, **kwargs):
48 self.debug("SSO_SOAP.POST() begin")
50 self.debug("SSO_SOAP transaction provider=%s id=%s" %
51 (self.trans.provider, self.trans.transaction_id))
56 self.debug("SSO_SOAP user=%s" % (user.name))
59 raise cherrypy.HTTPError(403, 'No user specified for SSO_SOAP')
61 soap_xml_doc = cherrypy.request.rfile.read()
62 soap_xml_doc = soap_xml_doc.strip()
63 self.debug("SSO_SOAP soap_xml_doc=%s" % soap_xml_doc)
64 login = self.saml2login(soap_xml_doc)
66 return self.auth(login)
69 class Redirect(AuthenticateRequest):
71 def __init__(self, *args, **kwargs):
72 super(Redirect, self).__init__(*args, **kwargs)
73 self.binding = metadata.SAML2_SERVICE_MAP['sso-redirect'][1]
75 def GET(self, *args, **kwargs):
77 query = cherrypy.request.query_string
79 login = self.saml2login(query)
80 return self.auth(login)
83 class POSTAuth(AuthenticateRequest):
85 def __init__(self, *args, **kwargs):
86 super(POSTAuth, self).__init__(*args, **kwargs)
87 self.binding = metadata.SAML2_SERVICE_MAP['sso-post'][1]
89 def POST(self, *args, **kwargs):
91 request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
92 relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
94 login = self.saml2login(request)
95 login.set_msgRelayState(relaystate)
96 return self.auth(login)
99 class Continue(AuthenticateRequest):
101 def GET(self, *args, **kwargs):
103 session = UserSession()
104 user = session.get_user()
105 transdata = self.trans.retrieve()
106 self.stage = transdata['saml2_stage']
108 if user.is_anonymous:
109 self.debug("User is marked anonymous?!")
110 # TODO: Return to SP with auth failed error
111 raise cherrypy.HTTPError(401)
113 self.debug('Continue auth for %s' % user.name)
115 if 'saml2_request' not in transdata:
116 self.debug("Couldn't find Request dump?!")
117 # TODO: Return to SP with auth failed error
118 raise cherrypy.HTTPError(400)
119 dump = transdata['saml2_request']
122 login = self.cfg.idp.get_login_handler(dump)
123 except Exception, e: # pylint: disable=broad-except
124 self.debug('Failed to load status from dump: %r' % e)
127 self.debug("Empty Request dump?!")
128 # TODO: Return to SP with auth failed error
129 raise cherrypy.HTTPError(400)
131 return self.auth(login)
134 class Logout(LogoutRequest):
136 def GET(self, *args, **kwargs):
137 query = cherrypy.request.query_string
139 relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
140 response = kwargs.get(lasso.SAML2_FIELD_RESPONSE)
142 return self.logout(query,
143 relaystate=relaystate,
144 samlresponse=response)
147 class SSO(ProviderPageBase):
149 def __init__(self, *args, **kwargs):
150 super(SSO, self).__init__(*args, **kwargs)
151 self.Redirect = Redirect(*args, **kwargs)
152 self.POST = POSTAuth(*args, **kwargs)
153 self.Continue = Continue(*args, **kwargs)
154 self.SOAP = SSO_SOAP(*args, **kwargs)
157 class SLO(ProviderPageBase):
159 def __init__(self, *args, **kwargs):
160 super(SLO, self).__init__(*args, **kwargs)
161 self.debug('SLO init')
162 self.Redirect = Logout(*args, **kwargs)
166 METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7
167 # five years (approximately)
168 METADATA_DEFAULT_VALIDITY_PERIOD = 365 * 5
171 class Metadata(ProviderPageBase):
172 def GET(self, *args, **kwargs):
174 body = self._get_metadata()
175 cherrypy.response.headers["Content-Type"] = XML_MEDIA_TYPE
176 cherrypy.response.headers["Content-Disposition"] = \
177 'attachment; filename="metadata.xml"'
180 def _get_metadata(self):
181 if os.path.isfile(self.cfg.idp_metadata_file):
182 s = os.stat(self.cfg.idp_metadata_file)
183 if s.st_mtime > time.time() - METADATA_RENEW_INTERVAL:
184 with open(self.cfg.idp_metadata_file) as m:
187 # Otherwise generate and save
188 idp_cert = Certificate()
189 idp_cert.import_cert(self.cfg.idp_certificate_file,
190 self.cfg.idp_key_file)
192 validity = int(self.cfg.idp_metadata_validity)
193 meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert,
196 with open(self.cfg.idp_metadata_file, 'w+') as m:
201 class SAML2(ProviderPageBase):
203 def __init__(self, *args, **kwargs):
204 super(SAML2, self).__init__(*args, **kwargs)
205 self.metadata = Metadata(*args, **kwargs)
206 self.SSO = SSO(*args, **kwargs)
207 self.SLO = SLO(*args, **kwargs)
210 class IdpProvider(ProviderBase):
212 def __init__(self, *pargs):
213 super(IdpProvider, self).__init__('saml2', 'saml2', *pargs)
218 self.sessionfactory = None
219 self.description = """
220 Provides SAML 2.0 authentication infrastructure. """
226 'Path to data storage accessible by the IdP.',
227 '/var/lib/ipsilon/saml2'),
230 'The IdP Metadata file generated at install time.',
233 'idp metadata validity',
234 'The IdP Metadata validity period (in days) to use when '
235 'generating new metadata.',
236 METADATA_DEFAULT_VALIDITY_PERIOD),
238 'idp certificate file',
239 'The IdP PEM Certificate generated at install time.',
243 'The IdP Certificate Key generated at install time.',
247 'The salt used for persistent Name IDs.',
250 'allow self registration',
251 'Allow authenticated users to register applications.',
254 'default allowed nameids',
255 'Default Allowed NameIDs for Service Providers.',
256 metadata.SAML2_NAMEID_MAP.keys(),
257 ['unspecified', 'persistent', 'transient', 'email',
258 'kerberos', 'x509']),
261 'Default NameID used by Service Providers.',
262 metadata.SAML2_NAMEID_MAP.keys(),
265 'default email domain',
266 'Used for users missing the email property.',
269 'default attribute mapping',
270 'Defines how to map attributes before returning them to SPs',
273 'default allowed attributes',
274 'Defines a list of allowed attributes, applied after mapping',
277 'session database url',
278 'Database URL for SAML2 sessions',
279 'saml2.sessions.db.sqlite'),
281 if cherrypy.config.get('debug', False):
284 logger = logging.getLogger('lasso')
285 lh = logging.StreamHandler(sys.stderr)
286 logger.addHandler(lh)
287 logger.setLevel(logging.DEBUG)
289 store = SAML2SessionStore(
290 database_url=self.get_config_value('session database url')
292 bt = cherrypy.process.plugins.BackgroundTask(
293 60, store.remove_expired_sessions
298 def allow_self_registration(self):
299 return self.get_config_value('allow self registration')
302 def idp_storage_path(self):
303 return self.get_config_value('idp storage path')
306 def idp_metadata_file(self):
307 return os.path.join(self.idp_storage_path,
308 self.get_config_value('idp metadata file'))
311 def idp_metadata_validity(self):
312 return self.get_config_value('idp metadata validity')
315 def idp_certificate_file(self):
316 return os.path.join(self.idp_storage_path,
317 self.get_config_value('idp certificate file'))
320 def idp_key_file(self):
321 return os.path.join(self.idp_storage_path,
322 self.get_config_value('idp key file'))
325 def idp_nameid_salt(self):
326 return self.get_config_value('idp nameid salt')
329 def default_allowed_nameids(self):
330 return self.get_config_value('default allowed nameids')
333 def default_nameid(self):
334 return self.get_config_value('default nameid')
337 def default_email_domain(self):
338 return self.get_config_value('default email domain')
341 def default_attribute_mapping(self):
342 return self.get_config_value('default attribute mapping')
345 def default_allowed_attributes(self):
346 return self.get_config_value('default allowed attributes')
348 def get_tree(self, site):
349 self.page = SAML2(site, self)
350 self.admin = Saml2AdminPage(site, self)
351 self.rest = Saml2RestBase(site, self)
356 self.sessionfactory = SAMLSessionFactory(
357 database_url=self.get_config_value('session database url')
361 idp = IdentityProvider(self,
362 sessionfactory=self.sessionfactory)
363 except Exception, e: # pylint: disable=broad-except
364 self.debug('Failed to init SAML2 provider: %r' % e)
367 self._root.logout.add_handler(self.name, self.idp_initiated_logout)
369 # Import all known applications
370 data = self.get_data()
373 if 'type' not in sp or sp['type'] != 'SP':
375 if 'name' not in sp or 'metadata' not in sp:
379 except Exception, e: # pylint: disable=broad-except
380 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
385 super(IdpProvider, self).on_enable()
386 self.idp = self.init_idp()
387 if hasattr(self, 'admin'):
391 def idp_initiated_logout(self):
393 Logout all SP sessions when the logout comes from the IdP.
395 For the current user only.
397 Only use HTTP-Redirect to start the logout. This is guaranteed
398 to be supported in SAML 2.
400 self.debug("IdP-initiated SAML2 logout")
404 saml_sessions = self.sessionfactory
405 # pylint: disable=unused-variable
406 (mech, session) = saml_sessions.get_next_logout(
407 logout_mechs=[lasso.SAML2_METADATA_BINDING_REDIRECT])
411 logout = self.idp.get_logout_handler()
412 logout.setSessionFromDump(session.login_session)
413 logout.initRequest(session.provider_id)
415 logout.buildRequestMsg()
416 except lasso.Error, e:
417 self.error('failure to build logout request msg: %s' % e)
418 raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
421 # Add a fake session to indicate where the user should
422 # be redirected to when all SP's are logged out.
423 idpurl = self._root.instance_base_url()
424 session_id = "_" + uuid.uuid4().hex.upper()
425 saml_sessions.add_session(session_id, idpurl, user.name, "", "",
426 [lasso.SAML2_METADATA_BINDING_REDIRECT])
427 init_session = saml_sessions.get_session_by_id(session_id)
428 saml_sessions.start_logout(init_session, relaystate=idpurl)
430 # Add the logout request id we just created to the session to be
431 # logged out so that when it responds we can find the right
433 session.set_logoutstate(request_id=logout.request.id)
434 saml_sessions.start_logout(session, initial=False)
436 self.debug('Sending initial logout request to %s' % logout.msgUrl)
437 raise cherrypy.HTTPRedirect(logout.msgUrl)
440 class IdpMetadataGenerator(object):
442 def __init__(self, url, idp_cert, expiration=None):
443 self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
444 self.meta.set_entity_id('%s/saml2/metadata' % url)
445 self.meta.add_certs(idp_cert, idp_cert)
446 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
447 '%s/saml2/SSO/POST' % url)
448 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
449 '%s/saml2/SSO/Redirect' % url)
450 if is_lasso_ecp_enabled():
451 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
452 '%s/saml2/SSO/SOAP' % url)
453 self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
454 '%s/saml2/SLO/Redirect' % url)
455 self.meta.add_allowed_name_format(
456 lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
457 self.meta.add_allowed_name_format(
458 lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
459 self.meta.add_allowed_name_format(
460 lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
462 def output(self, path=None):
463 return self.meta.output(path)
466 class Installer(ProviderInstaller):
468 def __init__(self, *pargs):
469 super(Installer, self).__init__()
473 def install_args(self, group):
474 group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
475 help='Configure SAML2 Provider')
476 group.add_argument('--saml2-metadata-validity',
477 default=METADATA_DEFAULT_VALIDITY_PERIOD,
478 help=('Metadata validity period in days '
480 METADATA_DEFAULT_VALIDITY_PERIOD))
481 group.add_argument('--saml2-session-dburl',
482 help='session database URL')
484 def configure(self, opts, changes):
485 if opts['saml2'] != 'yes':
488 # Check storage path is present or create it
489 path = os.path.join(opts['data_dir'], 'saml2')
490 if not os.path.exists(path):
491 os.makedirs(path, 0700)
493 # Use the same cert for signing and ecnryption for now
494 cert = Certificate(path)
495 cert.generate('idp', opts['hostname'])
497 # Generate Idp Metadata
499 if opts['secure'].lower() == 'no':
501 url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
502 validity = int(opts['saml2_metadata_validity'])
503 meta = IdpMetadataGenerator(url, cert,
505 if 'gssapi' in opts and opts['gssapi'] == 'yes':
506 meta.meta.add_allowed_name_format(
507 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
509 meta.output(os.path.join(path, 'metadata.xml'))
511 # Add configuration data to database
512 po = PluginObject(*self.pargs)
515 po.wipe_config_values()
516 config = {'idp storage path': path,
517 'idp metadata file': 'metadata.xml',
518 'idp certificate file': cert.cert,
519 'idp key file': cert.key,
520 'idp nameid salt': uuid.uuid4().hex,
521 'idp metadata validity': opts['saml2_metadata_validity'],
522 'session database url': opts['saml2_session_dburl'] or
523 opts['database_url'] % {
524 'datadir': opts['data_dir'],
525 'dbname': 'saml2.sessions.db'}}
526 po.save_plugin_config(config)
528 # Update global config to add login plugin
530 po.save_enabled_state()
532 # Fixup permissions so only the ipsilon user can read these files
533 files.fix_user_dirs(path, opts['system_user'])