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,86 @@
|
||||
"""Admin-Endpoints: User-CRUD. Nur für Admins."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.auth import admin_required, get_user_by_username, hash_password
|
||||
from app.core.db import get_session
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(admin_required)])
|
||||
log = logging.getLogger("voice-agent.admin")
|
||||
|
||||
|
||||
class CreateUserIn(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
|
||||
password: str = Field(min_length=8, max_length=200)
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
password: str = Field(min_length=8, max_length=200)
|
||||
|
||||
|
||||
def _user_dict(u: User) -> dict:
|
||||
return {
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": u.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(session: Session = Depends(get_session)):
|
||||
users = session.exec(select(User).order_by(User.created_at)).all()
|
||||
return [_user_dict(u) for u in users]
|
||||
|
||||
|
||||
@router.post("/users", status_code=status.HTTP_201_CREATED)
|
||||
def create_user(payload: CreateUserIn, session: Session = Depends(get_session)):
|
||||
if get_user_by_username(session, payload.username.strip()):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"Benutzer '{payload.username}' existiert bereits")
|
||||
user = User(
|
||||
username=payload.username.strip(),
|
||||
password_hash=hash_password(payload.password),
|
||||
is_admin=payload.is_admin,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
log.info("User angelegt: %s admin=%s", user.username, user.is_admin)
|
||||
return _user_dict(user)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(user_id: int, session: Session = Depends(get_session), me: User = Depends(admin_required)):
|
||||
if user_id == me.id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Eigenen Account kann man nicht löschen")
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||
# Sicherheit: mind. ein Admin muss bleiben
|
||||
if user.is_admin:
|
||||
admins = session.exec(select(User).where(User.is_admin == True)).all() # noqa: E712
|
||||
if len(admins) <= 1:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Letzten Admin kann man nicht löschen")
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
log.info("User gelöscht: id=%d username=%s", user_id, user.username)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
def reset_password(user_id: int, payload: ResetPasswordIn, session: Session = Depends(get_session)):
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||
user.password_hash = hash_password(payload.password)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
log.info("Passwort zurückgesetzt: user=%s", user.username)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Auth-Endpoints: Login, Logout, Me, Erst-Setup (Initial-Admin)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core import migrate
|
||||
from app.core.auth import (
|
||||
current_user,
|
||||
get_user_by_username,
|
||||
has_any_user,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.core.db import get_session
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
log = logging.getLogger("voice-agent.auth")
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class SetupIn(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
|
||||
password: str = Field(min_length=8, max_length=200)
|
||||
|
||||
|
||||
def _user_dict(u: User) -> dict:
|
||||
return {"id": u.id, "username": u.username, "is_admin": u.is_admin}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(user: User = Depends(current_user)):
|
||||
return _user_dict(user)
|
||||
|
||||
|
||||
@router.get("/needs-setup")
|
||||
def needs_setup(session: Session = Depends(get_session)):
|
||||
"""Vor Login prüfen ob noch kein User existiert (erstes Deployment)."""
|
||||
return {"needs_setup": not has_any_user(session)}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(payload: LoginIn, request: Request, session: Session = Depends(get_session)):
|
||||
user = get_user_by_username(session, payload.username.strip())
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
|
||||
request.session["user_id"] = user.id
|
||||
log.info("Login: user=%s id=%d admin=%s", user.username, user.id, user.is_admin)
|
||||
return _user_dict(user)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request):
|
||||
request.session.clear()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/setup")
|
||||
def setup(payload: SetupIn, request: Request, session: Session = Depends(get_session)):
|
||||
"""Erstellt den ersten Admin und ordnet alle Bestand-Jobs ihm zu.
|
||||
Nur möglich, solange kein User existiert."""
|
||||
if has_any_user(session):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Setup bereits abgeschlossen")
|
||||
user = User(
|
||||
username=payload.username.strip(),
|
||||
password_hash=hash_password(payload.password),
|
||||
is_admin=True,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
log.info("Initial-Admin angelegt: %s (id=%d)", user.username, user.id)
|
||||
n = migrate.assign_orphan_jobs_to(user.id)
|
||||
request.session["user_id"] = user.id
|
||||
return {**_user_dict(user), "migrated_jobs": n}
|
||||
@@ -1,5 +1,4 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
@@ -7,9 +6,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPExcepti
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.auth import current_user
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_session
|
||||
from app.models.job import Job, JobStatus
|
||||
from app.models.job import Job, JobStatus # noqa: F401
|
||||
from app.models.user import User
|
||||
from app.services.pipeline import run_pipeline
|
||||
|
||||
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||
@@ -17,12 +18,20 @@ router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
||||
MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024
|
||||
|
||||
|
||||
def _require_access(job: Job | None, user: User) -> Job:
|
||||
"""404 wenn nicht gefunden oder nicht zugänglich (kein Hinweis darauf, dass es existiert)."""
|
||||
if not job or (job.owner_id != user.id and not user.is_admin):
|
||||
raise HTTPException(404, "Job nicht gefunden")
|
||||
return job
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_job(
|
||||
background: BackgroundTasks,
|
||||
audio: UploadFile = File(...),
|
||||
title: str = Form(""),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(current_user),
|
||||
):
|
||||
if not audio.filename:
|
||||
raise HTTPException(400, "Dateiname fehlt")
|
||||
@@ -43,34 +52,45 @@ async def create_job(
|
||||
raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)")
|
||||
await f.write(chunk)
|
||||
|
||||
job = Job(filename=safe, original_name=audio.filename, title=title.strip())
|
||||
job = Job(
|
||||
owner_id=user.id,
|
||||
filename=safe,
|
||||
original_name=audio.filename,
|
||||
title=title.strip(),
|
||||
)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
|
||||
background.add_task(run_pipeline, job.id)
|
||||
return _job_dict(job)
|
||||
return _job_dict(job, user)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_jobs(session: Session = Depends(get_session)):
|
||||
jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all()
|
||||
return [_job_dict(j) for j in jobs]
|
||||
def list_jobs(session: Session = Depends(get_session), user: User = Depends(current_user)):
|
||||
stmt = select(Job).order_by(Job.created_at.desc())
|
||||
if not user.is_admin:
|
||||
stmt = stmt.where(Job.owner_id == user.id)
|
||||
jobs = session.exec(stmt).all()
|
||||
# Admin sieht zusätzlich Owner-Username — kleine Map für effiziente Anzeige.
|
||||
owner_names: dict[int, str] = {}
|
||||
if user.is_admin:
|
||||
owner_ids = {j.owner_id for j in jobs if j.owner_id is not None}
|
||||
if owner_ids:
|
||||
owners = session.exec(select(User).where(User.id.in_(owner_ids))).all() # type: ignore[attr-defined]
|
||||
owner_names = {u.id: u.username for u in owners}
|
||||
return [_job_dict(j, user, owner_names.get(j.owner_id) if user.is_admin else None) for j in jobs]
|
||||
|
||||
|
||||
@router.get("/{job_id}")
|
||||
def get_job(job_id: int, session: Session = Depends(get_session)):
|
||||
job = session.get(Job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job nicht gefunden")
|
||||
return _job_dict(job)
|
||||
def get_job(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)):
|
||||
job = _require_access(session.get(Job, job_id), user)
|
||||
return _job_dict(job, user)
|
||||
|
||||
|
||||
@router.get("/{job_id}/download/{kind}")
|
||||
def download(job_id: int, kind: str, session: Session = Depends(get_session)):
|
||||
job = session.get(Job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job nicht gefunden")
|
||||
def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)):
|
||||
job = _require_access(session.get(Job, job_id), user)
|
||||
mapping = {
|
||||
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
|
||||
"transcript": (job.transcript_path, "application/json", "transcript.json"),
|
||||
@@ -86,8 +106,8 @@ def download(job_id: int, kind: str, session: Session = Depends(get_session)):
|
||||
return FileResponse(path, media_type=media, filename=f"{base}-{name}")
|
||||
|
||||
|
||||
def _job_dict(j: Job) -> dict:
|
||||
return {
|
||||
def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
|
||||
d = {
|
||||
"id": j.id,
|
||||
"title": j.title,
|
||||
"original_name": j.original_name,
|
||||
@@ -103,3 +123,7 @@ def _job_dict(j: Job) -> dict:
|
||||
"docx": bool(j.docx_path),
|
||||
},
|
||||
}
|
||||
if user.is_admin:
|
||||
d["owner_id"] = j.owner_id
|
||||
d["owner_username"] = owner_username or ""
|
||||
return d
|
||||
|
||||
Reference in New Issue
Block a user