@@ -60,8 +60,11 @@ def test_expired_token_fails(self):
6060class TestLoginEndpoint :
6161 def setup_method (self ):
6262 from app .config import settings
63+ from app .routes import admin_auth
6364
6465 self ._prior = (settings .ADMIN_EMAIL , settings .ADMIN_PASSWORD_HASH , settings .SESSION_SECRET_KEY )
66+ admin_auth ._login_limiter ._failures .clear ()
67+ admin_auth ._login_limiter ._locked_until .clear ()
6568
6669 def teardown_method (self ):
6770 from app .config import settings
@@ -118,6 +121,84 @@ def test_login_email_check_is_case_insensitive(self, monkeypatch):
118121 assert response .status_code == 200
119122
120123
124+ class TestLoginLockout :
125+ """No lockout existed before this — an attacker could brute-force
126+ the single admin password with unlimited attempts. See
127+ app/services/rate_limit.py::LoginAttemptLimiter.
128+ """
129+
130+ def setup_method (self ):
131+ from app .config import settings
132+ from app .routes import admin_auth
133+
134+ self .admin_auth = admin_auth
135+ self ._prior = (settings .ADMIN_EMAIL , settings .ADMIN_PASSWORD_HASH , settings .SESSION_SECRET_KEY )
136+ admin_auth ._login_limiter ._failures .clear ()
137+ admin_auth ._login_limiter ._locked_until .clear ()
138+ settings .ADMIN_EMAIL = "lockout-test@example.org"
139+ settings .ADMIN_PASSWORD_HASH = self ._hash ("a-strong-password-123" )
140+ settings .SESSION_SECRET_KEY = "test-secret"
141+
142+ def teardown_method (self ):
143+ from app .config import settings
144+
145+ settings .ADMIN_EMAIL , settings .ADMIN_PASSWORD_HASH , settings .SESSION_SECRET_KEY = self ._prior
146+ self .admin_auth ._login_limiter ._failures .clear ()
147+ self .admin_auth ._login_limiter ._locked_until .clear ()
148+
149+ @staticmethod
150+ def _hash (password : str ) -> str :
151+ from app .security import hash_password
152+
153+ return hash_password (password )
154+
155+ def _bad_login (self ):
156+ return client .post (
157+ "/api/v1/admin/login" ,
158+ json = {"email" : "lockout-test@example.org" , "password" : "wrong-password" },
159+ )
160+
161+ def test_locks_out_after_max_attempts (self ):
162+ for _ in range (self .admin_auth ._LOGIN_MAX_ATTEMPTS ):
163+ response = self ._bad_login ()
164+ assert response .status_code == 401
165+ # One more, still within the window, now locked out.
166+ locked = self ._bad_login ()
167+ assert locked .status_code == 429
168+
169+ def test_locked_out_rejects_even_the_correct_password (self ):
170+ for _ in range (self .admin_auth ._LOGIN_MAX_ATTEMPTS ):
171+ self ._bad_login ()
172+ response = client .post (
173+ "/api/v1/admin/login" ,
174+ json = {"email" : "lockout-test@example.org" , "password" : "a-strong-password-123" },
175+ )
176+ assert response .status_code == 429
177+
178+ def test_successful_login_resets_the_failure_count (self ):
179+ for _ in range (self .admin_auth ._LOGIN_MAX_ATTEMPTS - 1 ):
180+ self ._bad_login ()
181+ success = client .post (
182+ "/api/v1/admin/login" ,
183+ json = {"email" : "lockout-test@example.org" , "password" : "a-strong-password-123" },
184+ )
185+ assert success .status_code == 200
186+ # Failure count should be cleared, not still one shy of lockout.
187+ response = self ._bad_login ()
188+ assert response .status_code == 401
189+
190+ def test_different_email_is_not_affected_by_another_lockout (self ):
191+ for _ in range (self .admin_auth ._LOGIN_MAX_ATTEMPTS ):
192+ self ._bad_login ()
193+ # A wrong-but-different email must not be caught by the
194+ # lockout recorded for "lockout-test@example.org".
195+ response = client .post (
196+ "/api/v1/admin/login" ,
197+ json = {"email" : "someone-else@example.org" , "password" : "whatever" },
198+ )
199+ assert response .status_code == 401
200+
201+
121202class TestSessionEndpoint :
122203 def setup_method (self ):
123204 from app .config import settings
0 commit comments