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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user