Skip to content

Commit 5695188

Browse files
author
rodrigo.nogueira
committed
feat: introduce progressive lockout tiers for dynamic cool-off periods based on failure count.
1 parent f2af7c9 commit 5695188

6 files changed

Lines changed: 258 additions & 3 deletions

File tree

axes/checks.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django.utils.module_loading import import_string
77

88
from axes.backends import AxesStandaloneBackend
9-
from axes.conf import settings
9+
from axes.conf import LockoutTier, settings
1010

1111

1212
class Messages:
@@ -26,6 +26,14 @@ class Messages:
2626
"AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'."
2727
" This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies."
2828
)
29+
LOCKOUT_TIERS_CONFLICT = (
30+
"AXES_LOCKOUT_TIERS is set alongside AXES_COOLOFF_TIME."
31+
" When tiers are active, AXES_COOLOFF_TIME is ignored."
32+
" Remove AXES_COOLOFF_TIME to silence this warning."
33+
)
34+
LOCKOUT_TIERS_INVALID = (
35+
"AXES_LOCKOUT_TIERS must be a list of LockoutTier instances."
36+
)
2937

3038

3139
class Hints:
@@ -35,6 +43,10 @@ class Hints:
3543
SETTING_DEPRECATED = None
3644
CALLABLE_INVALID = None
3745
LOCKOUT_PARAMETERS_INVALID = "Add 'ip_address' to AXES_LOCKOUT_PARAMETERS."
46+
LOCKOUT_TIERS_CONFLICT = "Remove AXES_COOLOFF_TIME when using AXES_LOCKOUT_TIERS."
47+
LOCKOUT_TIERS_INVALID = (
48+
"Use: AXES_LOCKOUT_TIERS = [LockoutTier(failures=3, cooloff=timedelta(minutes=15)), ...]"
49+
)
3850

3951

4052
class Codes:
@@ -44,6 +56,8 @@ class Codes:
4456
SETTING_DEPRECATED = "axes.W004"
4557
CALLABLE_INVALID = "axes.W005"
4658
LOCKOUT_PARAMETERS_INVALID = "axes.W006"
59+
LOCKOUT_TIERS_CONFLICT = "axes.W007"
60+
LOCKOUT_TIERS_INVALID = "axes.W008"
4761

4862

4963
@register(Tags.security, Tags.caches, Tags.compatibility)
@@ -192,6 +206,41 @@ def axes_lockout_params_check(app_configs, **kwargs): # pylint: disable=unused-
192206
return warnings
193207

194208

209+
@register(Tags.security)
210+
def axes_lockout_tiers_check(app_configs, **kwargs): # pylint: disable=unused-argument
211+
warnings = []
212+
tiers = getattr(settings, "AXES_LOCKOUT_TIERS", None)
213+
if tiers is None:
214+
return warnings
215+
216+
if not _is_valid_tiers_list(tiers):
217+
warnings.append(
218+
Warning(
219+
msg=Messages.LOCKOUT_TIERS_INVALID,
220+
hint=Hints.LOCKOUT_TIERS_INVALID,
221+
id=Codes.LOCKOUT_TIERS_INVALID,
222+
)
223+
)
224+
return warnings
225+
226+
if getattr(settings, "AXES_COOLOFF_TIME", None) is not None:
227+
warnings.append(
228+
Warning(
229+
msg=Messages.LOCKOUT_TIERS_CONFLICT,
230+
hint=Hints.LOCKOUT_TIERS_CONFLICT,
231+
id=Codes.LOCKOUT_TIERS_CONFLICT,
232+
)
233+
)
234+
235+
return warnings
236+
237+
238+
def _is_valid_tiers_list(tiers) -> bool:
239+
if not isinstance(tiers, (list, tuple)):
240+
return False
241+
return all(isinstance(t, LockoutTier) for t in tiers)
242+
243+
195244
@register
196245
def axes_conf_check(app_configs, **kwargs): # pylint: disable=unused-argument
197246
warnings = []

axes/conf.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
from dataclasses import dataclass
2+
from datetime import timedelta
3+
14
from django.conf import settings
25
from django.contrib.auth import get_user_model
36
from django.utils.translation import gettext_lazy as _
47

8+
9+
@dataclass(frozen=True, order=True)
10+
class LockoutTier:
11+
failures: int
12+
cooloff: timedelta
13+
514
# disable plugin when set to False
615
settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
716

@@ -87,6 +96,10 @@
8796

8897
settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
8998

99+
# Progressive lockout tiers: list of LockoutTier(failures, cooloff) instances.
100+
# When set, overrides AXES_FAILURE_LIMIT and AXES_COOLOFF_TIME.
101+
settings.AXES_LOCKOUT_TIERS = getattr(settings, "AXES_LOCKOUT_TIERS", None)
102+
90103
settings.AXES_USE_ATTEMPT_EXPIRATION = getattr(settings, "AXES_USE_ATTEMPT_EXPIRATION", False)
91104

92105
settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)

axes/helpers.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from django.utils.encoding import force_bytes
1212
from django.utils.module_loading import import_string
1313

14-
from axes.conf import settings
14+
from axes.conf import LockoutTier, settings
1515
from axes.models import AccessBase
1616

1717
log = getLogger(__name__)
@@ -60,9 +60,16 @@ def get_cool_off(request: Optional[HttpRequest] = None) -> Optional[timedelta]:
6060
offers a unified _timedelta or None_ representation of that configuration for use with the
6161
Axes internal implementations.
6262
63+
When ``AXES_LOCKOUT_TIERS`` is configured, the cooloff is resolved from the
64+
matching tier based on the failure count attached to the request.
65+
6366
:exception TypeError: if settings.AXES_COOLOFF_TIME is of wrong type.
6467
"""
6568

69+
tier = _resolve_tier_from_request(request)
70+
if tier is not None:
71+
return tier.cooloff
72+
6673
cool_off = settings.AXES_COOLOFF_TIME
6774

6875
if isinstance(cool_off, int):
@@ -100,6 +107,31 @@ def get_cool_off_iso8601(delta: timedelta) -> str:
100107
return f"P{days_str}T{time_str}"
101108
return f"P{days_str}"
102109

110+
111+
def get_lockout_tier(failures: int) -> Optional[LockoutTier]:
112+
"""Return the matching ``LockoutTier`` for *failures*, or ``None``."""
113+
tiers = settings.AXES_LOCKOUT_TIERS
114+
if not tiers:
115+
return None
116+
sorted_tiers = sorted(tiers, key=lambda t: t.failures)
117+
matched = None
118+
for tier in sorted_tiers:
119+
if failures >= tier.failures:
120+
matched = tier
121+
return matched
122+
123+
124+
def _resolve_tier_from_request(
125+
request: Optional[HttpRequest],
126+
) -> Optional[LockoutTier]:
127+
"""Extract failure count from *request* and resolve the tier."""
128+
if not settings.AXES_LOCKOUT_TIERS or request is None:
129+
return None
130+
failures = getattr(request, "axes_failures_since_start", None)
131+
if failures is None:
132+
return None
133+
return get_lockout_tier(failures)
134+
103135
def get_attempt_expiration(request: Optional[HttpRequest] = None) -> datetime:
104136
"""
105137
Get threshold for fetching access attempts from the database.
@@ -442,6 +474,11 @@ def get_query_str(query: Type[QueryDict], max_length: int = 1024) -> str:
442474

443475

444476
def get_failure_limit(request: HttpRequest, credentials) -> int:
477+
tiers = settings.AXES_LOCKOUT_TIERS
478+
if tiers:
479+
sorted_tiers = sorted(tiers, key=lambda t: t.failures)
480+
return sorted_tiers[0].failures
481+
445482
if callable(settings.AXES_FAILURE_LIMIT):
446483
return settings.AXES_FAILURE_LIMIT( # pylint: disable=not-callable
447484
request, credentials
@@ -454,7 +491,7 @@ def get_failure_limit(request: HttpRequest, credentials) -> int:
454491

455492

456493
def get_lockout_message() -> str:
457-
if settings.AXES_COOLOFF_TIME:
494+
if settings.AXES_COOLOFF_TIME or settings.AXES_LOCKOUT_TIERS:
458495
return settings.AXES_COOLOFF_MESSAGE
459496
return settings.AXES_PERMALOCK_MESSAGE
460497

@@ -486,8 +523,10 @@ def get_lockout_response(
486523
)
487524

488525
status = settings.AXES_HTTP_RESPONSE_CODE
526+
failures = getattr(request, "axes_failures_since_start", None) or 0
489527
context = {
490528
"failure_limit": get_failure_limit(request, credentials),
529+
"failure_count": failures,
491530
"username": get_client_username(request, credentials) or "",
492531
}
493532

docs/4_configuration.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ The following ``settings.py`` options are available for customizing Axes behavio
2525
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
2626
| AXES_COOLOFF_TIME | None | If set, defines a period of inactivity after which old failed login attempts will be cleared. Can be set to a Python timedelta object, an integer, a float, a callable, or a string path to a callable which takes the request as argument. If an integer or float, will be interpreted as a number of hours: ``AXES_COOLOFF_TIME = 2`` 2 hours, ``AXES_COOLOFF_TIME = 2.0`` 2 hours, 120 minutes, ``AXES_COOLOFF_TIME = 1.7`` 1.7 hours, 102 minutes, 6120 seconds |
2727
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
28+
| AXES_LOCKOUT_TIERS | None | A list of ``LockoutTier(failures, cooloff)`` instances that define progressive lockout durations. When set, overrides ``AXES_FAILURE_LIMIT`` and ``AXES_COOLOFF_TIME``. The lowest tier threshold becomes the effective failure limit, and each subsequent tier applies a longer cool-off. Example: ``from datetime import timedelta``, ``from axes.conf import LockoutTier``, ``AXES_LOCKOUT_TIERS = [LockoutTier(failures=3, cooloff=timedelta(minutes=15)), LockoutTier(failures=6, cooloff=timedelta(hours=2)), LockoutTier(failures=10, cooloff=timedelta(days=1))]``. With this configuration: 3 failures → 15 min lockout, 6 failures → 2 h, 10+ failures → 24 h. |
29+
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
2830
| AXES_ONLY_ADMIN_SITE | False | If ``True``, lock is only enabled for admin site. Admin site is determined by checking request path against the path of ``"admin:index"`` view. If admin urls are not registered in current urlconf, all requests will not be locked. |
2931
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
3032
| AXES_ONLY_USER_FAILURES | False | DEPRECATED: USE ``AXES_LOCKOUT_PARAMETERS`` INSTEAD. If ``True``, only lock based on username, and never lock based on IP if attempts exceed the limit. Otherwise utilize the existing IP and user locking logic. |

tests/test_checks.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
from datetime import timedelta
2+
13
from django.core.checks import run_checks, Warning # pylint: disable=redefined-builtin
24
from django.test import override_settings, modify_settings
35

46
from axes.backends import AxesStandaloneBackend
57
from axes.checks import Messages, Hints, Codes
8+
from axes.conf import LockoutTier
69
from tests.base import AxesTestCase
710

811

@@ -129,3 +132,46 @@ def test_invalid_import_path(self):
129132
def test_valid_callable(self):
130133
warnings = run_checks()
131134
self.assertEqual(warnings, [])
135+
136+
137+
class LockoutTiersCheckTestCase(AxesTestCase):
138+
SAMPLE_TIERS = [
139+
LockoutTier(failures=3, cooloff=timedelta(minutes=15)),
140+
LockoutTier(failures=6, cooloff=timedelta(hours=2)),
141+
]
142+
143+
@override_settings(AXES_LOCKOUT_TIERS=SAMPLE_TIERS, AXES_COOLOFF_TIME=None)
144+
def test_tiers_alone_no_warning(self):
145+
warnings = run_checks()
146+
self.assertEqual(warnings, [])
147+
148+
@override_settings(AXES_LOCKOUT_TIERS=SAMPLE_TIERS, AXES_COOLOFF_TIME=1)
149+
def test_tiers_with_cooloff_time_warns(self):
150+
warnings = run_checks()
151+
warning = Warning(
152+
msg=Messages.LOCKOUT_TIERS_CONFLICT,
153+
hint=Hints.LOCKOUT_TIERS_CONFLICT,
154+
id=Codes.LOCKOUT_TIERS_CONFLICT,
155+
)
156+
self.assertIn(warning, warnings)
157+
158+
@override_settings(AXES_LOCKOUT_TIERS="not a list")
159+
def test_tiers_invalid_format_warns(self):
160+
warnings = run_checks()
161+
warning = Warning(
162+
msg=Messages.LOCKOUT_TIERS_INVALID,
163+
hint=Hints.LOCKOUT_TIERS_INVALID,
164+
id=Codes.LOCKOUT_TIERS_INVALID,
165+
)
166+
self.assertIn(warning, warnings)
167+
168+
@override_settings(AXES_LOCKOUT_TIERS=[(3, timedelta(minutes=15))])
169+
def test_tiers_plain_tuples_warns(self):
170+
warnings = run_checks()
171+
warning = Warning(
172+
msg=Messages.LOCKOUT_TIERS_INVALID,
173+
hint=Hints.LOCKOUT_TIERS_INVALID,
174+
id=Codes.LOCKOUT_TIERS_INVALID,
175+
)
176+
self.assertIn(warning, warnings)
177+

0 commit comments

Comments
 (0)