diff --git a/.env.example b/.env.example index ee1cd68..6ff6b38 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,11 @@ DATABASE_URL=sqlite:///./mcm.db # ── TUI Web-Modus (textual serve) ────────────────────────── # Starten: .venv/bin/python -m textual serve --host 0.0.0.0 --port $WEB_PORT tui_standalone.py WEB_PORT=8001 + +# ── Benutzer-Authentifizierung ────────────────────────────────────────────── +# secret_key: mindestens 32 Zeichen, zufällig generieren (z.B. openssl rand -hex 32) +SECRET_KEY=change-this-secret-key-min-32-chars!! +TOKEN_EXPIRE_HOURS=24 +# Standard-Admin-Benutzer (wird beim ersten Start angelegt) +DEFAULT_ADMIN_USER=admin +DEFAULT_ADMIN_PASSWORD=admin diff --git a/api/app.py b/api/app.py index c0374d8..44a2b3d 100644 --- a/api/app.py +++ b/api/app.py @@ -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") diff --git a/api/auth.py b/api/auth.py index 6e2876b..ab77c72 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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 diff --git a/api/routes/auth.py b/api/routes/auth.py new file mode 100644 index 0000000..774408c --- /dev/null +++ b/api/routes/auth.py @@ -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"]} diff --git a/api/routes/users.py b/api/routes/users.py new file mode 100644 index 0000000..df3f952 --- /dev/null +++ b/api/routes/users.py @@ -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() diff --git a/config.py b/config.py index a71cf10..ef31d24 100644 --- a/config.py +++ b/config.py @@ -29,6 +29,12 @@ class Settings(BaseSettings): # TUI Web-Modus web_port: int = 8001 + # Benutzer-Authentifizierung + secret_key: str = "change-this-secret-key-min-32-chars!!" + token_expire_hours: int = 24 + default_admin_user: str = "admin" + default_admin_password: str = "admin" + @property def telegram_enabled(self) -> bool: return bool(self.telegram_token) diff --git a/db/database.py b/db/database.py index 7d8f1b9..04dd11a 100644 --- a/db/database.py +++ b/db/database.py @@ -36,3 +36,27 @@ def get_db(): def init_db() -> None: from db import models # noqa: F401 – Modelle müssen importiert sein Base.metadata.create_all(bind=engine) + _create_default_admin() + + +def _create_default_admin() -> None: + """Legt beim ersten Start einen Admin-Benutzer an, falls keine User existieren.""" + import bcrypt + from db.models import User + + db = SessionLocal() + try: + if not db.query(User).first(): + pw_hash = bcrypt.hashpw( + settings.default_admin_password.encode(), bcrypt.gensalt() + ).decode() + admin = User( + username=settings.default_admin_user, + password_hash=pw_hash, + role="admin", + is_active=True, + ) + db.add(admin) + db.commit() + finally: + db.close() diff --git a/db/models.py b/db/models.py index 513281e..124043a 100644 --- a/db/models.py +++ b/db/models.py @@ -23,6 +23,23 @@ def _uuid() -> str: return str(uuid.uuid4()) +# ── User ─────────────────────────────────────────────────────────────────────── +class User(Base): + __tablename__ = "users" + + id = Column(String(36), primary_key=True, default=_uuid) + username = Column(String(64), nullable=False, unique=True, index=True) + password_hash = Column(String(255), nullable=False) + role = Column( + Enum("admin", "user", name="user_role"), + nullable=False, + default="user", + ) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=func.now(), nullable=False) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + # ── Many-to-many: Conversation ↔ Contact ────────────────────────────────────── conversation_participants = Table( "conversation_participants", diff --git a/requirements.txt b/requirements.txt index 5e004d1..a40c190 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,5 @@ apscheduler>=3.10.4 textual>=0.75.0 python-dotenv>=1.0.0 httpx>=0.27.0 +python-jose[cryptography]>=3.3.0 +bcrypt>=4.0.0 diff --git a/schemas.py b/schemas.py index 1fc3265..6638a3f 100644 --- a/schemas.py +++ b/schemas.py @@ -126,3 +126,43 @@ class SystemStatusResponse(BaseModel): channels: list[ChannelStatusResponse] database: bool timestamp: datetime + +# ── Auth / User ───────────────────────────────────────────────────────────── + +class UserRole(str, Enum): + admin = "admin" + user = "user" + + +class LoginRequest(BaseModel): + username: str = Field(..., min_length=1, max_length=64) + password: str = Field(..., min_length=1) + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + username: str + role: UserRole + + +class UserCreate(BaseModel): + username: str = Field(..., min_length=1, max_length=64) + password: str = Field(..., min_length=6) + role: UserRole = UserRole.user + + +class UserUpdate(BaseModel): + password: Optional[str] = Field(None, min_length=6) + role: Optional[UserRole] = None + is_active: Optional[bool] = None + + +class UserResponse(BaseModel): + model_config = {"from_attributes": True} + + id: str + username: str + role: UserRole + is_active: bool + created_at: datetime diff --git a/tui/api_client.py b/tui/api_client.py index 9fdcf7f..d547a31 100644 --- a/tui/api_client.py +++ b/tui/api_client.py @@ -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 ───────────────────────────────────────────────────────── diff --git a/tui/app.py b/tui/app.py index df20a94..97b9cdb 100644 --- a/tui/app.py +++ b/tui/app.py @@ -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") diff --git a/tui/screens/login_screen.py b/tui/screens/login_screen.py new file mode 100644 index 0000000..fb5ac9c --- /dev/null +++ b/tui/screens/login_screen.py @@ -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() diff --git a/tui/styles.tcss b/tui/styles.tcss index 6cafce7..53934d5 100644 --- a/tui/styles.tcss +++ b/tui/styles.tcss @@ -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; +}