Support unified LDAP, email, and phone login
This commit is contained in:
parent
cae8e97146
commit
f2e85de672
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@ -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 не подтверждён/аккаунт не активен
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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`.
|
||||
|
||||
## Модель данных и направление синхронизации
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
} from "@/types/api";
|
||||
|
||||
interface LoginCredentials {
|
||||
login?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user