93dcbc6c448828e3ca29104ac286124263a4dbb0
[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.util.data import SAML2SessionStore
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 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, *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 = Logout(*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.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         store = SAML2SessionStore(
290             database_url=self.get_config_value('session database url')
291         )
292         bt = cherrypy.process.plugins.BackgroundTask(
293             60, store.remove_expired_sessions
294         )
295         bt.start()
296
297     @property
298     def allow_self_registration(self):
299         return self.get_config_value('allow self registration')
300
301     @property
302     def idp_storage_path(self):
303         return self.get_config_value('idp storage path')
304
305     @property
306     def idp_metadata_file(self):
307         return os.path.join(self.idp_storage_path,
308                             self.get_config_value('idp metadata file'))
309
310     @property
311     def idp_metadata_validity(self):
312         return self.get_config_value('idp metadata validity')
313
314     @property
315     def idp_certificate_file(self):
316         return os.path.join(self.idp_storage_path,
317                             self.get_config_value('idp certificate file'))
318
319     @property
320     def idp_key_file(self):
321         return os.path.join(self.idp_storage_path,
322                             self.get_config_value('idp key file'))
323
324     @property
325     def idp_nameid_salt(self):
326         return self.get_config_value('idp nameid salt')
327
328     @property
329     def default_allowed_nameids(self):
330         return self.get_config_value('default allowed nameids')
331
332     @property
333     def default_nameid(self):
334         return self.get_config_value('default nameid')
335
336     @property
337     def default_email_domain(self):
338         return self.get_config_value('default email domain')
339
340     @property
341     def default_attribute_mapping(self):
342         return self.get_config_value('default attribute mapping')
343
344     @property
345     def default_allowed_attributes(self):
346         return self.get_config_value('default allowed attributes')
347
348     def get_tree(self, site):
349         self.page = SAML2(site, self)
350         self.admin = Saml2AdminPage(site, self)
351         self.rest = Saml2RestBase(site, self)
352         return self.page
353
354     def init_idp(self):
355         idp = None
356         self.sessionfactory = SAMLSessionFactory(
357             database_url=self.get_config_value('session database url')
358         )
359         # Init IDP data
360         try:
361             idp = IdentityProvider(self,
362                                    sessionfactory=self.sessionfactory)
363         except Exception, e:  # pylint: disable=broad-except
364             self.debug('Failed to init SAML2 provider: %r' % e)
365             return None
366
367         self._root.logout.add_handler(self.name, self.idp_initiated_logout)
368
369         # Import all known applications
370         data = self.get_data()
371         for idval in data:
372             sp = data[idval]
373             if 'type' not in sp or sp['type'] != 'SP':
374                 continue
375             if 'name' not in sp or 'metadata' not in sp:
376                 continue
377             try:
378                 idp.add_provider(sp)
379             except Exception, e:  # pylint: disable=broad-except
380                 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
381
382         return idp
383
384     def on_enable(self):
385         super(IdpProvider, self).on_enable()
386         self.idp = self.init_idp()
387         if hasattr(self, 'admin'):
388             if self.admin:
389                 self.admin.add_sps()
390
391     def idp_initiated_logout(self):
392         """
393         Logout all SP sessions when the logout comes from the IdP.
394
395         For the current user only.
396
397         Only use HTTP-Redirect to start the logout. This is guaranteed
398         to be supported in SAML 2.
399         """
400         self.debug("IdP-initiated SAML2 logout")
401         us = UserSession()
402         user = us.get_user()
403
404         saml_sessions = self.sessionfactory
405         # pylint: disable=unused-variable
406         (mech, session) = saml_sessions.get_next_logout(
407             logout_mechs=[lasso.SAML2_METADATA_BINDING_REDIRECT])
408         if session is None:
409             return
410
411         logout = self.idp.get_logout_handler()
412         logout.setSessionFromDump(session.login_session)
413         logout.initRequest(session.provider_id)
414         try:
415             logout.buildRequestMsg()
416         except lasso.Error, e:
417             self.error('failure to build logout request msg: %s' % e)
418             raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
419                                         % e)
420
421         # Add a fake session to indicate where the user should
422         # be redirected to when all SP's are logged out.
423         idpurl = self._root.instance_base_url()
424         session_id = "_" + uuid.uuid4().hex.upper()
425         saml_sessions.add_session(session_id, idpurl, user.name, "", "",
426                                   [lasso.SAML2_METADATA_BINDING_REDIRECT])
427         init_session = saml_sessions.get_session_by_id(session_id)
428         saml_sessions.start_logout(init_session, relaystate=idpurl)
429
430         # Add the logout request id we just created to the session to be
431         # logged out so that when it responds we can find the right
432         # session.
433         session.set_logoutstate(request_id=logout.request.id)
434         saml_sessions.start_logout(session, initial=False)
435
436         self.debug('Sending initial logout request to %s' % logout.msgUrl)
437         raise cherrypy.HTTPRedirect(logout.msgUrl)
438
439
440 class IdpMetadataGenerator(object):
441
442     def __init__(self, url, idp_cert, expiration=None):
443         self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
444         self.meta.set_entity_id('%s/saml2/metadata' % url)
445         self.meta.add_certs(idp_cert, idp_cert)
446         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
447                               '%s/saml2/SSO/POST' % url)
448         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
449                               '%s/saml2/SSO/Redirect' % url)
450         if is_lasso_ecp_enabled():
451             self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
452                                   '%s/saml2/SSO/SOAP' % url)
453         self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
454                               '%s/saml2/SLO/Redirect' % url)
455         self.meta.add_allowed_name_format(
456             lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
457         self.meta.add_allowed_name_format(
458             lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
459         self.meta.add_allowed_name_format(
460             lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
461
462     def output(self, path=None):
463         return self.meta.output(path)
464
465
466 class Installer(ProviderInstaller):
467
468     def __init__(self, *pargs):
469         super(Installer, self).__init__()
470         self.name = 'saml2'
471         self.pargs = pargs
472
473     def install_args(self, group):
474         group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
475                            help='Configure SAML2 Provider')
476         group.add_argument('--saml2-metadata-validity',
477                            default=METADATA_DEFAULT_VALIDITY_PERIOD,
478                            help=('Metadata validity period in days '
479                                  '(default - %d)' %
480                                  METADATA_DEFAULT_VALIDITY_PERIOD))
481         group.add_argument('--saml2-session-dburl',
482                            help='session database URL')
483
484     def configure(self, opts, changes):
485         if opts['saml2'] != 'yes':
486             return
487
488         # Check storage path is present or create it
489         path = os.path.join(opts['data_dir'], 'saml2')
490         if not os.path.exists(path):
491             os.makedirs(path, 0700)
492
493         # Use the same cert for signing and ecnryption for now
494         cert = Certificate(path)
495         cert.generate('idp', opts['hostname'])
496
497         # Generate Idp Metadata
498         proto = 'https'
499         if opts['secure'].lower() == 'no':
500             proto = 'http'
501         url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
502         validity = int(opts['saml2_metadata_validity'])
503         meta = IdpMetadataGenerator(url, cert,
504                                     timedelta(validity))
505         if 'gssapi' in opts and opts['gssapi'] == 'yes':
506             meta.meta.add_allowed_name_format(
507                 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
508
509         meta.output(os.path.join(path, 'metadata.xml'))
510
511         # Add configuration data to database
512         po = PluginObject(*self.pargs)
513         po.name = 'saml2'
514         po.wipe_data()
515         po.wipe_config_values()
516         config = {'idp storage path': path,
517                   'idp metadata file': 'metadata.xml',
518                   'idp certificate file': cert.cert,
519                   'idp key file': cert.key,
520                   'idp nameid salt': uuid.uuid4().hex,
521                   'idp metadata validity': opts['saml2_metadata_validity'],
522                   'session database url': opts['saml2_session_dburl'] or
523                   opts['database_url'] % {
524                       'datadir': opts['data_dir'],
525                       'dbname': 'saml2.sessions.db'}}
526         po.save_plugin_config(config)
527
528         # Update global config to add login plugin
529         po.is_enabled = True
530         po.save_enabled_state()
531
532         # Fixup permissions so only the ipsilon user can read these files
533         files.fix_user_dirs(path, opts['system_user'])