lizerk 0539f50233 refactor: переход на строгий AD-only LDAP, регистрация + верификация email
- Удалён LDAP_SERVER_TYPE и все OpenLDAP ветки кода
- user_service.py: только AD (sAMAccountName, UAC, microsoft.modify_password)
- auth_backends.py: AD фильтры и атрибуты (objectCategory, objectGUID)
- Удалён server_configs.py (больше не нужен)
- Удалён OpenLDAP Docker окружение (docker-compose.ldap.yml, ldap/, QUICKSTART_LDAP.md)
- Фронтенд: мульти-шаговая регистрация, AvatarCropper 3:4, verify-email страница
- Убран CSRF для JWT API, IP-ограничения работают независимо
- Полевые ошибки регистрации с красной подсветкой на фронте
2026-03-19 00:48:13 +03:00

240 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Auth-related views: регистрация, подтверждение email, повторная отправка кода."""
from __future__ import annotations
import logging
from datetime import timedelta
from common.emails import send_templated_mail
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from employees.models import Position, Skill
from employees.utils import _normalize_phone
from rest_framework import status
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from ..serializers import (EmailSerializer, EmailVerifySerializer,
RegisterSerializer)
from ._helpers import Employee
from .mixins import LdapUserCreationMixin
logger = logging.getLogger(__name__)
class AnonymousAPIView(APIView):
"""Базовый класс для анонимных (публичных) API endpoints.
Все auth-related views наследуются от этого класса для DRY.
Используется только JWT (без SessionAuthentication) → CSRF не требуется.
"""
authentication_classes = [] # Отключаем SessionAuthentication для публичных endpoints
throttle_scope = "anon"
permission_classes = [AllowAny]
class ResendEmailAPIView(AnonymousAPIView):
"""POST /api/v1/auth/resend-email/ body: {"email": "..."}"""
def post(self, request):
ser = EmailSerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"]
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response({"ok": False, "error": "user_not_found"}, status=404)
if user.email_verified:
return Response({"ok": False, "error": "already_verified"}, status=400)
user.email_activation_code = get_random_string(6, "0123456789")
user.save(update_fields=["email_activation_code"])
send_templated_mail(
subject="Подтверждение регистрации",
to=[user.email],
template_base="emails/registration_verify_code",
context={"code": user.email_activation_code, "user": user},
)
return Response({"ok": True}, status=200)
class VerifyEmailAPIView(AnonymousAPIView):
def post(self, request):
"""Подтверждает email и активирует пользователя.
Активация синхронизируется в LDAP через сигналы.
"""
ser = EmailVerifySerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"].strip().lower()
code = ser.validated_data["code"].strip()
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response({"ok": False, "error": "user_not_found"}, status=404)
if not code:
return Response({"ok": False, "error": "empty_code"}, status=400)
created = getattr(user, "created_at", None) or getattr(
user, "date_joined", None
)
if created and not user.email_verified:
if timezone.now() - created > timedelta(minutes=5):
user.delete()
return Response({"ok": False, "error": "expired"}, status=400)
if not user.verify_email(code):
return Response({"ok": False, "error": "invalid_code"}, status=400)
# Активируем пользователя (сигнал sync_employee_to_ldap_on_save синхронизирует в LDAP)
user.is_active = True
user._ldap_changes = {"is_active": True}
user.save()
return Response({"ok": True, "user_id": user.id}, status=200)
class RegisterAPIView(LdapUserCreationMixin, AnonymousAPIView):
"""Регистрация: создаём учётку в LDAP (disabled) с паролем, в БД — set_unusable_password.
Использует LdapUserCreationMixin для вынесения LDAP-специфичной логики.
"""
parser_classes = (JSONParser, FormParser, MultiPartParser)
@transaction.atomic
def post(self, request):
# Логируем входящие данные для диагностики
logger.warning(f"[REGISTER] Received data: {request.data}")
logger.warning(f"[REGISTER] Content-Type: {request.content_type}")
ser = RegisterSerializer(data=request.data)
if not ser.is_valid():
logger.warning(f"[REGISTER] Validation errors: {ser.errors}")
ser.is_valid(raise_exception=True)
v = ser.validated_data
email = v["email"].strip().lower()
password = v["password"]
phone_norm = _normalize_phone(
v.get("phone_number") or request.data.get("phone")
)
logger.warning(f"[REGISTER] Phone normalization: input={v.get('phone_number')}, normalized={phone_norm}")
if not phone_norm:
logger.error(f"[REGISTER] Phone normalization FAILED for: {v.get('phone_number')}")
return Response({"ok": False, "error": "invalid_phone"}, status=400)
existing_phone = Employee.objects.filter(
phone_number=phone_norm).first()
if existing_phone:
logger.warning(
"[REGISTER] Phone %s already used by user id=%s",
phone_norm,
existing_phone.id,
)
return Response(
{
"ok": False,
"error": "phone_taken",
"detail": "Номер телефона уже зарегистрирован."
" Войдите в существующий аккаунт или используйте другой номер.",
"phone_number": [
"Этот номер телефона уже привязан к другому аккаунту."
],
},
status=400,
)
user = Employee.objects.filter(email__iexact=email).first()
if user:
logger.warning(f"[REGISTER] User exists: email={email}, verified={user.email_verified}")
if user.email_verified:
# Email уже верифицирован - нельзя регистрироваться
logger.error(f"[REGISTER] Email already taken and verified: {email}")
return Response({"ok": False, "error": "email_taken"}, status=400)
else:
# Есть неверифицированный пользователь - повторная отправка кода
return Response(
{"ok": True, "pending_verification": True, "user_id": user.id},
status=200,
)
avatar_file = v.get("avatar") or getattr(
request, "FILES", {}).get("avatar")
avatar_bytes = None
avatar_name = None
if avatar_file:
try:
avatar_bytes = (
avatar_file.read() if hasattr(avatar_file, "read") else None
)
avatar_name = getattr(
avatar_file, "name", None) or "avatar.jpg"
except Exception:
avatar_bytes = None
avatar_name = None
# Создаём пользователя (миксин сам выберет LDAP или БД режим)
emp, error_response = self.create_user(
first_name=v["first_name"],
last_name=v["last_name"],
email=email,
phone=phone_norm,
password=password,
avatar_bytes=avatar_bytes,
is_active=False, # не активен до верификации email
)
if error_response:
return error_response
# 2) Заполняем доп.поля БД
if avatar_bytes:
try:
emp.avatar.save(avatar_name, ContentFile(
avatar_bytes), save=False)
except Exception:
pass
emp.telegram = v.get("telegram", "")
emp.whatsapp = v.get("whatsapp", "")
emp.wechat = v.get("wechat", "")
emp.birth_date = v["birth_date"]
emp.gender = v.get("gender") # опциональное поле
if v.get("patronymic"):
emp.patronymic = v["patronymic"]
pos_id = v.get("position")
if pos_id and Position.objects.filter(pk=pos_id).exists():
emp.position_id = pos_id
emp.save()
skills_ids = v.get("skills") or []
if skills_ids:
emp.skills.set(Skill.objects.filter(pk__in=skills_ids))
# 3) Отправляем код верификации
emp.email_activation_code = get_random_string(6, "0123456789")
emp.save(update_fields=["email_activation_code"])
send_templated_mail(
subject="Подтверждение регистрации",
to=[emp.email],
template_base="emails/registration_verify_code",
context={"code": emp.email_activation_code, "user": emp},
)
return Response(
{
"id": emp.id,
"email": emp.email,
"email_verified": emp.email_verified,
"is_active": emp.is_active,
},
status=status.HTTP_201_CREATED,
)