gateway/modules/auth/authentication.py

497 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, TokenPurpose
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
elif token_authority == str(AuthAuthority.GOOGLE.value):
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.GOOGLE,
sessionId=sessionId,
mandateId=None,
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
)
if not active_token:
logger.info(
f"Google JWT db record not active/valid: jti={tokenId}, userId={user.id}"
)
raise credentialsException
elif token_authority == str(AuthAuthority.MSFT.value):
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.MSFT,
sessionId=sessionId,
mandateId=None,
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
)
if not active_token:
logger.info(
f"Microsoft JWT db record not active/valid: jti={tokenId}, userId={user.id}"
)
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),
str(AuthAuthority.GOOGLE.value),
str(AuthAuthority.MSFT.value),
):
logger.info(
"JWT without server record or missing authority claim (local/google/msft require DB row)"
)
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: Infrastructure/System Operator flag.
For Category A (Logs, Tokens, DB-Health, i18n-Master, Registry).
Wirkt auch als RBAC-Engine-Bypass (siehe rbac.py:getUserPermissions)."""
return getattr(self.user, 'isSysAdmin', False)
@property
def isPlatformAdmin(self) -> bool:
"""Convenience property: Cross-Mandate-Governance flag.
For Categories BE (User-/Mandate-/RBAC-/Feature-Registry über alle Mandanten).
KEIN RBAC-Bypass — Daten-Zugriff geht weiterhin über Mandanten-Mitgliedschaft."""
return getattr(self.user, 'isPlatformAdmin', 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.
- isSysAdmin users: RBAC-Engine-Bypass; können jeden Mandant für
Infrastruktur-Operationen betreten ohne Mitgliedschaft. ``ctx.roleIds``
bleibt leer (Bypass läuft direkt in ``rbac.py:getUserPermissions``).
- isPlatformAdmin users: Cross-Mandate-Governance; können jeden Mandant
betreten, aber Routen prüfen die Berechtigung explizit via
``requirePlatformAdmin``. ``ctx.roleIds`` bleibt leer.
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 user is not member of mandate (and not Sys/Platform admin)
"""
ctx = RequestContext(user=currentUser)
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
isPlatformAdmin = getattr(currentUser, 'isPlatformAdmin', 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 or isPlatformAdmin:
# Platform-level authority can enter any mandate without membership.
# No fake role loading: isSysAdmin bypasses RBAC engine; platform-admin
# routes verify authority explicitly via requirePlatformAdmin.
ctx.mandateId = mandateId
ctx.roleIds = []
logger.debug(
f"Platform-level user {currentUser.id} accessing mandate {mandateId} "
f"(isSysAdmin={isSysAdmin}, isPlatformAdmin={isPlatformAdmin})"
)
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 or isPlatformAdmin:
# Platform-level authority can enter any feature instance without
# explicit access record.
ctx.featureInstanceId = featureInstanceId
logger.debug(
f"Platform-level user {currentUser.id} accessing feature instance "
f"{featureInstanceId} (isSysAdmin={isSysAdmin}, "
f"isPlatformAdmin={isPlatformAdmin})"
)
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
# =============================================================================
# PLATFORM ADMIN: Flag-based cross-mandate governance (replaces sysadmin role)
# =============================================================================
def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
"""
Require Platform-Admin flag for cross-mandate governance operations.
Verwendung für alle Operationen, die mandanten-übergreifend wirken:
User-Mgmt, Mandate-Mgmt, RBAC-Catalog, Feature-Registry, User-Access-Overview,
Cross-Mandate-Audit, Cross-Mandate-Billing-Übersicht, Subscription-Mgmt.
KEIN RBAC-Bypass: Daten-Zugriff auf einen einzelnen Mandanten erfordert
weiterhin Mitgliedschaft (oder zusätzlich isSysAdmin für Infrastruktur-Bypass).
Args:
currentUser: Current authenticated user
Returns:
User if they have isPlatformAdmin=True
Raises:
HTTPException 403: If user is not a Platform Admin
"""
if not getattr(currentUser, 'isPlatformAdmin', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Platform admin privileges required"
)
# Audit for all Platform-Admin actions
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",
action="platform_admin_action",
details="Cross-mandate governance operation"
)
except Exception:
pass
return currentUser