feat: multi-user auth — per-user spaces, pbkdf2 passwords, session tokens, login page

This commit is contained in:
2026-04-01 08:39:16 +02:00
parent 94dd871031
commit 1466a1529f
7 changed files with 468 additions and 24 deletions
+59 -23
View File
@@ -1,6 +1,8 @@
import asyncio
import os
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, Header
from api.state import state, Status
from config import load as load_config
@@ -11,28 +13,54 @@ _ws_clients: list[WebSocket] = []
# ---------------------------------------------------------------------------
# Auth stub — replaced by auth.py in Task 13.
# current_user() returns a dict with at least {"username": str, "output_dir": str}.
# Until auth is wired up, every request runs as an anonymous guest using the
# global output path from config.
# Auth dependency
# ---------------------------------------------------------------------------
def _guest_user() -> dict:
cfg = load_config()
return {"username": "guest", "output_dir": cfg["output"]["path"]}
def current_user(user: dict = Depends(_guest_user)) -> dict:
async def current_user(authorization: Optional[str] = Header(None)) -> dict:
from auth import get_user_for_token
token = None
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
if not token:
raise HTTPException(status_code=401, detail="Nicht angemeldet")
user = get_user_for_token(token)
if not user:
raise HTTPException(status_code=401, detail="Ungültiger oder abgelaufener Token")
return user
# ---------------------------------------------------------------------------
# Endpoints
# Auth endpoints (no current_user dependency — these are unauthenticated)
# ---------------------------------------------------------------------------
@router.post("/login")
async def login(body: dict):
from auth import authenticate
username = body.get("username", "")
password = body.get("password", "")
if not username or not password:
raise HTTPException(status_code=400, detail="Benutzername und Passwort erforderlich")
token = authenticate(username, password)
if not token:
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
return {"token": token, "username": username}
@router.post("/logout")
async def logout(authorization: Optional[str] = Header(None)):
from auth import invalidate_token
if authorization and authorization.startswith("Bearer "):
invalidate_token(authorization[7:])
return {"ok": True}
# ---------------------------------------------------------------------------
# Protected endpoints
# ---------------------------------------------------------------------------
@router.get("/status")
async def get_status():
return {"status": state.status}
async def get_status(user: dict = Depends(current_user)):
return {"status": state.status, "username": user["username"]}
@router.post("/toggle")
@@ -46,7 +74,7 @@ async def toggle_recording(user: dict = Depends(current_user)):
state._recorder = AudioRecorder()
state._recorder.start()
state.recording_user = user["username"]
state._recording_output_dir = user["output_dir"]
state._recording_output_dir = os.path.join(user["output_dir"], user["username"])
state._recording_instructions = user.get("instructions", "")
await state.set_status(Status.RECORDING)
return {"action": "started"}
@@ -55,40 +83,48 @@ async def toggle_recording(user: dict = Depends(current_user)):
@router.post("/instructions")
async def set_instructions(body: dict, user: dict = Depends(current_user)):
# Instructions are per-user; stored on state only for the active recording.
# Full per-user persistence comes in Task 13.
user["instructions"] = body.get("instructions", "")
return {"ok": True}
@router.get("/transcripts")
async def get_transcripts(user: dict = Depends(current_user)):
return list_transcripts(user["output_dir"])
user_dir = os.path.join(user["output_dir"], user["username"])
return list_transcripts(user_dir)
@router.get("/config")
async def get_config():
async def get_config(user: dict = Depends(current_user)):
return load_config()
@router.put("/config")
async def put_config(body: dict):
async def put_config(body: dict, user: dict = Depends(current_user)):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Nur Administratoren können die Config ändern")
cfg = load_config()
cfg.update(body)
return cfg
@router.post("/open")
async def open_file(body: dict):
async def open_file(body: dict, user: dict = Depends(current_user)):
import subprocess
path = body.get("path", "")
if path and os.path.exists(path):
# Only allow opening files within the user's own output directory
user_dir = os.path.join(user["output_dir"], user["username"])
if path and os.path.exists(path) and os.path.abspath(path).startswith(os.path.abspath(user_dir)):
subprocess.Popen(["xdg-open", path])
return {"ok": True}
@router.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
async def websocket_endpoint(ws: WebSocket, token: str = ""):
from auth import get_user_for_token
user = get_user_for_token(token)
if not user:
await ws.close(code=4001)
return
await ws.accept()
_ws_clients.append(ws)
try: