Browse Source

Add user synchronization to LDAP

master
klonfish 5 years ago
parent
commit
8c0f78cb26
  1. 32
      README.md
  2. 6
      bam/hashers.py
  3. 152
      bam/ldap_sync.py
  4. 37
      bam/management/commands/sync_users.py

32
README.md

@ -23,23 +23,45 @@ ln -sr bam PROJECT_DIRECTORY/bam @@ -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

6
bam/hashers.py

@ -0,0 +1,6 @@ @@ -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)

152
bam/ldap_sync.py

@ -0,0 +1,152 @@ @@ -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

37
bam/management/commands/sync_users.py

@ -0,0 +1,37 @@ @@ -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')
Loading…
Cancel
Save