mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-28 20:41:19 +00:00
This update modifies permission classes in multiple viewsets to restrict access based on user roles. The `IsAdmin` permission is now enforced for several actions, including group management and permission listing, ensuring that only administrators can perform sensitive operations. Additionally, a new utility function, `resolve_safe_local_data_path`, is introduced to enhance security when accessing local file paths. The changes improve overall security and maintainability of the codebase.
372 lines
14 KiB
Python
372 lines
14 KiB
Python
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, network rules, or catchup access
|
|
# via this endpoint.
|
|
ADMIN_ONLY_PROPS = {"xc_password", "allowed_networks", "catchup_enabled"}
|
|
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 (Django auth.Group; unused by the React UI)
|
|
class GroupViewSet(viewsets.ModelViewSet):
|
|
"""CRUD for Django auth groups and their permissions.
|
|
|
|
Dispatcharr authorization uses ``user_level``, not these groups. The
|
|
endpoint is kept for compatibility but restricted to admins so
|
|
non-admins cannot invent auth groups or attach Django permissions.
|
|
"""
|
|
|
|
queryset = Group.objects.all()
|
|
serializer_class = GroupSerializer
|
|
permission_classes = [IsAdmin]
|
|
|
|
@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([IsAdmin])
|
|
def list_permissions(request):
|
|
"""Returns a list of all available Django permissions (admin only)."""
|
|
permissions = Permission.objects.all()
|
|
serializer = PermissionSerializer(permissions, many=True)
|
|
return Response(serializer.data)
|