214 lines
9.2 KiB
Python
214 lines
9.2 KiB
Python
"""
|
|
Multi-Account Middleware
|
|
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
|
|
import json
|
|
from datetime import datetime
|
|
|
|
logger = logging.getLogger('auth.middleware')
|
|
|
|
# Logout reason codes for precise tracking
|
|
LOGOUT_REASONS = {
|
|
'SESSION_ACCOUNT_MISMATCH': 'Session contamination: account ID mismatch',
|
|
'SESSION_USER_MISMATCH': 'Session contamination: user ID mismatch',
|
|
'ACCOUNT_MISSING': 'Account not configured for this user',
|
|
'ACCOUNT_SUSPENDED': 'Account is suspended',
|
|
'ACCOUNT_CANCELLED': 'Account is cancelled',
|
|
'PLAN_MISSING': 'No subscription plan assigned',
|
|
'PLAN_INACTIVE': 'Subscription plan is inactive',
|
|
'USER_INACTIVE': 'User account is inactive',
|
|
}
|
|
|
|
try:
|
|
import jwt
|
|
JWT_AVAILABLE = True
|
|
except ImportError:
|
|
JWT_AVAILABLE = False
|
|
|
|
from django.conf import settings
|
|
|
|
|
|
class AccountContextMiddleware(MiddlewareMixin):
|
|
"""
|
|
Middleware that extracts account information from JWT token
|
|
and adds it to request context for account isolation.
|
|
"""
|
|
|
|
def process_request(self, request):
|
|
"""Extract account from JWT token in Authorization header or session."""
|
|
# Skip for admin and auth endpoints
|
|
if request.path.startswith('/admin/') or request.path.startswith('/api/v1/auth/'):
|
|
return None
|
|
|
|
# First, try to get user from Django session (cookie-based auth)
|
|
# This handles cases where frontend uses credentials: 'include' with session cookies
|
|
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
|
# CRITICAL FIX: Never query DB again or mutate request.user
|
|
# Django's AuthenticationMiddleware already loaded the user correctly
|
|
# Just use it directly and set request.account from the ALREADY LOADED relationship
|
|
try:
|
|
# 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:
|
|
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)
|
|
|
|
# REMOVED: Session contamination checks on every request
|
|
# These were causing random logouts - session integrity handled by Django
|
|
|
|
return None
|
|
except (AttributeError, Exception):
|
|
# If anything fails, just set account to None and continue
|
|
request.account = None
|
|
return None
|
|
|
|
# Get token from Authorization header (JWT auth - for future implementation)
|
|
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
|
if not auth_header.startswith('Bearer '):
|
|
# No JWT token - if session auth didn't work, set account to None
|
|
# But don't set request.user to None - it might be set by Django's auth middleware
|
|
if not hasattr(request, 'account'):
|
|
request.account = None
|
|
return None
|
|
|
|
token = auth_header.split(' ')[1] if len(auth_header.split(' ')) > 1 else None
|
|
if not token:
|
|
if not hasattr(request, 'account'):
|
|
request.account = None
|
|
return None
|
|
|
|
try:
|
|
if not JWT_AVAILABLE:
|
|
# JWT library not installed yet - skip for now
|
|
request.account = None
|
|
return None
|
|
|
|
# Decode JWT token with signature verification
|
|
# Use JWT_SECRET_KEY from settings (falls back to SECRET_KEY if not set)
|
|
jwt_secret = getattr(settings, 'JWT_SECRET_KEY', getattr(settings, 'SECRET_KEY', None))
|
|
if not jwt_secret:
|
|
raise ValueError("JWT_SECRET_KEY or SECRET_KEY must be set in settings")
|
|
|
|
decoded = jwt.decode(token, jwt_secret, algorithms=[getattr(settings, 'JWT_ALGORITHM', 'HS256')])
|
|
|
|
# Extract user and account info from token
|
|
user_id = decoded.get('user_id')
|
|
account_id = decoded.get('account_id')
|
|
|
|
if user_id:
|
|
from .models import User, Account
|
|
try:
|
|
# Get user from DB (but don't set request.user - let DRF authentication handle that)
|
|
# Only set request.account for account context
|
|
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
|
validation_error = self._validate_account_and_plan(request, user)
|
|
if validation_error:
|
|
return validation_error
|
|
if account_id:
|
|
# Verify account still exists
|
|
try:
|
|
account = Account.objects.get(id=account_id)
|
|
request.account = account
|
|
except Account.DoesNotExist:
|
|
# Account from token doesn't exist - don't fallback, set to None
|
|
request.account = None
|
|
else:
|
|
# No account_id in token - set to None (don't fallback to user.account)
|
|
request.account = None
|
|
except (User.DoesNotExist, Account.DoesNotExist):
|
|
request.account = None
|
|
else:
|
|
request.account = None
|
|
|
|
except jwt.InvalidTokenError:
|
|
request.account = None
|
|
except Exception:
|
|
# Fail silently for now - allow unauthenticated access
|
|
request.account = None
|
|
|
|
return None
|
|
|
|
def _validate_account_and_plan(self, request, user):
|
|
"""
|
|
Ensure the authenticated user has an account and an active plan.
|
|
Uses shared validation helper for consistency.
|
|
Bypasses validation for superusers, developers, and system accounts.
|
|
"""
|
|
# Bypass validation for superusers
|
|
if getattr(user, 'is_superuser', False):
|
|
return None
|
|
|
|
# Bypass validation for developers
|
|
if hasattr(user, 'role') and user.role == 'developer':
|
|
return None
|
|
|
|
# Bypass validation for system account users
|
|
try:
|
|
if hasattr(user, 'is_system_account_user') and user.is_system_account_user():
|
|
return None
|
|
except Exception:
|
|
pass
|
|
|
|
from .utils import validate_account_and_plan
|
|
|
|
is_valid, error_message, http_status = validate_account_and_plan(user)
|
|
|
|
if not is_valid:
|
|
return self._deny_request(request, error_message, http_status)
|
|
|
|
return None
|
|
|
|
def _deny_request(self, request, error, status_code):
|
|
"""Logout session users (if any) and return a consistent JSON error with detailed tracking."""
|
|
# Determine logout reason code based on error message
|
|
reason_code = 'UNKNOWN'
|
|
if 'Account not configured' in error or 'Account not found' in error:
|
|
reason_code = 'ACCOUNT_MISSING'
|
|
elif 'suspended' in error.lower():
|
|
reason_code = 'ACCOUNT_SUSPENDED'
|
|
elif 'cancelled' in error.lower():
|
|
reason_code = 'ACCOUNT_CANCELLED'
|
|
elif 'No subscription plan' in error or 'plan assigned' in error.lower():
|
|
reason_code = 'PLAN_MISSING'
|
|
elif 'plan is inactive' in error.lower() or 'Active subscription required' in error:
|
|
reason_code = 'PLAN_INACTIVE'
|
|
elif 'inactive' in error.lower():
|
|
reason_code = 'USER_INACTIVE'
|
|
|
|
try:
|
|
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
|
logger.warning(
|
|
f"[AUTO-LOGOUT] {reason_code}: {error}. "
|
|
f"User={request.user.id}, Account={getattr(request, 'account', None)}, "
|
|
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}, "
|
|
f"Status={status_code}, Timestamp={datetime.now().isoformat()}"
|
|
)
|
|
logout(request)
|
|
except Exception as e:
|
|
logger.error(f"[AUTO-LOGOUT] Error during logout: {e}")
|
|
|
|
return JsonResponse(
|
|
{
|
|
'success': False,
|
|
'error': error,
|
|
'logout_reason': reason_code,
|
|
'logout_message': LOGOUT_REASONS.get(reason_code, error),
|
|
'logout_path': request.path,
|
|
'logout_context': {
|
|
'user_id': request.user.id if hasattr(request, 'user') and request.user and request.user.is_authenticated else None,
|
|
'account_id': getattr(request, 'account', None).id if hasattr(request, 'account') and getattr(request, 'account', None) else None,
|
|
'status_code': status_code,
|
|
}
|
|
},
|
|
status=status_code,
|
|
)
|
|
|