domain: login lockout uses a stale whole-row read-modify-write, racing the counter and clobbering profile edits #56
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
rosa/vernier#56
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found during a code review of the
domaincrate.Location:
crates/domain/src/services.rs:221(authenticate)Severity: Security / Correctness — concurrency
Problem
authenticateatomically increments the failed-attempt counter, then writes a whole-rowupdate_userbuilt from the pre-incrementusersnapshot to set/clear the lock. The increment and the lock write are separate non-atomic calls, andupdate_useroverwrites the whole row (including name/email/biography and the counter).Failure scenario
A user at 9 failed attempts gets two concurrent wrong-password requests: both increment (DB to 10 then 11), both build the update from a stale clone and write it; interleavings can leave the account unlocked with the counter reset, weakening brute-force protection. Separately, the full-row write on any login can revert a name/email/bio edit made in parallel.
Suggested fix
Move the lockout decision onto the model (e.g.
User::record_failed_login(now) -> LockoutDecision) and expose narrow repo ops (record_failed_login/clear_lockout) that update only the lock columns, instead of round-tripping the whole row.Agent Brief
Category: bug
Summary: Lockout state becomes writable only through two narrow atomic repository operations, ending the read-modify-write races in
authenticateDesign decisions were settled in a triage session and recorded in ADR 0011 (lockout state is written only by narrow atomic repo ops) and the Authentication section of CONTEXT.md (Lockout, Record). Read both before starting; the ADR also records the rejected alternatives, so don't re-litigate them.
Current behavior:
authenticateincrements the failed-login counter atomically, but decides whether to lock from the pre-incrementUsersnapshot and writes the result back through the whole-rowupdate_user. Two consequences:update_user_settingsandconfirm_emailalso round-trip the lock columns from stale snapshots.Additionally,
confirm_emailclears the lockout as an unintended side effect, and the attempt that crosses the threshold reportsIncorrectPasswordrather than the lock it just created.Desired behavior:
The two lock columns (
failed_login_attempts,locked_until) are written by exactly two repository operations and nothing else:UPDATEin the Postgres adapter) that increments the counter and, when the post-increment count reaches the policy threshold, zeroes the counter and setslocked_until = now + durationin the same statement. There must be no observable state where the count has moved but the lock decision hasn't. It reports what happened.locked_until, called unconditionally on every successful login. Idempotent.authenticateuses these instead of its two whole-row writes. When recording reports that this attempt entered the Lockout,authenticatereturnsUserLocked(until)immediately (behavior change: previously the crossing attempt read as one more wrong password). The pre-verification "is the user currently locked" gate stays as-is.confirm_emailstops touching lockout state entirely — it only sets the email-confirmed timestamp (deliberate behavior change per ADR 0011).Key interfaces:
LockoutPolicy— new domain type alongsideUser:threshold(attempts) andduration, with aDefaultof 10 attempts / 15 minutes. TheServiceholds one (initialized to default in its constructor) so tests can inject a small threshold instead of scripting ten failures. No app-config surface.LockoutOutcome— what recording returns: either not-locked carrying the fresh attempt count, or locked carrying the expiry instant. (Named Outcome, not Decision — the store reports, the domain doesn't decide from a snapshot.)AppRepository::record_failed_login(user_id, &LockoutPolicy) -> LockoutOutcomeandAppRepository::clear_lockout(user_id)— the only writers of lock columns. The existingincrement_login_attemptsport op is subsumed and should be removed.UpdateUserRequest— losesfailed_login_attemptsandlocked_untilentirely;update_user's SQL stops setting those columns. The type system, not discipline, is what prevents profile writes from clobbering lockout state.Acceptance criteria:
sqlx::testand the memory repo viatokio::test), covering:update_userleaves an existing count and lock unchangedauthenticatetests (mock repo) cover: the crossing attempt returnsUserLocked(until); an already-locked user is refused before password verification; a successful login clears the lockout;confirm_emailleaves lockout state untouchedUpdateUserRequestno longer has lock-column fields (compile-time guarantee)cimise task passesOut of scope:
LockoutPolicyoperator-configurable (env/app config)update_user(profile-vs-profile write races are a separate concern)