summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
blob: 32810887ddac5167f14c5947add9450ebed22f27 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
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