From efbee1540c0ac49b03c63b064c4383b289a715c6 Mon Sep 17 00:00:00 2001 From: Malin Date: Thu, 14 May 2026 13:41:08 +0200 Subject: [PATCH] feat: add login / user management to BeautyLeads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/beauty_main.py | 125 ++++++++++++++++++++++++++++- app/db.py | 151 +++++++++++++++++++++++++++++++++++ app/static/beauty/index.html | 118 ++++++++++++++++++++++++++- app/static/beauty/login.html | 130 ++++++++++++++++++++++++++++++ 4 files changed, 519 insertions(+), 5 deletions(-) create mode 100644 app/static/beauty/login.html diff --git a/app/beauty_main.py b/app/beauty_main.py index 7f57474..7ddce33 100644 --- a/app/beauty_main.py +++ b/app/beauty_main.py @@ -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") diff --git a/app/db.py b/app/db.py index 7854ef3..22000f8 100644 --- a/app/db.py +++ b/app/db.py @@ -3,6 +3,9 @@ import asyncio import logging import aiosqlite import duckdb +import hashlib +import secrets +import datetime from pathlib import Path logger = logging.getLogger(__name__) @@ -631,3 +634,151 @@ async def get_queue_status(): rate = int(os.getenv("CONCURRENCY_LIMIT", "50")) eta_seconds = (pending + running) / max(rate / 10, 1) if (pending + running) > 0 else None return {"total": total, "pending": pending, "running": running, "done": done, "failed": failed, "eta_seconds": eta_seconds} + + +# ── Auth (beauty users & sessions) ─────────────────────────────────────────── + +def _hash_password(password: str, salt: str) -> str: + return hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt.encode("utf-8"), 200_000 + ).hex() + + +async def init_beauty_auth(): + """Create beauty_users / beauty_sessions tables; seed default Admin.""" + async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS beauty_users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ) + """) + await db.execute(""" + CREATE TABLE IF NOT EXISTS beauty_sessions ( + token TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ) + """) + await db.execute( + "DELETE FROM beauty_sessions WHERE expires_at <= datetime('now')" + ) + await db.commit() + + cur = await db.execute( + "SELECT 1 FROM beauty_users WHERE username=?", ("Admin",) + ) + if not await cur.fetchone(): + salt = secrets.token_hex(16) + pw = _hash_password("Asdpsd9012!HAP", salt) + await db.execute( + "INSERT INTO beauty_users (username, password_hash, salt, is_admin)" + " VALUES (?,?,?,1)", + ("Admin", pw, salt), + ) + await db.commit() + logger.info("Auth: default Admin user created") + + +async def verify_beauty_user(username: str, password: str): + async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + "SELECT username, password_hash, salt, is_admin" + " FROM beauty_users WHERE username=?", + (username,), + ) + row = await cur.fetchone() + if not row: + return None + if not secrets.compare_digest( + _hash_password(password, row["salt"]), row["password_hash"] + ): + return None + return {"username": row["username"], "is_admin": bool(row["is_admin"])} + + +async def create_beauty_session(username: str) -> str: + token = secrets.token_urlsafe(32) + expires = ( + datetime.datetime.utcnow() + datetime.timedelta(days=30) + ).strftime("%Y-%m-%d %H:%M:%S") + async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db: + await db.execute( + "INSERT INTO beauty_sessions (token, username, expires_at)" + " VALUES (?,?,?)", + (token, username, expires), + ) + await db.commit() + return token + + +async def validate_beauty_session(token: str): + async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + """SELECT s.username, u.is_admin + FROM beauty_sessions s + JOIN beauty_users u ON s.username = u.username + WHERE s.token = ? AND s.expires_at > datetime('now')""", + (token,), + ) + row = await cur.fetchone() + if not row: + return None + return {"username": row["username"], "is_admin": bool(row["is_admin"])} + + +async def delete_beauty_session(token: str): + async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db: + await db.execute("DELETE FROM beauty_sessions WHERE token=?", (token,)) + await db.commit() + + +async def change_beauty_password(username: str, new_password: str): + salt = secrets.token_hex(16) + pw = _hash_password(new_password, salt) + async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db: + await db.execute( + "UPDATE beauty_users SET password_hash=?, salt=? WHERE username=?", + (pw, salt, username), + ) + await db.execute( + "DELETE FROM beauty_sessions WHERE username=?", (username,) + ) + await db.commit() + + +async def list_beauty_users() -> list: + async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + "SELECT username, is_admin, created_at" + " FROM beauty_users ORDER BY created_at" + ) + return [dict(r) for r in await cur.fetchall()] + + +async def add_beauty_user(username: str, password: str, is_admin: bool = False): + salt = secrets.token_hex(16) + pw = _hash_password(password, salt) + async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db: + await db.execute( + "INSERT INTO beauty_users (username, password_hash, salt, is_admin)" + " VALUES (?,?,?,?)", + (username, pw, salt, int(is_admin)), + ) + await db.commit() + + +async def delete_beauty_user(username: str): + async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db: + await db.execute( + "DELETE FROM beauty_sessions WHERE username=?", (username,) + ) + await db.execute("DELETE FROM beauty_users WHERE username=?", (username,)) + await db.commit() diff --git a/app/static/beauty/index.html b/app/static/beauty/index.html index fa57d3f..8094f08 100644 --- a/app/static/beauty/index.html +++ b/app/static/beauty/index.html @@ -105,6 +105,59 @@ textarea{width:100%;resize:vertical;font-family:monospace;font-size:12px} + +
+ + + +
+ + + +
+
+ +

Account Settings

+ + +
+

Change Password

+ + + + +
+ + + +
@@ -628,14 +681,27 @@ function app() { valTld: '', valRescan: false, toasts: [], prescreening: false, validating: false, reassessing: false, - _loadGen: 0, // incremented on every loadDomains() call; stale responses are discarded - assessPopup: null, // parsed _beauty object shown in overlay; null = hidden + _loadGen: 0, + assessPopup: null, exportQuality: '', exportCountry: '', f: {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics', site_type:'ecommerce', country:'', assessed:'', alpha_only:false, no_sld:false, limit:'100', page:1}, pf: {quality:'', country:'', limit:'100', page:1}, + // Auth / settings + authUser: null, + settingsModal: false, + chPw: {current:'', next:'', confirm:''}, + newUser: {username:'', password:'', is_admin:false}, + userList: [], async init() { + // Verify session — redirect to login if not authenticated + try { + const me = await fetch('/api/auth/me'); + if (!me.ok) { window.location.replace('/login.html'); return; } + this.authUser = await me.json(); + } catch(e) { window.location.replace('/login.html'); return; } + await Promise.all([this.loadStats(), this.loadAiStatus(), this.loadValStatus()]); await this.loadDomains(); setInterval(async () => { @@ -645,6 +711,54 @@ function app() { }, 4000); }, + async logout() { + await fetch('/api/auth/logout', {method:'POST'}).catch(()=>{}); + window.location.replace('/login.html'); + }, + + async changePassword() { + if (!this.chPw.next) return this.notify('Enter a new password', 'error'); + if (this.chPw.next !== this.chPw.confirm) return this.notify('Passwords do not match', 'error'); + const r = await fetch('/api/auth/change-password', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({current_password: this.chPw.current, new_password: this.chPw.next}), + }); + if (r.ok) { + this.notify('Password changed — please sign in again', 'info'); + setTimeout(() => window.location.replace('/login.html'), 1500); + } else { + const d = await r.json().catch(()=>({})); + this.notify(d.detail || 'Failed to change password', 'error'); + } + }, + + async loadUserList() { + try { this.userList = await fetch('/api/auth/users').then(r=>r.json()); } catch(e){} + }, + + async addUser() { + if (!this.newUser.username || !this.newUser.password) return this.notify('Username and password required', 'error'); + const r = await fetch('/api/auth/users', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify(this.newUser), + }); + if (r.ok) { + this.notify('User added', 'info'); + this.newUser = {username:'', password:'', is_admin:false}; + await this.loadUserList(); + } else { + const d = await r.json().catch(()=>({})); + this.notify(d.detail || 'Failed to add user', 'error'); + } + }, + + async deleteUser(username) { + if (!confirm(`Delete user "${username}"?`)) return; + const r = await fetch(`/api/auth/users/${encodeURIComponent(username)}`, {method:'DELETE'}); + if (r.ok) { this.notify('User deleted', 'info'); await this.loadUserList(); } + else { this.notify('Failed to delete user', 'error'); } + }, + async loadStats() { try { const d = await fetch('/api/stats').then(r=>r.json()); diff --git a/app/static/beauty/login.html b/app/static/beauty/login.html new file mode 100644 index 0000000..e04b704 --- /dev/null +++ b/app/static/beauty/login.html @@ -0,0 +1,130 @@ + + + + + +BeautyLeads — Sign In + + + +
+ + +
+

Sign In

+
+ +
+
+ + +
+
+ + +
+ +
+
+
+ + + +