diff --git a/README.md b/README.md index 894bbfa..ab2d00c 100644 --- a/README.md +++ b/README.md @@ -23,23 +23,45 @@ ln -sr bam PROJECT_DIRECTORY/bam 4. Add `bam` to `INSTALLED_APPS` in the project's `settings.py`. - 5. Append `path('', include('bam.urls')),` to the project's `urls.py`. + 5. Set `bam.hashers.LDAPPBKDF2PasswordHasher` as the first item of the + `PASSWORD_HASHERS` array in the project's `settings.py`. - 6. Initialize database: + 6. Insert the settings `BAM_LDAP_URI`, `BAM_LDAP_BIND_DN`, `BAM_LDAP_SECRET` + and `BAM_LDAP_BASE_DN_MAP` according to your setup into the project's + `settings.py`. The base DN map has to be a dict from base DNs to arrays of + Django groups. + + Example: + + ``` + BAM_LDAP_BASE_DN_MAP = { + 'ou=test,dc=blinkenbunt,dc=org': [ + 'jabber', + 'git', + ], + 'ou=admins,dc=blinkenbunt,dc=org': [ + 'admin', + ], + } +``` + + 7. Append `path('', include('bam.urls')),` to the project's `urls.py`. + + 8. Initialize database: ``` ./manage.py migrate ``` - 7. Create a superuser account: + 9. Create a superuser account: ``` ./manage.py createsuperuser ``` - 8. Download _Pure.css_ and extract it to `bam/static/pure.css/`. + 10. Download _Pure.css_ and extract it to `bam/static/pure.css/`. - 9. Start the development server: + 11. Start the development server: ``` ./manage.py runserver diff --git a/bam/hashers.py b/bam/hashers.py new file mode 100644 index 0000000..618623a --- /dev/null +++ b/bam/hashers.py @@ -0,0 +1,6 @@ +from django.contrib.auth.hashers import PBKDF2PasswordHasher +from django.utils.crypto import get_random_string + +class LDAPPBKDF2PasswordHasher(PBKDF2PasswordHasher): + def salt(self): + return get_random_string(16) diff --git a/bam/ldap_sync.py b/bam/ldap_sync.py new file mode 100644 index 0000000..9671aee --- /dev/null +++ b/bam/ldap_sync.py @@ -0,0 +1,152 @@ +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' + + 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 diff --git a/bam/management/commands/sync_users.py b/bam/management/commands/sync_users.py new file mode 100644 index 0000000..736b318 --- /dev/null +++ b/bam/management/commands/sync_users.py @@ -0,0 +1,37 @@ +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth import get_user_model +from django.conf import settings +from ...ldap_sync import make_ldap_conn, LDAPAction, LDAPUserSyncer + +User = get_user_model() + +class Command(BaseCommand): + def handle(self, *args, **kwargs): + uri = settings.BAM_LDAP_URI + bind_dn = settings.BAM_LDAP_BIND_DN + secret = settings.BAM_LDAP_SECRET + base_dn_map = settings.BAM_LDAP_BASE_DN_MAP + ldap_conn = make_ldap_conn(uri, bind_dn, secret) + + syncer = LDAPUserSyncer(ldap_conn, base_dn_map) + for user in User.objects.all(): + self.stdout.write('Syncing user "%s"...' % user.username) + actions = syncer.sync_user(user) + something_done = False + for base_dn, act in zip(base_dn_map.keys(), actions): + if act == LDAPAction.USER_ADDED: + self.stdout.write(self.style.SUCCESS( + '... added them to "%s"' % base_dn)) + something_done = True + elif act == LDAPAction.USER_UPDATED: + self.stdout.write(self.style.WARNING( + '... updated their entry in "%s"' % base_dn + )) + something_done = True + elif act == LDAPAction.USER_DELETED: + self.stdout.write(self.style.ERROR( + '... deleted their entry from "%s"' % base_dn + )) + something_done = True + if not something_done: + self.stdout.write('... nothing to do')