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