import enum import base64 import ldap import ldap.modlist import ldap.filter class LDAPAction(enum.Enum): USER_UNCHANGED = 0 USER_ADDED = 1 USER_UPDATED = 2 USER_DELETED = 3 def b64_to_ab64(b64): return b64.replace('+', '.').replace('=', '') def transform_password(pw): parts = pw.split('$') raw_scheme = parts[0] if raw_scheme == 'pbkdf2': scheme = '{PBKDF2}' elif raw_scheme.startswith('pbkdf2_sha'): sha_version = raw_scheme[10:] scheme = '{PBKDF2-SHA' + sha_version + '}' else: raise ValueError('Unsupported hashing scheme "%s"' % raw_scheme) iters = parts[1] salt = b64_to_ab64(base64.b64encode( parts[2].encode('ascii'), b'./').decode('ascii')) pw_hash = b64_to_ab64(parts[3]) return '%s%s$%s$%s' % (scheme, iters, salt, pw_hash) def make_ldap_conn(uri, bind_dn, secret): conn = ldap.initialize(uri) conn.simple_bind_s(bind_dn, secret) return conn class LDAPUserEntry(): user_object_class = 'inetOrgPerson' id_attr = 'uid' attr_map = { 'uid': 'username', 'cn': 'username', 'sn': 'last_name', 'givenName': 'first_name', 'mail': 'email', } mandatory_attr_fallbacks = { 'sn': 'username', } passw_attr = 'userPassword' @classmethod def get_mapped_fields(cls): fields = set() fields.update(cls.attr_map.values()) fields.update(cls.mandatory_attr_fallbacks.values()) fields.add(cls.passw_attr) return fields def __init__(self, user, ldap_conn, base_dn): self.user = user self.base_dn = base_dn self.dn = self.create_dn() self.conn = ldap_conn def create_dn(self): id_value = getattr(self.user, self.attr_map[self.id_attr]) id_value = ldap.dn.escape_dn_chars(id_value) return '%s=%s,%s' % (self.id_attr, id_value, self.base_dn) def create_attrs(self): cls = type(self) attrs = {} attrs['objectClass'] = cls.user_object_class for ldap_attr, model_attr in cls.attr_map.items(): value = getattr(self.user, model_attr, None) if ldap_attr in cls.mandatory_attr_fallbacks and not value: value = getattr(self.user, cls.mandatory_attr_fallbacks[ldap_attr], 'undefined') if value != '': attrs[ldap_attr] = value pw_hash = transform_password(self.user.password) attrs[cls.passw_attr] = pw_hash return {k: [v.encode('utf8')] for k, v in attrs.items()} def get(self): try: res = self.conn.search_s(self.dn, ldap.SCOPE_BASE) if len(res) == 1: return res[0][1] except ldap.NO_SUCH_OBJECT: pass def exists(self): return self.get() is not None def add(self): attrs = self.create_attrs() user_modlist = ldap.modlist.addModlist(attrs) self.conn.add_s(self.dn, user_modlist) def update(self): attrs_ldap = self.get() attrs_model = self.create_attrs() if not self.user.has_usable_password(): # Avoid overwriting the LDAP password with an unusable one pw_attr = self(type).passw_attr attrs_model[pw_attr] = attrs_ldap[pw_attr] modlist = ldap.modlist.modifyModlist(attrs_ldap, attrs_model) if len(modlist) > 0: self.conn.modify_s(self.dn, modlist) return LDAPAction.USER_UPDATED return LDAPAction.USER_UNCHANGED def add_or_update(self): if self.exists(): return self.update() else: self.add() return LDAPAction.USER_ADDED def delete(self): self.conn.delete_s(self.dn) def delete_if_present(self): if self.exists(): self.delete() return LDAPAction.USER_DELETED return LDAPAction.USER_UNCHANGED class LDAPUserSyncer(): def __init__(self, ldap_conn, base_dn_map): self.base_dn_map = base_dn_map self.conn = ldap_conn def sync_user(self, user): actions = [] for base_dn, grp_names in self.base_dn_map.items(): user_entry = LDAPUserEntry(user, self.conn, base_dn) action = LDAPAction.USER_UNCHANGED if (user.is_active and user.groups.filter(name__in=grp_names).exists()): action = user_entry.add_or_update() else: action = user_entry.delete_if_present() actions.append(action) return actions def remove_user(self, user): actions = [] for base_dn in self.base_dn_map: user_entry = LDAPUserEntry(user, self.conn, base_dn) action = user_entry.delete_if_present() actions.append(action) return actions