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