1 # Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING
3 from ipsilon.login.common import LoginHelper
4 from ipsilon.providers.common import ProviderBase, ProviderPageBase, \
6 from ipsilon.providers.saml2.auth import AuthenticateRequest
7 from ipsilon.providers.saml2.logout import LogoutRequest
8 from ipsilon.providers.saml2.admin import Saml2AdminPage
9 from ipsilon.providers.saml2.rest import Saml2RestBase
10 from ipsilon.providers.saml2.provider import IdentityProvider
11 from ipsilon.providers.saml2.sessions import SAMLSessionFactory
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 # Look for an exported symbol we know was added with ECP support
33 return 'ECP_ERROR_MISSING_AUTHN_REQUEST' in dir(lasso)
36 class SSO_SOAP(AuthenticateRequest, LoginHelper):
38 def __init__(self, site, provider, *args, **kwargs):
39 super(SSO_SOAP, self).__init__(site, provider, *args, **kwargs)
40 # pylint: disable=protected-access
41 self.info = provider._root.login.info
42 self.binding = metadata.SAML2_SERVICE_MAP['sso-soap'][1]
44 @cherrypy.tools.require_content_type(
45 required=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
46 @cherrypy.tools.accept(media=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
47 @cherrypy.tools.response_headers(
48 headers=[('Content-Type', 'SOAP_MEDIA_TYPE')])
49 def POST(self, *args, **kwargs):
50 self.debug("SSO_SOAP.POST() begin")
52 self.debug("SSO_SOAP transaction provider=%s id=%s" %
53 (self.trans.provider, self.trans.transaction_id))
55 username, auth_type = self.get_external_auth_info()
57 raise cherrypy.HTTPError(403, 'No user specified for SSO_SOAP')
58 self.debug("SSO_SOAP user=%s auth_type=%s" % (username, auth_type))
59 self.initialize_login_session(username, self.info, auth_type)
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, site, provider, *args, **kwargs):
72 super(Redirect, self).__init__(site, provider, *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 spidentifier = kwargs.get('SPIdentifier')
80 relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
82 login = self.saml2login(query, spidentifier, relaystate)
83 return self.auth(login)
86 class POSTAuth(AuthenticateRequest):
88 def __init__(self, site, provider, *args, **kwargs):
89 super(POSTAuth, self).__init__(site, provider, *args, **kwargs)
90 self.binding = metadata.SAML2_SERVICE_MAP['sso-post'][1]
92 def POST(self, *args, **kwargs):
94 request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
95 relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
97 login = self.saml2login(request)
98 login.set_msgRelayState(relaystate)
99 return self.auth(login)
102 class Continue(AuthenticateRequest):
104 def GET(self, *args, **kwargs):
106 session = UserSession()
107 user = session.get_user()
108 transdata = self.trans.retrieve()
109 self.stage = transdata['saml2_stage']
111 if user.is_anonymous:
112 self.debug("User is marked anonymous?!")
113 # TODO: Return to SP with auth failed error
114 raise cherrypy.HTTPError(401)
116 self.debug('Continue auth for %s' % user.name)
118 if 'saml2_request' not in transdata:
119 self.error("Couldn't find Request dump in transaction?!")
120 # TODO: Return to SP with auth failed error
121 raise cherrypy.HTTPError(400)
122 dump = transdata['saml2_request']
125 login = self.cfg.idp.get_login_handler(dump)
126 except Exception, e: # pylint: disable=broad-except
127 self.error('Failed to load login status from dump: %r' % e)
130 self.error("Empty login Request dump?!")
131 # TODO: Return to SP with auth failed error
132 raise cherrypy.HTTPError(400)
134 return self.auth(login)
137 class Logout(LogoutRequest):
139 def GET(self, *args, **kwargs):
140 query = cherrypy.request.query_string
142 relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
143 response = kwargs.get(lasso.SAML2_FIELD_RESPONSE)
145 return self.logout(query,
146 relaystate=relaystate,
147 samlresponse=response)
150 class SSO(ProviderPageBase):
152 def __init__(self, site, provider, *args, **kwargs):
153 super(SSO, self).__init__(site, provider)
154 self.Redirect = Redirect(site, provider, *args, **kwargs)
155 self.POST = POSTAuth(site, provider, *args, **kwargs)
156 self.Continue = Continue(site, provider, *args, **kwargs)
157 self.SOAP = SSO_SOAP(site, provider, *args, **kwargs)
160 class SLO(ProviderPageBase):
162 def __init__(self, site, provider, *args, **kwargs):
163 super(SLO, self).__init__(site, provider)
164 self.debug('SLO init')
165 self.Redirect = Logout(site, provider, *args, **kwargs)
169 METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7
170 # five years (approximately)
171 METADATA_DEFAULT_VALIDITY_PERIOD = 365 * 5
174 class Metadata(ProviderPageBase):
175 def GET(self, *args, **kwargs):
177 body = self._get_metadata()
178 cherrypy.response.headers["Content-Type"] = XML_MEDIA_TYPE
179 cherrypy.response.headers["Content-Disposition"] = \
180 'attachment; filename="metadata.xml"'
183 def _get_metadata(self):
184 if os.path.isfile(self.cfg.idp_metadata_file):
185 s = os.stat(self.cfg.idp_metadata_file)
186 if s.st_mtime > time.time() - METADATA_RENEW_INTERVAL:
187 with open(self.cfg.idp_metadata_file) as m:
190 # Otherwise generate and save
191 idp_cert = Certificate()
192 idp_cert.import_cert(self.cfg.idp_certificate_file,
193 self.cfg.idp_key_file)
195 validity = int(self.cfg.idp_metadata_validity)
196 meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert,
199 with open(self.cfg.idp_metadata_file, 'w+') as m:
204 class SAML2(ProviderPageBase):
206 def __init__(self, site, provider, *args, **kwargs):
207 super(SAML2, self).__init__(site, provider)
208 self.metadata = Metadata(site, provider, *args, **kwargs)
209 self.SSO = SSO(site, provider, *args, **kwargs)
210 self.SLO = SLO(site, provider, *args, **kwargs)
213 class IdpProvider(ProviderBase):
215 def __init__(self, *pargs):
216 super(IdpProvider, self).__init__('saml2', 'saml2', *pargs)
221 self.sessionfactory = None
222 self.description = """
223 Provides SAML 2.0 authentication infrastructure. """
229 'Path to data storage accessible by the IdP.',
230 '/var/lib/ipsilon/saml2'),
233 'The IdP Metadata file generated at install time.',
236 'idp metadata validity',
237 'The IdP Metadata validity period (in days) to use when '
238 'generating new metadata.',
239 METADATA_DEFAULT_VALIDITY_PERIOD),
241 'idp certificate file',
242 'The IdP PEM Certificate generated at install time.',
246 'The IdP Certificate Key generated at install time.',
250 'The salt used for persistent Name IDs.',
253 'allow self registration',
254 'Allow authenticated users to register applications.',
257 'default allowed nameids',
258 'Default Allowed NameIDs for Service Providers.',
259 metadata.SAML2_NAMEID_MAP.keys(),
260 ['unspecified', 'persistent', 'transient', 'email',
261 'kerberos', 'x509']),
264 'Default NameID used by Service Providers.',
265 metadata.SAML2_NAMEID_MAP.keys(),
268 'default email domain',
269 'Used for users missing the email property.',
272 'default attribute mapping',
273 'Defines how to map attributes before returning them to SPs',
276 'default allowed attributes',
277 'Defines a list of allowed attributes, applied after mapping',
280 'session database url',
281 'Database URL for SAML2 sessions',
282 'saml2.sessions.db.sqlite'),
284 if cherrypy.config.get('debug', False):
287 logger = logging.getLogger('lasso')
288 lh = logging.StreamHandler(sys.stderr)
289 logger.addHandler(lh)
290 logger.setLevel(logging.DEBUG)
292 def get_providers(self):
293 return self.admin.providers
296 def allow_self_registration(self):
297 return self.get_config_value('allow self registration')
300 def idp_storage_path(self):
301 return self.get_config_value('idp storage path')
304 def idp_metadata_file(self):
305 return os.path.join(self.idp_storage_path,
306 self.get_config_value('idp metadata file'))
309 def idp_metadata_validity(self):
310 return self.get_config_value('idp metadata validity')
313 def idp_certificate_file(self):
314 return os.path.join(self.idp_storage_path,
315 self.get_config_value('idp certificate file'))
318 def idp_key_file(self):
319 return os.path.join(self.idp_storage_path,
320 self.get_config_value('idp key file'))
323 def idp_nameid_salt(self):
324 return self.get_config_value('idp nameid salt')
327 def default_allowed_nameids(self):
328 return self.get_config_value('default allowed nameids')
331 def default_nameid(self):
332 return self.get_config_value('default nameid')
335 def default_email_domain(self):
336 return self.get_config_value('default email domain')
339 def default_attribute_mapping(self):
340 return self.get_config_value('default attribute mapping')
343 def default_allowed_attributes(self):
344 return self.get_config_value('default allowed attributes')
346 def get_tree(self, site):
347 self.page = SAML2(site, self)
348 self.admin = Saml2AdminPage(site, self)
349 self.rest = Saml2RestBase(site, self)
352 def used_datastores(self):
353 # pylint: disable=protected-access
354 return [self.sessionfactory._ss]
358 self.sessionfactory = SAMLSessionFactory(
359 database_url=self.get_config_value('session database url')
363 idp = IdentityProvider(self,
364 sessionfactory=self.sessionfactory)
365 except Exception, e: # pylint: disable=broad-except
366 self.error('Failed to init SAML2 provider: %r' % e)
369 self._root.logout.add_handler(self.name, self.idp_initiated_logout)
371 # Import all known applications
372 data = self.get_data()
375 if 'type' not in sp or sp['type'] != 'SP':
377 if 'name' not in sp or 'metadata' not in sp:
381 except Exception, e: # pylint: disable=broad-except
382 self.error('Failed to add SP %s: %r' % (sp['name'], e))
387 super(IdpProvider, self).on_enable()
388 self.idp = self.init_idp()
389 if hasattr(self, 'admin'):
393 def idp_initiated_logout(self):
395 Logout all SP sessions when the logout comes from the IdP.
397 For the current user only.
399 Only use HTTP-Redirect to start the logout. This is guaranteed
400 to be supported in SAML 2.
402 self.debug("IdP-initiated SAML2 logout")
406 saml_sessions = self.sessionfactory
407 # pylint: disable=unused-variable
408 (mech, session) = saml_sessions.get_next_logout(
409 logout_mechs=[lasso.SAML2_METADATA_BINDING_REDIRECT])
413 logout = self.idp.get_logout_handler()
414 logout.setSessionFromDump(session.login_session)
415 logout.initRequest(session.provider_id)
417 logout.buildRequestMsg()
418 except lasso.Error, e:
419 self.error('failure to build logout request msg: %s' % e)
420 raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
423 # Add a fake session to indicate where the user should
424 # be redirected to when all SP's are logged out.
425 idpurl = self._root.instance_base_url()
426 session_id = "_" + uuid.uuid4().hex.upper()
427 saml_sessions.add_session(session_id, idpurl, user.name, "", "",
428 [lasso.SAML2_METADATA_BINDING_REDIRECT])
429 init_session = saml_sessions.get_session_by_id(session_id)
430 saml_sessions.start_logout(init_session, relaystate=idpurl)
432 # Add the logout request id we just created to the session to be
433 # logged out so that when it responds we can find the right
435 session.set_logoutstate(request_id=logout.request.id)
436 saml_sessions.start_logout(session, initial=False)
438 self.debug('Sending initial logout request to %s' % logout.msgUrl)
439 raise cherrypy.HTTPRedirect(logout.msgUrl)
442 class IdpMetadataGenerator(object):
444 def __init__(self, url, idp_cert, expiration=None):
445 self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
446 self.meta.set_entity_id('%s/saml2/metadata' % url)
447 self.meta.add_certs(idp_cert, idp_cert)
448 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
449 '%s/saml2/SSO/POST' % url)
450 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
451 '%s/saml2/SSO/Redirect' % url)
452 if is_lasso_ecp_enabled():
453 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
454 '%s/saml2/SSO/SOAP' % url)
455 self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
456 '%s/saml2/SLO/Redirect' % url)
457 self.meta.add_allowed_name_format(
458 lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
459 self.meta.add_allowed_name_format(
460 lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
461 self.meta.add_allowed_name_format(
462 lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
464 def output(self, path=None):
465 return self.meta.output(path)
468 class Installer(ProviderInstaller):
470 def __init__(self, *pargs):
471 super(Installer, self).__init__()
475 def install_args(self, group):
476 group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
477 help='Configure SAML2 Provider')
478 group.add_argument('--saml2-metadata-validity',
479 default=METADATA_DEFAULT_VALIDITY_PERIOD,
480 help=('Metadata validity period in days '
482 METADATA_DEFAULT_VALIDITY_PERIOD))
483 group.add_argument('--saml2-session-dburl',
484 help='session database URL')
486 def configure(self, opts, changes):
487 if opts['saml2'] != 'yes':
490 # Check storage path is present or create it
491 path = os.path.join(opts['data_dir'], 'saml2')
492 if not os.path.exists(path):
493 os.makedirs(path, 0700)
495 # Use the same cert for signing and ecnryption for now
496 cert = Certificate(path)
497 cert.generate('idp', opts['hostname'])
499 # Generate Idp Metadata
501 if opts['secure'].lower() == 'no':
503 url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
504 validity = int(opts['saml2_metadata_validity'])
505 meta = IdpMetadataGenerator(url, cert,
507 if 'gssapi' in opts and opts['gssapi'] == 'yes':
508 meta.meta.add_allowed_name_format(
509 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
511 meta.output(os.path.join(path, 'metadata.xml'))
513 # Add configuration data to database
514 po = PluginObject(*self.pargs)
517 po.wipe_config_values()
518 config = {'idp storage path': path,
519 'idp metadata file': 'metadata.xml',
520 'idp certificate file': cert.cert,
521 'idp key file': cert.key,
522 'idp nameid salt': uuid.uuid4().hex,
523 'idp metadata validity': opts['saml2_metadata_validity'],
524 'session database url': opts['saml2_session_dburl'] or
525 opts['database_url'] % {
526 'datadir': opts['data_dir'],
527 'dbname': 'saml2.sessions.db'}}
528 po.save_plugin_config(config)
530 # Update global config to add login plugin
532 po.save_enabled_state()
534 # Fixup permissions so only the ipsilon user can read these files
535 files.fix_user_dirs(path, opts['system_user'])