# authentic2 - versatile identity manager # Copyright (C) 2010-2019 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 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 Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from __future__ import unicode_literals import logging from django.utils import six from django.contrib import messages from django.core.exceptions import MultipleObjectsReturned from django.db.transaction import atomic from django.utils.translation import ugettext as _ from mellon.adapters import DefaultAdapter, UserCreationError from mellon.utils import get_setting from authentic2 import utils from authentic2.backends import get_user_queryset from authentic2.utils.evaluate import evaluate_condition from authentic2.a2_rbac.models import Role, OrganizationalUnit as OU from authentic2.a2_rbac.utils import get_default_ou logger = logging.getLogger('authentic2.auth_saml') class MappingError(Exception): details = None def __init__(self, message, details=None): self.details = details or {} super(MappingError, self).__init__(message) def __str__(self): s = six.text_type(self.args[0]) if self.details: s += ' ' + repr(self.details) return s class SamlConditionContextProxy(object): def __init__(self, saml_attributes): self.saml_attributes = saml_attributes def __getitem__(self, key): if key.endswith('__list'): return self.saml_attributes[key[:-len('__list')]] else: v = self.saml_attributes[key] if isinstance(v, list): return v[0] if v else None else: return v class AuthenticAdapter(DefaultAdapter): def create_user(self, user_class): user = user_class() user.set_unusable_password() user.save() return user def finish_create_user(self, idp, saml_attributes, user): try: self.provision_a2_attributes(user, idp, saml_attributes) except MappingError as e: logger.warning('auth_saml: user creation failed on a mandatory mapping action, %s', e) if self.request: messages.error(self.request, _('user creation failed on a mandatory mapping action: %s') % e) raise UserCreationError if not user.ou: user.ou = get_default_ou() user.save() def provision(self, user, idp, saml_attributes): super(AuthenticAdapter, self).provision(user, idp, saml_attributes) try: self.provision_a2_attributes(user, idp, saml_attributes) except MappingError as e: logger.warning('auth_saml: failure during attribute provisionning %s', e) @atomic def provision_a2_attributes(self, user, idp, saml_attributes): '''Copy incoming SAML attributes to user attributes, A2_ATTRIBUTE_MAPPING must be a list of dictionaries like: { 'attribute': 'email', 'saml_attribute': 'email', # optional: 'mandatory': False, } If an attribute is not mandatory any error is just logged, if the attribute is mandatory, login will fail. ''' saml_attributes = saml_attributes.copy() attribute_mapping = get_setting(idp, 'A2_ATTRIBUTE_MAPPING', []) if not attribute_mapping: return if not isinstance(attribute_mapping, list): raise MappingError('invalid A2_ATTRIBUTE_MAPPING') if self.apply_attribute_mapping(user, idp, saml_attributes, attribute_mapping): user.save() def apply_attribute_mapping(self, user, idp, saml_attributes, attribute_mapping): user_modified = False for mapping in attribute_mapping: if not isinstance(mapping, dict): raise MappingError('invalid mapping action', details={'mapping': mapping}) action = mapping.get('action', 'set-attribute') mandatory = mapping.get('mandatory', False) is True method = None if isinstance(action, six.string_types): try: method = getattr(self, 'action_' + action.replace('-', '_')) except AttributeError: pass try: if not method: raise MappingError('invalid action') logger.debug('auth_saml: applying provisionning mapping %s', mapping) if method(user, idp, saml_attributes, mapping): user_modified = True except MappingError as e: e.details['mapping'] = mapping if mandatory: # it's mandatory, provisionning should fail completely raise e else: logger.warning('auth_saml: mapping action failed: %s', e) return user_modified def action_rename(self, user, idp, saml_attributes, mapping): from_name = mapping.get('from') if not from_name or not isinstance(from_name, six.string_types): raise MappingError('missing from in rename') to_name = mapping.get('to') if not to_name or not isinstance(to_name, six.string_types): raise MappingError('missing to in rename') if from_name in saml_attributes: saml_attributes[to_name] = saml_attributes[from_name] def action_set_attribute(self, user, idp, saml_attributes, mapping): attribute = mapping.get('attribute') if not attribute or not isinstance(attribute, six.string_types): raise MappingError('missing attribute key') saml_attribute = mapping.get('saml_attribute') if not saml_attribute or not isinstance(saml_attribute, six.string_types): raise MappingError('missing saml_attribute key') if saml_attribute not in saml_attributes: raise MappingError('unknown saml_attribute') value = saml_attributes[saml_attribute] return self.set_user_attribute(user, attribute, value) def set_user_attribute(self, user, attribute, value): if isinstance(value, list): if len(value) == 0: raise MappingError('no value') if len(value) > 1: raise MappingError('too many values', details={'value': value}) value = value[0] if attribute in ('first_name', 'last_name', 'email', 'username'): if getattr(user, attribute) != value: logger.info('auth_saml: attribute %r set to %r', attribute, value, extra={'user': user}) setattr(user, attribute, value) return True else: if getattr(user.attributes, attribute) != value: logger.info('auth_saml: attribute %r set to %r', attribute, value, extra={'user': user}) setattr(user.attributes, attribute, value) return True return False def get_ou(self, role_desc): ou_desc = role_desc.get('ou') if ou_desc is None: return None if not isinstance(ou_desc, dict): raise MappingError('invalid ou description') slug = ou_desc.get('slug') name = ou_desc.get('name') if slug: if not isinstance(slug, six.string_types): raise MappingError('invalid ou.slug in ou description') try: return OU.objects.get(slug=slug) except OU.DoesNotExist: raise MappingError('unknown ou', details={'slug': slug}) elif name: if not isinstance(name, six.string_types): raise MappingError('invalid ou.slug in ou description') try: return OU.objects.get(name=name) except OU.DoesNotExist: raise MappingError('unknown ou', details={'name': name}) else: raise MappingError('invalid ou description') def get_role(self, mapping): role_desc = mapping.get('role') if not role_desc or not isinstance(role_desc, dict): raise MappingError('missing or invalid role description') slug = role_desc.get('slug') name = role_desc.get('name') ou = self.get_ou(role_desc) kwargs = {} if ou: kwargs['ou'] = ou if slug: if not isinstance(slug, six.string_types): raise MappingError('invalid role slug', details={'slug': slug}) kwargs['slug'] = slug elif name: if not isinstance(name, six.string_types): raise MappingError('invalid role name', details={'name': name}) kwargs['name'] = name else: raise MappingError('invalid role description') try: return Role.objects.get(**kwargs) except Role.DoesNotExist: raise MappingError('unknown role', details=kwargs) except MultipleObjectsReturned: raise MappingError('ambiuous role description', details=kwargs) def evaluate_condition(self, user, saml_attributes, mapping): condition = mapping.get('condition') if condition is None: return True if not isinstance(condition, six.string_types): raise MappingError('invalid condition') try: # use a proxy to simplify condition expressions as subscript is forbidden # you can write "email == 'a@b.com'" but also "'a@b.com' in email__list" value = evaluate_condition(condition, SamlConditionContextProxy(saml_attributes)) logger.debug('auth_saml: condition %r is %s', condition, value, extra={'user': user}) return value except Exception as e: raise MappingError('condition evaluation failed', details={'error': six.text_type(e)}) def action_add_role(self, user, idp, saml_attributes, mapping): role = self.get_role(mapping) if self.evaluate_condition(user, saml_attributes, mapping): if role not in user.roles.all(): logger.info('auth_saml: adding role "%s"', role, extra={'user': user}) user.roles.add(role) else: if role in user.roles.all(): logger.info('auth_saml: removing role "%s"', role, extra={'user': user}) user.roles.remove(role) def action_toggle_role(self, *args, **kwargs): return self.action_add_role(*args, **kwargs) def auth_login(self, request, user): utils.login(request, user, 'saml') def get_users_queryset(self, idp, saml_attributes): return get_user_queryset()