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