9fa2fd6d7523b402e829bfb783afef69029162ea
[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             pconfig.MappingList(
213                 'default attribute mapping',
214                 'Defines how to map attributes before returning them to SPs',
215                 [['*', '*']]),
216             pconfig.ComplexList(
217                 'default allowed attributes',
218                 'Defines a list of allowed attributes, applied after mapping',
219                 ['*']),
220         )
221         if cherrypy.config.get('debug', False):
222             import logging
223             import sys
224             logger = logging.getLogger('lasso')
225             lh = logging.StreamHandler(sys.stderr)
226             logger.addHandler(lh)
227             logger.setLevel(logging.DEBUG)
228
229     @property
230     def allow_self_registration(self):
231         return self.get_config_value('allow self registration')
232
233     @property
234     def idp_storage_path(self):
235         return self.get_config_value('idp storage path')
236
237     @property
238     def idp_metadata_file(self):
239         return os.path.join(self.idp_storage_path,
240                             self.get_config_value('idp metadata file'))
241
242     @property
243     def idp_certificate_file(self):
244         return os.path.join(self.idp_storage_path,
245                             self.get_config_value('idp certificate file'))
246
247     @property
248     def idp_key_file(self):
249         return os.path.join(self.idp_storage_path,
250                             self.get_config_value('idp key file'))
251
252     @property
253     def default_allowed_nameids(self):
254         return self.get_config_value('default allowed nameids')
255
256     @property
257     def default_nameid(self):
258         return self.get_config_value('default nameid')
259
260     @property
261     def default_email_domain(self):
262         return self.get_config_value('default email domain')
263
264     @property
265     def default_attribute_mapping(self):
266         return self.get_config_value('default attribute mapping')
267
268     @property
269     def default_allowed_attributes(self):
270         return self.get_config_value('default allowed attributes')
271
272     def get_tree(self, site):
273         self.idp = self.init_idp()
274         self.page = SAML2(site, self)
275         self.admin = Saml2AdminPage(site, self)
276         return self.page
277
278     def init_idp(self):
279         idp = None
280         # Init IDP data
281         try:
282             idp = IdentityProvider(self)
283         except Exception, e:  # pylint: disable=broad-except
284             self._debug('Failed to init SAML2 provider: %r' % e)
285             return None
286
287         # Import all known applications
288         data = self.get_data()
289         for idval in data:
290             sp = data[idval]
291             if 'type' not in sp or sp['type'] != 'SP':
292                 continue
293             if 'name' not in sp or 'metadata' not in sp:
294                 continue
295             try:
296                 idp.add_provider(sp)
297             except Exception, e:  # pylint: disable=broad-except
298                 self._debug('Failed to add SP %s: %r' % (sp['name'], e))
299
300         return idp
301
302     def on_enable(self):
303         super(IdpProvider, self).on_enable()
304         self.idp = self.init_idp()
305         if hasattr(self, 'admin'):
306             if self.admin:
307                 self.admin.add_sps()
308
309
310 class IdpMetadataGenerator(object):
311
312     def __init__(self, url, idp_cert, expiration=None):
313         self.meta = metadata.Metadata(metadata.IDP_ROLE, expiration)
314         self.meta.set_entity_id('%s/saml2/metadata' % url)
315         self.meta.add_certs(idp_cert, idp_cert)
316         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-post'],
317                               '%s/saml2/SSO/POST' % url)
318         self.meta.add_service(metadata.SAML2_SERVICE_MAP['sso-redirect'],
319                               '%s/saml2/SSO/Redirect' % url)
320         self.meta.add_service(metadata.SAML2_SERVICE_MAP['logout-redirect'],
321                               '%s/saml2/SLO/Redirect' % url)
322         self.meta.add_allowed_name_format(
323             lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT)
324         self.meta.add_allowed_name_format(
325             lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT)
326         self.meta.add_allowed_name_format(
327             lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL)
328
329     def output(self, path=None):
330         return self.meta.output(path)
331
332
333 class Installer(object):
334
335     def __init__(self, *pargs):
336         self.name = 'saml2'
337         self.ptype = 'provider'
338         self.pargs = pargs
339
340     def install_args(self, group):
341         group.add_argument('--saml2', choices=['yes', 'no'], default='yes',
342                            help='Configure SAML2 Provider')
343
344     def configure(self, opts):
345         if opts['saml2'] != 'yes':
346             return
347
348         # Check storage path is present or create it
349         path = os.path.join(opts['data_dir'], 'saml2')
350         if not os.path.exists(path):
351             os.makedirs(path, 0700)
352
353         # Use the same cert for signing and ecnryption for now
354         cert = Certificate(path)
355         cert.generate('idp', opts['hostname'])
356
357         # Generate Idp Metadata
358         proto = 'https'
359         if opts['secure'].lower() == 'no':
360             proto = 'http'
361         url = '%s://%s/%s' % (proto, opts['hostname'], opts['instance'])
362         meta = IdpMetadataGenerator(url, cert,
363                                     timedelta(METADATA_VALIDITY_PERIOD))
364         if 'krb' in opts and opts['krb'] == 'yes':
365             meta.meta.add_allowed_name_format(
366                 lasso.SAML2_NAME_IDENTIFIER_FORMAT_KERBEROS)
367
368         meta.output(os.path.join(path, 'metadata.xml'))
369
370         # Add configuration data to database
371         po = PluginObject(*self.pargs)
372         po.name = 'saml2'
373         po.wipe_data()
374         po.wipe_config_values()
375         config = {'idp storage path': path,
376                   'idp metadata file': 'metadata.xml',
377                   'idp certificate file': cert.cert,
378                   'idp key file': cert.key}
379         po.save_plugin_config(config)
380
381         # Update global config to add login plugin
382         po.is_enabled = True
383         po.save_enabled_state()
384
385         # Fixup permissions so only the ipsilon user can read these files
386         files.fix_user_dirs(path, opts['system_user'])