""" Auth middleware — verifies the stack's hnf_session cookie using the shared CENTRAL_AUTH_SECRET. Replaces the old per-app JWT/users system. The app-local API-key system (public API access) is kept as-is. """ import os import base64 import hashlib import secrets from typing import Optional from fastapi import Depends, Header, HTTPException, Request, status from jose import JWTError, jwt from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from database import get_db CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET", "") JWT_ALGORITHM = "HS256" APP_SLUG = os.getenv("APP_SLUG", "forecasting") async def get_current_user(request: Request) -> dict: token = request.cookies.get("hnf_session") if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") try: payload = jwt.decode(token, CENTRAL_AUTH_SECRET, algorithms=[JWT_ALGORITHM]) except JWTError: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") apps = payload.get("apps", []) if APP_SLUG not in apps: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission for this app") prefix = f"{APP_SLUG}:" raw_caps = payload.get("caps", []) if isinstance(raw_caps, list): caps = [c[len(prefix):] for c in raw_caps if c.startswith(prefix)] else: caps = [] is_admin = payload.get("is_admin", False) return { "id": 0, "username": payload.get("sub", ""), "email": payload.get("sub", ""), "display_name": payload.get("name", ""), "name": payload.get("name", ""), "is_admin": is_admin, "caps": caps, "role": "admin" if is_admin else "user", } def has_cap(user: dict, cap: str) -> bool: return user.get("is_admin", False) or cap in user.get("caps", []) def require_cap(cap: str): async def checker(user: dict = Depends(get_current_user)): if not has_cap(user, cap): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing capability: {cap}", ) return user return checker # Keep get_admin_user for routes that require admin async def get_admin_user(user: dict = Depends(get_current_user)) -> dict: if not user.get("is_admin", False): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return user # ============================================ # API KEY AUTHENTICATION (app-local, for the public API) # ============================================ def generate_api_key() -> tuple[str, str, str]: """ Generate a new API key. Returns: (full_key, key_hash, key_prefix) - full_key: The complete key to give to user (shown only once) - key_hash: SHA256 hash to store in database - key_prefix: First 12 chars for display purposes """ random_bytes = secrets.token_bytes(32) key_body = base64.urlsafe_b64encode(random_bytes).decode('utf-8').rstrip('=') full_key = f"fk_{key_body}" key_hash = hashlib.sha256(full_key.encode()).hexdigest() key_prefix = full_key[:12] + "..." return full_key, key_hash, key_prefix async def verify_api_key(api_key: str, db: AsyncSession) -> Optional[dict]: """ Verify an API key and return key info if valid. Also updates last_used_at timestamp. """ if not api_key or not api_key.startswith("fk_"): return None key_hash = hashlib.sha256(api_key.encode()).hexdigest() result = await db.execute( text(""" SELECT id, name, key_prefix, is_active, created_at FROM api_keys WHERE key_hash = :key_hash """), {"key_hash": key_hash} ) key_record = result.fetchone() if not key_record: return None if not key_record.is_active: return None await db.execute( text("UPDATE api_keys SET last_used_at = NOW() WHERE id = :key_id"), {"key_id": key_record.id} ) await db.commit() return { "id": key_record.id, "name": key_record.name, "key_prefix": key_record.key_prefix, "is_active": key_record.is_active } async def get_api_key_auth( x_api_key: Optional[str] = Header(None, alias="X-API-Key"), db: AsyncSession = Depends(get_db) ) -> dict: """ FastAPI dependency for API key authentication. Raises 401 if key is missing or invalid. """ if not x_api_key: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key. Provide X-API-Key header.", ) key_info = await verify_api_key(x_api_key, db) if not key_info: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or revoked API key", ) return key_info async def get_all_api_keys(db: AsyncSession) -> list: """Get all API keys (without the actual key values)""" result = await db.execute( text(""" SELECT id, key_prefix, name, is_active, created_at, last_used_at, created_by FROM api_keys ORDER BY created_at DESC """) ) keys = result.fetchall() return [ { "id": k.id, "key_prefix": k.key_prefix, "name": k.name, "is_active": k.is_active, "created_at": k.created_at.isoformat() if k.created_at else None, "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None, "created_by": k.created_by } for k in keys ] async def create_api_key(db: AsyncSession, name: str, created_by: str) -> dict: """ Create a new API key. Returns the full key (only time it's shown) plus metadata. """ full_key, key_hash, key_prefix = generate_api_key() result = await db.execute( text(""" INSERT INTO api_keys (key_hash, key_prefix, name, is_active, created_at, created_by) VALUES (:key_hash, :key_prefix, :name, true, NOW(), :created_by) RETURNING id, key_prefix, name, is_active, created_at """), { "key_hash": key_hash, "key_prefix": key_prefix, "name": name, "created_by": created_by } ) await db.commit() key_record = result.fetchone() return { "id": key_record.id, "key": full_key, # Only time the full key is returned! "key_prefix": key_record.key_prefix, "name": key_record.name, "is_active": key_record.is_active, "created_at": key_record.created_at.isoformat() if key_record.created_at else None } async def revoke_api_key(db: AsyncSession, key_id: int) -> bool: """Revoke (deactivate) an API key""" result = await db.execute( text("SELECT id FROM api_keys WHERE id = :key_id"), {"key_id": key_id} ) if not result.fetchone(): raise HTTPException(status_code=404, detail="API key not found") await db.execute( text("UPDATE api_keys SET is_active = false WHERE id = :key_id"), {"key_id": key_id} ) await db.commit() return True async def delete_api_key(db: AsyncSession, key_id: int) -> bool: """Permanently delete an API key""" result = await db.execute( text("SELECT id FROM api_keys WHERE id = :key_id"), {"key_id": key_id} ) if not result.fetchone(): raise HTTPException(status_code=404, detail="API key not found") await db.execute( text("DELETE FROM api_keys WHERE id = :key_id"), {"key_id": key_id} ) await db.commit() return True