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,16 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin, GroupAdmin
|
||||
from django.contrib.auth.models import Group
|
||||
from .models import User
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password', 'avatar_config', 'groups')}),
|
||||
('Permissions', {'fields': ('is_staff', 'is_superuser', 'user_permissions')}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
|
||||
# Unregister default Group admin and re-register it.
|
||||
admin.site.unregister(Group)
|
||||
admin.site.register(Group, GroupAdmin)
|
||||
@@ -0,0 +1,42 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import (
|
||||
AuthViewSet,
|
||||
UserViewSet,
|
||||
GroupViewSet,
|
||||
APIKeyViewSet,
|
||||
TokenObtainPairView,
|
||||
TokenRefreshView,
|
||||
list_permissions,
|
||||
initialize_superuser,
|
||||
)
|
||||
from rest_framework_simplejwt import views as jwt_views
|
||||
|
||||
app_name = "accounts"
|
||||
|
||||
# 🔹 Register ViewSets with a Router
|
||||
router = DefaultRouter()
|
||||
router.register(r"users", UserViewSet, basename="user")
|
||||
router.register(r"groups", GroupViewSet, basename="group")
|
||||
router.register(r"api-keys", APIKeyViewSet, basename="api-key")
|
||||
|
||||
# 🔹 Custom Authentication Endpoints
|
||||
auth_view = AuthViewSet.as_view({"post": "login"})
|
||||
|
||||
logout_view = AuthViewSet.as_view({"post": "logout"})
|
||||
|
||||
# 🔹 Define API URL patterns
|
||||
urlpatterns = [
|
||||
# Authentication
|
||||
path("auth/login/", auth_view, name="user-login"),
|
||||
path("auth/logout/", logout_view, name="user-logout"),
|
||||
# Superuser API
|
||||
path("initialize-superuser/", initialize_superuser, name="initialize_superuser"),
|
||||
# Permissions API
|
||||
path("permissions/", list_permissions, name="list-permissions"),
|
||||
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||
]
|
||||
|
||||
# 🔹 Include ViewSet routes
|
||||
urlpatterns += router.urls
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
verbose_name = "Accounts & Authentication"
|
||||
@@ -0,0 +1,86 @@
|
||||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
from django.conf import settings
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from .models import User
|
||||
|
||||
|
||||
class JWTAuthenticationScheme(OpenApiAuthenticationExtension):
|
||||
target_class = "rest_framework_simplejwt.authentication.JWTAuthentication"
|
||||
name = "jwtAuth"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
return {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": (
|
||||
"JWT Bearer authentication.\n\n"
|
||||
"Obtain a token pair via `POST /api/accounts/token/` using your username and password, "
|
||||
"then paste the **access token** here — Swagger adds the `Bearer ` prefix automatically.\n\n"
|
||||
"Access tokens expire after 30 minutes. Refresh using `POST /api/accounts/token/refresh/`."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension):
|
||||
target_class = "apps.accounts.authentication.ApiKeyAuthentication"
|
||||
name = "ApiKeyAuth"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
return {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": (
|
||||
"API key authentication.\n\n"
|
||||
"Pass your personal API key in the `X-API-Key` request header. "
|
||||
"Keys can be generated via `POST /api/accounts/api-keys/generate/` "
|
||||
"and revoked via `POST /api/accounts/api-keys/revoke/`."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ApiKeyAuthentication(authentication.BaseAuthentication):
|
||||
"""
|
||||
Accepts header `Authorization: ApiKey <key>` or `X-API-Key: <key>`.
|
||||
"""
|
||||
|
||||
keyword = "ApiKey"
|
||||
|
||||
def authenticate(self, request):
|
||||
# Check X-API-Key header first
|
||||
raw_key = request.META.get("HTTP_X_API_KEY")
|
||||
|
||||
if not raw_key:
|
||||
auth = authentication.get_authorization_header(request).split()
|
||||
if not auth:
|
||||
return None
|
||||
|
||||
if len(auth) != 2:
|
||||
return None
|
||||
|
||||
scheme = auth[0].decode().lower()
|
||||
if scheme != self.keyword.lower():
|
||||
return None
|
||||
|
||||
raw_key = auth[1].decode()
|
||||
|
||||
if not raw_key:
|
||||
return None
|
||||
|
||||
if not raw_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = User.objects.get(api_key=raw_key)
|
||||
except User.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed("Invalid API key")
|
||||
|
||||
if not user.is_active:
|
||||
raise exceptions.AuthenticationFailed("User inactive")
|
||||
|
||||
return (user, None)
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
@@ -0,0 +1,59 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import Group as AuthGroup
|
||||
from apps.channels.models import ChannelGroup
|
||||
from .models import User
|
||||
|
||||
from .models import User
|
||||
|
||||
|
||||
class UserRegistrationForm(UserCreationForm):
|
||||
groups = forms.ModelMultipleChoiceField(
|
||||
queryset=AuthGroup.objects.all(),
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'groups', 'password1', 'password2', ]
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
if commit:
|
||||
user.save()
|
||||
self.save_m2m() # Save the many-to-many field data
|
||||
return user
|
||||
|
||||
|
||||
|
||||
class GroupForm(forms.ModelForm):
|
||||
permissions = forms.ModelMultipleChoiceField(
|
||||
queryset=Permission.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = AuthGroup
|
||||
fields = ['name', 'permissions']
|
||||
|
||||
|
||||
class UserEditForm(forms.ModelForm):
|
||||
auth_groups = forms.ModelMultipleChoiceField(
|
||||
queryset=AuthGroup.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label="Auth Groups"
|
||||
)
|
||||
channel_groups = forms.ModelMultipleChoiceField(
|
||||
queryset=ChannelGroup.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label="Channel Groups"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'email', 'auth_groups', 'channel_groups']
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-05 22:07
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('dispatcharr_channels', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('avatar_config', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('channel_groups', models.ManyToManyField(blank=True, related_name='users', to='dispatcharr_channels.channelgroup')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.1.6 on 2025-05-18 15:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_user_level_to_10(apps, schema_editor):
|
||||
User = apps.get_model("accounts", "User")
|
||||
User.objects.update(user_level=10)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
("dispatcharr_channels", "0021_channel_user_level"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="channel_groups",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="channel_profiles",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="users",
|
||||
to="dispatcharr_channels.channelprofile",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="user_level",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="custom_properties",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.RunPython(set_user_level_to_10),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-02 14:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_remove_user_channel_groups_user_channel_profiles_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='custom_properties',
|
||||
field=models.JSONField(blank=True, default=dict, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-21 18:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0003_alter_user_custom_properties'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='api_key',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=200, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-26 19:24
|
||||
|
||||
import apps.accounts.models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0004_user_api_key'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='user',
|
||||
managers=[
|
||||
('objects', apps.accounts.models.CustomUserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-19 13:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0005_alter_user_managers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='stream_limit',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
# apps/accounts/models.py
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser, Permission, UserManager
|
||||
|
||||
|
||||
class CustomUserManager(UserManager):
|
||||
def create_superuser(self, username, email=None, password=None, **extra_fields):
|
||||
extra_fields.setdefault('user_level', 10)
|
||||
return super().create_superuser(username, email, password, **extra_fields)
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
objects = CustomUserManager()
|
||||
"""
|
||||
Custom user model for Dispatcharr.
|
||||
Inherits from Django's AbstractUser to add additional fields if needed.
|
||||
"""
|
||||
|
||||
class UserLevel(models.IntegerChoices):
|
||||
STREAMER = 0, "Streamer"
|
||||
STANDARD = 1, "Standard User"
|
||||
ADMIN = 10, "Admin"
|
||||
|
||||
avatar_config = models.JSONField(default=dict, blank=True, null=True)
|
||||
channel_profiles = models.ManyToManyField(
|
||||
"dispatcharr_channels.ChannelProfile",
|
||||
blank=True,
|
||||
related_name="users",
|
||||
)
|
||||
user_level = models.IntegerField(default=UserLevel.STREAMER)
|
||||
custom_properties = models.JSONField(default=dict, blank=True, null=True)
|
||||
api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True)
|
||||
stream_limit = models.IntegerField(default=0)
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
def get_groups(self):
|
||||
"""
|
||||
Returns the groups (roles) the user belongs to.
|
||||
"""
|
||||
return self.groups.all()
|
||||
|
||||
def get_permissions(self):
|
||||
"""
|
||||
Returns the permissions assigned to the user and their groups.
|
||||
"""
|
||||
return self.user_permissions.all() | Permission.objects.filter(group__user=self)
|
||||
@@ -0,0 +1,56 @@
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from .models import User
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
|
||||
|
||||
class Authenticated(IsAuthenticated):
|
||||
def has_permission(self, request, view):
|
||||
is_authenticated = super().has_permission(request, view)
|
||||
network_allowed = network_access_allowed(request, "UI")
|
||||
|
||||
return is_authenticated and network_allowed
|
||||
|
||||
|
||||
class IsStandardUser(Authenticated):
|
||||
def has_permission(self, request, view):
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
return request.user and request.user.user_level >= User.UserLevel.STANDARD
|
||||
|
||||
|
||||
class IsAdmin(Authenticated):
|
||||
def has_permission(self, request, view):
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
return request.user.user_level >= 10
|
||||
|
||||
|
||||
class IsOwnerOfObject(Authenticated):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
is_admin = IsAdmin().has_permission(request, view)
|
||||
is_owner = request.user in obj.users.all()
|
||||
|
||||
return is_admin or is_owner
|
||||
|
||||
|
||||
permission_classes_by_action = {
|
||||
"list": [IsStandardUser],
|
||||
"create": [IsAdmin],
|
||||
"retrieve": [IsStandardUser],
|
||||
"update": [IsAdmin],
|
||||
"partial_update": [IsAdmin],
|
||||
"destroy": [IsAdmin],
|
||||
}
|
||||
|
||||
permission_classes_by_method = {
|
||||
"GET": [IsStandardUser],
|
||||
"POST": [IsAdmin],
|
||||
"PATCH": [IsAdmin],
|
||||
"PUT": [IsAdmin],
|
||||
"DELETE": [IsAdmin],
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import json
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from .models import User
|
||||
from apps.channels.models import ChannelProfile
|
||||
|
||||
|
||||
# Valid navigation item IDs for validation
|
||||
VALID_NAV_ITEM_IDS = {
|
||||
'channels', 'vods', 'sources', 'guide', 'dvr',
|
||||
'stats', 'plugins', 'integrations', 'system', 'settings'
|
||||
}
|
||||
MAX_CUSTOM_PROPS_SIZE = 10240 # 10KB limit
|
||||
|
||||
|
||||
def validate_nav_array(value, field_name):
|
||||
"""Validate that a value is an array of valid nav item ID strings."""
|
||||
if not isinstance(value, list):
|
||||
raise serializers.ValidationError(f"{field_name} must be an array")
|
||||
if len(value) > 50:
|
||||
raise serializers.ValidationError(f"{field_name} exceeds maximum length of 50 items")
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
raise serializers.ValidationError(f"{field_name} items must be strings")
|
||||
if item not in VALID_NAV_ITEM_IDS:
|
||||
raise serializers.ValidationError(f"'{item}' is not a valid navigation item ID")
|
||||
|
||||
|
||||
# 🔹 Fix for Permission serialization
|
||||
class PermissionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Permission
|
||||
fields = ["id", "name", "codename"]
|
||||
|
||||
|
||||
# 🔹 Fix for Group serialization
|
||||
class GroupSerializer(serializers.ModelSerializer):
|
||||
permissions = serializers.PrimaryKeyRelatedField(
|
||||
many=True, queryset=Permission.objects.all()
|
||||
) # ✅ Fixes ManyToManyField `_meta` error
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = ["id", "name", "permissions"]
|
||||
|
||||
|
||||
# 🔹 Fix for User serialization
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, required=False)
|
||||
channel_profiles = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChannelProfile.objects.all(), many=True, required=False
|
||||
)
|
||||
api_key = serializers.CharField(read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"id",
|
||||
"username",
|
||||
"api_key",
|
||||
"email",
|
||||
"user_level",
|
||||
"password",
|
||||
"channel_profiles",
|
||||
"custom_properties",
|
||||
"avatar_config",
|
||||
"stream_limit",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"last_login",
|
||||
"date_joined",
|
||||
"first_name",
|
||||
"last_name",
|
||||
]
|
||||
|
||||
def validate_custom_properties(self, value):
|
||||
"""Validate custom_properties structure and size."""
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("custom_properties must be a dictionary")
|
||||
|
||||
# Size limit check
|
||||
try:
|
||||
if len(json.dumps(value)) > MAX_CUSTOM_PROPS_SIZE:
|
||||
raise serializers.ValidationError(
|
||||
f"custom_properties exceeds maximum size of {MAX_CUSTOM_PROPS_SIZE} bytes"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
raise serializers.ValidationError("custom_properties contains non-serializable data")
|
||||
|
||||
# Validate navOrder if present
|
||||
if 'navOrder' in value:
|
||||
validate_nav_array(value['navOrder'], 'navOrder')
|
||||
|
||||
# Validate hiddenNav if present
|
||||
if 'hiddenNav' in value:
|
||||
validate_nav_array(value['hiddenNav'], 'hiddenNav')
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
channel_profiles = validated_data.pop("channel_profiles", [])
|
||||
|
||||
user = User(**validated_data)
|
||||
user.set_password(validated_data["password"])
|
||||
user.save()
|
||||
|
||||
user.channel_profiles.set(channel_profiles)
|
||||
|
||||
return user
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
password = validated_data.pop("password", None)
|
||||
channel_profiles = validated_data.pop("channel_profiles", None)
|
||||
|
||||
# Merge custom_properties instead of replacing (prevents data loss)
|
||||
# Strip null values — sending null for a key omits it rather than overwriting with null
|
||||
custom_properties = validated_data.pop("custom_properties", None)
|
||||
if custom_properties is not None:
|
||||
existing = instance.custom_properties or {}
|
||||
cleaned = {k: v for k, v in custom_properties.items() if v is not None}
|
||||
merged = {**existing, **cleaned}
|
||||
# Scrub stale nav IDs so the DB self-heals on next save
|
||||
for nav_field in ('navOrder', 'hiddenNav'):
|
||||
if nav_field in merged and isinstance(merged[nav_field], list):
|
||||
merged[nav_field] = [
|
||||
item for item in merged[nav_field]
|
||||
if item in VALID_NAV_ITEM_IDS
|
||||
]
|
||||
instance.custom_properties = merged
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
if password:
|
||||
instance.set_password(password)
|
||||
|
||||
instance.save()
|
||||
|
||||
if channel_profiles is not None:
|
||||
instance.channel_profiles.set(channel_profiles)
|
||||
|
||||
return instance
|
||||
@@ -0,0 +1,15 @@
|
||||
# apps/accounts/signals.py
|
||||
# Example: automatically create something on user creation
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from .models import User
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def handle_new_user(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
# e.g. initialize default avatar config
|
||||
if not instance.avatar_config:
|
||||
instance.avatar_config = {"style": "circle"}
|
||||
instance.save()
|
||||
@@ -0,0 +1,72 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class InitializeSuperuserTests(TestCase):
|
||||
"""Tests for the initialize_superuser endpoint"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = "/api/accounts/initialize-superuser/"
|
||||
|
||||
def test_returns_true_when_superuser_exists(self):
|
||||
"""Superuser with is_superuser=True should be detected"""
|
||||
User.objects.create_superuser(
|
||||
username="admin", password="testpass123", user_level=10
|
||||
)
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["superuser_exists"])
|
||||
|
||||
def test_returns_true_when_admin_level_user_exists(self):
|
||||
"""User with user_level=10 but is_superuser=False should be detected"""
|
||||
user = User.objects.create_user(username="admin", password="testpass123")
|
||||
user.user_level = 10
|
||||
user.is_superuser = False
|
||||
user.save()
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["superuser_exists"])
|
||||
|
||||
def test_returns_false_when_no_admin_exists(self):
|
||||
"""No admin or superuser should return false"""
|
||||
# Create a non-admin user
|
||||
User.objects.create_user(username="regular", password="testpass123")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.json()["superuser_exists"])
|
||||
|
||||
def test_returns_false_when_no_users_exist(self):
|
||||
"""Empty database should return false"""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.json()["superuser_exists"])
|
||||
|
||||
def test_create_superuser_when_none_exists(self):
|
||||
"""POST should create superuser when none exists"""
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"username": "newadmin", "password": "testpass123", "email": "admin@test.com"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["superuser_exists"])
|
||||
self.assertTrue(User.objects.filter(username="newadmin", user_level=10).exists())
|
||||
|
||||
def test_cannot_create_superuser_when_admin_exists(self):
|
||||
"""POST should fail when an admin-level user already exists"""
|
||||
user = User.objects.create_user(username="existing", password="testpass123")
|
||||
user.user_level = 10
|
||||
user.save()
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"username": "newadmin", "password": "testpass123"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["superuser_exists"])
|
||||
# Should NOT have created a new user
|
||||
self.assertFalse(User.objects.filter(username="newadmin").exists())
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
app_name = 'accounts'
|
||||
|
||||
urlpatterns = [
|
||||
# Login view using Django's built-in authentication
|
||||
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
|
||||
# Logout view using Django's built-in authentication
|
||||
path('logout/', auth_views.LogoutView.as_view(next_page='accounts:login'), name='logout'),
|
||||
# Onetime use superuser account creation
|
||||
]
|
||||
Reference in New Issue
Block a user