ldap: ignore case of group distinguished names (#50908)

This commit is contained in:
Benjamin Dauvergne 2021-02-05 15:58:09 +01:00
parent 2a5f5c3ef3
commit 19a8dfc2bd
2 changed files with 144 additions and 57 deletions

View File

@ -507,7 +507,9 @@ class LDAPBackend(object):
_REQUIRED = ('url', 'basedn')
_TO_ITERABLE = ('url', 'groupsu', 'groupstaff', 'groupactive')
_TO_LOWERCASE = ('fname_field', 'lname_field', 'email_field', 'attributes',
'mandatory_attributes_values')
'mandatory_attributes_values', 'member_of_attribute',
'group_to_role_mapping', 'group_mapping',
'attribute_mappings', 'external_id_tuples')
_VALID_CONFIG_KEYS = list(set(_REQUIRED).union(set(_DEFAULTS)))
@classmethod
@ -592,6 +594,7 @@ class LDAPBackend(object):
log.debug('[%s] looking up dn for username %r using query %r', ldap_uri,
username, query)
results = conn.search_s(user_basedn, ldap.SCOPE_SUBTREE, query, [u'1.1'])
results = self.normalize_ldap_results(results)
# remove search references
results = [result for result in results if result[0] is not None]
log.debug('found dns %r', results)
@ -755,19 +758,19 @@ class LDAPBackend(object):
user.save()
user._changed = False
groups = user.groups.all()
for dn, group_names in group_mapping:
for group_dn, group_names in group_mapping:
for group_name in group_names:
group = self.get_group_by_name(block, group_name)
if group is None:
continue
# Add missing groups
if dn in group_dns and group not in groups:
if group_dn in group_dns and group not in groups:
user.groups.add(group)
# Remove extra groups
elif dn not in group_dns and group in groups:
elif group_dn not in group_dns and group in groups:
user.groups.remove(group)
def populate_roles_by_mapping(self, user, dn, conn, block, role_dns):
def populate_roles_by_mapping(self, user, dn, conn, block, group_dns):
'''Assign role to user based on a mapping from group DNs'''
group_to_role_mapping = block.get('group_to_role_mapping')
if not group_to_role_mapping:
@ -776,17 +779,17 @@ class LDAPBackend(object):
user.save()
user._changed = False
roles = user.roles.all()
for dn, role_names in group_to_role_mapping:
for group_dn, role_names in group_to_role_mapping:
for role_name in role_names:
role, error = self.get_role(block, role_id=role_name)
if role is None:
log.warning('error %s: couldn\'t retrieve role %r', error, role_name)
continue
# Add missing roles
if dn in role_dns and role not in roles:
if group_dn in group_dns and role not in roles:
user.roles.add(role)
# Remove extra roles
elif dn not in role_dns and role in roles:
elif group_dn not in group_dns and role in roles:
user.roles.remove(role)
if role.can_manage_members:
log.info('role %s is now only manageable through LDAP', role)
@ -802,7 +805,7 @@ class LDAPBackend(object):
group_filter = block['group_filter']
group_dns = set()
if member_of_attribute:
group_dns.update(attributes.get(member_of_attribute, []))
group_dns.update([dn.lower() for dn in attributes.get(member_of_attribute, [])])
if group_filter:
group_filter = force_text(group_filter)
params = attributes.copy()
@ -810,11 +813,11 @@ class LDAPBackend(object):
query = FilterFormatter().format(group_filter, **params)
try:
results = conn.search_s(group_base_dn, ldap.SCOPE_SUBTREE, query, [])
results = self.normalize_ldap_results(results)
except ldap.NO_SUCH_OBJECT:
pass
else:
# ignore referrals by checking if bool(dn) is True
group_dns.update(dn for dn, attributes in results if dn)
group_dns.update(dn for dn, attrs in results)
return group_dns
def populate_user_groups(self, user, dn, conn, block, attributes):
@ -987,7 +990,9 @@ class LDAPBackend(object):
except ldap.LDAPError as e:
log.error('unable to retrieve attributes of dn %r: %r', dn, e)
return None
attribute_map = cls.normalize_ldap_results(results[0][1])
else:
results = cls.normalize_ldap_results(results)
attribute_map = results[0][1]
# add mandatory attributes
for key, mandatory_values in mandatory_attributes_values.items():
key = force_text(key)
@ -1041,15 +1046,18 @@ class LDAPBackend(object):
except ldap.LDAPError:
log.exception('unable to retrieve extra attribute %s for item %s' % (extra_attribute_name, item))
continue
else:
results = cls.normalize_ldap_results(results)
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 dn, attrs in results:
log.debug(u'Object retrieved for extra attr %s with item %s : %s %s' % (
extra_attribute_name, item, dn, attrs))
obj_attributes = attrs.copy()
obj_attributes['dn'] = dn
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]))
log.debug('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:
@ -1228,8 +1236,8 @@ class LDAPBackend(object):
msgid = conn.search_ext(*args, serverctrls=[pg_ctrl], **kwargs)
result_type, data, msgid, serverctrls = conn.result3(msgid)
pg_ctrl.cookie = serverctrls[0].cookie
for user_dn, user in data:
yield user_dn, user
for dn, attrs in cls.normalize_ldap_results(data):
yield dn, attrs
@classmethod
def get_users(cls):
@ -1242,17 +1250,13 @@ class LDAPBackend(object):
user_basedn = force_text(block.get('user_basedn') or block['basedn'])
user_filter = force_text(block['sync_ldap_users_filter'] or block['user_filter'])
user_filter = user_filter.replace('%s', '*')
attrs = cls.get_ldap_attributes_names(block)
users = cls.paged_search(conn, user_basedn, ldap.SCOPE_SUBTREE, user_filter,
attrlist=attrs)
attribute_names = cls.get_ldap_attributes_names(block)
results = cls.paged_search(conn, user_basedn, ldap.SCOPE_SUBTREE, user_filter, attrlist=attribute_names)
backend = cls()
for user_dn, data in users:
# ignore referrals
if not user_dn:
continue
data = cls.normalize_ldap_results(data)
data['dn'] = user_dn
yield backend._return_user(user_dn, None, conn, block, data)
for dn, attrs in results:
data = attrs.copy()
data['dn'] = dn
yield backend._return_user(dn, None, conn, block, data)
@classmethod
def ad_encoding(cls, s):
@ -1283,15 +1287,22 @@ class LDAPBackend(object):
log.debug('modified password for dn %r', dn)
@classmethod
def normalize_ldap_results(cls, attributes, encoding='utf-8'):
new_attributes = {}
for key in attributes:
try:
new_attributes[key.lower()] = [force_text(value, encoding) for value in attributes[key]]
except UnicodeDecodeError:
log.debug('unable to decode attribute %r as UTF-8, converting to base64', key)
new_attributes[key.lower()] = [base64.b64encode(value).decode('ascii') for value in attributes[key]]
return new_attributes
def normalize_ldap_results(cls, results, encoding='utf-8'):
new_results = []
for dn, attrs in results:
# ignore referrals
if not dn:
continue
new_attrs = {}
for key in attrs:
try:
new_attrs[key.lower()] = [force_text(value, encoding) for value in attrs[key]]
except UnicodeDecodeError:
log.debug('unable to decode attribute %r as UTF-8, converting to base64', key)
new_attrs[key.lower()] = [base64.b64encode(value).decode('ascii') for value in attrs[key]]
new_results.append((dn.lower(), new_attrs))
return new_results
@classmethod
def get_connections(cls, block, credentials=()):
@ -1436,10 +1447,6 @@ class LDAPBackend(object):
if isinstance(block[d], (list, tuple, dict)):
block[d] = map_text(block[d])
# lowercase LDAP attribute names
block['external_id_tuples'] = map_text([[t.lower() for t in id_tuple]
for id_tuple in block['external_id_tuples']])
block['attribute_mappings'] = map_text([[t.lower() for t in at_mapping]
for at_mapping in block['attribute_mappings']])
assert block['external_id_tuples'] is not None
for key in cls._TO_LOWERCASE:
# we handle strings, list of strings and list of list or tuple whose first element is a
@ -1449,14 +1456,16 @@ class LDAPBackend(object):
elif isinstance(block[key], (list, tuple)):
new_seq = []
for elt in block[key]:
if isinstance(elt, six.string_types):
elt = force_text(elt).lower()
if isinstance(elt, str):
new_seq.append(elt.lower())
elif isinstance(elt, (list, tuple)):
elt = list(elt)
elt[0] = force_text(elt[0]).lower()
elt = tuple(elt)
new_seq.append(elt)
block[key] = tuple(new_seq)
new_elt = []
for subelt in elt:
if isinstance(subelt, str):
subelt = subelt.lower()
new_elt.append(subelt)
new_seq.append(new_elt)
block[key] = new_seq
elif isinstance(block[key], dict):
newdict = {}
for subkey in block[key]:
@ -1466,6 +1475,17 @@ class LDAPBackend(object):
raise NotImplementedError(
'LDAP setting %r cannot be converted to lowercase setting, its type is %r'
% (key, type(block[key])))
# special case user_attributes
user_attributes = []
for mapping in block['user_attributes']:
if 'from_ldap' not in mapping or 'to_user' not in mapping:
continue
from_ldap = mapping['from_ldap']
if not isinstance(from_ldap, str):
continue
from_ldap = from_ldap.lower()
user_attributes.append({'from_ldap': from_ldap, 'to_user': mapping['to_user']})
block['user_attributes'] = user_attributes
# Want to randomize our access, otherwise what's the point of having multiple servers?
block['url'] = list(block['url'])
if block['shuffle_replicas']:
@ -1497,6 +1517,7 @@ class LDAPBackendPasswordLost(LDAPBackend):
ldap_filter = self.external_id_to_filter(external_id, external_id_tuple)
results = conn.search_s(block['basedn'],
ldap.SCOPE_SUBTREE, ldap_filter)
results = self.normalize_ldap_results(results)
if not results:
log.warning(
u'unable to find user %r based on external id %s',

View File

@ -105,9 +105,13 @@ jpegPhoto:: ACOE
carLicense: {cl}
o: EO
o: EE
# memberOf is not defined on OpenLDAP so we use street for storing DN like
# memberOf values
strEET: cn=group2,o=ôrga
dn: cn=group1,o=ôrga
dn: cn=GRoup1,o=ôrga
objectClass: groupOfNames
cn: GrOuP1
member: {dn}
dn: o={eo_o},o=ôrga
@ -344,19 +348,25 @@ def test_posix_group_mapping(slapd, settings, client, db):
def test_group_to_role_mapping(slapd, settings, client, db):
Role.objects.get_or_create(name='Role1')
Role.objects.get_or_create(name='Role2')
settings.LDAP_AUTH_SETTINGS = [{
'url': [slapd.ldap_url],
'basedn': u'o=ôrga',
'use_tls': False,
# memberOf is not defined on OpenLDAP so we use street for storing DN like
# memberOf values
'member_of_attribute': 'STReet',
'group_to_role_mapping': [
['cn=group1,o=ôrga', ['Role1']],
['cn=GrouP1,o=ôrga', ['Role1']],
['cn=GrouP2,o=ôrga', ['Role2']],
],
}]
response = client.post('/login/', {'login-password-submit': '1',
'username': USERNAME,
'password': PASS}, follow=True)
assert response.context['user'].username == u'%s@ldap' % USERNAME
assert response.context['user'].roles.count() == 1
assert set(response.context['user'].roles.values_list('name', flat=True)) == set(['Role1', 'Role2'])
def test_posix_group_to_role_mapping(slapd, settings, client, db):
@ -986,8 +996,8 @@ def test_get_attributes(slapd, settings, db, rf):
user = authenticate(username=USERNAME, password=UPASS)
assert user
assert user.get_attributes(object(), {}) == {
'dn': u'cn=\xc9tienne Michu,o=\xf4rga',
'givenname': [u'\xc9tienne'],
'dn': 'cn=étienne michu,o=\xf4rga',
'givenname': [u'Étienne'],
'mail': [u'etienne.michu@example.net'],
'sn': [u'Michu'],
'uid': [u'etienne.michu'],
@ -996,7 +1006,7 @@ def test_get_attributes(slapd, settings, db, rf):
# simulate LDAP down
slapd.stop()
assert user.get_attributes(object(), {}) == {
'dn': u'cn=\xc9tienne Michu,o=\xf4rga',
'dn': u'cn=étienne michu,o=\xf4rga',
'givenname': [u'\xc9tienne'],
'mail': [u'etienne.michu@example.net'],
'sn': [u'Michu'],
@ -1012,7 +1022,7 @@ def test_get_attributes(slapd, settings, db, rf):
ldif = [(ldap.MOD_REPLACE, 'sn', [b'Micho'])]
conn.modify_s(DN, ldif)
assert user.get_attributes(object(), {}) == {
'dn': u'cn=\xc9tienne Michu,o=\xf4rga',
'dn': u'cn=étienne michu,o=\xf4rga',
'givenname': [u'\xc9tienne'],
'mail': [u'etienne.michu@example.net'],
'sn': [u'Micho'],
@ -1057,3 +1067,59 @@ def test_get_extra_attributes(slapd, settings, client):
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
def test_config_to_lowercase():
config = {
'fname_field': 'givenName',
'lname_field': 'surName',
'email_field': 'EMAIL',
'attributes': ['ZoB', 'CoiN'],
'mandatory_attributes_values': {
'XXX': ['A'],
},
'member_of_attribute': 'memberOf',
'group_mapping': [
['CN=coin,OU=Groups,DC=coin,DC=Fr', ['Group 1']],
],
'group_to_role_mapping': [
['CN=coin,OU=Groups,DC=coin,DC=Fr', ['Group 1']],
],
'attribute_mappings': [
['XXX', 'YYY'],
],
'external_id_tuples': [['A', 'B', 'C']],
'user_attributes': [
{
'from_ldap': 'ABC',
'to_user': 'Phone',
}
]
}
config_normalized = dict(config, url='ldap://example.net', basedn='dc=coin,dc=fr')
ldap_backend.LDAPBackend.update_default(config_normalized)
# only keep keys we are interested in
for key in list(config_normalized):
if key not in config:
del config_normalized[key]
assert config_normalized == {
"fname_field": "givenname",
"lname_field": "surname",
"email_field": "email",
"attributes": ["zob", "coin"],
"mandatory_attributes_values": {"xxx": ["A"]},
"member_of_attribute": "memberof",
"group_mapping": [["cn=coin,ou=groups,dc=coin,dc=fr", ["Group 1"]]],
"group_to_role_mapping": [["cn=coin,ou=groups,dc=coin,dc=fr", ["Group 1"]]],
"attribute_mappings": [["xxx", "yyy"],],
"external_id_tuples": [["a", "b", "c"],],
'user_attributes': [
{
'from_ldap': 'abc',
'to_user': 'Phone',
}
]
}