**Что сделано**
* **API**
* Добавлен `POST /api/v1/auth/register/` — создание пользователя, генерация кода и письмо (повторная отправка, если email есть, но не подтверждён).
* `POST /api/v1/auth/verify-email/` возвращает `user_id` при успехе.
* `POST /api/v1/auth/resend-email/` — без изменений по контракту.
* **Фронт (views\_front)**
* `RegisterView.form_valid` теперь вызывает API `auth/register` (без прямой работы с ORM).
* `EmailVerifyView.post` переводён на API `auth/verify-email` + автологин при успехе.
* Повторная отправка кода (`resend_email`) уже через API, добавлена защита от частых запросов по сессии.
* Исправлена сериализация телефона (E.164/строка) при отправке в API.
* **Клиент API**
* Новый модуль `employees/api_client.py`: безопасная сериализация payload, удобные ошибки (`api_bad_json status=… body=…`) и абсолютные URL через `request.build_absolute_uri`.
* **Middleware**
* `AuthRequiredMiddleware` теперь **пропускает**: `/api/v1/**`, статику/медиа, страницы логина/регистрации/сброса пароля, админ-логин и `OPTIONS`. Остальное для анонима — редирект на `login`.
* **Аутентификация/выход**
* Страница подтверждения выхода (в стиле iOS bottom sheet) + `ConfirmLogoutView`. Пункт «Выйти» ведёт на GET `/auth/logout/`, далее POST подтверждения.
* Шаблон выхода принимает `next` из контекста (без обращений к `request.GET.next` в шаблоне).
* **UX/мелочи**
* Устранены 500 при отсутствии `password_reset` — добавлены/учтены URL’ы потока сброса (или безопасные ссылки).
* Уточнены сообщения об ошибках при регистрации/верификации.
**Как проверить**
1. Аноним может вызвать:
* `POST /api/v1/auth/register/` → `201 {"ok":true}` / `200 {"ok":true,"resent":true}` / `400 {"error":"email_taken"}`.
* `POST /api/v1/auth/verify-email/` с корректным кодом → `200 {"ok":true,"user_id":...}`.
* `POST /api/v1/auth/resend-email/` → `200 {"ok":true}`.
2. UI:
* Регистрация в форме → обращение к API, письмо с кодом, переход на ввод кода.
* Ввод кода → API, автологин, редирект в ленту.
* «Выйти» в меню → страница подтверждения → POST → сообщение «Вы вышли…» и редирект на логин/`next`.
3. Проверить, что `/api/v1/**` не редиректится на логин (middleware whitelist работает).
4. Телефон в форме не ломает JSON (нет `TypeError: PhoneNumber is not JSON serializable`).
**Миграции**: нет.
99 lines
3.5 KiB
Python
99 lines
3.5 KiB
Python
# eusrr_backend/auth_backends.py
|
|
import re
|
|
from typing import Optional
|
|
|
|
from django.contrib.auth.backends import ModelBackend
|
|
from django.conf import settings
|
|
|
|
from employees.models import Employee # если модель в другом месте — поправь импорт
|
|
|
|
# phonenumbers — опционально, но очень желательно: pip install phonenumbers
|
|
try:
|
|
import phonenumbers
|
|
from phonenumbers import PhoneNumberFormat
|
|
except Exception:
|
|
phonenumbers = None
|
|
PhoneNumberFormat = None
|
|
|
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
|
|
|
|
def _looks_like_email(s: str) -> bool:
|
|
return bool(EMAIL_RE.match(s or ""))
|
|
|
|
|
|
def _normalize_phone(raw: str) -> Optional[str]:
|
|
"""
|
|
Приводим номер к формату E.164 (+79991234567).
|
|
Возвращаем None, если распарсить не удалось или пакет не установлен.
|
|
"""
|
|
if not raw:
|
|
return None
|
|
if phonenumbers is None:
|
|
# fallback — без нормализации
|
|
return str(raw).strip()
|
|
region = getattr(settings, "PHONE_DEFAULT_REGION", "RU")
|
|
try:
|
|
pn = phonenumbers.parse(str(raw), region)
|
|
if not phonenumbers.is_valid_number(pn):
|
|
return None
|
|
return phonenumbers.format_number(pn, PhoneNumberFormat.E164)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _detect_phone_field() -> Optional[str]:
|
|
"""
|
|
Определяем имя поля телефона в модели Employee.
|
|
Поддерживаем распространённые варианты.
|
|
"""
|
|
candidates = ("phone", "phone_number", "mobile", "msisdn", "tel")
|
|
field_names = {f.name for f in Employee._meta.get_fields()}
|
|
for name in candidates:
|
|
if name in field_names:
|
|
return name
|
|
return None
|
|
|
|
|
|
PHONE_FIELD = _detect_phone_field()
|
|
|
|
|
|
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")
|
|
if not login or not password:
|
|
return None
|
|
|
|
user: Optional[Employee] = None
|
|
|
|
# 1) Email
|
|
if _looks_like_email(login):
|
|
user = Employee.objects.filter(email__iexact=login).first()
|
|
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()
|
|
|
|
if not user:
|
|
return None
|
|
|
|
# допускаем логин только активных (email подтверждён → is_active=True)
|
|
if not user.is_active:
|
|
return None
|
|
|
|
if user.check_password(password):
|
|
return user
|
|
return None
|