feat: Multi-User-Unterstützung mit JWT-Authentifizierung

- User-Modell (username, password_hash, role admin/user, is_active)
- Standard-Admin-Benutzer wird beim ersten Start automatisch angelegt
- JWT-Tokens (HS256) für Benutzer-Sessions, konfigurierbare Ablaufzeit
- API-Key bleibt für service-to-service-Calls (backward-compatible)
- POST /api/v1/auth/login → JWT-Token
- GET  /api/v1/auth/me   → aktueller Benutzer
- CRUD /api/v1/users/    → Benutzerverwaltung (nur Admin)
- TUI zeigt Login-Screen beim Start; nach Erfolg → MainScreen
- Passwort-Hashing mit bcrypt (python-jose für JWT)
This commit is contained in:
2026-03-04 20:55:13 +01:00
parent 0e2a8a6bc0
commit 6eb27a62b1
14 changed files with 421 additions and 10 deletions

View File

@@ -3,7 +3,7 @@ from datetime import datetime
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.routes import channels, contacts, conversations, messages
from api.routes import auth, channels, contacts, conversations, messages, users
app = FastAPI(
title="MCM MultiCustomerMessenger API",
@@ -22,6 +22,8 @@ app.add_middleware(
)
# Routen
app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
app.include_router(messages.router, prefix="/api/v1")
app.include_router(conversations.router, prefix="/api/v1")
app.include_router(contacts.router, prefix="/api/v1")

View File

@@ -1,18 +1,76 @@
from fastapi import HTTPException, Security, status
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import bcrypt
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from config import settings
_bearer = HTTPBearer(auto_error=False)
ALGORITHM = "HS256"
def require_api_key(
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode(), hashed.encode())
except Exception:
return False
def create_access_token(data: dict[str, Any]) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=settings.token_expire_hours)
payload["exp"] = expire
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def require_auth(
credentials: HTTPAuthorizationCredentials | None = Security(_bearer),
) -> str:
if not credentials or credentials.credentials != settings.api_key:
) -> dict[str, Any]:
"""Akzeptiert API-Key (service-to-service) ODER JWT-Token (Benutzer-Login)."""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
detail="Authentifizierung erforderlich",
headers={"WWW-Authenticate": "Bearer"},
)
return credentials.credentials
token = credentials.credentials
# Service-API-Key-Prüfung (backward-compatible)
if token == settings.api_key:
return {"type": "api_key", "username": "system", "role": "admin"}
# JWT-Prüfung
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
return {
"type": "user",
"user_id": payload["sub"],
"username": payload["username"],
"role": payload["role"],
}
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültiger oder abgelaufener Token",
headers={"WWW-Authenticate": "Bearer"},
)
def require_admin(principal: dict = Depends(require_auth)) -> dict:
if principal.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich")
return principal
# Alias für Abwärtskompatibilität
require_api_key = require_auth

32
api/routes/auth.py Normal file
View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from api.auth import create_access_token, require_auth, verify_password
from db.database import get_db
from db.models import User
from schemas import LoginRequest, TokenResponse
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(req: LoginRequest, db: Session = Depends(get_db)):
user = (
db.query(User)
.filter(User.username == req.username, User.is_active == True) # noqa: E712
.first()
)
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültiger Benutzername oder Passwort",
)
token = create_access_token(
{"sub": user.id, "username": user.username, "role": user.role}
)
return TokenResponse(access_token=token, username=user.username, role=user.role)
@router.get("/me", response_model=dict)
def me(principal: dict = Depends(require_auth)):
return {"username": principal["username"], "role": principal["role"]}

82
api/routes/users.py Normal file
View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from api.auth import hash_password, require_admin, require_auth
from db.database import get_db
from db.models import User
from schemas import UserCreate, UserResponse, UserUpdate
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=list[UserResponse])
def list_users(
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
return db.query(User).order_by(User.created_at).all()
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
data: UserCreate,
db: Session = Depends(get_db),
_: dict = Depends(require_admin),
):
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=409, detail="Benutzername bereits vergeben")
user = User(
username=data.username,
password_hash=hash_password(data.password),
role=data.role,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
user_id: str,
data: UserUpdate,
db: Session = Depends(get_db),
principal: dict = Depends(require_auth),
):
# Admins dürfen alle ändern; Benutzer nur sich selbst (nur Passwort)
if principal["role"] != "admin" and principal.get("user_id") != user_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
if data.password is not None:
user.password_hash = hash_password(data.password)
if data.role is not None and principal["role"] == "admin":
user.role = data.role
if data.is_active is not None and principal["role"] == "admin":
user.is_active = data.is_active
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: str,
db: Session = Depends(get_db),
principal: dict = Depends(require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
# Letzten Admin nicht löschen
if user.role == "admin":
admin_count = db.query(User).filter(User.role == "admin", User.is_active == True).count() # noqa
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Letzten Admin kann nicht gelöscht werden")
db.delete(user)
db.commit()