import os import base64 import hashlib import secrets from types import SimpleNamespace 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", "") STACK_INTERNAL_SECRET = os.getenv("STACK_INTERNAL_SECRET", "") JWT_ALGORITHM = "HS256" APP_SLUG = os.getenv("APP_SLUG", "kitchen") async def get_current_user(request: Request): """ Verify the stack hnf_session cookie. Returns a SimpleNamespace so existing routers can use current_user.kitchen_id, current_user.is_admin, etc. without modification. kitchen_id is pinned to 1 (single hotel — remove when stripping multi-tenant scaffolding, see log B1). """ 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", []) caps = [c[len(prefix):] for c in raw_caps if isinstance(c, str) and c.startswith(prefix)] is_admin = payload.get("is_admin", False) return SimpleNamespace( id=0, email=payload.get("sub", ""), username=payload.get("sub", ""), name=payload.get("name", ""), display_name=payload.get("name", ""), is_admin=is_admin, is_active=True, kitchen_id=1, caps=caps, role="admin" if is_admin else "user", ) def has_cap(user, cap: str) -> bool: return user.is_admin or cap in user.caps def require_cap(cap: str): async def checker(user=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 async def get_current_user_from_token(token: str, db: AsyncSession = None): """Decode a JWT passed as a query-param (e.g. ?token=...) and return a user or None.""" try: payload = jwt.decode(token, CENTRAL_AUTH_SECRET, algorithms=[JWT_ALGORITHM]) except JWTError: return None apps = payload.get("apps", []) if APP_SLUG not in apps: return None prefix = f"{APP_SLUG}:" raw_caps = payload.get("caps", []) caps = [c[len(prefix):] for c in raw_caps if isinstance(c, str) and c.startswith(prefix)] return SimpleNamespace( id=0, email=payload.get("sub", ""), username=payload.get("sub", ""), name=payload.get("name", ""), display_name=payload.get("name", ""), is_admin=payload.get("is_admin", False), is_active=True, kitchen_id=1, caps=caps, role="admin" if payload.get("is_admin", False) else "user", ) async def get_admin_user(user=Depends(get_current_user)): if not user.is_admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return user def verify_internal_secret(authorization: Optional[str] = Header(None)) -> None: """Verify STACK_INTERNAL_SECRET for inter-app calls (e.g. KDS bookings feed).""" if not STACK_INTERNAL_SECRET: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Internal secret not configured") if not authorization or authorization != f"Bearer {STACK_INTERNAL_SECRET}": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid internal secret") # ─── API KEY AUTH (public/external API) ────────────────────────────────────── def generate_api_key() -> tuple[str, str, str]: raw = secrets.token_bytes(32) body = base64.urlsafe_b64encode(raw).decode().rstrip("=") full_key = f"fk_{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]: 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 FROM api_keys WHERE key_hash = :h"), {"h": key_hash}, ) row = result.fetchone() if not row or not row.is_active: return None await db.execute(text("UPDATE api_keys SET last_used_at = NOW() WHERE id = :id"), {"id": row.id}) await db.commit() return {"id": row.id, "name": row.name, "key_prefix": row.key_prefix} async def get_api_key_auth( x_api_key: Optional[str] = Header(None, alias="X-API-Key"), db: AsyncSession = Depends(get_db), ) -> dict: if not x_api_key: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing X-API-Key header") info = await verify_api_key(x_api_key, db) if not info: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or revoked API key") return info