feat: User-Verwaltung mit Login, Sessions und Owner-Filter
Backend: - User-Modell (sqlmodel) mit bcrypt-Passwort-Hash und is_admin-Flag - Job.owner_id (FK auf user.id), per Migration nachgerüstet (SQLite ALTER) - Starlette SessionMiddleware mit signiertem Cookie (SESSION_SECRET) - /api/auth: login / logout / me / needs-setup / setup (Initial-Admin) - /api/admin: User-CRUD + Passwort-Reset (Admin-only); letzter Admin / eigener Account vor Löschung geschützt - /api/jobs: Owner-Filter (Admin sieht alle inkl. owner_username), 404 statt 403 bei fremden Jobs (kein Existenz-Leak) Frontend: - Auth-Overlay mit Login bzw. Erst-Setup-Modus (auto-Detection) - Header zeigt User + Logout, Admin-Badge bei Admin-Konten - Admin-Karte: User anlegen, Passwort zurücksetzen, Löschen (mit Confirm) - 401 → Login-Overlay (auch bei XHR-Upload) Deploy: - install.sh generiert SESSION_SECRET einmalig per python-secrets - .env wird auf chmod 600 gesetzt - Erstaufruf zeigt automatisch das Setup-Formular; bestehende Jobs werden dem ersten Admin zugeordnet
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""Auth-Helfer: Passwort-Hashing, current_user-Dependency, Admin-Check."""
|
||||
from __future__ import annotations
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.db import get_session
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def current_user(request: Request, session: Session = Depends(get_session)) -> User:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet")
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sitzung ungültig")
|
||||
return user
|
||||
|
||||
|
||||
def admin_required(user: User = Depends(current_user)) -> User:
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich")
|
||||
return user
|
||||
|
||||
|
||||
def get_user_by_username(session: Session, username: str) -> User | None:
|
||||
return session.exec(select(User).where(User.username == username)).first()
|
||||
|
||||
|
||||
def has_any_user(session: Session) -> bool:
|
||||
return session.exec(select(User).limit(1)).first() is not None
|
||||
@@ -19,6 +19,11 @@ class Settings(BaseSettings):
|
||||
max_upload_mb: int = 500
|
||||
allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac"
|
||||
|
||||
# Wird beim Deploy automatisch generiert (siehe install.sh).
|
||||
session_secret: str = "CHANGE-ME-SET-IN-ENV"
|
||||
session_cookie_name: str = "voice_agent_session"
|
||||
session_max_age: int = 60 * 60 * 24 * 14 # 14 Tage
|
||||
|
||||
@property
|
||||
def allowed_ext_set(self) -> set[str]:
|
||||
return {e.strip().lower().lstrip(".") for e in self.allowed_exts.split(",") if e.strip()}
|
||||
|
||||
@@ -6,6 +6,7 @@ engine = create_engine(settings.db_url, connect_args={"check_same_thread": False
|
||||
|
||||
def init_db() -> None:
|
||||
from app.models.job import Job # noqa: F401
|
||||
from app.models.user import User # noqa: F401
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Leichte In-Place-Migrationen für SQLite (kein Alembic).
|
||||
|
||||
Wird beim App-Start nach create_all() ausgeführt. Idempotent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.db import engine
|
||||
|
||||
log = logging.getLogger("voice-agent.migrate")
|
||||
|
||||
|
||||
def _existing_columns(conn, table: str) -> set[str]:
|
||||
rows = conn.execute(text(f"PRAGMA table_info({table})")).all()
|
||||
return {r[1] for r in rows} # row[1] = column name
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
with engine.begin() as conn:
|
||||
# 1) Job.owner_id Spalte nachrüsten, falls die Tabelle aus alter Version kommt.
|
||||
cols = _existing_columns(conn, "job")
|
||||
if "owner_id" not in cols:
|
||||
log.info("Migration: füge job.owner_id hinzu")
|
||||
conn.execute(text("ALTER TABLE job ADD COLUMN owner_id INTEGER"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_job_owner_id ON job(owner_id)"))
|
||||
|
||||
|
||||
def assign_orphan_jobs_to(user_id: int) -> int:
|
||||
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""
|
||||
with Session(engine) as session:
|
||||
result = session.execute(
|
||||
text("UPDATE job SET owner_id = :uid WHERE owner_id IS NULL"),
|
||||
{"uid": user_id},
|
||||
)
|
||||
session.commit()
|
||||
n = result.rowcount or 0
|
||||
if n:
|
||||
log.info("Migration: %d Bestand-Jobs an user_id=%d zugeordnet", n, user_id)
|
||||
return n
|
||||
Reference in New Issue
Block a user