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:
151
app/db.py
151
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()
|
||||
|
||||
Reference in New Issue
Block a user