End-user UI for SP Portal
[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.tools.certs import Certificate
12 from ipsilon.tools import saml2metadata as metadata
13 from ipsilon.tools import files
14 from ipsilon.util.http import require_content_type
15 from ipsilon.util.constants import SOAP_MEDIA_TYPE, XML_MEDIA_TYPE
16 from ipsilon.util.user import UserSession
17 from ipsilon.util.plugin import PluginObject
18 from ipsilon.util import config as pconfig
19 import cherrypy
20 from datetime import timedelta
21 import lasso
22 import os
23 import time
24 import uuid
25
26 cherrypy.tools.require_content_type = cherrypy.Tool('before_request_body',
27                                                     require_content_type)
28
29
30 def is_lasso_ecp_enabled():
31     # Full ECP support appeared in lasso version 2.4.2
32     return lasso.checkVersion(2, 4, 2, lasso.CHECK_VERSION_NUMERIC)
33
34
35 class SSO_SOAP(AuthenticateRequest):
36
37     def __init__(self, *args, **kwargs):
38         super(SSO_SOAP, self).__init__(*args, **kwargs)
39         self.binding = metadata.SAML2_SERVICE_MAP['sso-soap'][1]
40
41     @cherrypy.tools.require_content_type(
42         required=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
43     @cherrypy.tools.accept(media=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
44     @cherrypy.tools.response_headers(
45         headers=[('Content-Type', 'SOAP_MEDIA_TYPE')])
46     def POST(self, *args, **kwargs):
47         self.debug("SSO_SOAP.POST() begin")
48
49         self.debug("SSO_SOAP transaction provider=%s id=%s" %
50                    (self.trans.provider, self.trans.transaction_id))
51
52         us = UserSession()
53         us.remote_login()
54         user = us.get_user()
55         self.debug("SSO_SOAP user=%s" % (user.name))
56
57         if not user:
58             raise cherrypy.HTTPError(403, 'No user specified for SSO_SOAP')
59
60         soap_xml_doc = cherrypy.request.rfile.read()
61         soap_xml_doc = soap_xml_doc.strip()
62         self.debug("SSO_SOAP soap_xml_doc=%s" % soap_xml_doc)
63         login = self.saml2login(soap_xml_doc)
64
65         return self.auth(login)
66
67
68 class Redirect(AuthenticateRequest):
69
70     def __init__(self, *args, **kwargs):
71         super(Redirect, self).__init__(*args, **kwargs)
72         self.binding = metadata.SAML2_SERVICE_MAP['sso-redirect'][1]
73
74     def GET(self, *args, **kwargs):
75
76         query = cherrypy.request.query_string
77
78         login = self.saml2login(query)
79         return self.auth(login)
80
81
82 class POSTAuth(AuthenticateRequest):
83
84     def __init__(self, *args, **kwargs):
85         super(POSTAuth, self).__init__(*args, **kwargs)
86         self.binding = metadata.SAML2_SERVICE_MAP['sso-post'][1]
87
88     def POST(self, *args, **kwargs):
89
90         request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
91         relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
92
93         login = self.saml2login(request)
94         login.set_msgRelayState(relaystate)
95         return self.auth(login)
96
97
98 class Continue(AuthenticateRequest):
99
100     def GET(self, *args, **kwargs):
101
102         session = UserSession()
103         user = session.get_user()
104         transdata = self.trans.retrieve()
105         self.stage = transdata['saml2_stage']
106
107         if user.is_anonymous:
108             self.debug("User is marked anonymous?!")
109             # TODO: Return to SP with auth failed error
110             raise cherrypy.HTTPError(401)
111
112         self.debug('Continue auth for %s' % user.name)
113
114         if 'saml2_request' not in transdata:
115             self.debug("Couldn't find Request dump?!")
116             # TODO: Return to SP with auth failed error
117             raise cherrypy.HTTPError(400)
118         dump = transdata['saml2_request']
119
120         try:
121             login = self.cfg.idp.get_login_handler(dump)
122         except Exception, e:  # pylint: disable=broad-except
123             self.debug('Failed to load status from dump: %r' % e)
124
125         if not login:
126             self.debug("Empty Request dump?!")
127             # TODO: Return to SP with auth failed error
128             raise cherrypy.HTTPError(400)
129
130         return self.auth(login)
131
132
133 class Logout(LogoutRequest):
134
135     def GET(self, *args, **kwargs):
136         query = cherrypy.request.query_string
137
138         relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
139         response = kwargs.get(lasso.SAML2_FIELD_RESPONSE)
140
141         return self.logout(query,
142                            relaystate=relaystate,
143                            samlresponse=response)
144
145
146 class SSO(ProviderPageBase):
147
148     def __init__(self, *args, **kwargs):
149         super(SSO, self).__init__(*args, **kwargs)
150         self.Redirect = Redirect(*args, **kwargs)
151         self.POST = POSTAuth(*args, **kwargs)
152         self.Continue = Continue(*args, **kwargs)
153         self.SOAP = SSO_SOAP(*args, **kwargs)
154
155
156 class SLO(ProviderPageBase):
157
158     def __init__(self, *args, **kwargs):
159         super(SLO, self).__init__(*args, **kwargs)
160         self.debug('SLO init')
161         self.Redirect = Logout(*args, **kwargs)
162
163
164 # one week
165 METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7
166 # five years (approximately)
167 METADATA_DEFAULT_VALIDITY_PERIOD = 365 * 5
168
169
170 class Metadata(ProviderPageBase):
171     def GET(self, *args, **kwargs):
172
173         body = self._get_metadata()
174         cherrypy.response.headers["Content-Type"] = XML_MEDIA_TYPE
175         cherrypy.response.headers["Content-Disposition"] = \
176             'attachment; filename="metadata.xml"'
177         return body
178
179     def _get_metadata(self):
180         if os.path.isfile(self.cfg.idp_metadata_file):
181             s = os.stat(self.cfg.idp_metadata_file)
182             if s.st_mtime > time.time() - METADATA_RENEW_INTERVAL:
183                 with open(self.cfg.idp_metadata_file) as m:
184                     return m.read()
185
186         # Otherwise generate and save
187         idp_cert = Certificate()
188         idp_cert.import_cert(self.cfg.idp_certificate_file,
189                              self.cfg.idp_key_file)
190
191         validity = int(self.cfg.idp_metadata_validity)
192         meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert,
193                                     timedelta(validity))
194         body = meta.output()
195         with open(self.cfg.idp_metadata_file, 'w+') as m:
196             m.write(body)
197         return body
198
199
200 class SAML2(ProviderPageBase):
201
202     def __init__(self, *args, **kwargs):
203         super(SAML2, self).__init__(*args, **kwargs)
204         self.metadata = Metadata(*args, **kwargs)
205         self.SSO = SSO(*args, **kwargs)
206         self.SLO = SLO(*args, **kwargs)
207
208
209 class IdpProvider(ProviderBase):
210
211     def __init__(self, *pargs):
212         super(IdpProvider, self).__init__('saml2', 'saml2', *pargs)
213         self.admin = None
214         self.rest = None
215         self.page = None
216         self.idp = None
217         self.sessionfactory = 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             pconfig.String(
276                 'session database url',
277                 'Database URL for SAML2 sessions',
278                 'saml2.sessions.db.sqlite'),
279         )
280         if cherrypy.config.get('debug', False):
281             import logging
282             import sys
283             logger = logging.getLogger('lasso')
284             lh = logging.StreamHandler(sys.stderr)
285             logger.addHandler(lh)
286             logger.setLevel(logging.DEBUG)
287
288     def get_providers(self):
289         return self.admin.providers
290
291     @property
292     def allow_self_registration(self):
293         return self.get_config_value('allow self registration')
294
295     @property
296     def idp_storage_path(self):
297         return self.get_config_value('idp storage path')
298
299     @property
300     def idp_metadata_file(self):
301         return os.path.join(self.idp_storage_path,
302                             self.get_config_value('idp metadata file'))
303
304     @property
305     def idp_metadata_validity(self):
306         return self.get_config_value('idp metadata validity')
307
308     @property
309     def idp_certificate_file(self):
310         return os.path.join(self.idp_storage_path,
311                             self.get_config_value('idp certificate file'))
312
313     @property
314     def idp_key_file(self):
315         return os.path.join(self.idp_storage_path,
316                             self.get_config_value('idp key file'))
317
318     @property
319     def idp_nameid_salt(self):
320         return self.get_config_value('idp nameid salt')
321
322     @property
323     def default_allowed_nameids(self):
324         return self.get_config_value('default allowed nameids')
325
326     @property
327     def default_nameid(self):
328         return self.get_config_value('default nameid')
329
330     @property
331     def default_email_domain(self):
332         return self.get_config_value('default email domain')
333
334     @property
335     def default_attribute_mapping(self):
336         return self.get_config_value('default attribute mapping')
337
338     @property
339     def default_allowed_attributes(self):
340         return self.get_config_value('default allowed attributes')
341
342     def get_tree(self, site):
343         self.page = SAML2(site, self)
344         self.admin = Saml2AdminPage(site, self)
345         self.rest = Saml2RestBase(site, self)
346         return self.page
347
348     def used_datastores(self):
349         # pylint: disable=protected-access
350         return [self.sessionfactory._ss]
351
352     def init_idp(self):
353         idp = None
354         self.sessionfactory = SAMLSessionFactory(
355             database_url=self.get_config_value('session database url')
356         )
357         # Schedule cleanups
358         # pylint: disable=protected-access
359         bt = cherrypy.process.plugins.BackgroundTask(
360             60, self.sessionfactory._ss.remove_expired_sessions
361         )
362         bt.start()
363         # Init IDP data
364         try:
365             idp = IdentityProvider(self,
366                                    sessionfactory=self.sessionfactory)
367         except Exception, e:  # pylint: disable=broad-except
368             self.debug('Failed to init SAML2 provider: %r' % e)
369             return None
370
371         self._root.logout.add_handler(self.name, self.idp_initiated_logout)
372
373         # Import all known applications
374         data = self.get_data()
375         for idval in data:
376             sp = data[idval]
377             if 'type' not in sp or sp['type'] != 'SP':
378                 continue
379             if 'name' not in sp or 'metadata' not in sp:
380                 continue
381             try:
382                 idp.add_provider(sp)
383             except Exception, e:  # pylint: disable=broad-except
384                 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
385
386         return idp
387
388     def on_enable(self):
389         super(IdpProvider, self).on_enable()
390         self.idp = self.init_idp()
391         if hasattr(self, 'admin'):
392             if self.admin:
393                 self.admin.add_sps()
394
395     def idp_initiated_logout(self):
396         """
397         Logout all SP sessions when the logout comes from the IdP.
398
399         For the current user only.
400
401         Only use HTTP-Redirect to start the logout. This is guaranteed
402         to be supported in SAML 2.
403         """
404         self.debug("IdP-initiated SAML2 logout")
405         us = UserSession()
406         user = us.get_user()
407
408         saml_sessions = self.sessionfactory
409         # pylint: disable=unused-variable
410         (mech, session) = saml_sessions.get_next_logout(
411             logout_mechs=[lasso.SAML2_METADATA_BINDING_REDIRECT])
412         if session is None:
413             return
414
415         logout = self.idp.get_logout_handler()
416         logout.setSessionFromDump(session.login_session)
417         logout.initRequest(session.provider_id)
418         try:
419             logout.buildRequestMsg()
420         except lasso.Error, e:
421             self.error('failure to build logout request msg: %s' % e)
422             raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
423                                         % e)
424
425         # Add a fake session to indicate where the user should
426         # be redirected to when all SP's are logged out.
427         idpurl = self._root.instance_base_url()
428         session_id = "_" + uuid.uuid4().hex.upper()
429         saml_sessions.add_session(session_id, idpurl, user.name, "", "",
430                                   [lasso.SAML2_METADATA_BINDING_REDIRECT])
431         init_session = saml_sessions.get_session_by_id(session_id)
432         saml_sessions.start_logout(init_session, relaystate=idpurl)
433
434         # Add the logout request id we just created to the session to be
435         # logged out so that when it responds we can find the right
436         # session.
437         session.set_logoutstate(request_id=logout.request.id)
438         saml_sessions.start_logout(session, initial=False)
439
440         self.debug('Sending initial logout request to %s' % logout.msgUrl)
441         raise cherrypy.HTTPRedirect(logout.msgUrl)
442
443
444 class IdpMetadataGenerator(object):
445
446     def __init__(self, url, idp_cert, expiration=None):
447         self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
448         self.meta.set_entity_id('%s/saml2/metadata' % url)
449         self.meta.add_certs(idp_cert, idp_cert)
450         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
451                               '%s/saml2/SSO/POST' % url)
452         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
453                               '%s/saml2/SSO/Redirect' % url)
454         if is_lasso_ecp_enabled():
455             self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
456                                   '%s/saml2/SSO/SOAP' % url)
457         self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
458                               '%s/saml2/SLO/Redirect' % url)
459         self.meta.add_allowed_name_format(
460             lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
461         self.meta.add_allowed_name_format(
462             lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
463         self.meta.add_allowed_name_format(
464             lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
465
466     def output(self, path=None):
467         return self.meta.output(path)
468
469
470 class Installer(ProviderInstaller):
471
472     def __init__(self, *pargs):
473         super(Installer, self).__init__()
474         self.name = 'saml2'
475         self.pargs = pargs
476
477     def install_args(self, group):
478         group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
479                            help='Configure SAML2 Provider')
480         group.add_argument('--saml2-metadata-validity',
481                            default=METADATA_DEFAULT_VALIDITY_PERIOD,
482                            help=('Metadata validity period in days '
483                                  '(default - %d)' %
484                                  METADATA_DEFAULT_VALIDITY_PERIOD))
485         group.add_argument('--saml2-session-dburl',
486                            help='session database URL')
487
488     def configure(self, opts, changes):
489         if opts['saml2'] != 'yes':
490             return
491
492         # Check storage path is present or create it
493         path = os.path.join(opts['data_dir'], 'saml2')
494         if not os.path.exists(path):
495             os.makedirs(path, 0700)
496
497         # Use the same cert for signing and ecnryption for now
498         cert = Certificate(path)
499         cert.generate('idp', opts['hostname'])
500
501         # Generate Idp Metadata
502         proto = 'https'
503         if opts['secure'].lower() == 'no':
504             proto = 'http'
505         url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
506         validity = int(opts['saml2_metadata_validity'])
507         meta = IdpMetadataGenerator(url, cert,
508                                     timedelta(validity))
509         if 'gssapi' in opts and opts['gssapi'] == 'yes':
510             meta.meta.add_allowed_name_format(
511                 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
512
513         meta.output(os.path.join(path, 'metadata.xml'))
514
515         # Add configuration data to database
516         po = PluginObject(*self.pargs)
517         po.name = 'saml2'
518         po.wipe_data()
519         po.wipe_config_values()
520         config = {'idp storage path': path,
521                   'idp metadata file': 'metadata.xml',
522                   'idp certificate file': cert.cert,
523                   'idp key file': cert.key,
524                   'idp nameid salt': uuid.uuid4().hex,
525                   'idp metadata validity': opts['saml2_metadata_validity'],
526                   'session database url': opts['saml2_session_dburl'] or
527                   opts['database_url'] % {
528                       'datadir': opts['data_dir'],
529                       'dbname': 'saml2.sessions.db'}}
530         po.save_plugin_config(config)
531
532         # Update global config to add login plugin
533         po.is_enabled = True
534         po.save_enabled_state()
535
536         # Fixup permissions so only the ipsilon user can read these files
537         files.fix_user_dirs(path, opts['system_user'])