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