Repository: privacyidea/privacyidea

Issue:
Allow auth_cache to cache only for a limited number of authentications
The ``auth_cache`` is policy in scope=auth.
http://privacyidea.readthedocs.io/en/latest/policies/authentication.html#auth-cache

Currently you can define it like 4h/4m, which would mean cache for 4 hours if every 4 minutes there is a request. 

We need to enhance this login to "cache for 5 seconds, but only allow 2 authentications within this 5 seconds".

This could be specified like "5s/2".

To achieve this, we need to

1. allow seconds in the auth_cache
2. count the authentications performed by the auth_cache. Therefore we need to add a column for counting the auth's https://github.com/privacyidea/privacyidea/blob/master/privacyidea/models.py#L2431


Discussion:
This was e.g. needed for the ldap proxy, if you know, that the same OTP would be used within the next 5 seconds, but only two times.

Currently the auth_cache allows to specify  2h/5m, which means, that the the user may authenticate for 2h (checked in the column ``first_auth``, if the last authentication happened less then 5 minutes ago (checkd in the column ``last_auth``).

Thus we need the additional column ``auth_count``. That simply records, how often this auth_cache entry was used.
The the setting 10s/2 will mean, check ``first_auth`` to be less than 10s ago and theck ``auth_count`` to be less than 2.
(If it exceeds the auth_cache entry can be deleted again.)

Resolving patch:
diff --git a/privacyidea/lib/authcache.py b/privacyidea/lib/authcache.py
--- a/privacyidea/lib/authcache.py
+++ b/privacyidea/lib/authcache.py
@@ -45,10 +45,11 @@ def add_to_cache(username, realm, resolver, password):
     return r
 
 
-def update_cache_last_auth(cache_id):
+def update_cache(cache_id):
     last_auth = datetime.datetime.utcnow()
-    AuthCache.query.filter(
-        AuthCache.id == cache_id).update({"last_auth": last_auth})
+    db.session.query(AuthCache).filter(
+        AuthCache.id == cache_id).update({"last_auth": last_auth,
+                                          AuthCache.auth_count: AuthCache.auth_count + 1})
     db.session.commit()
 
 
@@ -89,7 +90,8 @@ def cleanup(minutes):
     return r
 
 
-def verify_in_cache(username, realm, resolver, password, first_auth=None, last_auth=None):
+def verify_in_cache(username, realm, resolver, password, first_auth=None, last_auth=None,
+                    max_auths=0):
     """
     Verify if the given credentials are cached and if the time is correct.
     
@@ -100,7 +102,11 @@ def verify_in_cache(username, realm, resolver, password, first_auth=None, last_a
     :param first_auth: The timestamp when the entry was first written to the 
         cache. Only find newer entries 
     :param last_auth: The timestamp when the entry was last successfully 
-        verified. Only find newer entries 
+        verified. Only find newer entries
+    :param max_auths: Maximum number of times the authcache entry can be used to skip
+        authentication, as defined by ACTION.AUTH_CACHE policy. Will return False if the current number of
+        authentications + 1 of the cached authentication exceeds this value.
+    :type max_auths: int
     :return: 
     """
     conditions = []
@@ -123,9 +129,14 @@ def verify_in_cache(username, realm, resolver, password, first_auth=None, last_a
         except ValueError:
             log.debug("Old authcache entry for user {0!s}@{1!s}.".format(username, realm))
             result = False
+
+        if result and max_auths > 0:
+            # Check if auth_count allows this authentication too
+            result = cached_auth.auth_count < max_auths
+
         if result:
-            # Update the last_auth
-            update_cache_last_auth(cached_auth.id)
+            # Update the last_auth and the auth_count
+            update_cache(cached_auth.id)
             break
 
     if not result:
diff --git a/privacyidea/lib/policydecorators.py b/privacyidea/lib/policydecorators.py
--- a/privacyidea/lib/policydecorators.py
+++ b/privacyidea/lib/policydecorators.py
@@ -44,6 +44,7 @@
 The functions of this module are tested in tests/test_lib_policy_decorator.py
 """
 import logging
+import re
 
 from privacyidea.lib.policy import Match
 from privacyidea.lib.error import PolicyError, privacyIDEAError
@@ -156,28 +157,34 @@ def auth_cache(wrapped_function, user_object, passw, options=None):
     options = options or {}
     g = options.get("g")
     auth_cache_dict = None
+
     if g:
         auth_cache_dict = Match.user(g, scope=SCOPE.AUTH, action=ACTION.AUTH_CACHE,
                                      user_object=user_object).action_values(unique=True, write_to_audit_log=False)
         if auth_cache_dict:
-            # verify in cache and return an early success
             auth_times = list(auth_cache_dict)[0].split("/")
+
             # determine first_auth from policy!
             first_offset = parse_timedelta(auth_times[0])
+            first_auth = datetime.datetime.utcnow() - first_offset
+            last_auth = first_auth  # Default if no last auth exists
+            max_auths = 0  # Default value, 0 has no effect on verification
 
+            # Use auth cache when number of allowed authentications is defined
             if len(auth_times) == 2:
-                # Determine last_auth from policy
-                last_offset = parse_timedelta(auth_times[1])
-            else:
-                # If there is no last_auth, it is equal to first_auth
-                last_offset = first_offset
+                if re.match(r"^\d+$", auth_times[1]):
+                    max_auths = int(auth_times[1])
+                else:
+                    # Determine last_auth delta from policy
+                    last_offset = parse_timedelta(auth_times[1])
+                    last_auth = datetime.datetime.utcnow() - last_offset
 
-            first_auth = datetime.datetime.utcnow() - first_offset
-            last_auth = datetime.datetime.utcnow() - last_offset
             result = verify_in_cache(user_object.login, user_object.realm,
                                      user_object.resolver, passw,
                                      first_auth=first_auth,
-                                     last_auth=last_auth)
+                                     last_auth=last_auth,
+                                     max_auths=max_auths)
+
             if result:
                 g.audit_object.add_policy(next(iter(auth_cache_dict.values())))
                 return True, {"message": "Authenticated by AuthCache."}
diff --git a/privacyidea/models.py b/privacyidea/models.py
--- a/privacyidea/models.py
+++ b/privacyidea/models.py
@@ -2688,6 +2688,7 @@ class AuthCache(MethodsMixin, db.Model):
     realm = db.Column(db.Unicode(120), default=u'', index=True)
     client_ip = db.Column(db.Unicode(40), default=u"")
     user_agent = db.Column(db.Unicode(120), default=u"")
+    auth_count = db.Column(db.Integer, default=0)
     # We can hash the password like this:
     # binascii.hexlify(hashlib.sha256("secret123456").digest())
     authentication = db.Column(db.Unicode(255), default=u"")
@@ -2698,8 +2699,8 @@ def __init__(self, username, realm, resolver, authentication,
         self.realm = realm
         self.resolver = resolver
         self.authentication = authentication
-        self.first_auth = first_auth
-        self.last_auth = last_auth
+        self.first_auth = first_auth if first_auth else datetime.utcnow()
+        self.last_auth = last_auth if last_auth else self.first_auth
 
 
 ### Periodic Tasks


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
@@ -6,7 +6,7 @@
 from .base import MyTestCase
 
 from privacyidea.lib.authcache import (add_to_cache, delete_from_cache,
-                                       update_cache_last_auth, verify_in_cache,
+                                       update_cache, verify_in_cache,
                                        _hash_password,
                                        cleanup)
 from passlib.hash import argon2
@@ -37,7 +37,7 @@ def test_01_write_update_delete_cache(self):
         self.assertTrue(auth.first_auth > teststart)
         self.assertEqual(auth.last_auth, auth.first_auth)
 
-        update_cache_last_auth(r)
+        update_cache(r)
         auth = AuthCache.query.filter(AuthCache.id == r).first()
         self.assertTrue(auth.last_auth > teststart)
 
@@ -57,22 +57,32 @@ def test_02_verify_cache(self):
 
         # Add Entry to cache
         r = add_to_cache(self.username, self.realm, self.resolver, self.password)
-        update_cache_last_auth(r)
+
         auth = AuthCache.query.filter(AuthCache.id == r).first()
         last_auth1 = auth.last_auth
+        auth_count1 = auth.auth_count
+        self.assertEqual(0, auth_count1)
 
         first_auth = datetime.datetime.utcnow() - datetime.timedelta(hours=4)
         last_auth = datetime.datetime.utcnow() - datetime.timedelta(minutes=5)
 
         r = verify_in_cache(self.username, self.realm, self.resolver,
                             self.password, first_auth=first_auth,
-                            last_auth=last_auth)
+                            last_auth=last_auth, max_auths=1)
         self.assertTrue(r)
 
         # The last_auth was increased!
         auth = AuthCache.query.filter(AuthCache.id == r).first()
         last_auth2 = auth.last_auth
+        auth_count2 = auth.auth_count
         self.assertTrue(last_auth2 > last_auth1)
+        self.assertEqual(1, auth_count2)
+
+        r = verify_in_cache(self.username, self.realm, self.resolver,
+                            self.password, first_auth=first_auth,
+                            last_auth=last_auth, max_auths=1)
+        self.assertFalse(r)
+
 
     def test_03_delete_old_entries(self):
         # Create a VERY old authcache entry
diff --git a/tests/test_lib_policydecorator.py b/tests/test_lib_policydecorator.py
--- a/tests/test_lib_policydecorator.py
+++ b/tests/test_lib_policydecorator.py
@@ -548,10 +548,12 @@ def test_12_authcache(self):
         realm = "myrealm"
         resolver = "reso001"
 
-        r = save_resolver({"resolver": "reso001",
+        pwd_hash = _hash_password(password)
+
+        r = save_resolver({"resolver": resolver,
                            "type": "passwdresolver",
                            "fileName": "tests/testdata/passwords"})
-        (added, failed) = set_realm("myrealm", ["reso001"])
+        (added, failed) = set_realm(realm, [resolver])
 
         def fake_check_user_pass(user, passw, options=None):
             return True, {"message": "Fake Authentication"}
@@ -569,22 +571,22 @@ def fake_check_user_pass(user, passw, options=None):
 
         # This successfully authenticates against the authcache
         # We have an authentication, that is within the policy timeout
-        AuthCache(username, realm, resolver, _hash_password(password),
+        AuthCache(username, realm, resolver, pwd_hash,
                   first_auth=datetime.datetime.utcnow() - timedelta(hours=3),
                   last_auth=datetime.datetime.utcnow() - timedelta(minutes=1)).save()
-        r = auth_cache(fake_check_user_pass, User("cornelius", "myrealm"),
-                        password, options=options)
+        r = auth_cache(fake_check_user_pass, User(username, realm),
+                       password, options=options)
         self.assertTrue(r[0])
-        self.assertEqual(r[1].get("message"), "Authenticated by AuthCache." )
+        self.assertEqual(r[1].get("message"), "Authenticated by AuthCache.")
 
         # We have an authentication, that is not read from the authcache,
         # since the authcache first_auth is too old.
         delete_from_cache(username, realm, resolver, password)
-        AuthCache(username, realm, resolver, _hash_password(password),
+        AuthCache(username, realm, resolver, pwd_hash,
                   first_auth=datetime.datetime.utcnow() - timedelta(hours=5),
                   last_auth=datetime.datetime.utcnow() - timedelta(
                       minutes=1)).save()
-        r = auth_cache(fake_check_user_pass, User("cornelius", "myrealm"),
+        r = auth_cache(fake_check_user_pass, User(username, realm),
                        password, options=options)
         self.assertTrue(r[0])
         self.assertEqual(r[1].get("message"), "Fake Authentication")
@@ -592,11 +594,11 @@ def fake_check_user_pass(user, passw, options=None):
         # We have an authentication, that is not read from authcache, since
         # the last_auth is too old = 10 minutes.
         delete_from_cache(username, realm, resolver, password)
-        AuthCache(username, realm, resolver, _hash_password(password),
+        AuthCache(username, realm, resolver, pwd_hash,
                   first_auth=datetime.datetime.utcnow() - timedelta(hours=1),
                   last_auth=datetime.datetime.utcnow() - timedelta(
                       minutes=10)).save()
-        r = auth_cache(fake_check_user_pass, User("cornelius", "myrealm"),
+        r = auth_cache(fake_check_user_pass, User(username, realm),
                        password, options=options)
         self.assertTrue(r[0])
         self.assertEqual(r[1].get("message"), "Fake Authentication")
@@ -615,19 +617,71 @@ def fake_check_user_pass(user, passw, options=None):
         options = {"g": g}
 
         delete_from_cache(username, realm, resolver, password)
-        AuthCache(username, realm, resolver, _hash_password(password),
+        AuthCache(username, realm, resolver, pwd_hash,
                   first_auth=datetime.datetime.utcnow() - timedelta(hours=2),
                   last_auth=datetime.datetime.utcnow() - timedelta(
                       hours=1)).save()
-        r = auth_cache(fake_check_user_pass, User("cornelius", "myrealm"),
+        r = auth_cache(fake_check_user_pass, User(username, realm),
                        password, options=options)
         self.assertTrue(r[0])
         self.assertEqual(r[1].get("message"), "Authenticated by AuthCache.")
 
+        # Test auth_cache policy with format "<seconds>/<#allowed authentications>"
+        set_policy(name="pol1",
+                   scope=SCOPE.AUTH,
+                   realm=realm,
+                   resolver=resolver,
+                   action="{0!s}={1!s}".format(ACTION.AUTH_CACHE, "50s/2"))
+
+        g = FakeFlaskG()
+        g.policy_object = PolicyClass()
+        g.audit_object = FakeAudit()
+        options = {"g": g}
+
+        AuthCache(username, realm, resolver, pwd_hash,
+                  first_auth=datetime.datetime.utcnow()).save()
+
+        r = auth_cache(fake_check_user_pass, User(username, realm),
+                       password, options=options)
+        self.assertTrue(r[0])
+        self.assertEqual("Authenticated by AuthCache.", r[1].get("message"))
+
+        r = auth_cache(fake_check_user_pass, User(username, realm),
+                       password, options=options)
+        self.assertTrue(r[0])
+        self.assertEqual("Authenticated by AuthCache.", r[1].get("message"))
+
+        r = auth_cache(fake_check_user_pass, User(username, realm),
+                       password, options=options)
+        self.assertTrue(r[0])
+        self.assertEqual("Fake Authentication", r[1].get("message"))
+
+        delete_from_cache(username, realm, resolver, password)
+
+        # Authentication not read from cache because first auth was too long ago
+        set_policy(name="pol1",
+                   scope=SCOPE.AUTH,
+                   realm=realm,
+                   resolver=resolver,
+                   action="{0!s}={1!s}".format(ACTION.AUTH_CACHE, "50s/2"))
+
+        g = FakeFlaskG()
+        g.policy_object = PolicyClass()
+        g.audit_object = FakeAudit()
+        options = {"g": g}
+
+        AuthCache(username, realm, resolver, pwd_hash,
+                  first_auth=datetime.datetime.utcnow() - timedelta(seconds=55)).save()
+
+        r = auth_cache(fake_check_user_pass, User(username, realm),
+                       password, options=options)
+        self.assertTrue(r[0])
+        self.assertEqual(r[1].get("message"), "Fake Authentication")
+
         # Clean up
         delete_policy("pol1")
-        delete_realm("myrealm")
-        delete_resolver("reso001")
+        delete_realm(realm)
+        delete_resolver(resolver)
 
     @radiusmock.activate
     def test_13_passthru_priorities(self):