Skip to content

Commit 2fb4c81

Browse files
browniebrokealeksihakli
authored andcommitted
feat: pass username to AXES_COOLOFF_TIME callback
If the AXES_COOLOFF_TIME is a callable or path to a callable taking an argument, pass the username to it. This should enable users to customize the cool off to be user dependant, and possibly implement a growing cool-off time: - First lockout cools off after 5 mins - Second one after 10 mins - etc...
1 parent 3fa7fce commit 2fb4c81

File tree

5 files changed

+51
-11
lines changed

5 files changed

+51
-11
lines changed

axes/handlers/cache.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def user_login_failed(self, sender, credentials: dict, request=None, **kwargs):
113113
return
114114

115115
cache_keys = get_client_cache_keys(request, credentials)
116-
cache_timeout = get_cache_timeout()
116+
cache_timeout = get_cache_timeout(username)
117117
failures = []
118118
for cache_key in cache_keys:
119119
added = self.cache.add(key=cache_key, value=1, timeout=cache_timeout)

axes/helpers.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import functools
2+
import inspect
13
from datetime import timedelta
24
from hashlib import sha256
35
from logging import getLogger
@@ -32,7 +34,7 @@ def get_cache() -> BaseCache:
3234
return caches[getattr(settings, "AXES_CACHE", "default")]
3335

3436

35-
def get_cache_timeout() -> Optional[int]:
37+
def get_cache_timeout(username: Optional[str] = None) -> Optional[int]:
3638
"""
3739
Return the cache timeout interpreted from settings.AXES_COOLOFF_TIME.
3840
@@ -43,21 +45,22 @@ def get_cache_timeout() -> Optional[int]:
4345
for use with the Django cache backends.
4446
"""
4547

46-
cool_off = get_cool_off()
48+
cool_off = get_cool_off(username)
4749
if cool_off is None:
4850
return None
4951
return int(cool_off.total_seconds())
5052

5153

52-
def get_cool_off() -> Optional[timedelta]:
54+
def get_cool_off(username: Optional[str] = None) -> Optional[timedelta]:
5355
"""
5456
Return the login cool off time interpreted from settings.AXES_COOLOFF_TIME.
5557
5658
The return value is either None or timedelta.
5759
58-
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, or integer/float of hours,
59-
and this function offers a unified _timedelta or None_ representation of that configuration
60-
for use with the Axes internal implementations.
60+
Notice that the settings.AXES_COOLOFF_TIME is either None, timedelta, integer/float of hours,
61+
a path to a callable or a callable taking zero or 1 argument (the username). This function
62+
offers a unified _timedelta or None_ representation of that configuration for use with the
63+
Axes internal implementations.
6164
6265
:exception TypeError: if settings.AXES_COOLOFF_TIME is of wrong type.
6366
"""
@@ -69,13 +72,22 @@ def get_cool_off() -> Optional[timedelta]:
6972
if isinstance(cool_off, float):
7073
return timedelta(minutes=cool_off * 60)
7174
if isinstance(cool_off, str):
72-
return import_string(cool_off)()
75+
cool_off_func = import_string(cool_off)
76+
return _maybe_partial(cool_off_func, username)()
7377
if callable(cool_off):
74-
return cool_off() # pylint: disable=not-callable
78+
return _maybe_partial(cool_off, username)() # pylint: disable=not-callable
7579

7680
return cool_off
7781

7882

83+
def _maybe_partial(func: Callable, username: Optional[str] = None):
84+
"""Bind the given username to the function if it accepts a single argument."""
85+
sig = inspect.signature(func)
86+
if len(sig.parameters) == 1:
87+
return functools.partial(func, username)
88+
return func
89+
90+
7991
def get_cool_off_iso8601(delta: timedelta) -> str:
8092
"""
8193
Return datetime.timedelta translated to ISO 8601 formatted duration for use in e.g. cool offs.

axes/models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ class Meta:
5353

5454
class AccessLog(AccessBase):
5555
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
56-
session_hash = models.CharField(_("Session key hash (sha256)"), default="", blank=True, max_length=64)
56+
session_hash = models.CharField(
57+
_("Session key hash (sha256)"), default="", blank=True, max_length=64
58+
)
5759

5860
def __str__(self):
5961
return f"Access Log for {self.username} @ {self.attempt_time}"

docs/4_configuration.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ The following ``settings.py`` options are available for customizing Axes behavio
2323
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
2424
| AXES_LOCK_OUT_AT_FAILURE | True | After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? |
2525
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
26-
| 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 no arguments. 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 |
26+
| 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 username 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
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
2828
| 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. |
2929
+------------------------------------------------------+----------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

tests/test_helpers.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,32 @@ def test_get_cache_timeout_timedelta(self):
5959
def test_get_cache_timeout_none(self):
6060
self.assertEqual(get_cache_timeout(), None)
6161

62+
def test_get_increasing_cache_timeout(self):
63+
user_durations = {
64+
"ben": timedelta(minutes=5),
65+
"jen": timedelta(minutes=10),
66+
}
67+
68+
def _callback(username):
69+
previous_duration = user_durations.get(username, timedelta())
70+
user_durations[username] = previous_duration + timedelta(minutes=5)
71+
return user_durations[username]
72+
73+
with override_settings(AXES_COOLOFF_TIME=_callback):
74+
with self.subTest("no username"):
75+
self.assertEqual(get_cache_timeout(), 300)
76+
77+
with self.subTest("ben"):
78+
self.assertEqual(get_cache_timeout("ben"), 600)
79+
self.assertEqual(get_cache_timeout("ben"), 900)
80+
self.assertEqual(get_cache_timeout("ben"), 1200)
81+
82+
with self.subTest("jen"):
83+
self.assertEqual(get_cache_timeout("jen"), 900)
84+
85+
with self.subTest("james"):
86+
self.assertEqual(get_cache_timeout("james"), 300)
87+
6288

6389
class TimestampTestCase(AxesTestCase):
6490
def test_iso8601(self):

0 commit comments

Comments
 (0)