Corp/backend/api/auth/serializers.py

284 lines
9.7 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.

from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
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 BrowserSessionRequestSerializer(serializers.Serializer):
next = serializers.CharField(
required=False,
allow_blank=True,
trim_whitespace=True,
max_length=4096,
)
class BrowserSessionResponseSerializer(serializers.Serializer):
redirect_url = serializers.CharField(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):
"""
Принимает единый login (LDAP username, email или телефон) + password.
Старые поля email/phone/phone_number остаются совместимыми.
"""
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 пользователя (поле сохранено для обратной совместимости)."
)
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,
help_text="Номер телефона пользователя в произвольном формате.",
)
self.fields["phone_number"] = serializers.CharField(
required=False,
allow_blank=True,
help_text="Альтернативное имя поля для номера телефона.",
)
def validate(self, attrs):
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")
)
identifier = str(identifier or "").strip()
if not identifier:
raise AuthenticationFailed(
"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 не подтверждён/аккаунт не активен
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)}