Fix medium/low priority review items; update README

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Hofmann 2026-04-27 21:48:26 +02:00
parent bf694b79e2
commit cfa874a4c3
9 changed files with 188 additions and 214 deletions

25
.gitignore vendored Normal file
View File

@ -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

142
README.md
View File

@ -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})
{
```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,23 +130,25 @@ environment:
```
llm_quota/
├── backend/
│ ├── main.py # Proxy-Server (Ollama API forwarden)
│ ├── admin.py # Admin API (User/Quota Management)
│ ├── main.py # Proxy-Server
│ ├── admin.py # Admin-API
│ ├── database.py # DB-Verbindung & Session
│ ├── models.py # SQLAlchemy Models
│ ├── schemas.py # Pydantic Schemas
│ ├── crud.py # Database Operations
│ ├── types.py # Response Types
│ ├── 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
```

View File

@ -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

View File

@ -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})

View File

@ -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)

View File

@ -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()

View File

@ -1,97 +1,21 @@
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
@ -101,6 +25,6 @@ def test_auth_middleware_valid_key(mock_proxy, test_client):
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

View File

@ -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()

View File

@ -2,28 +2,38 @@ 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 () => {
try {
const res = await axios.get('/api/users');
setUsers(res.data);
} catch (err) {
setError('Benutzer konnten nicht geladen werden.');
}
};
const fetchApiKeys = async () => {
try {
const res = await axios.get('/api/api-keys');
setApiKeys(res.data);
} catch (err) {
setError('API-Keys konnten nicht geladen werden.');
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="container">
@ -70,7 +80,7 @@ function App() {
<tr key={key.id}>
<td>{key.id}</td>
<td>{key.name}</td>
<td>{key.key}</td>
<td>{maskKey(key.key)}</td>
<td>{key.user_id}</td>
<td>{key.is_active ? 'Active' : 'Inactive'}</td>
</tr>