fix: follow Flask-Security-Too 5.8.2's corrected is_locked() semantics (#10256)

Flask-Security-Too 5.8.2, released on 12 August 2026, fixed a long-standing
inversion in its login forms: `LoginForm.validate()` previously read a `True`
return from `UserMixin.is_locked()` as "not locked, carry on", and the base
implementation unconditionally returned `True`. Our `User.is_locked()` was
written against that inverted convention, so as soon as CI began resolving
5.8.2 through the loose `Flask-Security-Too==5.8.*` pin, an unlocked user
returned `True`, form validation failed, the login POST redirected with a 302
and every subsequent request arrived as `AnonymousUser`. The server-mode data
isolation tests caught it, though the breakage is not limited to tests: on
5.8.2 nobody could log in at all.

`User.is_locked()` now returns `True` when the account is locked, matching the
corrected upstream contract, and the dependency is floored at 5.8.2 so that we
cannot silently resolve a release which reads the value backwards. The two
conventions are mutually exclusive, hence a floor rather than a version check
in the model. The regression tests are updated to assert the fixed contract.
This commit is contained in:
Dave Page
2026-08-14 11:17:09 +01:00
committed by GitHub
parent 6487f2a93f
commit 35d68c47eb
3 changed files with 20 additions and 12 deletions
+4 -1
View File
@@ -30,7 +30,10 @@ Flask-Mail==0.*
Flask-Migrate==4.*
Flask-Paranoid==0.*
Flask-Security-Too==5.6.*; python_version <= '3.9'
Flask-Security-Too==5.8.*; python_version > '3.9'
# 5.8.2 corrected the inverted UserMixin.is_locked() test in LoginForm;
# User.is_locked() in web/pgadmin/model/__init__.py follows the fixed
# semantics, so anything earlier in the 5.8 series would refuse all logins.
Flask-Security-Too>=5.8.2,<5.9; python_version > '3.9'
Flask-SocketIO==5.6.*
Flask-SQLAlchemy==3.1.*
Flask-WTF==1.2.*; python_version <= '3.9'
+9 -6
View File
@@ -222,17 +222,20 @@ class User(db.Model, UserMixin):
def is_locked(self, form_error=None):
# Flask-Security's LoginForm.validate() calls this after password
# verification and treats the return value inverted: True means
# "not locked, proceed"; False means "locked, fail validation".
# The default UserMixin.is_locked unconditionally returns True,
# which is what allows the /login bypass on a locked account.
# verification and fails validation when it returns True, so True
# means "locked, refuse the login". Flask-Security-Too up to and
# including 5.8.1 had that test inverted (fixed upstream in 5.8.2 by
# pallets-eco/flask-security#1267), which is why requirements.txt
# floors the dependency at 5.8.2: on an older release the value
# below would be read backwards and every unlocked user would be
# refused a session.
if self.locked:
if form_error is not None:
form_error.append(gettext(
'Your account is locked. Please contact the '
'Administrator.'))
return False
return True
return True
return False
class Setting(db.Model, UserScopedMixin):
@@ -26,8 +26,10 @@ class TestLockedUser(BaseTestGenerator):
- `is_active` -> False when the account is deactivated OR locked
(Flask-Login's `login_user()` then refuses the session).
- `is_locked(errors)` -> False when locked, True otherwise; populates
- `is_locked(errors)` -> True when locked, False otherwise; populates
the supplied error list when locked so the form surfaces a message.
Flask-Security-Too <= 5.8.1 read this the other way round, which is
why requirements.txt floors the dependency at 5.8.2.
"""
# Pure model-contract test - no Postgres server interaction needed.
@@ -39,16 +41,16 @@ class TestLockedUser(BaseTestGenerator):
scenarios = [
('active and not locked: login allowed',
dict(active=True, locked=False,
expect_is_active=True, expect_is_locked=True)),
expect_is_active=True, expect_is_locked=False)),
('active and locked: login blocked',
dict(active=True, locked=True,
expect_is_active=False, expect_is_locked=False)),
expect_is_active=False, expect_is_locked=True)),
('inactive and not locked: login blocked',
dict(active=False, locked=False,
expect_is_active=False, expect_is_locked=True)),
expect_is_active=False, expect_is_locked=False)),
('inactive and locked: login blocked',
dict(active=False, locked=True,
expect_is_active=False, expect_is_locked=False)),
expect_is_active=False, expect_is_locked=True)),
]
def runTest(self):