1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/accounts/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

54 lines
1.8 KiB
Python

"""
Settings for django-axes, read from the Settings page (AppSettings) on each
use, so an administrator can change them without a restart.
"""
from datetime import timedelta
from ipaddress import ip_address, ip_network
from appsettings.models import AppSettings
from django.conf import settings
DEFAULT_FAILURE_LIMIT = 5
DEFAULT_LOCKOUT_MINUTES = 15
def _positive_int(key, default):
try:
value = int(AppSettings.objects.get(key=key).value)
except (AppSettings.DoesNotExist, ValueError):
return default
return value if value > 0 else default
def failure_limit(request, credentials):
"""AXES_FAILURE_LIMIT: failed logins allowed before the lock."""
return _positive_int("LOGIN_FAILURE_LIMIT", DEFAULT_FAILURE_LIMIT)
def cooloff_time(request):
"""AXES_COOLOFF_TIME: how long a lock lasts."""
return timedelta(minutes=_positive_int("LOGIN_LOCKOUT_MINUTES", DEFAULT_LOCKOUT_MINUTES))
def _from_trusted_proxy(remote):
try:
address = ip_address(remote)
except ValueError:
return False
if address.is_loopback: # the bundled nginx
return True
return any(address in ip_network(net, strict=False) for net in getattr(settings, "LOGIN_TRUSTED_PROXIES", []))
def client_ip(request):
"""
AXES_CLIENT_IP_CALLABLE. Behind the bundled nginx every request comes from
127.0.0.1 and nginx sets X-Real-IP to the client's address (overwriting
any sent by the client). The header is used only for requests from that
proxy, or from the addresses in LOGIN_TRUSTED_PROXIES; from anyone else
it could be forged to escape a lock.
"""
remote = request.META.get("REMOTE_ADDR", "")
real_ip = request.META.get("HTTP_X_REAL_IP", "").strip()
return real_ip if real_ip and _from_trusted_proxy(remote) else remote