Repository navigation
fix(auth): write the verification code once, and check the resend limiter in the same critical section - #212
Merged
Conversation
…iter in the same critical section
`register` / `resend-code` / `forgot-password` wrote the `email_verifications`
row in two steps: `store_verification_code` bound the literal string
`"+10 minutes"` as the `expires_at` **parameter** (so that is what the column
held), and a second statement in a second lock scope rewrote it to
`datetime('now','+10 minutes')`. The reader (`find_valid_verification`) matches
`expires_at > datetime('now')`, so between the two writes the row exists but is
already "expired" — and if the second write fails the code can never be
verified, while a resend hits the 60 s limiter.
The limiter was checked in a different critical section than the write, with no
lock and no await spanning them, so concurrent resends all passed the check and
each mailed its own code to the same address (measured through the real router:
32 concurrent requests -> 2-4 codes with no instrumentation at all; 32/32 once
the existing lock-free gap is widened from microseconds to 300 ms).
- `dao::store_verification_code` takes no `expires_at`: the SQL owns the value,
so writer and reader share one idiom.
- `dao::begin_resend_verification` checks the limiter and writes the new code
inside one critical section (`false` = rate-limited, nothing written).
- `send_code` only mails; the caller writes inside its own lock scope, so the
mail call (which retries) is never made while holding the lock.
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
POST /api/auth/register,/api/auth/resend-codeand/api/auth/forgot-passwordwrote theemail_verificationsrow in two steps, in two different lock scopes:store_verification_codeboundexpires_atas a parameter (?3), so lock 1 stored the literal string'+10 minutes'; only lock 2 made the row meaningful. The reader (find_valid_verification) matchesexpires_at > datetime('now'), and SQLite agrees with the old writer's value only in the second form —sqlite3 ":memory:" "SELECT ('+10 minutes' > datetime('now')), (datetime('now','+10 minutes') > datetime('now'));"returns0|1. Between the two writes, therefore, the row exists but is already "expired" (a user who verifies inside that window gets验证码不存在或已过期,请重新获取), and if the second write ever fails the code can never be verified while a resend hits the 60 s limiter. The invariant this PR encodes: the value the writer stores must be a value the reader's predicate can interpret, and no intermediate state may exist between two writes of the same row.The same two-step shape disabled the 60 s resend limiter. The existence gate, the limiter check (
dao::resend_too_soon) and the write each took the lock separately, with no lock and no.awaitspanning the check and the write, so on the multi-threaded runtime (#[tokio::main], default flavor) a burst of concurrent resends all passed the check and each one mailed its own verification code to the same address — an unauthenticated way to send N mails per burst. A scan of every top-level function shows these three handlers are the only functions that take the sharedArc<Mutex<Connection>>more than once (send_code3,resend_code2,forgot_password2).Related Issue
Changes
src/dao.rs::store_verification_codeno longer takesexpires_at; the statement computes it (datetime('now', '+10 minutes')), so the stored value is by construction one the reader's predicate can interpret. The secondUPDATEdisappears.src/dao.rs::begin_resend_verification(conn, email, code_hash) -> Result<bool>is new: it checksresend_too_soonand writes inside one critical section;falsemeans rate-limited and nothing was written (INSERT OR REPLACEresetscreated_at, so a successful write is the start of the next window).src/routes/mod.rs::send_codenow only mails (returning whether dev mode is on); the code and its hash come fromnew_verification_code(). Callers write inside their own lock scope:registeralongsidecreate_unverified_user(no "user created, code missing" gap),resend_code/forgot_passwordviabegin_resend_verification, both answering 429 onfalse.okresponse are untouched (C2034/C2035 behaviour byte-identical: an unknown address still getsok, no record, no mail), as is the SMTP-failure path (502 + record cleared so an immediate resend is possible).ui/change, no new i18n keys. The 10-minute TTL and the 60 s window are unchanged.Tests
cargo test— 211 passed (209 before, +2 new).cargo fmt --checkclean;cargo clippy --all-targets -- -D warningsclean.New tests added.
verification_code_is_usable_exactly_as_written— after a singlestore_verification_code,find_valid_verificationmust already return the row (no second write is needed for the code to be readable), the hash round-trips, and the storedexpires_atmust not be the literal'+10 minutes'.resend_limiter_and_write_share_one_critical_section—begin_resend_verificationreturnstruethenfalseon two consecutive calls, and the rate-limited call must not have replaced the stored code (otherwise concurrent resends would keep swapping codes under each other).A/B teeth proof (each mutation applied alone, restored byte-identically afterwards, snapshot taken after
cargo fmt):"+10 minutes"as a parameter, no second write):passed=205, and the red set contains the new writer/reader test plus every end-to-end flow that has to verify a code (register_verify_login_full_flow,login_normalizes_email_case,verify_wrong_code_expires_and_attempt_limit,forgot_reset_password_full_flow), i.e. the failure is exactly "the row is written but is not readable".passed=208, red =resend_limiter_and_write_share_one_critical_section,resend_code_rate_limited,forgot_reset_password_full_flow— and the new writer/reader test stays green, so the two new tests measure two different properties.Runtime evidence for the limiter (probe, not committed)
A temporary probe (test module only, reverted byte-identically) fired 32 concurrent
POST /api/auth/resend-coderequests at the real router with an existing, verified account whosecreated_atwas backdated past the 60 s window, and counted the200s — each200is one verification code mailed to the same address:ok=2,ok=4,ok=4over three runs (one row in the table)ok=32 limited=0ok=1 limited=31on three consecutive runsThe 300 ms arm injects only a delay into the gap that already exists (the two lock scopes with nothing held between them); it creates no new window — the un-instrumented arm already reproduces the defect. After the fix the outcome is decided by the mutex rather than by timing, so no timing-dependent test is committed.
Checklist
fix/verify-code-critical-section)fix(auth): ...)