Remove expired SAML2 sessions
[cascardo/ipsilon.git] / ipsilon / providers / saml2idp.py
1 # Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING
2
3 from ipsilon.providers.common import ProviderBase, ProviderPageBase, \
4     ProviderInstaller
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
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     # Full ECP support appeared in lasso version 2.4.2
33     return lasso.checkVersion(2, 4, 2, lasso.CHECK_VERSION_NUMERIC)
34
35
36 class SSO_SOAP(AuthenticateRequest):
37
38     def __init__(self, *args, **kwargs):
39         super(SSO_SOAP, self).__init__(*args, **kwargs)
40         self.binding = metadata.SAML2_SERVICE_MAP['sso-soap'][1]
41
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")
49
50         self.debug("SSO_SOAP transaction provider=%s id=%s" %
51                    (self.trans.provider, self.trans.transaction_id))
52
53         us = UserSession()
54         us.remote_login()
55         user = us.get_user()
56         self.debug("SSO_SOAP user=%s" % (user.name))
57
58         if not user:
59             raise cherrypy.HTTPError(403, 'No user specified for SSO_SOAP')
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, *args, **kwargs):
72         super(Redirect, self).__init__(*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, *args, **kwargs):
86         super(POSTAuth, self).__init__(*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.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']
120
121         try:
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)
125
126         if not login:
127             self.debug("Empty 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 RedirectLogout(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, *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)
155
156
157 class SLO(ProviderPageBase):
158
159     def __init__(self, *args, **kwargs):
160         super(SLO, self).__init__(*args, **kwargs)
161         self.debug('SLO init')
162         self.Redirect = RedirectLogout(*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, *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)
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.description = """
219 Provides SAML 2.0 authentication infrastructure. """
220
221         self.new_config(
222             self.name,
223             pconfig.String(
224                 'idp storage path',
225                 'Path to data storage accessible by the IdP.',
226                 '/var/lib/ipsilon/saml2'),
227             pconfig.String(
228                 'idp metadata file',
229                 'The IdP Metadata file generated at install time.',
230                 'metadata.xml'),
231             pconfig.String(
232                 'idp metadata validity',
233                 'The IdP Metadata validity period (in days) to use when '
234                 'generating new metadata.',
235                 METADATA_DEFAULT_VALIDITY_PERIOD),
236             pconfig.String(
237                 'idp certificate file',
238                 'The IdP PEM Certificate generated at install time.',
239                 'certificate.pem'),
240             pconfig.String(
241                 'idp key file',
242                 'The IdP Certificate Key generated at install time.',
243                 'certificate.key'),
244             pconfig.String(
245                 'idp nameid salt',
246                 'The salt used for persistent Name IDs.',
247                 None),
248             pconfig.Condition(
249                 'allow self registration',
250                 'Allow authenticated users to register applications.',
251                 True),
252             pconfig.Choice(
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']),
258             pconfig.Pick(
259                 'default nameid',
260                 'Default NameID used by Service Providers.',
261                 metadata.SAML2_NAMEID_MAP.keys(),
262                 'unspecified'),
263             pconfig.String(
264                 'default email domain',
265                 'Used for users missing the email property.',
266                 'example.com'),
267             pconfig.MappingList(
268                 'default attribute mapping',
269                 'Defines how to map attributes before returning them to SPs',
270                 [['*', '*']]),
271             pconfig.ComplexList(
272                 'default allowed attributes',
273                 'Defines a list of allowed attributes, applied after mapping',
274                 ['*']),
275         )
276         if cherrypy.config.get('debug', False):
277             import logging
278             import sys
279             logger = logging.getLogger('lasso')
280             lh = logging.StreamHandler(sys.stderr)
281             logger.addHandler(lh)
282             logger.setLevel(logging.DEBUG)
283
284         bt = cherrypy.process.plugins.BackgroundTask(60, expire_sessions)
285         bt.start()
286
287     @property
288     def allow_self_registration(self):
289         return self.get_config_value('allow self registration')
290
291     @property
292     def idp_storage_path(self):
293         return self.get_config_value('idp storage path')
294
295     @property
296     def idp_metadata_file(self):
297         return os.path.join(self.idp_storage_path,
298                             self.get_config_value('idp metadata file'))
299
300     @property
301     def idp_metadata_validity(self):
302         return self.get_config_value('idp metadata validity')
303
304     @property
305     def idp_certificate_file(self):
306         return os.path.join(self.idp_storage_path,
307                             self.get_config_value('idp certificate file'))
308
309     @property
310     def idp_key_file(self):
311         return os.path.join(self.idp_storage_path,
312                             self.get_config_value('idp key file'))
313
314     @property
315     def idp_nameid_salt(self):
316         return self.get_config_value('idp nameid salt')
317
318     @property
319     def default_allowed_nameids(self):
320         return self.get_config_value('default allowed nameids')
321
322     @property
323     def default_nameid(self):
324         return self.get_config_value('default nameid')
325
326     @property
327     def default_email_domain(self):
328         return self.get_config_value('default email domain')
329
330     @property
331     def default_attribute_mapping(self):
332         return self.get_config_value('default attribute mapping')
333
334     @property
335     def default_allowed_attributes(self):
336         return self.get_config_value('default allowed attributes')
337
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)
343         return self.page
344
345     def init_idp(self):
346         idp = None
347         # Init IDP data
348         try:
349             idp = IdentityProvider(self)
350         except Exception, e:  # pylint: disable=broad-except
351             self.debug('Failed to init SAML2 provider: %r' % e)
352             return None
353
354         self._root.logout.add_handler(self.name, self.idp_initiated_logout)
355
356         # Import all known applications
357         data = self.get_data()
358         for idval in data:
359             sp = data[idval]
360             if 'type' not in sp or sp['type'] != 'SP':
361                 continue
362             if 'name' not in sp or 'metadata' not in sp:
363                 continue
364             try:
365                 idp.add_provider(sp)
366             except Exception, e:  # pylint: disable=broad-except
367                 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
368
369         return idp
370
371     def on_enable(self):
372         super(IdpProvider, self).on_enable()
373         self.idp = self.init_idp()
374         if hasattr(self, 'admin'):
375             if self.admin:
376                 self.admin.add_sps()
377
378     def idp_initiated_logout(self):
379         """
380         Logout all SP sessions when the logout comes from the IdP.
381
382         For the current user only.
383         """
384         self.debug("IdP-initiated SAML2 logout")
385         us = UserSession()
386         user = us.get_user()
387
388         saml_sessions = SAMLSessionFactory()
389         session = saml_sessions.get_next_logout()
390         if session is None:
391             return
392
393         logout = self.idp.get_logout_handler()
394         logout.setSessionFromDump(session.login_session)
395         logout.initRequest(session.provider_id)
396         try:
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 '
401                                         % e)
402
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)
410
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
413         # session.
414         session.set_logoutstate(request_id=logout.request.id)
415         saml_sessions.start_logout(session, initial=False)
416
417         self.debug('Sending initial logout request to %s' % logout.msgUrl)
418         raise cherrypy.HTTPRedirect(logout.msgUrl)
419
420
421 class IdpMetadataGenerator(object):
422
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)
442
443     def output(self, path=None):
444         return self.meta.output(path)
445
446
447 class Installer(ProviderInstaller):
448
449     def __init__(self, *pargs):
450         super(Installer, self).__init__()
451         self.name = 'saml2'
452         self.pargs = pargs
453
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 '
460                                  '(default - %d)' %
461                                  METADATA_DEFAULT_VALIDITY_PERIOD))
462
463     def configure(self, opts, changes):
464         if opts['saml2'] != 'yes':
465             return
466
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)
471
472         # Use the same cert for signing and ecnryption for now
473         cert = Certificate(path)
474         cert.generate('idp', opts['hostname'])
475
476         # Generate Idp Metadata
477         proto = 'https'
478         if opts['secure'].lower() == 'no':
479             proto = 'http'
480         url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
481         validity = int(opts['saml2_metadata_validity'])
482         meta = IdpMetadataGenerator(url, cert,
483                                     timedelta(validity))
484         if 'gssapi' in opts and opts['gssapi'] == 'yes':
485             meta.meta.add_allowed_name_format(
486                 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
487
488         meta.output(os.path.join(path, 'metadata.xml'))
489
490         # Add configuration data to database
491         po = PluginObject(*self.pargs)
492         po.name = 'saml2'
493         po.wipe_data()
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)
502
503         # Update global config to add login plugin
504         po.is_enabled = True
505         po.save_enabled_state()
506
507         # Fixup permissions so only the ipsilon user can read these files
508         files.fix_user_dirs(path, opts['system_user'])