Drop usage of self._debug and use self.debug instead
[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 # five years (approximately)
129 METADATA_DEFAULT_VALIDITY_PERIOD = 365 * 5
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
153         validity = int(self.cfg.idp_metadata_validity)
154         meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert,
155                                     timedelta(validity))
156         body = meta.output()
157         with open(self.cfg.idp_metadata_file, 'w+') as m:
158             m.write(body)
159         return body
160
161
162 class SAML2(ProviderPageBase):
163
164     def __init__(self, *args, **kwargs):
165         super(SAML2, self).__init__(*args, **kwargs)
166         self.metadata = Metadata(*args, **kwargs)
167         self.SSO = SSO(*args, **kwargs)
168         self.SLO = SLO(*args, **kwargs)
169
170
171 class IdpProvider(ProviderBase):
172
173     def __init__(self, *pargs):
174         super(IdpProvider, self).__init__('saml2', 'saml2', *pargs)
175         self.admin = None
176         self.rest = None
177         self.page = None
178         self.idp = None
179         self.description = """
180 Provides SAML 2.0 authentication infrastructure. """
181
182         self.new_config(
183             self.name,
184             pconfig.String(
185                 'idp storage path',
186                 'Path to data storage accessible by the IdP.',
187                 '/var/lib/ipsilon/saml2'),
188             pconfig.String(
189                 'idp metadata file',
190                 'The IdP Metadata file generated at install time.',
191                 'metadata.xml'),
192             pconfig.String(
193                 'idp metadata validity',
194                 'The IdP Metadata validity period (in days) to use when '
195                 'generating new metadata.',
196                 METADATA_DEFAULT_VALIDITY_PERIOD),
197             pconfig.String(
198                 'idp certificate file',
199                 'The IdP PEM Certificate generated at install time.',
200                 'certificate.pem'),
201             pconfig.String(
202                 'idp key file',
203                 'The IdP Certificate Key generated at install time.',
204                 'certificate.key'),
205             pconfig.String(
206                 'idp nameid salt',
207                 'The salt used for persistent Name IDs.',
208                 None),
209             pconfig.Condition(
210                 'allow self registration',
211                 'Allow authenticated users to register applications.',
212                 True),
213             pconfig.Choice(
214                 'default allowed nameids',
215                 'Default Allowed NameIDs for Service Providers.',
216                 metadata.SAML2_NAMEID_MAP.keys(),
217                 ['unspecified', 'persistent', 'transient', 'email',
218                  'kerberos', 'x509']),
219             pconfig.Pick(
220                 'default nameid',
221                 'Default NameID used by Service Providers.',
222                 metadata.SAML2_NAMEID_MAP.keys(),
223                 'unspecified'),
224             pconfig.String(
225                 'default email domain',
226                 'Used for users missing the email property.',
227                 'example.com'),
228             pconfig.MappingList(
229                 'default attribute mapping',
230                 'Defines how to map attributes before returning them to SPs',
231                 [['*', '*']]),
232             pconfig.ComplexList(
233                 'default allowed attributes',
234                 'Defines a list of allowed attributes, applied after mapping',
235                 ['*']),
236         )
237         if cherrypy.config.get('debug', False):
238             import logging
239             import sys
240             logger = logging.getLogger('lasso')
241             lh = logging.StreamHandler(sys.stderr)
242             logger.addHandler(lh)
243             logger.setLevel(logging.DEBUG)
244
245     @property
246     def allow_self_registration(self):
247         return self.get_config_value('allow self registration')
248
249     @property
250     def idp_storage_path(self):
251         return self.get_config_value('idp storage path')
252
253     @property
254     def idp_metadata_file(self):
255         return os.path.join(self.idp_storage_path,
256                             self.get_config_value('idp metadata file'))
257
258     @property
259     def idp_metadata_validity(self):
260         return self.get_config_value('idp metadata validity')
261
262     @property
263     def idp_certificate_file(self):
264         return os.path.join(self.idp_storage_path,
265                             self.get_config_value('idp certificate file'))
266
267     @property
268     def idp_key_file(self):
269         return os.path.join(self.idp_storage_path,
270                             self.get_config_value('idp key file'))
271
272     @property
273     def idp_nameid_salt(self):
274         return self.get_config_value('idp nameid salt')
275
276     @property
277     def default_allowed_nameids(self):
278         return self.get_config_value('default allowed nameids')
279
280     @property
281     def default_nameid(self):
282         return self.get_config_value('default nameid')
283
284     @property
285     def default_email_domain(self):
286         return self.get_config_value('default email domain')
287
288     @property
289     def default_attribute_mapping(self):
290         return self.get_config_value('default attribute mapping')
291
292     @property
293     def default_allowed_attributes(self):
294         return self.get_config_value('default allowed attributes')
295
296     def get_tree(self, site):
297         self.idp = self.init_idp()
298         self.page = SAML2(site, self)
299         self.admin = Saml2AdminPage(site, self)
300         self.rest = Saml2RestBase(site, self)
301         return self.page
302
303     def init_idp(self):
304         idp = None
305         # Init IDP data
306         try:
307             idp = IdentityProvider(self)
308         except Exception, e:  # pylint: disable=broad-except
309             self.debug('Failed to init SAML2 provider: %r' % e)
310             return None
311
312         self._root.logout.add_handler(self.name, self.idp_initiated_logout)
313
314         # Import all known applications
315         data = self.get_data()
316         for idval in data:
317             sp = data[idval]
318             if 'type' not in sp or sp['type'] != 'SP':
319                 continue
320             if 'name' not in sp or 'metadata' not in sp:
321                 continue
322             try:
323                 idp.add_provider(sp)
324             except Exception, e:  # pylint: disable=broad-except
325                 self.debug('Failed to add SP %s: %r' % (sp['name'], e))
326
327         return idp
328
329     def on_enable(self):
330         super(IdpProvider, self).on_enable()
331         self.idp = self.init_idp()
332         if hasattr(self, 'admin'):
333             if self.admin:
334                 self.admin.add_sps()
335
336     def idp_initiated_logout(self):
337         """
338         Logout all SP sessions when the logout comes from the IdP.
339
340         For the current user only.
341         """
342         self.debug("IdP-initiated SAML2 logout")
343         us = UserSession()
344
345         saml_sessions = us.get_provider_data('saml2')
346         if saml_sessions is None:
347             self.debug("No SAML2 sessions to logout")
348             return
349         session = saml_sessions.get_next_logout(remove=False)
350         if session is None:
351             return
352
353         # Add a fake session to indicate where the user should
354         # be redirected to when all SP's are logged out.
355         idpurl = self._root.instance_base_url()
356         saml_sessions.add_session("_idp_initiated_logout",
357                                   idpurl,
358                                   "")
359         init_session = saml_sessions.find_session_by_provider(idpurl)
360         init_session.set_logoutstate(idpurl, "idp_initiated_logout", None)
361         saml_sessions.start_logout(init_session)
362
363         logout = self.idp.get_logout_handler()
364         logout.setSessionFromDump(session.session.dump())
365         logout.initRequest(session.provider_id)
366         try:
367             logout.buildRequestMsg()
368         except lasso.Error, e:
369             self.error('failure to build logout request msg: %s' % e)
370             raise cherrypy.HTTPRedirect(400, 'Failed to log out user: %s '
371                                         % e)
372
373         raise cherrypy.HTTPRedirect(logout.msgUrl)
374
375
376 class IdpMetadataGenerator(object):
377
378     def __init__(self, url, idp_cert, expiration=None):
379         self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
380         self.meta.set_entity_id('%s/saml2/metadata' % url)
381         self.meta.add_certs(idp_cert, idp_cert)
382         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
383                               '%s/saml2/SSO/POST' % url)
384         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
385                               '%s/saml2/SSO/Redirect' % url)
386         self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
387                               '%s/saml2/SLO/Redirect' % url)
388         self.meta.add_allowed_name_format(
389             lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
390         self.meta.add_allowed_name_format(
391             lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
392         self.meta.add_allowed_name_format(
393             lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
394
395     def output(self, path=None):
396         return self.meta.output(path)
397
398
399 class Installer(ProviderInstaller):
400
401     def __init__(self, *pargs):
402         super(Installer, self).__init__()
403         self.name = 'saml2'
404         self.pargs = pargs
405
406     def install_args(self, group):
407         group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
408                            help='Configure SAML2 Provider')
409         group.add_argument('--saml2-metadata-validity',
410                            default=METADATA_DEFAULT_VALIDITY_PERIOD,
411                            help=('Metadata validity period in days '
412                                  '(default - %d)' %
413                                  METADATA_DEFAULT_VALIDITY_PERIOD))
414
415     def configure(self, opts):
416         if opts['saml2'] != 'yes':
417             return
418
419         # Check storage path is present or create it
420         path = os.path.join(opts['data_dir'], 'saml2')
421         if not os.path.exists(path):
422             os.makedirs(path, 0700)
423
424         # Use the same cert for signing and ecnryption for now
425         cert = Certificate(path)
426         cert.generate('idp', opts['hostname'])
427
428         # Generate Idp Metadata
429         proto = 'https'
430         if opts['secure'].lower() == 'no':
431             proto = 'http'
432         url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
433         validity = int(opts['saml2_metadata_validity'])
434         meta = IdpMetadataGenerator(url, cert,
435                                     timedelta(validity))
436         if 'gssapi' in opts and opts['gssapi'] == 'yes':
437             meta.meta.add_allowed_name_format(
438                 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
439
440         meta.output(os.path.join(path, 'metadata.xml'))
441
442         # Add configuration data to database
443         po = PluginObject(*self.pargs)
444         po.name = 'saml2'
445         po.wipe_data()
446         po.wipe_config_values()
447         config = {'idp storage path': path,
448                   'idp metadata file': 'metadata.xml',
449                   'idp certificate file': cert.cert,
450                   'idp key file': cert.key,
451                   'idp nameid salt': uuid.uuid4().hex,
452                   'idp metadata validity': opts['saml2_metadata_validity']}
453         po.save_plugin_config(config)
454
455         # Update global config to add login plugin
456         po.is_enabled = True
457         po.save_enabled_state()
458
459         # Fixup permissions so only the ipsilon user can read these files
460         files.fix_user_dirs(path, opts['system_user'])