From 7098e59967f4b367c2fc1a43b26d961e161b3b46 Mon Sep 17 00:00:00 2001 From: savsis Date: Sat, 12 Sep 2026 15:43:52 +0500 Subject: [PATCH] feat: TOTP two-factor auth for admin login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches Remnawave's security-first positioning (passkeys/OAuth there) with the more universal standard instead — RFC 6238 TOTP, works with Google Authenticator/Authy/1Password/anything. Implemented from scratch on stdlib only (hashlib/hmac/struct/base64) — no new dependency — and verified against the official RFC 4226 HOTP test vectors (all 10 pass exactly) before wiring it into any auth path. Per-admin, optional: setup shows the secret + otpauth:// URI (no QR render, just copyable text — didn't want to fake a QR library), confirmed by entering a real code before it's persisted. Login is now two-step when 2FA is on: password first (returns a short-lived pending_token instead of a session if totp_secret is set), then a second call with the pending_token + code creates the real session. pending_totp rows are single-use and expire in 5 minutes, cleaned up alongside the existing admin_sessions cleanup in periodic_sync. Disabling 2FA requires re-entering the current password. Verified end-to-end: enable/disable round trip, pending-token resolve+single-use+expiry, code verification against the stored secret, wrong-code and wrong-password rejection — on top of the raw HOTP correctness check. Co-Authored-By: Claude Sonnet 5 --- admin.html | 118 +++++++++++++++++++++++++++++++++++++++++++++++++---- api.py | 68 ++++++++++++++++++++++++++++-- bot.py | 1 + db.py | 61 +++++++++++++++++++++++++++ totp.py | 45 ++++++++++++++++++++ 5 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 totp.py diff --git a/admin.html b/admin.html index 76a7da4..0d0bde6 100644 --- a/admin.html +++ b/admin.html @@ -281,11 +281,19 @@
MBS Panel
made by savsis
-

Вход

-

Логин и пароль администратора

- - - +
+

Вход

+

Логин и пароль администратора

+ + + +
+
@@ -557,6 +565,27 @@
+
+

Двухфакторная аутентификация

+

Код из Google Authenticator/Authy/1Password при входе, в дополнение к паролю. Настраивается для твоего текущего логина.

+
+ + +
+
+

Бэкап и восстановление

Бэкап — это база (юзеры, подписки, ноды, платежи) и .env одним файлом. Держи копии где-то отдельно от сервера.

@@ -593,6 +622,11 @@ async function api(path, opts) { function showLogin() { document.getElementById("login-screen").style.display = "flex"; document.getElementById("app").classList.remove("show"); + document.getElementById("login-step-password").style.display = "block"; + document.getElementById("login-step-totp").style.display = "none"; + document.getElementById("login-totp-code").value = ""; + document.getElementById("login-password").value = ""; + pendingTotpToken = null; } function showApp() { document.getElementById("login-screen").style.display = "none"; @@ -603,18 +637,39 @@ function showApp() { }).catch(() => {}); } +let pendingTotpToken = null; + 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({ username, password }) }); + const res = await api("/admin/api/login", { method: "POST", body: JSON.stringify({ username, password }) }); + if (res.needs_totp) { + pendingTotpToken = res.pending_token; + document.getElementById("login-step-password").style.display = "none"; + document.getElementById("login-step-totp").style.display = "block"; + document.getElementById("login-totp-code").focus(); + return; + } showApp(); } catch (e) { err.textContent = "Неверный логин или пароль"; } } + +async function loginTotp() { + const code = document.getElementById("login-totp-code").value.trim(); + const err = document.getElementById("login-err"); + err.textContent = ""; + try { + await api("/admin/api/login/totp", { method: "POST", body: JSON.stringify({ pending_token: pendingTotpToken, code }) }); + showApp(); + } catch (e) { + err.textContent = "Неверный код"; + } +} async function logout() { await api("/admin/api/logout", { method: "POST" }); showLogin(); @@ -631,7 +686,7 @@ function showView(name) { if (name === "nodes") loadNodes(); if (name === "traffic") loadTraffic(); if (name === "payments") loadPayments(); - if (name === "settings") { loadBotSettings(); loadAdmins(); } + if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); } } const COUNTRIES = [ @@ -950,6 +1005,55 @@ async function deleteAdmin(id) { } } +async function loadTotpStatus() { + const status = document.getElementById("totp-status"); + const setupBox = document.getElementById("totp-setup-box"); + const disableBox = document.getElementById("totp-disable-box"); + setupBox.style.display = "none"; + disableBox.style.display = "none"; + const s = await api("/admin/api/2fa/status"); + if (s.enabled) { + status.innerHTML = '

включена

'; + status.innerHTML += ''; + } else { + status.innerHTML = '

выключена

'; + status.innerHTML += ''; + } +} + +async function startEnableTotp() { + const res = await api("/admin/api/2fa/setup", { method: "POST" }); + document.getElementById("totp-secret-display").textContent = res.secret; + document.getElementById("totp-setup-box").dataset.secret = res.secret; + document.getElementById("totp-setup-box").style.display = "block"; +} + +async function confirmEnableTotp() { + const secret = document.getElementById("totp-setup-box").dataset.secret; + const code = document.getElementById("totp-confirm-code").value.trim(); + const result = document.getElementById("totp-result"); + try { + await api("/admin/api/2fa/enable", { method: "POST", body: JSON.stringify({ secret, code }) }); + result.innerHTML = '

2FA включена

'; + loadTotpStatus(); + } catch (e) { + result.innerHTML = '

Неверный код

'; + } +} + +async function confirmDisableTotp() { + const password = document.getElementById("totp-disable-password").value; + const result = document.getElementById("totp-result"); + try { + await api("/admin/api/2fa/disable", { method: "POST", body: JSON.stringify({ password }) }); + result.innerHTML = '

2FA отключена

'; + document.getElementById("totp-disable-password").value = ""; + loadTotpStatus(); + } catch (e) { + result.innerHTML = '

Неверный пароль

'; + } +} + function downloadBackup() { window.location.href = "/admin/api/backup"; } diff --git a/api.py b/api.py index c84243b..c0ef6c8 100644 --- a/api.py +++ b/api.py @@ -15,6 +15,7 @@ import db import links import nodeprov import payments +import totp import xray_manager from config import ( PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, @@ -442,6 +443,25 @@ def admin_login(response: Response, body: dict = Body(...)): admin = db.verify_admin_login(username, password) if not admin: raise HTTPException(401, "wrong username or password") + if admin.get("totp_secret"): + pending_token = db.create_pending_totp(admin["id"]) + return {"ok": True, "needs_totp": True, "pending_token": pending_token} + 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} + + +@app.post("/admin/api/login/totp") +def admin_login_totp(response: Response, body: dict = Body(...)): + pending_token = body.get("pending_token") or "" + code = (body.get("code") or "").strip() + pending = db.resolve_pending_totp(pending_token) + if not pending: + raise HTTPException(401, "login session expired, log in again") + admin = db.get_admin_by_id(pending["admin_id"]) + if not admin or not admin.get("totp_secret") or not totp.verify(admin["totp_secret"], code): + raise HTTPException(401, "wrong code") + db.delete_pending_totp(pending_token) 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} @@ -486,10 +506,7 @@ def admin_create_admin(request: Request, body: dict = Body(...)): @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") + current = _require_current_admin(request) if current["id"] == admin_id: raise HTTPException(400, "cannot delete your own account while logged in as it") try: @@ -499,6 +516,49 @@ def admin_delete_admin(admin_id: int, request: Request): return {"ok": True} +def _require_current_admin(request: Request): + token = request.cookies.get(ADMIN_COOKIE) + current = db.get_session_admin(token) + if not current: + raise HTTPException(401, "unauthorized") + return current + + +@app.get("/admin/api/2fa/status") +def admin_2fa_status(request: Request): + current = _require_current_admin(request) + admin = db.get_admin_by_id(current["id"]) + return {"enabled": bool(admin and admin.get("totp_secret"))} + + +@app.post("/admin/api/2fa/setup") +def admin_2fa_setup(request: Request): + current = _require_current_admin(request) + secret = totp.generate_secret() + return {"secret": secret, "uri": totp.uri(secret, current["username"])} + + +@app.post("/admin/api/2fa/enable") +def admin_2fa_enable(request: Request, body: dict = Body(...)): + current = _require_current_admin(request) + secret = body.get("secret") or "" + code = (body.get("code") or "").strip() + if not secret or not totp.verify(secret, code): + raise HTTPException(400, "wrong code") + db.set_admin_totp_secret(current["id"], secret) + return {"ok": True} + + +@app.post("/admin/api/2fa/disable") +def admin_2fa_disable(request: Request, body: dict = Body(...)): + current = _require_current_admin(request) + password = body.get("password") or "" + if not db.verify_admin_password_by_id(current["id"], password): + raise HTTPException(401, "wrong password") + db.set_admin_totp_secret(current["id"], None) + return {"ok": True} + + @app.get("/admin/api/settings/bot") def admin_get_bot_settings(request: Request): diff --git a/bot.py b/bot.py index a52ab2d..c01e695 100644 --- a/bot.py +++ b/bot.py @@ -369,6 +369,7 @@ async def periodic_sync(): log.exception("payment reconciliation failed") try: db.delete_expired_admin_sessions() + db.delete_expired_pending_totp() except Exception: log.exception("expired admin session cleanup failed") await asyncio.sleep(90) diff --git a/db.py b/db.py index 365ade9..91914c7 100644 --- a/db.py +++ b/db.py @@ -61,6 +61,13 @@ CREATE TABLE IF NOT EXISTS admins ( created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS pending_totp ( + token TEXT PRIMARY KEY, + admin_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS payments ( id TEXT PRIMARY KEY, tg_id INTEGER NOT NULL, @@ -124,6 +131,10 @@ _NEW_ADMIN_SESSION_COLUMNS = { "admin_id": "INTEGER", } +_NEW_ADMIN_COLUMNS = { + "totp_secret": "TEXT", +} + def _migrate(): with get_conn() as conn: @@ -140,6 +151,10 @@ def _migrate(): 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}") + acols = {r["name"] for r in conn.execute("PRAGMA table_info(admins)").fetchall()} + for name, decl in _NEW_ADMIN_COLUMNS.items(): + if name not in acols: + conn.execute(f"ALTER TABLE admins ADD COLUMN {name} {decl}") if needs_sort_order_backfill: rows = conn.execute( "SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC" @@ -483,6 +498,52 @@ def delete_admin(admin_id: int): conn.execute("DELETE FROM admin_sessions WHERE admin_id=?", (admin_id,)) +def get_admin_by_id(admin_id: int): + with get_conn() as conn: + row = conn.execute("SELECT id, username, created_at, totp_secret FROM admins WHERE id=?", (admin_id,)).fetchone() + return dict(row) if row else None + + +def verify_admin_password_by_id(admin_id: int, password: str) -> bool: + with get_conn() as conn: + row = conn.execute("SELECT password_hash FROM admins WHERE id=?", (admin_id,)).fetchone() + return bool(row) and _verify_password(password, row["password_hash"]) + + +def set_admin_totp_secret(admin_id: int, secret: str | None): + with get_conn() as conn: + conn.execute("UPDATE admins SET totp_secret=? WHERE id=?", (secret, admin_id)) + + +def create_pending_totp(admin_id: int, minutes: int = 5) -> str: + token = secrets.token_urlsafe(24) + expires = datetime.datetime.utcnow() + datetime.timedelta(minutes=minutes) + with get_conn() as conn: + conn.execute( + "INSERT INTO pending_totp (token, admin_id, created_at, expires_at) VALUES (?,?,?,?)", + (token, admin_id, now_iso(), expires.isoformat()), + ) + return token + + +def resolve_pending_totp(token: str): + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM pending_totp WHERE token=? AND expires_at>?", (token, now_iso()) + ).fetchone() + return dict(row) if row else None + + +def delete_pending_totp(token: str): + with get_conn() as conn: + conn.execute("DELETE FROM pending_totp WHERE token=?", (token,)) + + +def delete_expired_pending_totp(): + with get_conn() as conn: + conn.execute("DELETE FROM pending_totp WHERE expires_at<=?", (now_iso(),)) + + def list_all_subscriptions(limit: int = 200): with get_conn() as conn: rows = conn.execute( diff --git a/totp.py b/totp.py new file mode 100644 index 0000000..4c26ef0 --- /dev/null +++ b/totp.py @@ -0,0 +1,45 @@ +import base64 +import hashlib +import hmac +import os +import struct +import time as timemod +import urllib.parse + + +def generate_secret() -> str: + return base64.b32encode(os.urandom(20)).decode("ascii").rstrip("=") + + +def _hotp(secret_b32: str, counter: int) -> str: + padded = secret_b32 + "=" * ((8 - len(secret_b32) % 8) % 8) + key = base64.b32decode(padded.upper()) + msg = struct.pack(">Q", counter) + h = hmac.new(key, msg, hashlib.sha1).digest() + offset = h[-1] & 0x0F + code = (struct.unpack(">I", h[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000 + return f"{code:06d}" + + +def now_code(secret_b32: str, for_time: float | None = None) -> str: + t = for_time if for_time is not None else timemod.time() + counter = int(t // 30) + return _hotp(secret_b32, counter) + + +def verify(secret_b32: str, code: str, window: int = 1) -> bool: + if not code or not code.isdigit() or len(code) != 6: + return False + counter = int(timemod.time() // 30) + for offset in range(-window, window + 1): + if hmac.compare_digest(_hotp(secret_b32, counter + offset), code): + return True + return False + + +def uri(secret_b32: str, username: str, issuer: str = "MBS Panel") -> str: + label = urllib.parse.quote(f"{issuer}:{username}") + return ( + f"otpauth://totp/{label}?secret={secret_b32}" + f"&issuer={urllib.parse.quote(issuer)}&algorithm=SHA1&digits=6&period=30" + )