Throttle the change-password route and make epoch bumps atomic

Two session-management bugs from the audit:

- /api/password recorded failed attempts against the login budget but never
  checked it, so a borrowed session could guess the current password without
  limit while still locking the owner out of /api/login. Verified: thirteen
  consecutive wrong guesses all returned 403 and none returned 429. It now
  spends from the same budget it was topping up.

- bump_epoch read the epoch and wrote it back without the write lock, so
  concurrent revocations lost increments and sessions that should have been cut
  off survived. Verified: twenty concurrent bumps advanced the counter from 2 to
  6, and twenty concurrent "sign out other devices" calls left four sessions
  authenticated. It takes BEGIN IMMEDIATE now; set_password hashes before
  locking, so scrypt doesn't serialise unrelated writes.

Removing either fix makes its test fail with exactly that symptom.

README corrections: deployment is rsync, not git pull — gitea on .8 cannot
serve a clone to .8 itself, which the deploy section now documents — and the
concurrency check count was understated.

Checks go from 182 to 197.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 11:04:52 -04:00
parent ab82b5e9a9
commit d76642f85d
5 changed files with 183 additions and 30 deletions
+37
View File
@@ -397,6 +397,26 @@ with TestClient(app) as client:
check("wrong current password is refused",
client.post("/api/password",
json={"current_password": "nope", "new_password": "brand-new-secret"}).status_code == 403)
# The change-password route verifies the same credential as the login form,
# so it has to spend from the same budget instead of only topping it up.
auth_mod.clear_failures()
pw_codes = [client.post("/api/password",
json={"current_password": "wrong", "new_password": "brand-new-secret"}).status_code
for _ in range(auth_mod._max_failures() + 3)]
check("guessing through change-password eventually 429s", 429 in pw_codes, str(pw_codes))
check("change-password throttles on the same budget as login",
pw_codes.index(429) == auth_mod._max_failures(), str(pw_codes))
check("a throttled change-password blocks even the right password",
client.post("/api/password",
json={"current_password": "hunter2",
"new_password": "brand-new-secret"}).status_code == 429)
check("login is throttled too once the budget is spent",
client.post("/api/login", json={"password": "hunter2"}).status_code == 429)
auth_mod.clear_failures()
check("clearing the budget restores change-password",
client.post("/api/password",
json={"current_password": "wrong", "new_password": "x"}).status_code in (403, 422))
auth_mod.clear_failures()
check("short new password rejected",
client.post("/api/password",
@@ -438,6 +458,23 @@ with TestClient(app) as client:
check("revoke keeps the calling session", client.get("/api/parts").status_code == 200)
check("revoke cuts off the other session", third.get("/api/parts").status_code == 401)
# --- epoch bumps must not lose increments ---
from concurrent.futures import ThreadPoolExecutor as _Pool
import app.db as _dbmod
def _bump_once(_):
with _dbmod.session() as _c:
auth_mod.bump_epoch(_c)
with _dbmod.session() as _c:
_epoch_before = auth_mod.current_epoch(_c)
with _Pool(max_workers=20) as _ex:
list(_ex.map(_bump_once, range(20)))
with _dbmod.session() as _c:
_epoch_after = auth_mod.current_epoch(_c)
check("20 concurrent epoch bumps advance it by exactly 20",
_epoch_after - _epoch_before == 20, f"{_epoch_before} -> {_epoch_after}")
# --- the hash itself ---
import app.db as _db
with _db.session() as _conn: