Repository: jazzband/django-axes

Issue:
Reset does not work correctly with the cache backend
Axes commands axes_reset and axes_reset_username not working, always give 'No attempts found.' 

```bash
$ python3.6 manage.py axes_reset_username 'example@example.com'
No attempts found.
```

```bash
$ python3.6 manage.py axes_reset
No attempts found.
```

i have the following configuration:

settings.py
```python
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'django.contrib.redirects.middleware.RedirectFallbackMiddleware',
    'debug_toolbar.middleware.DebugToolbarMiddleware',
    'axes.middleware.AxesMiddleware',
]

AUTHENTICATION_BACKENDS = [
    'axes.backends.AxesBackend',
    'django.contrib.auth.backends.AllowAllUsersModelBackend',
]

AXES_FAILURE_LIMIT = 10
AXES_ONLY_USER_FAILURES = True
AXES_COOLOFF_TIME = 24
AXES_LOCKOUT_TEMPLATE = '429.html'
AXES_CACHE = 'axes'
AXES_HANDLER = 'axes.handlers.cache.AxesCacheHandler'

REDIS_HOST = 'redis'
REDIS_PORT = 6379
CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': f'redis://{REDIS_HOST}:{REDIS_PORT}/1',
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
        }
    },
    'sessions': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': f'redis://{REDIS_HOST}:{REDIS_PORT}/2',
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
        }
    },
    'compress': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': f'redis://{REDIS_HOST}:{REDIS_PORT}/3',
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
        }
    },
    'axes': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': f'redis://{REDIS_HOST}:{REDIS_PORT}/4',
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
        }
    },
}
```

requirements.txt
```text
django==2.2.2

channels==2.2.0
coverage==4.5.3
gunicorn==19.9.0
holidays==0.9.10
huey==2.1.0
ipython==7.5.0
pillow==6.0.0
piprot==0.9.10
prospector==1.1.6.4
psycopg2-binary==2.8.3
pytest-cov==2.7.1
pytest==4.6.3
python_dateutil==2.8.0
python_magic==0.4.15
raven==6.10.0
requests==2.22.0
tblib==1.4.0
uvicorn==0.7.2

dj-inmemorystorage==1.4.1
django-admin-sortable==2.1.15
django-axes==5.0.7
django-compressor==2.3
django-constance==2.4.0
django-debug-toolbar==1.11
django-dynamic-fixture==2.0.0
django-extensions==2.1.9
django-hashid-field==2.1.6
django-ipware==2.1.0
django-qr-code==1.0.0
django-redis==4.10.0
django-widget-tweaks==1.4.5
pytest-django==3.5.0
```


Discussion:
Hey @felipe3dfx! Due to how `axes.handlers.cache.AxesCacheBackend` is implemented, the specific reset commands do not function with the cache backend. The generic resets can be tuned to work with it.

The reason is that the cache backend uses a hash in the cache that is calculated from multiple different elements. The implementation can be found [here](https://github.com/jazzband/django-axes/blob/9fa0e7f2158921380922b8d1081e6fdb8f5d171b/axes/helpers.py#L362).

If you can figure out a good way for implementing the resets in the cache handler that would be most welcome contribution.

The command should most probably warn if it is being used with the cache handlersince the current functionality is to leave the user unaware of the reason of the failure.
The handlers that can not reset attempts now raise a `NotImplementedError` on invalid command execution. The database handler handles resets for the access attempts and logs correctly.
Hello! I'm new to this lib.

This code is resetting attempts:
```python
from axes.helpers import get_client_cache_key, get_credentials, get_cache
# .........
cache = get_cache()
credentials = get_credentials(username=username)
client_cache_key = get_client_cache_key(request, credentials=credentials)
cache.delete(client_cache_key)
```
Though I guess it's working because I'm doing it from the same `ip_address` and `user_agent`.

In that case, `get_client_cache_key` should be refactorized in order to skip `ip_address` and `user_agent` dependencies.
It would be a fair approach when `AXES_ONLY_USER_FAILURES` is set to `True`.

Any thoughts?
The approach does look valid, would you be interested in opening a PR for getting the solution merged?
Giving it a closer look, what I'm saying it's already implemented here:

https://github.com/jazzband/django-axes/blob/7b8123a72c76893dcf42de96329ce53a2420b49d/axes/helpers.py#L177-L190

When it's called by `get_client_parameters` in:

https://github.com/jazzband/django-axes/blob/7b8123a72c76893dcf42de96329ce53a2420b49d/axes/helpers.py#L226

So it won't consider `ip_address` and `user_agent`.
Hi @aleksihakli I would be happy to consider looking at writing a PR for this if it is something that is still desired. Could I possibly get a bit of guidance?
Hi @nattyg93! If you want to get started with PRs you can refer to the Axes documentation which describes the development process: https://django-axes.readthedocs.io/en/latest/9_development.html

The discussion and work outlined here pretty much describes the desired outcome and things that need to be implemented for it to work. If you have any specific questions please ask away!

Resolving patch:
diff --git a/axes/handlers/cache.py b/axes/handlers/cache.py
--- a/axes/handlers/cache.py
+++ b/axes/handlers/cache.py
@@ -11,6 +11,7 @@
     get_credentials,
     get_failure_limit,
 )
+from axes.models import AccessBase
 from axes.signals import user_locked_out
 
 log = getLogger(__name__)
@@ -25,6 +26,35 @@ def __init__(self):
         self.cache = get_cache()
         self.cache_timeout = get_cache_timeout()
 
+    def reset_attempts(
+        self,
+        *,
+        ip_address: str = None,
+        username: str = None,
+        ip_or_username: bool = False,
+    ) -> int:
+        cache_keys: list = []
+        count = 0
+
+        if ip_address is None and username is None:
+            raise NotImplementedError("Cannot clear all entries from cache")
+        if ip_or_username:
+            raise NotImplementedError(
+                "Due to the cache key ip_or_username=True is not supported"
+            )
+
+        cache_keys.extend(
+            get_client_cache_key(AccessBase(username=username, ip_address=ip_address))
+        )
+
+        for cache_key in cache_keys:
+            deleted = self.cache.delete(cache_key)
+            count += int(deleted) if deleted is not None else 1
+
+        log.info("AXES: Reset %d access attempts from database.", count)
+
+        return count
+
     def get_failures(self, request, credentials: dict = None) -> int:
         cache_keys = get_client_cache_key(request, credentials)
         failure_count = max(


Test patch:
diff --git a/tests/base.py b/tests/base.py
--- a/tests/base.py
+++ b/tests/base.py
@@ -105,7 +105,13 @@ def create_log(self, **kwargs):
     def reset(self, ip=None, username=None):
         return reset(ip, username)
 
-    def login(self, is_valid_username=False, is_valid_password=False, **kwargs):
+    def login(
+        self,
+        is_valid_username=False,
+        is_valid_password=False,
+        remote_addr=None,
+        **kwargs
+    ):
         """
         Login a user.
 
@@ -128,7 +134,7 @@ def login(self, is_valid_username=False, is_valid_password=False, **kwargs):
         return self.client.post(
             reverse("admin:login"),
             post_data,
-            REMOTE_ADDR=self.ip_address,
+            REMOTE_ADDR=remote_addr or self.ip_address,
             HTTP_USER_AGENT=self.user_agent,
         )
 
diff --git a/tests/test_handlers.py b/tests/test_handlers.py
--- a/tests/test_handlers.py
+++ b/tests/test_handlers.py
@@ -330,6 +330,98 @@ def test_user_login_failed_multiple_username(self):
             AccessAttempt.objects.all().delete()
 
 
+@override_settings(AXES_HANDLER="axes.handlers.cache.AxesCacheHandler")
+class ResetAttemptsCacheHandlerTestCase(AxesHandlerBaseTestCase):
+    """ Test reset attempts for the cache handler """
+
+    USERNAME_1 = "foo_username"
+    USERNAME_2 = "bar_username"
+    IP_1 = "127.1.0.1"
+    IP_2 = "127.1.0.2"
+
+    def set_up_login_attemtps(self):
+        """Set up the login attempts."""
+        self.login(username=self.USERNAME_1, remote_addr=self.IP_1)
+        self.login(username=self.USERNAME_1, remote_addr=self.IP_2)
+        self.login(username=self.USERNAME_2, remote_addr=self.IP_1)
+        self.login(username=self.USERNAME_2, remote_addr=self.IP_2)
+
+    def check_failures(self, failures, username=None, ip_address=None):
+        if ip_address is None and username is None:
+            raise NotImplementedError("Must supply ip_address or username")
+        try:
+            prev_ip = self.request.META["REMOTE_ADDR"]
+            credentials = {"username": username} if username else {}
+            if ip_address is not None:
+                self.request.META["REMOTE_ADDR"] = ip_address
+            self.assertEqual(
+                failures,
+                AxesProxyHandler.get_failures(self.request, credentials=credentials),
+            )
+        finally:
+            self.request.META["REMOTE_ADDR"] = prev_ip
+
+    def test_handler_reset_attempts(self):
+        with self.assertRaises(NotImplementedError):
+            AxesProxyHandler.reset_attempts()
+
+    @override_settings(AXES_ONLY_USER_FAILURES=True)
+    def test_handler_reset_attempts_username(self):
+        self.set_up_login_attemtps()
+        self.assertEqual(
+            2,
+            AxesProxyHandler.get_failures(
+                self.request, credentials={"username": self.USERNAME_1}
+            ),
+        )
+        self.assertEqual(
+            2,
+            AxesProxyHandler.get_failures(
+                self.request, credentials={"username": self.USERNAME_2}
+            ),
+        )
+        self.assertEqual(1, AxesProxyHandler.reset_attempts(username=self.USERNAME_1))
+        self.assertEqual(
+            0,
+            AxesProxyHandler.get_failures(
+                self.request, credentials={"username": self.USERNAME_1}
+            ),
+        )
+        self.assertEqual(
+            2,
+            AxesProxyHandler.get_failures(
+                self.request, credentials={"username": self.USERNAME_2}
+            ),
+        )
+
+    def test_handler_reset_attempts_ip(self):
+        self.set_up_login_attemtps()
+        self.check_failures(2, ip_address=self.IP_1)
+        self.assertEqual(1, AxesProxyHandler.reset_attempts(ip_address=self.IP_1))
+        self.check_failures(0, ip_address=self.IP_1)
+        self.check_failures(2, ip_address=self.IP_2)
+
+    @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
+    def test_handler_reset_attempts_ip_and_username(self):
+        self.set_up_login_attemtps()
+        self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_1)
+        self.check_failures(1, username=self.USERNAME_2, ip_address=self.IP_1)
+        self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_2)
+        self.assertEqual(
+            1,
+            AxesProxyHandler.reset_attempts(
+                ip_address=self.IP_1, username=self.USERNAME_1
+            ),
+        )
+        self.check_failures(0, username=self.USERNAME_1, ip_address=self.IP_1)
+        self.check_failures(1, username=self.USERNAME_2, ip_address=self.IP_1)
+        self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_2)
+
+    def test_handler_reset_attempts_ip_or_username(self):
+        with self.assertRaises(NotImplementedError):
+            AxesProxyHandler.reset_attempts()
+
+
 @override_settings(
     AXES_HANDLER="axes.handlers.cache.AxesCacheHandler",
     AXES_COOLOFF_TIME=timedelta(seconds=1),