Repository: privacyidea/privacyidea

Issue:
issues when deleting auth_cache entries 
If the ``auth_cache`` is enabled, the entry should be deleted if the authentication with the entry does not succeed:
https://github.com/privacyidea/privacyidea/blob/fd7fa754577137cc040be54ee1c33454ee3546f8/privacyidea/lib/authcache.py#L133
There are two possibilities that the entry does not work:
- old style auth_cache entry
- password (hash) mismatch

The ``delete_from_cache()`` function iterates over all matching (username, resolver, realm) ``auth_cache`` entries and deletes the ones where the password (hash) matches:
https://github.com/privacyidea/privacyidea/blob/fd7fa754577137cc040be54ee1c33454ee3546f8/privacyidea/lib/authcache.py#L64
This means that old ``auth_cache`` entries only get deleted when the password matches which usually isn't the case with OTPs.
Also the cache time is not taken into account for the deletion.



Discussion:
@plettich 
What do you mean with 
> This means that old auth_cache entries only get deleted when the password matches which usually isn't the case with OTPs.

[This](https://github.com/privacyidea/privacyidea/blob/fd7fa754577137cc040be54ee1c33454ee3546f8/privacyidea/lib/authcache.py#L66) implies that those entries get deleted too.

Also, what do you mean by the cache time not being taken into account? 
https://github.com/privacyidea/privacyidea/blob/fd7fa754577137cc040be54ee1c33454ee3546f8/privacyidea/lib/authcache.py#L64

This line should be extended by two cases where cache entries can also be deleted:
1. If the current number of authentications is greater or equal than the maximum number of allowed entries
2. If the entry is expired, i.e., if the first authentication was longer ago then entries should be cached

Resolving patch:
diff --git a/privacyidea/lib/authcache.py b/privacyidea/lib/authcache.py
--- a/privacyidea/lib/authcache.py
+++ b/privacyidea/lib/authcache.py
@@ -53,19 +53,36 @@ def update_cache(cache_id):
     db.session.commit()
 
 
-def delete_from_cache(username, realm, resolver, password):
+def delete_from_cache(username, realm, resolver, password, last_valid_cache_time=None, max_auths=0):
+    """
+    Deletes all authcache entries that match the user and either match the password, are expired, or have reached the
+    maximum number of allowed authentications.
+
+    :param username:
+    :param realm:
+    :param resolver:
+    :param password:
+    :param last_valid_cache_time: Oldest valid time for a cache entry to be still valid. I.e., if the first
+    authentication of the entry is before this time point, it is not valid anymore.
+    :param max_auths: Maximum number of allowed authentications.
+    """
     cached_auths = db.session.query(AuthCache).filter(AuthCache.username == username,
                                                       AuthCache.realm == realm,
                                                       AuthCache.resolver == resolver).all()
     r = 0
     for cached_auth in cached_auths:
         delete_entry = False
-        # if the password does match, we deleted it.
+        # if the password matches or the entry is otherwise invalid, we deleted it.
         try:
-            if argon2.verify(password, cached_auth.authentication):
+            if max_auths > 0 and cached_auth.auth_count >= max_auths:
+                delete_entry = True
+            elif last_valid_cache_time and cached_auth.first_auth < last_valid_cache_time:
                 delete_entry = True
+            elif argon2.verify(password, cached_auth.authentication):
+                delete_entry = True
+
         except ValueError:
-            log.debug("Old authcache entry for user {0!s}@{1!s}.".format(username, realm))
+            log.debug("Old (non-argon2) authcache entry for user {0!s}@{1!s}.".format(username, realm))
             # Also delete old entries
             delete_entry = True
         if delete_entry:
@@ -127,7 +144,7 @@ def verify_in_cache(username, realm, resolver, password, first_auth=None, last_a
         try:
             result = argon2.verify(password, cached_auth.authentication)
         except ValueError:
-            log.debug("Old authcache entry for user {0!s}@{1!s}.".format(username, realm))
+            log.debug("Old (non-argon2) authcache entry for user {0!s}@{1!s}.".format(username, realm))
             result = False
 
         if result and max_auths > 0:
@@ -141,6 +158,6 @@ def verify_in_cache(username, realm, resolver, password, first_auth=None, last_a
 
     if not result:
         # Delete older entries
-        delete_from_cache(username, realm, resolver, password)
+        delete_from_cache(username, realm, resolver, password, first_auth, max_auths)
 
     return result


Test patch:
diff --git a/tests/test_lib_authcache.py b/tests/test_lib_authcache.py
--- a/tests/test_lib_authcache.py
+++ b/tests/test_lib_authcache.py
@@ -83,7 +83,6 @@ def test_02_verify_cache(self):
                             last_auth=last_auth, max_auths=1)
         self.assertFalse(r)
 
-
     def test_03_delete_old_entries(self):
         # Create a VERY old authcache entry
         AuthCache("grandpa", self.realm, self.resolver, _hash_password(self.password),
@@ -137,3 +136,48 @@ def test_05_old_hashes(self):
 
         r = verify_in_cache("grandpa", self.realm, self.resolver, "old password")
         self.assertFalse(r)
+
+    def test_06_delete_other_invalid_entries(self):
+        # Test deletion of expired entries
+        r1 = add_to_cache(self.username, self.realm, self.resolver, u"somethingDifferent")
+        r2 = add_to_cache(self.username, self.realm, self.resolver, self.password)
+
+        auth1 = AuthCache.query.filter(AuthCache.id == r1).first()
+        auth2 = AuthCache.query.filter(AuthCache.id == r2).first()
+        last_valid_cache_time = auth1.first_auth
+        new_valid_cache_time = auth2.first_auth
+
+        self.assertFalse(auth1.first_auth == auth2.first_auth)
+
+        # delete entries where password matches or first_auth is older than last_valid_cache_time
+        delete_from_cache(self.username, self.realm, self.resolver, self.password,
+                          last_valid_cache_time=last_valid_cache_time)
+
+        auth = AuthCache.query.filter(AuthCache.username == self.username).first()
+        # r2 should have been deleted since the password matches,
+        # r1 is still there since it's first_auth is equal to last_valid_cache_time
+        self.assertEqual(auth.id, r1)
+
+        # by setting the last_valid_cache_time to the first_auth of r2, the
+        # entry r1 should be deleted as well
+        delete_from_cache(self.username, self.realm, self.resolver, 'unknown_pw',
+                          last_valid_cache_time=new_valid_cache_time)
+
+        auth = AuthCache.query.filter(AuthCache.username == self.username).first()
+        self.assertEqual(None, auth)
+
+        # Test deletion if max_auths is reached
+        r1 = add_to_cache(self.username, self.realm, self.resolver, self.password)
+        r2 = add_to_cache(self.username, self.realm, self.resolver, u"somethingDifferent")
+
+        update_cache(r2)
+        update_cache(r2)
+
+        auth2 = AuthCache.query.filter(AuthCache.id == r2).first()
+
+        self.assertEqual(2, auth2.auth_count)
+        # this deletes the entries matching the password and max_auth
+        delete_from_cache(self.username, self.realm, self.resolver, self.password, max_auths=2)
+
+        auth = AuthCache.query.filter(AuthCache.username == self.username).first()
+        self.assertEqual(auth, None)