[ADD] HORILLA LDAP: New app for handling LDAP authentication in Horilla
This commit is contained in:
@@ -123,16 +123,47 @@ if settings.env("AWS_ACCESS_KEY_ID", default=None):
|
|||||||
settings.DEFAULT_FILE_STORAGE = DEFAULT_FILE_STORAGE
|
settings.DEFAULT_FILE_STORAGE = DEFAULT_FILE_STORAGE
|
||||||
settings.AWS_S3_ADDRESSING_STYLE = AWS_S3_ADDRESSING_STYLE
|
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
|
if settings.env("AWS_ACCESS_KEY_ID", default=None) and "storages" in INSTALLED_APPS:
|
||||||
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:
|
|
||||||
settings.MEDIA_URL = f"{settings.env('MEDIA_URL')}/{settings.env('NAMESPACE')}/"
|
settings.MEDIA_URL = f"{settings.env('MEDIA_URL')}/{settings.env('NAMESPACE')}/"
|
||||||
settings.MEDIA_ROOT = f"{settings.env('MEDIA_ROOT')}/{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
|
||||||
|
|||||||
0
horilla_ldap/__init__.py
Normal file
0
horilla_ldap/__init__.py
Normal file
6
horilla_ldap/admin.py
Normal file
6
horilla_ldap/admin.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
|
from .models import LDAPSettings
|
||||||
|
|
||||||
|
admin.site.register(LDAPSettings)
|
||||||
32
horilla_ldap/apps.py
Normal file
32
horilla_ldap/apps.py
Normal file
@@ -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
|
||||||
19
horilla_ldap/forms.py
Normal file
19
horilla_ldap/forms.py
Normal file
@@ -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
|
||||||
117
horilla_ldap/management/commands/import_ldap_users.py
Normal file
117
horilla_ldap/management/commands/import_ldap_users.py
Normal file
@@ -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}."))
|
||||||
76
horilla_ldap/management/commands/import_users_to_ldap.py
Normal file
76
horilla_ldap/management/commands/import_users_to_ldap.py
Normal file
@@ -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}'))
|
||||||
0
horilla_ldap/migrations/__init__.py
Normal file
0
horilla_ldap/migrations/__init__.py
Normal file
16
horilla_ldap/models.py
Normal file
16
horilla_ldap/models.py
Normal file
@@ -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})"
|
||||||
|
|
||||||
8
horilla_ldap/templates/ldap_settings.html
Normal file
8
horilla_ldap/templates/ldap_settings.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends 'settings.html' %} {% load i18n %} {% block settings %}
|
||||||
|
{% load static %}
|
||||||
|
<h2 class="oh-inner-sidebar-content__title mb-3">{% trans "LDAP Configuration" %}</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
</form>
|
||||||
|
{% endblock settings %}
|
||||||
3
horilla_ldap/tests.py
Normal file
3
horilla_ldap/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
12
horilla_ldap/urls.py
Normal file
12
horilla_ldap/urls.py
Normal file
@@ -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'),
|
||||||
|
]
|
||||||
25
horilla_ldap/views.py
Normal file
25
horilla_ldap/views.py
Normal file
@@ -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})
|
||||||
@@ -139,6 +139,18 @@
|
|||||||
>
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if "horilla_ldap"|app_installed %}
|
||||||
|
{% if perms.horilla_ldap.add_ldapsettings or perms.horilla_ldap.update_ldapsettings %}
|
||||||
|
<div class="oh-input-group">
|
||||||
|
<a
|
||||||
|
id="date"
|
||||||
|
href="{% url 'ldap-settings' %}"
|
||||||
|
class="oh-inner-sidebar__link oh-dropdown__link"
|
||||||
|
>{% trans "LDAP Configuration" %}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user