@@ -5,6 +5,7 @@ Extracts account from JWT token and injects into request context
|
||||
import logging
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.http import JsonResponse
|
||||
from django.contrib.auth import logout
|
||||
from rest_framework import status
|
||||
|
||||
logger = logging.getLogger('auth.middleware')
|
||||
@@ -40,19 +41,45 @@ class AccountContextMiddleware(MiddlewareMixin):
|
||||
# Validate account/plan - but use the user object already set by Django
|
||||
validation_error = self._validate_account_and_plan(request, request.user)
|
||||
if validation_error:
|
||||
# CRITICAL: Return error response, DO NOT logout
|
||||
# Frontend will handle auth errors appropriately
|
||||
return validation_error
|
||||
|
||||
# Set request.account from the user's account relationship
|
||||
# This is already loaded, no need to query DB again
|
||||
request.account = getattr(request.user, 'account', None)
|
||||
|
||||
# Store account and user IDs in session for audit purposes only
|
||||
# DO NOT use these for validation - they are informational only
|
||||
# CRITICAL: Add account ID to session to prevent cross-contamination
|
||||
# This ensures each session is tied to a specific account
|
||||
if request.account:
|
||||
request.session['_account_id'] = request.account.id
|
||||
request.session['_user_id'] = request.user.id
|
||||
# Verify session integrity - if stored IDs don't match, logout
|
||||
stored_account_id = request.session.get('_account_id')
|
||||
stored_user_id = request.session.get('_user_id')
|
||||
if stored_account_id and stored_account_id != request.account.id:
|
||||
# Session contamination detected - force logout
|
||||
logger.warning(
|
||||
f"[AUTO-LOGOUT] Session contamination: account_id mismatch. "
|
||||
f"Session={stored_account_id}, Current={request.account.id}, "
|
||||
f"User={request.user.id}, Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||
)
|
||||
logout(request)
|
||||
return JsonResponse(
|
||||
{'success': False, 'error': 'Session integrity violation detected. Please login again.'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
if stored_user_id and stored_user_id != request.user.id:
|
||||
# Session contamination detected - force logout
|
||||
logger.warning(
|
||||
f"[AUTO-LOGOUT] Session contamination: user_id mismatch. "
|
||||
f"Session={stored_user_id}, Current={request.user.id}, "
|
||||
f"Account={request.account.id if request.account else None}, "
|
||||
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||
)
|
||||
logout(request)
|
||||
return JsonResponse(
|
||||
{'success': False, 'error': 'Session integrity violation detected. Please login again.'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
return None
|
||||
except (AttributeError, Exception):
|
||||
@@ -101,7 +128,6 @@ class AccountContextMiddleware(MiddlewareMixin):
|
||||
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
||||
validation_error = self._validate_account_and_plan(request, user)
|
||||
if validation_error:
|
||||
# CRITICAL: Return error response, DO NOT logout
|
||||
return validation_error
|
||||
if account_id:
|
||||
# Verify account still exists
|
||||
@@ -158,17 +184,18 @@ class AccountContextMiddleware(MiddlewareMixin):
|
||||
return None
|
||||
|
||||
def _deny_request(self, request, error, status_code):
|
||||
"""Return a consistent JSON error WITHOUT logging out the user."""
|
||||
# Log the denial for audit purposes
|
||||
logger.warning(
|
||||
f"[ACCESS-DENIED] {error}. "
|
||||
f"User={request.user.id if hasattr(request, 'user') and request.user else 'anonymous'}, "
|
||||
f"Account={getattr(request, 'account', None)}, "
|
||||
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||
)
|
||||
"""Logout session users (if any) and return a consistent JSON error."""
|
||||
try:
|
||||
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
||||
logger.warning(
|
||||
f"[AUTO-LOGOUT] Account/plan validation failed: {error}. "
|
||||
f"User={request.user.id}, Account={getattr(request, 'account', None)}, "
|
||||
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||
)
|
||||
logout(request)
|
||||
except Exception as e:
|
||||
logger.error(f"[AUTO-LOGOUT] Error during logout: {e}")
|
||||
|
||||
# Return error response - frontend will handle appropriately
|
||||
# DO NOT call logout() - let the frontend decide based on error type
|
||||
return JsonResponse(
|
||||
{
|
||||
'success': False,
|
||||
|
||||
@@ -8,9 +8,6 @@ from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
|
||||
from simple_history.models import HistoricalRecords
|
||||
|
||||
# Import RefreshToken model
|
||||
from .models_refresh_token import RefreshToken
|
||||
|
||||
|
||||
class AccountBaseModel(models.Model):
|
||||
"""
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
"""
|
||||
Refresh Token Model - Server-side storage for JWT refresh tokens
|
||||
Implements token rotation, revocation, and device tracking
|
||||
"""
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import secrets
|
||||
|
||||
|
||||
class RefreshToken(models.Model):
|
||||
"""
|
||||
Server-side refresh token storage with rotation and revocation support.
|
||||
|
||||
Design principles:
|
||||
- Refresh tokens are the authoritative source of login state
|
||||
- Each token has a unique identifier for rotation tracking
|
||||
- Tokens can be revoked explicitly (password change, admin action)
|
||||
- Device information helps identify and manage sessions
|
||||
- Expiry is configurable (20 days for remember-me, 7 days otherwise)
|
||||
"""
|
||||
|
||||
# Token identification
|
||||
token_id = models.CharField(
|
||||
max_length=64,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Unique identifier for this refresh token"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
get_user_model(),
|
||||
on_delete=models.CASCADE,
|
||||
related_name='refresh_tokens',
|
||||
db_index=True
|
||||
)
|
||||
|
||||
# Token lifecycle
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
expires_at = models.DateTimeField(db_index=True)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
revoked_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
|
||||
# Device and context tracking
|
||||
device_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Client-generated device identifier for multi-device tracking"
|
||||
)
|
||||
user_agent = models.TextField(blank=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
|
||||
# Rotation tracking
|
||||
parent_token_id = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Token ID that was used to create this token (for rotation chain)"
|
||||
)
|
||||
rotation_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of times this token chain has been rotated"
|
||||
)
|
||||
|
||||
# Remember me flag
|
||||
remember_me = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this token was created with remember-me option"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'auth_refresh_token'
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'revoked_at', 'expires_at']),
|
||||
models.Index(fields=['token_id']),
|
||||
models.Index(fields=['user', 'device_id']),
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
status = "revoked" if self.is_revoked else ("expired" if self.is_expired else "active")
|
||||
return f"{self.user.email} - {self.token_id[:8]}... ({status})"
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""Check if token is expired"""
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
@property
|
||||
def is_revoked(self):
|
||||
"""Check if token is revoked"""
|
||||
return self.revoked_at is not None
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""Check if token is both not expired and not revoked"""
|
||||
return not self.is_expired and not self.is_revoked
|
||||
|
||||
def revoke(self):
|
||||
"""
|
||||
Revoke this token.
|
||||
Once revoked, it cannot be used for refresh operations.
|
||||
"""
|
||||
if not self.is_revoked:
|
||||
self.revoked_at = timezone.now()
|
||||
self.save(update_fields=['revoked_at'])
|
||||
|
||||
def mark_used(self):
|
||||
"""Update last_used_at timestamp"""
|
||||
self.last_used_at = timezone.now()
|
||||
self.save(update_fields=['last_used_at'])
|
||||
|
||||
@classmethod
|
||||
def create_token(cls, user, remember_me=False, device_id='', user_agent='', ip_address=None, parent_token_id=''):
|
||||
"""
|
||||
Create a new refresh token for the user.
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
remember_me: If True, token expires in 20 days; otherwise 7 days
|
||||
device_id: Client device identifier
|
||||
user_agent: Browser user agent
|
||||
ip_address: Client IP address
|
||||
parent_token_id: Token ID that was rotated to create this token
|
||||
|
||||
Returns:
|
||||
RefreshToken instance
|
||||
"""
|
||||
# Generate unique token ID
|
||||
token_id = secrets.token_urlsafe(32)
|
||||
|
||||
# Calculate expiry based on remember_me
|
||||
if remember_me:
|
||||
expiry = timezone.now() + timedelta(days=20)
|
||||
else:
|
||||
expiry = timezone.now() + timedelta(days=7)
|
||||
|
||||
# Determine rotation count
|
||||
rotation_count = 0
|
||||
if parent_token_id:
|
||||
parent = cls.objects.filter(token_id=parent_token_id).first()
|
||||
if parent:
|
||||
rotation_count = parent.rotation_count + 1
|
||||
|
||||
# Create token
|
||||
token = cls.objects.create(
|
||||
token_id=token_id,
|
||||
user=user,
|
||||
expires_at=expiry,
|
||||
device_id=device_id,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
parent_token_id=parent_token_id,
|
||||
rotation_count=rotation_count,
|
||||
remember_me=remember_me
|
||||
)
|
||||
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def get_valid_token(cls, token_id):
|
||||
"""
|
||||
Get a valid (not expired, not revoked) refresh token by ID.
|
||||
|
||||
Returns:
|
||||
RefreshToken instance or None
|
||||
"""
|
||||
try:
|
||||
token = cls.objects.select_related('user', 'user__account').get(
|
||||
token_id=token_id,
|
||||
revoked_at__isnull=True,
|
||||
expires_at__gt=timezone.now()
|
||||
)
|
||||
return token
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def revoke_all_for_user(cls, user, exclude_token_id=None):
|
||||
"""
|
||||
Revoke all refresh tokens for a user (e.g., on password change).
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
exclude_token_id: Optional token ID to exclude from revocation
|
||||
"""
|
||||
now = timezone.now()
|
||||
queryset = cls.objects.filter(
|
||||
user=user,
|
||||
revoked_at__isnull=True
|
||||
)
|
||||
|
||||
if exclude_token_id:
|
||||
queryset = queryset.exclude(token_id=exclude_token_id)
|
||||
|
||||
queryset.update(revoked_at=now)
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired(cls, days=30):
|
||||
"""
|
||||
Delete expired and revoked tokens older than specified days.
|
||||
Should be run periodically via cron/celery task.
|
||||
|
||||
Args:
|
||||
days: Delete tokens expired/revoked more than this many days ago
|
||||
"""
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Delete expired tokens
|
||||
expired_count = cls.objects.filter(
|
||||
expires_at__lt=cutoff
|
||||
).delete()[0]
|
||||
|
||||
# Delete revoked tokens
|
||||
revoked_count = cls.objects.filter(
|
||||
revoked_at__lt=cutoff
|
||||
).delete()[0]
|
||||
|
||||
return expired_count + revoked_count
|
||||
@@ -478,11 +478,9 @@ class RegisterSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
"""Serializer for user login with remember-me support."""
|
||||
"""Serializer for user login."""
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField(write_only=True)
|
||||
remember_me = serializers.BooleanField(required=False, default=False)
|
||||
device_id = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
|
||||
@@ -314,7 +314,5 @@ urlpatterns = [
|
||||
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
|
||||
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
||||
path('me/', MeView.as_view(), name='auth-me'),
|
||||
# Logout tracking endpoint for debugging
|
||||
path('logout-event/', csrf_exempt(lambda request: __import__('igny8_core.auth.views_logout_tracking', fromlist=['track_logout_event']).track_logout_event(request)), name='auth-logout-event'),
|
||||
]
|
||||
|
||||
|
||||
@@ -57,48 +57,33 @@ def generate_access_token(user, account=None):
|
||||
return token
|
||||
|
||||
|
||||
def generate_refresh_token_pair(user, account=None, remember_me=False, device_id='', user_agent='', ip_address=None):
|
||||
def generate_refresh_token(user, account=None):
|
||||
"""
|
||||
Generate JWT refresh token and store it server-side for rotation/revocation.
|
||||
Generate JWT refresh token for user
|
||||
|
||||
Args:
|
||||
user: User instance
|
||||
account: Account instance (optional, will use user.account if not provided)
|
||||
remember_me: If True, token expires in 20 days; otherwise 7 days
|
||||
device_id: Client device identifier
|
||||
user_agent: Browser user agent
|
||||
ip_address: Client IP address
|
||||
|
||||
Returns:
|
||||
tuple: (refresh_token_string, refresh_token_id, expiry_datetime)
|
||||
str: JWT refresh token
|
||||
"""
|
||||
from .models_refresh_token import RefreshToken
|
||||
|
||||
if account is None:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Create server-side refresh token record
|
||||
token_record = RefreshToken.create_token(
|
||||
user=user,
|
||||
remember_me=remember_me,
|
||||
device_id=device_id,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address
|
||||
)
|
||||
|
||||
# Generate JWT with token_id embedded (for rotation tracking)
|
||||
now = timezone.now()
|
||||
expiry = now + get_refresh_token_expiry()
|
||||
|
||||
payload = {
|
||||
'user_id': user.id,
|
||||
'account_id': account.id if account else None,
|
||||
'token_id': token_record.token_id,
|
||||
'exp': int(token_record.expires_at.timestamp()),
|
||||
'exp': int(expiry.timestamp()),
|
||||
'iat': int(now.timestamp()),
|
||||
'type': 'refresh',
|
||||
}
|
||||
|
||||
token_string = jwt.encode(payload, get_jwt_secret_key(), algorithm=get_jwt_algorithm())
|
||||
return token_string, token_record.token_id, token_record.expires_at
|
||||
token = jwt.encode(payload, get_jwt_secret_key(), algorithm=get_jwt_algorithm())
|
||||
return token
|
||||
|
||||
|
||||
def decode_token(token):
|
||||
|
||||
@@ -1049,12 +1049,11 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def login(self, request):
|
||||
"""User login endpoint with remember-me support."""
|
||||
"""User login endpoint."""
|
||||
serializer = LoginSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
email = serializer.validated_data['email']
|
||||
password = serializer.validated_data['password']
|
||||
remember_me = serializer.validated_data.get('remember_me', False)
|
||||
|
||||
try:
|
||||
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
||||
@@ -1088,17 +1087,11 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
from django.contrib.auth import login
|
||||
login(request, user)
|
||||
|
||||
# Extract device information from request
|
||||
device_id = request.data.get('device_id', '')
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||||
ip_address = request.META.get('REMOTE_ADDR')
|
||||
|
||||
# Generate JWT tokens with remember-me support
|
||||
# Generate JWT tokens
|
||||
access_token = generate_access_token(user, account)
|
||||
refresh_token, token_id, refresh_expires_at = generate_refresh_token_pair(
|
||||
user, account, remember_me, device_id, user_agent, ip_address
|
||||
)
|
||||
refresh_token = generate_refresh_token(user, account)
|
||||
access_expires_at = get_token_expiry('access')
|
||||
refresh_expires_at = get_token_expiry('refresh')
|
||||
|
||||
user_serializer = UserSerializer(user)
|
||||
return success_response(
|
||||
@@ -1128,9 +1121,7 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def change_password(self, request):
|
||||
"""Change password endpoint - revokes all refresh tokens."""
|
||||
from .models_refresh_token import RefreshToken
|
||||
|
||||
"""Change password endpoint."""
|
||||
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
|
||||
if serializer.is_valid():
|
||||
user = request.user
|
||||
@@ -1144,12 +1135,8 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
user.set_password(serializer.validated_data['new_password'])
|
||||
user.save()
|
||||
|
||||
# CRITICAL: Revoke all refresh tokens when password changes
|
||||
# This forces re-login on all devices for security
|
||||
RefreshToken.revoke_all_for_user(user)
|
||||
|
||||
return success_response(
|
||||
message='Password changed successfully. Please login again on all devices.',
|
||||
message='Password changed successfully',
|
||||
request=request
|
||||
)
|
||||
|
||||
@@ -1174,10 +1161,7 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def refresh(self, request):
|
||||
"""Refresh access token using refresh token with atomic rotation."""
|
||||
from .models_refresh_token import RefreshToken
|
||||
from django.db import transaction
|
||||
|
||||
"""Refresh access token using refresh token."""
|
||||
serializer = RefreshTokenSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return error_response(
|
||||
@@ -1190,7 +1174,7 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
refresh_token = serializer.validated_data['refresh']
|
||||
|
||||
try:
|
||||
# Decode and validate refresh token JWT
|
||||
# Decode and validate refresh token
|
||||
payload = decode_token(refresh_token)
|
||||
|
||||
# Verify it's a refresh token
|
||||
@@ -1201,59 +1185,39 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get token_id from payload and validate against database
|
||||
token_id = payload.get('token_id')
|
||||
if not token_id:
|
||||
# Get user
|
||||
user_id = payload.get('user_id')
|
||||
account_id = payload.get('account_id')
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return error_response(
|
||||
error='Invalid refresh token - missing token ID',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
error='User not found',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Validate token exists, not revoked, and not expired in database
|
||||
token_record = RefreshToken.get_valid_token(token_id)
|
||||
if not token_record:
|
||||
return error_response(
|
||||
error='Refresh token is invalid, revoked, or expired',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
request=request
|
||||
)
|
||||
# Get account
|
||||
account_id = payload.get('account_id')
|
||||
account = None
|
||||
if account_id:
|
||||
try:
|
||||
account = Account.objects.get(id=account_id)
|
||||
except Account.DoesNotExist:
|
||||
pass
|
||||
|
||||
user = token_record.user
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Extract device information from request
|
||||
device_id = request.data.get('device_id', token_record.device_id)
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||||
ip_address = request.META.get('REMOTE_ADDR')
|
||||
|
||||
# ATOMIC ROTATION: Create new token, then revoke old token
|
||||
# This ensures no gap where user could be logged out
|
||||
with transaction.atomic():
|
||||
# Generate new access and refresh tokens
|
||||
access_token = generate_access_token(user, account)
|
||||
new_refresh_token, new_token_id, refresh_expires_at = generate_refresh_token_pair(
|
||||
user=user,
|
||||
account=account,
|
||||
remember_me=token_record.remember_me, # Preserve remember_me setting
|
||||
device_id=device_id,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
parent_token_id=token_id # Track rotation chain
|
||||
)
|
||||
|
||||
# Only revoke old token AFTER new one is created
|
||||
token_record.revoke()
|
||||
token_record.mark_used()
|
||||
if not account:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Generate new access token
|
||||
access_token = generate_access_token(user, account)
|
||||
access_expires_at = get_token_expiry('access')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'access': access_token,
|
||||
'refresh': new_refresh_token, # Return new refresh token
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
'access_expires_at': access_expires_at.isoformat()
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
Logout event tracking for debugging
|
||||
"""
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('auth.logout_tracking')
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
def track_logout_event(request):
|
||||
"""
|
||||
Track logout events from frontend for debugging.
|
||||
This helps identify why users are being logged out.
|
||||
"""
|
||||
try:
|
||||
data = request.data
|
||||
|
||||
# Extract logout information
|
||||
logout_type = data.get('type', 'UNKNOWN')
|
||||
message = data.get('message', 'No message provided')
|
||||
timestamp = data.get('timestamp')
|
||||
idle_minutes = data.get('idleMinutes', 0)
|
||||
location = data.get('location', '')
|
||||
context = data.get('context', {})
|
||||
|
||||
# Log with high visibility
|
||||
logger.warning(
|
||||
f"\n"
|
||||
f"{'=' * 80}\n"
|
||||
f"🚨 FRONTEND LOGOUT DETECTED\n"
|
||||
f"{'=' * 80}\n"
|
||||
f"Type: {logout_type}\n"
|
||||
f"Reason: {message}\n"
|
||||
f"Idle Time: {idle_minutes} minutes\n"
|
||||
f"Location: {location}\n"
|
||||
f"Timestamp: {timestamp}\n"
|
||||
f"User Agent: {context.get('userAgent', 'Unknown')}\n"
|
||||
f"Screen: {context.get('screenResolution', 'Unknown')}\n"
|
||||
f"IP: {request.META.get('REMOTE_ADDR', 'Unknown')}\n"
|
||||
f"User ID: {context.get('userId', 'Unknown')}\n"
|
||||
f"User Email: {context.get('userEmail', 'Unknown')}\n"
|
||||
f"Had Token: {context.get('hasToken', False)}\n"
|
||||
f"Had Refresh: {context.get('hasRefreshToken', False)}\n"
|
||||
f"Was Authenticated: {context.get('isAuthenticated', False)}\n"
|
||||
f"{'=' * 80}\n"
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Logout event logged successfully'
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[LOGOUT-TRACKING] Error logging logout event: {e}")
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Failed to log logout event'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
@@ -96,34 +96,12 @@ CSRF_COOKIE_SECURE = USE_SECURE_COOKIES
|
||||
# CRITICAL: Session isolation to prevent contamination
|
||||
SESSION_COOKIE_NAME = 'igny8_sessionid' # Custom name to avoid conflicts
|
||||
SESSION_COOKIE_HTTPONLY = True # Prevent JavaScript access
|
||||
SESSION_COOKIE_SAMESITE = 'Lax' # Changed from Strict - allows external redirects
|
||||
SESSION_COOKIE_AGE = 1209600 # 14 days (2 weeks)
|
||||
SESSION_SAVE_EVERY_REQUEST = True # Enable sliding window - extends session on activity
|
||||
SESSION_COOKIE_SAMESITE = 'Strict' # Prevent cross-site cookie sharing
|
||||
SESSION_COOKIE_AGE = 86400 # 24 hours
|
||||
SESSION_SAVE_EVERY_REQUEST = False # Don't update session on every request (reduces DB load)
|
||||
SESSION_COOKIE_PATH = '/' # Explicit path
|
||||
# Don't set SESSION_COOKIE_DOMAIN - let it default to current domain for strict isolation
|
||||
|
||||
# CRITICAL: Use Redis for session storage (not database)
|
||||
# Provides better performance and automatic expiry
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
|
||||
SESSION_CACHE_ALIAS = 'default'
|
||||
|
||||
# Configure Redis cache for sessions
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django_redis.cache.RedisCache',
|
||||
'LOCATION': f"redis://{os.getenv('REDIS_HOST', 'redis')}:{os.getenv('REDIS_PORT', '6379')}/1",
|
||||
'OPTIONS': {
|
||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||||
'SOCKET_CONNECT_TIMEOUT': 5,
|
||||
'SOCKET_TIMEOUT': 5,
|
||||
'CONNECTION_POOL_KWARGS': {
|
||||
'max_connections': 50,
|
||||
'retry_on_timeout': True
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# CRITICAL: Custom authentication backend to disable user caching
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'igny8_core.auth.backends.NoCacheModelBackend', # Custom backend without caching
|
||||
@@ -542,7 +520,7 @@ CORS_EXPOSE_HEADERS = [
|
||||
# JWT Configuration
|
||||
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY)
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1) # Increased from 15min to 1 hour
|
||||
JWT_ACCESS_TOKEN_EXPIRY = timedelta(minutes=15)
|
||||
JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30) # Extended to 30 days for persistent login
|
||||
|
||||
# Celery Configuration
|
||||
|
||||
@@ -2,7 +2,6 @@ Django>=5.2.7
|
||||
gunicorn
|
||||
psycopg2-binary
|
||||
redis
|
||||
django-redis>=5.4.0
|
||||
whitenoise
|
||||
djangorestframework
|
||||
django-filter
|
||||
|
||||
Reference in New Issue
Block a user