feat: add login / user management to BeautyLeads

- db.py: beauty_users + beauty_sessions tables; PBKDF2-SHA256 password
  hashing; init_beauty_auth seeds default Admin user; full CRUD helpers
- beauty_main.py: AuthMiddleware blocks all routes except /api/auth/* and
  /login.html; auth routes: login (sets HttpOnly 30-day cookie), logout,
  /me, change-password (invalidates sessions), list/add/delete users
- login.html: standalone dark-themed sign-in page matching app palette
- index.html: auth check in init() → redirects to login.html if 401;
  header shows username + settings gear + sign-out; settings modal with
  change-password form and admin user management (add/delete users)

Default credentials: Admin / Asdpsd9012!HAP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:41:08 +02:00
parent 76cb0dd41e
commit efbee1540c
4 changed files with 519 additions and 5 deletions

View File

@@ -12,9 +12,10 @@ from contextlib import asynccontextmanager
import aiosqlite
from typing import Optional
from fastapi import FastAPI, Query
from fastapi.responses import StreamingResponse, JSONResponse
from fastapi import FastAPI, Query, Request, Response
from fastapi.responses import StreamingResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from dotenv import load_dotenv
load_dotenv()
@@ -24,6 +25,9 @@ from app.db import (
build_duckdb_index, index_status,
queue_beauty, requeue_beauty, get_beauty_queue_status, save_beauty_assessment, get_beauty_leads,
save_prescreen_results,
init_beauty_auth, verify_beauty_user, create_beauty_session, validate_beauty_session,
delete_beauty_session, change_beauty_password, list_beauty_users, add_beauty_user,
delete_beauty_user,
)
from app.validator import start_validator, stop_validator, get_validator_status
@@ -106,7 +110,7 @@ def _start_beauty_worker():
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
# Detect existing DuckDB index (built by main service); don't rebuild
await init_beauty_auth()
asyncio.create_task(build_duckdb_index())
_start_beauty_worker()
logger.info("BeautyLeads ready on port 7788")
@@ -116,6 +120,121 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="BeautyLeads", lifespan=lifespan)
# ── Auth middleware ───────────────────────────────────────────────────────────
class AuthMiddleware(BaseHTTPMiddleware):
# Paths that don't require a session
_EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = {"/login.html", "/favicon.ico"}
async def dispatch(self, request: Request, call_next):
path = request.url.path
if path in self._EXEMPT_EXACT or any(
path.startswith(p) for p in self._EXEMPT_PREFIXES
):
return await call_next(request)
token = request.cookies.get("beauty_session")
if token:
user = await validate_beauty_session(token)
if user:
request.state.user = user
return await call_next(request)
# Unauthenticated — API calls get 401, browser requests get redirect
if path.startswith("/api/"):
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return RedirectResponse(url="/login.html", status_code=302)
app.add_middleware(AuthMiddleware)
# ── Auth routes ───────────────────────────────────────────────────────────────
@app.post("/api/auth/login")
async def auth_login(body: dict, response: Response):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
user = await verify_beauty_user(username, password)
if not user:
return JSONResponse({"detail": "Invalid credentials"}, status_code=401)
token = await create_beauty_session(user["username"])
response.set_cookie(
"beauty_session", token,
max_age=30 * 24 * 3600,
httponly=True,
samesite="lax",
path="/",
)
return {"username": user["username"], "is_admin": user["is_admin"]}
@app.post("/api/auth/logout")
async def auth_logout(request: Request, response: Response):
token = request.cookies.get("beauty_session")
if token:
await delete_beauty_session(token)
response.delete_cookie("beauty_session", path="/")
return {"ok": True}
@app.get("/api/auth/me")
async def auth_me(request: Request):
return request.state.user # middleware already validated; state always set here
@app.post("/api/auth/change-password")
async def auth_change_password(request: Request, body: dict, response: Response):
user = request.state.user
current = body.get("current_password") or ""
new_pw = body.get("new_password") or ""
if not new_pw or len(new_pw) < 8:
return JSONResponse({"detail": "Password must be at least 8 characters"}, status_code=400)
if not await verify_beauty_user(user["username"], current):
return JSONResponse({"detail": "Current password is incorrect"}, status_code=400)
await change_beauty_password(user["username"], new_pw)
# Clear cookie so the user is re-directed to login after password change
response.delete_cookie("beauty_session", path="/")
return {"ok": True}
@app.get("/api/auth/users")
async def auth_list_users(request: Request):
if not request.state.user.get("is_admin"):
return JSONResponse({"detail": "Admin only"}, status_code=403)
return await list_beauty_users()
@app.post("/api/auth/users")
async def auth_add_user(request: Request, body: dict):
if not request.state.user.get("is_admin"):
return JSONResponse({"detail": "Admin only"}, status_code=403)
username = (body.get("username") or "").strip()
password = body.get("password") or ""
is_admin = bool(body.get("is_admin", False))
if not username or not password:
return JSONResponse({"detail": "username and password required"}, status_code=400)
if len(password) < 8:
return JSONResponse({"detail": "Password must be at least 8 characters"}, status_code=400)
try:
await add_beauty_user(username, password, is_admin)
except Exception as e:
return JSONResponse({"detail": f"Could not create user: {e}"}, status_code=400)
return {"ok": True}
@app.delete("/api/auth/users/{username}")
async def auth_delete_user(username: str, request: Request):
if not request.state.user.get("is_admin"):
return JSONResponse({"detail": "Admin only"}, status_code=403)
if username == request.state.user["username"]:
return JSONResponse({"detail": "Cannot delete yourself"}, status_code=400)
await delete_beauty_user(username)
return {"ok": True}
# ── Shared read endpoints (same DB) ──────────────────────────────────────────
@app.get("/api/stats")