6dfb03ab1a19ac5b0a66deb3e432997d7397e277
[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.tools.certs import Certificate
11 from ipsilon.tools import saml2metadata as metadata
12 from ipsilon.tools import files
13 from ipsilon.util.http import require_content_type
14 from ipsilon.util.constants import SOAP_MEDIA_TYPE, XML_MEDIA_TYPE
15 from ipsilon.util.user import UserSession
16 from ipsilon.util.plugin import PluginObject
17 from ipsilon.util import config as pconfig
18 import cherrypy
19 from datetime import timedelta
20 import lasso
21 import os
22 import time
23 import uuid
24
25 cherrypy.tools.require_content_type = cherrypy.Tool('before_request_body',
26                                                     require_content_type)
27
28
29 def is_lasso_ecp_enabled():
30     # Full ECP support appeared in lasso version 2.4.2
31     return lasso.checkVersion(2, 4, 2, lasso.CHECK_VERSION_NUMERIC)
32
33
34 class SSO_SOAP(AuthenticateRequest):
35
36     def __init__(self, *args, **kwargs):
37         super(SSO_SOAP, self).__init__(*args, **kwargs)
38         self.binding = metadata.SAML2_SERVICE_MAP['sso-soap'][1]
39
40     @cherrypy.tools.require_content_type(
41         required=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
42     @cherrypy.tools.accept(media=[SOAP_MEDIA_TYPE, XML_MEDIA_TYPE])
43     @cherrypy.tools.response_headers(
44         headers=[('Content-Type', 'SOAP_MEDIA_TYPE')])
45     def POST(self, *args, **kwargs):
46         self.debug("SSO_SOAP.POST() begin")
47
48         self.debug("SSO_SOAP transaction provider=%s id=%s" %
49                    (self.trans.provider, self.trans.transaction_id))
50
51         us = UserSession()
52         us.remote_login()
53         user = us.get_user()
54         self.debug("SSO_SOAP user=%s" % (user.name))
55
56         if not user:
57             raise cherrypy.HTTPError(403, 'No user specified for SSO_SOAP')
58
59         soap_xml_doc = cherrypy.request.rfile.read()
60         soap_xml_doc = soap_xml_doc.strip()
61         self.debug("SSO_SOAP soap_xml_doc=%s" % soap_xml_doc)
62         login = self.saml2login(soap_xml_doc)
63
64         return self.auth(login)
65
66
67 class Redirect(AuthenticateRequest):
68
69     def __init__(self, *args, **kwargs):
70         super(Redirect, self).__init__(*args, **kwargs)
71         self.binding = metadata.SAML2_SERVICE_MAP['sso-redirect'][1]
72
73     def GET(self, *args, **kwargs):
74
75         query = cherrypy.request.query_string
76
77         login = self.saml2login(query)
78         return self.auth(login)
79
80
81 class POSTAuth(AuthenticateRequest):
82
83     def __init__(self, *args, **kwargs):
84         super(POSTAuth, self).__init__(*args, **kwargs)
85         self.binding = metadata.SAML2_SERVICE_MAP['sso-post'][1]
86
87     def POST(self, *args, **kwargs):
88
89         request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
90         relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
91
92         login = self.saml2login(request)
93         login.set_msgRelayState(relaystate)
94         return self.auth(login)
95
96
97 class Continue(AuthenticateRequest):
98
99     def GET(self, *args, **kwargs):
100
101         session = UserSession()
102         user = session.get_user()
103         transdata = self.trans.retrieve()
104         self.stage = transdata['saml2_stage']
105
106         if user.is_anonymous:
107             self.debug("User is marked anonymous?!")
108             # TODO: Return to SP with auth failed error
109             raise cherrypy.HTTPError(401)
110
111         self.debug('Continue auth for %s' % user.name)
112
113         if 'saml2_request' not in transdata:
114             self.debug("Couldn't find Request dump?!")
115             # TODO: Return to SP with auth failed error
116             raise cherrypy.HTTPError(400)
117         dump = transdata['saml2_request']
118
119         try:
120             login = self.cfg.idp.get_login_handler(dump)
121         except Exception, e:  # pylint: disable=broad-except
122             self.debug('Failed to load status from dump: %r' % e)
123
124         if not login:
125             self.debug("Empty Request dump?!")
126             # TODO: Return to SP with auth failed error
127             raise cherrypy.HTTPError(400)
128
129         return self.auth(login)
130
131
132 class RedirectLogout(LogoutRequest):
133
134     def GET(self, *args, **kwargs):
135         query = cherrypy.request.query_string
136
137         relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
138         response = kwargs.get(lasso.SAML2_FIELD_RESPONSE)
139
140         return self.logout(query,
141                            relaystate=relaystate,
142                            samlresponse=response)
143
144
145 class SSO(ProviderPageBase):
146
147     def __init__(self, *args, **kwargs):
148         super(SSO, self).__init__(*args, **kwargs)
149         self.Redirect = Redirect(*args, **kwargs)
150         self.POST = POSTAuth(*args, **kwargs)
151         self.Continue = Continue(*args, **kwargs)
152         self.SOAP = SSO_SOAP(*args, **kwargs)
153
154
155 class SLO(ProviderPageBase):
156
157     def __init__(self, *args, **kwargs):
158         super(SLO, self).__init__(*args, **kwargs)
159         self.debug('SLO init')
160         self.Redirect = RedirectLogout(*args, **kwargs)
161
162
163 # one week
164 METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7
165 # five years (approximately)
166 METADATA_DEFAULT_VALIDITY_PERIOD = 365 * 5
167
168
169 class Metadata(ProviderPageBase):
170     def GET(self, *args, **kwargs):
171
172         body = self._get_metadata()
173         cherrypy.response.headers["Content-Type"] = XML_MEDIA_TYPE
174         cherrypy.response.headers["Content-Disposition"] = \
175             'attachment; filename="metadata.xml"'
176         return body
177
178     def _get_metadata(self):
179         if os.path.isfile(self.cfg.idp_metadata_file):
180             s = os.stat(self.cfg.idp_metadata_file)
181             if s.st_mtime > time.time() - METADATA_RENEW_INTERVAL:
182                 with open(self.cfg.idp_metadata_file) as m:
183                     return m.read()
184
185         # Otherwise generate and save
186         idp_cert = Certificate()
187         idp_cert.import_cert(self.cfg.idp_certificate_file,
188                              self.cfg.idp_key_file)
189
190         validity = int(self.cfg.idp_metadata_validity)
191         meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert,
192                                     timedelta(validity))
193         body = meta.output()
194         with open(self.cfg.idp_metadata_file, 'w+') as m:
195             m.write(body)
196         return body
197
198
199 class SAML2(ProviderPageBase):
200
201     def __init__(self, *args, **kwargs):
202         super(SAML2, self).__init__(*args, **kwargs)
203         self.metadata = Metadata(*args, **kwargs)
204         self.SSO = SSO(*args, **kwargs)
205         self.SLO = SLO(*args, **kwargs)
206
207
208 class IdpProvider(ProviderBase):
209
210     def __init__(self, *pargs):
211         super(IdpProvider, self).__init__('saml2', 'saml2', *pargs)
212         self.admin = None
213         self.rest = None
214         self.page = None
215         self.idp = None
216         self.description = """
217 Provides SAML 2.0 authentication infrastructure. """
218
219         self.new_config(
220             self.name,
221             pconfig.String(
222                 'idp storage path',
223                 'Path to data storage accessible by the IdP.',
224                 '/var/lib/ipsilon/saml2'),
225             pconfig.String(
226                 'idp metadata file',
227                 'The IdP Metadata file generated at install time.',
228                 'metadata.xml'),
229             pconfig.String(
230                 'idp metadata validity',
231                 'The IdP Metadata validity period (in days) to use when '
232                 'generating new metadata.',
233                 METADATA_DEFAULT_VALIDITY_PERIOD),
234             pconfig.String(
235                 'idp certificate file',
236                 'The IdP PEM Certificate generated at install time.',
237                 'certificate.pem'),
238             pconfig.String(
239                 'idp key file',
240                 'The IdP Certificate Key generated at install time.',
241                 'certificate.key'),
242             pconfig.String(
243                 'idp nameid salt',
244                 'The salt used for persistent Name IDs.',
245                 None),
246             pconfig.Condition(
247                 'allow self registration',
248                 'Allow authenticated users to register applications.',
249                 True),
250             pconfig.Choice(
251                 'default allowed nameids',
252                 'Default Allowed NameIDs for Service Providers.',
253                 metadata.SAML2_NAMEID_MAP.keys(),
254                 ['unspecified', 'persistent', 'transient', 'email',
255                  'kerberos', 'x509']),
256             pconfig.Pick(
257                 'default nameid',
258                 'Default NameID used by Service Providers.',
259                 metadata.SAML2_NAMEID_MAP.keys(),
260                 'unspecified'),
261             pconfig.String(
262                 'default email domain',
263                 'Used for users missing the email property.',
264                 'example.com'),
265             pconfig.MappingList(
266                 'default attribute mapping',
267                 'Defines how to map attributes before returning them to SPs',
268                 [['*', '*']]),
269             pconfig.ComplexList(
270                 'default allowed attributes',
271                 'Defines a list of allowed attributes, applied after mapping',
272                 ['*']),
273         )
274         if cherrypy.config.get('debug', False):
275             import logging
276             import sys
277             logger = logging.getLogger('lasso')
278             lh = logging.StreamHandler(sys.stderr)
279             logger.addHandler(lh)
280             logger.setLevel(logging.DEBUG)
281
282     @property
283     def allow_self_registration(self):
284         return self.get_config_value('allow self registration')
285
286     @property
287     def idp_storage_path(self):
288         return self.get_config_value('idp storage path')
289
290     @property
291     def idp_metadata_file(self):
292         return os.path.join(self.idp_storage_path,
293                             self.get_config_value('idp metadata file'))
294
295     @property
296     def idp_metadata_validity(self):
297         return self.get_config_value('idp metadata validity')
298
299     @property
300     def idp_certificate_file(self):
301         return os.path.join(self.idp_storage_path,
302                             self.get_config_value('idp certificate file'))
303
304     @property
305     def idp_key_file(self):
306         return os.path.join(self.idp_storage_path,
307                             self.get_config_value('idp key file'))
308
309     @property
310     def idp_nameid_salt(self):
311         return self.get_config_value('idp nameid salt')
312
313     @property
314     def default_allowed_nameids(self):
315         return self.get_config_value('default allowed nameids')
316
317     @property
318     def default_nameid(self):
319         return self.get_config_value('default nameid')
320
321     @property
322     def default_email_domain(self):
323         return self.get_config_value('default email domain')
324
325     @property
326     def default_attribute_mapping(self):
327         return self.get_config_value('default attribute mapping')
328
329     @property
330     def default_allowed_attributes(self):
331         return self.get_config_value('default allowed attributes')
332
333     def get_tree(self, site):
334         self.idp = self.init_idp()
335         self.page = SAML2(site, self)
336         self.admin = Saml2AdminPage(site, self)
337         self.rest = Saml2RestBase(site, self)
338         return self.page
339
340     def init_idp(self):
341         idp = None
342         # Init IDP data
343         try:
344             idp = IdentityProvider(self)
345         except Exception, e:  # pylint: disable=broad-except
346             self.debug('Failed to init SAML2 provider: %r' % e)
347             return None
348
349         self._root.logout.add_handler(self.name, self.idp_initiated_logout)
350
351         # Import all known applications
352         data = self.get_data()
353         for idval in data:
354             sp = data[idval]
355             if 'type' not in sp or sp['type'] != 'SP':
356                 continue
357             if 'name' not in sp or 'metadata' not in sp:
358                 continue
359             try:
360                 idp.add_provider(sp)
361             except Exception, e:  # pylint: disable=broad-except
362                 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
363
364         return idp
365
366     def on_enable(self):
367         super(IdpProvider, self).on_enable()
368         self.idp = self.init_idp()
369         if hasattr(self, 'admin'):
370             if self.admin:
371                 self.admin.add_sps()
372
373     def idp_initiated_logout(self):
374         """
375         Logout all SP sessions when the logout comes from the IdP.
376
377         For the current user only.
378         """
379         self.debug("IdP-initiated SAML2 logout")
380         us = UserSession()
381
382         saml_sessions = us.get_provider_data('saml2')
383         if saml_sessions is None:
384             self.debug("No SAML2 sessions to logout")
385             return
386         session = saml_sessions.get_next_logout(remove=False)
387         if session is None:
388             return
389
390         # Add a fake session to indicate where the user should
391         # be redirected to when all SP's are logged out.
392         idpurl = self._root.instance_base_url()
393         saml_sessions.add_session("_idp_initiated_logout",
394                                   idpurl,
395                                   "")
396         init_session = saml_sessions.find_session_by_provider(idpurl)
397         init_session.set_logoutstate(idpurl, "idp_initiated_logout", None)
398         saml_sessions.start_logout(init_session)
399
400         logout = self.idp.get_logout_handler()
401         logout.setSessionFromDump(session.session.dump())
402         logout.initRequest(session.provider_id)
403         try:
404             logout.buildRequestMsg()
405         except lasso.Error, e:
406             self.error('failure to build logout request msg: %s' % e)
407             raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
408                                         % e)
409
410         raise cherrypy.HTTPRedirect(logout.msgUrl)
411
412
413 class IdpMetadataGenerator(object):
414
415     def __init__(self, url, idp_cert, expiration=None):
416         self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
417         self.meta.set_entity_id('%s/saml2/metadata' % url)
418         self.meta.add_certs(idp_cert, idp_cert)
419         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
420                               '%s/saml2/SSO/POST' % url)
421         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
422                               '%s/saml2/SSO/Redirect' % url)
423         if is_lasso_ecp_enabled():
424             self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-soap'],
425                                   '%s/saml2/SSO/SOAP' % url)
426         self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
427                               '%s/saml2/SLO/Redirect' % url)
428         self.meta.add_allowed_name_format(
429             lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
430         self.meta.add_allowed_name_format(
431             lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
432         self.meta.add_allowed_name_format(
433             lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
434
435     def output(self, path=None):
436         return self.meta.output(path)
437
438
439 class Installer(ProviderInstaller):
440
441     def __init__(self, *pargs):
442         super(Installer, self).__init__()
443         self.name = 'saml2'
444         self.pargs = pargs
445
446     def install_args(self, group):
447         group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
448                            help='Configure SAML2 Provider')
449         group.add_argument('--saml2-metadata-validity',
450                            default=METADATA_DEFAULT_VALIDITY_PERIOD,
451                            help=('Metadata validity period in days '
452                                  '(default - %d)' %
453                                  METADATA_DEFAULT_VALIDITY_PERIOD))
454
455     def configure(self, opts):
456         if opts['saml2'] != 'yes':
457             return
458
459         # Check storage path is present or create it
460         path = os.path.join(opts['data_dir'], 'saml2')
461         if not os.path.exists(path):
462             os.makedirs(path, 0700)
463
464         # Use the same cert for signing and ecnryption for now
465         cert = Certificate(path)
466         cert.generate('idp', opts['hostname'])
467
468         # Generate Idp Metadata
469         proto = 'https'
470         if opts['secure'].lower() == 'no':
471             proto = 'http'
472         url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
473         validity = int(opts['saml2_metadata_validity'])
474         meta = IdpMetadataGenerator(url, cert,
475                                     timedelta(validity))
476         if 'gssapi' in opts and opts['gssapi'] == 'yes':
477             meta.meta.add_allowed_name_format(
478                 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
479
480         meta.output(os.path.join(path, 'metadata.xml'))
481
482         # Add configuration data to database
483         po = PluginObject(*self.pargs)
484         po.name = 'saml2'
485         po.wipe_data()
486         po.wipe_config_values()
487         config = {'idp storage path': path,
488                   'idp metadata file': 'metadata.xml',
489                   'idp certificate file': cert.cert,
490                   'idp key file': cert.key,
491                   'idp nameid salt': uuid.uuid4().hex,
492                   'idp metadata validity': opts['saml2_metadata_validity']}
493         po.save_plugin_config(config)
494
495         # Update global config to add login plugin
496         po.is_enabled = True
497         po.save_enabled_state()
498
499         # Fixup permissions so only the ipsilon user can read these files
500         files.fix_user_dirs(path, opts['system_user'])