feat: TOTP two-factor auth for admin login

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 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-12 15:43:52 +05:00
parent 9e6e314c94
commit 7098e59967
5 changed files with 282 additions and 11 deletions

View file

@ -281,11 +281,19 @@
<div class="splash-title">MBS Panel</div> <div class="splash-title">MBS Panel</div>
<div class="splash-tagline">made by savsis</div> <div class="splash-tagline">made by savsis</div>
</div> </div>
<div id="login-step-password">
<h1>Вход</h1> <h1>Вход</h1>
<p>Логин и пароль администратора</p> <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="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()"> <input type="password" id="login-password" placeholder="Пароль" autocomplete="current-password" onkeydown="if(event.key==='Enter')login()">
<button class="btn block" onclick="login()">Войти</button> <button class="btn block" onclick="login()">Войти</button>
</div>
<div id="login-step-totp" style="display:none">
<h1>Код из приложения</h1>
<p>Двухфакторка включена — введи 6-значный код</p>
<input type="text" id="login-totp-code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" onkeydown="if(event.key==='Enter')loginTotp()">
<button class="btn block" onclick="loginTotp()">Подтвердить</button>
</div>
<div id="login-err"></div> <div id="login-err"></div>
</div> </div>
</div> </div>
@ -557,6 +565,27 @@
<div id="admins-result"></div> <div id="admins-result"></div>
</div> </div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Двухфакторная аутентификация</h2></div>
<p class="page-sub" style="margin-bottom:16px">Код из Google Authenticator/Authy/1Password при входе, в дополнение к паролю. Настраивается для твоего текущего логина.</p>
<div id="totp-status"></div>
<div id="totp-setup-box" style="display:none;margin-top:16px">
<p class="page-sub">Добавь в приложение-аутентификатор вручную (ключ) или скопируй ссылку:</p>
<div class="code-box"><span id="totp-secret-display"></span><button class="copy-btn" onclick="copyText(document.getElementById('totp-secret-display').textContent)">Копировать</button></div>
<div class="form-row" style="margin-top:12px">
<div><label class="f">Код из приложения</label><input type="text" id="totp-confirm-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="confirmEnableTotp()">Подтвердить</button></div>
</div>
</div>
<div id="totp-disable-box" style="display:none;margin-top:16px">
<div class="form-row">
<div><label class="f">Пароль (подтвердить отключение)</label><input type="password" id="totp-disable-password"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" style="background:var(--red)" onclick="confirmDisableTotp()">Отключить</button></div>
</div>
</div>
<div id="totp-result"></div>
</div>
<div class="section" style="margin-top:20px"> <div class="section" style="margin-top:20px">
<div class="section-head"><h2>Бэкап и восстановление</h2></div> <div class="section-head"><h2>Бэкап и восстановление</h2></div>
<p class="page-sub" style="margin-bottom:16px">Бэкап — это база (юзеры, подписки, ноды, платежи) и <code>.env</code> одним файлом. Держи копии где-то отдельно от сервера.</p> <p class="page-sub" style="margin-bottom:16px">Бэкап — это база (юзеры, подписки, ноды, платежи) и <code>.env</code> одним файлом. Держи копии где-то отдельно от сервера.</p>
@ -593,6 +622,11 @@ async function api(path, opts) {
function showLogin() { function showLogin() {
document.getElementById("login-screen").style.display = "flex"; document.getElementById("login-screen").style.display = "flex";
document.getElementById("app").classList.remove("show"); 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() { function showApp() {
document.getElementById("login-screen").style.display = "none"; document.getElementById("login-screen").style.display = "none";
@ -603,18 +637,39 @@ function showApp() {
}).catch(() => {}); }).catch(() => {});
} }
let pendingTotpToken = null;
async function login() { async function login() {
const username = document.getElementById("login-username").value.trim(); const username = document.getElementById("login-username").value.trim();
const password = document.getElementById("login-password").value; const password = document.getElementById("login-password").value;
const err = document.getElementById("login-err"); const err = document.getElementById("login-err");
err.textContent = ""; err.textContent = "";
try { 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(); showApp();
} catch (e) { } catch (e) {
err.textContent = "Неверный логин или пароль"; 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() { async function logout() {
await api("/admin/api/logout", { method: "POST" }); await api("/admin/api/logout", { method: "POST" });
showLogin(); showLogin();
@ -631,7 +686,7 @@ function showView(name) {
if (name === "nodes") loadNodes(); if (name === "nodes") loadNodes();
if (name === "traffic") loadTraffic(); if (name === "traffic") loadTraffic();
if (name === "payments") loadPayments(); if (name === "payments") loadPayments();
if (name === "settings") { loadBotSettings(); loadAdmins(); } if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); }
} }
const COUNTRIES = [ 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 = '<p class="page-sub"><span class="badge ok">включена</span></p>';
status.innerHTML += '<button class="muted-btn" onclick="document.getElementById(\'totp-disable-box\').style.display=\'block\'">Отключить</button>';
} else {
status.innerHTML = '<p class="page-sub"><span class="badge bad">выключена</span></p>';
status.innerHTML += '<button class="btn" onclick="startEnableTotp()">Включить 2FA</button>';
}
}
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 = '<p class="page-sub" style="margin-top:10px;color:var(--green)">2FA включена</p>';
loadTotpStatus();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Неверный код</p>';
}
}
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 = '<p class="page-sub" style="margin-top:10px;color:var(--green)">2FA отключена</p>';
document.getElementById("totp-disable-password").value = "";
loadTotpStatus();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Неверный пароль</p>';
}
}
function downloadBackup() { function downloadBackup() {
window.location.href = "/admin/api/backup"; window.location.href = "/admin/api/backup";
} }

68
api.py
View file

@ -15,6 +15,7 @@ import db
import links import links
import nodeprov import nodeprov
import payments import payments
import totp
import xray_manager import xray_manager
from config import ( from config import (
PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, 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) admin = db.verify_admin_login(username, password)
if not admin: if not admin:
raise HTTPException(401, "wrong username or password") 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"]) token = db.create_admin_session(admin["id"])
response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600) response.set_cookie(ADMIN_COOKIE, token, httponly=True, secure=True, samesite="strict", max_age=7 * 24 * 3600)
return {"ok": True} return {"ok": True}
@ -486,10 +506,7 @@ def admin_create_admin(request: Request, body: dict = Body(...)):
@app.delete("/admin/api/admins/{admin_id}") @app.delete("/admin/api/admins/{admin_id}")
def admin_delete_admin(admin_id: int, request: Request): def admin_delete_admin(admin_id: int, request: Request):
token = request.cookies.get(ADMIN_COOKIE) current = _require_current_admin(request)
current = db.get_session_admin(token)
if not current:
raise HTTPException(401, "unauthorized")
if current["id"] == admin_id: if current["id"] == admin_id:
raise HTTPException(400, "cannot delete your own account while logged in as it") raise HTTPException(400, "cannot delete your own account while logged in as it")
try: try:
@ -499,6 +516,49 @@ def admin_delete_admin(admin_id: int, request: Request):
return {"ok": True} 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") @app.get("/admin/api/settings/bot")
def admin_get_bot_settings(request: Request): def admin_get_bot_settings(request: Request):

1
bot.py
View file

@ -369,6 +369,7 @@ async def periodic_sync():
log.exception("payment reconciliation failed") log.exception("payment reconciliation failed")
try: try:
db.delete_expired_admin_sessions() db.delete_expired_admin_sessions()
db.delete_expired_pending_totp()
except Exception: except Exception:
log.exception("expired admin session cleanup failed") log.exception("expired admin session cleanup failed")
await asyncio.sleep(90) await asyncio.sleep(90)

61
db.py
View file

@ -61,6 +61,13 @@ CREATE TABLE IF NOT EXISTS admins (
created_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
tg_id INTEGER NOT NULL, tg_id INTEGER NOT NULL,
@ -124,6 +131,10 @@ _NEW_ADMIN_SESSION_COLUMNS = {
"admin_id": "INTEGER", "admin_id": "INTEGER",
} }
_NEW_ADMIN_COLUMNS = {
"totp_secret": "TEXT",
}
def _migrate(): def _migrate():
with get_conn() as conn: with get_conn() as conn:
@ -140,6 +151,10 @@ def _migrate():
for name, decl in _NEW_ADMIN_SESSION_COLUMNS.items(): for name, decl in _NEW_ADMIN_SESSION_COLUMNS.items():
if name not in scols: if name not in scols:
conn.execute(f"ALTER TABLE admin_sessions ADD COLUMN {name} {decl}") 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: if needs_sort_order_backfill:
rows = conn.execute( rows = conn.execute(
"SELECT code FROM nodes ORDER BY (code='de1') DESC, created_at ASC" "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,)) 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): def list_all_subscriptions(limit: int = 200):
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(

45
totp.py Normal file
View file

@ -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"
)