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:
parent
11c75c13d1
commit
52f5c551cb
3 changed files with 54 additions and 2 deletions
37
db.py
37
db.py
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue