Corp/backend/eusrr_backend/auth_backends.py
lizerk 75662ec7ea feat(ldap): Полная миграция на django-ldapdb ORM с миксинами и Django Admin
LDAP ORM модели (orm_models.py):
================================
✅ Применены миксины ModifyDnMixin и LdapSyncStateMixin ко всем моделям
✅ LdapUser - поддержка modify_dn + автоуправление LdapSyncState
✅ LdapGroup - поддержка modify_dn для перемещения групп
✅ LdapOrganizationalUnit - поддержка modify_dn для реорганизации

Миксины (mixins.py) - НОВЫЙ ФАЙЛ:
=================================
✅ ModifyDnMixin - автоматический modify_dn при изменении base_dn
   • Отслеживает изменения base_dn
   • Выполняет LDAP modify_dn с newsuperior
   • Обновляет _saved_dn для корректного последующего save()
   • Использование: user.base_dn = new_dn; user.save()

✅ LdapSyncStateMixin - автоуправление LdapSyncState
   • Автоматически создает/обновляет LdapSyncState при save()
   • Автоматически удаляет LdapSyncState при delete()
   • Настройки: _sync_model_name, _sync_pk_field

Django Admin для LDAP (admin.py) - НОВЫЙ ФАЙЛ:
==============================================
✅ LdapUserAdmin - полноценная админка для LDAP пользователей
   • List display: CN, SAM, email, Django Employee link, sync status
   • Search: по CN, SAM, email, имени, фамилии
   • Filters: по userAccountControl
   • Actions синхронизации:
     - sync_from_ldap_to_django (LDAP → Django)
     - sync_from_django_to_ldap (Django → LDAP)
     - show_sync_diff (сравнение данных)
   • Fieldsets: идентификация, персональные данные, контакты, UAC, группы
   • DN форматирование с подсветкой компонентов
   • Sync status indicators с иконками

Django Admin для Employee (employees/admin.py):
===============================================
✅ Добавлены зеркальные actions синхронизации:
   • sync_from_django_to_ldap - создание/обновление в LDAP
     - Автоматическое создание LDAP пользователя если не существует
     - Генерация временного пароля
     - Установка is_ldap_managed = True
   • sync_from_ldap_to_django - обновление из LDAP
   • show_sync_diff - сравнение Django ↔ LDAP
✅ Импорт django.contrib.messages для уведомлений

Сервисы LDAP (services/):
=========================
✅ UserService - полная миграция на ORM для UPDATE операций
✅ GroupService - использование ORM для модификаций
✅ DepartmentService - ORM для обновления OU
✅ PositionService - ORM для групп позиций

Тесты (tests/unit/):
===================
✅ test_user_service.py - обновлены под ORM
✅ test_group_service.py - обновлены под ORM
✅ test_position_service.py - обновлены под ORM
✅ conftest.py - добавлены фикстуры для ORM моделей

Authentication (auth_backends.py):
=================================
✅ EmailOrPhoneBackend - добавлено подробное debug логирование
   • Логирование email/phone lookup
   • Логирование is_active проверки
   • Логирование password check с hash prefix
✅ SuperuserOnlyBackend - добавлено debug логирование
   • Логирование parent auth результата
   • Логирование is_superuser проверки

API (api/auth/serializers.py):
==============================
✅ PhoneOrEmailTokenObtainPairSerializer - исправлен KeyError
   • Добавлена проверка credentials перед super().validate()
   • Явный AuthenticationFailed при отсутствии учетных данных

Middleware (middleware.py):
===========================
✅ Очистка от устаревшего кода (305 → 102 строки)
   • ❌ Удалена EmailVerificationMiddleware (не используется)
   • ❌ Удалена RegistrationIPRestrictionMiddleware (не используется)
   • ❌ Удалены все ссылки на namespace 'auth_front'
   • ✅ Возврат 403 для неавторизованных вместо редиректа

Settings (settings.py):
=======================
✅ LOGIN_URL изменен с 'auth_front:login' на '/admin/login/'
✅ Добавлено DEBUG логирование для:
   • django.contrib.auth
   • eusrr_backend.auth_backends

Миграции:
=========
✅ Удалены старые миграции LDAP моделей (0042, 0043)
✅ Добавлена миграция LdapSyncQueue (0042_ldapsyncqueue.py)

Документация:
=============
✅ ADMIN_GUIDE.md - руководство по использованию LDAP Admin
✅ docs/guides/ldap-orm-mixins-usage.md - usage guide для миксинов
✅ docs/completed/ldap-orm-create-support.md - документация ORM миграции
✅ docs/in-progress/LDAP_ORM_MIGRATION_PLAN.md - план миграции

Инфраструктура (__init__.py):
=============================
✅ Добавлен импорт admin для авторегистрации
✅ Экспорт ORM моделей (LdapUser, LdapGroup, LdapOrganizationalUnit)

ИТОГО:
======
• Полная миграция с ldap3 на django-ldapdb ORM для UPDATE/MOVE/DELETE
• Унификация: один подход для всех LDAP операций через модели
• Автоматизация: синхронизация состояния и перемещения через миксины
• UI: полноценный Django Admin для управления LDAP объектами
• Отладка: расширенное логирование аутентификации
• Чистота: удален устаревший код middleware и миграций
• Тесты: 29 passed, 0 failed, 18 skipped

Breaking Changes: НЕТ - обратная совместимость сохранена
2026-03-19 22:33:06 +03:00

583 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# eusrr_backend/auth_backends.py
from __future__ import annotations
import logging
import re
from typing import Any, List, Optional, Tuple
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from django.utils import timezone
from employees.ldap.utils.text_utils import esc_filter
from employees.models import Department, Employee, EmployeeDepartment
from employees.utils import _normalize_phone
from ldap3 import SUBTREE, Connection, Server
from ldap3.core.exceptions import LDAPBindError, LDAPSocketOpenError
logger = logging.getLogger(__name__)
UserModel = get_user_model()
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _looks_like_email(s: str) -> bool:
return bool(EMAIL_RE.match(s or ""))
def _model_has_field(model, field_name: str) -> bool:
"""Проверяет наличие поля модели по имени (по _meta)."""
return any(getattr(f, "name", None) == field_name for f in model._meta.get_fields())
_DEFAULT_PHONE_FIELD = getattr(settings, "AUTH_PHONE_FIELD", "phone_number")
PHONE_FIELD: Optional[str] = (
_DEFAULT_PHONE_FIELD if _model_has_field(UserModel, _DEFAULT_PHONE_FIELD) else None
)
class EmailOrPhoneBackend(ModelBackend):
"""
Аутентификация по email ИЛИ по телефону (E.164) + стандартная проверка пароля.
Пользователь должен быть активен (is_active=True) — активируйте после verify_email().
"""
def authenticate(self, request, username=None, password=None, **kwargs):
login = username or kwargs.get("email") or kwargs.get("phone")
logger.debug(
"EmailOrPhoneBackend.authenticate: login=%s, password_provided=%s",
bool(login), bool(password)
)
if not login or not password:
logger.debug("EmailOrPhoneBackend: missing credentials")
return None
user: Optional[Employee] = None
# 1) Email
if _looks_like_email(login):
user = Employee.objects.filter(email__iexact=login).first()
logger.debug("EmailOrPhoneBackend: email lookup=%s, found=%s", login, bool(user))
else:
# 2) Phone
if PHONE_FIELD:
normalized = _normalize_phone(login)
# сначала пробуем нормализованный E.164
if normalized:
user = Employee.objects.filter(**{PHONE_FIELD: normalized}).first()
# если не нашли — пробуем как ввёл пользователь (на случай старых данных)
if not user:
raw = str(login).strip()
user = Employee.objects.filter(**{PHONE_FIELD: raw}).first()
logger.debug("EmailOrPhoneBackend: phone lookup, found=%s", bool(user))
if not user:
logger.debug("EmailOrPhoneBackend: user not found")
return None
logger.debug(
"EmailOrPhoneBackend: found user id=%s email=%s is_active=%s has_usable_password=%s",
user.id, user.email, user.is_active, user.has_usable_password()
)
# допускаем логин только активных (email подтверждён → is_active=True)
if not user.is_active:
logger.debug("EmailOrPhoneBackend: user not active")
return None
password_valid = user.check_password(password)
logger.debug("EmailOrPhoneBackend: password_valid=%s", password_valid)
if password_valid:
logger.info("EmailOrPhoneBackend: AUTH SUCCESS for %s", user.email)
return user
logger.warning(
"EmailOrPhoneBackend: password check FAILED for user=%s (id=%s). "
"Password hash starts with: %s...",
user.email, user.id, user.password[:20] if user.password else "NO_PASSWORD"
)
return None
class LDAP3Backend(ModelBackend):
"""LDAP-аутентификация через ldap3 с логином по email/телефону/uid.
Особенности:
* Поиск DN сервисной учёткой (service bind), затем проверка пароля user bind.
* Автосоздание локального пользователя (опционально).
* Лёгкая синхронизация профиля (имя/фамилия/email/телефон/DN/GUID).
Безопасность:
* Пароли не логируются.
* Рекомендуется LDAPS/StartTLS с проверкой сертификата.
"""
def authenticate(
self,
request,
username: Optional[str] = None,
password: Optional[str] = None,
**kwargs: Any,
):
"""Аутентифицирует пользователя через LDAP/AD.
Логика:
1) Валидирует входные данные и настройки.
2) Service bind -> поиск записи пользователя (DN) по идентификатору/телефону.
3) User bind -> проверка пароля.
4) Поиск/создание локального пользователя и лёгкая синхронизация профиля.
Args:
request: Django HttpRequest или None.
username: Идентификатор (может быть email/телефон/uid).
password: Пароль пользователя.
**kwargs: Могут содержать email/login/USERNAME_FIELD.
Returns:
User | None: Аутентифицированный пользователь или None при неудаче.
Raises:
None: Метод перехватывает ошибки LDAP/сети и возвращает None при сбое.
"""
identifier = (
(username or "")
or (kwargs.get("email") or "")
or (kwargs.get("login") or "")
or (kwargs.get(getattr(UserModel, "USERNAME_FIELD", "email")) or "")
).strip()
logger.debug(
"LDAP3Backend.authenticate called: identifier_provided=%s, password_provided=%s, LDAP_ENABLED=%s",
bool(identifier),
bool(password),
getattr(settings, "LDAP_ENABLED", False),
)
if (
not identifier
or not password
or not getattr(settings, "LDAP_ENABLED", False)
):
logger.debug("Fast-exit: identifier/password missing or LDAP disabled")
return None
uri = getattr(settings, "LDAP_URI", "")
user_base = getattr(settings, "LDAP_USERS_AUTH", None) or getattr(
settings, "LDAP_USER_BASE", None
)
bind_dn = getattr(settings, "LDAP_BIND_DN", "")
bind_pw = getattr(settings, "LDAP_BIND_PASSWORD", "")
base_tpl = getattr(
settings,
"LDAP_USER_FILTER",
"(|(uid={username})(mail={username}))",
)
phone_attrs: Tuple[str, ...] = tuple(
getattr(settings, "LDAP_PHONE_ATTRS", ("mobile", "telephoneNumber"))
)
if not uri or not user_base:
logger.warning("Fast-exit: LDAP_URI or LDAP_USERS_AUTH not configured")
return None
raw = identifier
is_email = _looks_like_email(raw)
e164 = _normalize_phone(raw)
logger.debug(
"Identifier parse: is_email=%s, normalized_phone=%s", is_email, e164
)
# --- Service bind & search ---
# Важно: используем get_info=NONE для скорости/минимума утечек.
server = Server(uri) # get_info=NONE по умолчанию
svc_conn: Optional[Connection] = None
try:
logger.debug("Service bind: connecting...")
if bind_dn and bind_pw:
svc_conn = Connection(
server,
user=bind_dn,
password=bind_pw,
auto_bind=True,
receive_timeout=30,
)
logger.debug("Service bind OK (bind_dn)")
else:
svc_conn = Connection(server, auto_bind=True, receive_timeout=30)
logger.debug("Service bind OK (anonymous/simple)")
# Базовый объектный фильтр AD
base_object_filter = getattr(
settings,
"LDAP_OBJECT_FILTER",
"(&(objectCategory=person)(objectClass=user))",
)
# Фильтр по идентификатору
if "{username}" in base_tpl:
id_filter = base_tpl.format(username=esc_filter(raw))
logger.debug("Using configured ID filter: %s", id_filter)
else:
base_object_filter = base_tpl # админ дал базовый объектный фильтр
if is_email:
id_attrs = getattr(
settings,
"LDAP_LOGIN_MAIL_ATTRS",
("mail", "userPrincipalName"),
)
else:
id_attrs = getattr(
settings,
"LDAP_LOGIN_UID_ATTRS",
("uid", "sAMAccountName"),
)
id_filter = (
"(|" + "".join(f"({a}={esc_filter(raw)})" for a in id_attrs) + ")"
)
logger.debug("Built default ID filter: %s", id_filter)
phone_filter = ""
if e164:
phone_filter = (
"(|"
+ "".join(f"({attr}={esc_filter(e164)})" for attr in phone_attrs)
+ ")"
)
logger.debug("Built phone filter: %s", phone_filter)
# (& base_object (| id_filter phone_filter?))
if phone_filter:
ldap_filter = f"(&{base_object_filter}(|{id_filter}{phone_filter}))"
else:
ldap_filter = f"(&{base_object_filter}{id_filter})"
logger.debug("FINAL LDAP search filter=%s", ldap_filter)
# AD атрибуты для поиска
ok = svc_conn.search(
search_base=user_base,
search_filter=ldap_filter,
search_scope=SUBTREE,
attributes=[
getattr(settings, "LDAP_ATTR_GIVENNAME", "givenName"),
getattr(settings, "LDAP_ATTR_SN", "sn"),
getattr(settings, "LDAP_ATTR_MAIL", "mail"),
"telephoneNumber",
"mobile",
"sAMAccountName",
"userPrincipalName",
"userAccountControl",
"objectGUID",
"displayName",
],
size_limit=1,
)
entries_count = len(svc_conn.entries) if svc_conn else 0
logger.debug("Search ok=%s entries=%s", ok, entries_count)
if not ok or not svc_conn.entries:
logger.info("No entries found -> auth failed")
return None
entry = svc_conn.entries[0]
user_dn: str = str(entry.entry_dn)
logger.debug("Found DN: %s", user_dn)
except (LDAPSocketOpenError, LDAPBindError) as e:
logger.error("Service bind/search LDAP error: %s: %s", type(e).__name__, e)
return None
except Exception as e:
logger.exception("Service bind/search unexpected error: %s", e)
return None
finally:
try:
if svc_conn is not None:
svc_conn.unbind()
logger.debug("Service connection unbound")
except Exception as e:
logger.warning("Service unbind error: %s", e)
# --- User bind (password check) ---
try:
logger.debug("User bind check for DN=%s ...", user_dn)
user_conn = Connection(
server,
user=user_dn,
password=password,
auto_bind=True,
receive_timeout=30,
)
user_conn.unbind()
logger.debug("User bind OK (password valid)")
except LDAPBindError as e:
logger.info("User bind FAILED (invalid credentials): %s", e)
return None
except Exception as e:
logger.error("User bind FAILED: %s: %s", type(e).__name__, e)
return None
# --- Read attributes ---
get = self._attr
first_name = (
get(entry, getattr(settings, "LDAP_ATTR_GIVENNAME", "givenName")) or ""
)
last_name = get(entry, getattr(settings, "LDAP_ATTR_SN", "sn")) or ""
email = (get(entry, getattr(settings, "LDAP_ATTR_MAIL", "mail")) or "").lower()
logger.debug(
"Attrs: first_name=%r last_name=%r email=%r", first_name, last_name, email
)
phone_e164: Optional[str] = None
for attr in list(phone_attrs) + [
getattr(settings, "LDAP_ATTR_PHONE", "telephoneNumber")
]:
val = get(entry, attr)
if val:
phone_e164 = _normalize_phone(val)
logger.debug(
"Phone candidate attr=%s raw=%r -> norm=%r", attr, val, phone_e164
)
if phone_e164:
break
# --- Find or create local user ---
user = None
if email and _model_has_field(UserModel, "email"):
logger.debug("Lookup local user by email=%r", email)
user = UserModel.objects.filter(email__iexact=email).first()
if user is None and PHONE_FIELD and phone_e164:
logger.debug("Lookup local user by %s=%r", PHONE_FIELD, phone_e164)
user = UserModel.objects.filter(**{PHONE_FIELD: phone_e164}).first()
if user and not self.user_can_authenticate(user):
logger.info("user_can_authenticate=False -> auth denied")
return None
# Auto-create отключен для LDAP3Backend (создание через API)
if user is None:
logger.info("User not found in DB, auto-create disabled")
return None
# --- Light profile sync ---
if user:
changed: List[str] = []
if (
_model_has_field(UserModel, "first_name")
and user.first_name != first_name
):
user.first_name = first_name
changed.append("first_name")
if _model_has_field(UserModel, "last_name") and user.last_name != last_name:
user.last_name = last_name
changed.append("last_name")
if (
email
and _model_has_field(UserModel, "email")
and (user.email or "").lower() != email
):
user.email = email
changed.append("email")
if (
PHONE_FIELD
and phone_e164
and getattr(user, PHONE_FIELD, None) != phone_e164
):
setattr(user, PHONE_FIELD, phone_e164)
changed.append(PHONE_FIELD)
if hasattr(user, "ldap_dn") and getattr(user, "ldap_dn") != user_dn:
setattr(user, "ldap_dn", user_dn)
changed.append("ldap_dn")
guid = get(entry, "objectGUID")
if (
hasattr(user, "ldap_guid")
and guid
and getattr(user, "ldap_guid") != str(guid)
):
# При необходимости замените str(guid) на корректную конверсию GUID.
setattr(user, "ldap_guid", str(guid))
changed.append("ldap_guid")
if hasattr(user, "is_ldap_managed") and not getattr(
user, "is_ldap_managed", False
):
setattr(user, "is_ldap_managed", True)
changed.append("is_ldap_managed")
if hasattr(user, "last_ldap_sync_at"):
setattr(user, "last_ldap_sync_at", timezone.now())
changed.append("last_ldap_sync_at")
if changed:
user.save(update_fields=list(set(changed)))
logger.debug("Local user updated fields: %s", sorted(set(changed)))
else:
logger.debug("Local user has no changes to save")
if not self.user_can_authenticate(user):
logger.info("user_can_authenticate=False after sync -> auth denied")
return None
logger.info(
"AUTH SUCCESS: user_id=%s email=%s",
getattr(user, "id", None),
getattr(user, "email", None),
)
return user
logger.info(
"AUTH FAILED: no local user (auto_create disabled or constraints not met)"
)
return None
@staticmethod
def _attr(entry: Any, name: str) -> Optional[str]:
"""Возвращает первое строковое значение LDAP-атрибута.
Args:
entry: ldap3 Entry.
name: Имя атрибута (например, 'mail').
Returns:
str | None: Нормализованное строковое значение или None.
Raises:
None: Ошибки приводят к возврату None и логируются на debug.
"""
try:
val = getattr(entry, name, None)
raw = getattr(val, "value", None)
if raw is None:
return None
if isinstance(raw, (list, tuple)):
return str(raw[0]).strip() if raw else None
return str(raw).strip()
except Exception as e:
logger.debug("LDAP3Backend._attr('%s') error: %s", name, e)
return None
class SuperuserOnlyBackend(ModelBackend):
"""Локальный фолбэк-бэкенд: пускает только суперюзера.
Используется как «break-glass» при отказе LDAP. Любые не-суперюзеры
не проходят локальную аутентификацию, даже при корректном пароле.
Это устраняет локальный фолбэк для рядовых сотрудников (все они идут через LDAP),
не ломая админ-доступ на случай недоступности каталога.
"""
def authenticate(
self,
request: HttpRequest | None,
username: str | None = None,
password: str | None = None,
**kwargs,
) -> Optional[UserModel]:
"""Аутентифицирует только суперюзера через локальную БД.
Args:
request: HttpRequest или None.
username: Идентификатор (для вашей модели это email).
password: Пароль.
Returns:
Optional[UserModel]: Пользователь, если это суперюзер и пароль верен; иначе None.
Raises:
Ничего не выбрасывает наружу — при неуспехе возвращает None.
"""
logger.debug(
"SuperuserOnlyBackend.authenticate: username=%s, password_provided=%s",
bool(username), bool(password)
)
user = super().authenticate(
request, username=username, password=password, **kwargs
)
logger.debug("SuperuserOnlyBackend: parent auth returned user=%s", bool(user))
if user:
is_super = getattr(user, "is_superuser", False)
logger.debug(
"SuperuserOnlyBackend: user=%s, is_superuser=%s",
getattr(user, "email", "?"), is_super
)
if is_super:
logger.info("SuperuserOnlyBackend: AUTH SUCCESS for superuser %s", user.email)
return user
else:
logger.debug("SuperuserOnlyBackend: user is not superuser, rejecting")
return None
class PositionRoleBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
"""Этот бэкенд не участвует в аутентификации, только в расчёте прав."""
return None
def get_all_permissions(self, user_obj, obj=None):
perms = super().get_all_permissions(user_obj, obj)
# Проверяем, что пользователь authenticated и active
if not getattr(user_obj, "is_authenticated", False) or obj is not None:
return perms
if not getattr(user_obj, "is_active", True):
return perms
# 1) Глобальные права от должности — оставить
pos = getattr(user_obj, "position", None)
if pos:
pos_perms = (
Permission.objects.filter(group__positions=pos)
.select_related("content_type")
.distinct()
)
perms |= {f"{p.content_type.app_label}.{p.codename}" for p in pos_perms}
# 2) ⚠️ НЕ добавляем сюда пермишены из DepartmentRole.
# Они должны проверяться только на уровне конкретного Department в has_perm(..., obj=dept)
return perms
def has_perm(self, user_obj, perm, obj=None):
# стандартная ветка + суперюзер/группы/пользовательские
if super().has_perm(user_obj, perm, obj=obj):
return True
if not getattr(user_obj, "is_authenticated", False):
return False
if obj is None:
# добавить глобальные (от должности)
return perm in self.get_all_permissions(user_obj)
# Объектные права на Department — как у вас
if isinstance(obj, Department):
if obj.head_id == user_obj.id:
return True
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee=user_obj, department=obj, is_active=True)
.first()
)
if not (link and link.role_id):
return False
app_label, sep, codename = perm.partition(".")
if not sep:
return False
return link.role.scoped_permissions.filter(
content_type__app_label=app_label,
codename=codename,
).exists()
return False