import logging import os import xml.etree.ElementTree as ET from time import sleep import requests from authentic2 import app_settings from authentic2.a2_rbac.models import OrganizationalUnit, Role from authentic2.a2_rbac.utils import get_default_ou from authentic2.compat_lasso import lasso from authentic2.models import Attribute from authentic2.saml.models import LibertyProvider, LibertyServiceProvider, SAMLAttribute, SPOptionsIdPPolicy from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core import serializers from django.utils.translation import activate from django.utils.translation import gettext as _ from tenant_schemas.utils import tenant_context from hobo.agent.authentic2.provisionning import Provisionning from hobo.agent.common.management.commands import hobo_deploy User = get_user_model() class Command(hobo_deploy.Command): help = 'Deploy multitenant authentic service from hobo' backoff_factor = 5 def __init__(self, *args, **kwargs): self.logger = logging.getLogger(__name__) super().__init__(*args, **kwargs) def deploy_specifics(self, hobo_environment, tenant): # generate SAML keys self.generate_saml_keys(tenant) self.configure_theme(hobo_environment, tenant) with tenant_context(tenant): # Activate default translation activate(settings.LANGUAGE_CODE) for user_dict in hobo_environment.get('users'): # create hobo users in authentic to bootstrap # (don't update them, hobo is not a provisioning system) if not user_dict.get('email'): # get an email for settings, or default to system try: user_dict['email'] = settings.ADMINS[0][1] except (IndexError, ValueError): user_dict['email'] = 'root@localhost' if not (user_dict.get('first_name') or user_dict.get('last_name')): # some SP require a name user_dict['first_name'] = user_dict['username'] user_dict['is_staff'] = True user_dict['is_superuser'] = True user, created = User.objects.get_or_create(defaults=user_dict, username=user_dict['username']) # create/update user attributes fields = [] disabled_fields = [] for i, attribute in enumerate(hobo_environment.get('profile', {}).get('fields')): if attribute['name'] == 'email': # this field is hardcoded in the user model, don't add # it as a new attribute, but add it to the fields list, # so it gets shared as SAML attribute. fields.append(attribute['name']) continue attr, created = Attribute.all_objects.update_or_create( name=attribute['name'], defaults={'kind': attribute['kind']} ) for key in ( 'label', 'description', 'asked_on_registration', 'user_editable', 'user_visible', 'required', 'searchable', 'disabled', 'required_on_login', ): if key in attribute: setattr(attr, key, attribute[key]) attr.order = i if attribute['disabled']: disabled_fields.append(attr.name) else: fields.append(attr.name) attr.save() # creation of IdpPolicy policy, created = SPOptionsIdPPolicy.objects.get_or_create(name='Default') policy.enabled = True policy.authn_request_signed = False policy.accepted_name_id_format = ['uuid'] policy.default_name_id_format = 'uuid' policy.idp_initiated_sso = True policy.save() policy_type = ContentType.objects.get_for_model(SPOptionsIdPPolicy) provider_type = ContentType.objects.get_for_model(LibertyProvider) # create SAML default policy attributes names = ['username', 'is_superuser'] + fields + disabled_fields for name in names: attribute, created = SAMLAttribute.objects.get_or_create( name=name, name_format='basic', attribute_name='django_user_%s' % name, object_id=policy.id, content_type=policy_type, ) attribute.enabled = not (name in disabled_fields) attribute.save() # also pass verified attributes SAMLAttribute.objects.get_or_create( name='verified_attributes', name_format='basic', attribute_name='@verified_attributes@', object_id=policy.id, content_type=policy_type, ) # create or update Service Providers services = hobo_environment['services'] retries = 0 provision_target_ous = {} max_retries = 1 if self.redeploy else 5 while retries < max_retries: for service in services: if service.get('$done'): continue if not service.get('saml-sp-metadata-url'): service['$done'] = True continue sp_url = service['saml-sp-metadata-url'] metadata_text = None try: metadata_response = requests.get(sp_url, verify=app_settings.A2_VERIFY_SSL, timeout=5) metadata_response.raise_for_status() # verify metadata is correct if self.check_saml_metadata(metadata_response.text): metadata_text = metadata_response.text else: service['$last-error'] = 'metadata is incorrect' continue except requests.exceptions.RequestException as e: service['$last-error'] = str(e) continue metadata_text = metadata_response.text provider, service_created = None, False for legacy_urls in service.get('legacy_urls', []): try: provider = LibertyProvider.objects.get( entity_id=legacy_urls['saml-sp-metadata-url'], protocol_conformance=lasso.PROTOCOL_SAML_2_0, ) provider.entity_id = sp_url break except LibertyProvider.DoesNotExist: pass if not provider: provider, service_created = LibertyProvider.objects.get_or_create( entity_id=sp_url, protocol_conformance=lasso.PROTOCOL_SAML_2_0 ) provider.name = service['title'] provider.slug = service['slug'] provider.federation_source = 'hobo' provider.metadata = metadata_text provider.metadata_url = service['saml-sp-metadata-url'] variables = service.get('variables', {}) if variables.get('ou-slug'): ou, created = OrganizationalUnit.objects.get_or_create( slug=service['variables']['ou-slug'] ) ou.name = service['variables']['ou-label'] ou.save() if service.get('secondary') and variables.get('ou-label'): # for secondary services include collectivity in label provider.name = '%s (%s)' % (service['title'], service['variables']['ou-label']) else: # if there are more than one w.c.s. service we will create an # ou of the same name ou = get_default_ou() create_ou = False if service_created and service['service-id'] == 'wcs': for s in services: if s['service-id'] != 'wcs': continue if s['slug'] == service['slug']: continue if LibertyProvider.objects.filter(slug=s['slug']).exists(): create_ou = True break if create_ou: ou, created = OrganizationalUnit.objects.get_or_create(name=service['title']) if service_created or not provider.ou: provider.ou = ou provision_target_ous[provider.ou.id] = provider.ou if service.get('template_name') == 'portal-user': provider.ou.home_url = service['base_url'] provider.ou.save() provider.save() if service_created: service_provider = LibertyServiceProvider( enabled=True, liberty_provider=provider, sp_options_policy=policy, users_can_manage_federations=False, ) service_provider.save() # add a superuser role for the service name = _('Superuser of %s') % service['title'] su_role, created = Role.objects.get_or_create( service=provider, slug='_a2-hobo-superuser', defaults={'name': name} ) if su_role.name != name: su_role.name = name su_role.save() su_role.is_superuser = True su_role.save() # pass the new attribute to the service SAMLAttribute.objects.get_or_create( name='is_superuser', name_format='basic', attribute_name='is_superuser', object_id=provider.pk, content_type=provider_type, ) SAMLAttribute.objects.get_or_create( name='role-slug', name_format='basic', attribute_name='a2_service_ou_role_uuids', object_id=provider.pk, content_type=provider_type, ) # load skeleton if service is new if service.get('template_name'): # if there are more of the same servie, we will create an # ou self.load_skeleton(provider, service['service-id'], service['template_name']) service['$done'] = True if all(service.get('$done') for service in services): # it's finished no need to continue break # wait 5, 10, 20, 40, .. seconds sleep(self.backoff_factor * (2**retries)) retries += 1 if provision_target_ous: # mass provision roles on new created services engine = Provisionning() roles = Role.objects.all() engine.notify_roles(provision_target_ous, roles, full=True) for service in services: if not service.get('$done'): last_error = service['$last-error'] sp_url = service['saml-sp-metadata-url'] self.stderr.write(self.style.WARNING('Error registering %s: %s\n' % (sp_url, last_error))) def load_skeleton(self, provider, service_id, template_name, create_ou=False): if not getattr(settings, 'HOBO_SKELETONS_DIR', None): self.logger.debug('no skeleton: no HOBO_SKELETONS_DIR setting') return # ex.: /var/lib/authentic2-multitenant/skeletons/communes/wcs/ skeleton_dir = os.path.join(settings.HOBO_SKELETONS_DIR, template_name, service_id) if not os.path.exists(skeleton_dir): self.logger.debug('no skeleton: skeleton dir %r does not exist', skeleton_dir) return self.load_skeleton_roles(skeleton_dir, provider) def load_skeleton_roles(self, skeleton_dir, provider): '''Load default roles based on a template''' roles_filename = os.path.join(skeleton_dir, 'roles.json') if not os.path.exists(roles_filename): self.logger.debug('no skeleton roles: roles file %r does not ' 'exist', roles_filename) return if Role.objects.filter(ou=provider.ou).exclude(slug__startswith='_').exists(): return roles = [] for role in serializers.deserialize('json', open(roles_filename)): assert isinstance(role.object, Role) # reset id and natural key role.object.pk = None role.object.uuid = Role._meta.get_field('uuid').default() # same ou as provider role.object.ou = provider.ou # XXX: attach to service or not ? roles.append(role.object) if roles: Role.objects.bulk_create(roles) Role.objects.get(uuid=roles[-1].uuid).save() def check_saml_metadata(self, saml_metadata): try: root = ET.fromstring(saml_metadata.encode('utf-8')) except ET.ParseError: return False return root.tag == '{%s}EntityDescriptor' % lasso.SAML2_METADATA_HREF