6528fdfa4026889a3b0d400852604f6859be268e
[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 RedirectLogout(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 = RedirectLogout(*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.description = """
218 Provides SAML 2.0 authentication infrastructure. """
219
220         self.new_config(
221             self.name,
222             pconfig.String(
223                 'idp storage path',
224                 'Path to data storage accessible by the IdP.',
225                 '/var/lib/ipsilon/saml2'),
226             pconfig.String(
227                 'idp metadata file',
228                 'The IdP Metadata file generated at install time.',
229                 'metadata.xml'),
230             pconfig.String(
231                 'idp metadata validity',
232                 'The IdP Metadata validity period (in days) to use when '
233                 'generating new metadata.',
234                 METADATA_DEFAULT_VALIDITY_PERIOD),
235             pconfig.String(
236                 'idp certificate file',
237                 'The IdP PEM Certificate generated at install time.',
238                 'certificate.pem'),
239             pconfig.String(
240                 'idp key file',
241                 'The IdP Certificate Key generated at install time.',
242                 'certificate.key'),
243             pconfig.String(
244                 'idp nameid salt',
245                 'The salt used for persistent Name IDs.',
246                 None),
247             pconfig.Condition(
248                 'allow self registration',
249                 'Allow authenticated users to register applications.',
250                 True),
251             pconfig.Choice(
252                 'default allowed nameids',
253                 'Default Allowed NameIDs for Service Providers.',
254                 metadata.SAML2_NAMEID_MAP.keys(),
255                 ['unspecified', 'persistent', 'transient', 'email',
256                  'kerberos', 'x509']),
257             pconfig.Pick(
258                 'default nameid',
259                 'Default NameID used by Service Providers.',
260                 metadata.SAML2_NAMEID_MAP.keys(),
261                 'unspecified'),
262             pconfig.String(
263                 'default email domain',
264                 'Used for users missing the email property.',
265                 'example.com'),
266             pconfig.MappingList(
267                 'default attribute mapping',
268                 'Defines how to map attributes before returning them to SPs',
269                 [['*', '*']]),
270             pconfig.ComplexList(
271                 'default allowed attributes',
272                 'Defines a list of allowed attributes, applied after mapping',
273                 ['*']),
274         )
275         if cherrypy.config.get('debug', False):
276             import logging
277             import sys
278             logger = logging.getLogger('lasso')
279             lh = logging.StreamHandler(sys.stderr)
280             logger.addHandler(lh)
281             logger.setLevel(logging.DEBUG)
282
283     @property
284     def allow_self_registration(self):
285         return self.get_config_value('allow self registration')
286
287     @property
288     def idp_storage_path(self):
289         return self.get_config_value('idp storage path')
290
291     @property
292     def idp_metadata_file(self):
293         return os.path.join(self.idp_storage_path,
294                             self.get_config_value('idp metadata file'))
295
296     @property
297     def idp_metadata_validity(self):
298         return self.get_config_value('idp metadata validity')
299
300     @property
301     def idp_certificate_file(self):
302         return os.path.join(self.idp_storage_path,
303                             self.get_config_value('idp certificate file'))
304
305     @property
306     def idp_key_file(self):
307         return os.path.join(self.idp_storage_path,
308                             self.get_config_value('idp key file'))
309
310     @property
311     def idp_nameid_salt(self):
312         return self.get_config_value('idp nameid salt')
313
314     @property
315     def default_allowed_nameids(self):
316         return self.get_config_value('default allowed nameids')
317
318     @property
319     def default_nameid(self):
320         return self.get_config_value('default nameid')
321
322     @property
323     def default_email_domain(self):
324         return self.get_config_value('default email domain')
325
326     @property
327     def default_attribute_mapping(self):
328         return self.get_config_value('default attribute mapping')
329
330     @property
331     def default_allowed_attributes(self):
332         return self.get_config_value('default allowed attributes')
333
334     def get_tree(self, site):
335         self.idp = self.init_idp()
336         self.page = SAML2(site, self)
337         self.admin = Saml2AdminPage(site, self)
338         self.rest = Saml2RestBase(site, self)
339         return self.page
340
341     def init_idp(self):
342         idp = None
343         # Init IDP data
344         try:
345             idp = IdentityProvider(self)
346         except Exception, e:  # pylint: disable=broad-except
347             self.debug('Failed to init SAML2 provider: %r' % e)
348             return None
349
350         self._root.logout.add_handler(self.name, self.idp_initiated_logout)
351
352         # Import all known applications
353         data = self.get_data()
354         for idval in data:
355             sp = data[idval]
356             if 'type' not in sp or sp['type'] != 'SP':
357                 continue
358             if 'name' not in sp or 'metadata' not in sp:
359                 continue
360             try:
361                 idp.add_provider(sp)
362             except Exception, e:  # pylint: disable=broad-except
363                 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
364
365         return idp
366
367     def on_enable(self):
368         super(IdpProvider, self).on_enable()
369         self.idp = self.init_idp()
370         if hasattr(self, 'admin'):
371             if self.admin:
372                 self.admin.add_sps()
373
374     def idp_initiated_logout(self):
375         """
376         Logout all SP sessions when the logout comes from the IdP.
377
378         For the current user only.
379         """
380         self.debug("IdP-initiated SAML2 logout")
381         us = UserSession()
382         user = us.get_user()
383
384         saml_sessions = SAMLSessionFactory()
385         session = saml_sessions.get_next_logout()
386         if session is None:
387             return
388
389         logout = self.idp.get_logout_handler()
390         logout.setSessionFromDump(session.login_session)
391         logout.initRequest(session.provider_id)
392         try:
393             logout.buildRequestMsg()
394         except lasso.Error, e:
395             self.error('failure to build logout request msg: %s' % e)
396             raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
397                                         % e)
398
399         # Add a fake session to indicate where the user should
400         # be redirected to when all SP's are logged out.
401         idpurl = self._root.instance_base_url()
402         session_id = "_" + uuid.uuid4().hex.upper()
403         saml_sessions.add_session(session_id, idpurl, user.name, "")
404         init_session = saml_sessions.get_session_by_id(session_id)
405         saml_sessions.start_logout(init_session, relaystate=idpurl)
406
407         # Add the logout request id we just created to the session to be
408         # logged out so that when it responds we can find the right
409         # session.
410         session.set_logoutstate(request_id=logout.request.id)
411         saml_sessions.start_logout(session, initial=False)
412
413         self.debug('Sending initial logout request to %s' % logout.msgUrl)
414         raise cherrypy.HTTPRedirect(logout.msgUrl)
415
416
417 class IdpMetadataGenerator(object):
418
419     def __init__(self, url, idp_cert, expiration=None):
420         self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
421         self.meta.set_entity_id('%s/saml2/metadata' % url)
422         self.meta.add_certs(idp_cert, idp_cert)
423         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
424                               '%s/saml2/SSO/POST' % url)
425         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
426                               '%s/saml2/SSO/Redirect' % url)
427         if is_lasso_ecp_enabled():
428             self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
429                                   '%s/saml2/SSO/SOAP' % url)
430         self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
431                               '%s/saml2/SLO/Redirect' % url)
432         self.meta.add_allowed_name_format(
433             lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
434         self.meta.add_allowed_name_format(
435             lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
436         self.meta.add_allowed_name_format(
437             lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
438
439     def output(self, path=None):
440         return self.meta.output(path)
441
442
443 class Installer(ProviderInstaller):
444
445     def __init__(self, *pargs):
446         super(Installer, self).__init__()
447         self.name = 'saml2'
448         self.pargs = pargs
449
450     def install_args(self, group):
451         group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
452                            help='Configure SAML2 Provider')
453         group.add_argument('--saml2-metadata-validity',
454                            default=METADATA_DEFAULT_VALIDITY_PERIOD,
455                            help=('Metadata validity period in days '
456                                  '(default - %d)' %
457                                  METADATA_DEFAULT_VALIDITY_PERIOD))
458
459     def configure(self, opts, changes):
460         if opts['saml2'] != 'yes':
461             return
462
463         # Check storage path is present or create it
464         path = os.path.join(opts['data_dir'], 'saml2')
465         if not os.path.exists(path):
466             os.makedirs(path, 0700)
467
468         # Use the same cert for signing and ecnryption for now
469         cert = Certificate(path)
470         cert.generate('idp', opts['hostname'])
471
472         # Generate Idp Metadata
473         proto = 'https'
474         if opts['secure'].lower() == 'no':
475             proto = 'http'
476         url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
477         validity = int(opts['saml2_metadata_validity'])
478         meta = IdpMetadataGenerator(url, cert,
479                                     timedelta(validity))
480         if 'gssapi' in opts and opts['gssapi'] == 'yes':
481             meta.meta.add_allowed_name_format(
482                 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
483
484         meta.output(os.path.join(path, 'metadata.xml'))
485
486         # Add configuration data to database
487         po = PluginObject(*self.pargs)
488         po.name = 'saml2'
489         po.wipe_data()
490         po.wipe_config_values()
491         config = {'idp storage path': path,
492                   'idp metadata file': 'metadata.xml',
493                   'idp certificate file': cert.cert,
494                   'idp key file': cert.key,
495                   'idp nameid salt': uuid.uuid4().hex,
496                   'idp metadata validity': opts['saml2_metadata_validity']}
497         po.save_plugin_config(config)
498
499         # Update global config to add login plugin
500         po.is_enabled = True
501         po.save_enabled_state()
502
503         # Fixup permissions so only the ipsilon user can read these files
504         files.fix_user_dirs(path, opts['system_user'])