feat: multi-admin support — named logins instead of one shared password

Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.

Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.

Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-12 15:13:16 +05:00
parent 81cbc4e391
commit 9e6e314c94
3 changed files with 210 additions and 13 deletions

View file

@ -282,8 +282,9 @@
<div class="splash-tagline">made by savsis</div>
</div>
<h1>Вход</h1>
<p>Введи пароль администратора</p>
<input type="password" id="login-password" placeholder="Пароль" onkeydown="if(event.key==='Enter')login()">
<p>Логин и пароль администратора</p>
<input type="text" id="login-username" placeholder="Логин" value="admin" autocomplete="username" onkeydown="if(event.key==='Enter')document.getElementById('login-password').focus()">
<input type="password" id="login-password" placeholder="Пароль" autocomplete="current-password" onkeydown="if(event.key==='Enter')login()">
<button class="btn block" onclick="login()">Войти</button>
<div id="login-err"></div>
</div>
@ -301,6 +302,7 @@
<div class="nav-item" data-view="docs" onclick="showView('docs')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>Документация</div>
<div class="nav-item" data-view="settings" onclick="showView('settings')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Настройки</div>
<div class="sidebar-footer">
<div class="version-tag" id="logged-in-as" style="margin-bottom:6px"></div>
<button class="btn ghost" style="width:100%" onclick="logout()">Выйти</button>
<div class="version-tag">MBS Panel v1.0.0 · <a href="https://github.com/devsavsis/mbs-panel/releases/latest" target="_blank" style="color:inherit">обновления</a></div>
</div>
@ -541,6 +543,20 @@
<div id="settings-bot-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Админы</h2></div>
<p class="page-sub" style="margin-bottom:16px">Отдельные логины для входа в панель — на случай если админов несколько.</p>
<div class="table-wrap"><table><thead><tr>
<th>Логин</th><th>Создан</th><th></th>
</tr></thead><tbody id="admins-body"></tbody></table></div>
<div class="form-row" style="margin-top:16px">
<div><label class="f">Логин</label><input type="text" id="new-admin-username" placeholder="Новый логин"></div>
<div><label class="f">Пароль</label><input type="password" id="new-admin-password" placeholder="Минимум 8 символов"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="createAdmin()">Добавить</button></div>
</div>
<div id="admins-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Бэкап и восстановление</h2></div>
<p class="page-sub" style="margin-bottom:16px">Бэкап — это база (юзеры, подписки, ноды, платежи) и <code>.env</code> одним файлом. Держи копии где-то отдельно от сервера.</p>
@ -582,17 +598,21 @@ function showApp() {
document.getElementById("login-screen").style.display = "none";
document.getElementById("app").classList.add("show");
loadDashboard();
api("/admin/api/me").then((me) => {
document.getElementById("logged-in-as").textContent = me.username ? "вошёл как " + me.username : "";
}).catch(() => {});
}
async function login() {
const username = document.getElementById("login-username").value.trim();
const password = document.getElementById("login-password").value;
const err = document.getElementById("login-err");
err.textContent = "";
try {
await api("/admin/api/login", { method: "POST", body: JSON.stringify({ password }) });
await api("/admin/api/login", { method: "POST", body: JSON.stringify({ username, password }) });
showApp();
} catch (e) {
err.textContent = "Неверный пароль";
err.textContent = "Неверный логин или пароль";
}
}
async function logout() {
@ -611,7 +631,7 @@ function showView(name) {
if (name === "nodes") loadNodes();
if (name === "traffic") loadTraffic();
if (name === "payments") loadPayments();
if (name === "settings") loadBotSettings();
if (name === "settings") { loadBotSettings(); loadAdmins(); }
}
const COUNTRIES = [
@ -889,6 +909,47 @@ async function saveBotSettings() {
}
}
let currentAdminUsername = null;
async function loadAdmins() {
const admins = await api("/admin/api/admins");
const me = await api("/admin/api/me");
currentAdminUsername = me.username;
const body = document.getElementById("admins-body");
body.innerHTML = admins.map((a, i) => `
<tr ${rowAttr(i)}>
<td>${esc(a.username)}${a.username === currentAdminUsername ? ' <span class="badge ok">это ты</span>' : ""}</td>
<td>${fmtDate(a.created_at)}</td>
<td>${admins.length > 1 && a.username !== currentAdminUsername ? `<button class="muted-btn" onclick="deleteAdmin(${a.id})">Удалить</button>` : ""}</td>
</tr>
`).join("");
}
async function createAdmin() {
const username = document.getElementById("new-admin-username").value.trim();
const password = document.getElementById("new-admin-password").value;
const result = document.getElementById("admins-result");
if (!username || !password) return;
try {
await api("/admin/api/admins", { method: "POST", body: JSON.stringify({ username, password }) });
document.getElementById("new-admin-username").value = "";
document.getElementById("new-admin-password").value = "";
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Админ добавлен</p>';
loadAdmins();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
async function deleteAdmin(id) {
if (!confirm("Удалить этого админа?")) return;
try {
await api(`/admin/api/admins/${id}`, { method: "DELETE" });
loadAdmins();
} catch (e) {
document.getElementById("admins-result").innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
function downloadBackup() {
window.location.href = "/admin/api/backup";
}

50
api.py
View file

@ -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}

102
db.py
View file

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