From cfa874a4c3ec1f57e824d262aa8b65253f07079c Mon Sep 17 00:00:00 2001 From: Oliver Hofmann Date: Mon, 27 Apr 2026 21:48:26 +0200 Subject: [PATCH] Fix medium/low priority review items; update README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Medium: - Frontend: Error-Handling in fetchUsers/fetchApiKeys (try/catch) - Frontend: Loading-Race behoben (Promise.all + .finally) - Frontend: API-Keys maskiert (nur letzte 4 Zeichen sichtbar) - Tests: Setup-Code aus test_auth.py in conftest.py konsolidiert - Tests: Fixture-Scope vereinheitlicht (function statt session) Low: - bare except in database.py → except Exception - datetime.utcnow → datetime.now(timezone.utc) durchgängig - DateTime(timezone=True) in allen Modell-Spalten - .gitignore hinzugefügt (.env, *.db, __pycache__, .idea, node_modules) Docs: - README aktualisiert (Sicherheit, Konfiguration, Projektstruktur, Tests) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 25 ++++++ README.md | 154 ++++++++++++++++++++---------------- backend/crud.py | 4 +- backend/database.py | 2 +- backend/models.py | 14 ++-- backend/tests/conftest.py | 77 ++++++++---------- backend/tests/test_auth.py | 86 ++------------------ backend/tests/test_quota.py | 10 +-- frontend/src/main.jsx | 30 ++++--- 9 files changed, 188 insertions(+), 214 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f768044 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +*.egg-info/ + +# Environment & secrets +.env +*.env.local + +# Databases +*.db +*.sqlite3 + +# IDE +.idea/ +.vscode/ + +# Frontend build +frontend/node_modules/ +frontend/dist/ + +# Misc +config.json \ No newline at end of file diff --git a/README.md b/README.md index de6701b..f6e5113 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,41 @@ Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung und Quota-Management ## Features -- 🔑 API-Key-Authentifizierung (Bearer Token oder `sk-` Prefix) -- 📊 Quota-Management (Tokens & Requests pro Tag/Monat) -- 📈 Usage-Tracking in Echtzeit -- 🖥️ Web-Admin-Oberfläche für User & Quotas -- 🔒 Benutzer-Management +- API-Key-Authentifizierung (Bearer Token oder `sk-`-Prefix) +- Quota-Management mit getrennten Tages- und Monatslimits (Tokens & Requests) +- Token-Zählung via tiktoken (cl100k_base) +- Usage-Tracking mit automatischem täglichem/monatlichem Reset +- Web-Admin-Oberfläche für User- und Quota-Verwaltung +- OpenAI-kompatibler `/v1/chat/completions`-Endpunkt + +## Sicherheit + +- Passwörter mit bcrypt gehasht +- API-Keys als SHA-256-Hash in der DB – Plaintext wird nur einmalig bei Erstellung zurückgegeben +- Admin-Zugriff über `is_admin`-Flag in der DB, nicht über Hardcoded-Namen +- CORS-Origins konfigurierbar via `ALLOWED_ORIGINS` +- Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race) ## Installation & Start ### Voraussetzungen - Python 3.12+ -- PostgreSQL 16+ +- PostgreSQL 16+ (oder SQLite für Entwicklung) - Node.js 18+ (für Frontend) -### lokal mit SQLite (Entwicklung) +### Lokal mit SQLite (Entwicklung) ```bash # Backend cd backend +pip install -r requirements.txt python init_db.py python setup_admin.py -uvicorn main:app --reload +uvicorn main:app --reload --port 8000 + +# Admin-API (in neuem Terminal) +uvicorn admin:app --reload --port 8001 # Frontend (in neuem Terminal) cd frontend @@ -33,7 +46,7 @@ npm install npm run dev ``` -### mit PostgreSQL & Docker (Produktion) +### Mit PostgreSQL & Docker (Produktion) ```bash docker compose up -d @@ -41,18 +54,25 @@ docker compose exec backend python init_db.py docker compose exec backend python setup_admin.py ``` -## Usage +## Konfiguration -### Mit API-Key authentifizieren +`.env`-Datei im `backend/`-Verzeichnis anlegen: -```bash -curl -X POST http://localhost:8000/api/generate \ - -H "Authorization: sk-xxxxxx" \ - -H "Content-Type: application/json" \ - -d '{"model":"llama3","prompt":"Say hello"}' +```env +DATABASE_URL=postgresql://user:pass@host:5432/db +OLLAMA_URL=http://ollama:11434 +ALLOWED_ORIGINS=https://admin.example.com ``` -Oder mit Bearer Token: +| Variable | Standard | Beschreibung | +|----------|----------|--------------| +| `DATABASE_URL` | PostgreSQL lokal | DB-Verbindungsstring; `sqlite:///` für SQLite | +| `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz | +| `ALLOWED_ORIGINS` | `http://localhost:5173` | Kommagetrennte CORS-Origins für die Admin-UI | + +## Proxy-Endpunkte + +Alle Endpunkte erfordern einen gültigen API-Key im `Authorization`-Header. ```bash curl -X POST http://localhost:8000/api/generate \ @@ -61,50 +81,48 @@ curl -X POST http://localhost:8000/api/generate \ -d '{"model":"llama3","prompt":"Say hello"}' ``` -### Admin API Endpoints +| Endpunkt | Beschreibung | +|----------|--------------| +| `POST /api/generate` | Ollama generate | +| `POST /api/chat` | Ollama chat | +| `GET /api/tags` | Verfügbare Modelle | +| `GET /v1/models` | Modelle (OpenAI-Format) | +| `POST /v1/chat/completions` | Chat (OpenAI-Format) | -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/users` | GET | Liste aller User | -| `/api/users` | POST | Neuen User erstellen | -| `/api/api-keys` | GET | Liste aller API-Keys | -| `/api/api-keys` | POST | Neuen API-Key erstellen | +## Admin-API (Port 8001) + +Alle Endpunkte erfordern einen API-Key eines Nutzers mit `is_admin=true`. + +| Endpunkt | Methode | Beschreibung | +|----------|---------|--------------| +| `/api/users` | GET | Alle User auflisten | +| `/api/users` | POST | Neuen User anlegen | +| `/api/api-keys` | GET | Alle API-Keys auflisten | +| `/api/api-keys` | POST | Neuen API-Key erstellen (Plaintext einmalig in Response) | | `/api/api-keys/{id}/deactivate` | PUT | API-Key deaktivieren | | `/api/quotas/{user_id}` | PUT | Quota für User setzen | -### Quota-Beispiele +### Quota setzen -```json -// Quota setzen (per PUT /api/quotas/{user_id}) -{ - "daily_tokens": 1000000, - "monthly_tokens": 10000000, - "daily_requests": 1000, - "monthly_requests": 10000 -} +```bash +curl -X PUT http://localhost:8001/api/quotas/1 \ + -H "Authorization: Bearer sk-admin-key" \ + -H "Content-Type: application/json" \ + -d '{ + "daily_tokens": 1000000, + "monthly_tokens": 10000000, + "daily_requests": 1000, + "monthly_requests": 10000 + }' ``` -## Konfiguration +`null` für ein Limit bedeutet unbegrenzt. -### Umgebungsvariablen +## Tests -Im `backend/` Verzeichnis `.env` Datei erstellen: - -```env -OLLAMA_URL=http://ollama:11434 -DATABASE_URL=postgresql://user:pass@host:5432/db -SECRET_KEY=your-secret-key-min-32-chars -``` - -### Docker Compose - -`docker-compose.yml` anpassen: - -```yaml -environment: - - DATABASE_URL=postgresql://ollama:password@db:5432/ollama_proxy - - OLLAMA_URL=http://ollama:11434 - - SECRET_KEY=your-secret-key +```bash +cd backend +python -m pytest tests/ -v ``` ## Projektstruktur @@ -112,26 +130,28 @@ environment: ``` llm_quota/ ├── backend/ -│ ├── main.py # Proxy-Server (Ollama API forwarden) -│ ├── admin.py # Admin API (User/Quota Management) -│ ├── database.py # DB-Verbindung & Session -│ ├── models.py # SQLAlchemy Models -│ ├── schemas.py # Pydantic Schemas -│ ├── crud.py # Database Operations -│ ├── types.py # Response Types +│ ├── main.py # Proxy-Server +│ ├── admin.py # Admin-API +│ ├── database.py # DB-Verbindung & Session +│ ├── models.py # SQLAlchemy-Modelle +│ ├── schemas.py # Pydantic-Schemas +│ ├── crud.py # DB-Operationen & Token-Zählung +│ ├── init_db.py # Tabellen anlegen +│ ├── setup_admin.py # Admin-User & API-Key erstellen │ ├── requirements.txt │ ├── Dockerfile -│ └── setup_admin.py # Admin User erstellen +│ └── tests/ +│ ├── conftest.py # Fixtures +│ ├── test_auth.py # Authentifizierungs-Tests +│ └── test_quota.py # Quota- & Token-Tests ├── frontend/ -│ ├── src/ -│ │ ├── main.jsx -│ │ └── styles.css -│ ├── index.html -│ ├── package.json -│ └── vite.config.js +│ └── src/ +│ ├── main.jsx +│ └── styles.css +├── .gitignore └── docker-compose.yml ``` ## Lizenz -MIT +MIT \ No newline at end of file diff --git a/backend/crud.py b/backend/crud.py index fb17afe..c07185d 100644 --- a/backend/crud.py +++ b/backend/crud.py @@ -2,7 +2,7 @@ import secrets import hashlib import bcrypt import tiktoken -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.orm import Session from models import APIKey, User, Quota, Usage @@ -89,7 +89,7 @@ def check_and_increment_quota(db: Session, user_id: int, tokens: int = 0, reques db.add(usage) db.flush() - now = datetime.utcnow() + now = datetime.now(timezone.utc) if usage.daily_reset_at.date() < now.date(): usage.tokens_used_today = 0 diff --git a/backend/database.py b/backend/database.py index 9cec505..ea74e10 100644 --- a/backend/database.py +++ b/backend/database.py @@ -8,7 +8,7 @@ try: engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) else: engine = create_engine(DATABASE_URL) -except: +except Exception: DATABASE_URL = "sqlite:///./test.db" engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) diff --git a/backend/models.py b/backend/models.py index 071955f..1d2d1c6 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,7 +1,9 @@ from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, BigInteger -from datetime import datetime +from datetime import datetime, timezone from database import Base +_now = lambda: datetime.now(timezone.utc) + class User(Base): __tablename__ = "users" @@ -11,7 +13,7 @@ class User(Base): hashed_password = Column(String) is_active = Column(Boolean, default=True) is_admin = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime(timezone=True), default=_now) class APIKey(Base): __tablename__ = "api_keys" @@ -21,7 +23,7 @@ class APIKey(Base): key = Column(String, unique=True, index=True) user_id = Column(Integer, ForeignKey("users.id")) is_active = Column(Boolean, default=True) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime(timezone=True), default=_now) class Quota(Base): __tablename__ = "quotas" @@ -32,7 +34,7 @@ class Quota(Base): monthly_tokens = Column(BigInteger, nullable=True) daily_requests = Column(Integer, nullable=True) monthly_requests = Column(Integer, nullable=True) - reset_at = Column(DateTime, default=datetime.utcnow) + reset_at = Column(DateTime(timezone=True), default=_now) class Usage(Base): __tablename__ = "usage" @@ -43,5 +45,5 @@ class Usage(Base): tokens_used_month = Column(BigInteger, default=0) requests_today = Column(Integer, default=0) requests_month = Column(Integer, default=0) - daily_reset_at = Column(DateTime, default=datetime.utcnow) - monthly_reset_at = Column(DateTime, default=datetime.utcnow) + daily_reset_at = Column(DateTime(timezone=True), default=_now) + monthly_reset_at = Column(DateTime(timezone=True), default=_now) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cdb70be..1a3c04f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,49 +1,41 @@ import pytest -from fastapi.testclient import TestClient -import tempfile import os -from pathlib import Path -def create_test_db(): - """Create a temporary SQLite database for tests.""" - temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False) - temp_db.close() - os.environ["DATABASE_URL"] = f"sqlite:///{temp_db.name}" - return temp_db.name +os.environ.setdefault("OLLAMA_URL", "http://127.0.0.1:9999") -def cleanup_test_db(db_path): - """Remove the temporary database.""" - if os.path.exists(db_path): - os.unlink(db_path) - os.environ.pop("DATABASE_URL", None) -def setup_test_db(): - """Setup test database with required data.""" +def _setup_db(): from database import Base, engine, SessionLocal - from models import User, APIKey, Quota, Usage + from models import User, Quota from crud import create_api_key, hash_password - - # Create tables + + Base.metadata.drop_all(bind=engine) Base.metadata.create_all(bind=engine) - + db = SessionLocal() - - # Create test user + test_user = User( username="testuser", email="test@example.com", hashed_password=hash_password("test123"), - is_active=True + is_active=True, ) db.add(test_user) db.commit() db.refresh(test_user) - - # Create API key for test user + + db.add(Quota( + user_id=test_user.id, + daily_tokens=1_000_000, + monthly_tokens=10_000_000, + daily_requests=1000, + monthly_requests=10000, + )) + db.commit() + _, raw_key = create_api_key(db, test_user.id, "test-key") os.environ["TEST_API_KEY"] = raw_key - # Create admin user admin_user = User( username="admin", email="admin@example.com", @@ -55,33 +47,34 @@ def setup_test_db(): db.commit() db.refresh(admin_user) - # Create admin API key + db.add(Quota( + user_id=admin_user.id, + daily_tokens=10_000_000, + monthly_tokens=100_000_000, + daily_requests=10000, + monthly_requests=100000, + )) + db.commit() + _, admin_raw_key = create_api_key(db, admin_user.id, "admin-key") os.environ["ADMIN_API_KEY"] = admin_raw_key - + db.close() - return raw_key, admin_raw_key -def teardown_test_db(): - """Clean up test database and environment.""" +def _teardown_db(): from database import engine from models import Base Base.metadata.drop_all(bind=engine) - os.environ.pop("TEST_API_KEY", None) os.environ.pop("ADMIN_API_KEY", None) -@pytest.fixture(scope="session") + +@pytest.fixture(scope="function") def test_client(): - """Create test client with test database.""" - db_path = create_test_db() - setup_test_db() - + _setup_db() from main import app - client = TestClient(app) - + from fastapi.testclient import TestClient + client = TestClient(app, raise_server_exceptions=False) yield client - - teardown_test_db() - cleanup_test_db(db_path) + _teardown_db() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 853c863..50b3525 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,106 +1,30 @@ -import pytest import os from unittest.mock import AsyncMock, patch -from fastapi.testclient import TestClient -from main import app -from database import Base, engine, SessionLocal -from models import User, APIKey, Quota -from crud import create_api_key, hash_password -os.environ["OLLAMA_URL"] = "http://127.0.0.1:9999" - -def setup_test_db(): - """Setup test database with required data.""" - Base.metadata.drop_all(bind=engine) - Base.metadata.create_all(bind=engine) - - db = SessionLocal() - - test_user = User( - username="testuser", - email="test@example.com", - hashed_password=hash_password("test123"), - is_active=True - ) - db.add(test_user) - db.commit() - db.refresh(test_user) - - quota = Quota( - user_id=test_user.id, - daily_tokens=1000000, - monthly_tokens=10000000, - daily_requests=1000, - monthly_requests=10000 - ) - db.add(quota) - db.commit() - - api_key_record, raw_key = create_api_key(db, test_user.id, "test-key") - os.environ["TEST_API_KEY"] = raw_key - - admin_user = User( - username="admin", - email="admin@example.com", - hashed_password=hash_password("admin123"), - is_active=True, - is_admin=True, - ) - db.add(admin_user) - db.commit() - db.refresh(admin_user) - - admin_quota = Quota( - user_id=admin_user.id, - daily_tokens=10000000, - monthly_tokens=100000000, - daily_requests=10000, - monthly_requests=100000 - ) - db.add(admin_quota) - db.commit() - - _, admin_raw_key = create_api_key(db, admin_user.id, "admin-key") - os.environ["ADMIN_API_KEY"] = admin_raw_key - - db.close() - - return os.environ["TEST_API_KEY"], os.environ["ADMIN_API_KEY"] - -def teardown_test_db(): - """Clean up test database and environment.""" - Base.metadata.drop_all(bind=engine) - os.environ.pop("TEST_API_KEY", None) - os.environ.pop("ADMIN_API_KEY", None) - -@pytest.fixture(scope="function") -def test_client(): - setup_test_db() - client = TestClient(app, raise_server_exceptions=False) - yield client - teardown_test_db() def test_auth_middleware_missing_auth(test_client): response = test_client.post("/api/generate", json={"model": "llama3", "prompt": "test"}) assert response.status_code == 401 + def test_auth_middleware_invalid_key(test_client): response = test_client.post( "/api/generate", headers={"Authorization": "sk-invalid-key"}, - json={"model": "llama3", "prompt": "test"} + json={"model": "llama3", "prompt": "test"}, ) assert response.status_code == 401 + @patch("main.proxy_request", new_callable=AsyncMock) def test_auth_middleware_valid_key(mock_proxy, test_client): mock_proxy.return_value.status_code = 200 mock_proxy.return_value.json = lambda: {"response": "success"} mock_proxy.return_value.headers = {} - + response = test_client.post( "/api/generate", headers={"Authorization": os.environ.get("TEST_API_KEY", "")}, - json={"model": "llama3", "prompt": "test"} + json={"model": "llama3", "prompt": "test"}, ) assert response.status_code == 200 diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index f2ab9e6..a6d4d8b 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -1,6 +1,6 @@ import pytest import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone os.environ.setdefault("OLLAMA_URL", "http://127.0.0.1:9999") @@ -126,7 +126,7 @@ def test_daily_reset_restores_access(db): # Backdate daily_reset_at to yesterday usage = db.query(Usage).filter(Usage.user_id == user_id).first() - usage.daily_reset_at = datetime.utcnow() - timedelta(days=1) + usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1) db.commit() # Should pass again after reset @@ -140,7 +140,7 @@ def test_daily_reset_does_not_affect_monthly_counter(db): check_and_increment_quota(db, user_id, tokens=50, requests=1) usage = db.query(Usage).filter(Usage.user_id == user_id).first() - usage.daily_reset_at = datetime.utcnow() - timedelta(days=1) + usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1) db.commit() check_and_increment_quota(db, user_id, tokens=50, requests=1) @@ -154,7 +154,7 @@ def test_monthly_reset_restores_access(db): check_and_increment_quota(db, user_id, tokens=90, requests=1) usage = db.query(Usage).filter(Usage.user_id == user_id).first() - usage.monthly_reset_at = datetime.utcnow() - timedelta(days=32) + usage.monthly_reset_at = datetime.now(timezone.utc) - timedelta(days=32) db.commit() assert check_and_increment_quota(db, user_id, tokens=90, requests=1) is True @@ -168,7 +168,7 @@ def test_failed_quota_check_still_commits_reset(db): # Backdate so a reset fires, but the new request still exceeds the limit usage = db.query(Usage).filter(Usage.user_id == user_id).first() - usage.daily_reset_at = datetime.utcnow() - timedelta(days=1) + usage.daily_reset_at = datetime.now(timezone.utc) - timedelta(days=1) usage.tokens_used_today = 80 db.commit() diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 6e58eb5..1f5d570 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,33 +2,43 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; import './styles.css'; +const maskKey = (key) => `••••••••${key.slice(-4)}`; + function App() { const [users, setUsers] = useState([]); const [apiKeys, setApiKeys] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { - fetchUsers(); - fetchApiKeys(); - setLoading(false); + Promise.all([fetchUsers(), fetchApiKeys()]).finally(() => setLoading(false)); }, []); const fetchUsers = async () => { - const res = await axios.get('/api/users'); - setUsers(res.data); + try { + const res = await axios.get('/api/users'); + setUsers(res.data); + } catch (err) { + setError('Benutzer konnten nicht geladen werden.'); + } }; const fetchApiKeys = async () => { - const res = await axios.get('/api/api-keys'); - setApiKeys(res.data); + try { + const res = await axios.get('/api/api-keys'); + setApiKeys(res.data); + } catch (err) { + setError('API-Keys konnten nicht geladen werden.'); + } }; if (loading) return
Loading...
; + if (error) return
{error}
; return (

Ollama Proxy Admin

- +

Users

@@ -70,7 +80,7 @@ function App() { - + @@ -82,4 +92,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file
{key.id} {key.name}{key.key}{maskKey(key.key)} {key.user_id} {key.is_active ? 'Active' : 'Inactive'}