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 login = self.saml2login(query)
80 return self.auth(login)
83 class POSTAuth(AuthenticateRequest):
85 def __init__(self, site, provider, *args, **kwargs):
86 super(POSTAuth, self).__init__(site, provider, *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.error("Couldn't find Request dump in transaction?!")
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.error('Failed to load login status from dump: %r' % e)
127 self.error("Empty login 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, site, provider, *args, **kwargs):
150 super(SSO, self).__init__(site, provider)
151 self.Redirect = Redirect(site, provider, *args, **kwargs)
152 self.POST = POSTAuth(site, provider, *args, **kwargs)
153 self.Continue = Continue(site, provider, *args, **kwargs)
154 self.SOAP = SSO_SOAP(site, provider, *args, **kwargs)
157 class SLO(ProviderPageBase):
159 def __init__(self, site, provider, *args, **kwargs):
160 super(SLO, self).__init__(site, provider)
161 self.debug('SLO init')
162 self.Redirect = Logout(site, provider, *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, site, provider, *args, **kwargs):
204 super(SAML2, self).__init__(site, provider)
205 self.metadata = Metadata(site, provider, *args, **kwargs)
206 self.SSO = SSO(site, provider, *args, **kwargs)
207 self.SLO = SLO(site, provider, *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 def get_providers(self):
290 return self.admin.providers
293 def allow_self_registration(self):
294 return self.get_config_value('allow self registration')
297 def idp_storage_path(self):
298 return self.get_config_value('idp storage path')
301 def idp_metadata_file(self):
302 return os.path.join(self.idp_storage_path,
303 self.get_config_value('idp metadata file'))
306 def idp_metadata_validity(self):
307 return self.get_config_value('idp metadata validity')
310 def idp_certificate_file(self):
311 return os.path.join(self.idp_storage_path,
312 self.get_config_value('idp certificate file'))
315 def idp_key_file(self):
316 return os.path.join(self.idp_storage_path,
317 self.get_config_value('idp key file'))
320 def idp_nameid_salt(self):
321 return self.get_config_value('idp nameid salt')
324 def default_allowed_nameids(self):
325 return self.get_config_value('default allowed nameids')
328 def default_nameid(self):
329 return self.get_config_value('default nameid')
332 def default_email_domain(self):
333 return self.get_config_value('default email domain')
336 def default_attribute_mapping(self):
337 return self.get_config_value('default attribute mapping')
340 def default_allowed_attributes(self):
341 return self.get_config_value('default allowed attributes')
343 def get_tree(self, site):
344 self.page = SAML2(site, self)
345 self.admin = Saml2AdminPage(site, self)
346 self.rest = Saml2RestBase(site, self)
349 def used_datastores(self):
350 # pylint: disable=protected-access
351 return [self.sessionfactory._ss]
355 self.sessionfactory = SAMLSessionFactory(
356 database_url=self.get_config_value('session database url')
360 idp = IdentityProvider(self,
361 sessionfactory=self.sessionfactory)
362 except Exception, e: # pylint: disable=broad-except
363 self.error('Failed to init SAML2 provider: %r' % e)
366 self._root.logout.add_handler(self.name, self.idp_initiated_logout)
368 # Import all known applications
369 data = self.get_data()
372 if 'type' not in sp or sp['type'] != 'SP':
374 if 'name' not in sp or 'metadata' not in sp:
378 except Exception, e: # pylint: disable=broad-except
379 self.error('Failed to add SP %s: %r' % (sp['name'], e))
384 super(IdpProvider, self).on_enable()
385 self.idp = self.init_idp()
386 if hasattr(self, 'admin'):
390 def idp_initiated_logout(self):
392 Logout all SP sessions when the logout comes from the IdP.
394 For the current user only.
396 Only use HTTP-Redirect to start the logout. This is guaranteed
397 to be supported in SAML 2.
399 self.debug("IdP-initiated SAML2 logout")
403 saml_sessions = self.sessionfactory
404 # pylint: disable=unused-variable
405 (mech, session) = saml_sessions.get_next_logout(
406 logout_mechs=[lasso.SAML2_METADATA_BINDING_REDIRECT])
410 logout = self.idp.get_logout_handler()
411 logout.setSessionFromDump(session.login_session)
412 logout.initRequest(session.provider_id)
414 logout.buildRequestMsg()
415 except lasso.Error, e:
416 self.error('failure to build logout request msg: %s' % e)
417 raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
420 # Add a fake session to indicate where the user should
421 # be redirected to when all SP's are logged out.
422 idpurl = self._root.instance_base_url()
423 session_id = "_" + uuid.uuid4().hex.upper()
424 saml_sessions.add_session(session_id, idpurl, user.name, "", "",
425 [lasso.SAML2_METADATA_BINDING_REDIRECT])
426 init_session = saml_sessions.get_session_by_id(session_id)
427 saml_sessions.start_logout(init_session, relaystate=idpurl)
429 # Add the logout request id we just created to the session to be
430 # logged out so that when it responds we can find the right
432 session.set_logoutstate(request_id=logout.request.id)
433 saml_sessions.start_logout(session, initial=False)
435 self.debug('Sending initial logout request to %s' % logout.msgUrl)
436 raise cherrypy.HTTPRedirect(logout.msgUrl)
439 class IdpMetadataGenerator(object):
441 def __init__(self, url, idp_cert, expiration=None):
442 self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
443 self.meta.set_entity_id('%s/saml2/metadata' % url)
444 self.meta.add_certs(idp_cert, idp_cert)
445 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
446 '%s/saml2/SSO/POST' % url)
447 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
448 '%s/saml2/SSO/Redirect' % url)
449 if is_lasso_ecp_enabled():
450 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
451 '%s/saml2/SSO/SOAP' % url)
452 self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
453 '%s/saml2/SLO/Redirect' % url)
454 self.meta.add_allowed_name_format(
455 lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
456 self.meta.add_allowed_name_format(
457 lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
458 self.meta.add_allowed_name_format(
459 lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
461 def output(self, path=None):
462 return self.meta.output(path)
465 class Installer(ProviderInstaller):
467 def __init__(self, *pargs):
468 super(Installer, self).__init__()
472 def install_args(self, group):
473 group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
474 help='Configure SAML2 Provider')
475 group.add_argument('--saml2-metadata-validity',
476 default=METADATA_DEFAULT_VALIDITY_PERIOD,
477 help=('Metadata validity period in days '
479 METADATA_DEFAULT_VALIDITY_PERIOD))
480 group.add_argument('--saml2-session-dburl',
481 help='session database URL')
483 def configure(self, opts, changes):
484 if opts['saml2'] != 'yes':
487 # Check storage path is present or create it
488 path = os.path.join(opts['data_dir'], 'saml2')
489 if not os.path.exists(path):
490 os.makedirs(path, 0700)
492 # Use the same cert for signing and ecnryption for now
493 cert = Certificate(path)
494 cert.generate('idp', opts['hostname'])
496 # Generate Idp Metadata
498 if opts['secure'].lower() == 'no':
500 url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
501 validity = int(opts['saml2_metadata_validity'])
502 meta = IdpMetadataGenerator(url, cert,
504 if 'gssapi' in opts and opts['gssapi'] == 'yes':
505 meta.meta.add_allowed_name_format(
506 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
508 meta.output(os.path.join(path, 'metadata.xml'))
510 # Add configuration data to database
511 po = PluginObject(*self.pargs)
514 po.wipe_config_values()
515 config = {'idp storage path': path,
516 'idp metadata file': 'metadata.xml',
517 'idp certificate file': cert.cert,
518 'idp key file': cert.key,
519 'idp nameid salt': uuid.uuid4().hex,
520 'idp metadata validity': opts['saml2_metadata_validity'],
521 'session database url': opts['saml2_session_dburl'] or
522 opts['database_url'] % {
523 'datadir': opts['data_dir'],
524 'dbname': 'saml2.sessions.db'}}
525 po.save_plugin_config(config)
527 # Update global config to add login plugin
529 po.save_enabled_state()
531 # Fixup permissions so only the ipsilon user can read these files
532 files.fix_user_dirs(path, opts['system_user'])