diff --git a/.env.example b/.env.example index 264a0c7c..9aff1952 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,8 @@ LDAP_WRITE_ENABLED=False # LDAP_DISMISSED_BASE=OU=Dismissed,DC=example,DC=com # LDAP_UPN_SUFFIX=example.com # LDAP_EMPLOYEE_ID_ATTR=employeeNumber +# LDAP_LOGIN_UID_ATTRS=uid,sAMAccountName +# LDAP_LOGIN_MAIL_ATTRS=mail,userPrincipalName # Email Settings (if used) # EMAIL_HOST=smtp.gmail.com diff --git a/backend/api/auth/endpoints/tokens.py b/backend/api/auth/endpoints/tokens.py index 02c2199b..58091873 100644 --- a/backend/api/auth/endpoints/tokens.py +++ b/backend/api/auth/endpoints/tokens.py @@ -28,10 +28,9 @@ logger = logging.getLogger(__name__) tags=["Auth"], summary="Получить JWT access и refresh токены", description=( - "Принимает email или телефон вместе с паролем. " - "Если передан телефон, backend найдёт пользователя " - "и использует его email " - "для стандартной JWT-аутентификации." + "Принимает LDAP-логин, email или телефон вместе с паролем. " + "Рекомендуемое поле идентификатора — login; старые поля email, " + "phone и phone_number поддерживаются для обратной совместимости." ), request=PhoneOrEmailTokenObtainPairSerializer, responses={ @@ -43,14 +42,19 @@ logger = logging.getLogger(__name__) ), }, examples=[ + OpenApiExample( + "Вход по LDAP-логину", + value={"login": "ivanov", "password": "Str0ngPass!"}, + request_only=True, + ), OpenApiExample( "Вход по email", - value={"email": "user@example.com", "password": "Str0ngPass!"}, + value={"login": "user@example.com", "password": "Str0ngPass!"}, request_only=True, ), OpenApiExample( "Вход по телефону", - value={"phone_number": "+79990000001", "password": "Str0ngPass!"}, + value={"login": "+79990000001", "password": "Str0ngPass!"}, request_only=True, ), ], diff --git a/backend/api/auth/serializers.py b/backend/api/auth/serializers.py index 739f5090..6d4d0573 100644 --- a/backend/api/auth/serializers.py +++ b/backend/api/auth/serializers.py @@ -1,10 +1,7 @@ from __future__ import annotations -from api.v1.employees.views._helpers import PHONE_FIELD from django.contrib.auth import get_user_model from django.contrib.auth.password_validation import validate_password -from employees.models import Employee -from employees.utils import _normalize_phone from rest_framework import serializers from rest_framework.exceptions import AuthenticationFailed from rest_framework_simplejwt.serializers import ( @@ -199,8 +196,8 @@ class PasswordResetConfirmResponseSerializer(serializers.Serializer): class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer): """ - Принимает email ИЛИ phone/phone_number + password. - Если пришёл телефон — ищем пользователя и подставляем его email. + Принимает единый login (LDAP username, email или телефон) + password. + Старые поля email/phone/phone_number остаются совместимыми. """ def __init__(self, *args, **kwargs): @@ -208,9 +205,14 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer): # <- КЛЮЧЕВОЕ: email (username_field) не обязателен self.fields[self.username_field].required = False self.fields[self.username_field].help_text = ( - "Email пользователя. Можно передать либо email, либо телефон." + "Email пользователя (поле сохранено для обратной совместимости)." + ) + self.fields["login"] = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=True, + help_text="LDAP-логин, email или номер телефона пользователя.", ) - # добавляем телефонные поля self.fields["phone"] = serializers.CharField( required=False, allow_blank=True, @@ -223,27 +225,24 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer): ) def validate(self, attrs): - raw_phone = ( - self.initial_data.get("phone") + identifier = ( + attrs.get(self.username_field) + or self.initial_data.get("login") + or self.initial_data.get("phone") or self.initial_data.get("phone_number") ) - if raw_phone and not attrs.get(self.username_field): - qfield = PHONE_FIELD or "phone_number" - norm = _normalize_phone(raw_phone) or str(raw_phone).strip() - try: - user = Employee.objects.get(**{qfield: norm}) - # подставляем email в стандартный механизм SimpleJWT - attrs[self.username_field] = user.email - except Employee.DoesNotExist: - pass # базовая валидация ниже вернёт 401 - - # Проверяем что есть email (после подстановки из телефона или напрямую) - if not attrs.get(self.username_field): + identifier = str(identifier or "").strip() + if not identifier: raise AuthenticationFailed( - "Email or phone number required", + "Login, email or phone number required", code="no_credentials", ) + # SimpleJWT передаёт в django.authenticate() USERNAME_FIELD модели. + # LDAP3Backend интерпретирует это значение как uid/email/телефон, а + # локальный backend — как email/телефон. + attrs[self.username_field] = identifier + super().validate(attrs) # не выдаём токены, если email не подтверждён/аккаунт не активен diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index f0c35046..4ddb998e 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -745,6 +745,12 @@ LDAP_EMPLOYEE_ID_ATTR = os.getenv("LDAP_EMPLOYEE_ID_ATTR", "employeeNumber") LDAP_PHONE_ATTRS = tuple( _split_env_list(os.getenv("LDAP_PHONE_ATTRS", "mobile,telephoneNumber")) ) +LDAP_LOGIN_UID_ATTRS = tuple( + _split_env_list(os.getenv("LDAP_LOGIN_UID_ATTRS", "uid,sAMAccountName")) +) +LDAP_LOGIN_MAIL_ATTRS = tuple( + _split_env_list(os.getenv("LDAP_LOGIN_MAIL_ATTRS", "mail,userPrincipalName")) +) # WRITE-BACK (LDAP_WRITE_ENABLED определён выше, в секции БАЗА ДАННЫХ) LDAP_WRITE_DN = os.getenv("LDAP_WRITE_DN", LDAP_BIND_DN) diff --git a/backend/tests/api/auth/test_auth_and_registration.py b/backend/tests/api/auth/test_auth_and_registration.py index 1f6808d2..af5e2cb0 100644 --- a/backend/tests/api/auth/test_auth_and_registration.py +++ b/backend/tests/api/auth/test_auth_and_registration.py @@ -1,8 +1,9 @@ import re import pytest from datetime import timedelta +from types import SimpleNamespace from urllib.parse import parse_qs, urlparse -from unittest.mock import Mock +from unittest.mock import Mock, patch from django.core import mail from django.urls import reverse @@ -106,17 +107,20 @@ def resend(api: APIClient, *, email: str): def jwt_obtain( api: APIClient, *, + login: str | None = None, email: str | None = None, phone: str | None = None, password: str = "Str0ngPass!" ): """ Ожидаем, что аутентификация поддерживает вход по email ИЛИ по телефону. - - По email: передаём {"email": ..., "password": ...} - - По телефону: передаём {"phone_number": ..., "password": ...} + - Через единое поле: {"login": ..., "password": ...} + - Старые email/phone_number тоже поддерживаются. """ url = reverse("api:token_obtain_pair") - if email: + if login: + payload = {"login": login, "password": password} + elif email: payload = {"email": email, "password": password} else: payload = {"phone_number": phone, "password": password} @@ -388,6 +392,91 @@ def test_login_by_phone_allowed_after_verify(api): assert {"access", "refresh"} <= set(r.json().keys()) +def test_unified_login_field_accepts_local_email_and_phone(api): + payload = register_payload() + assert register(api, **payload).status_code == status.HTTP_201_CREATED + code = extract_code_from_last_email() + assert verify(api, email=payload["email"], code=code).status_code in (200, 204) + + by_email = jwt_obtain( + api, + login=payload["email"], + password=payload["password"], + ) + assert by_email.status_code == status.HTTP_200_OK + + by_phone = jwt_obtain( + api, + login=payload["phone_number"], + password=payload["password"], + ) + assert by_phone.status_code == status.HTTP_200_OK + + +def test_login_by_ldap_username(api, settings): + settings.LDAP_ENABLED = True + settings.LDAP_URI = "ldaps://ldap.example.test:636" + settings.LDAP_USER_BASE = "OU=Users,DC=example,DC=test" + settings.LDAP_USER_FILTER = "(&(objectCategory=person)(objectClass=user))" + + user = User.objects.create_user( + email="ldap.user@example.test", + phone_number="+79990000081", + password=None, + username="ldap.user", + first_name="Старое", + last_name="Имя", + is_active=True, + send_activation_email=False, + ) + user.email_verified = True + user.save(update_fields=["email_verified"]) + + def attribute(value): + return SimpleNamespace(value=value) + + entry = SimpleNamespace( + entry_dn="CN=LDAP User,OU=Users,DC=example,DC=test", + givenName=attribute("Иван"), + sn=attribute("Иванов"), + mail=attribute(user.email), + telephoneNumber=attribute(str(user.phone_number)), + mobile=attribute(None), + sAMAccountName=attribute("ldap.user"), + userPrincipalName=attribute("ldap.user@example.test"), + userAccountControl=attribute("512"), + objectGUID=attribute("guid-ldap-user"), + displayName=attribute("Иван Иванов"), + ) + service_connection = Mock() + service_connection.search.return_value = True + service_connection.entries = [entry] + user_connection = Mock() + + with ( + patch("eusrr_backend.auth_backends.Server"), + patch( + "eusrr_backend.auth_backends.Connection", + side_effect=[service_connection, user_connection], + ), + ): + response = jwt_obtain( + api, + login="ldap.user", + password="DirectoryPass!", + ) + + assert response.status_code == status.HTTP_200_OK + assert {"access", "refresh"} <= set(response.json()) + search_filter = service_connection.search.call_args.kwargs["search_filter"] + assert "(sAMAccountName=ldap.user)" in search_filter + + user.refresh_from_db() + assert user.first_name == "Иван" + assert user.last_name == "Иванов" + assert user.is_ldap_managed is True + + def test_login_wrong_password(api): assert register(api).status_code == status.HTTP_201_CREATED code = extract_code_from_last_email() diff --git a/docs/guides/LDAP.md b/docs/guides/LDAP.md index a8817092..2e84fdfb 100644 --- a/docs/guides/LDAP.md +++ b/docs/guides/LDAP.md @@ -49,6 +49,8 @@ LDAP_EMPLOYEE_ID_ATTR=employeeNumber | Переменная | Назначение | | --- | --- | | `LDAP_USER_FILTER` | object filter для поиска пользователей | +| `LDAP_LOGIN_UID_ATTRS` | атрибуты входа по короткому логину; по умолчанию `uid,sAMAccountName` | +| `LDAP_LOGIN_MAIL_ATTRS` | атрибуты входа по адресу; по умолчанию `mail,userPrincipalName` | | `LDAP_PHONE_ATTRS` | comma-separated атрибуты для login по телефону | | `LDAP_ATTR_MAIL`, `LDAP_ATTR_GIVENNAME`, `LDAP_ATTR_SN`, `LDAP_ATTR_PHONE` | mapping основных полей | | `LDAP_CONNECT_TIMEOUT` | timeout подключения ldap3 | @@ -62,12 +64,21 @@ LDAP_EMPLOYEE_ID_ATTR=employeeNumber `LDAP3Backend` стоит первым в `AUTHENTICATION_BACKENDS`, но делает fast exit при `LDAP_ENABLED=false`. Далее доступны локальный email/phone backend, аварийный superuser backend, permissions по должности, django-rules и стандартный Django backend. -Login API принимает email или телефон: +Login API принимает LDAP-логин (`uid`/`sAMAccountName`), email или телефон через +единое поле `login`. Старые поля `email`, `phone` и `phone_number` сохранены для +обратной совместимости: ```text POST /api/auth/token/ ``` +```json +{ + "login": "ivanov", + "password": "секретный-пароль" +} +``` + LDAP-managed пользователь хранит локальный пароль в unusable-состоянии. Проверка пароля выполняется каталогом, а выданный JWT всё равно привязывается к `UserAuthSession`. ## Модель данных и направление синхронизации diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index c7425799..50212245 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -158,11 +158,7 @@ export default function Login() { setLoading(true); try { - const credentials = login.includes("@") - ? { email: login, password } - : { phone: login, password }; - - await apiClient.login(credentials); + await apiClient.login({ login: login.trim(), password }); await refreshUser(); } catch (err) { console.error("Ошибка авторизации:", err); @@ -302,7 +298,7 @@ export default function Login() {
@@ -317,7 +313,7 @@ export default function Login() { onChange={(e) => setLogin(e.target.value)} required className="app-input w-full rounded-lg py-3 pl-11 pr-4 text-sm" - placeholder="example@mail.com" + placeholder="ivanov, +7… или example@mail.com" />
diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 8f3bb19d..cc70a038 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -11,6 +11,7 @@ import type { } from "@/types/api"; interface LoginCredentials { + login?: string; email?: string; phone?: string; password: string;