- Updated comments in LeadsListScreen, MoreScreen, TaskDetailScreen, TaskFormScreen, TasksListScreen, TicketAnalyticsScreen, TicketDetailScreen, TicketsListScreen, ApiService, AuthService, CrashReporting, DealCard, TicketCard, MultiSelectSheet, KanbanColumn, StageStepper, TicketApprovalPanel, TicketPropertiesCard, and TicketTimePanel to improve readability and maintain a consistent style. - Adjusted punctuation and phrasing for better flow and understanding.
183 lines
6.8 KiB
Python
183 lines
6.8 KiB
Python
import logging
|
|
|
|
from django.core.exceptions import PermissionDenied
|
|
|
|
from common.models import Org, Profile
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GetProfileAndOrg:
|
|
"""
|
|
Middleware to extract and validate organization context from JWT tokens.
|
|
|
|
SECURITY: This middleware validates org_id from the SIGNED JWT token,
|
|
not from client-provided headers. This prevents org spoofing attacks.
|
|
|
|
The org context is cryptographically verified as part of the JWT signature.
|
|
"""
|
|
|
|
def __init__(self, get_response):
|
|
self.get_response = get_response
|
|
|
|
def __call__(self, request):
|
|
self.process_request(request)
|
|
return self.get_response(request)
|
|
|
|
def process_request(self, request):
|
|
# Skip JWT validation for authentication endpoints that don't need org context
|
|
auth_skip_paths = [
|
|
"/api/auth/google/",
|
|
"/api/auth/refresh-token/",
|
|
"/api/auth/me/",
|
|
"/api/auth/switch-org/",
|
|
"/api/auth/magic-link/request/",
|
|
"/api/auth/magic-link/verify/",
|
|
]
|
|
if request.path in auth_skip_paths:
|
|
return
|
|
|
|
# Initialize request attributes
|
|
request.profile = None
|
|
request.org = None
|
|
|
|
# Personal Access Token (agent / MCP). Resolve here so org context is
|
|
# set before RequireOrgContext runs (DRF auth runs too late for that).
|
|
# MUST come before the JWT branch so a PAT bearer is never handed to
|
|
# the JWT decoder.
|
|
raw_pat = self._extract_pat(request)
|
|
if raw_pat:
|
|
self._process_pat_auth(request, raw_pat)
|
|
return
|
|
|
|
# Try JWT token first (primary authentication)
|
|
if request.headers.get("Authorization"):
|
|
self._process_jwt_auth(request)
|
|
return
|
|
|
|
# Fall back to API key authentication
|
|
api_key = request.headers.get("Token")
|
|
if api_key:
|
|
self._process_api_key_auth(request, api_key)
|
|
return
|
|
|
|
def _extract_pat(self, request):
|
|
"""Return a bcrm_pat_-prefixed token from the request, else None.
|
|
|
|
Reuses the same extractor as the DRF auth class so the detection logic
|
|
(Authorization: Bearer … or the Token header) lives in one place.
|
|
"""
|
|
from common.pat_auth import _extract_raw
|
|
|
|
return _extract_raw(request)
|
|
|
|
def _process_pat_auth(self, request, raw):
|
|
"""Resolve a PAT and set org context, mirroring the JWT/org-key paths.
|
|
|
|
On an invalid/revoked/expired PAT we leave request.org unset so that
|
|
RequireOrgContext returns a clean 403 (the same denial the org-key path
|
|
produces for an unknown key once that exception surfaces). We swallow
|
|
AuthenticationFailed here rather than re-raising so the request is
|
|
denied cleanly downstream instead of 500-ing inside middleware.
|
|
"""
|
|
from rest_framework.exceptions import AuthenticationFailed
|
|
|
|
from common.pat_auth import resolve_valid_pat
|
|
|
|
try:
|
|
pat = resolve_valid_pat(raw)
|
|
except AuthenticationFailed:
|
|
# Leave org unset → RequireOrgContext denies with 403. The DRF
|
|
# PATAuthentication class will also raise on this token, but the
|
|
# middleware-level denial happens first.
|
|
return
|
|
request.profile = pat.profile
|
|
request.org = pat.org
|
|
request.META["org"] = str(pat.org.id)
|
|
request.META["mcp_token_id"] = str(pat.id)
|
|
request._pat = pat
|
|
|
|
def _process_jwt_auth(self, request):
|
|
"""
|
|
Process JWT authentication and extract org context from token.
|
|
|
|
SECURITY: Org ID is extracted from the signed JWT payload, not headers.
|
|
"""
|
|
from rest_framework_simplejwt.tokens import AccessToken
|
|
|
|
try:
|
|
auth_header = request.headers.get("Authorization")
|
|
token_value = auth_header.split(" ")[1]
|
|
|
|
# Validate and decode token
|
|
access_token = AccessToken(token_value)
|
|
user_id = access_token["user_id"]
|
|
|
|
# Get org_id from JWT claim (SECURE - cryptographically signed)
|
|
org_id = access_token.get("org_id")
|
|
|
|
if not org_id:
|
|
# Token doesn't have org context - this is allowed for some endpoints
|
|
# like /api/auth/me/ or initial login
|
|
logger.debug("JWT token for user %s has no org_id claim", user_id)
|
|
return
|
|
|
|
# Validate user membership in the org
|
|
try:
|
|
profile = Profile.objects.select_related("org").get(
|
|
user_id=user_id, org_id=org_id, is_active=True
|
|
)
|
|
request.profile = profile
|
|
request.org = profile.org
|
|
logger.debug("Set org context from JWT: user=%s, org=%s", user_id, org_id)
|
|
|
|
except Profile.DoesNotExist as exc:
|
|
# User doesn't have access to this org anymore
|
|
# This can happen if membership was revoked after token was issued
|
|
logger.warning(
|
|
"User %s no longer has access to org %s. Token may be stale.",
|
|
user_id,
|
|
org_id,
|
|
)
|
|
raise PermissionDenied(
|
|
"You no longer have access to this organization. "
|
|
"Please login again."
|
|
) from exc
|
|
|
|
except (IndexError, KeyError) as e:
|
|
logger.warning("Malformed Authorization header: %s", e)
|
|
# Let DRF authentication handle this
|
|
return
|
|
|
|
except Exception as e:
|
|
logger.warning("JWT validation failed: %s", e)
|
|
# Let DRF authentication handle invalid tokens
|
|
return
|
|
|
|
def _process_api_key_auth(self, request, api_key):
|
|
"""Process API key authentication."""
|
|
try:
|
|
organization = Org.objects.get(api_key=api_key, is_active=True)
|
|
|
|
# Get an admin profile for this org
|
|
profile = Profile.objects.filter(
|
|
org=organization, role="ADMIN", is_active=True
|
|
).first()
|
|
|
|
if not profile:
|
|
logger.error("No active admin profile found for org %s", organization.id)
|
|
# Let DRF authentication reject this, raising AuthenticationFailed
|
|
# here escapes the middleware stack as a 500 instead of a 401.
|
|
return
|
|
|
|
request.profile = profile
|
|
request.org = organization
|
|
request.META["org"] = str(organization.id)
|
|
|
|
logger.debug("Set org context from API key: org=%s", organization.id)
|
|
|
|
except Org.DoesNotExist:
|
|
logger.warning("Invalid API key attempted")
|
|
# Same as above: APIKeyAuthentication raises inside the DRF layer,
|
|
# which turns it into a proper 401 response.
|
|
return
|