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:
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
71
tui/screens/login_screen.py
Normal file
71
tui/screens/login_screen.py
Normal 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()
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user