Support unified LDAP, email, and phone login

This commit is contained in:
admin i 2026-09-04 09:58:20 +03:00
parent cae8e97146
commit f2e85de672
8 changed files with 148 additions and 40 deletions

View File

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

View File

@ -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,
),
],

View File

@ -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 не подтверждён/аккаунт не активен

View File

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

View File

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

View File

@ -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`.
## Модель данных и направление синхронизации

View File

@ -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() {
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="app-field-label" htmlFor="login">
Телефон или почта
Логин, телефон или почта
</label>
<div className="relative">
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]">
@ -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"
/>
</div>
</div>

View File

@ -11,6 +11,7 @@ import type {
} from "@/types/api";
interface LoginCredentials {
login?: string;
email?: string;
phone?: string;
password: string;