# w.c.s. - web application for online forms # Copyright (C) 2005-2014 Entr'ouvert # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, see . from __future__ import print_function import json import os import random import sys import tempfile import hashlib from django.utils import six from django.utils.encoding import force_bytes, force_text from django.utils.six.moves import configparser as ConfigParser from django.utils.six.moves.urllib import parse as urlparse from quixote import cleanup from wcs.qommon import force_str from ..qommon import misc from ..qommon.ctl import Command, make_option from ..qommon.storage import atomic_write from wcs.admin.settings import UserFieldsFormDef from wcs.fields import StringField, EmailField, DateField from django.utils.encoding import force_bytes class NoChange(Exception): pass def atomic_symlink(src, dst): if os.path.exists(dst) and os.readlink(dst) == src: return if os.path.exists(dst + '.tmp'): os.unlink(dst + '.tmp') os.symlink(src, dst + '.tmp') os.rename(dst + '.tmp', dst) class CmdCheckHobos(Command): name = 'hobo_deploy' # TODO: import this from django settings THEMES_DIRECTORY = os.environ.get('THEMES_DIRECTORY', '/usr/share/publik/themes') def __init__(self): Command.__init__(self, [ make_option('--ignore-timestamp', action='store_true', dest='ignore_timestamp', default=False), make_option('--redeploy', action='store_true', default=False), ]) def execute(self, base_options, sub_options, args): from .. import publisher publisher.WcsPublisher.configure(self.config) if sub_options.redeploy: sub_options.ignore_timestamp = True for tenant in publisher.WcsPublisher.get_tenants(): hobo_json_path = os.path.join(publisher.WcsPublisher.APP_DIR, tenant, 'hobo.json') if not os.path.exists(hobo_json_path): continue hobo_json = json.load(open(hobo_json_path)) try: me = [service for service in hobo_json['services'] if service.get('this') is True][0] except IndexError: pass else: cleanup() self.deploy(base_options, sub_options, [me['base_url'], hobo_json_path]) else: self.deploy(base_options, sub_options, args) def deploy(self, base_options, sub_options, args): from .. import publisher self.base_options = base_options if sub_options.extra: if not self.config.has_section('extra'): self.config.add_section('extra') for i, extra in enumerate(sub_options.extra): self.config.set('extra', 'cmd_line_extra_%d' % i, extra) publisher.WcsPublisher.configure(self.config) pub = publisher.WcsPublisher.create_publisher( register_tld_names=False) global_app_dir = pub.app_dir base_url = args[0] if args[1] == '-': # get environment definition from stdin self.all_services = json.load(sys.stdin) else: self.all_services = json.load(open(args[1])) try: service = [x for x in self.all_services.get('services', []) if \ x.get('service-id') == 'wcs' and x.get('base_url') == base_url and not x.get('secondary')][0] except IndexError: return service['this'] = True if base_url.endswith('/'): # wcs doesn't expect a trailing slash service['base_url'] = base_url[:-1] pub.app_dir = os.path.join(global_app_dir, self.get_instance_path(service)) if not os.path.exists(pub.app_dir): print('initializing instance in', pub.app_dir) os.mkdir(pub.app_dir) pub.initialize_app_dir() if service.get('template_name'): skeleton_filepath = os.path.join(global_app_dir, 'skeletons', service.get('template_name')) if os.path.exists(skeleton_filepath): pub.import_zip(open(skeleton_filepath, 'rb')) new_site = True else: print('updating instance in', pub.app_dir) new_site = False try: self.configure_site_options(service, pub, ignore_timestamp=sub_options.ignore_timestamp) except NoChange: print(' skipping') return pub.set_config(skip_sql=True) if new_site: self.configure_sql(service, pub) self.update_configuration(service, pub) self.configure_authentication_methods(service, pub) self.update_profile(self.all_services.get('profile', {}), pub) # Store hobo.json atomic_write(os.path.join(pub.app_dir, 'hobo.json'), force_bytes(json.dumps(self.all_services))) def update_configuration(self, service, pub): if not pub.cfg.get('misc'): pub.cfg['misc'] = {'charset': 'utf-8'} pub.cfg['misc']['sitename'] = force_str(service.get('title')) pub.cfg['misc']['frontoffice-url'] = force_str(service.get('base_url')) if not pub.cfg.get('language'): pub.cfg['language'] = {'language': 'fr'} if not pub.cfg.get('emails'): pub.cfg['emails'] = {} variables = self.all_services.get('variables') or {} variables.update(service.get('variables') or {}) theme_id = variables.get('theme') theme_data = None if theme_id and os.path.exists(self.THEMES_DIRECTORY): for theme_module in os.listdir(self.THEMES_DIRECTORY): try: themes_json = json.load(open( os.path.join(self.THEMES_DIRECTORY, theme_module, 'themes.json'))) except IOError: continue if not isinstance(themes_json, dict): # compat themes_json = {'themes': themes_json} for theme_data in themes_json.get('themes'): if theme_data.get('id') == theme_id: if not 'module' in theme_data: theme_data['module'] = theme_module break else: theme_data = None continue break if theme_data: pub.cfg['branding'] = {'theme': 'publik-base'} tenant_dir = pub.app_dir theme_dir = os.path.join(tenant_dir, 'theme') target_dir = os.path.join(self.THEMES_DIRECTORY, theme_data['module']) atomic_symlink(target_dir, theme_dir) for component in ('static', 'templates'): component_dir = os.path.join(tenant_dir, component) if not os.path.islink(component_dir) and os.path.isdir(component_dir): try: os.rmdir(component_dir) except OSError: continue if not theme_data.get('overlay'): try: os.unlink(component_dir) except OSError: pass else: atomic_symlink( os.path.join(self.THEMES_DIRECTORY, theme_data['overlay'], component), component_dir) if variables.get('default_from_email'): pub.cfg['emails']['from'] = force_str(variables.get('default_from_email')) if variables.get('email_signature') is not None: pub.cfg['emails']['footer'] = force_str(variables.get('email_signature')) pub.write_cfg() def update_profile(self, profile, pub): formdef = UserFieldsFormDef(publisher=pub) profile_fields = {} profile_field_ids = ['_' + x['name'] for x in profile.get('fields', [])] for field in formdef.fields: if field.id in profile_field_ids: profile_fields[field.id] = field html5_autocomplete_map = { 'first_name': 'given-name', 'last_name': 'family-name', 'address': 'address-line1', 'zipcode': 'postal-code', 'city': 'address-level2', 'country': 'country', 'phone': 'tel', 'email': 'email', } # create or update profile fields for attribute in profile.get('fields', []): field_id = '_' + attribute['name'] if not field_id in profile_fields: field_class = StringField if attribute['kind'] == 'email': field_class = EmailField elif attribute['kind'] in ('date', 'birthdate', 'fedict_date'): field_class = DateField new_field = field_class(label=force_str(attribute['label']), type=field_class.key, varname=attribute['name']) new_field.id = field_id profile_fields[field_id] = new_field else: # remove it for the moment formdef.fields.remove(profile_fields[field_id]) profile_fields[field_id].label = force_str(attribute['label']) profile_fields[field_id].hint = force_str(attribute['description']) profile_fields[field_id].required = attribute['required'] if attribute['disabled']: profile_field_ids.remove('_' + attribute['name']) if attribute['name'] in html5_autocomplete_map: profile_fields[field_id].extra_css_class = ( 'autocomplete-%s' % html5_autocomplete_map[attribute['name']]) # insert profile fields at the beginning formdef.fields = [profile_fields[x] for x in profile_field_ids] + formdef.fields formdef.store() pub.cfg['users']['field_email'] = '_email' pub.cfg['users']['field_name'] = ['_first_name', '_last_name'] pub.write_cfg() # add mapping for SAML provisioning for idp in pub.cfg.get('idp', {}).values(): if not idp.get('attribute-mapping'): idp['attribute-mapping'] = {} for field in profile.get('fields', []): attribute_name = field['name'] field_id = '_' + attribute_name if field_id in profile_field_ids: idp['attribute-mapping'][str(attribute_name)] = str(field_id) pub.write_cfg() def configure_authentication_methods(self, service, pub): # look for an identity provider idps = [x for x in self.all_services.get('services', []) if x.get('service-id') == 'authentic'] if not pub.cfg.get('identification'): pub.cfg['identification'] = {} methods = pub.cfg['identification'].get('methods', []) if idps and not 'idp' in methods: methods.append('idp') elif not idps and not 'password' in methods: methods.append('password') pub.cfg['identification']['methods'] = methods if not pub.cfg.get('sp'): pub.cfg['sp'] = {} pub.cfg['sp']['idp-manage-user-attributes'] = bool(idps) pub.cfg['sp']['idp-manage-roles'] = bool(idps) pub.write_cfg() if not idps: return # initialize service provider side if not pub.cfg['sp'].get('publickey'): from ..qommon.ident.idp import MethodAdminDirectory spconfig = pub.cfg['sp'] spconfig['saml2_base_url'] = str(service.get('base_url')) + '/saml' spconfig['saml2_providerid'] = spconfig['saml2_base_url'] + '/metadata' MethodAdminDirectory().generate_rsa_keypair() if not 'saml_identities' in pub.cfg: pub.cfg['saml_identities'] = {} if idps: pub.cfg['saml_identities']['identity-creation'] = 'self' # write down configuration to disk as it will get reloaded # automatically and we don't want to lose our changes. pub.write_cfg() for idp in idps: if not idp['base_url'].endswith('/'): idp['base_url'] = idp['base_url'] + '/' metadata_url = '%sidp/saml2/metadata' % idp['base_url'] try: rfd = misc.urlopen(metadata_url) except misc.ConnectionError as e: print('failed to get metadata URL', metadata_url, e, file=sys.stderr) continue s = rfd.read() (bfd, metadata_pathname) = tempfile.mkstemp('.metadata') atomic_write(metadata_pathname, force_bytes(s)) from ..qommon.ident.idp import AdminIDPDir admin_dir = AdminIDPDir() key_provider_id = admin_dir.submit_new_remote( metadata_pathname, None, metadata_url, None) admin_attribute = service.get('variables', {}).get('admin-attribute') if not admin_attribute: admin_attribute = 'is_superuser=true' else: admin_attribute = force_str(admin_attribute) admin_attribute_dict = dict([admin_attribute.split('=')]) pub.cfg['idp'][key_provider_id]['admin-attributes'] = admin_attribute_dict pub.cfg['idp'][key_provider_id]['nameidformat'] = 'unspecified' pub.cfg['saml_identities']['registration-url'] = str( '%saccounts/register/' % idp['base_url']) pub.write_cfg() def get_instance_path(self, service): parsed_url = urlparse.urlsplit(service.get('base_url')) instance_path = parsed_url.netloc if parsed_url.path: instance_path += '+%s' % parsed_url.path.replace('/', '+') return instance_path def configure_site_options(self, current_service, pub, ignore_timestamp=False): # configure site-options.cfg config = ConfigParser.RawConfigParser() site_options_filepath = os.path.join(pub.app_dir, 'site-options.cfg') if os.path.exists(site_options_filepath): config.read(site_options_filepath) if not ignore_timestamp: try: if config.get('hobo', 'timestamp') == self.all_services.get('timestamp'): raise NoChange() except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): pass if not 'hobo' in config.sections(): config.add_section('hobo') config.set('hobo', 'timestamp', self.all_services.get('timestamp')) if not 'options' in config.sections(): config.add_section('options') variables = {} api_secrets = {} for service in self.all_services.get('services', []): service_url = service.get('base_url') if not service_url.endswith('/'): service_url += '/' if service.get('slug'): variables['%s_url' % service.get('slug').replace('-', '_')] = service_url if not service.get('secret_key'): continue domain = urlparse.urlparse(service_url).netloc.split(':')[0] if service is current_service: if config.has_option('api-secrets', domain): api_secrets[domain] = config.get('api-secrets', domain) else: # custom key calcultation for "self", as the shared_secret code # would do secret_key ^ secret_key = 0. api_secrets[domain] = self.shared_secret(current_service.get('secret_key'), str(random.SystemRandom().random())) continue api_secrets[domain] = self.shared_secret(current_service.get('secret_key'), service.get('secret_key')) if service.get('service-id') == 'authentic': variables['idp_url'] = service_url variables['idp_account_url'] = service_url + 'accounts/' variables['idp_api_url'] = service_url + 'api/' variables['idp_registration_url'] = service_url + 'accounts/register/' if service.get('secondary'): continue if service.get('service-id') == 'combo': if 'portal-agent' in service.get('template_name', ''): variables['portal_agent_url'] = service_url variables['portal_agent_title'] = service.get('title') elif 'portal-user' in service.get('template_name', ''): variables['portal_url'] = service_url variables['portal_user_url'] = service_url variables['portal_user_title'] = service.get('title') config.set('options', 'theme_skeleton_url', service.get('base_url') + '__skeleton__/') if self.all_services.get('variables'): for key, value in self.all_services.get('variables').items(): variables[key] = value for key, value in current_service.get('variables', {}).items(): variables[key] = value if variables: if not 'variables' in config.sections(): config.add_section('variables') for key, value in variables.items(): key = force_str(key) value = force_str(value) config.set('variables', key, value) if not 'api-secrets' in config.sections(): config.add_section('api-secrets') if not 'wscall-secrets' in config.sections(): config.add_section('wscall-secrets') for key, value in api_secrets.items(): config.set('api-secrets', key, value) # for now the secrets are the same whatever the direction is. config.set('wscall-secrets', key, value) # add known services for service in self.all_services.get('services', []): if service.get('secondary'): continue if service.get('service-id') == 'fargo': config.set('options', 'fargo_url', service.get('base_url')) try: portal_agent_url = config.get('variables', 'portal_agent_url') except ConfigParser.NoOptionError: pass else: if portal_agent_url.endswith('/'): portal_agent_url = portal_agent_url.rstrip('/') extra_head = ''''''\ '''''' % ( portal_agent_url, portal_agent_url) config.set('options', 'backoffice_extra_head', extra_head) with open(site_options_filepath, 'w') as site_options: config.write(site_options) def normalize_database_name(self, database_name): if len(database_name) > 63: digest = hashlib.md5(force_bytes(database_name)).hexdigest()[:4] database_name = '%s_%s_%s' % (database_name[:29], digest, database_name[-28:]) return database_name def configure_sql(self, service, pub): if not pub.cfg.get('postgresql'): return if not pub.has_site_option('postgresql'): return import psycopg2 import psycopg2.errorcodes # determine database name using the instance path domain_table_name = self.get_instance_path(service).replace( '-', '_').replace('.', '_').replace('+', '_') if pub.cfg['postgresql'].get('database-template-name'): database_template_name = pub.cfg['postgresql'].pop('database-template-name') database_name = (database_template_name % domain_table_name).strip('_') else: # legacy way to create a database name, if it contained an # underscore character, use the first part as a prefix database_name = pub.cfg['postgresql'].get('database', 'wcs') if not domain_table_name in database_name: database_name = '%s_%s' % (database_name.split('_')[0], domain_table_name) database_name = self.normalize_database_name(database_name) createdb_cfg = pub.cfg['postgresql'].get('createdb-connection-params') if not createdb_cfg: createdb_cfg = {} for k, v in pub.cfg['postgresql'].items(): if v and isinstance(v, six.string_types): createdb_cfg[k] = v try: pgconn = psycopg2.connect(**createdb_cfg) except psycopg2.Error as e: print('failed to connect to postgresql (%s)' % \ psycopg2.errorcodes.lookup(e.pgcode), file=sys.stderr) return pgconn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) cur = pgconn.cursor() new_database = True try: cur.execute('''CREATE DATABASE %s''' % database_name) except psycopg2.Error as e: if e.pgcode == psycopg2.errorcodes.DUPLICATE_DATABASE: cur.execute("""SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name = 'wcs_meta'""") if cur.fetchall(): new_database = False else: print('failed to create database (%s)' % \ psycopg2.errorcodes.lookup(e.pgcode), file=sys.stderr) return else: cur.close() pub.cfg['postgresql']['database'] = database_name pub.write_cfg() pub.set_config(skip_sql=False) if not new_database: return # create tables etc. pub.initialize_sql() @classmethod def shared_secret(cls, secret1, secret2): secret1 = hashlib.sha256(force_bytes(secret1)).hexdigest() secret2 = hashlib.sha256(force_bytes(secret2)).hexdigest() return hex(int(secret1, 16) ^ int(secret2, 16))[2:-1] CmdCheckHobos.register()