"""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