kitchen/backend/auth.py
jtricerolph bcc94024e3 Pre-deploy security/correctness fixes (port log E17)
- Remove dead kitchen->KDS internal API (api/internal.py, verify_internal_secret)
  — KDS reads kitchen_db directly (E16), nothing ever called this endpoint
- Add expires_at to dispute_attachments; public attachment links now expire
  after 30 days instead of staying valid forever (A4)
- Add services/upload_validation.py: sniff real file content via python-magic
  instead of trusting the client-supplied Content-Type header, plus a 20MB
  cap. Applied across invoices/logbook/food_flags/credit_notes/disputes
  upload endpoints (A5) — disputes previously had no file-type check at all
- Fix nginx client_max_body_size drift (800m -> the plan's intended 20m)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 14:44:58 +00:00

147 lines
5 KiB
Python

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
# ─── 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