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
+29 -11
View File
@@ -45,22 +45,24 @@ Then open http://127.0.0.1:8123.
## Tests
```sh
.venv/bin/python -m tests.test_api # 166 checks, in-process
.venv/bin/python -m tests.test_concurrency # 15 checks, against a real uvicorn
.venv/bin/python -m tests.test_api # 172 checks, in-process
.venv/bin/python -m tests.test_concurrency # 25 checks, against a real uvicorn
```
`test_api` exercises the API end to end against a throwaway database — the auth
gate and session-token signing, nested categories and locations, search across
every indexed field, filter rollups, stock adjustment and history, patch
semantics including explicit nulls, taxonomy cycle rejection, security headers
and asset versioning, login throttling, and the full password-management flow
including scrypt hashing, session invalidation and the recovery CLI.
and asset versioning, login throttling on both the login and change-password
routes, and the full password-management flow including scrypt hashing, session
invalidation and the recovery CLI.
`test_concurrency` needs a real server process, because a lost update only shows
up when two requests genuinely overlap inside SQLite. It fires overlapping
adjustments, patches and creates at one part and asserts the stock log always
sums to the stored quantity, and races taxonomy renames against reads to check
the search index never describes a name the tree no longer has.
sums to the stored quantity; races taxonomy renames against reads to check the
search index never describes a name the tree no longer has; and races session
revocations to check no epoch increment is lost.
## Configuration
@@ -100,15 +102,31 @@ Lives at `/home/jay/srv/parts/` on `.8` and follows the same conventions as the
other services there — `build: .`, external `caddy_web` network, named volume
for `/data`.
`~/srv/parts` is a checkout of this repository, so deploying is a pull:
`~/srv/parts` is a checkout of this repository`git log` there tells you
exactly what is deployed — but **deploys go over rsync, not `git pull`**:
```sh
ssh jay@192.168.50.8
cd ~/srv/parts && git pull && docker compose up -d --build
rsync -az --delete \
--exclude '.venv' --exclude 'data' --exclude '__pycache__' --exclude '*.pyc' \
--exclude '.env' --exclude '.env.bak-*' \
./ jay@192.168.50.8:/home/jay/srv/parts/
ssh jay@192.168.50.8 'cd ~/srv/parts && docker compose up -d --build'
```
`.env` is untracked and gitignored, so it survives the pull. `~/srv` (the infra
repo) ignores `parts/`, since this directory is its own repository.
`.env` is excluded from the transfer, and rsync's `--delete` leaves excluded
files alone, so the deployed credentials survive.
The reason it isn't `git pull`: **gitea on `.8` cannot serve a clone or fetch to
`.8` itself.** Auth succeeds and gitea logs `git-upload-pack ... 200 OK`, then
the transfer dies with `fetch-pack: unexpected disconnect while reading sideband
packet`. Reproduced against both `git.tjm77.com:2222` and `127.0.0.1:2222`, with
`--depth 1`, with protocol v0, and with fsck disabled; `git ls-remote` succeeds
every time, and cloning the same repo from a laptop works. Undiagnosed. Until
it's fixed, the checkout in `~/srv/parts` is placed there by rsyncing the
working tree *including* `.git`, which is why `git status` there is clean.
`~/srv` (the infra repo) ignores `parts/`, since this directory is its own
repository.
The database is in the `parts_parts_data` docker volume, which survives
rebuilds.