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.tools.certs import Certificate
11 from ipsilon.tools import saml2metadata as metadata
12 from ipsilon.tools import files
13 from ipsilon.util.http import require_content_type
14 from ipsilon.util.constants import SOAP_MEDIA_TYPE, XML_MEDIA_TYPE
15 from ipsilon.util.user import UserSession
16 from ipsilon.util.plugin import PluginObject
17 from ipsilon.util import config as pconfig
19 from datetime import timedelta
25 cherrypy.tools.require_content_type = cherrypy.Tool('before_request_body',
29 def is_lasso_ecp_enabled():
30 # Full ECP support appeared in lasso version 2.4.2
31 return lasso.checkVersion(2, 4, 2, lasso.CHECK_VERSION_NUMERIC)
34 class SSO_SOAP(AuthenticateRequest):
36 def __init__(self, *args, **kwargs):
37 super(SSO_SOAP, self).__init__(*args, **kwargs)
38 self.binding = metadata.SAML2_SERVICE_MAP['sso-soap'][1]
40 @cherrypy.tools.require_content_type(
41 required=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
42 @cherrypy.tools.accept(media=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
43 @cherrypy.tools.response_headers(
44 headers=[('Content-Type', 'SOAP_MEDIA_TYPE')])
45 def POST(self, *args, **kwargs):
46 self.debug("SSO_SOAP.POST() begin")
48 self.debug("SSO_SOAP transaction provider=%s id=%s" %
49 (self.trans.provider, self.trans.transaction_id))
54 self.debug("SSO_SOAP user=%s" % (user.name))
57 raise cherrypy.HTTPError(403, 'No user specified for SSO_SOAP')
59 soap_xml_doc = cherrypy.request.rfile.read()
60 soap_xml_doc = soap_xml_doc.strip()
61 self.debug("SSO_SOAP soap_xml_doc=%s" % soap_xml_doc)
62 login = self.saml2login(soap_xml_doc)
64 return self.auth(login)
67 class Redirect(AuthenticateRequest):
69 def __init__(self, *args, **kwargs):
70 super(Redirect, self).__init__(*args, **kwargs)
71 self.binding = metadata.SAML2_SERVICE_MAP['sso-redirect'][1]
73 def GET(self, *args, **kwargs):
75 query = cherrypy.request.query_string
77 login = self.saml2login(query)
78 return self.auth(login)
81 class POSTAuth(AuthenticateRequest):
83 def __init__(self, *args, **kwargs):
84 super(POSTAuth, self).__init__(*args, **kwargs)
85 self.binding = metadata.SAML2_SERVICE_MAP['sso-post'][1]
87 def POST(self, *args, **kwargs):
89 request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
90 relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
92 login = self.saml2login(request)
93 login.set_msgRelayState(relaystate)
94 return self.auth(login)
97 class Continue(AuthenticateRequest):
99 def GET(self, *args, **kwargs):
101 session = UserSession()
102 user = session.get_user()
103 transdata = self.trans.retrieve()
104 self.stage = transdata['saml2_stage']
106 if user.is_anonymous:
107 self.debug("User is marked anonymous?!")
108 # TODO: Return to SP with auth failed error
109 raise cherrypy.HTTPError(401)
111 self.debug('Continue auth for %s' % user.name)
113 if 'saml2_request' not in transdata:
114 self.debug("Couldn't find Request dump?!")
115 # TODO: Return to SP with auth failed error
116 raise cherrypy.HTTPError(400)
117 dump = transdata['saml2_request']
120 login = self.cfg.idp.get_login_handler(dump)
121 except Exception, e: # pylint: disable=broad-except
122 self.debug('Failed to load status from dump: %r' % e)
125 self.debug("Empty Request dump?!")
126 # TODO: Return to SP with auth failed error
127 raise cherrypy.HTTPError(400)
129 return self.auth(login)
132 class RedirectLogout(LogoutRequest):
134 def GET(self, *args, **kwargs):
135 query = cherrypy.request.query_string
137 relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
138 response = kwargs.get(lasso.SAML2_FIELD_RESPONSE)
140 return self.logout(query,
141 relaystate=relaystate,
142 samlresponse=response)
145 class SSO(ProviderPageBase):
147 def __init__(self, *args, **kwargs):
148 super(SSO, self).__init__(*args, **kwargs)
149 self.Redirect = Redirect(*args, **kwargs)
150 self.POST = POSTAuth(*args, **kwargs)
151 self.Continue = Continue(*args, **kwargs)
152 self.SOAP = SSO_SOAP(*args, **kwargs)
155 class SLO(ProviderPageBase):
157 def __init__(self, *args, **kwargs):
158 super(SLO, self).__init__(*args, **kwargs)
159 self.debug('SLO init')
160 self.Redirect = RedirectLogout(*args, **kwargs)
164 METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7
165 # five years (approximately)
166 METADATA_DEFAULT_VALIDITY_PERIOD = 365 * 5
169 class Metadata(ProviderPageBase):
170 def GET(self, *args, **kwargs):
172 body = self._get_metadata()
173 cherrypy.response.headers["Content-Type"] = XML_MEDIA_TYPE
174 cherrypy.response.headers["Content-Disposition"] = \
175 'attachment; filename="metadata.xml"'
178 def _get_metadata(self):
179 if os.path.isfile(self.cfg.idp_metadata_file):
180 s = os.stat(self.cfg.idp_metadata_file)
181 if s.st_mtime > time.time() - METADATA_RENEW_INTERVAL:
182 with open(self.cfg.idp_metadata_file) as m:
185 # Otherwise generate and save
186 idp_cert = Certificate()
187 idp_cert.import_cert(self.cfg.idp_certificate_file,
188 self.cfg.idp_key_file)
190 validity = int(self.cfg.idp_metadata_validity)
191 meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert,
194 with open(self.cfg.idp_metadata_file, 'w+') as m:
199 class SAML2(ProviderPageBase):
201 def __init__(self, *args, **kwargs):
202 super(SAML2, self).__init__(*args, **kwargs)
203 self.metadata = Metadata(*args, **kwargs)
204 self.SSO = SSO(*args, **kwargs)
205 self.SLO = SLO(*args, **kwargs)
208 class IdpProvider(ProviderBase):
210 def __init__(self, *pargs):
211 super(IdpProvider, self).__init__('saml2', 'saml2', *pargs)
216 self.description = """
217 Provides SAML 2.0 authentication infrastructure. """
223 'Path to data storage accessible by the IdP.',
224 '/var/lib/ipsilon/saml2'),
227 'The IdP Metadata file generated at install time.',
230 'idp metadata validity',
231 'The IdP Metadata validity period (in days) to use when '
232 'generating new metadata.',
233 METADATA_DEFAULT_VALIDITY_PERIOD),
235 'idp certificate file',
236 'The IdP PEM Certificate generated at install time.',
240 'The IdP Certificate Key generated at install time.',
244 'The salt used for persistent Name IDs.',
247 'allow self registration',
248 'Allow authenticated users to register applications.',
251 'default allowed nameids',
252 'Default Allowed NameIDs for Service Providers.',
253 metadata.SAML2_NAMEID_MAP.keys(),
254 ['unspecified', 'persistent', 'transient', 'email',
255 'kerberos', 'x509']),
258 'Default NameID used by Service Providers.',
259 metadata.SAML2_NAMEID_MAP.keys(),
262 'default email domain',
263 'Used for users missing the email property.',
266 'default attribute mapping',
267 'Defines how to map attributes before returning them to SPs',
270 'default allowed attributes',
271 'Defines a list of allowed attributes, applied after mapping',
274 if cherrypy.config.get('debug', False):
277 logger = logging.getLogger('lasso')
278 lh = logging.StreamHandler(sys.stderr)
279 logger.addHandler(lh)
280 logger.setLevel(logging.DEBUG)
283 def allow_self_registration(self):
284 return self.get_config_value('allow self registration')
287 def idp_storage_path(self):
288 return self.get_config_value('idp storage path')
291 def idp_metadata_file(self):
292 return os.path.join(self.idp_storage_path,
293 self.get_config_value('idp metadata file'))
296 def idp_metadata_validity(self):
297 return self.get_config_value('idp metadata validity')
300 def idp_certificate_file(self):
301 return os.path.join(self.idp_storage_path,
302 self.get_config_value('idp certificate file'))
305 def idp_key_file(self):
306 return os.path.join(self.idp_storage_path,
307 self.get_config_value('idp key file'))
310 def idp_nameid_salt(self):
311 return self.get_config_value('idp nameid salt')
314 def default_allowed_nameids(self):
315 return self.get_config_value('default allowed nameids')
318 def default_nameid(self):
319 return self.get_config_value('default nameid')
322 def default_email_domain(self):
323 return self.get_config_value('default email domain')
326 def default_attribute_mapping(self):
327 return self.get_config_value('default attribute mapping')
330 def default_allowed_attributes(self):
331 return self.get_config_value('default allowed attributes')
333 def get_tree(self, site):
334 self.idp = self.init_idp()
335 self.page = SAML2(site, self)
336 self.admin = Saml2AdminPage(site, self)
337 self.rest = Saml2RestBase(site, self)
344 idp = IdentityProvider(self)
345 except Exception, e: # pylint: disable=broad-except
346 self.debug('Failed to init SAML2 provider: %r' % e)
349 self._root.logout.add_handler(self.name, self.idp_initiated_logout)
351 # Import all known applications
352 data = self.get_data()
355 if 'type' not in sp or sp['type'] != 'SP':
357 if 'name' not in sp or 'metadata' not in sp:
361 except Exception, e: # pylint: disable=broad-except
362 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
367 super(IdpProvider, self).on_enable()
368 self.idp = self.init_idp()
369 if hasattr(self, 'admin'):
373 def idp_initiated_logout(self):
375 Logout all SP sessions when the logout comes from the IdP.
377 For the current user only.
379 self.debug("IdP-initiated SAML2 logout")
382 saml_sessions = us.get_provider_data('saml2')
383 if saml_sessions is None:
384 self.debug("No SAML2 sessions to logout")
386 session = saml_sessions.get_next_logout(remove=False)
390 # Add a fake session to indicate where the user should
391 # be redirected to when all SP's are logged out.
392 idpurl = self._root.instance_base_url()
393 saml_sessions.add_session("_idp_initiated_logout",
396 init_session = saml_sessions.find_session_by_provider(idpurl)
397 init_session.set_logoutstate(idpurl, "idp_initiated_logout", None)
398 saml_sessions.start_logout(init_session)
400 logout = self.idp.get_logout_handler()
401 logout.setSessionFromDump(session.session.dump())
402 logout.initRequest(session.provider_id)
404 logout.buildRequestMsg()
405 except lasso.Error, e:
406 self.error('failure to build logout request msg: %s' % e)
407 raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
410 raise cherrypy.HTTPRedirect(logout.msgUrl)
413 class IdpMetadataGenerator(object):
415 def __init__(self, url, idp_cert, expiration=None):
416 self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
417 self.meta.set_entity_id('%s/saml2/metadata' % url)
418 self.meta.add_certs(idp_cert, idp_cert)
419 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
420 '%s/saml2/SSO/POST' % url)
421 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
422 '%s/saml2/SSO/Redirect' % url)
423 if is_lasso_ecp_enabled():
424 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
425 '%s/saml2/SSO/SOAP' % url)
426 self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
427 '%s/saml2/SLO/Redirect' % url)
428 self.meta.add_allowed_name_format(
429 lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
430 self.meta.add_allowed_name_format(
431 lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
432 self.meta.add_allowed_name_format(
433 lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
435 def output(self, path=None):
436 return self.meta.output(path)
439 class Installer(ProviderInstaller):
441 def __init__(self, *pargs):
442 super(Installer, self).__init__()
446 def install_args(self, group):
447 group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
448 help='Configure SAML2 Provider')
449 group.add_argument('--saml2-metadata-validity',
450 default=METADATA_DEFAULT_VALIDITY_PERIOD,
451 help=('Metadata validity period in days '
453 METADATA_DEFAULT_VALIDITY_PERIOD))
455 def configure(self, opts, changes):
456 if opts['saml2'] != 'yes':
459 # Check storage path is present or create it
460 path = os.path.join(opts['data_dir'], 'saml2')
461 if not os.path.exists(path):
462 os.makedirs(path, 0700)
464 # Use the same cert for signing and ecnryption for now
465 cert = Certificate(path)
466 cert.generate('idp', opts['hostname'])
468 # Generate Idp Metadata
470 if opts['secure'].lower() == 'no':
472 url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
473 validity = int(opts['saml2_metadata_validity'])
474 meta = IdpMetadataGenerator(url, cert,
476 if 'gssapi' in opts and opts['gssapi'] == 'yes':
477 meta.meta.add_allowed_name_format(
478 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
480 meta.output(os.path.join(path, 'metadata.xml'))
482 # Add configuration data to database
483 po = PluginObject(*self.pargs)
486 po.wipe_config_values()
487 config = {'idp storage path': path,
488 'idp metadata file': 'metadata.xml',
489 'idp certificate file': cert.cert,
490 'idp key file': cert.key,
491 'idp nameid salt': uuid.uuid4().hex,
492 'idp metadata validity': opts['saml2_metadata_validity']}
493 po.save_plugin_config(config)
495 # Update global config to add login plugin
497 po.save_enabled_state()
499 # Fixup permissions so only the ipsilon user can read these files
500 files.fix_user_dirs(path, opts['system_user'])