# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Authentication module for backend API. Handles JWT-based authentication, token generation, and user context. Multi-Tenant Design: - Token ist NICHT an einen Mandanten gebunden - User arbeitet parallel in mehreren Mandanten (z.B. mehrere Browser-Tabs) - Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt - Request-Context kapselt User + Mandant + Feature-Instanz + geladene Rollen """ from typing import Optional, Dict, Any, Tuple, List from fastapi import Depends, HTTPException, status, Request, Response, Header from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jose import JWTError, jwt import logging from slowapi import Limiter from slowapi.util import get_remote_address from modules.shared.configuration import APP_CONFIG from modules.security.rootAccess import getRootDbAppConnector, getRootUser from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelRbac import AccessRule # Get Config Data SECRET_KEY = APP_CONFIG.get("APP_JWT_KEY_SECRET") ALGORITHM = APP_CONFIG.get("Auth_ALGORITHM") ACCESS_TOKEN_EXPIRE_MINUTES = int(APP_CONFIG.get("APP_TOKEN_EXPIRY")) REFRESH_TOKEN_EXPIRE_DAYS = int(APP_CONFIG.get("APP_REFRESH_TOKEN_EXPIRY", "7")) # Cookie-based Authentication Setup class CookieAuth(HTTPBearer): """Cookie-based authentication that checks httpOnly cookies first, then Authorization header""" def __init__(self, auto_error: bool = True): super().__init__(auto_error=auto_error) async def __call__(self, request: Request) -> Optional[str]: # 1. Check httpOnly cookie first (preferred method) token = request.cookies.get('auth_token') if token: return token # 2. Fallback to Authorization header for API calls authorization = request.headers.get("Authorization") if authorization and authorization.startswith("Bearer "): return authorization.split(" ")[1] if self.auto_error: raise HTTPException(status_code=401, detail="Not authenticated") return None # Initialize cookie-based auth cookieAuth = CookieAuth(auto_error=False) # Rate Limiter limiter = Limiter(key_func=get_remote_address) # Logger logger = logging.getLogger(__name__) # Note: JWT creation and cookie helpers moved to modules.auth.jwtService def _getUserBase(token: str = Depends(cookieAuth)) -> User: """ Extracts and validates the current user from the JWT token. Args: token: JWT Token from the Authorization header Returns: User model instance Raises: HTTPException: For invalid token or user """ credentialsException = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"}, ) # Guard: token may be None or malformed when cookie/header is missing or bad if not token or not isinstance(token, str): logger.warning("Missing JWT Token (no cookie/header)") raise credentialsException # Basic JWT format check (header.payload.signature) try: if token.count(".") != 2: logger.warning("Malformed JWT token format") raise credentialsException except Exception: # If anything odd happens while checking format, treat as invalid creds raise credentialsException try: # Decode token payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) # Extract username from token username: str = payload.get("sub") if username is None: raise credentialsException # Extract user ID from token # MULTI-TENANT: mandateId is NO LONGER in the token - it comes from X-Mandate-Id header userId: str = payload.get("userId") authority: str = payload.get("authenticationAuthority") tokenId: Optional[str] = payload.get("jti") sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId") # Only userId is required in token now (no mandateId) if not userId: logger.error(f"Missing userId in token") raise credentialsException except JWTError: logger.warning("Invalid JWT Token") raise credentialsException # Get root user and interface for database access rootUser = getRootUser() appInterface = getInterface(rootUser) # Retrieve user from database user = appInterface.getUserByUsername(username) if user is None: logger.warning(f"User {username} not found") raise credentialsException # Check if user is enabled if not user.enabled: logger.warning(f"User {username} is disabled") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled") # Ensure the user ID in token matches the user in database # MULTI-TENANT: mandateId is NO LONGER checked here - it comes from headers if str(user.id) != str(userId): logger.error(f"User ID mismatch: token(userId={userId}) vs user(id={user.id})") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User context has changed. Please log in again.", headers={"WWW-Authenticate": "Bearer"}, ) # For LOCAL gateway JWTs, enforce DB-backed token validity and revocation try: # Normalize authority to string for comparison normalized_authority = (str(authority).lower() if authority is not None else None) # If we have a token id, check if a corresponding DB token exists for local authority db_tokens = [] if tokenId: try: dbApp = getRootDbAppConnector() db_tokens = dbApp.getRecordset( Token, recordFilter={"id": tokenId} ) except Exception as e: # Check if this is a table not found error (token table was deleted) if "does not exist" in str(e).lower() or "relation" in str(e).lower(): logger.error("Token table does not exist - database may have been reset") raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Authentication service temporarily unavailable. Please contact administrator." ) db_tokens = [] if db_tokens: # There is a server record for this token; enforce status and context when local db_token = db_tokens[0] token_authority = str(db_token.get("authority", "")).lower() if token_authority == str(AuthAuthority.LOCAL.value): # Must be active and match user/session # MULTI-TENANT: mandateId is NOT checked here - tokens are no longer mandate-bound active_token = appInterface.findActiveTokenById( tokenId=tokenId, userId=user.id, authority=AuthAuthority.LOCAL, sessionId=sessionId, mandateId=None, # Token is no longer mandate-bound ) if not active_token: logger.info( f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}" ) raise credentialsException else: # No DB record for this token. If the claim says local (or missing/unknown), require DB record. if normalized_authority in (None, "", str(AuthAuthority.LOCAL.value)): logger.info("Local JWT without server record or missing authority claim") raise credentialsException except HTTPException: raise except Exception as e: logger.error(f"Error during local token validation: {str(e)}") raise credentialsException return user def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User: """Get current active user with additional validation.""" # Check if current user is enabled if not currentUser.enabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled" ) return currentUser # ============================================================================= # MULTI-TENANT: Request Context System # ============================================================================= class RequestContext: """ Request context for multi-tenant operations. Contains user, mandate context, feature instance context, and loaded role IDs. This context is per-request (not persisted) - follows stateless design. IMPORTANT: SysAdmin also needs explicit membership for mandate context! isSysAdmin flag does NOT give implicit access to mandate data. """ def __init__(self, user: User): self.user: User = user self.mandateId: Optional[str] = None self.featureInstanceId: Optional[str] = None self.roleIds: List[str] = [] # Request-scoped cache: rules loaded only once per request self._cachedRules: Optional[List[tuple]] = None def getRules(self) -> List[tuple]: """ Loads rules once per request (not across requests). Returns list of (priority, AccessRule) tuples. """ if self._cachedRules is None: if not self.mandateId: # No mandate context = no rules self._cachedRules = [] else: try: rootUser = getRootUser() appInterface = getInterface(rootUser) self._cachedRules = appInterface.rbac.getRulesForUserBulk( self.user.id, self.mandateId, self.featureInstanceId ) except Exception as e: logger.error(f"Error loading RBAC rules: {e}") self._cachedRules = [] return self._cachedRules @property def isSysAdmin(self) -> bool: """Convenience property to check if user is a system admin.""" return getattr(self.user, 'isSysAdmin', False) def getRequestContext( request: Request, mandateId: Optional[str] = Header(None, alias="X-Mandate-Id"), featureInstanceId: Optional[str] = Header(None, alias="X-Instance-Id"), currentUser: User = Depends(getCurrentUser) ) -> RequestContext: """ Determines request context from headers. Checks authorization and loads role IDs. Security Model: - Regular users: Must be explicit members of mandates/feature instances - SysAdmin users: Can access ANY mandate for administrative operations, but don't get implicit roleIds (no automatic data access rights). Routes can check ctx.isSysAdmin to allow admin operations. Args: request: FastAPI Request object mandateId: Mandate ID from X-Mandate-Id header featureInstanceId: Feature instance ID from X-Instance-Id header currentUser: Current authenticated user Returns: RequestContext with user, mandate, roles Raises: HTTPException 403: If non-SysAdmin user is not member of mandate or has no feature access """ ctx = RequestContext(user=currentUser) isSysAdmin = getattr(currentUser, 'isSysAdmin', False) # Get root interface for membership checks rootInterface = getRootInterface() if mandateId: # Check mandate membership membership = rootInterface.getUserMandate(currentUser.id, mandateId) if membership: # User is a member - load their roles if not membership.enabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Mandate membership is disabled" ) ctx.mandateId = mandateId ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id) elif isSysAdmin: # SysAdmin can access any mandate for admin operations # But they don't get roleIds - no implicit data access ctx.mandateId = mandateId # roleIds stays empty - SysAdmin must rely on isSysAdmin flag for authorization logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} without membership") else: # Regular user without membership - denied logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not member of mandate" ) if featureInstanceId: # Check feature access access = rootInterface.getFeatureAccess(currentUser.id, featureInstanceId) if access: # User has access - load their instance roles if not access.enabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Feature access is disabled" ) ctx.featureInstanceId = featureInstanceId instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id) ctx.roleIds.extend(instanceRoleIds) elif isSysAdmin: # SysAdmin can access any feature instance for admin operations ctx.featureInstanceId = featureInstanceId logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} without explicit access") else: # Regular user without access - denied logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="No access to feature instance" ) return ctx def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User: """ SysAdmin check for system-level operations. Use this dependency for endpoints that require SysAdmin privileges. SysAdmin has access to system-level operations, but NOT to mandate data. Args: currentUser: Current authenticated user Returns: User if they are a SysAdmin Raises: HTTPException 403: If user is not a SysAdmin """ if not getattr(currentUser, 'isSysAdmin', False): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="SysAdmin privileges required" ) # Audit for all SysAdmin actions try: from modules.shared.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId="system", action="sysadmin_action", details="System-level operation" ) except Exception: # Don't fail if audit logging fails pass return currentUser