1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +00:00
webvirtcloud/accounts/test_login_lockout.py
catborise c2fea2aa39 feat(accounts): failed logins lock a user for the address they come from
Nothing limited failed logins: a password could be guessed at the speed
the server answers. django-axes (8.3.1, Django 5.2, Python 3.10-3.13)
now counts them.

- A user is locked for the address the failed logins come from
  (username and IP together), so an attacker cannot lock someone out
  from elsewhere and users behind one NAT address do not share a lock.
  A successful login resets the count. axes sits in front of every
  authentication: the login page with or without OTP, LDAP and the API.
- The number of failed logins (default 5) and the lock time in minutes
  (default 15) are on the Settings page (LOGIN_FAILURE_LIMIT,
  LOGIN_LOCKOUT_MINUTES, added by migration appsettings.0014) and apply
  at once; an invalid value falls back to the default. Unlock with
  "manage.py axes_reset_username <username>".
- Behind the bundled nginx every request comes from 127.0.0.1; the
  client address is read from X-Real-IP, which nginx sets, but only for
  requests from a loopback address or from LOGIN_TRUSTED_PROXIES, so a
  forged header does not escape a lock.

settings.py.template gets the app, middleware, backend and AXES_*
settings; README tells existing installations what to copy. Tests that
call client.login now pass a request, which the axes backend needs.
The new tests fail with axes disabled.
2026-10-06 11:05:03 +03:00

91 lines
4.6 KiB
Python

"""Failed logins lock a username for the address it is tried from
(django-axes). The number of attempts and the lock time come from the
Settings page."""
from datetime import timedelta
from appsettings.models import AppSettings
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
from accounts.lockout import client_ip, cooloff_time, failure_limit
@override_settings(OTP_ENABLED=False)
class LoginLockoutTests(TestCase):
def setUp(self):
get_user_model().objects.create_user("lo-user", password="right-pass-1")
AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="3")
def login(self, password, ip="203.0.113.10", via_proxy=True):
meta = {"REMOTE_ADDR": "127.0.0.1", "HTTP_X_REAL_IP": ip} if via_proxy else {"REMOTE_ADDR": ip}
return self.client.post(reverse("accounts:login"), {"username": "lo-user", "password": password}, **meta)
def test_the_limit_from_the_settings_page_locks_the_user_at_that_address(self):
for _ in range(3):
self.login("wrong")
self.assertEqual(self.login("right-pass-1").status_code, 429)
# another address is not locked: an attacker cannot lock someone out
self.assertEqual(self.login("right-pass-1", ip="198.51.100.7").status_code, 302)
def test_a_successful_login_starts_the_count_again(self):
self.login("wrong")
self.login("wrong")
self.assertEqual(self.login("right-pass-1").status_code, 302)
self.client.logout()
self.login("wrong")
self.login("wrong")
self.assertEqual(self.login("right-pass-1").status_code, 302)
def test_the_proxy_header_is_trusted_only_from_the_local_proxy(self):
# direct requests: X-Real-IP is ignored, so changing it does not escape the lock
for n in range(3):
self.client.post(reverse("accounts:login"), {"username": "lo-user", "password": "wrong"},
REMOTE_ADDR="203.0.113.20", HTTP_X_REAL_IP=f"10.0.0.{n}")
response = self.client.post(reverse("accounts:login"), {"username": "lo-user", "password": "right-pass-1"},
REMOTE_ADDR="203.0.113.20", HTTP_X_REAL_IP="10.0.0.99")
self.assertEqual(response.status_code, 429)
class LockoutSettingsTests(TestCase):
def request(self, remote, real_ip=None):
meta = {"REMOTE_ADDR": remote}
if real_ip:
meta["HTTP_X_REAL_IP"] = real_ip
return RequestFactory().post("/", **meta)
def test_values_from_the_settings_page(self):
AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="7")
AppSettings.objects.filter(key="LOGIN_LOCKOUT_MINUTES").update(value="30")
self.assertEqual(failure_limit(None, {}), 7)
self.assertEqual(cooloff_time(None), timedelta(minutes=30))
def test_defaults_and_invalid_values(self):
for value in ("", "abc", "0", "-3"):
with self.subTest(value=value):
AppSettings.objects.filter(key__in=["LOGIN_FAILURE_LIMIT", "LOGIN_LOCKOUT_MINUTES"]).update(value=value)
self.assertEqual(failure_limit(None, {}), 5)
self.assertEqual(cooloff_time(None), timedelta(minutes=15))
def test_client_ip(self):
self.assertEqual(client_ip(self.request("127.0.0.1", "203.0.113.5")), "203.0.113.5")
self.assertEqual(client_ip(self.request("::1", "203.0.113.5")), "203.0.113.5")
self.assertEqual(client_ip(self.request("127.0.0.1")), "127.0.0.1")
self.assertEqual(client_ip(self.request("198.51.100.2", "203.0.113.5")), "198.51.100.2")
def test_client_ip_behind_a_configured_proxy(self):
with self.settings(LOGIN_TRUSTED_PROXIES=["172.18.0.0/16", "10.0.0.5"]):
self.assertEqual(client_ip(self.request("172.18.3.4", "203.0.113.5")), "203.0.113.5")
self.assertEqual(client_ip(self.request("10.0.0.5", "203.0.113.6")), "203.0.113.6")
self.assertEqual(client_ip(self.request("10.0.0.6", "203.0.113.6")), "10.0.0.6")
class SettingsPageTests(TestCase):
def test_both_values_are_on_the_settings_page(self):
self.client.force_login(get_user_model().objects.create_superuser("lo-admin", "lo@example.com", "pw"))
page = self.client.get(reverse("appsettings")).content.decode()
self.assertIn('name="LOGIN_FAILURE_LIMIT"', page)
self.assertIn('name="LOGIN_LOCKOUT_MINUTES"', page)
self.client.post(reverse("appsettings"), {"LOGIN_LOCKOUT_MINUTES": "45"})
self.assertEqual(cooloff_time(None), timedelta(minutes=45))