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