from django.contrib.auth import authenticate, login, logout import logging from django.contrib.auth.models import Group, Permission from django.http import JsonResponse, HttpResponse from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view, permission_classes, action from rest_framework.response import Response from rest_framework import viewsets, status, serializers from rest_framework.throttling import AnonRateThrottle from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer from drf_spectacular.types import OpenApiTypes import json import secrets from .permissions import IsAdmin, Authenticated from dispatcharr.utils import network_access_allowed from .models import User from .serializers import UserSerializer, GroupSerializer, PermissionSerializer from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView logger = logging.getLogger(__name__) class LoginRateThrottle(AnonRateThrottle): scope = "login" class TokenObtainPairView(TokenObtainPairView): throttle_classes = [LoginRateThrottle] def post(self, request, *args, **kwargs): if not network_access_allowed(request, "UI"): # Log blocked login attempt due to network restrictions from core.utils import log_system_event username = request.data.get("username", 'unknown') client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') logger.info(f"Login blocked by network policy: user={username} ip={client_ip} ua={user_agent}") log_system_event( event_type='login_failed', user=username, client_ip=client_ip, user_agent=user_agent, reason='Network access denied', ) return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) # Get the response from the parent class first username = request.data.get("username") # Log login attempt from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') try: logger.debug(f"Attempting JWT login for user={username}") response = super().post(request, *args, **kwargs) # If login was successful, update last_login and log success if response.status_code == 200: if username: from django.utils import timezone try: user = User.objects.get(username=username) user.last_login = timezone.now() user.save(update_fields=['last_login']) # Log successful login log_system_event( event_type='login_success', user=username, client_ip=client_ip, user_agent=user_agent, ) logger.info(f"Login success: user={username} ip={client_ip}") except User.DoesNotExist: pass # User doesn't exist, but login somehow succeeded else: # Log failed login attempt log_system_event( event_type='login_failed', user=username or 'unknown', client_ip=client_ip, user_agent=user_agent, reason='Invalid credentials', ) logger.info(f"Login failed: user={username} ip={client_ip}") return response except Exception as e: # If parent class raises an exception (e.g., validation error), log failed attempt log_system_event( event_type='login_failed', user=username or 'unknown', client_ip=client_ip, user_agent=user_agent, reason=f'Authentication error: {str(e)[:100]}', ) logger.error(f"Login error for user={username}: {e}") raise # Re-raise the exception to maintain normal error flow class TokenRefreshView(TokenRefreshView): def post(self, request, *args, **kwargs): # Custom logic here if not network_access_allowed(request, "UI"): # Log blocked token refresh attempt due to network restrictions from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') logger.info(f"Token refresh blocked by network policy: ip={client_ip} ua={user_agent}") log_system_event( event_type='login_failed', user='token_refresh', client_ip=client_ip, user_agent=user_agent, reason='Network access denied (token refresh)', ) return Response({"error": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN) return super().post(request, *args, **kwargs) @csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists. def initialize_superuser(request): # If an admin-level user already exists, the system is configured if User.objects.filter(user_level__gte=10).exists(): return JsonResponse({"superuser_exists": True}) if request.method == "POST": try: data = json.loads(request.body) username = data.get("username") password = data.get("password") email = data.get("email", "") if not username or not password: return JsonResponse( {"error": "Username and password are required."}, status=400 ) # Create the superuser User.objects.create_superuser( username=username, password=password, email=email, user_level=10 ) return JsonResponse({"superuser_exists": True}) except Exception as e: return JsonResponse({"error": str(e)}, status=500) # For GET requests, indicate no superuser exists return JsonResponse({"superuser_exists": False}) # 🔹 1) Authentication APIs class AuthViewSet(viewsets.ViewSet): """Handles user login and logout""" def get_permissions(self): """ Login doesn't require auth, but logout does """ if self.action == 'logout': return [Authenticated()] return [] @extend_schema( description="Alias for POST /api/accounts/token/ — returns JWT access and refresh tokens.", request=inline_serializer( name="LoginRequest", fields={ "username": serializers.CharField(), "password": serializers.CharField(), }, ), ) def login(self, request): """Delegates to TokenObtainPairView (JWT login). Throttling, logging, and network access checks are handled there.""" view = TokenObtainPairView.as_view() return view(request._request) @extend_schema( description="Log out the current user", ) def logout(self, request): """Logs out the authenticated user""" # Log logout event before actually logging out from core.utils import log_system_event username = request.user.username if request.user and request.user.is_authenticated else 'unknown' client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') log_system_event( event_type='logout', user=username, client_ip=client_ip, user_agent=user_agent, ) logger.info(f"Logout: user={username} ip={client_ip}") logout(request) return Response({"message": "Logout successful"}) # 🔹 2) User Management APIs class UserViewSet(viewsets.ModelViewSet): """Handles CRUD operations for Users""" queryset = User.objects.all().prefetch_related('channel_profiles') serializer_class = UserSerializer def get_permissions(self): if self.action == "me": return [Authenticated()] return [IsAdmin()] @extend_schema( description="Retrieve a list of users", responses={200: UserSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @extend_schema(description="Retrieve a specific user by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) @extend_schema(description="Create a new user") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) @extend_schema(description="Update a user") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) @extend_schema(description="Delete a user") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) @extend_schema( description="Get or update active user information. PATCH updates custom_properties with merge semantics.", methods=["GET", "PATCH"], ) @action(detail=False, methods=["get", "patch"], url_path="me") def me(self, request): user = request.user if request.method == "PATCH": ALLOWED_FIELDS = {"custom_properties", "first_name", "last_name", "email", "password"} disallowed = set(request.data.keys()) - ALLOWED_FIELDS for key in disallowed: request.data.pop(key, None) # Strip admin-managed keys from custom_properties so users cannot # set their own XC credentials or network rules via this endpoint. ADMIN_ONLY_PROPS = {"xc_password", "allowed_networks"} cp = request.data.get("custom_properties") if isinstance(cp, dict): for key in ADMIN_ONLY_PROPS: cp.pop(key, None) serializer = UserSerializer(user, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data) serializer = UserSerializer(user) return Response(serializer.data) # 🔹 3) Group Management APIs class GroupViewSet(viewsets.ModelViewSet): """Handles CRUD operations for Groups""" queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [Authenticated] @extend_schema( description="Retrieve a list of groups", responses={200: GroupSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @extend_schema(description="Retrieve a specific group by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) @extend_schema(description="Create a new group") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) @extend_schema(description="Update a group") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) @extend_schema(description="Delete a group") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) # API Key management class APIKeyViewSet(viewsets.ViewSet): permission_classes = [Authenticated] def list(self, request): user = request.user return Response({"key": user.api_key}) @action(detail=False, methods=["post"], url_path="generate") def generate(self, request): target_user = request.user user_id = request.data.get("user_id") if user_id: from .permissions import IsAdmin if not IsAdmin().has_permission(request, self): return Response({"detail": "Not allowed to create keys for other users."}, status=status.HTTP_403_FORBIDDEN) try: target_user = User.objects.get(id=int(user_id)) except Exception: return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) raw = secrets.token_urlsafe(40) target_user.api_key = raw target_user.save(update_fields=["api_key"]) user_data = UserSerializer(target_user).data return Response({"key": raw, "user": user_data}, status=status.HTTP_201_CREATED) @action(detail=False, methods=["post"], url_path="revoke") def revoke(self, request): target_user = request.user user_id = request.data.get("user_id") if user_id: from .permissions import IsAdmin if not IsAdmin().has_permission(request, self): return Response({"detail": "Not allowed to revoke keys for other users."}, status=status.HTTP_403_FORBIDDEN) try: target_user = User.objects.get(id=int(user_id)) except Exception: return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) target_user.api_key = None target_user.save(update_fields=["api_key"]) return Response({"success": True}) # 🔹 4) Permissions List API @extend_schema( description="Retrieve a list of all permissions", responses={200: PermissionSerializer(many=True)}, ) @api_view(["GET"]) @permission_classes([Authenticated]) def list_permissions(request): """Returns a list of all available permissions""" permissions = Permission.objects.all() serializer = PermissionSerializer(permissions, many=True) return Response(serializer.data)