ldap: ignore case of group distinguished names (#50908)
This commit is contained in:
parent
2a5f5c3ef3
commit
19a8dfc2bd
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue