feat: multi-user auth — per-user spaces, pbkdf2 passwords, session tokens, login page
This commit is contained in:
+59
-23
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user