# 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( "Local authentication attempted", extra={ "event": "authentication.local.attempted", "identifier_provided": bool(login), "password_provided": bool(password), }, ) if not login or not password: logger.debug( "Local authentication skipped", extra={ "event": "authentication.local.skipped", "reason": "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( "Local authentication lookup completed", extra={ "event": "authentication.local.lookup", "identifier_type": "email", "user_found": 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( "Local authentication lookup completed", extra={ "event": "authentication.local.lookup", "identifier_type": "phone", "user_found": bool(user), }, ) if not user: logger.info( "Local authentication denied", extra={ "event": "authentication.local.denied", "reason": "user_not_found", }, ) return None logger.debug( "Local authentication candidate found", extra={ "event": "authentication.local.candidate_found", "user_id": user.id, "is_active": user.is_active, "has_usable_password": user.has_usable_password(), }, ) # допускаем логин только активных (email подтверждён → is_active=True) if not user.is_active: logger.info( "Local authentication denied", extra={ "event": "authentication.local.denied", "user_id": user.id, "reason": "inactive_user", }, ) return None password_valid = user.check_password(password) if password_valid: logger.info( "Local authentication succeeded", extra={ "event": "authentication.local.succeeded", "user_id": user.id, }, ) return user logger.warning( "Local authentication denied", extra={ "event": "authentication.local.denied", "user_id": user.id, "reason": "invalid_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( "LDAP authentication attempt received", extra={ "event": "authentication.ldap.attempted", "identifier_provided": bool(identifier), "password_provided": bool(password), "ldap_enabled": getattr(settings, "LDAP_ENABLED", False), }, ) if ( not identifier or not password or not getattr(settings, "LDAP_ENABLED", False) ): logger.debug( "LDAP authentication skipped because prerequisites are missing", extra={ "event": "authentication.ldap.skipped", "identifier_provided": bool(identifier), "password_provided": bool(password), "ldap_enabled": getattr(settings, "LDAP_ENABLED", False), "reason": "prerequisite_missing", }, ) 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( "LDAP authentication unavailable because configuration is incomplete", extra={ "event": "authentication.ldap.configuration_incomplete", "uri_configured": bool(uri), "user_base_configured": bool(user_base), }, ) return None raw = identifier is_email = _looks_like_email(raw) e164 = _normalize_phone(raw) logger.debug( "LDAP identifier parsed", extra={ "event": "authentication.ldap.identifier_parsed", "identifier_type": "email" if is_email else "other", "phone_normalized": bool(e164), }, ) # --- Service bind & search --- # Важно: используем get_info=NONE для скорости/минимума утечек. server = Server(uri) # get_info=NONE по умолчанию svc_conn: Optional[Connection] = None try: logger.debug( "LDAP service bind started", extra={"event": "authentication.ldap.service_bind_started"}, ) if bind_dn and bind_pw: svc_conn = Connection( server, user=bind_dn, password=bind_pw, auto_bind=True, receive_timeout=30, ) logger.debug( "LDAP service bind completed", extra={ "event": "authentication.ldap.service_bind_completed", "bind_mode": "configured_dn", }, ) else: svc_conn = Connection(server, auto_bind=True, receive_timeout=30) logger.debug( "LDAP service bind completed", extra={ "event": "authentication.ldap.service_bind_completed", "bind_mode": "anonymous_or_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( "LDAP configured identifier filter selected", extra={ "event": "authentication.ldap.filter_selected", "filter_type": "configured", }, ) 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( "LDAP default identifier filter selected", extra={ "event": "authentication.ldap.filter_selected", "filter_type": "default", }, ) phone_filter = "" if e164: phone_filter = ( "(|" + "".join(f"({attr}={esc_filter(e164)})" for attr in phone_attrs) + ")" ) logger.debug( "LDAP phone filter enabled", extra={"event": "authentication.ldap.phone_filter_enabled"}, ) # (& 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})" # 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( "LDAP authentication search completed", extra={ "event": "authentication.ldap.search_completed", "search_succeeded": ok, "entry_count": entries_count, }, ) if not ok or not svc_conn.entries: logger.info( "LDAP authentication denied because directory entry was not found", extra={ "event": "authentication.ldap.denied", "reason": "entry_not_found", "search_succeeded": ok, }, ) return None entry = svc_conn.entries[0] user_dn: str = str(entry.entry_dn) logger.debug( "LDAP user entry found", extra={"event": "authentication.ldap.entry_found"}, ) except (LDAPSocketOpenError, LDAPBindError) as error: logger.error( "LDAP service bind or search failed", extra={ "event": "authentication.ldap.service_failed", "error_type": type(error).__name__, }, ) return None except Exception as error: logger.exception( "Unexpected LDAP service error", extra={ "event": "authentication.ldap.service_failed", "error_type": type(error).__name__, }, ) return None finally: try: if svc_conn is not None: svc_conn.unbind() logger.debug( "LDAP service connection closed", extra={ "event": "authentication.ldap.service_connection_closed" }, ) except Exception as error: logger.warning( "LDAP service unbind failed", extra={ "event": "authentication.ldap.unbind_failed", "error_type": type(error).__name__, }, ) # --- User bind (password check) --- try: logger.debug( "LDAP user bind started", extra={"event": "authentication.ldap.user_bind_started"}, ) user_conn = Connection( server, user=user_dn, password=password, auto_bind=True, receive_timeout=30, ) user_conn.unbind() logger.debug( "LDAP credentials validated", extra={"event": "authentication.ldap.credentials_validated"}, ) except LDAPBindError: logger.info( "LDAP authentication denied", extra={ "event": "authentication.ldap.denied", "reason": "invalid_credentials", }, ) return None except Exception as error: logger.error( "LDAP user bind failed", extra={ "event": "authentication.ldap.user_bind_failed", "error_type": type(error).__name__, }, ) 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( "LDAP attributes read", extra={ "event": "authentication.ldap.attributes_read", "has_first_name": bool(first_name), "has_last_name": bool(last_name), "has_email": bool(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( "LDAP phone candidate processed", extra={ "event": "authentication.ldap.phone_processed", "attribute": attr, "phone_normalized": bool(phone_e164), }, ) if phone_e164: break # --- Find or create local user --- user = None if email and _model_has_field(UserModel, "email"): user = UserModel.objects.filter(email__iexact=email).first() if user is None and PHONE_FIELD and phone_e164: user = UserModel.objects.filter(**{PHONE_FIELD: phone_e164}).first() if user and not self.user_can_authenticate(user): logger.info( "LDAP authentication denied by local account policy", extra={ "event": "authentication.ldap.denied", "user_id": getattr(user, "id", None), "reason": "local_account_policy", }, ) return None # Auto-create отключен для LDAP3Backend (создание через API) if user is None: logger.info( "LDAP authentication denied because no local account exists", extra={ "event": "authentication.ldap.denied", "reason": "local_account_missing", "auto_create_enabled": False, }, ) 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 LDAP user profile updated", extra={ "event": "authentication.ldap.profile_updated", "user_id": getattr(user, "id", None), "updated_fields": sorted(set(changed)), "updated_field_count": len(set(changed)), }, ) else: logger.debug( "Local LDAP user profile is current", extra={ "event": "authentication.ldap.profile_unchanged", "user_id": getattr(user, "id", None), }, ) if not self.user_can_authenticate(user): logger.info( "LDAP authentication denied by local policy after profile sync", extra={ "event": "authentication.ldap.denied", "user_id": getattr(user, "id", None), "reason": "post_sync_local_account_policy", }, ) return None logger.info( "LDAP authentication succeeded", extra={ "event": "authentication.ldap.succeeded", "user_id": getattr(user, "id", None), }, ) return user logger.info( "LDAP authentication denied because no usable local account exists", extra={ "event": "authentication.ldap.denied", "reason": "usable_local_account_missing", }, ) 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 error: logger.debug( "LDAP attribute read failed", extra={ "event": "authentication.ldap.attribute_failed", "attribute": name, "error_type": type(error).__name__, }, ) 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( "Local break-glass authentication attempt received", extra={ "event": "authentication.break_glass.attempted", "identifier_provided": bool(username), "password_provided": bool(password), }, ) user = super().authenticate( request, username=username, password=password, **kwargs ) logger.debug( "Local break-glass credential check completed", extra={ "event": "authentication.break_glass.credentials_checked", "user_found": bool(user), }, ) if user: is_super = getattr(user, "is_superuser", False) logger.debug( "Local break-glass candidate evaluated", extra={ "event": "authentication.break_glass.evaluated", "user_id": getattr(user, "id", None), "is_superuser": is_super, }, ) if is_super: logger.info( "Local break-glass authentication succeeded", extra={ "event": "authentication.break_glass.succeeded", "user_id": getattr(user, "id", None), }, ) return user else: logger.debug( "Local break-glass authentication denied", extra={ "event": "authentication.break_glass.denied", "user_id": getattr(user, "id", None), "reason": "not_superuser", }, ) 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