""" Authentication module for backend API. Handles JWT-based authentication, token generation, and user context. """ from datetime import datetime, timedelta, timezone import uuid from typing import Optional, Dict, Any, Tuple from fastapi import Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordBearer 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.shared.timezoneUtils import get_utc_now, get_utc_timestamp from modules.interfaces.interfaceAppObjects import getRootInterface from modules.interfaces.interfaceAppModel import User, AuthAuthority, Token # Get Config Data SECRET_KEY = APP_CONFIG.get("APP_JWT_SECRET_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")) # OAuth2 Setup oauth2Scheme = OAuth2PasswordBearer(tokenUrl="token") # Rate Limiter limiter = Limiter(key_func=get_remote_address) # Logger logger = logging.getLogger(__name__) def createAccessToken(data: dict, expiresDelta: Optional[timedelta] = None) -> Tuple[str, datetime]: """ Creates a JWT Access Token. Args: data: Data to encode (usually user ID or username) expiresDelta: Validity duration of the token (optional) Returns: Tuple of (JWT Token as string, expiration datetime) """ toEncode = data.copy() # Ensure a token id (jti) exists for revocation tracking (only required for local, harmless otherwise) if "jti" not in toEncode or not toEncode.get("jti"): toEncode["jti"] = str(uuid.uuid4()) if expiresDelta: expire = get_utc_now() + expiresDelta else: expire = get_utc_now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) toEncode.update({"exp": expire}) encodedJwt = jwt.encode(toEncode, SECRET_KEY, algorithm=ALGORITHM) return encodedJwt, expire def _getUserBase(token: str = Depends(oauth2Scheme)) -> 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"}, ) 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 mandate ID and user ID from token mandateId: str = payload.get("mandateId") 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") if not mandateId or not userId: logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}") raise credentialsException except JWTError: logger.warning("Invalid JWT Token") raise credentialsException # Initialize Gateway Interface with context appInterface = getRootInterface() # 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 has the correct context if str(user.mandateId) != str(mandateId) or str(user.id) != str(userId): logger.error(f"User context mismatch: token(mandateId={mandateId}, userId={userId}) vs user(mandateId={user.mandateId}, 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: db_tokens = appInterface.db.getRecordset( Token, recordFilter={"id": tokenId} ) except Exception: 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/mandate active_token = appInterface.findActiveTokenById( tokenId=tokenId, userId=user.id, authority=AuthAuthority.LOCAL, sessionId=sessionId, mandateId=str(mandateId) if mandateId else None, ) if not active_token: logger.info( f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, mandateId={mandateId}, 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