Files
TieMeasureFlow/src/backend/api/middleware/api_key.py
T

91 lines
2.9 KiB
Python

"""API Key authentication dependency for FastAPI."""
from datetime import timedelta
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User
from src.backend.services.auth_service import hash_api_key
from src.backend.utils.tempo import utcnow
_RENEW_INTERVAL = timedelta(minutes=5)
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User:
"""Extract API key from header and return the authenticated user.
The API key is sent in the X-API-Key header on every request.
Login endpoint is excluded from this check.
"""
api_key = request.headers.get("X-API-Key")
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key in X-API-Key header",
)
result = await db.execute(
select(ApiKey, User)
.join(User, User.id == ApiKey.user_id)
.where(ApiKey.key_hash == hash_api_key(api_key), User.active == True)
)
row = result.first()
now = utcnow()
if row is None or row[0].expires_at <= now:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired API key",
)
key_row, user = row
# Sliding TTL, written at most once per _RENEW_INTERVAL to spare the DB.
if key_row.last_used_at is None or now - key_row.last_used_at > _RENEW_INTERVAL:
key_row.last_used_at = now
key_row.expires_at = now + timedelta(hours=settings.api_key_ttl_hours)
await db.flush()
# Store user_id in request state for access logging middleware
request.state.user_id = user.id
return user
def require_role(role: str):
"""Dependency factory that checks if user has a specific role."""
async def check_role(user: User = Depends(get_current_user)) -> User:
if not user.has_role(role) and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{role}' required",
)
return user
return check_role
def require_admin():
"""Dependency that checks if user is admin."""
async def check_admin(user: User = Depends(get_current_user)) -> User:
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
return check_admin
# Pre-built role dependencies for convenience
require_maker = require_role("Maker")
require_measurement_tec = require_role("MeasurementTec")
require_metrologist = require_role("Metrologist")
require_supervisor = require_role("Supervisor")
require_admin_user = require_admin()