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:
parent
81cbc4e391
commit
9e6e314c94
3 changed files with 210 additions and 13 deletions
71
admin.html
71
admin.html
|
|
@ -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"> </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
50
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}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
102
db.py
102
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue