feat: Fix LDAP DN bugs + add password management in admin
BREAKING CHANGES: - Department.head автоматически очищается при увольнении руководителя - LDAP managedBy атрибут очищается при увольнении Fixes: - DN не перемещался при восстановлении сотрудника (Dismissed → Users) - DN не перемещался при увольнении сотрудника без отделов - Ошибка при увольнении из множественных отделов (двойной modify_dn) - Неправильные URL patterns для LDAP моделей в админке - Двойное кодирование DN в редиректе смены пароля Features: - Добавлен метод _ensure_ldap_dn_location для гарантированного перемещения DN - Добавлена проверка already_there в sync_department_member_to_ldap - Управление паролями LDAP пользователей через админку: * Кнопка смены пароля в карточке пользователя * Массовое действие смены пароля * Автосинхронизация с Django password hash - Автоочистка Department.head и LDAP managedBy при увольнении руководителя - Добавлены методы set_password() и change_password() в LdapUser Changed: - Переписан сигнал sync_department_member_to_ldap с реальными методами - Исправлены URL patterns: ldap_* → employees_*
This commit is contained in:
parent
1a3d533c1d
commit
540e295ef4
@ -7,7 +7,7 @@ import logging
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from employees.constants import ACTION_DISMISSED
|
||||
from employees.models import EmployeeAction, EmployeeDepartment
|
||||
from employees.models import Department, EmployeeAction, EmployeeDepartment
|
||||
from rest_framework import filters, viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
@ -87,6 +87,20 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
emp = action_obj.employee
|
||||
|
||||
if action_obj.action == ACTION_DISMISSED:
|
||||
# Снимаем с должности руководителя (до деактивации!)
|
||||
headed_depts = list(Department.objects.filter(head=emp))
|
||||
if headed_depts:
|
||||
for dept in headed_depts:
|
||||
logger.info(
|
||||
f"Clearing head of department '{dept.name}' "
|
||||
f"(employee_id={emp.id}) due to dismissal"
|
||||
)
|
||||
Department.objects.filter(head=emp).update(
|
||||
head=None, head_appointed_at=None
|
||||
)
|
||||
# Очищаем managedBy в LDAP (best-effort)
|
||||
self._clear_ldap_managed_by(headed_depts)
|
||||
|
||||
# Деактивируем сотрудника (сигнал синхронизирует в LDAP)
|
||||
if emp.is_active:
|
||||
emp.is_active = False
|
||||
@ -107,6 +121,104 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
|
||||
emp._ldap_changes = {"is_active": True}
|
||||
emp.save(update_fields=["is_active"])
|
||||
|
||||
# Гарантируем, что DN в правильном OU (Users или Dismissed)
|
||||
# Покрывает: восстановление, увольнение без отделов, множественные отделы
|
||||
self._ensure_ldap_dn_location(emp)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_ldap_dn_location(emp):
|
||||
"""Гарантирует, что LDAP DN сотрудника в правильном OU.
|
||||
|
||||
is_active=False → OU=Dismissed
|
||||
is_active=True → OU=Users
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
if not getattr(settings, "LDAP_ENABLED", False):
|
||||
return
|
||||
|
||||
try:
|
||||
from employees.ldap.utils.ldap_utils import get_base_dn_for_employee
|
||||
from employees.models import LdapSyncState
|
||||
|
||||
sync_state = LdapSyncState.objects.filter(
|
||||
model="employee", object_pk=str(emp.pk)
|
||||
).first()
|
||||
if not sync_state or not sync_state.ldap_dn:
|
||||
return
|
||||
|
||||
current_dn = sync_state.ldap_dn
|
||||
target_base = get_base_dn_for_employee(emp)
|
||||
|
||||
# Проверяем, уже ли в правильном OU
|
||||
parts = current_dn.split(",", 1)
|
||||
if len(parts) == 2 and parts[1].lower() == target_base.lower():
|
||||
return
|
||||
|
||||
from employees.ldap.infrastructure.connections import _ldap
|
||||
from employees.ldap.repositories.ldap_repository import (
|
||||
ensure_container_exists,
|
||||
)
|
||||
|
||||
with _ldap() as conn:
|
||||
ensure_container_exists(conn, target_base)
|
||||
rdn = parts[0]
|
||||
ok = conn.modify_dn(current_dn, rdn, new_superior=target_base)
|
||||
if ok:
|
||||
new_dn = f"{rdn},{target_base}"
|
||||
sync_state.ldap_dn = new_dn
|
||||
sync_state.save(update_fields=["ldap_dn"])
|
||||
logger.info(
|
||||
f"Moved employee {emp.id} DN: "
|
||||
f"{current_dn} → {new_dn}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"LDAP modify_dn failed for employee "
|
||||
f"{emp.id}: {conn.result}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to ensure LDAP DN location for "
|
||||
f"employee {emp.id}: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clear_ldap_managed_by(departments):
|
||||
"""Очищает LDAP managedBy для переданных отделов (best-effort)."""
|
||||
from django.conf import settings
|
||||
|
||||
if not getattr(settings, "LDAP_ENABLED", False):
|
||||
return
|
||||
|
||||
try:
|
||||
from employees.ldap.orm_models import LdapOrganizationalUnit
|
||||
from employees.models import LdapSyncState
|
||||
|
||||
for dept in departments:
|
||||
sync = LdapSyncState.objects.filter(
|
||||
model="department", object_pk=str(dept.pk)
|
||||
).first()
|
||||
if not sync or not sync.ldap_dn:
|
||||
continue
|
||||
try:
|
||||
ou = LdapOrganizationalUnit.objects.get(dn=sync.ldap_dn)
|
||||
if ou.managed_by:
|
||||
ou.managed_by = ""
|
||||
ou.save()
|
||||
logger.info(
|
||||
f"Cleared LDAP managedBy for OU={sync.ldap_dn}"
|
||||
)
|
||||
except LdapOrganizationalUnit.DoesNotExist:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to clear LDAP managedBy for "
|
||||
f"dept '{dept.name}': {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clear LDAP managedBy: {e}")
|
||||
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
obj = serializer.save()
|
||||
|
||||
@ -55,10 +55,16 @@ class LdapUserAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
|
||||
def get_urls(self):
|
||||
"""Переопределяем URLs для корректной работы с DN как PK."""
|
||||
urls = super().get_urls()
|
||||
# Django автоматически добавит наши URL patterns с правильным кодированием
|
||||
return urls
|
||||
"""Добавляем URL для смены пароля конкретного пользователя."""
|
||||
from django.urls import re_path
|
||||
urls = [
|
||||
re_path(
|
||||
r'^(.+)/password/$',
|
||||
self.admin_site.admin_view(self.user_change_password),
|
||||
name='ldapuser_password_change',
|
||||
),
|
||||
]
|
||||
return urls + super().get_urls()
|
||||
|
||||
list_display = (
|
||||
'cn_display',
|
||||
@ -87,6 +93,7 @@ class LdapUserAdmin(admin.ModelAdmin):
|
||||
'member_of_display',
|
||||
'sync_info',
|
||||
'thumbnail_photo_display',
|
||||
'password_change_link',
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
@ -117,6 +124,7 @@ class LdapUserAdmin(admin.ModelAdmin):
|
||||
'fields': (
|
||||
'user_account_control',
|
||||
'description',
|
||||
'password_change_link',
|
||||
)
|
||||
}),
|
||||
('🖼️ Дополнительно', {
|
||||
@ -143,6 +151,7 @@ class LdapUserAdmin(admin.ModelAdmin):
|
||||
'sync_from_ldap_to_django',
|
||||
'sync_from_django_to_ldap',
|
||||
'show_sync_diff',
|
||||
'change_password_action',
|
||||
]
|
||||
|
||||
# Пагинация (LDAP может быть медленным)
|
||||
@ -346,6 +355,41 @@ class LdapUserAdmin(admin.ModelAdmin):
|
||||
)
|
||||
sync_info.short_description = 'Информация о синхронизации'
|
||||
|
||||
def password_change_link(self, obj):
|
||||
"""Ссылка на форму смены пароля (как в стандартной Django admin)."""
|
||||
if not obj.dn:
|
||||
return '-'
|
||||
|
||||
# Кодируем DN для URL
|
||||
from urllib.parse import quote
|
||||
dn_encoded = quote(obj.dn, safe='')
|
||||
|
||||
change_password_url = reverse(
|
||||
'admin:ldapuser_password_change',
|
||||
args=[dn_encoded],
|
||||
)
|
||||
|
||||
return format_html(
|
||||
'<div style="margin: 10px 0;">'
|
||||
'<a href="{}" class="button" style="'
|
||||
'display: inline-block; '
|
||||
'padding: 10px 20px; '
|
||||
'background: #417690; '
|
||||
'color: white; '
|
||||
'text-decoration: none; '
|
||||
'border-radius: 4px; '
|
||||
'font-weight: bold;'
|
||||
'">'
|
||||
'🔑 Изменить пароль'
|
||||
'</a>'
|
||||
'</div>'
|
||||
'<p style="color: #666; margin-top: 10px;">'
|
||||
'Пароль будет изменён в LDAP и автоматически синхронизирован с Django БД.'
|
||||
'</p>',
|
||||
change_password_url
|
||||
)
|
||||
password_change_link.short_description = 'Управление паролем'
|
||||
|
||||
# Actions для синхронизации
|
||||
|
||||
@admin.action(description='🔄 Синхронизировать LDAP → Django (LDAP как источник истины)')
|
||||
@ -518,6 +562,144 @@ class LdapUserAdmin(admin.ModelAdmin):
|
||||
level=messages.WARNING
|
||||
)
|
||||
|
||||
@admin.action(description='🔑 Изменить пароль выбранных пользователей')
|
||||
def change_password_action(self, request, queryset):
|
||||
"""Изменяет пароль выбранных LDAP пользователей.
|
||||
|
||||
Показывает форму для ввода нового пароля и применяет его
|
||||
к выбранным пользователям через AD extended operation.
|
||||
"""
|
||||
ldap_users = self._resolve_ldap_users(request)
|
||||
|
||||
if not ldap_users:
|
||||
self.message_user(
|
||||
request,
|
||||
'❌ Не удалось найти выбранных пользователей',
|
||||
level=messages.ERROR
|
||||
)
|
||||
return
|
||||
|
||||
# Если форма не отправлена - показываем её
|
||||
if 'new_password' not in request.POST:
|
||||
from django import forms
|
||||
from django.template.response import TemplateResponse
|
||||
|
||||
class PasswordForm(forms.Form):
|
||||
new_password = forms.CharField(
|
||||
label='Новый пароль',
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
|
||||
min_length=7,
|
||||
help_text='Минимум 7 символов, должен соответствовать политике AD'
|
||||
)
|
||||
confirm_password = forms.CharField(
|
||||
label='Подтверждение пароля',
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
pwd1 = cleaned.get('new_password')
|
||||
pwd2 = cleaned.get('confirm_password')
|
||||
if pwd1 and pwd2 and pwd1 != pwd2:
|
||||
raise forms.ValidationError('Пароли не совпадают')
|
||||
return cleaned
|
||||
|
||||
form = PasswordForm()
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
'title': 'Изменение пароля LDAP пользователей',
|
||||
'form': form,
|
||||
'users': ldap_users,
|
||||
'users_count': len(ldap_users),
|
||||
'action_name': 'change_password_action',
|
||||
'opts': self.model._meta,
|
||||
'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
|
||||
'media': self.media,
|
||||
}
|
||||
request.current_app = self.admin_site.name
|
||||
|
||||
# Используем кастомный шаблон для смены пароля
|
||||
return TemplateResponse(
|
||||
request,
|
||||
'admin/ldap_change_password.html',
|
||||
context,
|
||||
)
|
||||
|
||||
# Форма отправлена - обрабатываем
|
||||
new_password = request.POST.get('new_password', '').strip()
|
||||
confirm_password = request.POST.get('confirm_password', '').strip()
|
||||
|
||||
if not new_password:
|
||||
self.message_user(
|
||||
request,
|
||||
'❌ Пароль не может быть пустым',
|
||||
level=messages.ERROR
|
||||
)
|
||||
return
|
||||
|
||||
if new_password != confirm_password:
|
||||
self.message_user(
|
||||
request,
|
||||
'❌ Пароли не совпадают',
|
||||
level=messages.ERROR
|
||||
)
|
||||
return
|
||||
|
||||
if len(new_password) < 7:
|
||||
self.message_user(
|
||||
request,
|
||||
'❌ Пароль должен содержать минимум 7 символов',
|
||||
level=messages.ERROR
|
||||
)
|
||||
return
|
||||
|
||||
# Применяем пароль ко всем выбранным пользователям
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
for ldap_user in ldap_users:
|
||||
try:
|
||||
ldap_user.set_password(new_password)
|
||||
success_count += 1
|
||||
|
||||
# Обновляем Django Employee если есть связь
|
||||
if ldap_user.employee_number:
|
||||
try:
|
||||
emp = Employee.objects.get(pk=int(ldap_user.employee_number))
|
||||
emp.set_password(new_password)
|
||||
emp.save(update_fields=['password'])
|
||||
except (Employee.DoesNotExist, ValueError):
|
||||
pass # Не критично
|
||||
|
||||
except ValueError as e:
|
||||
error_count += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f'⚠️ {ldap_user.cn}: {e}',
|
||||
level=messages.WARNING
|
||||
)
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
self.message_user(
|
||||
request,
|
||||
f'❌ {ldap_user.cn}: ошибка - {e}',
|
||||
level=messages.ERROR
|
||||
)
|
||||
|
||||
if success_count:
|
||||
self.message_user(
|
||||
request,
|
||||
f'✅ Пароль успешно изменён для {success_count} пользователей',
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
if error_count:
|
||||
self.message_user(
|
||||
request,
|
||||
f'❌ Ошибок: {error_count}',
|
||||
level=messages.WARNING
|
||||
)
|
||||
|
||||
@admin.action(description='🔍 Показать различия LDAP ↔ Django')
|
||||
def show_sync_diff(self, request, queryset):
|
||||
"""Показывает различия между LDAP и Django для выбранных пользователей."""
|
||||
@ -568,6 +750,100 @@ class LdapUserAdmin(admin.ModelAdmin):
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
# Страница смены пароля для конкретного пользователя
|
||||
|
||||
def user_change_password(self, request, dn_encoded):
|
||||
"""Форма смены пароля для конкретного LDAP пользователя."""
|
||||
from django import forms
|
||||
from django.template.response import TemplateResponse
|
||||
from django.http import HttpResponseRedirect
|
||||
from urllib.parse import unquote
|
||||
|
||||
# Декодируем DN
|
||||
dn = unquote(dn_encoded)
|
||||
|
||||
# Находим пользователя
|
||||
try:
|
||||
user = LdapUser.objects.get(dn=dn)
|
||||
except LdapUser.DoesNotExist:
|
||||
self.message_user(
|
||||
request,
|
||||
'❌ Пользователь не найден',
|
||||
level=messages.ERROR
|
||||
)
|
||||
return HttpResponseRedirect('../../') # Возврат на список
|
||||
|
||||
# Форма для смены пароля
|
||||
class PasswordChangeForm(forms.Form):
|
||||
password1 = forms.CharField(
|
||||
label='Новый пароль',
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
|
||||
min_length=7,
|
||||
help_text='Минимум 7 символов, должен соответствовать политике AD'
|
||||
)
|
||||
password2 = forms.CharField(
|
||||
label='Подтверждение пароля',
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
pwd1 = cleaned.get('password1')
|
||||
pwd2 = cleaned.get('password2')
|
||||
if pwd1 and pwd2 and pwd1 != pwd2:
|
||||
raise forms.ValidationError('Пароли не совпадают')
|
||||
return cleaned
|
||||
|
||||
if request.method == 'POST':
|
||||
form = PasswordChangeForm(request.POST)
|
||||
if form.is_valid():
|
||||
new_password = form.cleaned_data['password1']
|
||||
|
||||
try:
|
||||
# Меняем пароль в LDAP
|
||||
user.set_password(new_password)
|
||||
|
||||
# Синхронизируем с Django Employee
|
||||
if user.employee_number:
|
||||
try:
|
||||
emp = Employee.objects.get(pk=int(user.employee_number))
|
||||
emp.set_password(new_password)
|
||||
emp.save(update_fields=['password'])
|
||||
except (Employee.DoesNotExist, ValueError):
|
||||
pass # Не критично
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'✅ Пароль успешно изменён для {user.cn}',
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
# Перенаправляем на список пользователей (избегаем проблем с кодировкой DN)
|
||||
return HttpResponseRedirect('../../')
|
||||
|
||||
except ValueError as e:
|
||||
form.add_error(None, f'Ошибка валидации: {e}')
|
||||
except Exception as e:
|
||||
form.add_error(None, f'Ошибка LDAP: {e}')
|
||||
else:
|
||||
form = PasswordChangeForm()
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
'title': f'Изменение пароля: {user.cn}',
|
||||
'form': form,
|
||||
'user': user,
|
||||
'opts': self.model._meta,
|
||||
'original': user,
|
||||
'media': self.media,
|
||||
}
|
||||
|
||||
return TemplateResponse(
|
||||
request,
|
||||
'admin/ldap_user_password_change.html',
|
||||
context,
|
||||
)
|
||||
|
||||
# Переопределение разрешений (опционально - можно разрешить редактирование)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
@ -806,7 +1082,7 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin):
|
||||
ldap_user = LdapUser.objects.get(dn=mb)
|
||||
# Правильный URL pattern для LDAP моделей
|
||||
url = reverse(
|
||||
'admin:ldap_ldapuser_change', args=[quote(ldap_user.dn, safe='')],
|
||||
'admin:employees_ldapuser_change', args=[quote(ldap_user.dn, safe='')],
|
||||
)
|
||||
return format_html(
|
||||
'<a href="{}">👤 {} ({})</a><br>'
|
||||
@ -1954,7 +2230,7 @@ class LdapGroupAdmin(admin.ModelAdmin):
|
||||
try:
|
||||
ldap_user = LdapUser.objects.get(dn=member_dn)
|
||||
url = reverse(
|
||||
'admin:ldap_ldapuser_change',
|
||||
'admin:employees_ldapuser_change',
|
||||
args=[quote(ldap_user.dn, safe='')],
|
||||
)
|
||||
html += format_html(
|
||||
@ -1968,7 +2244,7 @@ class LdapGroupAdmin(admin.ModelAdmin):
|
||||
try:
|
||||
ldap_group = LdapGroup.objects.get(dn=member_dn)
|
||||
url = reverse(
|
||||
'admin:ldap_ldapgroup_change',
|
||||
'admin:employees_ldapgroup_change',
|
||||
args=[quote(ldap_group.dn, safe='')],
|
||||
)
|
||||
html += format_html(
|
||||
@ -2013,7 +2289,7 @@ class LdapGroupAdmin(admin.ModelAdmin):
|
||||
try:
|
||||
parent_group = LdapGroup.objects.get(dn=group_dn)
|
||||
url = reverse(
|
||||
'admin:ldap_ldapgroup_change',
|
||||
'admin:employees_ldapgroup_change',
|
||||
args=[quote(parent_group.dn, safe='')],
|
||||
)
|
||||
html += format_html(
|
||||
|
||||
@ -284,6 +284,14 @@ class LdapUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
|
||||
# Управление учетной записью
|
||||
user_account_control = IntegerField(db_column='userAccountControl')
|
||||
|
||||
# Пароль (write-only атрибут AD)
|
||||
# ВАЖНО: unicodePwd - специальный атрибут Active Directory
|
||||
# - Write-only: можно записать, но нельзя прочитать
|
||||
# - Требует SSL/TLS соединения
|
||||
# - Требует специальной кодировки: UTF-16-LE с кавычками
|
||||
# - Используйте метод set_password() вместо прямой записи
|
||||
unicode_pwd = CharField(db_column='unicodePwd', blank=True)
|
||||
|
||||
# Дополнительная информация
|
||||
description = CharField(db_column='description', blank=True)
|
||||
thumbnail_photo = ImageField(db_column='thumbnailPhoto', blank=True)
|
||||
@ -307,6 +315,57 @@ class LdapUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
|
||||
def __repr__(self):
|
||||
return f"<LdapUser: {self.sam_account_name}>"
|
||||
|
||||
def set_password(self, new_password: str) -> None:
|
||||
"""Устанавливает новый пароль для пользователя через AD extended operation.
|
||||
|
||||
Использует Microsoft-специфичное расширение modify_password для корректной
|
||||
работы с политиками паролей AD. Это более надежный способ, чем прямая
|
||||
запись в unicodePwd.
|
||||
|
||||
Args:
|
||||
new_password: Новый пароль (plaintext)
|
||||
|
||||
Raises:
|
||||
ValueError: Если пароль пустой или не соответствует политике
|
||||
DirectoryLdapError: Если операция не удалась
|
||||
|
||||
Example:
|
||||
>>> ldap_user = LdapUser.objects.get(dn='CN=...')
|
||||
>>> ldap_user.set_password('NewSecurePass123!')
|
||||
>>> # Пароль изменён в AD, НЕ требуется .save()
|
||||
"""
|
||||
from .infrastructure.connections import _ldap
|
||||
from .services.user_password_service import UserPasswordService
|
||||
|
||||
service = UserPasswordService()
|
||||
with _ldap() as conn:
|
||||
service.set_password(conn, self.dn, new_password)
|
||||
|
||||
def change_password(self, old_password: str, new_password: str) -> None:
|
||||
"""Меняет пароль пользователя (требует знания старого пароля).
|
||||
|
||||
Используется для самостоятельной смены пароля пользователем.
|
||||
|
||||
Args:
|
||||
old_password: Текущий пароль
|
||||
new_password: Новый пароль
|
||||
|
||||
Raises:
|
||||
ValueError: Если новый пароль не соответствует политике
|
||||
DirectoryLdapError: Если старый пароль неверен или операция не удалась
|
||||
|
||||
Example:
|
||||
>>> ldap_user = LdapUser.objects.get(dn='CN=...')
|
||||
>>> ldap_user.change_password('OldPass123', 'NewSecurePass123!')
|
||||
>>> # Пароль изменён в AD, НЕ требуется .save()
|
||||
"""
|
||||
from .infrastructure.connections import _ldap
|
||||
from .services.user_password_service import UserPasswordService
|
||||
|
||||
service = UserPasswordService()
|
||||
with _ldap() as conn:
|
||||
service.change_password(conn, self.dn, old_password, new_password)
|
||||
|
||||
|
||||
class LdapGroup(ModifyDnMixin, LdapModel):
|
||||
"""LDAP модель для глобальных групп Active Directory.
|
||||
|
||||
@ -8,6 +8,7 @@ import logging
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_delete, post_save, m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from employees.ldap import DepartmentService
|
||||
from employees.ldap.domain.dtos import DirectoryDepartmentDTO
|
||||
@ -157,7 +158,11 @@ def sync_department_to_ldap_on_delete(sender, instance, **kwargs):
|
||||
|
||||
@receiver(post_save, sender='employees.EmployeeDepartment')
|
||||
def sync_department_member_to_ldap(sender, instance, created, **kwargs):
|
||||
"""Синхронизирует добавление/изменение члена отдела с LDAP."""
|
||||
"""Синхронизирует добавление/изменение члена отдела с LDAP.
|
||||
|
||||
is_active=True → перемещаем в OU отдела + добавляем в группу
|
||||
is_active=False → перемещаем в Users/Dismissed OU + убираем из группы
|
||||
"""
|
||||
if not _is_ldap_enabled():
|
||||
return
|
||||
|
||||
@ -165,24 +170,106 @@ def sync_department_member_to_ldap(sender, instance, created, **kwargs):
|
||||
return
|
||||
|
||||
try:
|
||||
from employees.ldap.infrastructure.connections import _ldap
|
||||
from employees.ldap.repositories.ldap_repository import ensure_container_exists
|
||||
from employees.ldap.services.department_service import DepartmentService
|
||||
from employees.ldap.services.group_service import GroupService
|
||||
from employees.ldap.services.user_service import UserService
|
||||
|
||||
from employees.ldap.utils.dn_utils import _move_to_department
|
||||
from employees.ldap.utils.ldap_utils import get_base_dn_for_employee
|
||||
from employees.ldap.services.constants import SyncDirection
|
||||
|
||||
group_service = GroupService()
|
||||
user_service = UserService(group_service)
|
||||
dept_service = DepartmentService(group_service, user_service)
|
||||
|
||||
employee = instance.employee
|
||||
department = instance.department
|
||||
|
||||
# Получаем DN сотрудника
|
||||
try:
|
||||
emp_dn = user_service._get_employee_dn(employee)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
f"Employee {employee.id} has no LDAP DN, skipping member sync"
|
||||
)
|
||||
return
|
||||
|
||||
if instance.is_active:
|
||||
# Добавляем/перемещаем пользователя в OU отдела
|
||||
dept_service._move_user_to_department(instance.employee, instance.department)
|
||||
|
||||
# === Активация: перемещаем в OU отдела + добавляем в группу ===
|
||||
try:
|
||||
dept_dn = dept_service._get_department_dn(department)
|
||||
except Exception:
|
||||
dept_dn = None
|
||||
|
||||
with _ldap() as conn:
|
||||
ensured_dn = dept_service._ensure_department_ou(conn, department.name)
|
||||
if not dept_dn or dept_dn != ensured_dn:
|
||||
dept_service._touch_state(
|
||||
model="department",
|
||||
object_pk=department.pk,
|
||||
ldap_dn=ensured_dn,
|
||||
last_django_modify_ts=timezone.now(),
|
||||
sync_dir=SyncDirection.AUTO,
|
||||
)
|
||||
dept_dn = ensured_dn
|
||||
|
||||
# Перемещаем пользователя в OU отдела
|
||||
new_dn = _move_to_department(conn, emp_dn, dept_dn)
|
||||
if new_dn != emp_dn:
|
||||
dept_service._touch_state(
|
||||
model="employee",
|
||||
object_pk=employee.pk,
|
||||
ldap_dn=new_dn,
|
||||
sync_dir=SyncDirection.LDAP,
|
||||
)
|
||||
|
||||
# Добавляем в группу отдела
|
||||
group_dn = dept_service._ensure_department_group(conn, department, dept_dn)
|
||||
if group_dn:
|
||||
group_service.add_members(group_dn, [new_dn])
|
||||
|
||||
# Устанавливаем роль если есть
|
||||
if instance.role:
|
||||
dept_service.set_member_role(instance.department, instance.employee, instance.role)
|
||||
dept_service.set_member_role(department, employee, instance.role)
|
||||
else:
|
||||
# Перемещаем обратно в Users OU
|
||||
dept_service._move_user_to_base_ou(instance.employee)
|
||||
# === Деактивация: убираем из группы + перемещаем в Users/Dismissed OU ===
|
||||
with _ldap() as conn:
|
||||
# Убираем из группы отдела
|
||||
grp_dn = (department.ldap_group_dn or "").strip()
|
||||
if grp_dn:
|
||||
try:
|
||||
group_service.remove_members(grp_dn, [emp_dn])
|
||||
except Exception as grp_err:
|
||||
logger.warning(
|
||||
f"Failed to remove {employee.id} "
|
||||
f"from group {grp_dn}: {grp_err}"
|
||||
)
|
||||
|
||||
# Определяем целевой OU
|
||||
target_base = get_base_dn_for_employee(employee)
|
||||
|
||||
# Проверяем: уже в целевом OU?
|
||||
parts = emp_dn.split(",", 1)
|
||||
already_there = (
|
||||
len(parts) == 2
|
||||
and parts[1].lower() == target_base.lower()
|
||||
)
|
||||
if not already_there:
|
||||
ensure_container_exists(conn, target_base)
|
||||
new_dn = user_service._move_user_to_base(
|
||||
conn, emp_dn, target_base
|
||||
)
|
||||
dept_service._touch_state(
|
||||
model="employee",
|
||||
object_pk=employee.pk,
|
||||
ldap_dn=new_dn,
|
||||
sync_dir=SyncDirection.LDAP,
|
||||
)
|
||||
logger.info(
|
||||
f"Moved employee {employee.id} "
|
||||
f"from {emp_dn} to {new_dn}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
63
backend/templates/admin/ldap_change_password.html
Normal file
63
backend/templates/admin/ldap_change_password.html
Normal file
@ -0,0 +1,63 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n admin_urls static %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
{{ media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› <a href="{% url 'admin:employees_ldapuser_changelist' %}">LDAP Users</a>
|
||||
› {% trans 'Change Password' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 600px; margin: 20px auto;">
|
||||
<h1>🔑 Изменение пароля LDAP пользователей</h1>
|
||||
|
||||
<div class="module" style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-left: 4px solid #007bff;">
|
||||
<h3>Выбрано пользователей: {{ users_count }}</h3>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
{% for user in users %}
|
||||
<li><strong>{{ user.display_name }}</strong> ({{ user.sam_account_name }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-row">
|
||||
{{ form.as_p }}
|
||||
</div>
|
||||
|
||||
<div class="submit-row" style="margin-top: 20px;">
|
||||
<input type="submit" value="Изменить пароль" class="default" />
|
||||
<a href="{% url 'admin:employees_ldapuser_changelist' %}" class="button cancel-link">Отмена</a>
|
||||
</div>
|
||||
|
||||
<!-- Сохраняем выбранные пользователи -->
|
||||
{% for user in users %}
|
||||
<input type="hidden" name="_selected_action" value="{{ user.dn }}" />
|
||||
{% endfor %}
|
||||
<input type="hidden" name="action" value="{{ action_name }}" />
|
||||
</form>
|
||||
|
||||
<div class="help" style="margin-top: 30px; padding: 15px; background: #fff3cd; border-left: 4px solid #ffc107;">
|
||||
<h4>⚠️ Требования к паролю:</h4>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>Минимум 7 символов</li>
|
||||
<li>Должен содержать буквы верхнего и нижнего регистра</li>
|
||||
<li>Должен содержать цифры</li>
|
||||
<li>Может содержать специальные символы</li>
|
||||
<li>Не должен содержать имя пользователя</li>
|
||||
<li>Должен соответствовать политике безопасности Active Directory</li>
|
||||
</ul>
|
||||
|
||||
<p style="margin-top: 10px;"><strong>Важно:</strong> Пароль будет изменён и в LDAP, и в Django БД для синхронизированных пользователей.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
103
backend/templates/admin/ldap_user_password_change.html
Normal file
103
backend/templates/admin/ldap_user_password_change.html
Normal file
@ -0,0 +1,103 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n admin_urls static %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
{{ media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› <a href="{% url 'admin:employees_ldapuser_changelist' %}">LDAP Users</a>
|
||||
› <a href="../">{{ original.cn }}</a>
|
||||
› {% trans 'Change Password' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main" style="max-width: 600px;">
|
||||
<h1>🔑 Изменение пароля</h1>
|
||||
|
||||
<div class="module" style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-left: 4px solid #007bff;">
|
||||
<h3 style="margin-top: 0;">Пользователь:</h3>
|
||||
<table style="width: 100%; margin-top: 10px;">
|
||||
<tr>
|
||||
<td style="padding: 5px; font-weight: bold; width: 150px;">CN:</td>
|
||||
<td style="padding: 5px;">{{ user.cn }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px; font-weight: bold;">sAMAccountName:</td>
|
||||
<td style="padding: 5px;">{{ user.sam_account_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px; font-weight: bold;">Email:</td>
|
||||
<td style="padding: 5px;">{{ user.mail }}</td>
|
||||
</tr>
|
||||
{% if user.employee_number %}
|
||||
<tr>
|
||||
<td style="padding: 5px; font-weight: bold;">Employee ID:</td>
|
||||
<td style="padding: 5px;">{{ user.employee_number }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" style="margin-top: 20px;">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="errornote" style="margin-bottom: 15px;">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-row">
|
||||
{% for field in form %}
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label for="{{ field.id_for_label }}" style="display: block; font-weight: bold; margin-bottom: 5px;">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<p class="help" style="color: #666; margin-top: 5px; font-size: 12px;">
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<div class="errorlist" style="color: #ba2121; margin-top: 5px;">
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="submit-row" style="margin-top: 20px; padding: 10px 0; border-top: 1px solid #ddd;">
|
||||
<input type="submit" value="Изменить пароль" class="default" />
|
||||
<a href=".." class="button cancel-link">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="help" style="margin-top: 30px; padding: 15px; background: #fff3cd; border-left: 4px solid #ffc107;">
|
||||
<h4 style="margin-top: 0;">⚠️ Требования к паролю:</h4>
|
||||
<ul style="margin: 10px 0 0 20px; padding: 0;">
|
||||
<li>Минимум 7 символов</li>
|
||||
<li>Должен содержать буквы верхнего и нижнего регистра</li>
|
||||
<li>Должен содержать цифры</li>
|
||||
<li>Может содержать специальные символы</li>
|
||||
<li>Не должен содержать имя пользователя</li>
|
||||
<li>Должен соответствовать политике безопасности Active Directory</li>
|
||||
</ul>
|
||||
|
||||
<p style="margin-top: 15px; margin-bottom: 0;">
|
||||
<strong>Важно:</strong> Пароль будет изменён в LDAP (Active Directory)
|
||||
{% if user.employee_number %}
|
||||
и автоматически синхронизирован с Django БД.
|
||||
{% else %}
|
||||
(без синхронизации с Django, т.к. employeeNumber не установлен).
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
195
docs/guides/ldap-password-change-admin.md
Normal file
195
docs/guides/ldap-password-change-admin.md
Normal file
@ -0,0 +1,195 @@
|
||||
# Смена паролей LDAP через админку
|
||||
|
||||
## Возможности
|
||||
|
||||
Теперь можно менять пароли пользователей LDAP прямо через Django админ-панель!
|
||||
|
||||
### Добавлено в LdapUser модель:
|
||||
|
||||
1. **Поле `unicode_pwd`** (write-only) - для записи паролей в AD
|
||||
2. **Метод `set_password(new_password)`** - установка пароля администратором
|
||||
3. **Метод `change_password(old_password, new_password)`** - смена пароля пользователем
|
||||
|
||||
### Добавлено в админку:
|
||||
|
||||
1. **Кнопка "🔑 Изменить пароль"** в карточке пользователя
|
||||
2. **Action "🔑 Изменить пароль выбранных пользователей"** в списке
|
||||
|
||||
## Как использовать
|
||||
|
||||
### Вариант 1: Через карточку пользователя (РЕКОМЕНДУЕТСЯ ⭐)
|
||||
|
||||
1. Откройте `/admin/employees/ldapuser/`
|
||||
2. **Кликните** на нужном пользователе (откроется карточка)
|
||||
3. Прокрутите до секции **"⚙️ Управление учетной записью"**
|
||||
4. Нажмите кнопку **"🔑 Изменить пароль"**
|
||||
5. Введите новый пароль (минимум 7 символов)
|
||||
6. Подтвердите пароль
|
||||
7. Нажмите **"Изменить пароль"**
|
||||
|
||||
**Результат:**
|
||||
- Пароль изменён в LDAP (Active Directory)
|
||||
- Пароль автоматически синхронизирован в Django БД (если есть связь через `employee_number`)
|
||||
- Вы вернётесь на страницу редактирования пользователя
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Удобнее - не нужно искать пользователя в списке и выбирать action
|
||||
- ✅ Быстрее - сразу видно, какого пользователя редактируем
|
||||
- ✅ Безопаснее - меньше шансов ошибиться с выбором пользователя
|
||||
|
||||
### Вариант 2: Через action в списке (массовая операция)
|
||||
|
||||
1. Откройте `/admin/employees/ldapuser/`
|
||||
2. Выберите одного или нескольких пользователей (чекбоксы)
|
||||
3. В dropdown "Действие" выберите **"🔑 Изменить пароль выбранных пользователей"**
|
||||
4. Нажмите "Выполнить"
|
||||
5. Введите новый пароль (минимум 7 символов)
|
||||
6. Подтвердите пароль
|
||||
7. Нажмите "Изменить пароль"
|
||||
|
||||
**Результат:**
|
||||
- Пароль изменён в LDAP (Active Directory)
|
||||
- Пароль автоматически синхронизирован в Django БД (если есть связь через `employee_number`)
|
||||
- Вы получите сообщение об успехе или ошибке
|
||||
|
||||
**Когда использовать:**
|
||||
- При необходимости сменить пароль сразу нескольким пользователям
|
||||
- Для массовых операций (например, сброс паролей всем новым сотрудникам)
|
||||
|
||||
### Вариант 3: Программно через ORM
|
||||
|
||||
```python
|
||||
from employees.ldap.orm_models import LdapUser
|
||||
|
||||
# Найти пользователя
|
||||
user = LdapUser.objects.get(sam_account_name='ivanov_i')
|
||||
|
||||
# Установить новый пароль (админ)
|
||||
user.set_password('NewSecurePass123!')
|
||||
|
||||
# Или сменить пароль (требует старый пароль)
|
||||
user.change_password('OldPass123', 'NewSecurePass123!')
|
||||
4
|
||||
# НЕ нужно вызывать .save() — пароль уже изменён в AD!
|
||||
```
|
||||
|
||||
### Вариант 3: Через Django Shell
|
||||
|
||||
```bash
|
||||
cd /home/lizerk/Dev/EUSRR/backend
|
||||
.venv/bin/python manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
from employees.ldap.orm_models import LdapUser
|
||||
from employees.models import Employee
|
||||
|
||||
# Найти LDAP пользователя
|
||||
ldap_user = LdapUser.objects.get(sam_account_name='ivanov_i')
|
||||
|
||||
# Установить пароль
|
||||
ldap_user.set_password('NewPass123!')
|
||||
|
||||
# Синхронизировать с Django (опционально)
|
||||
if ldap_user.employee_number:
|
||||
emp = Employee.objects.get(pk=int(ldap_user.employee_number))
|
||||
emp.set_password('NewPass123!')
|
||||
emp.save(update_fields=['password'])
|
||||
print(f"✅ Пароль синхронизирован для {emp.email}")
|
||||
```
|
||||
|
||||
## Требования к паролю
|
||||
|
||||
Active Directory политика по умолчанию:
|
||||
- ✅ Минимум **7 символов** (обычно 8+)
|
||||
- ✅ Буквы **верхнего и нижнего** регистра
|
||||
- ✅ **Цифры**
|
||||
- ✅ **Специальные символы** (рекомендуется)
|
||||
- ❌ НЕ должен содержать имя пользователя
|
||||
- ❌ НЕ должен быть в истории паролей
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
### ValueError
|
||||
```
|
||||
Пароль не соответствует политике сложности Active Directory
|
||||
```
|
||||
**Решение:** Используйте более сложный пароль (комбинация букв, цифр, спецсимволов)
|
||||
|
||||
### ValueError
|
||||
```
|
||||
Пароль слишком короткий
|
||||
```
|
||||
**Решение:** Увеличьте длину пароля (минимум 7-8 символов)
|
||||
|
||||
### DirectoryLdapError
|
||||
```
|
||||
Не удалось установить пароль: {...}
|
||||
```
|
||||
**Причины:**
|
||||
- Отсутствует SSL/TLS соединение с AD
|
||||
- Недостаточно прав у сервисной учётки
|
||||
- Проблемы с подключением к LDAP
|
||||
|
||||
## Безопасность
|
||||
|
||||
⚠️ **Важно:**
|
||||
- Пароли передаются через защищённое LDAP соединение (SSL/TLS)
|
||||
- В БД Django хранится только хэш (не plaintext)
|
||||
- В LDAP пароль хранится в защищённом виде (невозможно прочитать)
|
||||
- Логируются только попытки смены пароля (без самого пароля)
|
||||
|
||||
## Интеграция с существующим функционалом
|
||||
|
||||
Эта функция **дополняет**, а не заменяет:
|
||||
- `UserService.update_user(emp, changes={'password': '...'})` - работает как и раньше
|
||||
- Django admin кнопка "Изменить пароль" для Employee - работает, но **НЕ синхронизирует** с LDAP автоматически
|
||||
|
||||
Для **автоматической синхронизации** при смене пароля через стандартную админку Employee нужно добавить переопределение метода (см. предыдущее обсуждение).
|
||||
|
||||
## Техническая информация
|
||||
|
||||
### Как это реализовано
|
||||
|
||||
1. **LdapUser.set_password()** вызывает `UserPasswordService.set_password()`
|
||||
2. Сервис использует `conn.extend.microsoft.modify_password()` (AD extended operation)
|
||||
3. Это более надёжно, чем прямая запись в атрибут `unicodePwd`
|
||||
4. Политика паролей AD проверяется автоматически
|
||||
5. При ошибках возвращаются понятные сообщения
|
||||
|
||||
### Поле unicode_pwd
|
||||
|
||||
- **Тип:** `CharField` (но пустое по умолчанию)
|
||||
- **Write-only:** можно записать, нельзя прочитать
|
||||
- **Кодировка:** UTF-16-LE с кавычками (обрабатывается автоматически)
|
||||
- **Использование:** Не рекомендуется напрямую, используйте `.set_password()`
|
||||
|
||||
## Тестирование
|
||||
|
||||
```python
|
||||
# В Django shell
|
||||
from employees.ldap.orm_models import LdapUser
|
||||
|
||||
# Создать тестового пользователя (если есть)
|
||||
user = LdapUser.objects.get(sam_account_name='test_user')
|
||||
|
||||
# Попробовать сменить пароль
|
||||
try:
|
||||
user.set_password('TestPass123!')
|
||||
print("✅ Пароль успешно изменён")
|
||||
except ValueError as e:
|
||||
print(f"❌ Ошибка валидации: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка LDAP: {e}")
|
||||
```
|
||||
|
||||
## История изменений
|
||||
|
||||
**23.03.2026:**
|
||||
- ✅ Добавлено поле `unicode_pwd` в `LdapUser`
|
||||
- ✅ Добавлены методы `set_password()` и `change_password()`
|
||||
- ✅ Добавлен action "🔑 Изменить пароль" в список пользователей
|
||||
- ✅ **Добавлена кнопка "🔑 Изменить пароль" в карточку пользователя** ⭐
|
||||
- ✅ Создан шаблон `admin/ldap_change_password.html` для массовой смены
|
||||
- ✅ Создан шаблон `admin/ldap_user_password_change.html` для индивидуальной смены
|
||||
- ✅ Автоматическая синхронизация с Django Employee при смене пароля
|
||||
Loading…
x
Reference in New Issue
Block a user