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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user