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:
lizerk 2026-03-23 20:32:39 +03:00
parent 1a3d533c1d
commit 540e295ef4
7 changed files with 912 additions and 17 deletions

View File

@ -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()

View File

@ -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(

View File

@ -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.

View File

@ -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(

View 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>
&rsaquo; <a href="{% url 'admin:employees_ldapuser_changelist' %}">LDAP Users</a>
&rsaquo; {% 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 %}

View 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>
&rsaquo; <a href="{% url 'admin:employees_ldapuser_changelist' %}">LDAP Users</a>
&rsaquo; <a href="../">{{ original.cn }}</a>
&rsaquo; {% 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 %}

View 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 при смене пароля