From 34e7c1bb63ea5b95f3186457c8623ff47014a1eb Mon Sep 17 00:00:00 2001 From: Horilla Date: Wed, 5 Mar 2025 14:57:05 +0530 Subject: [PATCH] [ADD] HORILLA LDAP: New app for handling LDAP authentication in Horilla --- horilla/horilla_settings.py | 51 ++++++-- horilla_ldap/__init__.py | 0 horilla_ldap/admin.py | 6 + horilla_ldap/apps.py | 32 +++++ horilla_ldap/forms.py | 19 +++ .../management/commands/import_ldap_users.py | 117 ++++++++++++++++++ .../commands/import_users_to_ldap.py | 76 ++++++++++++ horilla_ldap/migrations/__init__.py | 0 horilla_ldap/models.py | 16 +++ horilla_ldap/templates/ldap_settings.html | 8 ++ horilla_ldap/tests.py | 3 + horilla_ldap/urls.py | 12 ++ horilla_ldap/views.py | 25 ++++ templates/settings.html | 12 ++ 14 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 horilla_ldap/__init__.py create mode 100644 horilla_ldap/admin.py create mode 100644 horilla_ldap/apps.py create mode 100644 horilla_ldap/forms.py create mode 100644 horilla_ldap/management/commands/import_ldap_users.py create mode 100644 horilla_ldap/management/commands/import_users_to_ldap.py create mode 100644 horilla_ldap/migrations/__init__.py create mode 100644 horilla_ldap/models.py create mode 100644 horilla_ldap/templates/ldap_settings.html create mode 100644 horilla_ldap/tests.py create mode 100644 horilla_ldap/urls.py create mode 100644 horilla_ldap/views.py diff --git a/horilla/horilla_settings.py b/horilla/horilla_settings.py index 17265fa73..b14760adc 100644 --- a/horilla/horilla_settings.py +++ b/horilla/horilla_settings.py @@ -123,16 +123,47 @@ settings.DEFAULT_FILE_STORAGE = DEFAULT_FILE_STORAGE settings.AWS_S3_ADDRESSING_STYLE = AWS_S3_ADDRESSING_STYLE -if settings.env("GOOGLE_APPLICATION_CREDENTIALS", default=None): - GS_BUCKET_NAME = settings.env("GS_BUCKET_NAME") - DEFAULT_FILE_STORAGE = settings.env("DEFAULT_FILE_STORAGE") - - settings.GS_BUCKET_NAME = GS_BUCKET_NAME - settings.DEFAULT_FILE_STORAGE = DEFAULT_FILE_STORAGE -if ( - settings.env("GOOGLE_APPLICATION_CREDENTIALS", default=None) - or settings.env("AWS_ACCESS_KEY_ID", default=None) -) and "storages" in INSTALLED_APPS: +if settings.env("AWS_ACCESS_KEY_ID", default=None) and "storages" in INSTALLED_APPS: settings.MEDIA_URL = f"{settings.env('MEDIA_URL')}/{settings.env('NAMESPACE')}/" settings.MEDIA_ROOT = f"{settings.env('MEDIA_ROOT')}/{settings.env('NAMESPACE')}/" + + +from django.conf import settings + +# Default LDAP settings +DEFAULT_LDAP_CONFIG = { + "LDAP_SERVER": "ldap://127.0.0.1:389", + "BIND_DN": "cn=admin,dc=horilla,dc=com", + "BIND_PASSWORD": "horilla", + "BASE_DN": "ou=users,dc=horilla,dc=com", +} + + +def load_ldap_settings(): + """ + Fetch LDAP settings dynamically from the database after Django is ready. + """ + try: + from django.db import connection + + from horilla_ldap.models import LDAPSettings + + # Ensure DB is ready before querying + if not connection.introspection.table_names(): + print("⚠️ Database is empty. Using default LDAP settings.") + return DEFAULT_LDAP_CONFIG + + ldap_config = LDAPSettings.objects.first() + if ldap_config: + return { + "LDAP_SERVER": ldap_config.ldap_server, + "BIND_DN": ldap_config.bind_dn, + "BIND_PASSWORD": ldap_config.bind_password, + "BASE_DN": ldap_config.base_dn, + } + except Exception as e: + print(f"⚠️ Warning: Could not load LDAP settings ({e})") + return DEFAULT_LDAP_CONFIG # Return default on error + + return DEFAULT_LDAP_CONFIG # Fallback in case of an issue diff --git a/horilla_ldap/__init__.py b/horilla_ldap/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_ldap/admin.py b/horilla_ldap/admin.py new file mode 100644 index 000000000..6f98b0f58 --- /dev/null +++ b/horilla_ldap/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +# Register your models here. +from .models import LDAPSettings + +admin.site.register(LDAPSettings) diff --git a/horilla_ldap/apps.py b/horilla_ldap/apps.py new file mode 100644 index 000000000..680529379 --- /dev/null +++ b/horilla_ldap/apps.py @@ -0,0 +1,32 @@ +from django.apps import AppConfig +from django.conf import settings +import horilla.horilla_settings + + +class HorillaLdapConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'horilla_ldap' + + def ready(self): + from django.urls import include, path + from horilla.urls import urlpatterns + from horilla.horilla_settings import APPS + + APPS.append("horilla_ldap") + urlpatterns.append( + path("", include("horilla_ldap.urls")), + ) + super().ready() + + ldap_config = horilla.horilla_settings.load_ldap_settings() + + # Apply settings dynamically + settings.LDAP_SERVER = ldap_config["LDAP_SERVER"] + settings.BIND_DN = ldap_config["BIND_DN"] + settings.BIND_PASSWORD = ldap_config["BIND_PASSWORD"] + settings.BASE_DN = ldap_config["BASE_DN"] + + settings.AUTH_LDAP_SERVER_URI = settings.LDAP_SERVER + settings.AUTH_LDAP_BIND_DN = settings.BIND_DN + settings.AUTH_LDAP_BIND_PASSWORD = settings.BIND_PASSWORD + settings.AUTH_LDAP_USER_SEARCH_BASE = settings.BASE_DN diff --git a/horilla_ldap/forms.py b/horilla_ldap/forms.py new file mode 100644 index 000000000..90fbf556a --- /dev/null +++ b/horilla_ldap/forms.py @@ -0,0 +1,19 @@ +from django import forms +from .models import LDAPSettings +from django.template.loader import render_to_string +from base.forms import ModelForm + +class LDAPSettingsForm(ModelForm): + bind_password = forms.CharField(widget=forms.PasswordInput(attrs={"class":"oh-input w-100"}), required=True) + + class Meta: + model = LDAPSettings + fields = ['ldap_server', 'bind_dn', 'bind_password', 'base_dn'] + + def as_p(self): + """ + Render the form fields as HTML table rows with Bootstrap styling. + """ + context = {"form": self} + table_html = render_to_string("common_form.html", context) + return table_html diff --git a/horilla_ldap/management/commands/import_ldap_users.py b/horilla_ldap/management/commands/import_ldap_users.py new file mode 100644 index 000000000..0a4ef1a7d --- /dev/null +++ b/horilla_ldap/management/commands/import_ldap_users.py @@ -0,0 +1,117 @@ +import re +import sys +import platform +from django.core.management.base import BaseCommand +from django.db.models import Q +from django.contrib.auth.models import User +from horilla_ldap.models import LDAPSettings +from employee.models import Employee + +if platform.system() == "Linux": + import ldap # Use python-ldap for Linux +else: + from ldap3 import Server, Connection, ALL # Use ldap3 for Windows + + +class Command(BaseCommand): + help = "Imports employees from LDAP into the Django database using LDAP settings from the database" + + def handle(self, *args, **kwargs): + # Detect OS + os_name = platform.system() + # self.stdout.write(self.style.NOTICE(f"Running on {os_name}")) + + # Fetch LDAP settings from the database + settings = LDAPSettings.objects.first() + if not settings: + self.stdout.write(self.style.ERROR("LDAP settings are not configured.")) + return + + ldap_server = settings.ldap_server + bind_dn = settings.bind_dn + bind_password = settings.bind_password + base_dn = settings.base_dn + + if not all([ldap_server, bind_dn, bind_password, base_dn]): + self.stdout.write(self.style.ERROR("LDAP settings are incomplete. Please check your configuration.")) + return + + try: + if os_name == "Linux": + # LDAP connection for Linux (python-ldap) + connection = ldap.initialize(ldap_server) + connection.simple_bind_s(bind_dn, bind_password) + search_filter = "(objectClass=inetOrgPerson)" + results = connection.search_s(base_dn, ldap.SCOPE_SUBTREE, search_filter) + + for dn, entry in results: + user_id = entry.get("uid", [b""])[0].decode("utf-8") + email = entry.get("mail", [b""])[0].decode("utf-8") + first_name = entry.get("givenName", [b""])[0].decode("utf-8") + last_name = entry.get("sn", [b""])[0].decode("utf-8") + name = entry.get("cn", [b""])[0].decode("utf-8") + phone = entry.get("telephoneNumber", [b""])[0].decode("utf-8") + + # Get the password from LDAP + ldap_password = entry.get("telephoneNumber", [b""])[0].decode("utf-8") + + # Remove non-numeric characters but keep numbers + clean_phone = re.sub(r"[^\d]", "", phone) + ldap_password = clean_phone + + self.create_or_update_employee(user_id, email, first_name, last_name, phone, ldap_password) + + connection.unbind_s() + + else: + # LDAP connection for Windows (ldap3) + server = Server(ldap_server, get_info=ALL) + connection = Connection(server, user=bind_dn, password=bind_password) + if not connection.bind(): + self.stdout.write(self.style.ERROR(f"Failed to bind to LDAP server: {connection.last_error}")) + return + + search_filter = "(objectClass=inetOrgPerson)" + connection.search(base_dn, search_filter, attributes=['uid', 'mail', 'givenName', 'sn', 'cn', 'telephoneNumber', 'userPassword']) + + for entry in connection.entries: + user_id = entry.uid.value if entry.uid else "" + email = entry.mail.value if entry.mail else "" + first_name = entry.givenName.value if entry.givenName else "" + last_name = entry.sn.value if entry.sn else "" + name = entry.cn.value if entry.cn else "" + phone = entry.telephoneNumber.value if entry.telephoneNumber else "" + + # Get the password from LDAP + clean_phone = re.sub(r"[^\d]", "", phone) + ldap_password = clean_phone + + self.create_or_update_employee(user_id, email, first_name, last_name, phone, ldap_password) + + connection.unbind() + + except Exception as e: + self.stderr.write(self.style.ERROR(f"Error: {e}")) + + def create_or_update_employee(self, user_id, email, first_name, last_name, phone, ldap_password): + employee, created = Employee.objects.update_or_create( + email=email, + defaults={ + "employee_first_name": first_name or "", + "employee_last_name": last_name or "", + "phone": phone or "", + } + ) + + try: + user = User.objects.get(Q(username=email) | Q(username=user_id) | Q(email=email)) + user.username = user_id + user.set_password(ldap_password) # Hash and store password securely + user.save() + action = "Updated" + except User.DoesNotExist: + self.stdout.write(self.style.WARNING(f"User for employee {first_name} {last_name} does not exist.")) + return + + action = "Created" if created else "Updated" + self.stdout.write(self.style.SUCCESS(f"{action} employee {first_name} {last_name}.")) diff --git a/horilla_ldap/management/commands/import_users_to_ldap.py b/horilla_ldap/management/commands/import_users_to_ldap.py new file mode 100644 index 000000000..b6d81a3f4 --- /dev/null +++ b/horilla_ldap/management/commands/import_users_to_ldap.py @@ -0,0 +1,76 @@ +import hashlib +import base64 +from django.core.management.base import BaseCommand +from ldap3 import Server, Connection, ALL, ALL_ATTRIBUTES +from horilla_ldap.models import LDAPSettings +from employee.models import Employee + + +class Command(BaseCommand): + help = 'Import users from Django to LDAP using LDAP settings from the database' + + def handle(self, *args, **kwargs): + # Get LDAP settings from the database + settings = LDAPSettings.objects.first() + if not settings: + self.stdout.write(self.style.ERROR("LDAP settings are not configured.")) + return + + # Fetch LDAP server details from settings + ldap_server = settings.ldap_server + bind_dn = settings.bind_dn + bind_password = settings.bind_password + base_dn = settings.base_dn + + if not all([ldap_server, bind_dn, bind_password, base_dn]): + self.stdout.write(self.style.ERROR("LDAP settings are incomplete. Please check your configuration.")) + return + + # Connect to the LDAP server + server = Server(ldap_server, get_info=ALL) + + try: + conn = Connection(server, bind_dn, bind_password, auto_bind=True) + + # Fetch all users from Django + users = Employee.objects.all() + + for user in users: + if not user.employee_user_id: + self.stdout.write(self.style.WARNING(f"Skipping user {user} due to missing employee_user_id")) + continue + + dn = f"uid={user.employee_user_id.username},{base_dn}" + + # Securely hash the password using SHA + hashed_password = "{SHA}" + base64.b64encode(hashlib.sha1(user.phone.encode()).digest()).decode() + + if user.employee_last_name is None: + user.employee_last_name = " " + + attributes = { + 'objectClass': ['inetOrgPerson'], + 'givenName': user.employee_first_name or "", + 'sn': user.employee_last_name or "", + 'cn': f"{user.employee_first_name} {user.employee_last_name}", + 'uid': user.email or "", + 'mail': user.email or "", + "telephoneNumber": user.phone or "", + 'userPassword': hashed_password, # Securely store password + } + + # Check if the user already exists in LDAP + conn.search(base_dn, f'(uid={user.employee_user_id.username})', attributes=ALL_ATTRIBUTES) + + if conn.entries: + self.stdout.write(self.style.WARNING(f'{user.employee_first_name} {user.employee_last_name} already exists in LDAP. Skipping...')) + else: + # Add user to LDAP + if not conn.add(dn, attributes=attributes): + self.stdout.write(self.style.ERROR(f'Failed to add {user.employee_first_name} {user.employee_last_name}: {conn.result}')) + else: + self.stdout.write(self.style.SUCCESS(f'Successfully added {user.employee_first_name} {user.employee_last_name} to LDAP.')) + + conn.unbind() + except Exception as e: + self.stdout.write(self.style.ERROR(f'An error occurred: {e}')) \ No newline at end of file diff --git a/horilla_ldap/migrations/__init__.py b/horilla_ldap/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horilla_ldap/models.py b/horilla_ldap/models.py new file mode 100644 index 000000000..a816ed63b --- /dev/null +++ b/horilla_ldap/models.py @@ -0,0 +1,16 @@ +from django.db import models + +# Create your models here. + + +from django.db import models + +class LDAPSettings(models.Model): + ldap_server = models.CharField(max_length=255, default="ldap://127.0.0.1:389") + bind_dn = models.CharField(max_length=255, default="cn=admin,dc=horilla,dc=com") + bind_password = models.CharField(max_length=255) + base_dn = models.CharField(max_length=255, default="ou=users,dc=horilla,dc=com") + + def __str__(self): + return f"LDAP Settings ({self.ldap_server})" + diff --git a/horilla_ldap/templates/ldap_settings.html b/horilla_ldap/templates/ldap_settings.html new file mode 100644 index 000000000..449e7d6f4 --- /dev/null +++ b/horilla_ldap/templates/ldap_settings.html @@ -0,0 +1,8 @@ +{% extends 'settings.html' %} {% load i18n %} {% block settings %} +{% load static %} +

{% trans "LDAP Configuration" %}

+
+ {% csrf_token %} + {{ form.as_p }} +
+{% endblock settings %} \ No newline at end of file diff --git a/horilla_ldap/tests.py b/horilla_ldap/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/horilla_ldap/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/horilla_ldap/urls.py b/horilla_ldap/urls.py new file mode 100644 index 000000000..aa47bf64c --- /dev/null +++ b/horilla_ldap/urls.py @@ -0,0 +1,12 @@ +""" +urls.py + +This module is used to map url path with view methods. +""" + +from django.urls import path +from horilla_ldap import views + +urlpatterns = [ + path('settings/ldap-settings/', views.ldap_settings_view, name='ldap-settings'), +] \ No newline at end of file diff --git a/horilla_ldap/views.py b/horilla_ldap/views.py new file mode 100644 index 000000000..95792242a --- /dev/null +++ b/horilla_ldap/views.py @@ -0,0 +1,25 @@ +from django.shortcuts import render + +# Create your views here. + +from horilla.decorators import login_required +from .models import LDAPSettings +from .forms import LDAPSettingsForm +from django.utils.translation import gettext as __ +from django.utils.translation import gettext_lazy as _ +from django.contrib import messages + + +@login_required +def ldap_settings_view(request): + settings = LDAPSettings.objects.first() + if request.method == "POST": + form = LDAPSettingsForm(request.POST, instance=settings) + if form.is_valid(): + form.save() + messages.success(request, _("Configuration updated successfully.")) + return render(request, "ldap_settings.html", {"form": form}) + else: + form = LDAPSettingsForm(instance=settings) + + return render(request, "ldap_settings.html", {"form": form}) diff --git a/templates/settings.html b/templates/settings.html index f6e289525..3289de143 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -139,6 +139,18 @@

{% trans "Settings" %}

> {% endif %} + {% if "horilla_ldap"|app_installed %} + {% if perms.horilla_ldap.add_ldapsettings or perms.horilla_ldap.update_ldapsettings %} +
+ {% trans "LDAP Configuration" %} +
+ {% endif %} + {% endif %}