From 11028140d9c1757763a68dfc258d3b9e5315efa7 Mon Sep 17 00:00:00 2001 From: Hugo Levy-Falk Date: Sun, 10 Jan 2021 17:49:23 +0100 Subject: [PATCH] feat: Move LDAP to an optional app. The Entire LDAP infrastructures now relies on signals rather than direct function calls and is in its own app. This means it can be deactivated, but also that we can easily plug new services in addition to LDAP, such as OAuth. Closes issue #270 --- ldap_sync/__init__.py | 0 ldap_sync/admin.py | 64 +++ ldap_sync/apps.py | 5 + .../management/commands/ldap_rebuild.py | 6 +- .../management/commands/ldap_sync.py | 6 +- ldap_sync/migrations/0001_initial.py | 108 +++++ ldap_sync/migrations/__init__.py | 0 ldap_sync/models.py | 334 ++++++++++++++ ldap_sync/tests.py | 3 + ldap_sync/urls.py | 4 + ldap_sync/views.py | 3 + re2o/context_processors.py | 4 +- users/admin.py | 59 --- users/migrations/0004_auto_20210110_1811.py | 27 ++ users/models.py | 435 ++---------------- users/signals.py | 33 ++ 16 files changed, 622 insertions(+), 469 deletions(-) create mode 100644 ldap_sync/__init__.py create mode 100644 ldap_sync/admin.py create mode 100644 ldap_sync/apps.py rename {users => ldap_sync}/management/commands/ldap_rebuild.py (94%) rename {users => ldap_sync}/management/commands/ldap_sync.py (88%) create mode 100644 ldap_sync/migrations/0001_initial.py create mode 100644 ldap_sync/migrations/__init__.py create mode 100644 ldap_sync/models.py create mode 100644 ldap_sync/tests.py create mode 100644 ldap_sync/urls.py create mode 100644 ldap_sync/views.py create mode 100644 users/migrations/0004_auto_20210110_1811.py create mode 100644 users/signals.py diff --git a/ldap_sync/__init__.py b/ldap_sync/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ldap_sync/admin.py b/ldap_sync/admin.py new file mode 100644 index 00000000..0cba55da --- /dev/null +++ b/ldap_sync/admin.py @@ -0,0 +1,64 @@ +from django.contrib import admin + +from .models import ( + LdapUser, + LdapServiceUser, + LdapServiceUserGroup, + LdapUserGroup, +) + +class LdapUserAdmin(admin.ModelAdmin): + """LdapUser Admin view. Can't change password, manage + by User General model. + + Parameters: + Django ModelAdmin: Apply on django ModelAdmin + + """ + list_display = ("name", "uidNumber", "login_shell") + exclude = ("user_password", "sambat_nt_password") + search_fields = ("name",) + + +class LdapServiceUserAdmin(admin.ModelAdmin): + """LdapServiceUser Admin view. Can't change password, manage + by User General model. + + Parameters: + Django ModelAdmin: Apply on django ModelAdmin + + """ + + list_display = ("name",) + exclude = ("user_password",) + search_fields = ("name",) + + +class LdapUserGroupAdmin(admin.ModelAdmin): + """LdapUserGroup Admin view. + + Parameters: + Django ModelAdmin: Apply on django ModelAdmin + + """ + + list_display = ("name", "members", "gid") + search_fields = ("name",) + + +class LdapServiceUserGroupAdmin(admin.ModelAdmin): + """LdapServiceUserGroup Admin view. + + Parameters: + Django ModelAdmin: Apply on django ModelAdmin + + """ + + list_display = ("name",) + search_fields = ("name",) + + +admin.site.register(LdapUser, LdapUserAdmin) +admin.site.register(LdapUserGroup, LdapUserGroupAdmin) +admin.site.register(LdapServiceUser, LdapServiceUserAdmin) +admin.site.register(LdapServiceUserGroup, LdapServiceUserGroupAdmin) diff --git a/ldap_sync/apps.py b/ldap_sync/apps.py new file mode 100644 index 00000000..b96c34d1 --- /dev/null +++ b/ldap_sync/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class LdapSyncConfig(AppConfig): + name = 'ldap_sync' diff --git a/users/management/commands/ldap_rebuild.py b/ldap_sync/management/commands/ldap_rebuild.py similarity index 94% rename from users/management/commands/ldap_rebuild.py rename to ldap_sync/management/commands/ldap_rebuild.py index c8b172f9..1fc3c969 100644 --- a/users/management/commands/ldap_rebuild.py +++ b/ldap_sync/management/commands/ldap_rebuild.py @@ -1,4 +1,5 @@ # Copyright © 2018 Maël Kervella +# Copyright © 2021 Hugo Levy-Falk # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,6 +22,7 @@ from django.core.management.base import BaseCommand, CommandError from django.conf import settings from users.models import User, ListRight +from ldap_sync.models import synchronise_user, synchronise_serviceuser, synchronise_usergroup def split_lines(lines): @@ -89,9 +91,9 @@ def flush_ldap(binddn, bindpass, server, usersdn, groupsdn): def sync_ldap(): """Syncrhonize the whole LDAP with the DB.""" for u in User.objects.all(): - u.ldap_sync() + synchronise_user(sender=User, instance=u) for lr in ListRight.objects.all(): - lr.ldap_sync() + synchronise_usergroup(sender=ListRight, instance=lr) class Command(BaseCommand): diff --git a/users/management/commands/ldap_sync.py b/ldap_sync/management/commands/ldap_sync.py similarity index 88% rename from users/management/commands/ldap_sync.py rename to ldap_sync/management/commands/ldap_sync.py index 73f6698e..984f3fd7 100644 --- a/users/management/commands/ldap_sync.py +++ b/ldap_sync/management/commands/ldap_sync.py @@ -1,6 +1,7 @@ # Copyright © 2017 Gabriel Détraz # Copyright © 2017 Lara Kermarec # Copyright © 2017 Augustin Lemesle +# Copyright © 2020 Hugo Levy-Falk # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -19,6 +20,7 @@ from django.core.management.base import BaseCommand, CommandError from users.models import User +from ldap_sync.models import synchronise_user class Command(BaseCommand): @@ -36,5 +38,5 @@ class Command(BaseCommand): ) def handle(self, *args, **options): - for usr in User.objects.all(): - usr.ldap_sync(mac_refresh=options["full"]) + for user in User.objects.all(): + synchronise_user(sender=User, instance=user) diff --git a/ldap_sync/migrations/0001_initial.py b/ldap_sync/migrations/0001_initial.py new file mode 100644 index 00000000..5a4c448e --- /dev/null +++ b/ldap_sync/migrations/0001_initial.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2021-01-10 16:59 +from __future__ import unicode_literals + +from django.db import migrations +#from django.conf import settings +import ldapdb.models.fields + +#from ldap_sync.management.commands.ldap_rebuild import flush_ldap, sync_ldap + +#def rebuild_ldap(apps, schema_editor): +# usersdn = settings.LDAP["base_user_dn"] +# groupsdn = settings.LDAP["base_usergroup_dn"] +# binddn = settings.DATABASES["ldap"]["USER"] +# bindpass = settings.DATABASES["ldap"]["PASSWORD"] +# server = settings.DATABASES["ldap"]["NAME"] +# flush_ldap(binddn, bindpass, server, usersdn, groupsdn) + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('users', '0004_auto_20210110_1811') + ] + + operations = [ + migrations.CreateModel( + name='LdapServiceUser', + fields=[ + ('dn', ldapdb.models.fields.CharField(max_length=200, serialize=False)), + ('name', ldapdb.models.fields.CharField(db_column='cn', max_length=200, primary_key=True, serialize=False)), + ('user_password', ldapdb.models.fields.CharField(blank=True, db_column='userPassword', max_length=200, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LdapServiceUserGroup', + fields=[ + ('dn', ldapdb.models.fields.CharField(max_length=200, serialize=False)), + ('name', ldapdb.models.fields.CharField(db_column='cn', max_length=200, primary_key=True, serialize=False)), + ('members', ldapdb.models.fields.ListField(blank=True, db_column='member')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LdapUser', + fields=[ + ('dn', ldapdb.models.fields.CharField(max_length=200, serialize=False)), + ('gid', ldapdb.models.fields.IntegerField(db_column='gidNumber')), + ('name', ldapdb.models.fields.CharField(db_column='cn', max_length=200, primary_key=True, serialize=False)), + ('uid', ldapdb.models.fields.CharField(db_column='uid', max_length=200)), + ('uidNumber', ldapdb.models.fields.IntegerField(db_column='uidNumber', unique=True)), + ('sn', ldapdb.models.fields.CharField(db_column='sn', max_length=200)), + ('login_shell', ldapdb.models.fields.CharField(blank=True, db_column='loginShell', max_length=200, null=True)), + ('mail', ldapdb.models.fields.CharField(db_column='mail', max_length=200)), + ('given_name', ldapdb.models.fields.CharField(db_column='givenName', max_length=200)), + ('home_directory', ldapdb.models.fields.CharField(db_column='homeDirectory', max_length=200)), + ('display_name', ldapdb.models.fields.CharField(blank=True, db_column='displayName', max_length=200, null=True)), + ('dialupAccess', ldapdb.models.fields.CharField(db_column='dialupAccess', max_length=200)), + ('sambaSID', ldapdb.models.fields.IntegerField(db_column='sambaSID', unique=True)), + ('user_password', ldapdb.models.fields.CharField(blank=True, db_column='userPassword', max_length=200, null=True)), + ('sambat_nt_password', ldapdb.models.fields.CharField(blank=True, db_column='sambaNTPassword', max_length=200, null=True)), + ('macs', ldapdb.models.fields.ListField(blank=True, db_column='radiusCallingStationId', max_length=200, null=True)), + ('shadowexpire', ldapdb.models.fields.CharField(blank=True, db_column='shadowExpire', max_length=200, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LdapUserGroup', + fields=[ + ('dn', ldapdb.models.fields.CharField(max_length=200, serialize=False)), + ('gid', ldapdb.models.fields.IntegerField(db_column='gidNumber')), + ('members', ldapdb.models.fields.ListField(blank=True, db_column='memberUid')), + ('name', ldapdb.models.fields.CharField(db_column='cn', max_length=200, primary_key=True, serialize=False)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterField( + model_name='ldapserviceuser', + name='dn', + field=ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='ldapserviceusergroup', + name='dn', + field=ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='ldapuser', + name='dn', + field=ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='ldapusergroup', + name='dn', + field=ldapdb.models.fields.CharField(max_length=200, primary_key=True, serialize=False), + ), + ] diff --git a/ldap_sync/migrations/__init__.py b/ldap_sync/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ldap_sync/models.py b/ldap_sync/models.py new file mode 100644 index 00000000..b360e7c1 --- /dev/null +++ b/ldap_sync/models.py @@ -0,0 +1,334 @@ +import sys + +from django.db import models +from django.conf import settings +from django.dispatch import receiver + +from django.contrib.auth.models import Group + +import ldapdb.models +import ldapdb.models.fields + +import users.signals +import users.models + +import machines.models + +class LdapUser(ldapdb.models.Model): + """A class representing a LdapUser in LDAP, its LDAP conterpart. + Synced from re2o django User model, (User django models), + with a copy of its attributes/fields into LDAP, so this class is a mirror + of the classic django User model. + + The basedn userdn is specified in settings. + + Attributes: + name: The name of this User + uid: The uid (login) for the unix user + uidNumber: Linux uid number + gid: The default gid number for this user + sn: The user "str" pseudo + login_shell: Linux shell for the user + mail: Email address contact for this user + display_name: Pretty display name for this user + dialupAccess: Boolean, True for valid membership + sambaSID: Identical id as uidNumber + user_password: SSHA hashed password of user + samba_nt_password: NTLM hashed password of user + macs: Multivalued mac address + shadowexpire: Set it to 0 to block access for this user and disabled + account + """ + + # LDAP meta-data + base_dn = settings.LDAP["base_user_dn"] + object_classes = [ + "inetOrgPerson", + "top", + "posixAccount", + "sambaSamAccount", + "radiusprofile", + "shadowAccount", + ] + + # attributes + gid = ldapdb.models.fields.IntegerField(db_column="gidNumber") + name = ldapdb.models.fields.CharField( + db_column="cn", max_length=200, primary_key=True + ) + uid = ldapdb.models.fields.CharField(db_column="uid", max_length=200) + uidNumber = ldapdb.models.fields.IntegerField(db_column="uidNumber", unique=True) + sn = ldapdb.models.fields.CharField(db_column="sn", max_length=200) + login_shell = ldapdb.models.fields.CharField( + db_column="loginShell", max_length=200, blank=True, null=True + ) + mail = ldapdb.models.fields.CharField(db_column="mail", max_length=200) + given_name = ldapdb.models.fields.CharField(db_column="givenName", max_length=200) + home_directory = ldapdb.models.fields.CharField( + db_column="homeDirectory", max_length=200 + ) + display_name = ldapdb.models.fields.CharField( + db_column="displayName", max_length=200, blank=True, null=True + ) + dialupAccess = ldapdb.models.fields.CharField(db_column="dialupAccess") + sambaSID = ldapdb.models.fields.IntegerField(db_column="sambaSID", unique=True) + user_password = ldapdb.models.fields.CharField( + db_column="userPassword", max_length=200, blank=True, null=True + ) + sambat_nt_password = ldapdb.models.fields.CharField( + db_column="sambaNTPassword", max_length=200, blank=True, null=True + ) + macs = ldapdb.models.fields.ListField( + db_column="radiusCallingStationId", max_length=200, blank=True, null=True + ) + shadowexpire = ldapdb.models.fields.CharField( + db_column="shadowExpire", blank=True, null=True + ) + + def __str__(self): + return self.name + + def __unicode__(self): + return self.name + + def save(self, *args, **kwargs): + self.sn = self.name + self.uid = self.name + self.sambaSID = self.uidNumber + super(LdapUser, self).save(*args, **kwargs) + + +@receiver(users.signals.synchronise, sender=users.models.User) +def synchronise_user(sender, **kwargs): + """ + Synchronise an User to the LDAP. + Args: + * sender : The model class. + * instance : The actual instance being synchronised. + * base : Default `True`. When `True`, synchronise basic attributes. + * access_refresh : Default `True`. When `True`, synchronise the access time. + * mac_refresh : Default `True`. When True, synchronise the list of mac addresses. + * group_refresh: Default `False`. When `True` synchronise the groups of the instance. + """ + base=kwargs.get('base', True) + access_refresh=kwargs.get('access_refresh', True) + mac_refresh=kwargs.get('mac_refresh', True ) + group_refresh=kwargs.get('group_refresh', False) + + user=kwargs["instance"] + + if sys.version_info[0] >= 3 and ( + user.state == user.STATE_ACTIVE + or user.state == user.STATE_ARCHIVE + or user.state == user.STATE_DISABLED + ): + user.refresh_from_db() + try: + user_ldap = LdapUser.objects.get(uidNumber=user.uid_number) + except LdapUser.DoesNotExist: + user_ldap = LdapUser(uidNumber=user.uid_number) + base = True + access_refresh = True + mac_refresh = True + if base: + user_ldap.name = user.pseudo + user_ldap.sn = user.pseudo + user_ldap.dialupAccess = str(user.has_access()) + user_ldap.home_directory = user.home_directory + user_ldap.mail = user.get_mail + user_ldap.given_name = ( + user.surname.lower() + "_" + user.name.lower()[:3] + ) + user_ldap.gid = settings.LDAP["user_gid"] + if "{SSHA}" in user.password or "{SMD5}" in user.password: + # We remove the extra $ added at import from ldap + user_ldap.user_password = user.password[:6] + user.password[7:] + elif "{crypt}" in user.password: + # depending on the length, we need to remove or not a $ + if len(user.password) == 41: + user_ldap.user_password = user.password + else: + user_ldap.user_password = user.password[:7] + user.password[8:] + + user_ldap.sambat_nt_password = user.pwd_ntlm.upper() + if user.get_shell: + user_ldap.login_shell = str(user.get_shell) + user_ldap.shadowexpire = user.get_shadow_expire + if access_refresh: + user_ldap.dialupAccess = str(user.has_access()) + if mac_refresh: + user_ldap.macs = [ + str(mac) + for mac in machines.models.Interface.objects.filter(machine__user=user) + .values_list("mac_address", flat=True) + .distinct() + ] + if group_refresh: + # Need to refresh all groups because we don't know which groups + # were updated during edition of groups and the user may no longer + # be part of the updated group (case of group removal) + for group in Group.objects.all(): + if hasattr(group, "listright"): + synchronise_usergroup(users.models.ListRight, instance=group.listright) + user_ldap.save() + +@receiver(users.signals.remove, sender=users.models.User) +def remove_user(sender, **kwargs): + user = kwargs["instance"] + try: + user_ldap = LdapUser.objects.get(name=user.pseudo) + user_ldap.delete() + except LdapUser.DoesNotExist: + pass + +@receiver(users.signals.remove_mass, sender=users.models.User) +def remove_users(sender, **kwargs): + queryset_users = kwargs["queryset"] + LdapUser.objects.filter( + name__in=list(queryset_users.values_list("pseudo", flat=True)) + ).delete() + + +class LdapUserGroup(ldapdb.models.Model): + """A class representing a LdapUserGroup in LDAP, its LDAP conterpart. + Synced from UserGroup, (ListRight/Group django models), + with a copy of its attributes/fields into LDAP, so this class is a mirror + of the classic django ListRight model. + + The basedn usergroupdn is specified in settings. + + Attributes: + name: The name of this LdapUserGroup + gid: The gid number for this unix group + members: Users dn members of this LdapUserGroup + """ + + # LDAP meta-data + base_dn = settings.LDAP["base_usergroup_dn"] + object_classes = ["posixGroup"] + + # attributes + gid = ldapdb.models.fields.IntegerField(db_column="gidNumber") + members = ldapdb.models.fields.ListField(db_column="memberUid", blank=True) + name = ldapdb.models.fields.CharField( + db_column="cn", max_length=200, primary_key=True + ) + + def __str__(self): + return self.name + +@receiver(users.signals.synchronise, sender=users.models.ListRight) +def synchronise_usergroup(sender, **kwargs): + group = kwargs["instance"] + try: + group_ldap = LdapUserGroup.objects.get(gid=group.gid) + except LdapUserGroup.DoesNotExist: + group_ldap = LdapUserGroup(gid=group.gid) + group_ldap.name = group.unix_name + group_ldap.members = [user.pseudo for user in group.user_set.all()] + group_ldap.save() + +@receiver(users.signals.remove, sender=users.models.ListRight) +def remove_usergroup(sender, **kwargs): + group = kwargs["instance"] + try: + group_ldap = LdapUserGroup.objects.get(gid=group.gid) + group_ldap.delete() + except LdapUserGroup.DoesNotExist: + pass + + + +class LdapServiceUser(ldapdb.models.Model): + """A class representing a ServiceUser in LDAP, its LDAP conterpart. + Synced from ServiceUser, with a copy of its attributes/fields into LDAP, + so this class is a mirror of the classic django ServiceUser model. + + The basedn userservicedn is specified in settings. + + Attributes: + name: The name of this ServiceUser + user_password: The SSHA hashed password of this ServiceUser + """ + + # LDAP meta-data + base_dn = settings.LDAP["base_userservice_dn"] + object_classes = ["applicationProcess", "simpleSecurityObject"] + + # attributes + name = ldapdb.models.fields.CharField( + db_column="cn", max_length=200, primary_key=True + ) + user_password = ldapdb.models.fields.CharField( + db_column="userPassword", max_length=200, blank=True, null=True + ) + + def __str__(self): + return self.name + + +def synchronise_serviceuser_group(serviceuser): + try: + group = LdapServiceUserGroup.objects.get(name=serviceuser.access_group) + except: + group = LdapServiceUserGroup(name=serviceuser.access_group) + group.members = list( + LdapServiceUser.objects.filter( + name__in=[ + user.pseudo + for user in users.models.ServiceUser.objects.filter( + access_group=serviceuser.access_group + ) + ] + ).values_list("dn", flat=True) + ) + group.save() + + +@receiver(users.signals.synchronise, sender=users.models.ServiceUser) +def synchronise_serviceuser(sender, **kwargs): + user = kwargs["instance"] + try: + user_ldap = LdapServiceUser.objects.get(name=user.pseudo) + except LdapServiceUser.DoesNotExist: + user_ldap = LdapServiceUser(name=user.pseudo) + user_ldap.user_password = user.password[:6] + user.password[7:] + user_ldap.save() + synchronise_serviceuser_group(user) + +@receiver(users.signals.remove, sender=users.models.ServiceUser) +def remove_serviceuser(sender, **kwargs): + user = kwargs["instance"] + try: + user_ldap = LdapServiceUser.objects.get(name=user.pseudo) + user_ldap.delete() + except LdapUser.DoesNotExist: + pass + synchronise_serviceuser_group(user) + + +class LdapServiceUserGroup(ldapdb.models.Model): + """A class representing a ServiceUserGroup in LDAP, its LDAP conterpart. + Synced from ServiceUserGroup, with a copy of its attributes/fields into LDAP, + so this class is a mirror of the classic django ServiceUserGroup model. + + The basedn userservicegroupdn is specified in settings. + + Attributes: + name: The name of this ServiceUserGroup + members: ServiceUsers dn members of this ServiceUserGroup + """ + + # LDAP meta-data + base_dn = settings.LDAP["base_userservicegroup_dn"] + object_classes = ["groupOfNames"] + + # attributes + name = ldapdb.models.fields.CharField( + db_column="cn", max_length=200, primary_key=True + ) + members = ldapdb.models.fields.ListField(db_column="member", blank=True) + + def __str__(self): + return self.name + diff --git a/ldap_sync/tests.py b/ldap_sync/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/ldap_sync/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/ldap_sync/urls.py b/ldap_sync/urls.py new file mode 100644 index 00000000..24f25ce8 --- /dev/null +++ b/ldap_sync/urls.py @@ -0,0 +1,4 @@ +from django.conf.urls import url +from .import views + +urlpatterns = [] diff --git a/ldap_sync/views.py b/ldap_sync/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/ldap_sync/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/re2o/context_processors.py b/re2o/context_processors.py index c32c263f..07ee1c68 100644 --- a/re2o/context_processors.py +++ b/re2o/context_processors.py @@ -79,12 +79,12 @@ def context_optionnal_apps(request): optionnal_templates_navbar_user_list = [ app.views.navbar_user() for app in optionnal_apps - if hasattr(app.views, "navbar_user") + if hasattr(app, "views") and hasattr(app.views, "navbar_user") ] optionnal_templates_navbar_logout_list = [ app.views.navbar_logout() for app in optionnal_apps - if hasattr(app.views, "navbar_logout") + if hasattr(app, "views") and hasattr(app.views, "navbar_logout") ] return { "optionnal_templates_navbar_user_list": optionnal_templates_navbar_user_list, diff --git a/users/admin.py b/users/admin.py index a18dae90..d083a951 100644 --- a/users/admin.py +++ b/users/admin.py @@ -46,10 +46,6 @@ from .models import ( Ban, Whitelist, Request, - LdapUser, - LdapServiceUser, - LdapServiceUserGroup, - LdapUserGroup, ) from .forms import ( UserAdminForm, @@ -57,57 +53,6 @@ from .forms import ( ) -class LdapUserAdmin(admin.ModelAdmin): - """LdapUser Admin view. Can't change password, manage - by User General model. - - Parameters: - Django ModelAdmin: Apply on django ModelAdmin - - """ - list_display = ("name", "uidNumber", "login_shell") - exclude = ("user_password", "sambat_nt_password") - search_fields = ("name",) - - -class LdapServiceUserAdmin(admin.ModelAdmin): - """LdapServiceUser Admin view. Can't change password, manage - by User General model. - - Parameters: - Django ModelAdmin: Apply on django ModelAdmin - - """ - - list_display = ("name",) - exclude = ("user_password",) - search_fields = ("name",) - - -class LdapUserGroupAdmin(admin.ModelAdmin): - """LdapUserGroup Admin view. - - Parameters: - Django ModelAdmin: Apply on django ModelAdmin - - """ - - list_display = ("name", "members", "gid") - search_fields = ("name",) - - -class LdapServiceUserGroupAdmin(admin.ModelAdmin): - """LdapServiceUserGroup Admin view. - - Parameters: - Django ModelAdmin: Apply on django ModelAdmin - - """ - - list_display = ("name",) - search_fields = ("name",) - - class SchoolAdmin(VersionAdmin): """School Admin view and management. @@ -338,10 +283,6 @@ class ServiceUserAdmin(VersionAdmin, BaseUserAdmin): admin.site.register(Adherent, AdherentAdmin) admin.site.register(Club, ClubAdmin) admin.site.register(ServiceUser, ServiceUserAdmin) -admin.site.register(LdapUser, LdapUserAdmin) -admin.site.register(LdapUserGroup, LdapUserGroupAdmin) -admin.site.register(LdapServiceUser, LdapServiceUserAdmin) -admin.site.register(LdapServiceUserGroup, LdapServiceUserGroupAdmin) admin.site.register(School, SchoolAdmin) admin.site.register(ListRight, ListRightAdmin) admin.site.register(ListShell, ListShellAdmin) diff --git a/users/migrations/0004_auto_20210110_1811.py b/users/migrations/0004_auto_20210110_1811.py new file mode 100644 index 00000000..f6179f01 --- /dev/null +++ b/users/migrations/0004_auto_20210110_1811.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2021-01-10 17:11 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_ldapserviceuser_ldapserviceusergroup_ldapuser_ldapusergroup'), + ] + + operations = [ + migrations.DeleteModel( + name='LdapServiceUser', + ), + migrations.DeleteModel( + name='LdapServiceUserGroup', + ), + migrations.DeleteModel( + name='LdapUser', + ), + migrations.DeleteModel( + name='LdapUserGroup', + ), + ] diff --git a/users/models.py b/users/models.py index 2b6eaacf..8313bfd7 100755 --- a/users/models.py +++ b/users/models.py @@ -39,14 +39,6 @@ Here are defined the following django models : * Schools (teaching structures) * Rights (Groups and ListRight) * ServiceUser (for ldap connexions) - -Also define django-ldapdb models : - * LdapUser - * LdapGroup - * LdapServiceUser - -These objects are sync from django regular models as auxiliary models from -sql data into ldap. """ @@ -82,8 +74,6 @@ from django.core.files.uploadedfile import InMemoryUploadedFile from reversion import revisions as reversion -import ldapdb.models -import ldapdb.models.fields from re2o.settings import LDAP, GID_RANGES, UID_RANGES from re2o.field_permissions import FieldPermissionModelMixin @@ -96,6 +86,8 @@ from machines.models import Domain, Interface, Machine, regen from preferences.models import GeneralOption, AssoOption, OptionalUser from preferences.models import OptionalMachine, MailMessageOption +from users import signals + from PIL import Image from io import BytesIO import sys @@ -1042,7 +1034,7 @@ class User( """ cls.mass_disable_email(queryset_users) Machine.mass_delete(Machine.objects.filter(user__in=queryset_users)) - cls.ldap_delete_users(queryset_users) + signals.remove_mass.send(sender=cls, queryset=queryset_users) def archive(self): """Method, archive user by unassigning ips. @@ -1072,7 +1064,7 @@ class User( def full_archive(self): """Method, full archive an user by unassigning ips, deleting data - and ldap deletion. + and authentication deletion. Parameters: self (user instance): user to full archive. @@ -1080,7 +1072,7 @@ class User( """ self.archive() self.delete_data() - self.ldap_del() + signals.remove.send(sender=User, instance=self) @classmethod def mass_full_archive(cls, users_list): @@ -1102,14 +1094,14 @@ class User( def unarchive(self): """Method, unarchive an user by assigning ips, and recreating - ldap user associated. + authentication user associated. Parameters: self (user instance): user to unarchive. """ self.assign_ips() - self.ldap_sync() + signals.synchronise.send(sender=self.__class__, instance=self) def state_sync(self): """Master Method, call unarchive, full_archive or archive method @@ -1135,109 +1127,6 @@ class User( ): self.full_archive() - def ldap_sync( - self, base=True, access_refresh=True, mac_refresh=True, group_refresh=False - ): - """Method ldap_sync, sync in ldap with self user attributes. - Each User instance is copy into ldap, via a LdapUser virtual objects. - This method performs a copy of several attributes (name, surname, mail, - hashed SSHA password, ntlm password, shell, homedirectory). - - Update, or create if needed a ldap entry related with the User instance. - - Parameters: - self (user instance): user to sync in ldap. - base (boolean): Default true, if base is true, perform a basic - sync of basic attributes. - access_refresh (boolean): Default true, if access_refresh is true, - update the dialup_access attributes based on has_access (is this user - has a valid internet access). - mac_refresh (boolean): Default true, if mac_refresh, update the mac_address - list of the user. - group_refresh (boolean): Default False, if true, update the groups membership - of this user. Onerous option, call ldap_sync() on every groups of the user. - - """ - if sys.version_info[0] >= 3 and ( - self.state == self.STATE_ACTIVE - or self.state == self.STATE_ARCHIVE - or self.state == self.STATE_DISABLED - ): - self.refresh_from_db() - try: - user_ldap = LdapUser.objects.get(uidNumber=self.uid_number) - except LdapUser.DoesNotExist: - user_ldap = LdapUser(uidNumber=self.uid_number) - base = True - access_refresh = True - mac_refresh = True - if base: - user_ldap.name = self.pseudo - user_ldap.sn = self.pseudo - user_ldap.dialupAccess = str(self.has_access()) - user_ldap.home_directory = self.home_directory - user_ldap.mail = self.get_mail - user_ldap.given_name = ( - self.surname.lower() + "_" + self.name.lower()[:3] - ) - user_ldap.gid = LDAP["user_gid"] - if "{SSHA}" in self.password or "{SMD5}" in self.password: - # We remove the extra $ added at import from ldap - user_ldap.user_password = self.password[:6] + self.password[7:] - elif "{crypt}" in self.password: - # depending on the length, we need to remove or not a $ - if len(self.password) == 41: - user_ldap.user_password = self.password - else: - user_ldap.user_password = self.password[:7] + self.password[8:] - - user_ldap.sambat_nt_password = self.pwd_ntlm.upper() - if self.get_shell: - user_ldap.login_shell = str(self.get_shell) - user_ldap.shadowexpire = self.get_shadow_expire - if access_refresh: - user_ldap.dialupAccess = str(self.has_access()) - if mac_refresh: - user_ldap.macs = [ - str(mac) - for mac in Interface.objects.filter(machine__user=self) - .values_list("mac_address", flat=True) - .distinct() - ] - if group_refresh: - # Need to refresh all groups because we don't know which groups - # were updated during edition of groups and the user may no longer - # be part of the updated group (case of group removal) - for group in Group.objects.all(): - if hasattr(group, "listright"): - group.listright.ldap_sync() - user_ldap.save() - - def ldap_del(self): - """Method, delete an user in ldap. - - Parameters: - self (user instance): user to delete in Ldap. - - """ - try: - user_ldap = LdapUser.objects.get(name=self.pseudo) - user_ldap.delete() - except LdapUser.DoesNotExist: - pass - - @classmethod - def ldap_delete_users(cls, queryset_users): - """Class method, delete several users in ldap (queryset). - - Parameters: - queryset_users (list of users queryset): users to delete - in ldap. - """ - LdapUser.objects.filter( - name__in=list(queryset_users.values_list("pseudo", flat=True)) - ) - ###### Send mail functions ###### def notif_inscription(self, request=None): @@ -2195,7 +2084,7 @@ class Club(User): @receiver(post_save, sender=User) def user_post_save(**kwargs): """Django signal, post save operations on Adherent, Club and User. - Sync pseudo, sync ldap, create mailalias and send welcome email if needed + Sync pseudo, sync authentication, create mailalias and send welcome email if needed (new user) """ @@ -2207,8 +2096,7 @@ def user_post_save(**kwargs): user.notif_inscription(user.request) user.set_active() user.state_sync() - user.ldap_sync( - base=True, access_refresh=True, mac_refresh=False, group_refresh=True + signals.synchronise.send(sender=User, instance=user, base=True, access_refresh=True, mac_refresh=False, group_refresh=True ) regen("mailing") @@ -2216,14 +2104,13 @@ def user_post_save(**kwargs): @receiver(m2m_changed, sender=User.groups.through) def user_group_relation_changed(**kwargs): """Django signal, used for User Groups change (related models). - Sync ldap, with calling group_refresh. + Sync authentication, with calling group_refresh. """ action = kwargs["action"] if action in ("post_add", "post_remove", "post_clear"): user = kwargs["instance"] - user.ldap_sync( - base=False, access_refresh=False, mac_refresh=False, group_refresh=True + signals.synchronise.send(sender=User, instance=user, base=False, access_refresh=False, mac_refresh=False, group_refresh=True ) @@ -2232,20 +2119,20 @@ def user_group_relation_changed(**kwargs): @receiver(post_delete, sender=User) def user_post_delete(**kwargs): """Django signal, post delete operations on Adherent, Club and User. - Delete user in ldap. + Delete user in authentication. """ user = kwargs["instance"] - user.ldap_del() + signals.remove.send(sender=User, instance=user) regen("mailing") class ServiceUser(RevMixin, AclMixin, AbstractBaseUser): """A class representing a serviceuser (it is considered as a user with special informations). - The serviceuser is a special user used with special access to ldap tree. It is + The serviceuser is a special user used with special access to authentication tree. It is its only usefullness, and service user can't connect to re2o. - Each service connected to ldap for auth (ex dokuwiki, owncloud, etc) should + Each service connected to authentication for auth (ex dokuwiki, owncloud, etc) should have a different service user with special acl (readonly, auth) and password. Attributes: @@ -2293,65 +2180,6 @@ class ServiceUser(RevMixin, AclMixin, AbstractBaseUser): """ return self.pseudo - def ldap_sync(self): - """Method ldap_sync, sync the serviceuser in ldap with its attributes. - Each ServiceUser instance is copy into ldap, via a LdapServiceUser virtual object. - This method performs a copy of several attributes (pseudo, access). - - Update, or create if needed a mirror ldap entry related with the ServiceUserinstance. - - Parameters: - self (serviceuser instance): ServiceUser to sync in ldap. - - """ - try: - user_ldap = LdapServiceUser.objects.get(name=self.pseudo) - except LdapServiceUser.DoesNotExist: - user_ldap = LdapServiceUser(name=self.pseudo) - user_ldap.user_password = self.password[:6] + self.password[7:] - user_ldap.save() - self.serviceuser_group_sync() - - def ldap_del(self): - """Method, delete an ServiceUser in ldap. - - Parameters: - self (ServiceUser instance): serviceuser to delete in Ldap. - - """ - try: - user_ldap = LdapServiceUser.objects.get(name=self.pseudo) - user_ldap.delete() - except LdapUser.DoesNotExist: - pass - self.serviceuser_group_sync() - - def serviceuser_group_sync(self): - """Method, update serviceuser group sync in ldap. - In LDAP, Acl depends on the ldapgroup (readonly, auth, or usermgt), - so the ldap group need to be synced with the accessgroup field on ServiceUser. - Called by ldap_sync and ldap_del. - - Parameters: - self (ServiceUser instance): serviceuser to update groups in LDAP. - - """ - try: - group = LdapServiceUserGroup.objects.get(name=self.access_group) - except: - group = LdapServiceUserGroup(name=self.access_group) - group.members = list( - LdapServiceUser.objects.filter( - name__in=[ - user.pseudo - for user in ServiceUser.objects.filter( - access_group=self.access_group - ) - ] - ).values_list("dn", flat=True) - ) - group.save() - def __str__(self): return self.pseudo @@ -2359,21 +2187,21 @@ class ServiceUser(RevMixin, AclMixin, AbstractBaseUser): @receiver(post_save, sender=ServiceUser) def service_user_post_save(**kwargs): """Django signal, post save operations on ServiceUser. - Sync or create serviceuser in ldap. + Sync or create serviceuser in authentication. """ service_user = kwargs["instance"] - service_user.ldap_sync() + signals.synchronise.send(sender=ServiceUser, instance=service_user) @receiver(post_delete, sender=ServiceUser) def service_user_post_delete(**kwargs): """Django signal, post delete operations on ServiceUser. - Delete service user in ldap. + Delete service user in authentication. """ service_user = kwargs["instance"] - service_user.ldap_del() + signals.remove.send(sender=ServiceUser, instance=service_user) class School(RevMixin, AclMixin, models.Model): @@ -2448,58 +2276,25 @@ class ListRight(RevMixin, AclMixin, Group): def __str__(self): return self.name - def ldap_sync(self): - """Method ldap_sync, sync the listright/group in ldap with its listright attributes. - Each ListRight/Group instance is copy into ldap, via a LdapUserGroup virtual objects. - This method performs a copy of several attributes (name, members, gid, etc). - The primary key is the gid, and should never change. - - Update, or create if needed a ldap entry related with the ListRight/Group instance. - - Parameters: - self (listright instance): ListRight/Group to sync in ldap. - - """ - try: - group_ldap = LdapUserGroup.objects.get(gid=self.gid) - except LdapUserGroup.DoesNotExist: - group_ldap = LdapUserGroup(gid=self.gid) - group_ldap.name = self.unix_name - group_ldap.members = [user.pseudo for user in self.user_set.all()] - group_ldap.save() - - def ldap_del(self): - """Method, delete an ListRight/Group in ldap. - - Parameters: - self (listright/Group instance): group to delete in Ldap. - - """ - try: - group_ldap = LdapUserGroup.objects.get(gid=self.gid) - group_ldap.delete() - except LdapUserGroup.DoesNotExist: - pass - @receiver(post_save, sender=ListRight) def listright_post_save(**kwargs): """Django signal, post save operations on ListRight/Group objects. - Sync or create group in ldap. + Sync or create group in authentication. """ right = kwargs["instance"] - right.ldap_sync() + signals.synchronise.send(sender=ListRight, instance=right) @receiver(post_delete, sender=ListRight) def listright_post_delete(**kwargs): """Django signal, post delete operations on ListRight/Group objects. - Delete group in ldap. + Delete group in authentication. """ right = kwargs["instance"] - right.ldap_del() + signals.remove.send(sender=ListRight, instance=right) class ListShell(RevMixin, AclMixin, models.Model): @@ -2649,13 +2444,13 @@ class Ban(RevMixin, AclMixin, models.Model): @receiver(post_save, sender=Ban) def ban_post_save(**kwargs): """Django signal, post save operations on Ban objects. - Sync user's access state in ldap, call email notification if needed. + Sync user's access state in authentication, call email notification if needed. """ ban = kwargs["instance"] is_created = kwargs["created"] user = ban.user - user.ldap_sync(base=False, access_refresh=True, mac_refresh=False) + signals.synchronise.send(sender=User, instance=user, base=False, access_refresh=True, mac_refresh=False) regen("mailing") if is_created: ban.notif_ban(ban.request) @@ -2669,11 +2464,11 @@ def ban_post_save(**kwargs): @receiver(post_delete, sender=Ban) def ban_post_delete(**kwargs): """Django signal, post delete operations on Ban objects. - Sync user's access state in ldap. + Sync user's access state in authentication. """ user = kwargs["instance"].user - user.ldap_sync(base=False, access_refresh=True, mac_refresh=False) + signals.synchronise.send(sender=User, instance=user, base=False, access_refresh=True, mac_refresh=False) regen("mailing") regen("dhcp") regen("mac_ip_list") @@ -2740,12 +2535,12 @@ class Whitelist(RevMixin, AclMixin, models.Model): @receiver(post_save, sender=Whitelist) def whitelist_post_save(**kwargs): """Django signal, post save operations on Whitelist objects. - Sync user's access state in ldap. + Sync user's access state in authentication. """ whitelist = kwargs["instance"] user = whitelist.user - user.ldap_sync(base=False, access_refresh=True, mac_refresh=False) + signals.synchronise.send(sender=User, instance=user, base=False, access_refresh=True, mac_refresh=False) is_created = kwargs["created"] regen("mailing") if is_created: @@ -2759,11 +2554,11 @@ def whitelist_post_save(**kwargs): @receiver(post_delete, sender=Whitelist) def whitelist_post_delete(**kwargs): """Django signal, post delete operations on Whitelist objects. - Sync user's access state in ldap. + Sync user's access state in authentication. """ user = kwargs["instance"].user - user.ldap_sync(base=False, access_refresh=True, mac_refresh=False) + signals.synchronise.send(sender=User, instance=user, base=False, access_refresh=True, mac_refresh=False) regen("mailing") regen("dhcp") regen("mac_ip_list") @@ -2996,171 +2791,3 @@ class EMailAddress(RevMixin, AclMixin, models.Model): raise ValidationError(reason) super(EMailAddress, self).clean(*args, **kwargs) - -class LdapUser(ldapdb.models.Model): - """A class representing a LdapUser in LDAP, its LDAP conterpart. - Synced from re2o django User model, (User django models), - with a copy of its attributes/fields into LDAP, so this class is a mirror - of the classic django User model. - - The basedn userdn is specified in settings. - - Attributes: - name: The name of this User - uid: The uid (login) for the unix user - uidNumber: Linux uid number - gid: The default gid number for this user - sn: The user "str" pseudo - login_shell: Linux shell for the user - mail: Email address contact for this user - display_name: Pretty display name for this user - dialupAccess: Boolean, True for valid membership - sambaSID: Identical id as uidNumber - user_password: SSHA hashed password of user - samba_nt_password: NTLM hashed password of user - macs: Multivalued mac address - shadowexpire: Set it to 0 to block access for this user and disabled - account - """ - - # LDAP meta-data - base_dn = LDAP["base_user_dn"] - object_classes = [ - "inetOrgPerson", - "top", - "posixAccount", - "sambaSamAccount", - "radiusprofile", - "shadowAccount", - ] - - # attributes - gid = ldapdb.models.fields.IntegerField(db_column="gidNumber") - name = ldapdb.models.fields.CharField( - db_column="cn", max_length=200, primary_key=True - ) - uid = ldapdb.models.fields.CharField(db_column="uid", max_length=200) - uidNumber = ldapdb.models.fields.IntegerField(db_column="uidNumber", unique=True) - sn = ldapdb.models.fields.CharField(db_column="sn", max_length=200) - login_shell = ldapdb.models.fields.CharField( - db_column="loginShell", max_length=200, blank=True, null=True - ) - mail = ldapdb.models.fields.CharField(db_column="mail", max_length=200) - given_name = ldapdb.models.fields.CharField(db_column="givenName", max_length=200) - home_directory = ldapdb.models.fields.CharField( - db_column="homeDirectory", max_length=200 - ) - display_name = ldapdb.models.fields.CharField( - db_column="displayName", max_length=200, blank=True, null=True - ) - dialupAccess = ldapdb.models.fields.CharField(db_column="dialupAccess") - sambaSID = ldapdb.models.fields.IntegerField(db_column="sambaSID", unique=True) - user_password = ldapdb.models.fields.CharField( - db_column="userPassword", max_length=200, blank=True, null=True - ) - sambat_nt_password = ldapdb.models.fields.CharField( - db_column="sambaNTPassword", max_length=200, blank=True, null=True - ) - macs = ldapdb.models.fields.ListField( - db_column="radiusCallingStationId", max_length=200, blank=True, null=True - ) - shadowexpire = ldapdb.models.fields.CharField( - db_column="shadowExpire", blank=True, null=True - ) - - def __str__(self): - return self.name - - def __unicode__(self): - return self.name - - def save(self, *args, **kwargs): - self.sn = self.name - self.uid = self.name - self.sambaSID = self.uidNumber - super(LdapUser, self).save(*args, **kwargs) - - -class LdapUserGroup(ldapdb.models.Model): - """A class representing a LdapUserGroup in LDAP, its LDAP conterpart. - Synced from UserGroup, (ListRight/Group django models), - with a copy of its attributes/fields into LDAP, so this class is a mirror - of the classic django ListRight model. - - The basedn usergroupdn is specified in settings. - - Attributes: - name: The name of this LdapUserGroup - gid: The gid number for this unix group - members: Users dn members of this LdapUserGroup - """ - - # LDAP meta-data - base_dn = LDAP["base_usergroup_dn"] - object_classes = ["posixGroup"] - - # attributes - gid = ldapdb.models.fields.IntegerField(db_column="gidNumber") - members = ldapdb.models.fields.ListField(db_column="memberUid", blank=True) - name = ldapdb.models.fields.CharField( - db_column="cn", max_length=200, primary_key=True - ) - - def __str__(self): - return self.name - - -class LdapServiceUser(ldapdb.models.Model): - """A class representing a ServiceUser in LDAP, its LDAP conterpart. - Synced from ServiceUser, with a copy of its attributes/fields into LDAP, - so this class is a mirror of the classic django ServiceUser model. - - The basedn userservicedn is specified in settings. - - Attributes: - name: The name of this ServiceUser - user_password: The SSHA hashed password of this ServiceUser - """ - - # LDAP meta-data - base_dn = LDAP["base_userservice_dn"] - object_classes = ["applicationProcess", "simpleSecurityObject"] - - # attributes - name = ldapdb.models.fields.CharField( - db_column="cn", max_length=200, primary_key=True - ) - user_password = ldapdb.models.fields.CharField( - db_column="userPassword", max_length=200, blank=True, null=True - ) - - def __str__(self): - return self.name - - -class LdapServiceUserGroup(ldapdb.models.Model): - """A class representing a ServiceUserGroup in LDAP, its LDAP conterpart. - Synced from ServiceUserGroup, with a copy of its attributes/fields into LDAP, - so this class is a mirror of the classic django ServiceUserGroup model. - - The basedn userservicegroupdn is specified in settings. - - Attributes: - name: The name of this ServiceUserGroup - members: ServiceUsers dn members of this ServiceUserGroup - """ - - # LDAP meta-data - base_dn = LDAP["base_userservicegroup_dn"] - object_classes = ["groupOfNames"] - - # attributes - name = ldapdb.models.fields.CharField( - db_column="cn", max_length=200, primary_key=True - ) - members = ldapdb.models.fields.ListField(db_column="member", blank=True) - - def __str__(self): - return self.name - - diff --git a/users/signals.py b/users/signals.py new file mode 100644 index 00000000..7336e5fc --- /dev/null +++ b/users/signals.py @@ -0,0 +1,33 @@ +""" +A set of signals used by users. Various classes in users emit these signals to signal the need to sync or +remove an object from optionnal authentication backends, e.g. LDAP. + +* `users.signals.synchronise`: + Expresses the need for an instance of a users class to be synchronised. `sender` and `instance` are + always set. It is up to the receiver to ensure the others are set correctly if they make sense. + Arguments: + * `sender` : The model class. + * `instance` : The actual instance being synchronised. + * `base` : Default `True`. When `True`, synchronise basic attributes. + * `access_refresh` : Default `True`. When `True`, synchronise the access time. + * `mac_refresh` : Default `True`. When True, synchronise the list of mac addresses. + * `group_refresh`: Default `False`. When `True` synchronise the groups of the instance. +* `users.signals.remove`: + Expresses the need for an instance of a users class to be removed. + Arguments: + * `sender` : The model class. + * `instance` : The actual instance being removed. +* `users.signals.remove_mass`: + Same as `users.signals.remove` except it removes a queryset. For now it is only used by `users.models.User`. + Arguments: + * `sender` : The model class. + * `queryset` : The actual instances being removed. + +""" + +import django.dispatch + +synchronise = django.dispatch.Signal(providing_args=["sender", "instance", "base", "access_refresh", "mac_refresh", "group_refresh"]) +remove = django.dispatch.Signal(providing_args=["sender", "instance"]) +remove_mass = django.dispatch.Signal(providing_args=["sender", "queryset"]) +