aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_login_lockout.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_login_lockout.py')
-rw-r--r--packages/meshbay-hub/tests/test_login_lockout.py256
1 files changed, 256 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_login_lockout.py b/packages/meshbay-hub/tests/test_login_lockout.py
new file mode 100644
index 0000000..43300d0
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_login_lockout.py
@@ -0,0 +1,256 @@
+"""
+Per-account sign-in lockout (`login_throttle.py`).
+
+The per-IP limit bounds one address, and IPv6 gives every subscriber a /64 of
+them; an online guess targets an account, so the account is what is counted.
+The properties pinned here are the ones that make that safe to ship:
+
+ * the Nth wrong passphrase locks, and a locked account is refused **before**
+ the passphrase is checked — the right one is refused too
+ * an unknown username locks exactly like a real one, so the 429 says nothing
+ the 401 did not (M1)
+ * a right passphrase clears the count, and failures age out of the window
+ * `change_password` checks the same passphrase, so it counts on the same row
+ * the two numbers are the admin's to change, and zero turns it off
+
+What one account costs another through this — a stranger locking your name —
+is `test_availability_between_members.py`, because it takes two accounts.
+"""
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select, update
+
+from meshbay_hub.api.deps import set_admin_usernames
+from meshbay_hub.db.models import LoginThrottle
+from meshbay_hub.login_throttle import _key
+
+RIGHT = "r" * 44
+WRONG = "w" * 44
+
+
+async def _register(client, username, auth_key=RIGHT):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": auth_key})
+ assert r.status_code == 201, r.text
+
+
+async def _login(client, username, auth_key):
+ return await client.post("/v1/users/login", json={
+ "username": username, "auth_key": auth_key})
+
+
+async def _fail(client, username, times):
+ for _ in range(times):
+ r = await _login(client, username, WRONG)
+ assert r.status_code == 401, r.text
+
+
+@pytest.mark.asyncio
+async def test_the_fourth_failure_locks_and_the_right_passphrase_is_refused(client):
+ await _register(client, "alice")
+ await _fail(client, "alice", 4)
+
+ r = await _login(client, "alice", RIGHT)
+ assert r.status_code == 429, r.text
+ assert r.json()["detail"] == "account_locked"
+ # An hour, give or take the time the four failures took.
+ assert 3500 <= int(r.headers["retry-after"]) <= 3600
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_name_locks_exactly_like_a_real_one(client):
+ """M1: the lockout must not become the enumeration oracle `login` avoids."""
+ await _register(client, "bob")
+ await _fail(client, "bob", 4)
+ await _fail(client, "nobody-by-this-name", 4)
+
+ real = await _login(client, "bob", WRONG)
+ ghost = await _login(client, "nobody-by-this-name", WRONG)
+ assert (real.status_code, real.json()) == (ghost.status_code, ghost.json())
+ assert real.status_code == 429
+
+
+@pytest.mark.asyncio
+async def test_the_right_passphrase_clears_the_count(client):
+ await _register(client, "carol")
+ await _fail(client, "carol", 3)
+ r = await _login(client, "carol", RIGHT)
+ assert r.status_code == 200, r.text
+
+ # Three more would have been seven in a row without the reset.
+ await _fail(client, "carol", 3)
+ assert (await _login(client, "carol", RIGHT)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_a_lockout_ends_when_its_window_does(client, db_session):
+ await _register(client, "dave")
+ await _fail(client, "dave", 4)
+ assert (await _login(client, "dave", RIGHT)).status_code == 429
+
+ await db_session.execute(
+ update(LoginThrottle).where(LoginThrottle.key == _key("dave"))
+ .values(last_failure_at=datetime.now(timezone.utc) - timedelta(minutes=61)))
+ await db_session.commit()
+
+ assert (await _login(client, "dave", RIGHT)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_old_failures_do_not_carry_into_a_new_window(client, db_session):
+ await _register(client, "erin")
+ await _fail(client, "erin", 3)
+ await db_session.execute(
+ update(LoginThrottle).where(LoginThrottle.key == _key("erin"))
+ .values(last_failure_at=datetime.now(timezone.utc) - timedelta(minutes=61)))
+ await db_session.commit()
+
+ # One stale window of three, then one fresh failure: a count of one, not four.
+ await _fail(client, "erin", 1)
+ assert (await _login(client, "erin", RIGHT)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_a_burst_of_concurrent_guesses_gets_no_more_than_the_limit(client):
+ """The attempt is taken before the check, in one statement.
+
+ Read-then-write would let every request in a burst read "no failures yet"
+ and be checked. On SQLite writes serialise anyway, so this pins the
+ behaviour rather than proving the statement under PostgreSQL's concurrency;
+ the statement is an `ON CONFLICT DO UPDATE … WHERE`, which both evaluate
+ against the row as locked.
+ """
+ await _register(client, "frank")
+ results = await asyncio.gather(*[_login(client, "frank", WRONG) for _ in range(10)])
+ codes = sorted(r.status_code for r in results)
+ assert codes.count(401) == 4, codes
+ assert codes.count(429) == 6, codes
+
+
+@pytest.mark.asyncio
+async def test_change_password_counts_on_the_same_row(client):
+ """It checks the same passphrase, so it is the same oracle."""
+ await _register(client, "grace")
+ token = (await _login(client, "grace", RIGHT)).json()["access_token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ for _ in range(4):
+ r = await client.post("/v1/users/password", headers=auth, json={
+ "old_auth_key": WRONG, "new_auth_key": "n" * 44})
+ assert r.status_code == 403, r.text
+
+ r = await client.post("/v1/users/password", headers=auth, json={
+ "old_auth_key": RIGHT, "new_auth_key": "n" * 44})
+ assert r.status_code == 429, r.text
+ assert (await _login(client, "grace", RIGHT)).status_code == 429
+
+
+@pytest.mark.asyncio
+async def test_a_signed_in_session_is_told_its_own_lockout(client):
+ """A passphrase change re-wraps every node's bundle before the hub accepts
+ it, so the client must know not to start one the hub would then refuse."""
+ await _register(client, "olivia")
+ token = (await _login(client, "olivia", RIGHT)).json()["access_token"]
+ auth = {"Authorization": f"Bearer {token}"}
+ assert (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"] == 0
+
+ await _fail(client, "olivia", 4)
+ left = (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"]
+ assert 3500 <= left <= 3600
+
+
+@pytest.mark.asyncio
+async def test_an_attempt_that_checks_no_passphrase_is_not_counted(client, db_session):
+ """A legacy account asked to upgrade has been told nothing about its passphrase."""
+ from meshbay_hub.db.models import User
+
+ await _register(client, "heidi")
+ await db_session.execute(
+ update(User).where(User.username == "heidi").values(pw_version=2))
+ await db_session.commit()
+
+ for _ in range(6):
+ r = await _login(client, "heidi", RIGHT)
+ assert r.status_code == 401 and r.json()["detail"] == "auth_upgrade_required"
+ failures = await db_session.scalar(
+ select(LoginThrottle.failures).where(LoginThrottle.key == _key("heidi")))
+ assert not failures
+
+
+@pytest.mark.asyncio
+async def test_the_table_never_holds_what_was_typed(client, db_session):
+ """People type passphrases into the username field."""
+ await _login(client, "my-secret-passphrase-typed-in-the-wrong-box", WRONG)
+ keys = (await db_session.execute(select(LoginThrottle.key))).scalars().all()
+ assert keys and all("secret" not in k for k in keys)
+
+
+# ── The admin's two numbers ──────────────────────────────────────────────────
+
+async def _admin_headers(client, username="root"):
+ await _register(client, username)
+ set_admin_usernames([username])
+ token = (await _login(client, username, RIGHT)).json()["access_token"]
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.mark.asyncio
+async def test_the_admin_sets_the_limit_and_the_hub_applies_it(client):
+ admin = await _admin_headers(client)
+
+ r = await client.get("/v1/admin/settings", headers=admin)
+ assert r.json()["login"] == {"max_failures": 4, "lockout_minutes": 60}
+ assert r.json()["login_defaults"] == {"max_failures": 4, "lockout_minutes": 60}
+
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"login": {"max_failures": 2, "lockout_minutes": 5}})
+ assert r.status_code == 200, r.text
+ assert r.json()["login"] == {"max_failures": 2, "lockout_minutes": 5}
+
+ await _register(client, "ivan")
+ await _fail(client, "ivan", 2)
+ r = await _login(client, "ivan", RIGHT)
+ assert r.status_code == 429
+ assert int(r.headers["retry-after"]) <= 300
+
+
+@pytest.mark.asyncio
+async def test_zero_failures_turns_the_lockout_off(client):
+ admin = await _admin_headers(client)
+ await client.patch("/v1/admin/settings", headers=admin,
+ json={"login": {"max_failures": 0}})
+
+ await _register(client, "judy")
+ await _fail(client, "judy", 8)
+ assert (await _login(client, "judy", RIGHT)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_values_are_clamped_and_unknown_keys_refused(client):
+ admin = await _admin_headers(client)
+
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"login": {"max_failures": -3, "lockout_minutes": 10**9}})
+ assert r.status_code == 200
+ low, _ = r.json()["login_bounds"]["max_failures"]
+ _, high = r.json()["login_bounds"]["lockout_minutes"]
+ assert r.json()["login"] == {"max_failures": low, "lockout_minutes": high}
+
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"login": {"lockout_hours": 1}})
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_only_an_admin_changes_them(client):
+ await _admin_headers(client) # an admin exists; this is someone else
+ await _register(client, "mallory")
+ token = (await _login(client, "mallory", RIGHT)).json()["access_token"]
+ r = await client.patch("/v1/admin/settings",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"login": {"max_failures": 0}})
+ assert r.status_code == 403