Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
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 via this endpoint.
|
||||
ADMIN_ONLY_PROPS = {"xc_password"}
|
||||
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)
|
||||
Reference in New Issue
Block a user