feat(auth): add session management and settings integration
This commit is contained in:
parent
23cc809e06
commit
7dcf84dcd1
23
backend/api/auth/authentication.py
Normal file
23
backend/api/auth/authentication.py
Normal file
@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
from .services import validate_access_session
|
||||
|
||||
|
||||
class SessionAwareJWTAuthentication(JWTAuthentication):
|
||||
def validate_session(self, validated_token, user, *, request=None):
|
||||
return validate_access_session(
|
||||
validated_token,
|
||||
user=user,
|
||||
request=request,
|
||||
)
|
||||
|
||||
def authenticate(self, request):
|
||||
result = super().authenticate(request)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
user, validated_token = result
|
||||
self.validate_session(validated_token, user, request=request)
|
||||
return user, validated_token
|
||||
82
backend/api/auth/models.py
Normal file
82
backend/api/auth/models.py
Normal file
@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class UserAuthSession(models.Model):
|
||||
session_id = models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
verbose_name="Session ID",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="auth_sessions",
|
||||
verbose_name="Пользователь",
|
||||
)
|
||||
refresh_token_hash = models.CharField(
|
||||
max_length=128,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Хеш refresh token",
|
||||
)
|
||||
ip_address = models.GenericIPAddressField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="IP адрес",
|
||||
)
|
||||
user_agent = models.TextField(blank=True, default="", verbose_name="User-Agent")
|
||||
device_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Устройство",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создана")
|
||||
last_seen_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Последняя активность",
|
||||
)
|
||||
revoked_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
verbose_name="Отозвана",
|
||||
)
|
||||
revoked_reason = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="Причина отзыва",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-last_seen_at", "-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "revoked_at"]),
|
||||
models.Index(fields=["user", "-last_seen_at"]),
|
||||
]
|
||||
verbose_name = "Сессия аутентификации"
|
||||
verbose_name_plural = "Сессии аутентификации"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user_id}:{self.session_id}"
|
||||
|
||||
@property
|
||||
def is_revoked(self) -> bool:
|
||||
return self.revoked_at is not None
|
||||
|
||||
def revoke(self, *, reason: str = "manual", commit: bool = True) -> None:
|
||||
if self.revoked_at:
|
||||
return
|
||||
self.revoked_at = timezone.now()
|
||||
self.revoked_reason = reason
|
||||
if commit:
|
||||
self.save(update_fields=["revoked_at", "revoked_reason"])
|
||||
@ -1,11 +1,23 @@
|
||||
# api/auth/serializers.py
|
||||
from __future__ import annotations
|
||||
|
||||
from api.v1.employees.views._helpers import PHONE_FIELD
|
||||
from employees.utils import _normalize_phone
|
||||
from django.contrib.auth import get_user_model
|
||||
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
|
||||
from rest_framework_simplejwt.serializers import (
|
||||
TokenObtainPairSerializer,
|
||||
TokenRefreshSerializer,
|
||||
)
|
||||
|
||||
from .models import UserAuthSession
|
||||
from .services import (
|
||||
SESSION_ID_CLAIM,
|
||||
create_auth_session,
|
||||
hash_refresh_token,
|
||||
validate_refresh_session,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -25,6 +37,31 @@ 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 PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||
"""
|
||||
Принимает email ИЛИ phone/phone_number + password.
|
||||
@ -69,10 +106,10 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||
if not attrs.get(self.username_field):
|
||||
raise AuthenticationFailed(
|
||||
"Email or phone number required",
|
||||
code="no_credentials"
|
||||
code="no_credentials",
|
||||
)
|
||||
|
||||
data = super().validate(attrs)
|
||||
super().validate(attrs)
|
||||
|
||||
# не выдаём токены, если email не подтверждён/аккаунт не активен
|
||||
if not self.user.email_verified or not self.user.is_active:
|
||||
@ -81,4 +118,36 @@ class PhoneOrEmailTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||
code="email_not_verified",
|
||||
)
|
||||
|
||||
return data
|
||||
request = self.context.get("request")
|
||||
if request is None:
|
||||
raise AuthenticationFailed("request_context_missing")
|
||||
|
||||
session = create_auth_session(user=self.user, request=request)
|
||||
refresh = self.get_token(self.user)
|
||||
refresh[SESSION_ID_CLAIM] = str(session.session_id)
|
||||
access = refresh.access_token
|
||||
access[SESSION_ID_CLAIM] = str(session.session_id)
|
||||
|
||||
session.refresh_token_hash = hash_refresh_token(str(refresh))
|
||||
session.save(update_fields=["refresh_token_hash"])
|
||||
|
||||
return {
|
||||
"refresh": str(refresh),
|
||||
"access": str(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)}
|
||||
|
||||
158
backend/api/auth/services.py
Normal file
158
backend/api/auth/services.py
Normal file
@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import salted_hmac
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken
|
||||
from rest_framework_simplejwt.settings import api_settings as jwt_api_settings
|
||||
|
||||
from .models import UserAuthSession
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SESSION_ID_CLAIM = "session_id"
|
||||
LAST_SEEN_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
def hash_refresh_token(raw_token: str) -> str:
|
||||
return salted_hmac("api.auth.refresh", raw_token).hexdigest()
|
||||
|
||||
|
||||
def extract_client_ip(request) -> str:
|
||||
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
return (request.META.get("REMOTE_ADDR") or "").strip()
|
||||
|
||||
|
||||
def extract_user_agent(request) -> str:
|
||||
return (request.META.get("HTTP_USER_AGENT") or "").strip()[:1024]
|
||||
|
||||
|
||||
def build_device_name(user_agent: str) -> str:
|
||||
if not user_agent:
|
||||
return "Неизвестное устройство"
|
||||
|
||||
lowered = user_agent.lower()
|
||||
|
||||
browser = "Браузер"
|
||||
if "yabrowser" in lowered:
|
||||
browser = "Yandex Browser"
|
||||
elif "edg/" in lowered:
|
||||
browser = "Microsoft Edge"
|
||||
elif "chrome/" in lowered and "chromium" not in lowered:
|
||||
browser = "Google Chrome"
|
||||
elif "firefox/" in lowered:
|
||||
browser = "Mozilla Firefox"
|
||||
elif "safari/" in lowered and "chrome/" not in lowered:
|
||||
browser = "Safari"
|
||||
|
||||
platform = "Unknown OS"
|
||||
if "android" in lowered:
|
||||
platform = "Android"
|
||||
elif "iphone" in lowered or "ipad" in lowered or "ios" in lowered:
|
||||
platform = "iOS"
|
||||
elif "windows" in lowered:
|
||||
platform = "Windows"
|
||||
elif "mac os x" in lowered or "macintosh" in lowered:
|
||||
platform = "macOS"
|
||||
elif "linux" in lowered:
|
||||
platform = "Linux"
|
||||
|
||||
return f"{browser} on {platform}"
|
||||
|
||||
|
||||
def create_auth_session(*, user: User, request) -> UserAuthSession:
|
||||
user_agent = extract_user_agent(request)
|
||||
return UserAuthSession.objects.create(
|
||||
user=user,
|
||||
ip_address=extract_client_ip(request) or None,
|
||||
user_agent=user_agent,
|
||||
device_name=build_device_name(user_agent),
|
||||
)
|
||||
|
||||
|
||||
def _load_session_from_token(token: Any, *, user: User | None = None) -> UserAuthSession:
|
||||
session_id = token.get(SESSION_ID_CLAIM)
|
||||
if not session_id:
|
||||
raise AuthenticationFailed("session_missing")
|
||||
|
||||
user_id = token.get(jwt_api_settings.USER_ID_CLAIM)
|
||||
if user is not None:
|
||||
user_id = user.pk
|
||||
|
||||
try:
|
||||
return UserAuthSession.objects.select_related("user").get(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except UserAuthSession.DoesNotExist as exc:
|
||||
raise AuthenticationFailed("session_not_found") from exc
|
||||
|
||||
|
||||
def touch_session(
|
||||
session: UserAuthSession,
|
||||
*,
|
||||
request=None,
|
||||
force: bool = False,
|
||||
) -> UserAuthSession:
|
||||
now = timezone.now()
|
||||
update_fields: list[str] = []
|
||||
|
||||
if force or not session.last_seen_at or (
|
||||
now - session.last_seen_at >= LAST_SEEN_UPDATE_INTERVAL
|
||||
):
|
||||
session.last_seen_at = now
|
||||
update_fields.append("last_seen_at")
|
||||
|
||||
if request is not None:
|
||||
ip_address = extract_client_ip(request) or None
|
||||
user_agent = extract_user_agent(request)
|
||||
device_name = build_device_name(user_agent)
|
||||
|
||||
if session.ip_address != ip_address:
|
||||
session.ip_address = ip_address
|
||||
update_fields.append("ip_address")
|
||||
if user_agent and session.user_agent != user_agent:
|
||||
session.user_agent = user_agent
|
||||
update_fields.append("user_agent")
|
||||
if device_name and session.device_name != device_name:
|
||||
session.device_name = device_name
|
||||
update_fields.append("device_name")
|
||||
|
||||
if update_fields:
|
||||
session.save(update_fields=update_fields)
|
||||
return session
|
||||
|
||||
|
||||
def validate_access_session(
|
||||
token: Any,
|
||||
*,
|
||||
user: User,
|
||||
request=None,
|
||||
) -> UserAuthSession:
|
||||
session = _load_session_from_token(token, user=user)
|
||||
if session.is_revoked:
|
||||
raise AuthenticationFailed("session_revoked")
|
||||
return touch_session(session, request=request)
|
||||
|
||||
|
||||
def validate_refresh_session(
|
||||
token: Any,
|
||||
*,
|
||||
raw_refresh: str,
|
||||
request=None,
|
||||
) -> UserAuthSession:
|
||||
session = _load_session_from_token(token)
|
||||
if session.is_revoked:
|
||||
raise InvalidToken("session_revoked")
|
||||
|
||||
expected_hash = hash_refresh_token(raw_refresh)
|
||||
if session.refresh_token_hash != expected_hash:
|
||||
raise InvalidToken("session_mismatch")
|
||||
|
||||
return touch_session(session, request=request, force=True)
|
||||
31
backend/api/auth/urls.py
Normal file
31
backend/api/auth/urls.py
Normal file
@ -0,0 +1,31 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
JWTTokenRefreshView,
|
||||
LogoutOtherSessionsAPIView,
|
||||
PhoneOrEmailTokenObtainPairView,
|
||||
RegisterAPIView,
|
||||
ResendEmailAPIView,
|
||||
SessionDetailAPIView,
|
||||
SessionListAPIView,
|
||||
VerifyEmailAPIView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("token/", PhoneOrEmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("token/refresh/", JWTTokenRefreshView.as_view(), name="token_refresh"),
|
||||
path("register/", RegisterAPIView.as_view(), name="register"),
|
||||
path("resend-email/", ResendEmailAPIView.as_view(), name="resend-email"),
|
||||
path("verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"),
|
||||
path("sessions/", SessionListAPIView.as_view(), name="sessions"),
|
||||
path(
|
||||
"sessions/logout-others/",
|
||||
LogoutOtherSessionsAPIView.as_view(),
|
||||
name="logout-others",
|
||||
),
|
||||
path(
|
||||
"sessions/<uuid:session_id>/",
|
||||
SessionDetailAPIView.as_view(),
|
||||
name="session-detail",
|
||||
),
|
||||
]
|
||||
@ -1,18 +1,55 @@
|
||||
# api/auth/views.py
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from api.v1.employees.serializers import (
|
||||
EmailSerializer,
|
||||
EmailVerifySerializer,
|
||||
RegisterSerializer,
|
||||
)
|
||||
from api.v1.employees.views._helpers import Employee
|
||||
from api.v1.employees.views.mixins import LdapUserCreationMixin
|
||||
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 drf_spectacular.utils import (
|
||||
OpenApiExample,
|
||||
OpenApiParameter,
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
inline_serializer,
|
||||
)
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
from employees.models import Position, Skill
|
||||
from employees.utils import _normalize_phone
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
|
||||
from .models import UserAuthSession
|
||||
from .serializers import (
|
||||
PhoneOrEmailTokenObtainPairSerializer,
|
||||
SessionBulkActionResponseSerializer,
|
||||
SessionSerializer,
|
||||
SessionTokenRefreshSerializer,
|
||||
TokenPairResponseSerializer,
|
||||
TokenRefreshRequestSerializer,
|
||||
TokenRefreshResponseSerializer,
|
||||
)
|
||||
from .services import SESSION_ID_CLAIM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnonymousAPIView(APIView):
|
||||
authentication_classes = []
|
||||
throttle_scope = "anon"
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@ -59,9 +96,350 @@ class PhoneOrEmailTokenObtainPairView(TokenObtainPairView):
|
||||
responses={
|
||||
200: TokenRefreshResponseSerializer,
|
||||
401: OpenApiResponse(
|
||||
description="Refresh token недействителен или истек."
|
||||
description="Refresh token недействителен, истек или сессия отозвана."
|
||||
),
|
||||
},
|
||||
)
|
||||
class JWTTokenRefreshView(TokenRefreshView):
|
||||
pass
|
||||
serializer_class = SessionTokenRefreshSerializer
|
||||
|
||||
|
||||
class ResendEmailAPIView(AnonymousAPIView):
|
||||
@extend_schema(
|
||||
tags=["Auth"],
|
||||
summary="Повторно отправить код подтверждения email",
|
||||
request=EmailSerializer,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
"ResendEmailResponse",
|
||||
{"ok": serializers.BooleanField()},
|
||||
),
|
||||
400: OpenApiResponse(description="Email уже подтвержден."),
|
||||
404: OpenApiResponse(description="Пользователь не найден."),
|
||||
},
|
||||
)
|
||||
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):
|
||||
@extend_schema(
|
||||
tags=["Auth"],
|
||||
summary="Подтвердить email кодом",
|
||||
request=EmailVerifySerializer,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
"VerifyEmailResponse",
|
||||
{
|
||||
"ok": serializers.BooleanField(),
|
||||
"user_id": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
400: OpenApiResponse(description="Код пустой, неверный или истек."),
|
||||
404: OpenApiResponse(description="Пользователь не найден."),
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
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)
|
||||
|
||||
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):
|
||||
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
||||
|
||||
def is_ldap_enabled(self) -> bool:
|
||||
# Сохраняем старую точку patch'а тестов:
|
||||
# api.v1.employees.views._is_ldap_enabled
|
||||
from api.v1.employees import views as employee_views
|
||||
|
||||
return employee_views._is_ldap_enabled()
|
||||
|
||||
@extend_schema(
|
||||
tags=["Auth"],
|
||||
summary="Зарегистрировать нового пользователя",
|
||||
request=RegisterSerializer,
|
||||
responses={
|
||||
201: inline_serializer(
|
||||
"RegisterResponse",
|
||||
{
|
||||
"id": serializers.IntegerField(),
|
||||
"email": serializers.EmailField(),
|
||||
"email_verified": serializers.BooleanField(),
|
||||
"is_active": serializers.BooleanField(),
|
||||
},
|
||||
),
|
||||
200: inline_serializer(
|
||||
"RegisterPendingVerificationResponse",
|
||||
{
|
||||
"ok": serializers.BooleanField(),
|
||||
"pending_verification": serializers.BooleanField(),
|
||||
"user_id": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
400: OpenApiResponse(
|
||||
description="Ошибка валидации или занятый email/телефон."
|
||||
),
|
||||
},
|
||||
)
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
logger.warning("[REGISTER] Received data: %s", request.data)
|
||||
logger.warning("[REGISTER] Content-Type: %s", request.content_type)
|
||||
|
||||
ser = RegisterSerializer(data=request.data)
|
||||
if not ser.is_valid():
|
||||
logger.warning("[REGISTER] Validation errors: %s", 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(
|
||||
"[REGISTER] Phone normalization: input=%s, normalized=%s",
|
||||
v.get("phone_number"),
|
||||
phone_norm,
|
||||
)
|
||||
if not phone_norm:
|
||||
logger.error(
|
||||
"[REGISTER] Phone normalization FAILED for: %s",
|
||||
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(
|
||||
"[REGISTER] User exists: email=%s, verified=%s",
|
||||
email,
|
||||
user.email_verified,
|
||||
)
|
||||
if user.email_verified:
|
||||
logger.error("[REGISTER] Email already taken and verified: %s", email)
|
||||
return Response({"ok": False, "error": "email_taken"}, status=400)
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
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))
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class SessionBaseAPIView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_current_session_id(self, request) -> str | None:
|
||||
token = getattr(request, "auth", None)
|
||||
if token is None:
|
||||
return None
|
||||
return token.get(SESSION_ID_CLAIM)
|
||||
|
||||
|
||||
class SessionListAPIView(SessionBaseAPIView):
|
||||
@extend_schema(
|
||||
tags=["Auth"],
|
||||
summary="Список активных сессий пользователя",
|
||||
responses={200: SessionSerializer(many=True)},
|
||||
)
|
||||
def get(self, request):
|
||||
current_session_id = self.get_current_session_id(request)
|
||||
queryset = UserAuthSession.objects.filter(
|
||||
user=request.user,
|
||||
revoked_at__isnull=True,
|
||||
).order_by("-last_seen_at", "-created_at")
|
||||
serializer = SessionSerializer(
|
||||
queryset,
|
||||
many=True,
|
||||
context={"current_session_id": current_session_id},
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class SessionDetailAPIView(SessionBaseAPIView):
|
||||
@extend_schema(
|
||||
tags=["Auth"],
|
||||
summary="Завершить одну сессию",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="session_id",
|
||||
type=str,
|
||||
location=OpenApiParameter.PATH,
|
||||
)
|
||||
],
|
||||
responses={204: OpenApiResponse(description="Сессия завершена.")},
|
||||
)
|
||||
def delete(self, request, session_id):
|
||||
session = UserAuthSession.objects.filter(
|
||||
user=request.user,
|
||||
session_id=session_id,
|
||||
revoked_at__isnull=True,
|
||||
).first()
|
||||
if not session:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
reason = "self_logout" if str(session.session_id) == str(
|
||||
self.get_current_session_id(request)
|
||||
) else "manual_logout"
|
||||
session.revoke(reason=reason)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class LogoutOtherSessionsAPIView(SessionBaseAPIView):
|
||||
@extend_schema(
|
||||
tags=["Auth"],
|
||||
summary="Завершить все остальные сессии",
|
||||
responses={200: SessionBulkActionResponseSerializer},
|
||||
)
|
||||
def post(self, request):
|
||||
current_session_id = self.get_current_session_id(request)
|
||||
sessions = UserAuthSession.objects.filter(
|
||||
user=request.user,
|
||||
revoked_at__isnull=True,
|
||||
).exclude(session_id=current_session_id)
|
||||
|
||||
revoked = 0
|
||||
for session in sessions:
|
||||
session.revoke(reason="logout_others")
|
||||
revoked += 1
|
||||
|
||||
return Response({"revoked": revoked}, status=status.HTTP_200_OK)
|
||||
|
||||
128
backend/api/migrations/0001_initial.py
Normal file
128
backend/api/migrations/0001_initial.py
Normal file
@ -0,0 +1,128 @@
|
||||
# Generated manually for auth session registry.
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserAuthSession",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"session_id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
unique=True,
|
||||
verbose_name="Session ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"refresh_token_hash",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=128,
|
||||
verbose_name="Хеш refresh token",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ip_address",
|
||||
models.GenericIPAddressField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="IP адрес",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_agent",
|
||||
models.TextField(blank=True, default="", verbose_name="User-Agent"),
|
||||
),
|
||||
(
|
||||
"device_name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=255,
|
||||
verbose_name="Устройство",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Создана"),
|
||||
),
|
||||
(
|
||||
"last_seen_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Последняя активность",
|
||||
),
|
||||
),
|
||||
(
|
||||
"revoked_at",
|
||||
models.DateTimeField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
null=True,
|
||||
verbose_name="Отозвана",
|
||||
),
|
||||
),
|
||||
(
|
||||
"revoked_reason",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
max_length=64,
|
||||
verbose_name="Причина отзыва",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="auth_sessions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Пользователь",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Сессия аутентификации",
|
||||
"verbose_name_plural": "Сессии аутентификации",
|
||||
"ordering": ["-last_seen_at", "-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userauthsession",
|
||||
index=models.Index(
|
||||
fields=["user", "revoked_at"],
|
||||
name="api_useraut_user_id_1e9721_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userauthsession",
|
||||
index=models.Index(
|
||||
fields=["user", "-last_seen_at"],
|
||||
name="api_useraut_user_id_82cdaa_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
3
backend/api/models.py
Normal file
3
backend/api/models.py
Normal file
@ -0,0 +1,3 @@
|
||||
from api.auth.models import UserAuthSession
|
||||
|
||||
__all__ = ["UserAuthSession"]
|
||||
@ -8,7 +8,6 @@ from drf_spectacular.views import (
|
||||
)
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from .auth.views import JWTTokenRefreshView, PhoneOrEmailTokenObtainPairView
|
||||
from .docs_views import AsyncAPIDocsView, AsyncAPISchemaView
|
||||
app_name = "api"
|
||||
|
||||
@ -42,14 +41,5 @@ urlpatterns = [
|
||||
path("ws/schema/", AsyncAPISchemaView.as_view(), name="ws-schema"),
|
||||
path("ws/docs/", AsyncAPIDocsView.as_view(), name="ws-docs"),
|
||||
path("v1/", include(("api.v1.urls", "v1"), namespace="v1")),
|
||||
path(
|
||||
"auth/token/",
|
||||
PhoneOrEmailTokenObtainPairView.as_view(),
|
||||
name="token_obtain_pair",
|
||||
),
|
||||
path(
|
||||
"auth/token/refresh/",
|
||||
JWTTokenRefreshView.as_view(),
|
||||
name="token_refresh",
|
||||
),
|
||||
path("auth/", include("api.auth.urls")),
|
||||
]
|
||||
|
||||
@ -1,343 +1,15 @@
|
||||
"""Auth-related views.
|
||||
"""Обратно-совместимый реэкспорт auth views из canonical api.auth."""
|
||||
|
||||
Регистрация, подтверждение email и повторная отправка кода.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from common.emails import send_templated_mail
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
inline_serializer,
|
||||
from api.auth.views import (
|
||||
AnonymousAPIView,
|
||||
RegisterAPIView,
|
||||
ResendEmailAPIView,
|
||||
VerifyEmailAPIView,
|
||||
)
|
||||
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 serializers, 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 не требуется.
|
||||
"""
|
||||
|
||||
# Отключаем SessionAuthentication для публичных endpoints.
|
||||
authentication_classes = []
|
||||
throttle_scope = "anon"
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
|
||||
class ResendEmailAPIView(AnonymousAPIView):
|
||||
"""POST /api/v1/auth/resend-email/ body: {"email": "..."}"""
|
||||
|
||||
@extend_schema(
|
||||
tags=["Auth"],
|
||||
summary="Повторно отправить код подтверждения email",
|
||||
request=EmailSerializer,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
"ResendEmailResponse",
|
||||
{"ok": serializers.BooleanField()},
|
||||
),
|
||||
400: OpenApiResponse(description="Email уже подтвержден."),
|
||||
404: OpenApiResponse(description="Пользователь не найден."),
|
||||
},
|
||||
)
|
||||
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):
|
||||
@extend_schema(
|
||||
tags=["Auth"],
|
||||
summary="Подтвердить email кодом",
|
||||
request=EmailVerifySerializer,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
"VerifyEmailResponse",
|
||||
{
|
||||
"ok": serializers.BooleanField(),
|
||||
"user_id": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
400: OpenApiResponse(description="Код пустой, неверный или истек."),
|
||||
404: OpenApiResponse(description="Пользователь не найден."),
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Auth"],
|
||||
summary="Зарегистрировать нового пользователя",
|
||||
request=RegisterSerializer,
|
||||
responses={
|
||||
201: inline_serializer(
|
||||
"RegisterResponse",
|
||||
{
|
||||
"id": serializers.IntegerField(),
|
||||
"email": serializers.EmailField(),
|
||||
"email_verified": serializers.BooleanField(),
|
||||
"is_active": serializers.BooleanField(),
|
||||
},
|
||||
),
|
||||
200: inline_serializer(
|
||||
"RegisterPendingVerificationResponse",
|
||||
{
|
||||
"ok": serializers.BooleanField(),
|
||||
"pending_verification": serializers.BooleanField(),
|
||||
"user_id": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
400: OpenApiResponse(
|
||||
description="Ошибка валидации или занятый email/телефон."
|
||||
),
|
||||
},
|
||||
)
|
||||
@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,
|
||||
)
|
||||
__all__ = [
|
||||
"AnonymousAPIView",
|
||||
"RegisterAPIView",
|
||||
"ResendEmailAPIView",
|
||||
"VerifyEmailAPIView",
|
||||
]
|
||||
|
||||
@ -34,6 +34,9 @@ class LdapUserCreationMixin:
|
||||
create_db_user(): Создаёт пользователя напрямую в БД (fallback без LDAP)
|
||||
"""
|
||||
|
||||
def is_ldap_enabled(self) -> bool:
|
||||
return _is_ldap_enabled()
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
*,
|
||||
@ -63,7 +66,7 @@ class LdapUserCreationMixin:
|
||||
- (Employee, None) при успехе
|
||||
- (None, Response) при ошибке (Response для возврата клиенту)
|
||||
"""
|
||||
if _is_ldap_enabled():
|
||||
if self.is_ldap_enabled():
|
||||
# Режим с LDAP: создаём disabled учётку в LDAP + пароль
|
||||
return self.create_ldap_user(
|
||||
first_name=first_name,
|
||||
|
||||
@ -24,10 +24,7 @@ from .employees.views import (
|
||||
EmployeeViewSet,
|
||||
GroupViewSet,
|
||||
PositionViewSet,
|
||||
RegisterAPIView,
|
||||
ResendEmailAPIView,
|
||||
SkillViewSet,
|
||||
VerifyEmailAPIView,
|
||||
)
|
||||
from .feed.views import PostViewSet
|
||||
from .requests_app.views import RequestViewSet
|
||||
@ -90,17 +87,7 @@ router.register(r"communications/polls", PollViewSet, basename="polls")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("auth/register/", RegisterAPIView.as_view(), name="register"),
|
||||
path(
|
||||
"auth/resend-email/",
|
||||
ResendEmailAPIView.as_view(),
|
||||
name="resend-email",
|
||||
),
|
||||
path(
|
||||
"auth/verify-email/",
|
||||
VerifyEmailAPIView.as_view(),
|
||||
name="verify-email",
|
||||
),
|
||||
path("auth/", include("api.auth.urls")),
|
||||
# Notifications API (из самого модуля notifications)
|
||||
path("notifications/", include("notifications.api.urls")),
|
||||
# Procurement API
|
||||
|
||||
@ -6,6 +6,7 @@ from urllib.parse import parse_qs
|
||||
|
||||
from channels.db import database_sync_to_async
|
||||
from channels.middleware import BaseMiddleware
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
|
||||
class JWTAuthMiddleware(BaseMiddleware):
|
||||
@ -28,22 +29,23 @@ class JWTAuthMiddleware(BaseMiddleware):
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def _get_user(self, validated_token):
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
def _authenticate_raw_token(self, raw_token: str):
|
||||
from api.auth.authentication import SessionAwareJWTAuthentication
|
||||
|
||||
auth = JWTAuthentication()
|
||||
auth = SessionAwareJWTAuthentication()
|
||||
validated_token = auth.get_validated_token(raw_token)
|
||||
user = auth.get_user(validated_token)
|
||||
return user
|
||||
auth.validate_session(validated_token, user)
|
||||
return user, validated_token
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
scope.setdefault("user", AnonymousUser())
|
||||
try:
|
||||
raw = self._get_raw_token(scope)
|
||||
if raw:
|
||||
from rest_framework_simplejwt.tokens import UntypedToken
|
||||
|
||||
token = UntypedToken(raw) # валидация подписи/срока
|
||||
user = await self._get_user(token)
|
||||
user, token = await self._authenticate_raw_token(raw)
|
||||
scope["user"] = user
|
||||
scope["auth"] = token
|
||||
except Exception:
|
||||
# оставим scope["user"] анонимным — соединение сам consumer решит
|
||||
pass
|
||||
|
||||
@ -417,7 +417,7 @@ CHANNEL_LAYERS = {
|
||||
# -----------------------------------------------------------------------------
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
"api.auth.authentication.SessionAwareJWTAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
|
||||
@ -18,6 +18,21 @@ CHANNEL_LAYERS = {
|
||||
}
|
||||
}
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "eusrr-test-cache",
|
||||
}
|
||||
}
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
**REST_FRAMEWORK,
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
**REST_FRAMEWORK.get("DEFAULT_THROTTLE_RATES", {}),
|
||||
"anon": "10000/min",
|
||||
},
|
||||
}
|
||||
|
||||
# Celery - синхронное выполнение в тестах (без отдельного worker)
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
@ -6,8 +6,10 @@ from django.core import mail
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import get_user_model
|
||||
from api.auth.models import UserAuthSession
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
User = get_user_model()
|
||||
@ -25,6 +27,8 @@ def api():
|
||||
def _locmem_email_backend(settings):
|
||||
# Все письма складываются в mail.outbox
|
||||
settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
settings.LDAP_ENABLED = False
|
||||
settings.LDAP_WRITE_ENABLED = False
|
||||
|
||||
|
||||
# ======== утилиты ========
|
||||
@ -100,6 +104,10 @@ def jwt_obtain(
|
||||
return api.post(url, payload, format="json")
|
||||
|
||||
|
||||
def auth_headers(access: str) -> dict[str, str]:
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {access}"}
|
||||
|
||||
|
||||
# ======== регистрация: happy-path ========
|
||||
|
||||
|
||||
@ -288,3 +296,110 @@ def test_login_wrong_password(api):
|
||||
|
||||
r = jwt_obtain(api, email="ivan@example.com", password="BAD")
|
||||
assert r.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("route_name", "path"),
|
||||
[
|
||||
("api:register", "/api/auth/register/"),
|
||||
("api:v1:register", "/api/v1/auth/register/"),
|
||||
("api:resend-email", "/api/auth/resend-email/"),
|
||||
("api:v1:resend-email", "/api/v1/auth/resend-email/"),
|
||||
("api:verify-email", "/api/auth/verify-email/"),
|
||||
("api:v1:verify-email", "/api/v1/auth/verify-email/"),
|
||||
("api:token_obtain_pair", "/api/auth/token/"),
|
||||
("api:v1:token_obtain_pair", "/api/v1/auth/token/"),
|
||||
("api:token_refresh", "/api/auth/token/refresh/"),
|
||||
("api:v1:token_refresh", "/api/v1/auth/token/refresh/"),
|
||||
("api:sessions", "/api/auth/sessions/"),
|
||||
("api:v1:sessions", "/api/v1/auth/sessions/"),
|
||||
("api:logout-others", "/api/auth/sessions/logout-others/"),
|
||||
("api:v1:logout-others", "/api/v1/auth/sessions/logout-others/"),
|
||||
],
|
||||
)
|
||||
def test_auth_routes_are_mirrored(route_name, path):
|
||||
assert reverse(route_name) == path
|
||||
|
||||
|
||||
def test_login_creates_auth_session_and_embeds_session_id(api):
|
||||
assert register(api).status_code == status.HTTP_201_CREATED
|
||||
code = extract_code_from_last_email()
|
||||
assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
|
||||
|
||||
response = jwt_obtain(api, email="ivan@example.com")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
payload = response.json()
|
||||
access = AccessToken(payload["access"])
|
||||
refresh = RefreshToken(payload["refresh"])
|
||||
|
||||
assert access["session_id"] == refresh["session_id"]
|
||||
|
||||
session = UserAuthSession.objects.get(session_id=access["session_id"])
|
||||
assert session.user.email == "ivan@example.com"
|
||||
assert session.refresh_token_hash
|
||||
|
||||
|
||||
def test_sessions_list_and_logout_others_revoke_other_session(api):
|
||||
assert register(api).status_code == status.HTTP_201_CREATED
|
||||
code = extract_code_from_last_email()
|
||||
assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
|
||||
|
||||
first = jwt_obtain(api, email="ivan@example.com")
|
||||
second = jwt_obtain(api, email="ivan@example.com")
|
||||
assert first.status_code == second.status_code == status.HTTP_200_OK
|
||||
|
||||
access1 = first.json()["access"]
|
||||
access2 = second.json()["access"]
|
||||
refresh2 = second.json()["refresh"]
|
||||
session_id_2 = str(AccessToken(access2)["session_id"])
|
||||
|
||||
listed = api.get(reverse("api:sessions"), **auth_headers(access1))
|
||||
assert listed.status_code == status.HTTP_200_OK
|
||||
assert len(listed.json()) == 2
|
||||
|
||||
logout_others = api.post(reverse("api:logout-others"), **auth_headers(access1))
|
||||
assert logout_others.status_code == status.HTTP_200_OK
|
||||
assert logout_others.json()["revoked"] == 1
|
||||
|
||||
listed_after = api.get(reverse("api:v1:sessions"), **auth_headers(access1))
|
||||
assert listed_after.status_code == status.HTTP_200_OK
|
||||
assert len(listed_after.json()) == 1
|
||||
assert listed_after.json()[0]["is_current"] is True
|
||||
|
||||
profile = api.get(reverse("api:v1:employees-me"), **auth_headers(access2))
|
||||
assert profile.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
refresh_resp = api.post(
|
||||
reverse("api:v1:token_refresh"),
|
||||
{"refresh": refresh2},
|
||||
format="json",
|
||||
)
|
||||
assert refresh_resp.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
session = UserAuthSession.objects.get(session_id=session_id_2)
|
||||
assert session.revoked_at is not None
|
||||
|
||||
|
||||
def test_delete_current_session_revokes_access_immediately(api):
|
||||
assert register(api).status_code == status.HTTP_201_CREATED
|
||||
code = extract_code_from_last_email()
|
||||
assert verify(api, email="ivan@example.com", code=code).status_code in (200, 204)
|
||||
|
||||
login = jwt_obtain(api, email="ivan@example.com")
|
||||
assert login.status_code == status.HTTP_200_OK
|
||||
|
||||
access = login.json()["access"]
|
||||
session_id = AccessToken(access)["session_id"]
|
||||
|
||||
before = api.get(reverse("api:v1:employees-me"), **auth_headers(access))
|
||||
assert before.status_code == status.HTTP_200_OK
|
||||
|
||||
deleted = api.delete(
|
||||
reverse("api:session-detail", kwargs={"session_id": session_id}),
|
||||
**auth_headers(access),
|
||||
)
|
||||
assert deleted.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
after = api.get(reverse("api:v1:employees-me"), **auth_headers(access))
|
||||
assert after.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@ -23,7 +23,11 @@ def test_openapi_schema_is_available(api_client: APIClient):
|
||||
payload = response.json()
|
||||
assert payload["openapi"].startswith("3.")
|
||||
assert "/api/auth/token/" in payload["paths"]
|
||||
assert "/api/auth/register/" in payload["paths"]
|
||||
assert "/api/auth/sessions/" in payload["paths"]
|
||||
assert "/api/v1/auth/token/" in payload["paths"]
|
||||
assert "/api/v1/auth/register/" in payload["paths"]
|
||||
assert "/api/v1/auth/sessions/" in payload["paths"]
|
||||
assert "/api/v1/documents/" in payload["paths"]
|
||||
assert "/api/v1/notifications/" in payload["paths"]
|
||||
assert "/api/v1/procurement/stats/overview/" in payload["paths"]
|
||||
|
||||
@ -283,7 +283,7 @@ def test_dismissal_handles_ldap_error_gracefully(
|
||||
):
|
||||
"""Проверяет graceful handling ошибки LDAP при увольнении."""
|
||||
from employees.ldap.errors import DirectoryLdapError
|
||||
from employees.models import LdapSyncState
|
||||
from employees.models import LdapSyncQueue, LdapSyncState
|
||||
|
||||
mock_is_enabled.return_value = True
|
||||
mock_update.side_effect = DirectoryLdapError("LDAP connection failed")
|
||||
@ -317,8 +317,20 @@ def test_dismissal_handles_ldap_error_gracefully(
|
||||
# БД-изменения должны быть применены
|
||||
assert emp.is_active is False
|
||||
|
||||
# LDAP был вызван (и упал)
|
||||
mock_update.assert_called_once()
|
||||
# LDAP sync был предпринят хотя бы один раз.
|
||||
# При eager Celery retry может вызвать update_user повторно в рамках
|
||||
# того же теста, поэтому количество вызовов здесь недетерминировано.
|
||||
assert mock_update.call_count >= 1
|
||||
first_call = mock_update.call_args_list[0]
|
||||
assert first_call.kwargs['emp'].id == emp.id
|
||||
assert first_call.kwargs['changes'] == {'is_active': False}
|
||||
|
||||
queue_item = LdapSyncQueue.objects.filter(
|
||||
operation="employee_save",
|
||||
model_name="employee",
|
||||
object_pk=str(emp.pk),
|
||||
).first()
|
||||
assert queue_item is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -591,4 +603,3 @@ def test_dismissal_without_department_moves_to_dismissed(
|
||||
assert call_args.kwargs['changes'] == {'is_active': False}
|
||||
mock_ensure_dn.assert_called_once()
|
||||
assert mock_ensure_dn.call_args.args[0].id == emp.id
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
Покрывает тест-кейсы R1-R9 из плана тестирования.
|
||||
"""
|
||||
import itertools
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
@ -45,8 +46,9 @@ def test_user_data():
|
||||
# ---------- Тесты БЕЗ LDAP (LDAP_ENABLED=False) ----------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route_name", ["api:register", "api:v1:register"])
|
||||
def test_register_without_ldap_creates_user_only_in_db(
|
||||
api_client, test_user_data, settings
|
||||
api_client, test_user_data, settings, route_name
|
||||
):
|
||||
"""
|
||||
R7: Успешная регистрация без LDAP.
|
||||
@ -56,7 +58,7 @@ def test_register_without_ldap_creates_user_only_in_db(
|
||||
"""
|
||||
settings.LDAP_ENABLED = False
|
||||
|
||||
url = reverse("api:v1:register")
|
||||
url = reverse(route_name)
|
||||
response = api_client.post(url, test_user_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
@ -69,8 +71,9 @@ def test_register_without_ldap_creates_user_only_in_db(
|
||||
assert user.check_password(test_user_data["password"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route_name", ["api:register", "api:v1:register"])
|
||||
def test_register_without_ldap_duplicate_email_returns_400(
|
||||
api_client, test_user_data, settings
|
||||
api_client, test_user_data, settings, route_name
|
||||
):
|
||||
"""R9: Дублирование email без LDAP возвращает 400"""
|
||||
settings.LDAP_ENABLED = False
|
||||
@ -84,8 +87,35 @@ def test_register_without_ldap_duplicate_email_returns_400(
|
||||
email_verified=True, # Верифицирован - нельзя регистрировать снова
|
||||
)
|
||||
|
||||
url = reverse("api:v1:register")
|
||||
url = reverse(route_name)
|
||||
response = api_client.post(url, test_user_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route_name", ["api:register", "api:v1:register"])
|
||||
@patch("api.auth.views.RegisterAPIView.create_ldap_user")
|
||||
def test_register_with_ldap_uses_ldap_creation_path(
|
||||
mock_create_ldap_user,
|
||||
api_client,
|
||||
test_user_data,
|
||||
settings,
|
||||
route_name,
|
||||
):
|
||||
settings.LDAP_ENABLED = True
|
||||
|
||||
emp = Employee(
|
||||
email=test_user_data["email"],
|
||||
phone_number=test_user_data["phone_number"],
|
||||
first_name=test_user_data["first_name"],
|
||||
last_name=test_user_data["last_name"],
|
||||
is_active=False,
|
||||
email_verified=False,
|
||||
is_ldap_managed=True,
|
||||
)
|
||||
mock_create_ldap_user.return_value = (emp, None)
|
||||
|
||||
response = api_client.post(reverse(route_name), test_user_data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
mock_create_ldap_user.assert_called_once()
|
||||
|
||||
@ -55,13 +55,14 @@ def verified_user(db):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
|
||||
def test_verify_email_without_ldap_activates_user_only_in_db(
|
||||
api_client, unverified_user, settings
|
||||
api_client, unverified_user, settings, route_name
|
||||
):
|
||||
"""V2: Верификация email без LDAP активирует пользователя только в БД"""
|
||||
settings.LDAP_ENABLED = False
|
||||
|
||||
url = reverse("api:v1:verify-email")
|
||||
url = reverse(route_name)
|
||||
data = {
|
||||
"email": unverified_user.email,
|
||||
"code": unverified_user.email_activation_code,
|
||||
@ -80,13 +81,14 @@ def test_verify_email_without_ldap_activates_user_only_in_db(
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
|
||||
def test_verify_email_with_wrong_code_returns_400_without_ldap(
|
||||
api_client, unverified_user, settings
|
||||
api_client, unverified_user, settings, route_name
|
||||
):
|
||||
"""V3: Неверный код возвращает 400"""
|
||||
settings.LDAP_ENABLED = False
|
||||
|
||||
url = reverse("api:v1:verify-email")
|
||||
url = reverse(route_name)
|
||||
data = {
|
||||
"email": unverified_user.email,
|
||||
"code": "999999", # Неправильный, но валидный формат (6 цифр)
|
||||
@ -106,13 +108,14 @@ def test_verify_email_with_wrong_code_returns_400_without_ldap(
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
|
||||
def test_verify_email_already_verified_returns_400_without_ldap(
|
||||
api_client, verified_user, settings
|
||||
api_client, verified_user, settings, route_name
|
||||
):
|
||||
"""V4: Повторная верификация уже верифицированного email возвращает 400"""
|
||||
settings.LDAP_ENABLED = False
|
||||
|
||||
url = reverse("api:v1:verify-email")
|
||||
url = reverse(route_name)
|
||||
data = {
|
||||
"email": verified_user.email,
|
||||
"code": "123456", # Валидный формат, но неправильный код
|
||||
@ -128,13 +131,14 @@ def test_verify_email_already_verified_returns_400_without_ldap(
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
|
||||
def test_verify_email_nonexistent_user_returns_404_without_ldap(
|
||||
api_client, settings
|
||||
api_client, settings, route_name
|
||||
):
|
||||
"""V5: Верификация несуществующего пользователя возвращает 404"""
|
||||
settings.LDAP_ENABLED = False
|
||||
|
||||
url = reverse("api:v1:verify-email")
|
||||
url = reverse(route_name)
|
||||
data = {
|
||||
"email": "nonexistent@example.com",
|
||||
"code": "123456",
|
||||
@ -146,13 +150,14 @@ def test_verify_email_nonexistent_user_returns_404_without_ldap(
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("route_name", ["api:verify-email", "api:v1:verify-email"])
|
||||
def test_verify_email_empty_code_returns_400_without_ldap(
|
||||
api_client, unverified_user, settings
|
||||
api_client, unverified_user, settings, route_name
|
||||
):
|
||||
"""V6: Пустой код возвращает 400"""
|
||||
settings.LDAP_ENABLED = False
|
||||
|
||||
url = reverse("api:v1:verify-email")
|
||||
url = reverse(route_name)
|
||||
data = {
|
||||
"email": unverified_user.email,
|
||||
"code": "",
|
||||
@ -162,4 +167,3 @@ def test_verify_email_empty_code_returns_400_without_ldap(
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
|
||||
60
backend/tests/realtime/test_jwt_session_middleware.py
Normal file
60
backend/tests/realtime/test_jwt_session_middleware.py
Normal file
@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
from api.auth.models import UserAuthSession
|
||||
from eusrr_backend.channels_jwt import JWTAuthMiddleware
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoked_session_becomes_anonymous_for_websocket():
|
||||
user = await sync_to_async(User.objects.create_user)(
|
||||
email="ws-auth@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79997770011",
|
||||
first_name="Ws",
|
||||
last_name="User",
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
session = await sync_to_async(UserAuthSession.objects.create)(
|
||||
user=user,
|
||||
refresh_token_hash="stub",
|
||||
)
|
||||
|
||||
token = AccessToken.for_user(user)
|
||||
token["session_id"] = str(session.session_id)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def app(scope, receive, send):
|
||||
captured["user"] = scope["user"]
|
||||
await send({"type": "websocket.accept"})
|
||||
await send({"type": "websocket.close"})
|
||||
|
||||
communicator = WebsocketCommunicator(
|
||||
JWTAuthMiddleware(app),
|
||||
f"/ws/?token={str(token)}",
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected is True
|
||||
assert captured["user"].is_authenticated is True
|
||||
await communicator.disconnect()
|
||||
|
||||
await sync_to_async(session.revoke)(reason="test")
|
||||
|
||||
captured.clear()
|
||||
communicator = WebsocketCommunicator(
|
||||
JWTAuthMiddleware(app),
|
||||
f"/ws/?token={str(token)}",
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
assert connected is True
|
||||
assert captured["user"].is_authenticated is False
|
||||
await communicator.disconnect()
|
||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
13
frontend/src/lib/api/auth.ts
Normal file
13
frontend/src/lib/api/auth.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { AuthSession, SessionBulkActionResult } from "@/types/api";
|
||||
|
||||
import type { RequestFn } from "./utils";
|
||||
|
||||
export function createAuthApi(request: RequestFn) {
|
||||
return {
|
||||
getAuthSessions: (): Promise<AuthSession[]> => request("/api/auth/sessions/"),
|
||||
deleteAuthSession: (sessionId: string): Promise<void> =>
|
||||
request(`/api/auth/sessions/${sessionId}/`, { method: "DELETE" }),
|
||||
logoutOtherSessions: (): Promise<SessionBulkActionResult> =>
|
||||
request("/api/auth/sessions/logout-others/", { method: "POST" }),
|
||||
};
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { ApiClientBase } from './client';
|
||||
import { createEmployeesApi } from './employees';
|
||||
import { createAuthApi } from './auth';
|
||||
import { createDocumentsApi } from './documents';
|
||||
import { createMessagesApi } from './messages';
|
||||
import { createNotificationsApi } from './notifications';
|
||||
@ -18,6 +19,7 @@ const req = base.request.bind(base);
|
||||
const tok = base.getToken.bind(base);
|
||||
|
||||
export const apiClient = Object.assign(base, {
|
||||
...createAuthApi(req),
|
||||
...createEmployeesApi(req),
|
||||
...createDocumentsApi(req),
|
||||
...createMessagesApi(req, tok),
|
||||
|
||||
@ -61,6 +61,20 @@ export interface User {
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
session_id: string;
|
||||
is_current: boolean;
|
||||
device_name?: string | null;
|
||||
ip_address?: string | null;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
revoked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionBulkActionResult {
|
||||
revoked: number;
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user