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

@@ -22,7 +22,38 @@ class MCMApiClient:
def __init__(self) -> None:
self._base = f"http://127.0.0.1:{settings.port}/api/v1"
self._headers = {"Authorization": f"Bearer {settings.api_key}"}
self._token: str | None = None
@property
def _headers(self) -> dict[str, str]:
if self._token:
return {"Authorization": f"Bearer {self._token}"}
return {}
# ── Authentifizierung ──────────────────────────────────────────────────────
async def login(self, username: str, password: str) -> bool:
"""Meldet den Benutzer an und speichert den JWT-Token. Gibt True bei Erfolg zurück."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.post(
self._base + "/auth/login",
json={"username": username, "password": password},
)
if resp.status_code == 200:
self._token = resp.json()["access_token"]
return True
return False
except Exception as exc:
logger.warning("Login fehlgeschlagen: %s", exc)
return False
def logout(self) -> None:
self._token = None
@property
def is_authenticated(self) -> bool:
return self._token is not None
# ── Konversationen ─────────────────────────────────────────────────────────

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from textual.app import App
from tui.api_client import MCMApiClient
from tui.screens.login_screen import LoginScreen
from tui.screens.main_screen import MainScreen
@@ -11,11 +12,11 @@ class MCMApp(App):
TITLE = "MCM MultiCustomerMessenger"
CSS_PATH = "styles.tcss"
SCREENS = {"main": MainScreen}
SCREENS = {"login": LoginScreen, "main": MainScreen}
def __init__(self) -> None:
super().__init__()
self._api_client = MCMApiClient()
def on_mount(self) -> None:
self.push_screen("main")
self.push_screen("login")

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Input, Label, Static
class LoginScreen(Screen):
"""Anmeldebildschirm erscheint beim Start der TUI."""
BINDINGS = [Binding("ctrl+c", "quit_app", "Beenden")]
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Vertical(id="login-container"):
yield Static("Anmeldung", id="login-title")
yield Label("Benutzername:")
yield Input(placeholder="admin", id="username-input")
yield Label("Passwort:")
yield Input(placeholder="", password=True, id="password-input")
yield Static("", id="login-error")
yield Button("Anmelden", variant="primary", id="login-btn")
yield Footer()
def on_mount(self) -> None:
self.query_one("#username-input", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "username-input":
self.query_one("#password-input", Input).focus()
elif event.input.id == "password-input":
self._do_login()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "login-btn":
self._do_login()
def _do_login(self) -> None:
import asyncio
asyncio.create_task(self._login_async())
async def _login_async(self) -> None:
username = self.query_one("#username-input", Input).value.strip()
password = self.query_one("#password-input", Input).value
if not username or not password:
self.query_one("#login-error", Static).update("Benutzername und Passwort eingeben.")
return
btn = self.query_one("#login-btn", Button)
btn.disabled = True
self.query_one("#login-error", Static).update("")
try:
ok = await self.app._api_client.login(username, password) # type: ignore[attr-defined]
except Exception:
ok = False
btn.disabled = False
if ok:
from tui.screens.main_screen import MainScreen
self.app.switch_screen(MainScreen())
else:
self.query_one("#login-error", Static).update("Ungültige Zugangsdaten oder Server nicht erreichbar.")
self.query_one("#password-input", Input).clear()
self.query_one("#password-input", Input).focus()
def action_quit_app(self) -> None:
self.app.exit()

View File

@@ -126,3 +126,40 @@ Screen {
#compose-buttons Button {
margin-left: 1;
}
/* ── Login-Screen ──────────────────────────────────────────────────────── */
#login-container {
align: center middle;
width: 100%;
height: 1fr;
}
#login-title {
text-style: bold;
text-align: center;
margin-bottom: 1;
color: $accent;
width: 40;
}
#login-container Label {
width: 40;
margin-top: 1;
}
#login-container Input {
width: 40;
}
#login-btn {
width: 40;
margin-top: 1;
}
#login-error {
width: 40;
color: $error;
text-align: center;
height: 1;
}