Handle user session data for both internal and external authentication
[cascardo/ipsilon.git] / ipsilon / providers / saml2idp.py
1 # Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING
2
3 from ipsilon.login.common import LoginHelper
4 from ipsilon.providers.common import ProviderBase, ProviderPageBase, \
5     ProviderInstaller
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
20 import cherrypy
21 from datetime import timedelta
22 import lasso
23 import os
24 import time
25 import uuid
26
27 cherrypy.tools.require_content_type = cherrypy.Tool('before_request_body',
28                                                     require_content_type)
29
30
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)
34
35
36 class SSO_SOAP(AuthenticateRequest, LoginHelper):
37
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]
43
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")
51
52         self.debug("SSO_SOAP transaction provider=%s id=%s" %
53                    (self.trans.provider, self.trans.transaction_id))
54
55         username, auth_type = self.get_external_auth_info()
56         if not username:
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)
60
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)
65
66         return self.auth(login)
67
68
69 class Redirect(AuthenticateRequest):
70
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]
74
75     def GET(self, *args, **kwargs):
76
77         query = cherrypy.request.query_string
78
79         login = self.saml2login(query)
80         return self.auth(login)
81
82
83 class POSTAuth(AuthenticateRequest):
84
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]
88
89     def POST(self, *args, **kwargs):
90
91         request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
92         relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
93
94         login = self.saml2login(request)
95         login.set_msgRelayState(relaystate)
96         return self.auth(login)
97
98
99 class Continue(AuthenticateRequest):
100
101     def GET(self, *args, **kwargs):
102
103         session = UserSession()
104         user = session.get_user()
105         transdata = self.trans.retrieve()
106         self.stage = transdata['saml2_stage']
107
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)
112
113         self.debug('Continue auth for %s' % user.name)
114
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']
120
121         try:
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)
125
126         if not login:
127             self.error("Empty login Request dump?!")
128             # TODO: Return to SP with auth failed error
129             raise cherrypy.HTTPError(400)
130
131         return self.auth(login)
132
133
134 class Logout(LogoutRequest):
135
136     def GET(self, *args, **kwargs):
137         query = cherrypy.request.query_string
138
139         relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
140         response = kwargs.get(lasso.SAML2_FIELD_RESPONSE)
141
142         return self.logout(query,
143                            relaystate=relaystate,
144                            samlresponse=response)
145
146
147 class SSO(ProviderPageBase):
148
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)
155
156
157 class SLO(ProviderPageBase):
158
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)
163
164
165 # one week
166 METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7
167 # five years (approximately)
168 METADATA_DEFAULT_VALIDITY_PERIOD = 365 * 5
169
170
171 class Metadata(ProviderPageBase):
172     def GET(self, *args, **kwargs):
173
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"'
178         return body
179
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:
185                     return m.read()
186
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)
191
192         validity = int(self.cfg.idp_metadata_validity)
193         meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert,
194                                     timedelta(validity))
195         body = meta.output()
196         with open(self.cfg.idp_metadata_file, 'w+') as m:
197             m.write(body)
198         return body
199
200
201 class SAML2(ProviderPageBase):
202
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)
208
209
210 class IdpProvider(ProviderBase):
211
212     def __init__(self, *pargs):
213         super(IdpProvider, self).__init__('saml2', 'saml2', *pargs)
214         self.admin = None
215         self.rest = None
216         self.page = None
217         self.idp = None
218         self.sessionfactory = None
219         self.description = """
220 Provides SAML 2.0 authentication infrastructure. """
221
222         self.new_config(
223             self.name,
224             pconfig.String(
225                 'idp storage path',
226                 'Path to data storage accessible by the IdP.',
227                 '/var/lib/ipsilon/saml2'),
228             pconfig.String(
229                 'idp metadata file',
230                 'The IdP Metadata file generated at install time.',
231                 'metadata.xml'),
232             pconfig.String(
233                 'idp metadata validity',
234                 'The IdP Metadata validity period (in days) to use when '
235                 'generating new metadata.',
236                 METADATA_DEFAULT_VALIDITY_PERIOD),
237             pconfig.String(
238                 'idp certificate file',
239                 'The IdP PEM Certificate generated at install time.',
240                 'certificate.pem'),
241             pconfig.String(
242                 'idp key file',
243                 'The IdP Certificate Key generated at install time.',
244                 'certificate.key'),
245             pconfig.String(
246                 'idp nameid salt',
247                 'The salt used for persistent Name IDs.',
248                 None),
249             pconfig.Condition(
250                 'allow self registration',
251                 'Allow authenticated users to register applications.',
252                 True),
253             pconfig.Choice(
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']),
259             pconfig.Pick(
260                 'default nameid',
261                 'Default NameID used by Service Providers.',
262                 metadata.SAML2_NAMEID_MAP.keys(),
263                 'unspecified'),
264             pconfig.String(
265                 'default email domain',
266                 'Used for users missing the email property.',
267                 'example.com'),
268             pconfig.MappingList(
269                 'default attribute mapping',
270                 'Defines how to map attributes before returning them to SPs',
271                 [['*', '*']]),
272             pconfig.ComplexList(
273                 'default allowed attributes',
274                 'Defines a list of allowed attributes, applied after mapping',
275                 ['*']),
276             pconfig.String(
277                 'session database url',
278                 'Database URL for SAML2 sessions',
279                 'saml2.sessions.db.sqlite'),
280         )
281         if cherrypy.config.get('debug', False):
282             import logging
283             import sys
284             logger = logging.getLogger('lasso')
285             lh = logging.StreamHandler(sys.stderr)
286             logger.addHandler(lh)
287             logger.setLevel(logging.DEBUG)
288
289     def get_providers(self):
290         return self.admin.providers
291
292     @property
293     def allow_self_registration(self):
294         return self.get_config_value('allow self registration')
295
296     @property
297     def idp_storage_path(self):
298         return self.get_config_value('idp storage path')
299
300     @property
301     def idp_metadata_file(self):
302         return os.path.join(self.idp_storage_path,
303                             self.get_config_value('idp metadata file'))
304
305     @property
306     def idp_metadata_validity(self):
307         return self.get_config_value('idp metadata validity')
308
309     @property
310     def idp_certificate_file(self):
311         return os.path.join(self.idp_storage_path,
312                             self.get_config_value('idp certificate file'))
313
314     @property
315     def idp_key_file(self):
316         return os.path.join(self.idp_storage_path,
317                             self.get_config_value('idp key file'))
318
319     @property
320     def idp_nameid_salt(self):
321         return self.get_config_value('idp nameid salt')
322
323     @property
324     def default_allowed_nameids(self):
325         return self.get_config_value('default allowed nameids')
326
327     @property
328     def default_nameid(self):
329         return self.get_config_value('default nameid')
330
331     @property
332     def default_email_domain(self):
333         return self.get_config_value('default email domain')
334
335     @property
336     def default_attribute_mapping(self):
337         return self.get_config_value('default attribute mapping')
338
339     @property
340     def default_allowed_attributes(self):
341         return self.get_config_value('default allowed attributes')
342
343     def get_tree(self, site):
344         self.page = SAML2(site, self)
345         self.admin = Saml2AdminPage(site, self)
346         self.rest = Saml2RestBase(site, self)
347         return self.page
348
349     def used_datastores(self):
350         # pylint: disable=protected-access
351         return [self.sessionfactory._ss]
352
353     def init_idp(self):
354         idp = None
355         self.sessionfactory = SAMLSessionFactory(
356             database_url=self.get_config_value('session database url')
357         )
358         # Init IDP data
359         try:
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)
364             return None
365
366         self._root.logout.add_handler(self.name, self.idp_initiated_logout)
367
368         # Import all known applications
369         data = self.get_data()
370         for idval in data:
371             sp = data[idval]
372             if 'type' not in sp or sp['type'] != 'SP':
373                 continue
374             if 'name' not in sp or 'metadata' not in sp:
375                 continue
376             try:
377                 idp.add_provider(sp)
378             except Exception, e:  # pylint: disable=broad-except
379                 self.error('Failed to add SP %s: %r' % (sp['name'], e))
380
381         return idp
382
383     def on_enable(self):
384         super(IdpProvider, self).on_enable()
385         self.idp = self.init_idp()
386         if hasattr(self, 'admin'):
387             if self.admin:
388                 self.admin.add_sps()
389
390     def idp_initiated_logout(self):
391         """
392         Logout all SP sessions when the logout comes from the IdP.
393
394         For the current user only.
395
396         Only use HTTP-Redirect to start the logout. This is guaranteed
397         to be supported in SAML 2.
398         """
399         self.debug("IdP-initiated SAML2 logout")
400         us = UserSession()
401         user = us.get_user()
402
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])
407         if session is None:
408             return
409
410         logout = self.idp.get_logout_handler()
411         logout.setSessionFromDump(session.login_session)
412         logout.initRequest(session.provider_id)
413         try:
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 '
418                                         % e)
419
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)
428
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
431         # session.
432         session.set_logoutstate(request_id=logout.request.id)
433         saml_sessions.start_logout(session, initial=False)
434
435         self.debug('Sending initial logout request to %s' % logout.msgUrl)
436         raise cherrypy.HTTPRedirect(logout.msgUrl)
437
438
439 class IdpMetadataGenerator(object):
440
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)
460
461     def output(self, path=None):
462         return self.meta.output(path)
463
464
465 class Installer(ProviderInstaller):
466
467     def __init__(self, *pargs):
468         super(Installer, self).__init__()
469         self.name = 'saml2'
470         self.pargs = pargs
471
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 '
478                                  '(default - %d)' %
479                                  METADATA_DEFAULT_VALIDITY_PERIOD))
480         group.add_argument('--saml2-session-dburl',
481                            help='session database URL')
482
483     def configure(self, opts, changes):
484         if opts['saml2'] != 'yes':
485             return
486
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)
491
492         # Use the same cert for signing and ecnryption for now
493         cert = Certificate(path)
494         cert.generate('idp', opts['hostname'])
495
496         # Generate Idp Metadata
497         proto = 'https'
498         if opts['secure'].lower() == 'no':
499             proto = 'http'
500         url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
501         validity = int(opts['saml2_metadata_validity'])
502         meta = IdpMetadataGenerator(url, cert,
503                                     timedelta(validity))
504         if 'gssapi' in opts and opts['gssapi'] == 'yes':
505             meta.meta.add_allowed_name_format(
506                 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
507
508         meta.output(os.path.join(path, 'metadata.xml'))
509
510         # Add configuration data to database
511         po = PluginObject(*self.pargs)
512         po.name = 'saml2'
513         po.wipe_data()
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)
526
527         # Update global config to add login plugin
528         po.is_enabled = True
529         po.save_enabled_state()
530
531         # Fixup permissions so only the ipsilon user can read these files
532         files.fix_user_dirs(path, opts['system_user'])