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