18d96193ab
auth.py
secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py
send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py
add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py
All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py
POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py
join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py
Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py
_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py
Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
117 lines
4.6 KiB
Python
117 lines
4.6 KiB
Python
import secrets
|
|
import time
|
|
from collections import defaultdict, deque
|
|
from typing import Optional
|
|
from fastapi import HTTPException, Security
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from firebase_admin import auth as firebase_auth
|
|
from app.config import settings
|
|
|
|
_bearer = HTTPBearer(auto_error=False)
|
|
|
|
|
|
async def require_firebase_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Verify a Firebase ID token from the Authorization: Bearer header."""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
try:
|
|
return firebase_auth.verify_id_token(credentials.credentials)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
|
|
async def require_service_or_firebase_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept either a Firebase ID token or the internal service key."""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
token = credentials.credentials
|
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
|
return {"service": True}
|
|
try:
|
|
return firebase_auth.verify_id_token(token)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
|
|
async def require_admin_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Verify a Firebase ID token AND require the admin custom claim."""
|
|
decoded = await require_firebase_token(credentials)
|
|
if not decoded.get("admin"):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return decoded
|
|
|
|
|
|
async def require_service_key(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept only the internal service key — used for bot-only endpoints."""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
if not settings.service_key:
|
|
raise HTTPException(status_code=503, detail="Service key not configured")
|
|
if not secrets.compare_digest(credentials.credentials, settings.service_key):
|
|
raise HTTPException(status_code=403, detail="Service key required")
|
|
return {"service": True}
|
|
|
|
|
|
async def require_service_key_or_admin(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept either the internal service key or a Firebase admin token.
|
|
|
|
Used for endpoints that the Discord bot (service key) and dashboard admins
|
|
(Firebase + admin claim) both need to call, but regular Firebase users must not.
|
|
"""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
token = credentials.credentials
|
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
|
return {"service": True}
|
|
try:
|
|
decoded = firebase_auth.verify_id_token(token)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
if not decoded.get("admin"):
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return decoded
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Simple in-memory sliding-window rate limiter
|
|
# ---------------------------------------------------------------------------
|
|
# Not persistent across restarts; good enough for a single-instance deployment.
|
|
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
|
|
|
|
class _RateLimiter:
|
|
def __init__(self, max_calls: int, window_seconds: int):
|
|
self.max_calls = max_calls
|
|
self.window = window_seconds
|
|
self._log: dict[str, deque] = defaultdict(deque)
|
|
|
|
def check(self, key: str) -> None:
|
|
now = time.monotonic()
|
|
q = self._log[key]
|
|
while q and now - q[0] > self.window:
|
|
q.popleft()
|
|
if len(q) >= self.max_calls:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail="Rate limit exceeded. Please wait before trying again.",
|
|
)
|
|
q.append(now)
|
|
|
|
|
|
# Shared limiter instances
|
|
# trip chat: 20 requests per user per 5 minutes
|
|
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
|
# per-incident summarize: 5 per incident per 10 minutes
|
|
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
|
# vocabulary bootstrap: 2 per system per hour
|
|
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|