From cb1953baf26ef2c254031041bc519595713120f8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 21 Nov 2025 10:50:48 -0600 Subject: [PATCH] Enhancement: Implement comprehensive logging for user authentication events and network access restrictions --- apps/accounts/api_views.py | 128 ++++++++++++++++-- apps/output/views.py | 66 +++++++++ .../0018_alter_systemevent_event_type.py | 18 +++ core/models.py | 5 + frontend/src/api.js | 2 +- frontend/src/components/Sidebar.jsx | 4 +- frontend/src/components/SystemEvents.jsx | 21 +++ frontend/src/store/auth.jsx | 12 +- 8 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 core/migrations/0018_alter_systemevent_event_type.py diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index bf87c2ab..41e2f077 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -20,30 +20,88 @@ class TokenObtainPairView(TokenObtainPairView): def post(self, request, *args, **kwargs): # Custom logic here 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') + 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 - response = super().post(request, *args, **kwargs) + username = request.data.get("username") - # If login was successful, update last_login - if response.status_code == 200: - username = request.data.get("username") - 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']) - except User.DoesNotExist: - pass # User doesn't exist, but login somehow succeeded + # 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') - return response + try: + 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, + ) + 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', + ) + + 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]}', + ) + 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') + 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) @@ -80,6 +138,15 @@ def initialize_superuser(request): 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': + from rest_framework.permissions import IsAuthenticated + return [IsAuthenticated()] + return [] + @swagger_auto_schema( operation_description="Authenticate and log in a user", request_body=openapi.Schema( @@ -100,6 +167,11 @@ class AuthViewSet(viewsets.ViewSet): password = request.data.get("password") user = authenticate(request, username=username, password=password) + # Get client info for logging + from core.utils import log_system_event + client_ip = request.META.get('REMOTE_ADDR', 'unknown') + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + if user: login(request, user) # Update last_login timestamp @@ -107,6 +179,14 @@ class AuthViewSet(viewsets.ViewSet): 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, + ) + return Response( { "message": "Login successful", @@ -118,6 +198,15 @@ class AuthViewSet(viewsets.ViewSet): }, } ) + + # 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', + ) return Response({"error": "Invalid credentials"}, status=400) @swagger_auto_schema( @@ -126,6 +215,19 @@ class AuthViewSet(viewsets.ViewSet): ) 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, + ) + logout(request) return Response({"message": "Logout successful"}) diff --git a/apps/output/views.py b/apps/output/views.py index faab1a24..edf603d3 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -53,6 +53,17 @@ def get_client_identifier(request): def m3u_endpoint(request, profile_name=None, user=None): logger.debug("m3u_endpoint called: method=%s, profile=%s", request.method, profile_name) if not network_access_allowed(request, "M3U_EPG"): + # Log blocked M3U download + from core.utils import log_system_event + client_ip = request.META.get('REMOTE_ADDR', 'unknown') + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + log_system_event( + event_type='m3u_blocked', + profile=profile_name or 'all', + reason='Network access denied', + client_ip=client_ip, + user_agent=user_agent, + ) return JsonResponse({"error": "Forbidden"}, status=403) # Handle HEAD requests efficiently without generating content @@ -67,6 +78,17 @@ def m3u_endpoint(request, profile_name=None, user=None): def epg_endpoint(request, profile_name=None, user=None): logger.debug("epg_endpoint called: method=%s, profile=%s", request.method, profile_name) if not network_access_allowed(request, "M3U_EPG"): + # Log blocked EPG download + from core.utils import log_system_event + client_ip = request.META.get('REMOTE_ADDR', 'unknown') + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + log_system_event( + event_type='epg_blocked', + profile=profile_name or 'all', + reason='Network access denied', + client_ip=client_ip, + user_agent=user_agent, + ) return JsonResponse({"error": "Forbidden"}, status=403) # Handle HEAD requests efficiently without generating content @@ -1958,12 +1980,34 @@ def xc_panel_api(request): def xc_get(request): if not network_access_allowed(request, 'XC_API'): + # Log blocked M3U download + from core.utils import log_system_event + client_ip = request.META.get('REMOTE_ADDR', 'unknown') + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + log_system_event( + event_type='m3u_blocked', + user=request.GET.get('username', 'unknown'), + reason='Network access denied (XC API)', + client_ip=client_ip, + user_agent=user_agent, + ) return JsonResponse({'error': 'Forbidden'}, status=403) action = request.GET.get("action") user = xc_get_user(request) if user is None: + # Log blocked M3U download due to invalid credentials + from core.utils import log_system_event + client_ip = request.META.get('REMOTE_ADDR', 'unknown') + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + log_system_event( + event_type='m3u_blocked', + user=request.GET.get('username', 'unknown'), + reason='Invalid XC credentials', + client_ip=client_ip, + user_agent=user_agent, + ) return JsonResponse({'error': 'Unauthorized'}, status=401) return generate_m3u(request, None, user) @@ -1971,11 +2015,33 @@ def xc_get(request): def xc_xmltv(request): if not network_access_allowed(request, 'XC_API'): + # Log blocked EPG download + from core.utils import log_system_event + client_ip = request.META.get('REMOTE_ADDR', 'unknown') + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + log_system_event( + event_type='epg_blocked', + user=request.GET.get('username', 'unknown'), + reason='Network access denied (XC API)', + client_ip=client_ip, + user_agent=user_agent, + ) return JsonResponse({'error': 'Forbidden'}, status=403) user = xc_get_user(request) if user is None: + # Log blocked EPG download due to invalid credentials + from core.utils import log_system_event + client_ip = request.META.get('REMOTE_ADDR', 'unknown') + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + log_system_event( + event_type='epg_blocked', + user=request.GET.get('username', 'unknown'), + reason='Invalid XC credentials', + client_ip=client_ip, + user_agent=user_agent, + ) return JsonResponse({'error': 'Unauthorized'}, status=401) return generate_epg(request, None, user) diff --git a/core/migrations/0018_alter_systemevent_event_type.py b/core/migrations/0018_alter_systemevent_event_type.py new file mode 100644 index 00000000..3fe4eecd --- /dev/null +++ b/core/migrations/0018_alter_systemevent_event_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-11-21 15:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_systemevent'), + ] + + operations = [ + migrations.AlterField( + model_name='systemevent', + name='event_type', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_buffering', 'Channel Buffering'), ('channel_failover', 'Channel Failover'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('stream_switch', 'Stream Switched'), ('m3u_refresh', 'M3U Refreshed'), ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded'), ('login_success', 'Login Successful'), ('login_failed', 'Login Failed'), ('logout', 'User Logged Out'), ('m3u_blocked', 'M3U Download Blocked'), ('epg_blocked', 'EPG Download Blocked')], db_index=True, max_length=50), + ), + ] diff --git a/core/models.py b/core/models.py index 2a5eb1f3..b9166f66 100644 --- a/core/models.py +++ b/core/models.py @@ -398,6 +398,11 @@ class SystemEvent(models.Model): ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded'), + ('login_success', 'Login Successful'), + ('login_failed', 'Login Failed'), + ('logout', 'User Logged Out'), + ('m3u_blocked', 'M3U Download Blocked'), + ('epg_blocked', 'EPG Download Blocked'), ] event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True) diff --git a/frontend/src/api.js b/frontend/src/api.js index 470373f1..3304e8ad 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -170,7 +170,7 @@ export default class API { static async logout() { return await request(`${host}/api/accounts/auth/logout/`, { - auth: false, + auth: true, // Send JWT token so backend can identify the user method: 'POST', }); } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 143d01ab..d8c3fae8 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -188,8 +188,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { } }; - const onLogout = () => { - logout(); + const onLogout = async () => { + await logout(); window.location.reload(); }; diff --git a/frontend/src/components/SystemEvents.jsx b/frontend/src/components/SystemEvents.jsx index 4047801c..c4945fa2 100644 --- a/frontend/src/components/SystemEvents.jsx +++ b/frontend/src/components/SystemEvents.jsx @@ -19,11 +19,16 @@ import { Gauge, HardDriveDownload, List, + LogIn, + LogOut, RefreshCw, + Shield, + ShieldAlert, SquareX, Timer, Users, Video, + XCircle, } from 'lucide-react'; import dayjs from 'dayjs'; import API from '../api'; @@ -108,6 +113,16 @@ const SystemEvents = () => { return ; case 'epg_download': return ; + case 'login_success': + return ; + case 'login_failed': + return ; + case 'logout': + return ; + case 'm3u_blocked': + return ; + case 'epg_blocked': + return ; default: return ; } @@ -118,12 +133,14 @@ const SystemEvents = () => { case 'channel_start': case 'client_connect': case 'recording_start': + case 'login_success': return 'green'; case 'channel_reconnect': return 'yellow'; case 'channel_stop': case 'client_disconnect': case 'recording_end': + case 'logout': return 'gray'; case 'channel_buffering': return 'yellow'; @@ -138,6 +155,10 @@ const SystemEvents = () => { case 'm3u_download': case 'epg_download': return 'teal'; + case 'login_failed': + case 'm3u_blocked': + case 'epg_blocked': + return 'red'; default: return 'gray'; } diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index fd2c52b8..b1d60a1a 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -134,13 +134,21 @@ const useAuthStore = create((set, get) => ({ return false; // Add explicit return for when data.access is not available } catch (error) { console.error('Token refresh failed:', error); - get().logout(); + await get().logout(); return false; // Add explicit return after error } }, // Action to logout - logout: () => { + logout: async () => { + // Call backend logout endpoint to log the event + try { + await API.logout(); + } catch (error) { + // Continue with logout even if API call fails + console.error('Logout API call failed:', error); + } + set({ accessToken: null, refreshToken: null,