Projet

Général

Profil

0001-Manage-LDAP-extra-attributes-19365.patch

Benjamin Dauvergne, 03 juillet 2020 00:17

Télécharger (11,2 ko)

Voir les différences:

Subject: [PATCH] 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 | 71 +++++++++++++++++++++++
 tests/test_ldap.py                      | 76 ++++++++++++++++++++++++-
 2 files changed, 146 insertions(+), 1 deletion(-)
src/authentic2/backends/ldap_backend.py
33 33
import random
34 34
import base64
35 35
import os
36
import json
36 37

  
37 38
# code originaly copied from by now merely inspired by
38 39
# http://www.amherst.k12.oh.us/django-ldap.html
......
449 450
        'mandatory_attributes_values': {},
450 451
        # mapping from LDAP attributes name to other names
451 452
        'attribute_mappings': [],
453
        # extra attributes retrieve by making other LDAP search using user object informations
454
        'extra_attributes': {},
452 455
        # realm for selecting an ldap configuration or formatting usernames
453 456
        'realm': 'ldap',
454 457
        # template for building username
......
961 964
            from_ldap = mapping.get('from_ldap')
962 965
            if from_ldap:
963 966
                attributes.add(from_ldap)
967
        for extra_at in block.get('extra_attributes', {}):
968
            if 'loop_over_attribute' in block['extra_attributes'][extra_at]:
969
                attributes.add(block['extra_attributes'][extra_at]['loop_over_attribute'])
970
            at_mapping = block['extra_attributes'][extra_at].get('mapping', {})
971
            for key in at_mapping:
972
                if at_mapping[key] != 'dn':
973
                    attributes.add(at_mapping[key])
964 974
        return list(set(attribute.lower() for attribute in attributes))
965 975

  
966 976
    @classmethod
......
992 1002
            new = set(old) | set(attribute_map[from_attribute])
993 1003
            attribute_map[to_attribute] = list(new)
994 1004
        attribute_map['dn'] = force_text(dn)
1005

  
1006
        # extra attributes
1007
        attribute_map = cls.get_ldap_extra_attributes(block, conn, dn, attribute_map)
1008

  
1009
        return attribute_map
1010

  
1011
    @classmethod
1012
    def get_ldap_extra_attributes(cls, block, conn, dn, attribute_map):
1013
        '''Retrieve extra attributes from LDAP'''
1014

  
1015
        ldap_scopes = {
1016
            'base': ldap.SCOPE_BASE,
1017
            'one': ldap.SCOPE_ONELEVEL,
1018
            'sub': ldap.SCOPE_SUBTREE,
1019
        }
1020
        log.debug('Attrs before extra attributes : %s', attribute_map)
1021
        for extra_attribute_name in block.get('extra_attributes', {}):
1022
            extra_attribute_config = block['extra_attributes'][extra_attribute_name]
1023
            extra_attribute_values = []
1024
            if 'loop_over_attribute' in extra_attribute_config:
1025
                extra_attribute_config['loop_over_attribute'] = extra_attribute_config['loop_over_attribute'].lower()
1026
                if extra_attribute_config['loop_over_attribute'] not in attribute_map:
1027
                    log.debug('loop_over_attribute %s not present (or empty) in user object attributes retreived. Pass.' % extra_attribute_config['loop_over_attribute'])
1028
                    continue
1029
                if 'filter' not in extra_attribute_config and 'basedn' not in extra_attribute_config:
1030
                    log.warning('Extra attribute %s not correctly configured : you need to defined at least one of filter or basedn parameters' % extra_attribute_name)
1031
                for item in attribute_map[extra_attribute_config['loop_over_attribute']]:
1032
                    ldap_filter = extra_attribute_config.get('filter', 'objectClass=*').format(item=item, **attribute_map)
1033
                    ldap_basedn = extra_attribute_config.get('basedn', block.get('basedn')).format(item=item, **attribute_map)
1034
                    ldap_scope = ldap_scopes.get(extra_attribute_config.get('scope', 'sub'), ldap.SCOPE_SUBTREE)
1035
                    ldap_attributes_mapping = extra_attribute_config.get('mapping', {})
1036
                    ldap_attributes_names = list(filter(lambda a: a != 'dn', ldap_attributes_mapping.values()))
1037
                    try:
1038
                        results = conn.search_s(ldap_basedn, ldap_scope, ldap_filter, ldap_attributes_names)
1039
                    except ldap.LDAPError:
1040
                        log.exception('unable to retrieve extra attribute %s for item %s' % (extra_attribute_name, item))
1041
                        continue
1042
                    item_value = {}
1043
                    for obj in results:
1044
                        log.debug(u'Object retrieved for extra attr %s with item %s : %s' % (extra_attribute_name, item, obj))
1045
                        obj_attributes = cls.normalize_ldap_results(obj[1])
1046
                        obj_attributes[dn] = obj[0]
1047
                        log.debug(u'Object attributes normalized for extra attr %s with item %s : %s' % (extra_attribute_name, item, obj_attributes))
1048
                        for key in ldap_attributes_mapping:
1049
                            item_value[key] = obj_attributes.get(ldap_attributes_mapping[key].lower())
1050
                            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]))
1051
                            if not item_value[key]:
1052
                                del item_value[key]
1053
                            elif len(item_value[key]) == 1:
1054
                                item_value[key] = item_value[key][0]
1055
                    extra_attribute_values.append(item_value)
1056
            else:
1057
                log.warning('loop_over_attribute not defined for extra attribute %s' % extra_attribute_name)
1058
            extra_attribute_serialization = extra_attribute_config.get('serialization', None)
1059
            if extra_attribute_serialization is None:
1060
                attribute_map[extra_attribute_name] = extra_attribute_values
1061
            elif extra_attribute_serialization == 'json':
1062
                attribute_map[extra_attribute_name] = json.dumps(extra_attribute_values)
1063
            else:
1064
                log.warning('Invalid serialization type "%s" for extra attribute %s' % (extra_attribute_serialization, extra_attribute_name))
995 1065
        return attribute_map
996 1066

  
997 1067
    @classmethod
......
1141 1211
        for block in cls.get_config():
1142 1212
            names.update(cls.get_ldap_attributes_names(block))
1143 1213
            names.update(map_text(block['mandatory_attributes_values']).keys())
1214
            names.update(map_text(block['extra_attributes']).keys())
1144 1215
        return [(a, '%s (LDAP)' % a) for a in sorted(names)]
1145 1216

  
1146 1217
    @classmethod
tests/test_ldap.py
15 15
# You should have received a copy of the GNU Affero General Public License
16 16
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
17 17

  
18
import json
18 19
import os
19 20

  
20 21
import pytest
......
54 55
PASS = 'passé'
55 56
UPASS = u'passé'
56 57
EMAIL = 'etienne.michu@example.net'
58
CARLICENSE = '123445ABC'
59

  
60
EO_O = "EO"
61
EO_STREET = "169 rue du Chateau"
62
EO_POSTALCODE = "75014"
63
EO_CITY = "PARIS"
64

  
65
EE_O = "EE"
66
EE_STREET = "44 rue de l'Ouest"
67
EE_POSTALCODE = "75014"
68
EE_CITY = "PARIS"
57 69

  
58 70
base_dir = os.path.dirname(__file__)
59 71
key_file = os.path.join(base_dir, 'key.pem')
......
90 102
l: Paris
91 103
mail: etienne.michu@example.net
92 104
jpegPhoto:: ACOE
105
carLicense: {cl}
106
o: EO
107
o: EE
93 108

  
94 109
dn: cn=group1,o=ôrga
95 110
objectClass: groupOfNames
96 111
member: {dn}
97 112

  
98
'''.format(dn=DN, uid=UID, password=PASS))
113
dn: o={eo_o},o=ôrga
114
objectClass: organization
115
o: {eo_o}
116
postalAddress: {eo_street}
117
postalCode: {eo_postalcode}
118
l: {eo_city}
119

  
120
dn: o={ee_o},o=ôrga
121
objectClass: organization
122
o: {ee_o}
123
postalAddress: {ee_street}
124
postalCode: {ee_postalcode}
125
l: {ee_city}
126

  
127
'''.format(dn=DN, uid=UID, password=PASS, cl=CARLICENSE,
128
           eo_o=EO_O, eo_street=EO_STREET, eo_postalcode=EO_POSTALCODE, eo_city=EO_CITY,
129
           ee_o=EE_O, ee_street=EE_STREET, ee_postalcode=EE_POSTALCODE, ee_city=EE_CITY
130
    ))
99 131
    for i in range(5):
100 132
        slapd.add_ldif('''dn: uid=mïchu{i},o=ôrga
101 133
objectClass: inetOrgPerson
......
949 981
        'url': [slapd.ldap_url],
950 982
        'basedn': u'o=ôrga',
951 983
        'use_tls': False,
984
        'attributes': ['uid', 'carLicense'],
952 985
    }]
953 986
    user = authenticate(username=USERNAME, password=UPASS)
954 987
    assert user
......
958 991
        'mail': [u'etienne.michu@example.net'],
959 992
        'sn': [u'Michu'],
960 993
        'uid': [u'etienne.michu'],
994
        'carlicense': ['123445ABC'],
961 995
    }
962 996
    # simulate LDAP down
963 997
    slapd.stop()
......
967 1001
        'mail': [u'etienne.michu@example.net'],
968 1002
        'sn': [u'Michu'],
969 1003
        'uid': [u'etienne.michu'],
1004
        'carlicense': ['123445ABC'],
970 1005
    }
971 1006
    assert not user.check_password(UPASS)
972 1007
    # simulate LDAP come back up
......
982 1017
        'mail': [u'etienne.michu@example.net'],
983 1018
        'sn': [u'Micho'],
984 1019
        'uid': [u'etienne.michu'],
1020
        'carlicense': ['123445ABC'],
985 1021
    }
1022

  
1023

  
1024
@pytest.mark.django_db
1025
def test_get_extra_attributes(slapd, settings, client):
1026
    settings.LDAP_AUTH_SETTINGS = [{
1027
        'url': [slapd.ldap_url],
1028
        'basedn': 'o=ôrga',
1029
        'use_tls': False,
1030
        'groupstaff': ['cn=group1,o=ôrga'],
1031
        'attributes': ['uid'],
1032
        'extra_attributes': {
1033
            'orga': {
1034
                'loop_over_attribute': 'o',
1035
                'filter': '(&(objectclass=organization)(o={item}))',
1036
                'basedn': 'o=ôrga',
1037
                'scope': 'sub',
1038
                'mapping': {
1039
                    'id': 'o',
1040
                    'street': 'postalAddress',
1041
                    'city': 'l',
1042
                    'postal_code': 'postalCode',
1043
                },
1044
                'serialization': 'json'
1045
            }
1046
        },
1047
    }]
1048
    response = client.post('/login/', {'login-password-submit': '1',
1049
                                       'username': 'etienne.michu',
1050
                                       'password': PASS}, follow=True)
1051
    user = response.context['user']
1052
    fetched_attrs = user.get_attributes(object(), {})
1053
    assert UID in fetched_attrs.get('uid')
1054
    assert 'orga' in fetched_attrs
1055
    orgas = json.loads(fetched_attrs.get('orga'))
1056
    assert isinstance(orgas, list)
1057
    assert len(orgas) == 2
1058
    assert {'id': EO_O, 'street': EO_STREET, 'city': EO_CITY, 'postal_code': EO_POSTALCODE} in orgas
1059
    assert {'id': EE_O, 'street': EE_STREET, 'city': EE_CITY, 'postal_code': EE_POSTALCODE} in orgas
986
-