From 609de6f56b4fbb3a815449a2c058d5ea8a3379fe Mon Sep 17 00:00:00 2001 From: Benjamin Renard Date: Mon, 11 Dec 2017 19:56:26 +0100 Subject: [PATCH 1/2] Manage LDAP extra attributes (#19365) This extra attributes are retreived by making other LDAP queries with parameters composed by looping on an user object's attribute values. A mapping will be apply on corresponding objects's attributes and resulting informations are compiled in a list and serialize in JSON (configurable, but JSON is the only format available for now) --- src/authentic2/backends/ldap_backend.py | 74 +++++++++++++++++++++++++++ tests/test_ldap.py | 91 ++++++++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py index a7186bf..0b0b87f 100644 --- a/src/authentic2/backends/ldap_backend.py +++ b/src/authentic2/backends/ldap_backend.py @@ -14,6 +14,7 @@ import base64 import urllib import six import os +import json # code originaly copied from by now merely inspired by # http://www.amherst.k12.oh.us/django-ldap.html @@ -256,6 +257,8 @@ class LDAPBackend(object): 'mandatory_attributes_values': {}, # mapping from LDAP attributes name to other names 'attribute_mappings': [], + # extra attributes retrieve by making other LDAP search using user object informations + 'extra_attributes': {}, # realm for selecting an ldap configuration or formatting usernames 'realm': 'ldap', # template for building username @@ -733,6 +736,13 @@ class LDAPBackend(object): external_id_tuple)) for from_at, to_at in block['attribute_mappings']: attributes.add(to_at) + for extra_at in block.get('extra_attributes', {}): + if 'loop_over_attribute' in block['extra_attributes'][extra_at]: + attributes.add(block['extra_attributes'][extra_at]['loop_over_attribute']) + at_mapping = block['extra_attributes'][extra_at].get('mapping', {}) + for key in at_mapping: + if at_mapping[key] != 'dn': + attributes.add(at_mapping[key]) return list(set(map(str.lower, map(str, attributes)))) @classmethod @@ -740,6 +750,7 @@ class LDAPBackend(object): '''Retrieve some attributes from LDAP, add mandatory values then apply defined mappings between atrribute names''' attributes = cls.get_ldap_attributes_names(block) + log.debug(u'Attrs names : %s' % attributes) attribute_mappings = block['attribute_mappings'] mandatory_attributes_values = block['mandatory_attributes_values'] try: @@ -763,7 +774,69 @@ class LDAPBackend(object): old = attribute_map.setdefault(to_attribute, []) new = set(old) | set(attribute_map[from_attribute]) attribute_map[to_attribute] = list(new) + attribute_map['dn'] = dn + + # extra attributes + attribute_map = cls.get_ldap_extra_attributes(block, conn, dn, attribute_map) + + return attribute_map + + @classmethod + def get_ldap_extra_attributes(cls, block, conn, dn, attribute_map): + '''Retrieve extra attributes from LDAP''' + + ldap_scopes = { + 'base': ldap.SCOPE_BASE, + 'one': ldap.SCOPE_ONELEVEL, + 'sub': ldap.SCOPE_SUBTREE, + } + log.debug(u'Attrs before extra attributes : %s' % attribute_map) + for extra_attribute_name in block.get('extra_attributes', {}): + extra_attribute_config = block['extra_attributes'][extra_attribute_name] + extra_attribute_values = [] + if 'loop_over_attribute' in extra_attribute_config: + extra_attribute_config['loop_over_attribute'] = extra_attribute_config['loop_over_attribute'].lower() + if extra_attribute_config['loop_over_attribute'] not in attribute_map: + log.debug('loop_over_attribute %s not present (or empty) in user object attributes retreived. Pass.' % extra_attribute_config['loop_over_attribute']) + continue + if 'filter' not in extra_attribute_config and 'basedn' not in extra_attribute_config: + log.warning('Extra attribute %s not correctly configured : you need to defined at least one of filter or basedn parameters' % extra_attribute_name) + for item in attribute_map[extra_attribute_config['loop_over_attribute']]: + ldap_filter = unicode(extra_attribute_config.get('filter', 'objectClass=*')).format(item=item, **attribute_map) + ldap_basedn = unicode(extra_attribute_config.get('basedn', block.get('basedn'))).format(item=item, **attribute_map) + ldap_scope = ldap_scopes.get(extra_attribute_config.get('scope', 'sub'), ldap.SCOPE_SUBTREE) + ldap_attributes_mapping = extra_attribute_config.get('mapping', {}) + ldap_attributes_names = filter(lambda a: a != 'dn', ldap_attributes_mapping.values()) + try: + results = conn.search_s(ldap_basedn, ldap_scope, ldap_filter, ldap_attributes_names) + except ldap.LDAPError: + log.exception('unable to retrieve extra attribute %s for item %s' % (extra_attribute_name, item)) + continue + item_value = {} + for obj in results: + log.debug(u'Object retrieved for extra attr %s with item %s : %s' % (extra_attribute_name, item, obj)) + obj_attributes = cls.normalize_ldap_results(obj[1]) + obj_attributes[dn] = obj[0] + log.debug(u'Object attributes normalized for extra attr %s with item %s : %s' % (extra_attribute_name, item, obj_attributes)) + for key in ldap_attributes_mapping: + item_value[key] = obj_attributes.get(ldap_attributes_mapping[key].lower()) + log.debug(u'Object attribute %s value retrieved for extra attr %s with item %s : %s' % (ldap_attributes_mapping[key], extra_attribute_name, item, item_value[key])) + if not item_value[key]: + del item_value[key] + elif len(item_value[key]) == 1: + item_value[key] = item_value[key][0] + extra_attribute_values.append(item_value) + else: + log.warning('loop_over_attribute not defined for extra attribute %s' % extra_attribute_name) + extra_attribute_serialization = extra_attribute_config.get('serialization', None) + if extra_attribute_serialization is None: + attribute_map[extra_attribute_name] = extra_attribute_values + elif extra_attribute_serialization == 'json': + attribute_map[extra_attribute_name] = json.dumps(extra_attribute_values) + else: + log.warning('Invalid serialization type "%s" for extra attribute %s' % (extra_attribute_serialization, extra_attribute_name)) + return attribute_map @classmethod @@ -906,6 +979,7 @@ class LDAPBackend(object): for block in cls.get_config(): names.update(cls.get_ldap_attributes_names(block)) names.update(block['mandatory_attributes_values'].keys()) + names.update(block.get('extra_attributes', {}).keys()) return [(a, '%s (LDAP)' % a) for a in sorted(names)] @classmethod diff --git a/tests/test_ldap.py b/tests/test_ldap.py index 5a22dba..b0e6a14 100644 --- a/tests/test_ldap.py +++ b/tests/test_ldap.py @@ -17,6 +17,7 @@ from authentic2.backends import ldap_backend from authentic2 import crypto import utils +import json pytestmark = pytest.mark.skipunless(has_slapd(), reason='slapd is not installed') @@ -26,6 +27,17 @@ CN = 'Étienne Michu' DN = 'cn=%s,o=orga' % escape_dn_chars(CN) PASS = 'passé' EMAIL = 'etienne.michu@example.net' +CARLICENSE = '123445ABC' + +EO_O = "EO" +EO_STREET = "169 rue du Chateau" +EO_POSTALCODE = "75014" +EO_CITY = "PARIS" + +EE_O = "EE" +EE_STREET = "44 rue de l'Ouest" +EE_POSTALCODE = "75014" +EE_CITY = "PARIS" @pytest.fixture @@ -39,12 +51,33 @@ cn: Étienne Michu sn: Michu gn: Étienne mail: etienne.michu@example.net +carLicense: {cl} +o: EO +o: EE dn: cn=group1,o=orga objectClass: groupOfNames member: {dn} -'''.format(dn=DN, uid=UID, password=PASS)) +dn: o={eo_o},o=orga +objectClass: organization +o: {eo_o} +postalAddress: {eo_street} +postalCode: {eo_postalcode} +l: {eo_city} + +dn: o={ee_o},o=orga +objectClass: organization +o: {ee_o} +postalAddress: {ee_street} +postalCode: {ee_postalcode} +l: {ee_city} + +'''.format(dn=DN, uid=UID, password=PASS, cl=CARLICENSE, + eo_o=EO_O, eo_street=EO_STREET, eo_postalcode=EO_POSTALCODE, eo_city=EO_CITY, + ee_o=EE_O, ee_street=EE_STREET, ee_postalcode=EE_POSTALCODE, ee_city=EE_CITY + ) + ) for i in range(100): slapd.add_ldif('''dn: uid=michu{i},o=orga objectClass: inetOrgPerson @@ -351,6 +384,62 @@ def test_group_staff(slapd, settings, client): @pytest.mark.django_db +def test_get_attributes(slapd, settings, client): + settings.LDAP_AUTH_SETTINGS = [{ + 'url': [slapd.ldap_url], + 'basedn': 'o=orga', + 'use_tls': False, + 'groupstaff': ['cn=group1,o=orga'], + 'attributes': ['uid', 'carLicense'], + }] + response = client.post('/login/', {'login-password-submit': '1', + 'username': 'etienne.michu', + 'password': PASS}, follow=True) + user = response.context['user'] + fetched_attrs = user.get_attributes() + assert UID in fetched_attrs.get('uid') + assert CARLICENSE in fetched_attrs.get('carlicense') + + +@pytest.mark.django_db +def test_get_extra_attributes(slapd, settings, client): + settings.LDAP_AUTH_SETTINGS = [{ + 'url': [slapd.ldap_url], + 'basedn': 'o=orga', + 'use_tls': False, + 'groupstaff': ['cn=group1,o=orga'], + 'attributes': ['uid'], + 'extra_attributes': { + 'orga': { + 'loop_over_attribute': 'o', + 'filter': '(&(objectclass=organization)(o={item}))', + 'basedn': 'o=orga', + 'scope': 'sub', + 'mapping': { + 'id': 'o', + 'street': 'postalAddress', + 'city': 'l', + 'postal_code': 'postalCode', + }, + 'serialization': 'json' + } + }, + }] + response = client.post('/login/', {'login-password-submit': '1', + 'username': 'etienne.michu', + 'password': PASS}, follow=True) + user = response.context['user'] + fetched_attrs = user.get_attributes() + assert UID in fetched_attrs.get('uid') + assert 'orga' in fetched_attrs + orgas = json.loads(fetched_attrs.get('orga')) + assert isinstance(orgas, list) + assert len(orgas) == 2 + assert {'id': EO_O, 'street': EO_STREET, 'city': EO_CITY, 'postal_code': EO_POSTALCODE} in orgas + assert {'id': EE_O, 'street': EE_STREET, 'city': EE_CITY, 'postal_code': EE_POSTALCODE} in orgas + + +@pytest.mark.django_db def test_get_users(slapd, settings): import django.db.models.base from types import MethodType -- 2.1.4