Skip to content

Production fixes from legacy migration - #105

Merged
shaddi merged 6 commits into
mainfrom
fix-legacy-sha256-login
Jul 6, 2026
Merged

Production fixes from legacy migration#105
shaddi merged 6 commits into
mainfrom
fix-legacy-sha256-login

Conversation

@shaddi

@shaddi shaddi commented Jul 6, 2026

Copy link
Copy Markdown
Member

A collection of fixes from prod that we hit when a legacy user went to do a new filing:

  1. Auth: werkzeug 3 removed our legacy hash method, which breaks old user logins. This transparently re-hashes them when users login. Additionally, increased the validity of expiration links and also made them one-time use.
  2. Tile generation: large fabric sets wind up requiring a large number of FDs during the tippecanoe pass. The default container FD limit causes tippecanoe to fail; this increases the limit and surfaces tippecanoe failure causes in logs.
  3. Filing: During investigation, identified that the "ready to file" check doesn't validate that fabric data was actually imported successfully; now gates on this.

shaddi added 6 commits July 6, 2026 13:00
werkzeug 3 removed the legacy salted-sha256 hash method, so
check_password_hash raises ValueError on any account whose stored hash
predates the move to pbkdf2:sha256 — every login attempt on such an
account returned a raw 500, locking it out entirely.

- utils/passwords.py (new): verify_password() checks current pbkdf2
  hashes via werkzeug and legacy sha256$salt$digest hashes via the
  stdlib HMAC primitive old werkzeug used (validated against werkzeug
  2.2.3, the version that wrote them). Malformed/None hashes read as
  invalid credentials instead of raising.
- Route all three login handlers (/auth/login, /api/login,
  /admin/login) through it; on a successful legacy-hash login the
  stored hash is transparently rewritten to pbkdf2:sha256, so users
  keep their existing passwords with no forced reset.
- Tests: legacy round-trip vector, migration on all three handlers,
  wrong password leaves the hash untouched, malformed/None hashes can
  never 500 a login.
Users routinely open verify/reset emails well after they arrive; a
15-minute window made valid links read as broken. An hour matches
common practice for reset links. Named constant + a test pinning the
lifetime. Note: tokens are stateless and not single-use, so this is
also the replay window for an already-used link — fine at an hour,
revisit if we ever make links single-use.
Email tokens (verify / reset / join-org) were stateless JWTs, so a
leaked or already-used link kept working until it expired — a window
the previous commit widened to an hour.

- create_email_token stamps a jti; consume_email_token records it in
  the new used_email_token table on first successful use (the unique
  constraint is the gate, so concurrent uses can't both win; expired
  rows are pruned opportunistically)
- all four consuming handlers (/api/reset_password, /api/verify_token,
  /auth/verify/<token>, /auth/reset/<token> POST) refuse replays;
  consumption happens after domain checks pass and before any mutation
- replayed verify links render as verified-successfully when the
  account is already verified (re-clicks, mail-scanner prefetch);
  reset links only burn on the form POST, so scanner GETs are harmless
- tokens minted before jti stamping are refused; they age out within
  the one-hour lifetime of a deploy
- alembic a9e5d21c7b48 adds used_email_token; migration parity and
  downgrade covered by the existing migration smoke test
tippecanoe opens temporary files scaled to the host core count plus
per-tile geometry files. On a many-core data host a large folder's tile
build exceeds Docker's default soft nofile limit of 1024 and tippecanoe
aborts with EMFILE ("Too many open files") — deterministically, so
Celery's retries fail identically and the filing is left with imported
fabric but no tiles. Small builds stay under the limit, which is why
the failure looked intermittent.

Verified on the data host: the exact failing tippecanoe command
succeeds with the soft limit at 65536 (well under the 524288 hard cap).
Recreating the worker container applies it; failed folders then just
need a map regeneration.
run_tippecanoe captured stderr but only printed it after a successful
run: with check=True the CalledProcessError raised before the print, so
on the one path where stderr matters it was discarded. A prod tile
failure stored only "returned non-zero exit status 1" as the task
result and took shell access on the worker to diagnose (the real error
was EMFILE — see the deploy ulimit commit).

Failures now raise RuntimeError carrying the tail of tippecanoe's
actual stderr, which is what lands in celerytaskinfo.result, and log
it; successful runs log stderr (progress/counts) instead of print.
The fabric debt in submission_debts cleared as soon as a fabric file
row existed — but that row is committed synchronously at upload, before
(and regardless of whether) the worker import succeeds. A failed import
left the badge saying "Ready to submit" on a filing whose fabric never
loaded, i.e. a submittable filing with no fabric-derived served
locations.

The gate now requires fabric_data rows to exist for the folder's fabric
files — the imported locations are the source of truth. When the file
row exists but no rows have landed, the latest Fabric task's status
shapes the message: PENDING/STARTED/RETRY reads "import in progress",
anything else "import failed — retry or re-upload". Either way the
filing is not ready.
@shaddi shaddi changed the title auth: migrate legacy hashes on login Production fixes from legacy migration Jul 6, 2026
@shaddi
shaddi merged commit d7c7bf9 into main Jul 6, 2026
3 checks passed
@shaddi
shaddi deleted the fix-legacy-sha256-login branch July 6, 2026 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant