aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/login_throttle.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/login_throttle.py143
1 files changed, 143 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/login_throttle.py b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
new file mode 100644
index 0000000..3281088
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
@@ -0,0 +1,143 @@
+"""Per-account sign-in lockout: after N wrong passphrases, refuse for a while.
+
+The per-IP rate limit on `login` bounds one address, and IPv6 hands every
+subscriber a /64 of them. What an online guess actually targets is an account,
+so that is what this counts.
+
+Three properties, each for a reason:
+
+- **Keyed by the username as typed, whether or not the account exists.** An
+ unknown name locks exactly like a real one, so a 429 says nothing a 401 did
+ not — `login` stays uniform (M1). The key is a hash: people type passphrases
+ into the username field, and this table must not keep them.
+- **The attempt is counted before the passphrase is checked, in one statement.**
+ Read-then-write would let a burst of concurrent requests all read "three
+ failures" and all be checked; an `INSERT … ON CONFLICT DO UPDATE … WHERE`
+ either takes one attempt or reports that none is left, atomically on SQLite
+ and PostgreSQL alike.
+- **A locked account is refused without verifying anything**, so a guess made
+ during the lockout learns nothing — not even whether it was right.
+
+What a lockout does not touch: sessions already open, token renewal, and device
+sign-in, none of which take a passphrase. That is what keeps a stranger who
+locks somebody else's name from signing them out (§13.5b, AV26).
+"""
+
+import hashlib
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import case, delete, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub import hub_settings
+from meshbay_hub.db.models import LoginThrottle
+
+
+def _key(username: str) -> str:
+ return hashlib.sha256(f"meshbay:login:{username}".encode()).hexdigest()
+
+
+def _aware(dt: datetime) -> datetime:
+ # SQLite hands back naive datetimes for a timezone-aware column.
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
+
+
+def _insert_for(db: AsyncSession):
+ dialect = db.bind.dialect.name
+ if dialect == "postgresql":
+ from sqlalchemy.dialects.postgresql import insert
+ elif dialect == "sqlite":
+ from sqlalchemy.dialects.sqlite import insert
+ else:
+ raise RuntimeError(f"login throttle has no upsert for dialect {dialect!r}")
+ return insert
+
+
+async def reserve(db: AsyncSession, username: str) -> tuple[bool, int]:
+ """Take one attempt for `username`, and commit it before anything is checked.
+
+ Returns `(allowed, retry_after_seconds)`. When allowed, the second value is
+ 0; when not, it is how long the lockout has left.
+ """
+ limits = await hub_settings.login_limits(db)
+ max_failures = limits["max_failures"]
+ if max_failures == 0:
+ return True, 0
+
+ now = datetime.now(timezone.utc)
+ window = timedelta(minutes=limits["lockout_minutes"])
+ window_start = now - window
+ key = _key(username)
+
+ table = LoginThrottle.__table__
+ stale = table.c.last_failure_at < window_start
+ insert = _insert_for(db)
+ stmt = (
+ insert(table)
+ .values(key=key, failures=1, last_failure_at=now)
+ .on_conflict_do_update(
+ index_elements=[table.c.key],
+ # Failures older than the window have aged out: start again at one
+ # rather than carrying three typos from last week into today.
+ set_={"failures": case((stale, 1), else_=table.c.failures + 1),
+ "last_failure_at": now},
+ where=(table.c.failures < max_failures) | stale,
+ )
+ .returning(table.c.failures)
+ )
+ taken = (await db.execute(stmt)).first()
+ await db.commit()
+ if taken is not None:
+ return True, 0
+ return False, max(1, await locked_for(db, username))
+
+
+async def locked_for(db: AsyncSession, username: str) -> int:
+ """Seconds left on a lockout, 0 if none — without spending an attempt."""
+ limits = await hub_settings.login_limits(db)
+ if limits["max_failures"] == 0:
+ return 0
+ row = await db.get(LoginThrottle, _key(username))
+ if row is None or row.failures < limits["max_failures"]:
+ return 0
+ remaining = (_aware(row.last_failure_at)
+ + timedelta(minutes=limits["lockout_minutes"])
+ - datetime.now(timezone.utc)).total_seconds()
+ return max(0, int(remaining + 0.999))
+
+
+async def is_now_locked(db: AsyncSession, username: str) -> bool:
+ """After a failure: did that one spend the last attempt?"""
+ limits = await hub_settings.login_limits(db)
+ if limits["max_failures"] == 0:
+ return False
+ failures = await db.scalar(
+ select(LoginThrottle.failures).where(LoginThrottle.key == _key(username)))
+ return (failures or 0) >= limits["max_failures"]
+
+
+async def release(db: AsyncSession, username: str) -> None:
+ """Give back an attempt that checked no passphrase. The caller owns the commit.
+
+ Only ever undoes the caller's own reservation, so it cannot be used to earn
+ attempts: the net effect of reserve-then-release is nothing.
+ """
+ await db.execute(
+ update(LoginThrottle)
+ .where(LoginThrottle.key == _key(username), LoginThrottle.failures > 0)
+ .values(failures=LoginThrottle.failures - 1))
+
+
+async def clear(db: AsyncSession, username: str) -> None:
+ """The right passphrase, or a reset proved by e-mail. The caller owns the commit."""
+ await db.execute(delete(LoginThrottle).where(LoginThrottle.key == _key(username)))
+
+
+async def purge_expired(db: AsyncSession) -> int:
+ """Rows whose failures have aged out. Every unknown name typed creates one."""
+ limits = await hub_settings.login_limits(db)
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=limits["lockout_minutes"])
+ result = await db.execute(
+ delete(LoginThrottle).where(LoginThrottle.last_failure_at < cutoff))
+ await db.commit()
+ return result.rowcount