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