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:
parent
9e6e314c94
commit
7098e59967
5 changed files with 282 additions and 11 deletions
118
admin.html
118
admin.html
|
|
@ -281,11 +281,19 @@
|
|||
<div class="splash-title">MBS Panel</div>
|
||||
<div class="splash-tagline">made by savsis</div>
|
||||
</div>
|
||||
<h1>Вход</h1>
|
||||
<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-step-password">
|
||||
<h1>Вход</h1>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -557,6 +565,27 @@
|
|||
<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">Код из 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"> </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"> </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-head"><h2>Бэкап и восстановление</h2></div>
|
||||
<p class="page-sub" style="margin-bottom:16px">Бэкап — это база (юзеры, подписки, ноды, платежи) и <code>.env</code> одним файлом. Держи копии где-то отдельно от сервера.</p>
|
||||
|
|
@ -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 = '<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() {
|
||||
window.location.href = "/admin/api/backup";
|
||||
}
|
||||
|
|
|
|||
68
api.py
68
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):
|
||||
|
|
|
|||
1
bot.py
1
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)
|
||||
|
|
|
|||
61
db.py
61
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(
|
||||
|
|
|
|||
45
totp.py
Normal file
45
totp.py
Normal 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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue