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