messy logout fixing

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-15 12:01:41 +00:00
parent 06e5f252a4
commit 4fb3a144d7
27 changed files with 4396 additions and 95 deletions

View File

@@ -5,7 +5,6 @@ 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')
@@ -41,45 +40,19 @@ 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)
# CRITICAL: Add account ID to session to prevent cross-contamination
# This ensures each session is tied to a specific account
# Store account and user IDs in session for audit purposes only
# DO NOT use these for validation - they are informational only
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):
@@ -128,6 +101,7 @@ 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
@@ -184,18 +158,17 @@ class AccountContextMiddleware(MiddlewareMixin):
return None
def _deny_request(self, request, error, status_code):
"""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 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')}"
)
# Return error response - frontend will handle appropriately
# DO NOT call logout() - let the frontend decide based on error type
return JsonResponse(
{
'success': False,

View File

@@ -8,6 +8,9 @@ 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):
"""

View File

@@ -0,0 +1,219 @@
"""
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

View File

@@ -478,9 +478,11 @@ class RegisterSerializer(serializers.Serializer):
class LoginSerializer(serializers.Serializer):
"""Serializer for user login."""
"""Serializer for user login with remember-me support."""
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):

View File

@@ -314,5 +314,7 @@ 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'),
]

View File

@@ -57,33 +57,48 @@ def generate_access_token(user, account=None):
return token
def generate_refresh_token(user, account=None):
def generate_refresh_token_pair(user, account=None, remember_me=False, device_id='', user_agent='', ip_address=None):
"""
Generate JWT refresh token for user
Generate JWT refresh token and store it server-side for rotation/revocation.
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:
str: JWT refresh token
tuple: (refresh_token_string, refresh_token_id, expiry_datetime)
"""
from .models_refresh_token import RefreshToken
if account is None:
account = getattr(user, 'account', None)
now = timezone.now()
expiry = now + get_refresh_token_expiry()
# 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()
payload = {
'user_id': user.id,
'account_id': account.id if account else None,
'exp': int(expiry.timestamp()),
'token_id': token_record.token_id,
'exp': int(token_record.expires_at.timestamp()),
'iat': int(now.timestamp()),
'type': 'refresh',
}
token = jwt.encode(payload, get_jwt_secret_key(), algorithm=get_jwt_algorithm())
return token
token_string = jwt.encode(payload, get_jwt_secret_key(), algorithm=get_jwt_algorithm())
return token_string, token_record.token_id, token_record.expires_at
def decode_token(token):

View File

@@ -1049,11 +1049,12 @@ class AuthViewSet(viewsets.GenericViewSet):
@action(detail=False, methods=['post'])
def login(self, request):
"""User login endpoint."""
"""User login endpoint with remember-me support."""
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)
@@ -1087,11 +1088,17 @@ class AuthViewSet(viewsets.GenericViewSet):
from django.contrib.auth import login
login(request, user)
# Generate JWT tokens
# 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
access_token = generate_access_token(user, account)
refresh_token = generate_refresh_token(user, account)
refresh_token, token_id, refresh_expires_at = generate_refresh_token_pair(
user, account, remember_me, device_id, user_agent, ip_address
)
access_expires_at = get_token_expiry('access')
refresh_expires_at = get_token_expiry('refresh')
user_serializer = UserSerializer(user)
return success_response(
@@ -1121,7 +1128,9 @@ class AuthViewSet(viewsets.GenericViewSet):
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def change_password(self, request):
"""Change password endpoint."""
"""Change password endpoint - revokes all refresh tokens."""
from .models_refresh_token import RefreshToken
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
user = request.user
@@ -1135,8 +1144,12 @@ 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',
message='Password changed successfully. Please login again on all devices.',
request=request
)
@@ -1161,7 +1174,10 @@ class AuthViewSet(viewsets.GenericViewSet):
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def refresh(self, request):
"""Refresh access token using refresh token."""
"""Refresh access token using refresh token with atomic rotation."""
from .models_refresh_token import RefreshToken
from django.db import transaction
serializer = RefreshTokenSerializer(data=request.data)
if not serializer.is_valid():
return error_response(
@@ -1174,7 +1190,7 @@ class AuthViewSet(viewsets.GenericViewSet):
refresh_token = serializer.validated_data['refresh']
try:
# Decode and validate refresh token
# Decode and validate refresh token JWT
payload = decode_token(refresh_token)
# Verify it's a refresh token
@@ -1185,39 +1201,59 @@ class AuthViewSet(viewsets.GenericViewSet):
request=request
)
# 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:
# Get token_id from payload and validate against database
token_id = payload.get('token_id')
if not token_id:
return error_response(
error='User not found',
status_code=status.HTTP_404_NOT_FOUND,
error='Invalid refresh token - missing token ID',
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
# 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
)
if not account:
account = getattr(user, 'account', None)
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()
# 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,
'access_expires_at': access_expires_at.isoformat()
'refresh': new_refresh_token, # Return new refresh token
'access_expires_at': access_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
},
request=request
)

View File

@@ -0,0 +1,63 @@
"""
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)

View File

@@ -96,12 +96,34 @@ 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 = '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_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_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
@@ -520,7 +542,7 @@ CORS_EXPOSE_HEADERS = [
# JWT Configuration
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY)
JWT_ALGORITHM = 'HS256'
JWT_ACCESS_TOKEN_EXPIRY = timedelta(minutes=15)
JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1) # Increased from 15min to 1 hour
JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30) # Extended to 30 days for persistent login
# Celery Configuration

View File

@@ -2,6 +2,7 @@ Django>=5.2.7
gunicorn
psycopg2-binary
redis
django-redis>=5.4.0
whitenoise
djangorestframework
django-filter