Implement Single Logout Service for SP-initiated logout
[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 from ipsilon.providers.saml2.auth import AuthenticateRequest
20 from ipsilon.providers.saml2.logout import LogoutRequest
21 from ipsilon.providers.saml2.admin import Saml2AdminPage
22 from ipsilon.providers.saml2.provider import IdentityProvider
23 from ipsilon.tools.certs import Certificate
24 from ipsilon.tools import saml2metadata as metadata
25 from ipsilon.tools import files
26 from ipsilon.util.user import UserSession
27 from ipsilon.util.plugin import PluginObject
28 from ipsilon.util import config as pconfig
29 import cherrypy
30 from datetime import timedelta
31 import lasso
32 import os
33 import time
34
35
36 class Redirect(AuthenticateRequest):
37
38     def GET(self, *args, **kwargs):
39
40         query = cherrypy.request.query_string
41
42         login = self.saml2login(query)
43         return self.auth(login)
44
45
46 class POSTAuth(AuthenticateRequest):
47
48     def POST(self, *args, **kwargs):
49
50         request = kwargs.get(lasso.SAML2_FIELD_REQUEST)
51         relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
52
53         login = self.saml2login(request)
54         login.set_msgRelayState(relaystate)
55         return self.auth(login)
56
57
58 class Continue(AuthenticateRequest):
59
60     def GET(self, *args, **kwargs):
61
62         session = UserSession()
63         user = session.get_user()
64         transdata = self.trans.retrieve()
65         self.stage = transdata['saml2_stage']
66
67         if user.is_anonymous:
68             self._debug("User is marked anonymous?!")
69             # TODO: Return to SP with auth failed error
70             raise cherrypy.HTTPError(401)
71
72         self._debug('Continue auth for %s' % user.name)
73
74         if 'saml2_request' not in transdata:
75             self._debug("Couldn't find Request dump?!")
76             # TODO: Return to SP with auth failed error
77             raise cherrypy.HTTPError(400)
78         dump = transdata['saml2_request']
79
80         try:
81             login = self.cfg.idp.get_login_handler(dump)
82         except Exception, e:  # pylint: disable=broad-except
83             self._debug('Failed to load status from dump: %r' % e)
84
85         if not login:
86             self._debug("Empty Request dump?!")
87             # TODO: Return to SP with auth failed error
88             raise cherrypy.HTTPError(400)
89
90         return self.auth(login)
91
92
93 class RedirectLogout(LogoutRequest):
94
95     def GET(self, *args, **kwargs):
96         query = cherrypy.request.query_string
97
98         relaystate = kwargs.get(lasso.SAML2_FIELD_RELAYSTATE)
99         response = kwargs.get(lasso.SAML2_FIELD_RESPONSE)
100
101         return self.logout(query,
102                            relaystate=relaystate,
103                            samlresponse=response)
104
105
106 class SSO(ProviderPageBase):
107
108     def __init__(self, *args, **kwargs):
109         super(SSO, self).__init__(*args, **kwargs)
110         self.Redirect = Redirect(*args, **kwargs)
111         self.POST = POSTAuth(*args, **kwargs)
112         self.Continue = Continue(*args, **kwargs)
113
114
115 class SLO(ProviderPageBase):
116
117     def __init__(self, *args, **kwargs):
118         super(SLO, self).__init__(*args, **kwargs)
119         self._debug('SLO init')
120         self.Redirect = RedirectLogout(*args, **kwargs)
121
122
123 # one week
124 METADATA_RENEW_INTERVAL = 60 * 60 * 24 * 7
125 # 30 days
126 METADATA_VALIDITY_PERIOD = 30
127
128
129 class Metadata(ProviderPageBase):
130     def GET(self, *args, **kwargs):
131
132         body = self._get_metadata()
133         cherrypy.response.headers["Content-Type"] = "text/xml"
134         cherrypy.response.headers["Content-Disposition"] = \
135             'attachment; filename="metadata.xml"'
136         return body
137
138     def _get_metadata(self):
139         if os.path.isfile(self.cfg.idp_metadata_file):
140             s = os.stat(self.cfg.idp_metadata_file)
141             if s.st_mtime > time.time() - METADATA_RENEW_INTERVAL:
142                 with open(self.cfg.idp_metadata_file) as m:
143                     return m.read()
144
145         # Otherwise generate and save
146         idp_cert = Certificate()
147         idp_cert.import_cert(self.cfg.idp_certificate_file,
148                              self.cfg.idp_key_file)
149         meta = IdpMetadataGenerator(self.instance_base_url(), idp_cert,
150                                     timedelta(METADATA_VALIDITY_PERIOD))
151         body = meta.output()
152         with open(self.cfg.idp_metadata_file, 'w+') as m:
153             m.write(body)
154         return body
155
156
157 class SAML2(ProviderPageBase):
158
159     def __init__(self, *args, **kwargs):
160         super(SAML2, self).__init__(*args, **kwargs)
161         self.metadata = Metadata(*args, **kwargs)
162         self.SSO = SSO(*args, **kwargs)
163         self.SLO = SLO(*args, **kwargs)
164
165
166 class IdpProvider(ProviderBase):
167
168     def __init__(self, *pargs):
169         super(IdpProvider, self).__init__('saml2', 'saml2', *pargs)
170         self.admin = None
171         self.page = None
172         self.idp = None
173         self.description = """
174 Provides SAML 2.0 authentication infrastructure. """
175
176         self.new_config(
177             self.name,
178             pconfig.String(
179                 'idp storage path',
180                 'Path to data storage accessible by the IdP.',
181                 '/var/lib/ipsilon/saml2'),
182             pconfig.String(
183                 'idp metadata file',
184                 'The IdP Metadata file genearated at install time.',
185                 'metadata.xml'),
186             pconfig.String(
187                 'idp certificate file',
188                 'The IdP PEM Certificate genearated at install time.',
189                 'certificate.pem'),
190             pconfig.String(
191                 'idp key file',
192                 'The IdP Certificate Key genearated at install time.',
193                 'certificate.key'),
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                 ['persistent', 'transient', 'email', 'kerberos', 'x509']),
203             pconfig.Pick(
204                 'default nameid',
205                 'Default NameID used by Service Providers.',
206                 metadata.SAML2_NAMEID_MAP.keys(),
207                 'persistent'),
208             pconfig.String(
209                 'default email domain',
210                 'Used for users missing the email property.',
211                 'example.com'),
212         )
213         if cherrypy.config.get('debug', False):
214             import logging
215             import sys
216             logger = logging.getLogger('lasso')
217             lh = logging.StreamHandler(sys.stderr)
218             logger.addHandler(lh)
219             logger.setLevel(logging.DEBUG)
220
221     @property
222     def allow_self_registration(self):
223         return self.get_config_value('allow self registration')
224
225     @property
226     def idp_storage_path(self):
227         return self.get_config_value('idp storage path')
228
229     @property
230     def idp_metadata_file(self):
231         return os.path.join(self.idp_storage_path,
232                             self.get_config_value('idp metadata file'))
233
234     @property
235     def idp_certificate_file(self):
236         return os.path.join(self.idp_storage_path,
237                             self.get_config_value('idp certificate file'))
238
239     @property
240     def idp_key_file(self):
241         return os.path.join(self.idp_storage_path,
242                             self.get_config_value('idp key file'))
243
244     @property
245     def default_allowed_nameids(self):
246         return self.get_config_value('default allowed nameids')
247
248     @property
249     def default_nameid(self):
250         return self.get_config_value('default nameid')
251
252     @property
253     def default_email_domain(self):
254         return self.get_config_value('default email domain')
255
256     def get_tree(self, site):
257         self.idp = self.init_idp()
258         self.page = SAML2(site, self)
259         self.admin = Saml2AdminPage(site, self)
260         return self.page
261
262     def init_idp(self):
263         idp = None
264         # Init IDP data
265         try:
266             idp = IdentityProvider(self)
267         except Exception, e:  # pylint: disable=broad-except
268             self._debug('Failed to init SAML2 provider: %r' % e)
269             return None
270
271         # Import all known applications
272         data = self.get_data()
273         for idval in data:
274             sp = data[idval]
275             if 'type' not in sp or sp['type'] != 'SP':
276                 continue
277             if 'name' not in sp or 'metadata' not in sp:
278                 continue
279             try:
280                 idp.add_provider(sp)
281             except Exception, e:  # pylint: disable=broad-except
282                 self._debug('Failed to add SP %s: %r' % (sp['name'], e))
283
284         return idp
285
286     def on_enable(self):
287         super(IdpProvider, self).on_enable()
288         self.idp = self.init_idp()
289         if hasattr(self, 'admin'):
290             if self.admin:
291                 self.admin.add_sps()
292
293
294 class IdpMetadataGenerator(object):
295
296     def __init__(self, url, idp_cert, expiration=None):
297         self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
298         self.meta.set_entity_id('%s/saml2/metadata' % url)
299         self.meta.add_certs(idp_cert, idp_cert)
300         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
301                               '%s/saml2/SSO/POST' % url)
302         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
303                               '%s/saml2/SSO/Redirect' % url)
304         self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
305                               '%s/saml2/SLO/Redirect' % url)
306         self.meta.add_allowed_name_format(
307             lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
308         self.meta.add_allowed_name_format(
309             lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
310         self.meta.add_allowed_name_format(
311             lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
312
313     def output(self, path=None):
314         return self.meta.output(path)
315
316
317 class Installer(object):
318
319     def __init__(self, *pargs):
320         self.name = 'saml2'
321         self.ptype = 'provider'
322         self.pargs = pargs
323
324     def install_args(self, group):
325         group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
326                            help='Configure SAML2 Provider')
327
328     def configure(self, opts):
329         if opts['saml2'] != 'yes':
330             return
331
332         # Check storage path is present or create it
333         path = os.path.join(opts['data_dir'], 'saml2')
334         if not os.path.exists(path):
335             os.makedirs(path, 0700)
336
337         # Use the same cert for signing and ecnryption for now
338         cert = Certificate(path)
339         cert.generate('idp', opts['hostname'])
340
341         # Generate Idp Metadata
342         proto = 'https'
343         if opts['secure'].lower() == 'no':
344             proto = 'http'
345         url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
346         meta = IdpMetadataGenerator(url, cert,
347                                     timedelta(METADATA_VALIDITY_PERIOD))
348         if 'krb' in opts and opts['krb'] == 'yes':
349             meta.meta.add_allowed_name_format(
350                 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
351
352         meta.output(os.path.join(path, 'metadata.xml'))
353
354         # Add configuration data to database
355         po = PluginObject(*self.pargs)
356         po.name = 'saml2'
357         po.wipe_data()
358         po.wipe_config_values()
359         config = {'idp storage path': path,
360                   'idp metadata file': 'metadata.xml',
361                   'idp certificate file': cert.cert,
362                   'idp key file': cert.key}
363         po.save_plugin_config(config)
364
365         # Update global config to add login plugin
366         po.is_enabled = True
367         po.save_enabled_state()
368
369         # Fixup permissions so only the ipsilon user can read these files
370         files.fix_user_dirs(path, opts['system_user'])