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