kitchen/backend/auth.py
jtricerolph ecd63fc65f Add get_current_user_from_token to auth.py; fix missing import in 3 api files
?token= query-param endpoints (PO/invoice print previews, recipe image exports,
backup download) call get_current_user_from_token which was not ported from the
original archive auth module. Added the function and fixed the missing import in
backup.py, ingredients.py, and invoices.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:34:01 +00:00

155 lines
5.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
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