diff --git a/admin.html b/admin.html index 76a7da4..0d0bde6 100644 --- a/admin.html +++ b/admin.html @@ -281,11 +281,19 @@
Логин и пароль администратора
- - - +Логин и пароль администратора
+ + + +Код из Google Authenticator/Authy/1Password при входе, в дополнение к паролю. Настраивается для твоего текущего логина.
+ + + + +Бэкап — это база (юзеры, подписки, ноды, платежи) и .env одним файлом. Держи копии где-то отдельно от сервера.
включена
'; + 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" + )