272 lines
9.5 KiB
Python
272 lines
9.5 KiB
Python
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 (
|
||
TokenObtainPairSerializer,
|
||
TokenRefreshSerializer,
|
||
)
|
||
|
||
from .models import UserAuthSession
|
||
from .services import (
|
||
SESSION_ID_CLAIM,
|
||
create_token_pair_for_session,
|
||
validate_refresh_session,
|
||
)
|
||
|
||
User = get_user_model()
|
||
|
||
|
||
class TokenPairResponseSerializer(serializers.Serializer):
|
||
refresh = serializers.CharField(read_only=True)
|
||
access = serializers.CharField(read_only=True)
|
||
|
||
|
||
class QrLoginCreateResponseSerializer(serializers.Serializer):
|
||
token = serializers.CharField(read_only=True)
|
||
expires_at = serializers.DateTimeField(read_only=True)
|
||
|
||
|
||
class QrLoginExchangeRequestSerializer(serializers.Serializer):
|
||
token = serializers.CharField(write_only=True, trim_whitespace=True)
|
||
|
||
|
||
class QrLoginRequestCreateResponseSerializer(serializers.Serializer):
|
||
scan_token = serializers.CharField(read_only=True)
|
||
client_secret = serializers.CharField(read_only=True)
|
||
expires_at = serializers.DateTimeField(read_only=True)
|
||
device_name = serializers.CharField(read_only=True)
|
||
ip_address = serializers.IPAddressField(read_only=True, allow_null=True)
|
||
|
||
|
||
class QrLoginRequestStatusRequestSerializer(serializers.Serializer):
|
||
client_secret = serializers.CharField(write_only=True, trim_whitespace=True)
|
||
|
||
|
||
class QrLoginRequestStatusResponseSerializer(serializers.Serializer):
|
||
status = serializers.CharField(read_only=True)
|
||
refresh = serializers.CharField(read_only=True, required=False)
|
||
access = serializers.CharField(read_only=True, required=False)
|
||
|
||
|
||
class QrLoginRequestDetailResponseSerializer(serializers.Serializer):
|
||
status = serializers.CharField(read_only=True)
|
||
device_name = serializers.CharField(read_only=True)
|
||
ip_address = serializers.IPAddressField(read_only=True, allow_null=True)
|
||
created_at = serializers.DateTimeField(read_only=True)
|
||
expires_at = serializers.DateTimeField(read_only=True)
|
||
|
||
|
||
class QrLoginRequestActionResponseSerializer(serializers.Serializer):
|
||
status = serializers.CharField(read_only=True)
|
||
|
||
|
||
class TokenRefreshRequestSerializer(serializers.Serializer):
|
||
refresh = serializers.CharField(
|
||
help_text="Refresh token, полученный при логине."
|
||
)
|
||
|
||
|
||
class TokenRefreshResponseSerializer(serializers.Serializer):
|
||
access = serializers.CharField(read_only=True)
|
||
|
||
|
||
class SessionSerializer(serializers.ModelSerializer):
|
||
session_id = serializers.UUIDField(read_only=True)
|
||
is_current = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = UserAuthSession
|
||
fields = (
|
||
"session_id",
|
||
"is_current",
|
||
"device_name",
|
||
"ip_address",
|
||
"created_at",
|
||
"last_seen_at",
|
||
"revoked_at",
|
||
)
|
||
|
||
def get_is_current(self, obj: UserAuthSession) -> bool:
|
||
current_session_id = self.context.get("current_session_id")
|
||
return str(obj.session_id) == str(current_session_id)
|
||
|
||
|
||
class SessionBulkActionResponseSerializer(serializers.Serializer):
|
||
revoked = serializers.IntegerField(read_only=True)
|
||
|
||
|
||
class ChangePasswordRequestSerializer(serializers.Serializer):
|
||
current_password = serializers.CharField(
|
||
write_only=True,
|
||
trim_whitespace=False,
|
||
style={"input_type": "password"},
|
||
)
|
||
new_password = serializers.CharField(
|
||
write_only=True,
|
||
min_length=8,
|
||
trim_whitespace=False,
|
||
style={"input_type": "password"},
|
||
)
|
||
new_password_confirm = serializers.CharField(
|
||
write_only=True,
|
||
trim_whitespace=False,
|
||
style={"input_type": "password"},
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
if attrs["new_password"] != attrs["new_password_confirm"]:
|
||
raise serializers.ValidationError(
|
||
{"new_password_confirm": "Подтверждение пароля не совпадает."}
|
||
)
|
||
if attrs["current_password"] == attrs["new_password"]:
|
||
raise serializers.ValidationError(
|
||
{"new_password": "Новый пароль должен отличаться от текущего."}
|
||
)
|
||
return attrs
|
||
|
||
def validate_new_password(self, value: str) -> str:
|
||
request = self.context.get("request")
|
||
validate_password(value, user=getattr(request, "user", None))
|
||
return value
|
||
|
||
|
||
class ChangePasswordResponseSerializer(serializers.Serializer):
|
||
ok = serializers.BooleanField(read_only=True)
|
||
|
||
|
||
class PasswordResetRequestSerializer(serializers.Serializer):
|
||
login = serializers.CharField(
|
||
write_only=True,
|
||
allow_blank=False,
|
||
trim_whitespace=True,
|
||
help_text="Email или телефон пользователя.",
|
||
)
|
||
|
||
|
||
class PasswordResetRequestResponseSerializer(serializers.Serializer):
|
||
ok = serializers.BooleanField(read_only=True)
|
||
|
||
|
||
class PasswordResetConfirmRequestSerializer(serializers.Serializer):
|
||
uid = serializers.CharField(write_only=True)
|
||
token = serializers.CharField(write_only=True)
|
||
new_password = serializers.CharField(
|
||
write_only=True,
|
||
min_length=8,
|
||
trim_whitespace=False,
|
||
style={"input_type": "password"},
|
||
)
|
||
new_password_confirm = serializers.CharField(
|
||
write_only=True,
|
||
trim_whitespace=False,
|
||
style={"input_type": "password"},
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
if attrs["new_password"] != attrs["new_password_confirm"]:
|
||
raise serializers.ValidationError(
|
||
{"new_password_confirm": "Подтверждение пароля не совпадает."}
|
||
)
|
||
return attrs
|
||
|
||
def validate_new_password(self, value: str) -> str:
|
||
validate_password(value)
|
||
return value
|
||
|
||
|
||
class PasswordResetConfirmResponseSerializer(serializers.Serializer):
|
||
ok = serializers.BooleanField(read_only=True)
|
||
|
||
|
||
class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||
"""
|
||
Принимает email ИЛИ phone/phone_number + password.
|
||
Если пришёл телефон — ищем пользователя и подставляем его email.
|
||
"""
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
# <- КЛЮЧЕВОЕ: email (username_field) не обязателен
|
||
self.fields[self.username_field].required = False
|
||
self.fields[self.username_field].help_text = (
|
||
"Email пользователя. Можно передать либо email, либо телефон."
|
||
)
|
||
# добавляем телефонные поля
|
||
self.fields["phone"] = serializers.CharField(
|
||
required=False,
|
||
allow_blank=True,
|
||
help_text="Номер телефона пользователя в произвольном формате.",
|
||
)
|
||
self.fields["phone_number"] = serializers.CharField(
|
||
required=False,
|
||
allow_blank=True,
|
||
help_text="Альтернативное имя поля для номера телефона.",
|
||
)
|
||
|
||
def validate(self, attrs):
|
||
raw_phone = (
|
||
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):
|
||
raise AuthenticationFailed(
|
||
"Email or phone number required",
|
||
code="no_credentials",
|
||
)
|
||
|
||
super().validate(attrs)
|
||
|
||
# не выдаём токены, если email не подтверждён/аккаунт не активен
|
||
if not self.user.email_verified or not self.user.is_active:
|
||
raise AuthenticationFailed(
|
||
"email_not_verified",
|
||
code="email_not_verified",
|
||
)
|
||
|
||
request = self.context.get("request")
|
||
if request is None:
|
||
raise AuthenticationFailed("request_context_missing")
|
||
|
||
refresh, access, _session = create_token_pair_for_session(
|
||
user=self.user,
|
||
request=request,
|
||
)
|
||
|
||
return {
|
||
"refresh": refresh,
|
||
"access": access,
|
||
}
|
||
|
||
|
||
class SessionTokenRefreshSerializer(TokenRefreshSerializer):
|
||
def validate(self, attrs):
|
||
raw_refresh = attrs["refresh"]
|
||
refresh = self.token_class(raw_refresh)
|
||
session = validate_refresh_session(
|
||
refresh,
|
||
raw_refresh=raw_refresh,
|
||
request=self.context.get("request"),
|
||
)
|
||
|
||
access = refresh.access_token
|
||
access[SESSION_ID_CLAIM] = str(session.session_id)
|
||
|
||
return {"access": str(access)}
|