diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/login_throttle.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/login_throttle.py | 143 |
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 |