Add support for logout over SOAP
[cascardo/ipsilon.git] / ipsilon / install / ipsilon-client-install
1 #!/usr/bin/python
2 #
3 # Copyright (C) 2014 Ipsilon project Contributors, for license see COPYING
4
5 from ipsilon.tools.saml2metadata import Metadata
6 from ipsilon.tools.saml2metadata import SAML2_NAMEID_MAP
7 from ipsilon.tools.saml2metadata import SAML2_SERVICE_MAP
8 from ipsilon.tools.certs import Certificate
9 from ipsilon.tools import files
10 from urllib import urlencode
11 import argparse
12 import ConfigParser
13 import getpass
14 import json
15 import logging
16 import os
17 import pwd
18 import requests
19 import shutil
20 import socket
21 import sys
22
23
24 HTTPDCONFD = '/etc/httpd/conf.d'
25 SAML2_TEMPLATE = '/usr/share/ipsilon/templates/install/saml2/sp.conf'
26 SAML2_CONFFILE = '/etc/httpd/conf.d/ipsilon-saml.conf'
27 SAML2_HTTPDIR = '/etc/httpd/saml2'
28 SAML2_PROTECTED = '/saml2protected'
29
30 #Installation arguments
31 args = dict()
32
33 # Regular logging
34 logger = logging.getLogger()
35
36
37 def openlogs():
38     global logger  # pylint: disable=W0603
39     logger = logging.getLogger()
40     lh = logging.StreamHandler(sys.stderr)
41     logger.addHandler(lh)
42
43
44 def saml2():
45     logger.info('Installing SAML2 Service Provider')
46
47     if args['saml_idp_metadata'] is None:
48         #TODO: detect via SRV records ?
49         if args['saml_idp_url']:
50             args['saml_idp_metadata'] = ('%s/saml2/metadata' %
51                                          args['saml_idp_url'].rstrip('/'))
52         else:
53             raise ValueError('An IDP URL or metadata file/URL is required.')
54
55     idpmeta = None
56
57     try:
58         if os.path.exists(args['saml_idp_metadata']):
59             with open(args['saml_idp_metadata']) as f:
60                 idpmeta = f.read()
61         elif args['saml_idp_metadata'].startswith('file://'):
62             with open(args['saml_idp_metadata'][7:]) as f:
63                 idpmeta = f.read()
64         else:
65             r = requests.get(args['saml_idp_metadata'])
66             r.raise_for_status()
67             idpmeta = r.content
68     except Exception, e:  # pylint: disable=broad-except
69         logger.error("Failed to retrieve IDP Metadata file!\n" +
70                      "Error: [%s]" % repr(e))
71         raise
72
73     path = None
74     if not args['saml_no_httpd']:
75         path = os.path.join(SAML2_HTTPDIR, args['hostname'])
76         os.makedirs(path, 0750)
77     else:
78         path = os.getcwd()
79
80     proto = 'https'
81     if not args['saml_secure_setup']:
82         proto = 'http'
83
84     port_str = ''
85     if args['port']:
86         port_str = ':%s' % args['port']
87
88     url = '%s://%s%s' % (proto, args['hostname'], port_str)
89     url_sp = url + args['saml_sp']
90     url_logout = url + args['saml_sp_logout']
91     url_post = url + args['saml_sp_post']
92
93     # Generate metadata
94     m = Metadata('sp')
95     c = Certificate(path)
96     c.generate('certificate', args['hostname'])
97     m.set_entity_id(url_sp)
98     m.add_certs(c)
99     m.add_service(SAML2_SERVICE_MAP['logout-redirect'], url_logout)
100     m.add_service(SAML2_SERVICE_MAP['slo-soap'], url_logout)
101     m.add_service(SAML2_SERVICE_MAP['response-post'], url_post, index="0")
102     m.add_allowed_name_format(SAML2_NAMEID_MAP[args['saml_nameid']])
103     sp_metafile = os.path.join(path, 'metadata.xml')
104     m.output(sp_metafile)
105
106     # Register with the IDP if the IDP URL was provided
107     if args['saml_idp_url']:
108         if args['admin_password']:
109             if args['admin_password'] == '-':
110                 admin_password = sys.stdin.readline().rstrip('\n')
111             else:
112                 try:
113                     with open(args['admin_password']) as f:
114                         admin_password = f.read().rstrip('\n')
115                 except Exception as e:  # pylint: disable=broad-except
116                     logger.error("Failed to read password file!\n" +
117                                  "Error: [%s]" % e)
118                     raise
119         elif ('IPSILON_ADMIN_PASSWORD' in os.environ) and \
120              (os.environ['IPSILON_ADMIN_PASSWORD']):
121             admin_password = os.environ['IPSILON_ADMIN_PASSWORD']
122         else:
123             admin_password = getpass.getpass('%s password: ' %
124                                              args['admin_user'])
125
126         # Read our metadata
127         sp_metadata = ''
128         try:
129             with open(sp_metafile) as f:
130                 for line in f:
131                     sp_metadata += line.strip()
132         except Exception as e:  # pylint: disable=broad-except
133             logger.error("Failed to read SP Metadata file!\n" +
134                          "Error: [%s]" % e)
135             raise
136
137         # Register the SP
138         try:
139             saml2_register_sp(args['saml_idp_url'], args['admin_user'],
140                               admin_password, args['saml_sp_name'],
141                               sp_metadata)
142         except Exception as e:  # pylint: disable=broad-except
143             logger.error("Failed to register SP with IDP!\n" +
144                          "Error: [%s]" % e)
145             raise
146
147     if not args['saml_no_httpd']:
148         idp_metafile = os.path.join(path, 'idp-metadata.xml')
149         with open(idp_metafile, 'w+') as f:
150             f.write(idpmeta)
151
152         saml_protect = 'auth'
153         saml_auth=''
154         if args['saml_base'] != args['saml_auth']:
155             saml_protect = 'info'
156             saml_auth = '<Location %s>\n' \
157                         '    MellonEnable "auth"\n' \
158                         '    Header append Cache-Control "no-cache"\n' \
159                         '</Location>\n' % args['saml_auth']
160
161         psp = '# '
162         if args['saml_auth'] == SAML2_PROTECTED:
163             # default location, enable the default page
164             psp = ''
165
166         saml_secure = 'Off'
167         ssl_require = '#'
168         ssl_rewrite = '#'
169         if args['port']:
170             ssl_port = args['port']
171         else:
172             ssl_port = '443'
173
174         if args['saml_secure_setup']:
175             saml_secure = 'On'
176             ssl_require = ''
177             ssl_rewrite = ''
178
179         samlopts = {'saml_base': args['saml_base'],
180                     'saml_protect': saml_protect,
181                     'saml_sp_key': c.key,
182                     'saml_sp_cert': c.cert,
183                     'saml_sp_meta': sp_metafile,
184                     'saml_idp_meta': idp_metafile,
185                     'saml_sp': args['saml_sp'],
186                     'saml_secure_on': saml_secure,
187                     'saml_auth': saml_auth,
188                     'ssl_require': ssl_require,
189                     'ssl_rewrite': ssl_rewrite,
190                     'ssl_port': ssl_port,
191                     'sp_hostname': args['hostname'],
192                     'sp_port': port_str,
193                     'sp': psp}
194         files.write_from_template(SAML2_CONFFILE, SAML2_TEMPLATE, samlopts)
195
196         files.fix_user_dirs(SAML2_HTTPDIR, args['httpd_user'])
197
198         logger.info('SAML Service Provider configured.')
199         logger.info('You should be able to restart the HTTPD server and' +
200                     ' then access it at %s%s' % (url, args['saml_auth']))
201     else:
202         logger.info('SAML Service Provider configuration ready.')
203         logger.info('Use the certificate, key and metadata.xml files to' +
204                     ' configure your Service Provider')
205
206
207 def saml2_register_sp(url, user, password, sp_name, sp_metadata):
208     s = requests.Session()
209
210     # Authenticate to the IdP
211     form_auth_url = '%s/login/form' % url.rstrip('/')
212     test_auth_url = '%s/login/testauth' % url.rstrip('/')
213     auth_data = {'login_name': user,
214                  'login_password': password}
215
216     r = s.post(form_auth_url, data=auth_data)
217     if r.status_code == 404:
218         r = s.post(test_auth_url, data=auth_data)
219
220     if r.status_code != 200:
221         raise Exception('Unable to authenticate to IdP (%d)' % r.status_code)
222
223     # Add the SP
224     sp_url = '%s/rest/providers/saml2/SPS/%s' % (url.rstrip('/'), sp_name)
225     sp_headers = {'Content-type': 'application/x-www-form-urlencoded',
226                   'Referer': sp_url}
227     sp_data = urlencode({'metadata': sp_metadata})
228
229     r = s.post(sp_url, headers=sp_headers, data=sp_data)
230     if r.status_code != 201:
231         message = json.loads(r.text)['message']
232         raise Exception('%s' % message)
233
234
235 def install():
236     if args['saml']:
237         saml2()
238
239
240 def saml2_uninstall():
241     try:
242         shutil.rmtree(os.path.join(SAML2_HTTPDIR, args['hostname']))
243     except Exception, e:  # pylint: disable=broad-except
244         log_exception(e)
245     try:
246         os.remove(SAML2_CONFFILE)
247     except Exception, e:  # pylint: disable=broad-except
248         log_exception(e)
249
250
251 def uninstall():
252     logger.info('Uninstalling Service Provider')
253     #FXIME: ask confirmation
254     saml2_uninstall()
255     logger.info('Uninstalled SAML2 data')
256
257
258 def log_exception(e):
259     if 'debug' in args and args['debug']:
260         logger.exception(e)
261     else:
262         logger.error(e)
263
264
265 def parse_config_profile(args):
266     config = ConfigParser.ConfigParser()
267     files = config.read(args['config_profile'])
268     if len(files) == 0:
269         raise ConfigurationError('Config Profile file %s not found!' %
270                                  args['config_profile'])
271
272     if 'globals' in config.sections():
273         G = config.options('globals')
274         for g in G:
275             val = config.get('globals', g)
276             if val == 'False':
277                 val = False
278             elif val == 'True':
279                 val = True
280             if g in globals():
281                 globals()[g] = val
282             else:
283                 for k in globals().keys():
284                     if k.lower() == g.lower():
285                         globals()[k] = val
286                         break
287
288     if 'arguments' in config.sections():
289         A = config.options('arguments')
290         for a in A:
291             val = config.get('arguments', a)
292             if val == 'False':
293                 val = False
294             elif val == 'True':
295                 val = True
296             args[a] = val
297
298     return args
299
300
301 def parse_args():
302     global args
303
304     fc = argparse.ArgumentDefaultsHelpFormatter
305     parser = argparse.ArgumentParser(description='Client Install Options',
306                                      formatter_class=fc)
307     parser.add_argument('--version',
308                         action='version', version='%(prog)s 0.1')
309     parser.add_argument('--hostname', default=socket.getfqdn(),
310                         help="Machine's fully qualified host name")
311     parser.add_argument('--port', default=None,
312                         help="Port number that SP listens on")
313     parser.add_argument('--admin-user', default='admin',
314                         help="Account allowed to create a SP")
315     parser.add_argument('--admin-password', default=None,
316                         help="File containing the password for the account " +
317                              "used to create a SP (- to read from stdin)")
318     parser.add_argument('--httpd-user', default='apache',
319                         help="Web server account used to read certs")
320     parser.add_argument('--saml', action='store_true', default=True,
321                         help="Whether to install a saml2 SP")
322     parser.add_argument('--saml-idp-url', default=None,
323                         help="A URL of the IDP to register the SP with")
324     parser.add_argument('--saml-idp-metadata', default=None,
325                         help="A URL pointing at the IDP Metadata (FILE or HTTP)")
326     parser.add_argument('--saml-no-httpd', action='store_true', default=False,
327                         help="Do not configure httpd")
328     parser.add_argument('--saml-base', default='/',
329                         help="Where saml2 authdata is available")
330     parser.add_argument('--saml-auth', default=SAML2_PROTECTED,
331                         help="Where saml2 authentication is enforced")
332     parser.add_argument('--saml-sp', default='/saml2',
333                         help="Where saml communication happens")
334     parser.add_argument('--saml-sp-logout', default=None,
335                         help="Single Logout URL")
336     parser.add_argument('--saml-sp-post', default=None,
337                         help="Post response URL")
338     parser.add_argument('--saml-secure-setup', action='store_true',
339                         default=True, help="Turn on all security checks")
340     parser.add_argument('--saml-nameid', default='unspecified',
341                         choices=SAML2_NAMEID_MAP.keys(),
342                         help="SAML NameID format to use")
343     parser.add_argument('--saml-sp-name', default=None,
344                         help="The SP name to register with the IdP")
345     parser.add_argument('--debug', action='store_true', default=False,
346                         help="Turn on script debugging")
347     parser.add_argument('--config-profile', default=None,
348                         help=argparse.SUPPRESS)
349     parser.add_argument('--uninstall', action='store_true',
350                         help="Uninstall the server and all data")
351
352     args = vars(parser.parse_args())
353
354     if args['config_profile']:
355         args = parse_config_profile(args)
356
357     if len(args['hostname'].split('.')) < 2:
358         raise ValueError('Hostname: %s is not a FQDN.' % args['hostname'])
359
360     if args['port'] and not args['port'].isdigit():
361         raise ValueError('Port number: %s is not an integer.' % args['port'])
362
363     # Validate that all path options begin with '/'
364     path_args = ['saml_base', 'saml_auth', 'saml_sp', 'saml_sp_logout',
365                  'saml_sp_post']
366     for path_arg in path_args:
367         if args[path_arg] is not None and not args[path_arg].startswith('/'):
368             raise ValueError('--%s must begin with a / character.' %
369                              path_arg.replace('_', '-'))
370
371     # The saml_sp setting must be a subpath of saml_base since it is
372     # used as the MellonEndpointPath.
373     if not args['saml_sp'].startswith(args['saml_base']):
374         raise ValueError('--saml-sp must be a subpath of --saml-base.')
375
376     # The saml_sp_logout and saml_sp_post settings must be subpaths
377     # of saml_sp (the mellon endpoint).
378     path_args = {'saml_sp_logout': 'logout',
379                  'saml_sp_post': 'postResponse'}
380     for path_arg, default_path in path_args.items():
381         if args[path_arg] is None:
382             args[path_arg] = '%s/%s' % (args['saml_sp'].rstrip('/'),
383                                         default_path)
384
385         elif not args[path_arg].startswith(args['saml_sp']):
386             raise ValueError('--%s must be a subpath of --saml-sp' %
387                              path_arg.replace('_', '-'))
388
389     # If saml_idp_url if being used, we require saml_sp_name to
390     # use when registering the SP.
391     if args['saml_idp_url'] and not args['saml_sp_name']:
392         raise ValueError('--saml-sp-name must be specified when using' +
393                          '--saml-idp-url')
394
395     # At least one on this list needs to be specified or we do nothing
396     sp_list = ['saml']
397     present = False
398     for sp in sp_list:
399         if args[sp]:
400             present = True
401     if not present and not args['uninstall']:
402         raise ValueError('Nothing to install, please select a Service type.')
403
404
405 if __name__ == '__main__':
406     out = 0
407     openlogs()
408     try:
409         parse_args()
410
411         if 'uninstall' in args and args['uninstall'] is True:
412             uninstall()
413         else:
414             install()
415     except Exception, e:  # pylint: disable=broad-except
416         log_exception(e)
417         if 'uninstall' in args and args['uninstall'] is True:
418             logging.info('Uninstallation aborted.')
419         else:
420             logging.info('Installation aborted.')
421         out = 1
422     finally:
423         if out == 0:
424             if 'uninstall' in args and args['uninstall'] is True:
425                 logging.info('Uninstallation complete.')
426             else:
427                 logging.info('Installation complete.')
428     sys.exit(out)