diff --git a/admin.html b/admin.html index 34713d4..76a7da4 100644 --- a/admin.html +++ b/admin.html @@ -282,8 +282,9 @@
Введи пароль администратора
- +Логин и пароль администратора
+ + @@ -301,6 +302,7 @@ @@ -541,6 +543,20 @@ +Отдельные логины для входа в панель — на случай если админов несколько.
+| Логин | Создан | + |
|---|
Бэкап — это база (юзеры, подписки, ноды, платежи) и .env одним файлом. Держи копии где-то отдельно от сервера.
Админ добавлен
'; + loadAdmins(); + } catch (e) { + result.innerHTML = 'Не получилось: ' + esc(e.message) + '
'; + } +} + +async function deleteAdmin(id) { + if (!confirm("Удалить этого админа?")) return; + try { + await api(`/admin/api/admins/${id}`, { method: "DELETE" }); + loadAdmins(); + } catch (e) { + document.getElementById("admins-result").innerHTML = 'Не получилось: ' + esc(e.message) + '
'; + } +} + function downloadBackup() { window.location.href = "/admin/api/backup"; } diff --git a/api.py b/api.py index 09094d9..c84243b 100644 --- a/api.py +++ b/api.py @@ -18,7 +18,7 @@ import payments import xray_manager from config import ( PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, - ADMIN_PANEL_PASSWORD, BOT_USERNAME, BOT_TOKEN, BASE_DIR, + BOT_USERNAME, BOT_TOKEN, BASE_DIR, HWID_LIMIT_ENABLED, HWID_FALLBACK_LIMIT, ) @@ -437,9 +437,12 @@ async def register_node(token: str, request: Request): @app.post("/admin/api/login") def admin_login(response: Response, body: dict = Body(...)): - if body.get("password") != ADMIN_PANEL_PASSWORD: - raise HTTPException(401, "wrong password") - token = db.create_admin_session() + username = (body.get("username") or "").strip() + password = body.get("password") or "" + admin = db.verify_admin_login(username, password) + if not admin: + raise HTTPException(401, "wrong username or password") + token = db.create_admin_session(admin["id"]) response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600) return {"ok": True} @@ -456,7 +459,44 @@ def admin_logout(request: Request, response: Response): @app.get("/admin/api/me") def admin_me(request: Request): token = request.cookies.get(ADMIN_COOKIE) - return {"authenticated": db.validate_admin_session(token)} + admin = db.get_session_admin(token) + return {"authenticated": admin is not None, "username": admin["username"] if admin else None} + + +@app.get("/admin/api/admins") +def admin_list_admins(request: Request): + require_admin(request) + return db.list_admins() + + +@app.post("/admin/api/admins") +def admin_create_admin(request: Request, body: dict = Body(...)): + require_admin(request) + username = (body.get("username") or "").strip() + password = body.get("password") or "" + if len(username) < 3: + raise HTTPException(400, "username too short") + if len(password) < 8: + raise HTTPException(400, "password too short") + try: + return db.create_admin(username, password) + except ValueError as e: + raise HTTPException(400, str(e)) + + +@app.delete("/admin/api/admins/{admin_id}") +def admin_delete_admin(admin_id: int, request: Request): + token = request.cookies.get(ADMIN_COOKIE) + current = db.get_session_admin(token) + if not current: + raise HTTPException(401, "unauthorized") + if current["id"] == admin_id: + raise HTTPException(400, "cannot delete your own account while logged in as it") + try: + db.delete_admin(admin_id) + except ValueError as e: + raise HTTPException(400, str(e)) + return {"ok": True} diff --git a/db.py b/db.py index cb7dab6..365ade9 100644 --- a/db.py +++ b/db.py @@ -1,3 +1,5 @@ +import hashlib +import hmac import os import sqlite3 import secrets @@ -52,6 +54,13 @@ CREATE TABLE IF NOT EXISTS admin_sessions ( expires_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS payments ( id TEXT PRIMARY KEY, tg_id INTEGER NOT NULL, @@ -111,6 +120,10 @@ _NEW_USER_COLUMNS = { "hwid_limit": "INTEGER", } +_NEW_ADMIN_SESSION_COLUMNS = { + "admin_id": "INTEGER", +} + def _migrate(): with get_conn() as conn: @@ -123,6 +136,10 @@ def _migrate(): for name, decl in _NEW_USER_COLUMNS.items(): if name not in ucols: conn.execute(f"ALTER TABLE users ADD COLUMN {name} {decl}") + scols = {r["name"] for r in conn.execute("PRAGMA table_info(admin_sessions)").fetchall()} + for name, decl in _NEW_ADMIN_SESSION_COLUMNS.items(): + if name not in scols: + conn.execute(f"ALTER TABLE admin_sessions ADD COLUMN {name} {decl}") if needs_sort_order_backfill: rows = conn.execute( "SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC" @@ -154,6 +171,7 @@ def init_db(): conn.executescript(SCHEMA) _migrate() _seed_local_node() + _seed_default_admin() for suffix in ("", "-wal", "-shm"): path = DB_PATH + suffix if os.path.exists(path): @@ -177,6 +195,36 @@ def _seed_local_node(): ) +def _hash_password(password: str, salt: bytes | None = None) -> str: + if salt is None: + salt = os.urandom(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 200_000) + return salt.hex() + "$" + dk.hex() + + +def _verify_password(password: str, stored: str) -> bool: + try: + salt_hex, hash_hex = stored.split("$") + except ValueError: + return False + salt = bytes.fromhex(salt_hex) + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 200_000) + return hmac.compare_digest(dk.hex(), hash_hex) + + +def _seed_default_admin(): + from config import ADMIN_PANEL_PASSWORD + + with get_conn() as conn: + row = conn.execute("SELECT 1 FROM admins LIMIT 1").fetchone() + if row or not ADMIN_PANEL_PASSWORD: + return + conn.execute( + "INSERT INTO admins (username, password_hash, created_at) VALUES (?,?,?)", + ("admin", _hash_password(ADMIN_PANEL_PASSWORD), now_iso()), + ) + + def list_nodes(enabled_only: bool = False): q = "SELECT * FROM nodes" if enabled_only: @@ -356,13 +404,13 @@ def redeem_gift_code(code: str, tg_id: int): return dict(row), None -def create_admin_session(hours: int = 168): +def create_admin_session(admin_id: int | None = None, hours: int = 168): token = secrets.token_urlsafe(32) expires = datetime.datetime.utcnow() + datetime.timedelta(hours=hours) with get_conn() as conn: conn.execute( - "INSERT INTO admin_sessions (token, created_at, expires_at) VALUES (?,?,?)", - (token, now_iso(), expires.isoformat()), + "INSERT INTO admin_sessions (token, admin_id, created_at, expires_at) VALUES (?,?,?,?)", + (token, admin_id, now_iso(), expires.isoformat()), ) return token @@ -377,6 +425,18 @@ def validate_admin_session(token: str) -> bool: return row is not None +def get_session_admin(token: str): + if not token: + return None + with get_conn() as conn: + row = conn.execute( + "SELECT a.id, a.username FROM admin_sessions s " + "JOIN admins a ON a.id = s.admin_id " + "WHERE s.token=? AND s.expires_at>?", (token, now_iso()) + ).fetchone() + return dict(row) if row else None + + def delete_admin_session(token: str): with get_conn() as conn: conn.execute("DELETE FROM admin_sessions WHERE token=?", (token,)) @@ -387,6 +447,42 @@ def delete_expired_admin_sessions(): conn.execute("DELETE FROM admin_sessions WHERE expires_at<=?", (now_iso(),)) +def verify_admin_login(username: str, password: str): + with get_conn() as conn: + row = conn.execute("SELECT * FROM admins WHERE username=?", (username,)).fetchone() + if not row or not _verify_password(password, row["password_hash"]): + return None + return dict(row) + + +def list_admins(): + with get_conn() as conn: + rows = conn.execute("SELECT id, username, created_at FROM admins ORDER BY created_at ASC").fetchall() + return [dict(r) for r in rows] + + +def create_admin(username: str, password: str): + with get_conn() as conn: + existing = conn.execute("SELECT 1 FROM admins WHERE username=?", (username,)).fetchone() + if existing: + raise ValueError("username already taken") + conn.execute( + "INSERT INTO admins (username, password_hash, created_at) VALUES (?,?,?)", + (username, _hash_password(password), now_iso()), + ) + row = conn.execute("SELECT id, username, created_at FROM admins WHERE username=?", (username,)).fetchone() + return dict(row) + + +def delete_admin(admin_id: int): + with get_conn() as conn: + count = conn.execute("SELECT COUNT(*) c FROM admins").fetchone()["c"] + if count <= 1: + raise ValueError("cannot delete the last remaining admin") + conn.execute("DELETE FROM admins WHERE id=?", (admin_id,)) + conn.execute("DELETE FROM admin_sessions WHERE admin_id=?", (admin_id,)) + + def list_all_subscriptions(limit: int = 200): with get_conn() as conn: rows = conn.execute(