"""Authentication: admin sessions (signed cookie) and API keys (hashed).""" from __future__ import annotations import hashlib import secrets import time from dataclasses import dataclass from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import Depends, Request from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from .config import Settings, get_settings from .db import Database from .errors import AuthError, Forbidden, RateLimited SESSION_COOKIE = "llm_session" KEY_PREFIX = "llm_live_" ph = PasswordHasher() def hash_password(pw: str) -> str: return ph.hash(pw) def verify_password(hash_: str, pw: str) -> bool: try: return ph.verify(hash_, pw) except VerifyMismatchError: return False except Exception: return False def hash_key(raw: str) -> str: return hashlib.sha256(raw.encode()).hexdigest() def generate_key() -> str: return KEY_PREFIX + secrets.token_urlsafe(32).replace("-", "").replace("_", "")[:40] @dataclass class Principal: kind: str # session | api_key id: int | None name: str scopes: set[str] def has(self, scope: str) -> bool: return "admin" in self.scopes or scope in self.scopes class RateLimiter: """Small in-memory sliding window limiter keyed by string.""" def __init__(self, limit: int, window: float): self.limit = limit self.window = window self.hits: dict[str, list[float]] = {} def check(self, key: str) -> bool: now = time.time() arr = [t for t in self.hits.get(key, []) if now - t < self.window] if len(arr) >= self.limit: self.hits[key] = arr return False arr.append(now) self.hits[key] = arr return True login_limiter = RateLimiter(limit=8, window=60) api_limiter = RateLimiter(limit=600, window=60) class Auth: def __init__(self, db: Database, settings: Settings): self.db = db self.settings = settings self.serializer = URLSafeTimedSerializer(settings.secret, salt="llm-api-session") # ---- users ---------------------------------------------------------------- async def user_count(self) -> int: return int(await self.db.scalar("SELECT COUNT(*) FROM users") or 0) async def create_user(self, email: str, password: str) -> int: if len(password) < 10: raise ValueError("password must be at least 10 characters") return await self.db.execute("INSERT INTO users(email, password_hash, role, created_at) VALUES(?,?,?,?)", (email.strip().lower(), hash_password(password), "admin", time.time())) async def authenticate(self, email: str, password: str) -> dict | None: row = await self.db.fetchone("SELECT * FROM users WHERE email=?", (email.strip().lower(),)) if not row or not verify_password(row["password_hash"], password): return None await self.db.execute("UPDATE users SET last_login_at=? WHERE id=?", (time.time(), row["id"])) return row async def change_password(self, user_id: int, new_password: str) -> None: if len(new_password) < 10: raise ValueError("password must be at least 10 characters") await self.db.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(new_password), user_id)) # ---- sessions --------------------------------------------------------------- def make_session(self, user_id: int, email: str) -> str: return self.serializer.dumps({"uid": user_id, "email": email, "n": secrets.token_hex(8)}) def read_session(self, token: str) -> dict | None: try: return self.serializer.loads(token, max_age=self.settings.session_hours * 3600) except (BadSignature, SignatureExpired): return None # ---- api keys --------------------------------------------------------------- async def create_key(self, name: str, scopes: list[str]) -> tuple[str, dict]: raw = generate_key() row_id = await self.db.execute( "INSERT INTO api_keys(name, prefix, key_hash, scopes, created_at) VALUES(?,?,?,?,?)", (name, raw[:16], hash_key(raw), ",".join(sorted(set(scopes))), time.time())) row = await self.db.fetchone("SELECT * FROM api_keys WHERE id=?", (row_id,)) return raw, row # type: ignore[return-value] async def lookup_key(self, raw: str) -> dict | None: row = await self.db.fetchone("SELECT * FROM api_keys WHERE key_hash=? AND revoked_at IS NULL", (hash_key(raw),)) return row async def touch_key(self, key_id: int) -> None: await self.db.execute("UPDATE api_keys SET last_used_at=?, request_count=request_count+1 WHERE id=?", (time.time(), key_id)) # --------------------------------------------------------------------------- # FastAPI dependencies # --------------------------------------------------------------------------- def get_auth(request: Request) -> Auth: return request.app.state.auth def _client_ip(request: Request) -> str: fwd = request.headers.get("x-forwarded-for") if fwd: return fwd.split(",")[0].strip() return request.client.host if request.client else "?" async def principal_from_request(request: Request, auth: Auth) -> Principal | None: header = request.headers.get("authorization") or "" if header.lower().startswith("bearer "): raw = header[7:].strip() if raw: row = await auth.lookup_key(raw) if not row: return None await auth.touch_key(row["id"]) return Principal("api_key", row["id"], row["name"], set(row["scopes"].split(","))) api_key_header = request.headers.get("x-api-key") if api_key_header: row = await auth.lookup_key(api_key_header.strip()) if not row: return None await auth.touch_key(row["id"]) return Principal("api_key", row["id"], row["name"], set(row["scopes"].split(","))) tok = request.cookies.get(SESSION_COOKIE) if tok: data = auth.read_session(tok) if data: return Principal("session", data["uid"], data["email"], {"admin"}) return None async def require_inference(request: Request, auth: Auth = Depends(get_auth)) -> Principal: """Bearer API key (scope inference/admin) or an admin session (playground).""" if not api_limiter.check(_client_ip(request)): raise RateLimited("Too many requests.") p = await principal_from_request(request, auth) if not p: if (request.headers.get("authorization") or request.headers.get("x-api-key")): raise AuthError("Incorrect API key provided.") raise AuthError("Missing API key. Pass it as 'Authorization: Bearer llm_live_...'.") if not p.has("inference"): raise Forbidden("This API key does not have the 'inference' scope.") request.state.principal = p return p async def require_admin(request: Request, auth: Auth = Depends(get_auth)) -> Principal: """Admin session (dashboard) or API key with the admin scope. Mutations from a session need the CSRF header.""" p = await principal_from_request(request, auth) if not p: raise AuthError("Authentication required.", code="UNAUTHENTICATED") if not p.has("admin"): raise Forbidden("Admin scope required.") if p.kind == "session" and request.method not in ("GET", "HEAD", "OPTIONS"): if request.headers.get("x-llm-csrf") != "1": raise Forbidden("Missing CSRF header.", code="CSRF") origin = request.headers.get("origin") if origin: host = request.headers.get("host", "") settings = get_settings() allowed = {settings.public_url.rstrip("/"), f"http://{host}", f"https://{host}", f"http://127.0.0.1:{settings.port}", f"http://localhost:{settings.port}", "http://localhost:3000", "http://127.0.0.1:3000"} if origin.rstrip("/") not in allowed: raise Forbidden("Origin not allowed.", code="CSRF") request.state.principal = p return p