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)}