gateway/modules/security/auth.py
2025-09-22 07:44:39 +02:00

288 lines
10 KiB
Python

"""
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, Response
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.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_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__)
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 createAccessTokenWithCookie(data: dict, response: Response, expiresDelta: Optional[timedelta] = None) -> str:
"""
Creates a JWT Access Token and sets it as an httpOnly cookie.
Args:
data: Data to encode (usually user ID or username)
response: FastAPI Response object to set cookie
expiresDelta: Validity duration of the token (optional)
Returns:
JWT Token as string
"""
access_token, expires_at = createAccessToken(data, expiresDelta)
# Set httpOnly cookie
response.set_cookie(
key="auth_token",
value=access_token,
httponly=True,
secure=True, # HTTPS only in production
samesite="strict",
max_age=int(expiresDelta.total_seconds()) if expiresDelta else ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
return access_token
def createRefreshToken(data: dict) -> Tuple[str, datetime]:
"""
Creates a JWT Refresh Token with longer expiration.
Args:
data: Data to encode (usually user ID or username)
Returns:
Tuple of (JWT Refresh Token as string, expiration datetime)
"""
toEncode = data.copy()
# Ensure a token id (jti) exists for revocation tracking
if "jti" not in toEncode or not toEncode.get("jti"):
toEncode["jti"] = str(uuid.uuid4())
# Add refresh token type
toEncode["type"] = "refresh"
expire = get_utc_now() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
toEncode.update({"exp": expire})
encodedJwt = jwt.encode(toEncode, SECRET_KEY, algorithm=ALGORITHM)
return encodedJwt, expire
def setRefreshTokenCookie(data: dict, response: Response) -> str:
"""
Creates a JWT Refresh Token and sets it as an httpOnly cookie.
Args:
data: Data to encode (usually user ID or username)
response: FastAPI Response object to set cookie
Returns:
JWT Refresh Token as string
"""
refresh_token, expires_at = createRefreshToken(data)
# Set httpOnly cookie for refresh token
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True, # HTTPS only in production
samesite="strict",
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 # Days to seconds
)
return refresh_token
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"},
)
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 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/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