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