security: rate-limit admin login and TOTP verification

Neither endpoint had any brute-force protection — TOTP codes are only
6 digits (1M combinations) and HMAC-SHA1 verification is cheap, so an
unthrottled /admin/api/login/totp is a realistic brute-force target
within a pending token's 5-minute window. Password login had the same
gap.

DB-backed (new login_attempts table), not in-memory — this matters
now that mbs-api runs multiple worker processes (see 11c75c1): an
in-process counter would let an attacker split requests across
workers and bypass it entirely, same class of mistake as an
unsynchronized in-memory cache. Keyed by client IP (nginx already
sets X-Real-IP on every proxied request, install.sh has always done
this).

10 failed attempts / 15min for password, 10 / 5min for TOTP codes,
counted per-IP per-kind. Successful login clears that IP's recent
failures. Old rows pruned in the existing 90s periodic_sync cleanup
alongside sessions and pending_totp.

Verified: threshold counting, per-IP isolation, per-kind isolation
(password vs totp tracked separately), clear-on-success, and the
age-based cleanup only removing rows older than the cutoff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-12 16:42:20 +05:00
parent 11c75c13d1
commit 52f5c551cb
3 changed files with 54 additions and 2 deletions

18
api.py
View file

@ -436,13 +436,22 @@ async def register_node(token: str, request: Request):
def _client_ip(request: Request) -> str:
return request.headers.get("x-real-ip") or (request.client.host if request.client else "unknown")
@app.post("/admin/api/login")
def admin_login(response: Response, body: dict = Body(...)):
def admin_login(request: Request, response: Response, body: dict = Body(...)):
ip = _client_ip(request)
if db.count_recent_login_attempts(ip, "password", minutes=15) >= 10:
raise HTTPException(429, "too many attempts, try again later")
username = (body.get("username") or "").strip()
password = body.get("password") or ""
admin = db.verify_admin_login(username, password)
if not admin:
db.record_login_attempt(ip, "password")
raise HTTPException(401, "wrong username or password")
db.clear_login_attempts(ip, "password")
if admin.get("totp_secret"):
pending_token = db.create_pending_totp(admin["id"])
return {"ok": True, "needs_totp": True, "pending_token": pending_token}
@ -452,7 +461,10 @@ def admin_login(response: Response, body: dict = Body(...)):
@app.post("/admin/api/login/totp")
def admin_login_totp(response: Response, body: dict = Body(...)):
def admin_login_totp(request: Request, response: Response, body: dict = Body(...)):
ip = _client_ip(request)
if db.count_recent_login_attempts(ip, "totp", minutes=5) >= 10:
raise HTTPException(429, "too many attempts, try again later")
pending_token = body.get("pending_token") or ""
code = (body.get("code") or "").strip()
pending = db.resolve_pending_totp(pending_token)
@ -460,7 +472,9 @@ def admin_login_totp(response: Response, body: dict = Body(...)):
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):
db.record_login_attempt(ip, "totp")
raise HTTPException(401, "wrong code")
db.clear_login_attempts(ip, "totp")
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)

1
bot.py
View file

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

37
db.py
View file

@ -68,6 +68,14 @@ CREATE TABLE IF NOT EXISTS pending_totp (
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_lookup ON login_attempts (ip, kind, created_at);
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
tg_id INTEGER NOT NULL,
@ -544,6 +552,35 @@ def delete_expired_pending_totp():
conn.execute("DELETE FROM pending_totp WHERE expires_at<=?", (now_iso(),))
def record_login_attempt(ip: str, kind: str):
with get_conn() as conn:
conn.execute(
"INSERT INTO login_attempts (ip, kind, created_at) VALUES (?,?,?)",
(ip, kind, now_iso()),
)
def count_recent_login_attempts(ip: str, kind: str, minutes: int) -> int:
since = (datetime.datetime.utcnow() - datetime.timedelta(minutes=minutes)).isoformat()
with get_conn() as conn:
row = conn.execute(
"SELECT COUNT(*) c FROM login_attempts WHERE ip=? AND kind=? AND created_at>?",
(ip, kind, since),
).fetchone()
return row["c"]
def clear_login_attempts(ip: str, kind: str):
with get_conn() as conn:
conn.execute("DELETE FROM login_attempts WHERE ip=? AND kind=?", (ip, kind))
def delete_old_login_attempts(hours: int = 1):
cutoff = (datetime.datetime.utcnow() - datetime.timedelta(hours=hours)).isoformat()
with get_conn() as conn:
conn.execute("DELETE FROM login_attempts WHERE created_at<=?", (cutoff,))
def list_all_subscriptions(limit: int = 200):
with get_conn() as conn:
rows = conn.execute(