Skip to content

fix(auth): per-user PasswordHistory uniqueness (login lockout) - #3173

Merged
FrankApiyo merged 7 commits into
mainfrom
feat/password-history-per-user
Jul 16, 2026
Merged

fix(auth): per-user PasswordHistory uniqueness (login lockout)#3173
FrankApiyo merged 7 commits into
mainfrom
feat/password-history-per-user

Conversation

@FrankApiyo

@FrankApiyo FrankApiyo commented Jul 15, 2026

Copy link
Copy Markdown
Member

Changes / Features implemented

PasswordHistory's global unique=True on hashed_password could lock users out at login when the pre-save hash upgrade hit a duplicate hash (reproduced from a Sentry trace). Login no longer fails on this bookkeeping write, and uniqueness is re-scoped to per-user.

Steps taken to verify this change does what is intended

  • Unit tests: the login-lockout repro, the explicit duplicate swallow, and per-user vs global uniqueness

Side effects of implementing this change

  • Schema change: a new migration alters the uniqueness constraint

Before submitting this PR for review, please make sure you have:

  • Included tests
  • Updated documentation

Closes #3175

…f login

Django upgrades a stale password hash on login (re-hash + save). The
PasswordHistory pre_save handler re-recorded the previous hash, but
hashed_password is globally unique, so once that hash was already stored
the unguarded create() raised IntegrityError and aborted the whole
User.save() — the upgraded hash never persisted and every subsequent
login repeated the failure (permanent lockout). Swallow the duplicate
inside a savepoint so it never breaks the user save.

Reproduces ONADATA Sentry 83483831.
Address the code-quality bot's empty-except finding: replace the bare
pass with an explicit return + comment. Behavior unchanged.
The global unique constraint on hashed_password was the real defect
behind the login lockout: it made one user's history collide with
another's, and forced a defensive IntegrityError swallow on the
re-hash-on-login path. Re-scope uniqueness to (user, hashed_password)
and record via get_or_create so re-recording a hash the user already
has is idempotent — no swallow needed, no history lost.
Was 0021 on the combined verified-email-change branch, chained after
the PendingEmailChange migration; this PR is independent of that one,
so depend directly on 0019. If the email-change PR merges first, this
renumbers again (or vice versa).
@FrankApiyo
FrankApiyo force-pushed the feat/password-history-per-user branch 2 times, most recently from e524b9b to db42a01 Compare July 16, 2026 07:13
@FrankApiyo
FrankApiyo force-pushed the feat/password-history-per-user branch from db42a01 to 416e453 Compare July 16, 2026 07:35
The previous commit ordered by 'created_at', which does not exist on
PasswordHistory and would raise FieldError at validation time. Use the
actual 'changed_at' field and add a regression test proving the
history limit keeps the most recently changed passwords rather than
an arbitrary unordered slice.
@FrankApiyo
FrankApiyo merged commit b52a4bc into main Jul 16, 2026
16 of 17 checks passed
@FrankApiyo
FrankApiyo deleted the feat/password-history-per-user branch July 16, 2026 10:54
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.

IntegrityError: duplicate key value violates unique constraint "main_passwordhistory_hashed_password_key"

3 participants