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.tools.certs import Certificate
12 from ipsilon.tools import saml2metadata as metadata
13 from ipsilon.tools import files
14 from ipsilon.util.http import require_content_type
15 from ipsilon.util.constants import SOAP_MEDIA_TYPE, XML_MEDIA_TYPE
16 from ipsilon.util.user import UserSession
17 from ipsilon.util.plugin import PluginObject
18 from ipsilon.util import config as pconfig
20 from datetime import timedelta
26 cherrypy.tools.require_content_type = cherrypy.Tool('before_request_body',
30 def is_lasso_ecp_enabled():
31 # Full ECP support appeared in lasso version 2.4.2
32 return lasso.checkVersion(2, 4, 2, lasso.CHECK_VERSION_NUMERIC)
35 class SSO_SOAP(AuthenticateRequest):
37 def __init__(self, *args, **kwargs):
38 super(SSO_SOAP, self).__init__(*args, **kwargs)
39 self.binding = metadata.SAML2_SERVICE_MAP['sso-soap'][1]
41 @cherrypy.tools.require_content_type(
42 required=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
43 @cherrypy.tools.accept(media=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
44 @cherrypy.tools.response_headers(
45 headers=[('Content-Type', 'SOAP_MEDIA_TYPE')])
46 def POST(self, *args, **kwargs):
47 self.debug("SSO_SOAP.POST() begin")
49 self.debug("SSO_SOAP transaction provider=%s id=%s" %
50 (self.trans.provider, self.trans.transaction_id))
55 self.debug("SSO_SOAP user=%s" % (user.name))
58 raise cherrypy.HTTPError(403, 'No user specified for SSO_SOAP')
60 soap_xml_doc = cherrypy.request.rfile.read()
61 soap_xml_doc = soap_xml_doc.strip()
62 self.debug("SSO_SOAP soap_xml_doc=%s" % soap_xml_doc)
63 login = self.saml2login(soap_xml_doc)
65 return self.auth(login)
68 class Redirect(AuthenticateRequest):
70 def __init__(self, *args, **kwargs):
71 super(Redirect, self).__init__(*args, **kwargs)
72 self.binding = metadata.SAML2_SERVICE_MAP['sso-redirect'][1]
74 def GET(self, *args, **kwargs):
76 query = cherrypy.request.query_string
78 login = self.saml2login(query)
79 return self.auth(login)
82 class POSTAuth(AuthenticateRequest):
84 def __init__(self, *args, **kwargs):
85 super(POSTAuth, self).__init__(*args, **kwargs)
86 self.binding = metadata.SAML2_SERVICE_MAP['sso-post'][1]
88 def POST(self, *args, **kwargs):
90 request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
91 relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
93 login = self.saml2login(request)
94 login.set_msgRelayState(relaystate)
95 return self.auth(login)
98 class Continue(AuthenticateRequest):
100 def GET(self, *args, **kwargs):
102 session = UserSession()
103 user = session.get_user()
104 transdata = self.trans.retrieve()
105 self.stage = transdata['saml2_stage']
107 if user.is_anonymous:
108 self.debug("User is marked anonymous?!")
109 # TODO: Return to SP with auth failed error
110 raise cherrypy.HTTPError(401)
112 self.debug('Continue auth for %s' % user.name)
114 if 'saml2_request' not in transdata:
115 self.debug("Couldn't find Request dump?!")
116 # TODO: Return to SP with auth failed error
117 raise cherrypy.HTTPError(400)
118 dump = transdata['saml2_request']
121 login = self.cfg.idp.get_login_handler(dump)
122 except Exception, e: # pylint: disable=broad-except
123 self.debug('Failed to load status from dump: %r' % e)
126 self.debug("Empty Request dump?!")
127 # TODO: Return to SP with auth failed error
128 raise cherrypy.HTTPError(400)
130 return self.auth(login)
133 class Logout(LogoutRequest):
135 def GET(self, *args, **kwargs):
136 query = cherrypy.request.query_string
138 relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
139 response = kwargs.get(lasso.SAML2_FIELD_RESPONSE)
141 return self.logout(query,
142 relaystate=relaystate,
143 samlresponse=response)
146 class SSO(ProviderPageBase):
148 def __init__(self, *args, **kwargs):
149 super(SSO, self).__init__(*args, **kwargs)
150 self.Redirect = Redirect(*args, **kwargs)
151 self.POST = POSTAuth(*args, **kwargs)
152 self.Continue = Continue(*args, **kwargs)
153 self.SOAP = SSO_SOAP(*args, **kwargs)
156 class SLO(ProviderPageBase):
158 def __init__(self, *args, **kwargs):
159 super(SLO, self).__init__(*args, **kwargs)
160 self.debug('SLO init')
161 self.Redirect = Logout(*args, **kwargs)
165 METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7
166 # five years (approximately)
167 METADATA_DEFAULT_VALIDITY_PERIOD = 365 * 5
170 class Metadata(ProviderPageBase):
171 def GET(self, *args, **kwargs):
173 body = self._get_metadata()
174 cherrypy.response.headers["Content-Type"] = XML_MEDIA_TYPE
175 cherrypy.response.headers["Content-Disposition"] = \
176 'attachment; filename="metadata.xml"'
179 def _get_metadata(self):
180 if os.path.isfile(self.cfg.idp_metadata_file):
181 s = os.stat(self.cfg.idp_metadata_file)
182 if s.st_mtime > time.time() - METADATA_RENEW_INTERVAL:
183 with open(self.cfg.idp_metadata_file) as m:
186 # Otherwise generate and save
187 idp_cert = Certificate()
188 idp_cert.import_cert(self.cfg.idp_certificate_file,
189 self.cfg.idp_key_file)
191 validity = int(self.cfg.idp_metadata_validity)
192 meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert,
195 with open(self.cfg.idp_metadata_file, 'w+') as m:
200 class SAML2(ProviderPageBase):
202 def __init__(self, *args, **kwargs):
203 super(SAML2, self).__init__(*args, **kwargs)
204 self.metadata = Metadata(*args, **kwargs)
205 self.SSO = SSO(*args, **kwargs)
206 self.SLO = SLO(*args, **kwargs)
209 class IdpProvider(ProviderBase):
211 def __init__(self, *pargs):
212 super(IdpProvider, self).__init__('saml2', 'saml2', *pargs)
217 self.sessionfactory = None
218 self.description = """
219 Provides SAML 2.0 authentication infrastructure. """
225 'Path to data storage accessible by the IdP.',
226 '/var/lib/ipsilon/saml2'),
229 'The IdP Metadata file generated at install time.',
232 'idp metadata validity',
233 'The IdP Metadata validity period (in days) to use when '
234 'generating new metadata.',
235 METADATA_DEFAULT_VALIDITY_PERIOD),
237 'idp certificate file',
238 'The IdP PEM Certificate generated at install time.',
242 'The IdP Certificate Key generated at install time.',
246 'The salt used for persistent Name IDs.',
249 'allow self registration',
250 'Allow authenticated users to register applications.',
253 'default allowed nameids',
254 'Default Allowed NameIDs for Service Providers.',
255 metadata.SAML2_NAMEID_MAP.keys(),
256 ['unspecified', 'persistent', 'transient', 'email',
257 'kerberos', 'x509']),
260 'Default NameID used by Service Providers.',
261 metadata.SAML2_NAMEID_MAP.keys(),
264 'default email domain',
265 'Used for users missing the email property.',
268 'default attribute mapping',
269 'Defines how to map attributes before returning them to SPs',
272 'default allowed attributes',
273 'Defines a list of allowed attributes, applied after mapping',
276 'session database url',
277 'Database URL for SAML2 sessions',
278 'saml2.sessions.db.sqlite'),
280 if cherrypy.config.get('debug', False):
283 logger = logging.getLogger('lasso')
284 lh = logging.StreamHandler(sys.stderr)
285 logger.addHandler(lh)
286 logger.setLevel(logging.DEBUG)
288 def get_providers(self):
289 return self.admin.providers
292 def allow_self_registration(self):
293 return self.get_config_value('allow self registration')
296 def idp_storage_path(self):
297 return self.get_config_value('idp storage path')
300 def idp_metadata_file(self):
301 return os.path.join(self.idp_storage_path,
302 self.get_config_value('idp metadata file'))
305 def idp_metadata_validity(self):
306 return self.get_config_value('idp metadata validity')
309 def idp_certificate_file(self):
310 return os.path.join(self.idp_storage_path,
311 self.get_config_value('idp certificate file'))
314 def idp_key_file(self):
315 return os.path.join(self.idp_storage_path,
316 self.get_config_value('idp key file'))
319 def idp_nameid_salt(self):
320 return self.get_config_value('idp nameid salt')
323 def default_allowed_nameids(self):
324 return self.get_config_value('default allowed nameids')
327 def default_nameid(self):
328 return self.get_config_value('default nameid')
331 def default_email_domain(self):
332 return self.get_config_value('default email domain')
335 def default_attribute_mapping(self):
336 return self.get_config_value('default attribute mapping')
339 def default_allowed_attributes(self):
340 return self.get_config_value('default allowed attributes')
342 def get_tree(self, site):
343 self.page = SAML2(site, self)
344 self.admin = Saml2AdminPage(site, self)
345 self.rest = Saml2RestBase(site, self)
348 def used_datastores(self):
349 # pylint: disable=protected-access
350 return [self.sessionfactory._ss]
354 self.sessionfactory = SAMLSessionFactory(
355 database_url=self.get_config_value('session database url')
358 # pylint: disable=protected-access
359 bt = cherrypy.process.plugins.BackgroundTask(
360 60, self.sessionfactory._ss.remove_expired_sessions
365 idp = IdentityProvider(self,
366 sessionfactory=self.sessionfactory)
367 except Exception, e: # pylint: disable=broad-except
368 self.debug('Failed to init SAML2 provider: %r' % e)
371 self._root.logout.add_handler(self.name, self.idp_initiated_logout)
373 # Import all known applications
374 data = self.get_data()
377 if 'type' not in sp or sp['type'] != 'SP':
379 if 'name' not in sp or 'metadata' not in sp:
383 except Exception, e: # pylint: disable=broad-except
384 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
389 super(IdpProvider, self).on_enable()
390 self.idp = self.init_idp()
391 if hasattr(self, 'admin'):
395 def idp_initiated_logout(self):
397 Logout all SP sessions when the logout comes from the IdP.
399 For the current user only.
401 Only use HTTP-Redirect to start the logout. This is guaranteed
402 to be supported in SAML 2.
404 self.debug("IdP-initiated SAML2 logout")
408 saml_sessions = self.sessionfactory
409 # pylint: disable=unused-variable
410 (mech, session) = saml_sessions.get_next_logout(
411 logout_mechs=[lasso.SAML2_METADATA_BINDING_REDIRECT])
415 logout = self.idp.get_logout_handler()
416 logout.setSessionFromDump(session.login_session)
417 logout.initRequest(session.provider_id)
419 logout.buildRequestMsg()
420 except lasso.Error, e:
421 self.error('failure to build logout request msg: %s' % e)
422 raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
425 # Add a fake session to indicate where the user should
426 # be redirected to when all SP's are logged out.
427 idpurl = self._root.instance_base_url()
428 session_id = "_" + uuid.uuid4().hex.upper()
429 saml_sessions.add_session(session_id, idpurl, user.name, "", "",
430 [lasso.SAML2_METADATA_BINDING_REDIRECT])
431 init_session = saml_sessions.get_session_by_id(session_id)
432 saml_sessions.start_logout(init_session, relaystate=idpurl)
434 # Add the logout request id we just created to the session to be
435 # logged out so that when it responds we can find the right
437 session.set_logoutstate(request_id=logout.request.id)
438 saml_sessions.start_logout(session, initial=False)
440 self.debug('Sending initial logout request to %s' % logout.msgUrl)
441 raise cherrypy.HTTPRedirect(logout.msgUrl)
444 class IdpMetadataGenerator(object):
446 def __init__(self, url, idp_cert, expiration=None):
447 self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
448 self.meta.set_entity_id('%s/saml2/metadata' % url)
449 self.meta.add_certs(idp_cert, idp_cert)
450 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
451 '%s/saml2/SSO/POST' % url)
452 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
453 '%s/saml2/SSO/Redirect' % url)
454 if is_lasso_ecp_enabled():
455 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
456 '%s/saml2/SSO/SOAP' % url)
457 self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
458 '%s/saml2/SLO/Redirect' % url)
459 self.meta.add_allowed_name_format(
460 lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
461 self.meta.add_allowed_name_format(
462 lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
463 self.meta.add_allowed_name_format(
464 lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
466 def output(self, path=None):
467 return self.meta.output(path)
470 class Installer(ProviderInstaller):
472 def __init__(self, *pargs):
473 super(Installer, self).__init__()
477 def install_args(self, group):
478 group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
479 help='Configure SAML2 Provider')
480 group.add_argument('--saml2-metadata-validity',
481 default=METADATA_DEFAULT_VALIDITY_PERIOD,
482 help=('Metadata validity period in days '
484 METADATA_DEFAULT_VALIDITY_PERIOD))
485 group.add_argument('--saml2-session-dburl',
486 help='session database URL')
488 def configure(self, opts, changes):
489 if opts['saml2'] != 'yes':
492 # Check storage path is present or create it
493 path = os.path.join(opts['data_dir'], 'saml2')
494 if not os.path.exists(path):
495 os.makedirs(path, 0700)
497 # Use the same cert for signing and ecnryption for now
498 cert = Certificate(path)
499 cert.generate('idp', opts['hostname'])
501 # Generate Idp Metadata
503 if opts['secure'].lower() == 'no':
505 url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
506 validity = int(opts['saml2_metadata_validity'])
507 meta = IdpMetadataGenerator(url, cert,
509 if 'gssapi' in opts and opts['gssapi'] == 'yes':
510 meta.meta.add_allowed_name_format(
511 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
513 meta.output(os.path.join(path, 'metadata.xml'))
515 # Add configuration data to database
516 po = PluginObject(*self.pargs)
519 po.wipe_config_values()
520 config = {'idp storage path': path,
521 'idp metadata file': 'metadata.xml',
522 'idp certificate file': cert.cert,
523 'idp key file': cert.key,
524 'idp nameid salt': uuid.uuid4().hex,
525 'idp metadata validity': opts['saml2_metadata_validity'],
526 'session database url': opts['saml2_session_dburl'] or
527 opts['database_url'] % {
528 'datadir': opts['data_dir'],
529 'dbname': 'saml2.sessions.db'}}
530 po.save_plugin_config(config)
532 # Update global config to add login plugin
534 po.save_enabled_state()
536 # Fixup permissions so only the ipsilon user can read these files
537 files.fix_user_dirs(path, opts['system_user'])