From 540e295ef4f9a0b0f9cedd966a31eb0dd8ee11cf Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 23 Mar 2026 20:32:39 +0300 Subject: [PATCH] feat: Fix LDAP DN bugs + add password management in admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_* --- backend/api/v1/employees/views/actions.py | 114 ++++++- backend/employees/ldap/admin.py | 292 +++++++++++++++++- backend/employees/ldap/orm_models.py | 59 ++++ backend/employees/signals/ldap/department.py | 103 +++++- .../templates/admin/ldap_change_password.html | 63 ++++ .../admin/ldap_user_password_change.html | 103 ++++++ docs/guides/ldap-password-change-admin.md | 195 ++++++++++++ 7 files changed, 912 insertions(+), 17 deletions(-) create mode 100644 backend/templates/admin/ldap_change_password.html create mode 100644 backend/templates/admin/ldap_user_password_change.html create mode 100644 docs/guides/ldap-password-change-admin.md diff --git a/backend/api/v1/employees/views/actions.py b/backend/api/v1/employees/views/actions.py index ea7a0f6b..8b2dcfd0 100644 --- a/backend/api/v1/employees/views/actions.py +++ b/backend/api/v1/employees/views/actions.py @@ -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() diff --git a/backend/employees/ldap/admin.py b/backend/employees/ldap/admin.py index 741d9445..64b31b98 100644 --- a/backend/employees/ldap/admin.py +++ b/backend/employees/ldap/admin.py @@ -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( + '
' + '' + '🔑 Изменить пароль' + '' + '
' + '

' + 'Пароль будет изменён в LDAP и автоматически синхронизирован с Django БД.' + '

', + 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( '👤 {} ({})
' @@ -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( diff --git a/backend/employees/ldap/orm_models.py b/backend/employees/ldap/orm_models.py index f79ef900..37971bcb 100644 --- a/backend/employees/ldap/orm_models.py +++ b/backend/employees/ldap/orm_models.py @@ -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"" + 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. diff --git a/backend/employees/signals/ldap/department.py b/backend/employees/signals/ldap/department.py index 8acdc61b..a3bfcbd4 100644 --- a/backend/employees/signals/ldap/department.py +++ b/backend/employees/signals/ldap/department.py @@ -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( diff --git a/backend/templates/admin/ldap_change_password.html b/backend/templates/admin/ldap_change_password.html new file mode 100644 index 00000000..c8115fed --- /dev/null +++ b/backend/templates/admin/ldap_change_password.html @@ -0,0 +1,63 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + {{ media }} +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

🔑 Изменение пароля LDAP пользователей

+ +
+

Выбрано пользователей: {{ users_count }}

+
    + {% for user in users %} +
  • {{ user.display_name }} ({{ user.sam_account_name }})
  • + {% endfor %} +
+
+ +
+ {% csrf_token %} + +
+ {{ form.as_p }} +
+ +
+ + Отмена +
+ + + {% for user in users %} + + {% endfor %} + +
+ +
+

⚠️ Требования к паролю:

+
    +
  • Минимум 7 символов
  • +
  • Должен содержать буквы верхнего и нижнего регистра
  • +
  • Должен содержать цифры
  • +
  • Может содержать специальные символы
  • +
  • Не должен содержать имя пользователя
  • +
  • Должен соответствовать политике безопасности Active Directory
  • +
+ +

Важно: Пароль будет изменён и в LDAP, и в Django БД для синхронизированных пользователей.

+
+
+{% endblock %} diff --git a/backend/templates/admin/ldap_user_password_change.html b/backend/templates/admin/ldap_user_password_change.html new file mode 100644 index 00000000..613ba1f8 --- /dev/null +++ b/backend/templates/admin/ldap_user_password_change.html @@ -0,0 +1,103 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + {{ media }} +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

🔑 Изменение пароля

+ +
+

Пользователь:

+ + + + + + + + + + + + + + {% if user.employee_number %} + + + + + {% endif %} +
CN:{{ user.cn }}
sAMAccountName:{{ user.sam_account_name }}
Email:{{ user.mail }}
Employee ID:{{ user.employee_number }}
+
+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + +
+ {% for field in form %} +
+ + {{ field }} + {% if field.help_text %} +

+ {{ field.help_text }} +

+ {% endif %} + {% if field.errors %} +
+ {{ field.errors }} +
+ {% endif %} +
+ {% endfor %} +
+ +
+ + Отмена +
+
+ +
+

⚠️ Требования к паролю:

+
    +
  • Минимум 7 символов
  • +
  • Должен содержать буквы верхнего и нижнего регистра
  • +
  • Должен содержать цифры
  • +
  • Может содержать специальные символы
  • +
  • Не должен содержать имя пользователя
  • +
  • Должен соответствовать политике безопасности Active Directory
  • +
+ +

+ Важно: Пароль будет изменён в LDAP (Active Directory) + {% if user.employee_number %} + и автоматически синхронизирован с Django БД. + {% else %} + (без синхронизации с Django, т.к. employeeNumber не установлен). + {% endif %} +

+
+
+{% endblock %} diff --git a/docs/guides/ldap-password-change-admin.md b/docs/guides/ldap-password-change-admin.md new file mode 100644 index 00000000..1d2fd743 --- /dev/null +++ b/docs/guides/ldap-password-change-admin.md @@ -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 при смене пароля