Repository: privacyidea/privacyidea

Issue:
Event condition counter
The Counter Event handler can count certain occurrances of events and write the counter to the database table ``Eventcounter``.

We could have a new condition for all event handlers called ``eventcounter``.

This condition would check, if the event counter has reached a certain value, is bigger or lower than the given value. This could be done like this:

    countername > 12

Where *countername* would be the keyname of a defined counter. The equal, bigger, smaller comparison would be done like with ``count_auth``...

This condition sounds a bit wired but could open up unexpected possibilities, since we can count any event.
Wiered Example: If the admin hat viewed the audit log for 100 times, then we could define a RequestMangler, that another used would not be able to login anymore. 


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/eventhandler/base.py b/privacyidea/lib/eventhandler/base.py
--- a/privacyidea/lib/eventhandler/base.py
+++ b/privacyidea/lib/eventhandler/base.py
@@ -38,7 +38,9 @@
 from privacyidea.lib.auth import ROLE
 from privacyidea.lib.token import get_token_owner, get_tokens
 from privacyidea.lib.user import User, UserError
+from privacyidea.lib.counter import read as counter_read
 from privacyidea.lib.utils import (compare_condition, compare_value_value,
+                                   compare_generic_condition,
                                    parse_time_offset_from_now, is_true,
                                    check_ip_in_policy)
 import datetime
@@ -64,6 +66,7 @@ class CONDITION(object):
     COUNT_AUTH = "count_auth"
     COUNT_AUTH_SUCCESS = "count_auth_success"
     COUNT_AUTH_FAIL = "count_auth_fail"
+    COUNTER = "counter"
     FAILCOUNTER = 'failcounter'
     TOKENINFO = "tokeninfo"
     DETAIL_ERROR_MESSAGE = "detail_error_message"
@@ -252,6 +255,12 @@ def conditions(cls):
                           "'<fieldname> == <fieldvalue>', '<fieldname> > "
                           "<fieldvalue>' or '<fieldname> < <fieldvalue>'")
             },
+            CONDITION.COUNTER: {
+                "type": "str",
+                "desc": _("This condition can check the value of an arbitrary event counter and "
+                          "compare it like 'myCounter == 1000', 'myCounter > 1000' or "
+                          "'myCounter < 1000'.")
+            },
             CONDITION.DETAIL_ERROR_MESSAGE: {
                 "type": "str",
                 "desc": _("Here you can enter a regular expression. The "
@@ -455,6 +464,15 @@ def check_condition(self, options):
             if not bool(m):
                 return False
 
+        if CONDITION.COUNTER in conditions:
+            # Can be counter==1000
+            if not compare_generic_condition(conditions.get(CONDITION.COUNTER),
+                                             lambda x: counter_read(x) or 0,
+                                             "Misconfiguration in your counter "
+                                             "condition: {0!s}"
+                                             ):
+                return False
+
         # Token specific conditions
         if token_obj:
             if CONDITION.TOKENTYPE in conditions:
@@ -535,25 +553,10 @@ def check_condition(self, options):
                 s_now = (datetime.datetime.now(tzlocal()) + td).strftime(
                     DATE_FORMAT)
                 cond = cond.format(now=s_now)
-                if len(cond.split("==")) == 2:
-                    key, value = [x.strip() for x in cond.split("==")]
-                    if not compare_value_value(token_obj.get_tokeninfo(key),
-                                              "==", value):
-                        return False
-                elif len(cond.split(">")) == 2:
-                    key, value = [x.strip() for x in cond.split(">")]
-                    if not compare_value_value(token_obj.get_tokeninfo(key),
-                                              ">", value):
-                        return False
-                elif len(cond.split("<")) == 2:
-                    key, value = [x.strip() for x in cond.split("<")]
-                    if not compare_value_value(token_obj.get_tokeninfo(key),
-                                              "<", value):
-                        return False
-                else:
-                    # There is a condition, but we do not know it!
-                    log.warning("Misconfiguration in your tokeninfo "
-                                "condition: {0!s}".format(cond))
+                if not compare_generic_condition(cond,
+                                                 token_obj.get_tokeninfo,
+                                                 "Misconfiguration in your tokeninfo "
+                                                 "condition: {0!s}"):
                     return False
 
             if CONDITION.ROLLOUT_STATE in conditions:
diff --git a/privacyidea/lib/utils/__init__.py b/privacyidea/lib/utils/__init__.py
--- a/privacyidea/lib/utils/__init__.py
+++ b/privacyidea/lib/utils/__init__.py
@@ -537,7 +537,7 @@ def parse_date(date_string):
         # If it stars with a year 2017/... we do NOT dayfirst.
         # See https://github.com/dateutil/dateutil/issues/457
         d = parse_date_string(date_string,
-                              dayfirst=re.match(r"^\d\d[/\.]", date_string))
+                              dayfirst=re.match(r"^\d\d[/.]", date_string))
     except ValueError:
         log.debug("Dateformat {0!s} could not be parsed".format(date_string))
 
@@ -610,8 +610,8 @@ def check_proxy(path_to_client, proxy_settings):
     try:
         proxy_dict = parse_proxy(proxy_settings)
     except AddrFormatError:
-        log.error("Error parsing the OverrideAuthorizationClient setting: {"
-                  "0!s}! The IP addresses need to be comma separated. Fix "
+        log.error("Error parsing the OverrideAuthorizationClient setting: "
+                  "{0!s}! The IP addresses need to be comma separated. Fix "
                   "this. The client IP will not be mapped!".format(proxy_settings))
         log.debug("{0!s}".format(traceback.format_exc()))
         return path_to_client[0]
@@ -822,23 +822,13 @@ def compare_condition(condition, value):
         return False
 
     try:
-        # compare equal
-        if condition[0] in "=" + string.digits:
-            if condition[0] == "=":
-                compare_value = int(condition[1:])
-            else:
-                compare_value = int(condition)
-            return value == compare_value
-
-        # compare bigger
-        if condition[0] == ">":
-            compare_value = int(condition[1:])
-            return value > compare_value
-
-        # compare less
-        if condition[0] == "<":
-            compare_value = int(condition[1:])
-            return value < compare_value
+        if condition[0:2] in ["==", "!=", ">=", "=>", "<=", "=<"]:
+            return compare_value_value(value, condition[0:2], int(condition[2:]))
+        elif condition[0] in ["=", "<", ">"]:
+            return compare_value_value(value, condition[0], int(condition[1:]))
+        else:
+            return value == int(condition)
+
     except ValueError:
         log.warning(u"Invalid condition {0!s}. Needs to contain an integer.".format(condition))
         return False
@@ -847,11 +837,14 @@ def compare_condition(condition, value):
 def compare_value_value(value1, comparator, value2):
     """
     This function compares value1 and value2 with the comparator.
-    The comparator may be "==", ">" or "<".
+    The comparator may be "==", "=", "!=", ">", "<", ">=", "=>", "<=" or "=<".
     
-    If the values can be converted to integers, they are compared as integers 
+    If the values can be converted to integers or dates, they are compared as such,
     otherwise as strings.
-    
+
+    In case of dates make sure they can be parsed by 'parse_date()', otherwise
+    they will be compared as strings.
+
     :param value1: First value 
     :param value2: Second value
     :param comparator: The comparator
@@ -878,14 +871,47 @@ def compare_value_value(value1, comparator, value2):
         except Exception:
             log.debug("error during date conversion.")
 
-    if comparator == "==":
+    if comparator in ["==", "="]:
         return value1 == value2
     elif comparator == ">":
         return value1 > value2
     elif comparator == "<":
         return value1 < value2
+    elif comparator in ['>=', '=>']:
+        return value1 >= value2
+    elif comparator in ['<=', '=<']:
+        return value1 <= value2
+    elif comparator == '!=':
+        return value1 != value2
+    else:
+        raise Exception("Unknown comparator: {0!s}".format(comparator))
 
-    raise Exception("Unknown comparator: {0!s}".format(comparator))
+
+def compare_generic_condition(cond, key_method, warning):
+    """
+    Compares a condition like "tokeninfoattribute == value".
+    It uses the "key_method" to determine the value of "tokeninfoattribute".
+
+    If the value does not match, it returns False.
+
+    :param cond: A condition containing a comparator like "==", ">", "<"
+    :param key_method: A function call, that get the value from the key
+    :param warning: A warning message to be written to the log file.
+    :return: True of False
+    """
+    key = value = None
+    for comparator in ["==", ">", "<"]:
+        if len(cond.split(comparator)) == 2:
+            key, value = [x.strip() for x in cond.split(comparator)]
+            break
+    if value:
+        res = compare_value_value(key_method(key), comparator, value)
+        log.debug("Comparing {0!s} {1!s} {2!s} with result {3!s}.".format(key, comparator, value, res))
+        return res
+    else:
+        # There is a condition, but we do not know it!
+        log.warning(warning.format(cond))
+        raise Exception("Condition not parsable.")
 
 
 def int_to_hex(serial):
@@ -1130,7 +1156,7 @@ def check_pin_policy(pin, policy):
     if not policy:
         return False, "No policy given."
 
-    if policy[0] in ["+", "-"] or policy[0] is not "[":
+    if policy[0] in ["+", "-"] or policy[0] != "[":
         for char in policy[1:]:
             if char not in chars.keys():
                 raise PolicyError("Unknown character specifier in PIN policy.")


Test patch:
diff --git a/tests/test_lib_events.py b/tests/test_lib_events.py
--- a/tests/test_lib_events.py
+++ b/tests/test_lib_events.py
@@ -22,6 +22,7 @@
 from privacyidea.lib.eventhandler.federationhandler import FederationEventHandler
 from privacyidea.lib.eventhandler.requestmangler import RequestManglerEventHandler
 from privacyidea.lib.eventhandler.base import BaseEventHandler, CONDITION
+from privacyidea.lib.counter import increase as counter_increase
 from flask import Request
 from werkzeug.test import EnvironBuilder
 from privacyidea.lib.event import (delete_event, set_event,
@@ -553,6 +554,76 @@ def test_07_check_rollout_state(self):
 
         remove_token(serial)
 
+    def test_08_counter_condition(self):
+        # increase a counter to 4
+        for i in range(0, 4):
+            counter_increase("myCounter")
+
+        uhandler = BaseEventHandler()
+        builder = EnvironBuilder(method='POST',
+                                 data={'user': "cornelius@realm1",
+                                       "pass": "secret"},
+                                 headers={})
+        env = builder.get_environ()
+        req = Request(env)
+        # This is a kind of authentication request
+        req.all_data = {"user": "cornelius@realm1",
+                        "pass": "secret"}
+        req.User = User("cornelius", "realm1")
+        resp = Response()
+        resp.data = """{"result": {"value": true}}"""
+
+        r = uhandler.check_condition(
+            {"g": {},
+             "handler_def": {"conditions": {CONDITION.COUNTER:
+                                                "myCounter<4"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertFalse(r)
+
+        r = uhandler.check_condition(
+            {"g": {},
+             "handler_def": {"conditions": {CONDITION.COUNTER:
+                                                "myCounter==4"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertTrue(r)
+
+        r = uhandler.check_condition(
+            {"g": {},
+             "handler_def": {"conditions": {CONDITION.COUNTER:
+                                                "myCounter>3"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertTrue(r)
+
+        # If we have a nonexisting counter this should be treated as zero
+        r = uhandler.check_condition(
+            {"g": {},
+             "handler_def": {"conditions": {CONDITION.COUNTER:
+                                                "myNonExistingCounter>3"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertFalse(r)
+
+        r = uhandler.check_condition(
+            {"g": {},
+             "handler_def": {"conditions": {CONDITION.COUNTER:
+                                                "myNonExistingCounter<3"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertTrue(r)
+
 
 class CounterEventTestCase(MyTestCase):
 
diff --git a/tests/test_lib_utils.py b/tests/test_lib_utils.py
--- a/tests/test_lib_utils.py
+++ b/tests/test_lib_utils.py
@@ -10,6 +10,7 @@
                                    parse_date, compare_condition,
                                    get_data_from_params, parse_legacy_time,
                                    int_to_hex, compare_value_value,
+                                   compare_generic_condition,
                                    parse_time_offset_from_now, censor_connect_string,
                                    parse_timedelta, to_unicode,
                                    parse_int, convert_column_to_unicode,
@@ -290,6 +291,8 @@ def test_07_parse_date(self):
         d = parse_date("")
         self.assertTrue(datetime.now(tzlocal()) >= d)
 
+        self.assertIsNone(parse_date("-2g"))
+
         d = parse_date("2016/12/23")
         self.assertEqual(d, datetime(2016, 12, 23))
 
@@ -322,9 +325,14 @@ def test_07_parse_date(self):
         d = parse_date("03.04.2016")
         # April 3rd
         self.assertEqual(d, datetime(2016, 4, 3, 0, 0))
+        self.assertEqual(parse_date('03/04/2016'), datetime(2016, 4, 3, 0, 0))
+        self.assertEqual(parse_date('01/01/20'), datetime(2020, 1, 1, 0, 0))
+        self.assertEqual(parse_date('01/15/20'), datetime(2020, 1, 15, 0, 0))
+        self.assertEqual(parse_date('15/01/20'), datetime(2020, 1, 15, 0, 0))
 
         # Non matching date returns None
         self.assertEqual(parse_date("7 Januar 17"), None)
+        self.assertIsNone(parse_date('15/15'))
 
     def test_08_compare_condition(self):
         self.assertTrue(compare_condition("100", 100))
@@ -348,6 +356,21 @@ def test_08_compare_condition(self):
         # An invalid condition, which misses a compare-value, will result in false
         self.assertFalse(compare_condition(">", 100))
 
+        # Test new comparators
+        self.assertTrue(compare_condition('>=100', 100))
+        self.assertTrue(compare_condition('=> 100', 200))
+        self.assertFalse(compare_condition('>= 100', 99))
+
+        self.assertTrue(compare_condition('<=100', 100))
+        self.assertTrue(compare_condition('=< 100', 99))
+        self.assertFalse(compare_condition('<= 100', 101))
+
+        self.assertTrue(compare_condition('!=100', 99))
+        self.assertFalse(compare_condition('!= 100', 100))
+
+        self.assertTrue(compare_condition('==100', 100))
+        self.assertFalse(compare_condition('== 100', 99))
+
     def test_09_get_data_from_params(self):
         config_description = {
             "local":  {
@@ -406,6 +429,21 @@ def test_12_compare_value_value(self):
         self.assertTrue(compare_value_value(1000, "==", "1000"))
         self.assertTrue(compare_value_value("99", "<", "1000"))
 
+        self.assertTrue(compare_value_value(100, '>', '10'))
+        self.assertTrue(compare_value_value(100, '>=', '10'))
+        self.assertTrue(compare_value_value("100", '=>', 10))
+        self.assertTrue(compare_value_value(100, '>=', '100'))
+        self.assertFalse(compare_value_value(100, '>=', '101'))
+
+        self.assertTrue(compare_value_value('ABC', '=', 'ABC'))
+        self.assertTrue(compare_value_value('ABC', '!=', 'ABD'))
+
+        self.assertTrue(compare_value_value(10, '<', '100'))
+        self.assertTrue(compare_value_value(10, '<=', '100'))
+        self.assertTrue(compare_value_value("10", '=<', 100))
+        self.assertTrue(compare_value_value(10, '<=', '10'))
+        self.assertFalse(compare_value_value(10, '<=', '9'))
+
         # compare dates
         self.assertTrue(compare_value_value(
                         datetime.now(tzlocal()).strftime(DATE_FORMAT), ">",
@@ -418,6 +456,21 @@ def test_12_compare_value_value(self):
             (datetime.now(tzlocal()) + timedelta(hours=10)).strftime(DATE_FORMAT),
             ">", datetime.now(tzlocal()).strftime(DATE_FORMAT)))
 
+        self.assertTrue(compare_value_value('+3h', '>', ''))
+        self.assertFalse(compare_value_value('2017/04/20 11:30+0200', '>',
+                                             datetime.now(tzlocal()).strftime(DATE_FORMAT)))
+
+        self.assertTrue(compare_value_value('2020-01-15T00:00', '==',
+                                            datetime(2020, 1, 15).strftime(DATE_FORMAT)))
+        # unexpected result: The date string can not be parsed since dateutil.parser
+        # does not understand locale dates. So the strings themselves are compared
+        # since parse_date() returns 'None'
+        self.assertTrue(compare_value_value('16. März 2020', '<',
+                                            datetime(2020, 3, 15).strftime(DATE_FORMAT)))
+
+        # check for unknown comparator
+        self.assertRaises(Exception, compare_value_value, 5, '~=', 5)
+
     def test_13_parse_time_offset_from_now(self):
         td = parse_timedelta("+5s")
         self.assertEqual(td, timedelta(seconds=5))
@@ -849,4 +902,52 @@ def test_33_determine_logged_in_user(self):
                            "username": "Wyatt Earp",
                            "realm": "Wild West"},
                           {"user": "Dave Rudabaugh",
-                           "realm": "Dodge City"})
\ No newline at end of file
+                           "realm": "Dodge City"})
+
+    def test_34_compare_generic_condition(self):
+
+        def mock_attribute(key):
+            attr = {"a": "10",
+                    "b": "100",
+                    "c": "1000"}
+            return attr.get(key)
+
+        self.assertTrue(compare_generic_condition("a<100",
+                                                  mock_attribute,
+                                                  "Error {0!s}"))
+
+        self.assertTrue(compare_generic_condition("a <100",
+                                                  mock_attribute,
+                                                  "Error {0!s}"))
+
+        self.assertTrue(compare_generic_condition("b==100",
+                                                  mock_attribute,
+                                                  "Error {0!s}"))
+
+        # Wrong condition
+        self.assertFalse(compare_generic_condition("a== 100",
+                                                   mock_attribute,
+                                                   "Error {0!s}"))
+
+        # Wrong condition
+        self.assertFalse(compare_generic_condition("b>100",
+                                                   mock_attribute,
+                                                   "Error {0!s}"))
+
+        # Wrong condition
+        self.assertFalse(compare_generic_condition("c < 500",
+                                                   mock_attribute,
+                                                   "Error {0!s}"))
+
+        # Wrong condition
+        self.assertFalse(compare_generic_condition("c <500",
+                                                   mock_attribute,
+                                                   "Error {0!s}"))
+
+        # Wrong entry, that is not processed
+        self.assertRaises(Exception, compare_generic_condition,
+                          "c 500", mock_attribute, "Error {0!s}")
+
+        # Wrong entry, that cannot be processed
+        self.assertRaises(Exception, compare_generic_condition,
+                          "b!~100", mock_attribute, "Error {0!s}")