Repository navigation
fix(auth): run the password KDF outside the shared DB lock - #213
Merged
Merged
Conversation
`AppState.db` is a single process-wide `Arc<Mutex<Connection>>`, and four
handlers ran argon2 while holding it: `login`, `register`, `reset_password`
and `change_password`. Argon2 is deliberately expensive (~0.24 s with default
parameters), so a single unauthenticated request stalled every DB-backed path
in the process for that long. Measured through the real router:
login (known e-mail, wrong password) -> max_lock_wait 235.5 ms
register (fresh e-mail) -> max_lock_wait 228.2 ms
8 concurrent -> 1.907 s (8 x ~235 ms, serialized)
control (unknown e-mail, returns early) -> 0.00 ms
No other slow work runs inside a lock: a sweep of all non-test `db.lock()`
sites left argon2 as the only case. The fix is the repo's own rule ("send mail
outside the lock", "keep the lock scope to the synchronous section") applied
to the password path: query under the lock, hash/verify outside it.
- `login`: read the stored hash under the lock, verify outside it.
- `register`: hash and generate the verification code outside the lock, then
re-check the e-mail under the lock before inserting. The e-mail check is
repeated because a concurrent registration may now win the race between the
first check and the insert; the second check returns the same 409 the first
one would have, instead of turning the `users.email` UNIQUE constraint into
a 500.
- `reset_password`: verify the code under one lock scope, hash outside it, and
re-check that the code is still valid under the final lock scope before
writing the new password.
- `change_password`: read the old hash under the lock; both argon2 calls
(verify old, hash new) run outside it.
- `dao::verify_user_password` is removed — `find_user_by_email` already returns
`(id, password_hash)`, so no helper is needed to verify under the lock.
Tests: four new tests measure lock availability with a counter thread that
`try_lock`s the real `AppState.db` once per millisecond while a request runs.
The fixture hash carries heavy argon2 parameters (~0.8 s to verify), so the
KDF runtime is guaranteed by the PHC string rather than by a timing threshold.
The assertion is a count, not a duration: before the fix each request held the
lock from its first query to its last write (0 hits); after the fix the lock is
free during the KDF (hundreds of hits). A positive control asserts the observer
can actually acquire the lock while it is idle, so a silent observer cannot
pass the test by reporting zero hits.
before -> after (observer hits): login 0 -> 158, register 0 -> 159,
reset_password 1 -> 525, change_password 0 -> 670
`cargo test`: 215 passed. `cargo fmt --check` and
`cargo clippy --all-targets -- -D warnings` are clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AppState.dbis a single process-wideArc<Mutex<Connection>>, and four handlers ran argon2 while holding it:login,register,reset_passwordandchange_password. Argon2 is deliberately expensive (~0.24 s with the default parameters), so one unauthenticated request blocked every DB-backed path in the process (including order settlement) for that long.Measured through the real router with a thread timing
st.db.lock():max_lock_waitlogin(known e-mail, wrong password — no auth needed)register(fresh e-mail — no preconditions at all)A sweep of every non-test
db.lock()site found argon2 to be the only slow work running inside a lock, so the fix applies the repo's own rule ("send mail outside the lock", "keep the lock scope to the synchronous section") to the password path.Related Issue
No linked issue — found while auditing lock hold times. Happy to open one if you'd prefer this tracked.
Changes
login: read the stored hash under the lock, verify the password outside it.register: hash the password (and build the verification code) outside the lock, then re-check the e-mail under the lock before inserting. The e-mail check is repeated because the KDF window now lets a concurrent registration win the race between the first check and the insert; the second check returns the same409the first one would have, instead of letting theusers.emailUNIQUE constraint turn into a500.reset_password: verify the code under one lock scope, hash outside it, and re-check that the code is still valid under the final lock scope before writing the new password.change_password: read the old hash under the lock; both argon2 calls (verify the old password, hash the new one) run outside it.dao::verify_user_password—find_user_by_emailalready returns(id, password_hash), so no helper is needed to verify under the lock.Tests
cargo test— 215 passed, 0 failedcargo fmt --check— cleancargo clippy --all-targets -- -D warnings— cleanThe new tests measure lock availability with a counter thread that
try_locks the realAppState.dbonce per millisecond while a request runs. The fixture hash carries heavy argon2 parameters (~0.8 s to verify), so the KDF runtime is guaranteed by the PHC string rather than by a timing threshold:login_verifies_the_password_outside_the_db_lockregister_hashes_the_password_outside_the_db_lockreset_password_hashes_the_password_outside_the_db_lockchange_password_hashes_the_password_outside_the_db_lockThe assertion is a count, not a duration: before the fix each request held the lock from its first query to its last write; after the fix the lock is free during the KDF. A positive control asserts the observer really can acquire the lock while it is idle, so a silently broken observer cannot pass by reporting zero hits. Reverting each handler individually turns exactly its own test red (4 mutations → 4 distinct single-test failures).
Checklist
fix/)fix(auth): ...)src/dao.rs,src/routes/mod.rs)