feat: Cloud-Fundament - Auth, Persistenz und Mandanten-Trennung
- SQLite-Store (app/store.py) hinter Store-Interface: Nutzer + Sessions persistent - Bearer-Token-Auth (app/auth.py); Nutzerverwaltung via Admin-Key (POST /api/admin/users) - GET /api/me, PUT /api/me/prefs (dauerhafte Nutzer-Praeferenzen) - chat/speak/transcribe/sessions auth-geschuetzt; Mandanten-Trennung (fremde Session -> 403) - Route-Aufloesung: Defaults < Profil < ENV < Nutzer-Prefs < Session < Request - SessionManager (in-memory) durch Store ersetzt - AUTH_ENABLED-Schalter (prod an, dev/Tests aus); DB_PATH/ADMIN_API_KEY - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur, deploy-env); data/ gitignored - Tests: 29 gruen (Auth, Mandanten, Persistenz, Routing) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
293ed257db
commit
e0e69fdf15
22 changed files with 625 additions and 57 deletions
197
app/store.py
Normal file
197
app/store.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Persistenzschicht: Nutzer und Sessions.
|
||||
|
||||
Ein abstraktes Store-Interface mit SQLite-Default (stdlib). Spaetere Backends
|
||||
(Postgres/Redis) koennen dasselbe Interface implementieren, ohne die App zu aendern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ANONYMOUS_USER_ID = "anonymous"
|
||||
|
||||
|
||||
def hash_token(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
id: str
|
||||
display_name: str
|
||||
prefs: dict = field(default_factory=dict)
|
||||
created_at: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
id: str
|
||||
user_id: str
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class SessionOwnershipError(Exception):
|
||||
"""Eine Session gehoert einem anderen Nutzer (-> HTTP 403)."""
|
||||
|
||||
|
||||
class Store(ABC):
|
||||
@abstractmethod
|
||||
def create_user(self, display_name: str) -> tuple[User, str]:
|
||||
"""Legt einen Nutzer an und liefert (User, Klartext-Token). Token nur hier sichtbar."""
|
||||
|
||||
@abstractmethod
|
||||
def get_user_by_token(self, raw_token: str) -> User | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_user(self, user_id: str) -> User | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def set_user_prefs(self, user_id: str, prefs: dict) -> User: ...
|
||||
|
||||
@abstractmethod
|
||||
def ensure_anonymous_user(self) -> User: ...
|
||||
|
||||
@abstractmethod
|
||||
def get_session(self, session_id: str) -> Session | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
|
||||
"""Erstellt/aktualisiert eine Session des Nutzers. Fremde Session -> SessionOwnershipError."""
|
||||
|
||||
|
||||
class SQLiteStore(Store):
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
prefs_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# ----- Nutzer -----------------------------------------------------------
|
||||
def _row_to_user(self, row: sqlite3.Row) -> User:
|
||||
return User(
|
||||
id=row["id"],
|
||||
display_name=row["display_name"],
|
||||
prefs=json.loads(row["prefs_json"] or "{}"),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
def create_user(self, display_name: str) -> tuple[User, str]:
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
user = User(id=uuid.uuid4().hex, display_name=display_name, prefs={}, created_at=_now())
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, display_name, token_hash, prefs_json, created_at)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(user.id, user.display_name, hash_token(raw_token), "{}", user.created_at),
|
||||
)
|
||||
return user, raw_token
|
||||
|
||||
def get_user_by_token(self, raw_token: str) -> User | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE token_hash = ?", (hash_token(raw_token),)
|
||||
).fetchone()
|
||||
return self._row_to_user(row) if row else None
|
||||
|
||||
def get_user(self, user_id: str) -> User | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
return self._row_to_user(row) if row else None
|
||||
|
||||
def set_user_prefs(self, user_id: str, prefs: dict) -> User:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET prefs_json = ? WHERE id = ?",
|
||||
(json.dumps(prefs), user_id),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"Unbekannter Nutzer: {user_id}")
|
||||
return self._row_to_user(row)
|
||||
|
||||
def ensure_anonymous_user(self) -> User:
|
||||
existing = self.get_user(ANONYMOUS_USER_ID)
|
||||
if existing:
|
||||
return existing
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (id, display_name, token_hash, prefs_json, created_at)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(ANONYMOUS_USER_ID, "Anonymous", f"anon-{ANONYMOUS_USER_ID}", "{}", _now()),
|
||||
)
|
||||
return self.get_user(ANONYMOUS_USER_ID)
|
||||
|
||||
# ----- Sessions ---------------------------------------------------------
|
||||
def get_session(self, session_id: str) -> Session | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return Session(id=row["id"], user_id=row["user_id"], data=json.loads(row["data_json"] or "{}"))
|
||||
|
||||
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
|
||||
existing = self.get_session(session_id)
|
||||
if existing and existing.user_id != user_id:
|
||||
raise SessionOwnershipError(
|
||||
f"Session {session_id!r} gehoert einem anderen Nutzer"
|
||||
)
|
||||
|
||||
data = dict(existing.data) if existing else {}
|
||||
data.update({k: v for k, v in values.items() if v is not None})
|
||||
payload = json.dumps(data)
|
||||
now = _now()
|
||||
|
||||
with self._connect() as conn:
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET data_json = ?, updated_at = ? WHERE id = ?",
|
||||
(payload, now, session_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, data_json, created_at, updated_at)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(session_id, user_id, payload, now, now),
|
||||
)
|
||||
return Session(id=session_id, user_id=user_id, data=data)
|
||||
Loading…
Add table
Add a link
Reference in a new issue