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.providers.saml2.sessions import expire_sessions
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 RedirectLogout(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 = RedirectLogout(*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.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 if cherrypy.config.get('debug', False):
279 logger = logging.getLogger('lasso')
280 lh = logging.StreamHandler(sys.stderr)
281 logger.addHandler(lh)
282 logger.setLevel(logging.DEBUG)
284 bt = cherrypy.process.plugins.BackgroundTask(60, expire_sessions)
288 def allow_self_registration(self):
289 return self.get_config_value('allow self registration')
292 def idp_storage_path(self):
293 return self.get_config_value('idp storage path')
296 def idp_metadata_file(self):
297 return os.path.join(self.idp_storage_path,
298 self.get_config_value('idp metadata file'))
301 def idp_metadata_validity(self):
302 return self.get_config_value('idp metadata validity')
305 def idp_certificate_file(self):
306 return os.path.join(self.idp_storage_path,
307 self.get_config_value('idp certificate file'))
310 def idp_key_file(self):
311 return os.path.join(self.idp_storage_path,
312 self.get_config_value('idp key file'))
315 def idp_nameid_salt(self):
316 return self.get_config_value('idp nameid salt')
319 def default_allowed_nameids(self):
320 return self.get_config_value('default allowed nameids')
323 def default_nameid(self):
324 return self.get_config_value('default nameid')
327 def default_email_domain(self):
328 return self.get_config_value('default email domain')
331 def default_attribute_mapping(self):
332 return self.get_config_value('default attribute mapping')
335 def default_allowed_attributes(self):
336 return self.get_config_value('default allowed attributes')
338 def get_tree(self, site):
339 self.idp = self.init_idp()
340 self.page = SAML2(site, self)
341 self.admin = Saml2AdminPage(site, self)
342 self.rest = Saml2RestBase(site, self)
349 idp = IdentityProvider(self)
350 except Exception, e: # pylint: disable=broad-except
351 self.debug('Failed to init SAML2 provider: %r' % e)
354 self._root.logout.add_handler(self.name, self.idp_initiated_logout)
356 # Import all known applications
357 data = self.get_data()
360 if 'type' not in sp or sp['type'] != 'SP':
362 if 'name' not in sp or 'metadata' not in sp:
366 except Exception, e: # pylint: disable=broad-except
367 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
372 super(IdpProvider, self).on_enable()
373 self.idp = self.init_idp()
374 if hasattr(self, 'admin'):
378 def idp_initiated_logout(self):
380 Logout all SP sessions when the logout comes from the IdP.
382 For the current user only.
384 self.debug("IdP-initiated SAML2 logout")
388 saml_sessions = SAMLSessionFactory()
389 session = saml_sessions.get_next_logout()
393 logout = self.idp.get_logout_handler()
394 logout.setSessionFromDump(session.login_session)
395 logout.initRequest(session.provider_id)
397 logout.buildRequestMsg()
398 except lasso.Error, e:
399 self.error('failure to build logout request msg: %s' % e)
400 raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
403 # Add a fake session to indicate where the user should
404 # be redirected to when all SP's are logged out.
405 idpurl = self._root.instance_base_url()
406 session_id = "_" + uuid.uuid4().hex.upper()
407 saml_sessions.add_session(session_id, idpurl, user.name, "")
408 init_session = saml_sessions.get_session_by_id(session_id)
409 saml_sessions.start_logout(init_session, relaystate=idpurl)
411 # Add the logout request id we just created to the session to be
412 # logged out so that when it responds we can find the right
414 session.set_logoutstate(request_id=logout.request.id)
415 saml_sessions.start_logout(session, initial=False)
417 self.debug('Sending initial logout request to %s' % logout.msgUrl)
418 raise cherrypy.HTTPRedirect(logout.msgUrl)
421 class IdpMetadataGenerator(object):
423 def __init__(self, url, idp_cert, expiration=None):
424 self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
425 self.meta.set_entity_id('%s/saml2/metadata' % url)
426 self.meta.add_certs(idp_cert, idp_cert)
427 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
428 '%s/saml2/SSO/POST' % url)
429 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
430 '%s/saml2/SSO/Redirect' % url)
431 if is_lasso_ecp_enabled():
432 self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
433 '%s/saml2/SSO/SOAP' % url)
434 self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
435 '%s/saml2/SLO/Redirect' % url)
436 self.meta.add_allowed_name_format(
437 lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
438 self.meta.add_allowed_name_format(
439 lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
440 self.meta.add_allowed_name_format(
441 lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
443 def output(self, path=None):
444 return self.meta.output(path)
447 class Installer(ProviderInstaller):
449 def __init__(self, *pargs):
450 super(Installer, self).__init__()
454 def install_args(self, group):
455 group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
456 help='Configure SAML2 Provider')
457 group.add_argument('--saml2-metadata-validity',
458 default=METADATA_DEFAULT_VALIDITY_PERIOD,
459 help=('Metadata validity period in days '
461 METADATA_DEFAULT_VALIDITY_PERIOD))
463 def configure(self, opts, changes):
464 if opts['saml2'] != 'yes':
467 # Check storage path is present or create it
468 path = os.path.join(opts['data_dir'], 'saml2')
469 if not os.path.exists(path):
470 os.makedirs(path, 0700)
472 # Use the same cert for signing and ecnryption for now
473 cert = Certificate(path)
474 cert.generate('idp', opts['hostname'])
476 # Generate Idp Metadata
478 if opts['secure'].lower() == 'no':
480 url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
481 validity = int(opts['saml2_metadata_validity'])
482 meta = IdpMetadataGenerator(url, cert,
484 if 'gssapi' in opts and opts['gssapi'] == 'yes':
485 meta.meta.add_allowed_name_format(
486 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
488 meta.output(os.path.join(path, 'metadata.xml'))
490 # Add configuration data to database
491 po = PluginObject(*self.pargs)
494 po.wipe_config_values()
495 config = {'idp storage path': path,
496 'idp metadata file': 'metadata.xml',
497 'idp certificate file': cert.cert,
498 'idp key file': cert.key,
499 'idp nameid salt': uuid.uuid4().hex,
500 'idp metadata validity': opts['saml2_metadata_validity']}
501 po.save_plugin_config(config)
503 # Update global config to add login plugin
505 po.save_enabled_state()
507 # Fixup permissions so only the ipsilon user can read these files
508 files.fix_user_dirs(path, opts['system_user'])