"""Authentication endpoints: register, login, refresh, logout, me.""" from __future__ import annotations import contextlib import logging from drf_spectacular.utils import extend_schema from rest_framework import generics, status from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.views import TokenObtainPairView as BaseTokenObtainPairView from rest_framework_simplejwt.views import TokenRefreshView as BaseTokenRefreshView from apps.accounts.models import User from apps.accounts.serializers import LogoutSerializer, RegisterSerializer, UserSerializer from apps.core.throttling import LoginRateThrottle audit = logging.getLogger("audit.auth") @extend_schema(tags=["auth"]) class RegisterView(generics.CreateAPIView): """Self-service sign-up. New accounts always get the ``viewer`` role.""" serializer_class = RegisterSerializer permission_classes = [AllowAny] throttle_classes = [LoginRateThrottle] queryset = User.objects.all() def create(self, request: Request, *args, **kwargs) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() audit.info("user_registered", extra={"event": "user_registered", "user_id": user.id}) return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED) @extend_schema(tags=["auth"]) class LoginView(BaseTokenObtainPairView): """Exchange credentials for an access/refresh token pair.""" permission_classes = [AllowAny] throttle_classes = [LoginRateThrottle] @extend_schema(tags=["auth"]) class RefreshView(BaseTokenRefreshView): """Rotate a refresh token; the presented token is blacklisted afterwards.""" permission_classes = [AllowAny] throttle_classes = [LoginRateThrottle] @extend_schema(tags=["auth"], request=LogoutSerializer, responses={205: None}) class LogoutView(APIView): """Blacklist a refresh token so it can no longer be rotated.""" permission_classes = [IsAuthenticated] serializer_class = LogoutSerializer def post(self, request: Request) -> Response: serializer = LogoutSerializer(data=request.data) serializer.is_valid(raise_exception=True) # An already-invalid token is not an error worth surfacing: the # caller's intent (be logged out) is satisfied either way. with contextlib.suppress(TokenError): RefreshToken(serializer.validated_data["refresh"]).blacklist() audit.info("logout", extra={"event": "logout", "user_id": request.user.id}) return Response(status=status.HTTP_205_RESET_CONTENT) @extend_schema(tags=["auth"]) class MeView(generics.RetrieveAPIView): """Identity and effective privileges of the calling user.""" serializer_class = UserSerializer permission_classes = [IsAuthenticated] def get_object(self) -> User: return self.request.user