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

151
app/db.py
View File

@@ -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()