Repository: privacyidea/privacyidea

Issue:
Event handlers dependent on IP or user
We could make event handlers dependent on users or IPs.

Well, actually we might also think about using the same **conditions** for event handlers and for policies.

Could we build some kinf of a conditions class, that would apply for both?

See https://community.privacyidea.org/t/event-handler-for-specific-user-s-or-ip-s/934


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
@@ -34,12 +34,14 @@
 from privacyidea.lib import _
 from privacyidea.lib.config import get_token_types
 from privacyidea.lib.realm import get_realms
+from privacyidea.lib.resolver import get_resolver_list
 from privacyidea.lib.auth import ROLE
 from privacyidea.lib.policy import ACTION
 from privacyidea.lib.token import get_token_owner, get_tokens
 from privacyidea.lib.user import User, UserError
 from privacyidea.lib.utils import (compare_condition, compare_value_value,
-                                   parse_time_offset_from_now, is_true)
+                                   parse_time_offset_from_now, is_true,
+                                   check_ip_in_policy)
 import datetime
 from dateutil.tz import tzlocal
 import re
@@ -69,6 +71,11 @@ class CONDITION(object):
     DETAIL_MESSAGE = "detail_message"
     RESULT_VALUE = "result_value"
     RESULT_STATUS = "result_status"
+    TOKENREALM = "tokenrealm"
+    TOKENRESOLVER = "tokenresolver"
+    REALM = "realm"
+    RESOLVER = "resolver"
+    CLIENT_IP = "client_ip"
 
 
 class BaseEventHandler(object):
@@ -118,17 +125,29 @@ def conditions(cls):
         :return: dict
         """
         realms = get_realms()
+        resolvers = get_resolver_list()
         cond = {
-            "realm": {
+            CONDITION.REALM: {
                 "type": "str",
                 "desc": _("The user realm, for which this event should apply."),
                 "value": list(realms)
             },
-            "tokenrealm": {
+            CONDITION.RESOLVER: {
+                "type": "str",
+                "desc": _("The user resolver, for which this event should apply."),
+                "value": list(resolvers)
+            },
+            CONDITION.TOKENREALM: {
                 "type": "multi",
                 "desc": _("The token realm, for which this event should "
                           "apply."),
-                "value": [{"name": r} for r in realms.keys()]
+                "value": [{"name": r} for r in realms]
+            },
+            CONDITION.TOKENRESOLVER: {
+                "type": "multi",
+                "desc": _("The token resolver, for which this event should "
+                          "apply."),
+                "value": [{"name": r} for r in resolvers]
             },
             CONDITION.TOKENTYPE: {
                 "type": "multi",
@@ -233,6 +252,10 @@ def conditions(cls):
                 "desc": _("Here you can enter a regular expression. The "
                           "condition only applies if the regular expression "
                           "matches the detail->message in the response.")
+            },
+            CONDITION.CLIENT_IP: {
+                "type": "str",
+                "desc": _("Trigger the action, if the client IP matches.")
             }
         }
         return cond
@@ -301,6 +324,7 @@ def check_condition(self, options):
         serial = request.all_data.get("serial") or \
                  content.get("detail", {}).get("serial")
         tokenrealms = []
+        tokenresolvers = []
         tokentype = None
         token_obj = None
         if serial:
@@ -311,12 +335,30 @@ def check_condition(self, options):
             #  the user has only one token
             token_obj_list = get_tokens(user=user)
         if len(token_obj_list) == 1:
+            # There is a token involved, so we determine it's resolvers and realms
             token_obj = token_obj_list[0]
             tokenrealms = token_obj.get_realms()
             tokentype = token_obj.get_tokentype()
 
-        if "realm" in conditions:
-            if user.realm != conditions.get("realm"):
+            all_realms = get_realms()
+            for tokenrealm in tokenrealms:
+                resolvers = all_realms.get(tokenrealm, {}).get("resolver", {})
+                tokenresolvers.extend([r.get("name") for r in resolvers])
+            tokenresolvers = list(set(tokenresolvers))
+
+        if CONDITION.CLIENT_IP in conditions:
+            if g and g.client_ip:
+                ip_policy = [ip.strip() for ip in conditions.get(CONDITION.CLIENT_IP).split(",")]
+                found, excluded = check_ip_in_policy(g.client_ip, ip_policy)
+                if not found or excluded:
+                    return False
+
+        if CONDITION.REALM in conditions:
+            if user.realm != conditions.get(CONDITION.REALM):
+                return False
+
+        if CONDITION.RESOLVER in conditions:
+            if user.resolver != conditions.get(CONDITION.RESOLVER):
                 return False
 
         if "logged_in_user" in conditions:
@@ -356,10 +398,19 @@ def check_condition(self, options):
                 if not ','.join([tok.get_serial() for tok in token_objects]):
                     return False
 
-        if "tokenrealm" in conditions and tokenrealms:
+        if CONDITION.TOKENREALM in conditions and tokenrealms:
             res = False
             for trealm in tokenrealms:
-                if trealm in conditions.get("tokenrealm").split(","):
+                if trealm in conditions.get(CONDITION.TOKENREALM).split(","):
+                    res = True
+                    break
+            if not res:
+                return False
+
+        if CONDITION.TOKENRESOLVER in conditions and tokenresolvers:
+            res = False
+            for tres in tokenresolvers:
+                if tres in conditions.get(CONDITION.TOKENRESOLVER).split(","):
                     res = True
                     break
             if not res:
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -155,8 +155,6 @@
 from .log import log_with
 from configobj import ConfigObj
 
-from netaddr import IPAddress
-from netaddr import IPNetwork
 from operator import itemgetter
 import six
 import logging
@@ -171,7 +169,7 @@
 from privacyidea.lib.smtpserver import get_smtpservers
 from privacyidea.lib.radiusserver import get_radiusservers
 from privacyidea.lib.utils import (check_time_in_range, reload_db,
-                                   fetch_one_resource, is_true)
+                                   fetch_one_resource, is_true, check_ip_in_policy)
 from privacyidea.lib.user import User
 from privacyidea.lib import _
 import datetime
@@ -580,17 +578,8 @@ def get_policies(self, name=None, scope=None, realm=None, active=None,
         if client is not None:
             new_policies = []
             for policy in reduced_policies:
-                client_found = False
-                client_excluded = False
-                for polclient in policy.get("client"):
-                    if polclient[0] in ['-', '!']:
-                        # exclude the client?
-                        if IPAddress(client) in IPNetwork(polclient[1:]):
-                            log.debug("the client %s is excluded by %s in "
-                                      "policy %s" % (client, polclient, policy))
-                            client_excluded = True
-                    elif IPAddress(client) in IPNetwork(polclient):
-                        client_found = True
+                log.debug(u"checking client ip in policy {0!s}.".format(policy))
+                client_found, client_excluded = check_ip_in_policy(client, policy.get("client"))
                 if client_found and not client_excluded:
                     # The client was contained in the defined subnets and was
                     #  not excluded
diff --git a/privacyidea/lib/utils.py b/privacyidea/lib/utils.py
--- a/privacyidea/lib/utils.py
+++ b/privacyidea/lib/utils.py
@@ -635,6 +635,29 @@ def get_client_ip(request, proxy_settings):
     return client_ip
 
 
+def check_ip_in_policy(client_ip, policy):
+    """
+    This checks, if the given client IP is contained in a list like
+
+       ["10.0.0.2", "192.168.2.1/24", "!192.168.2.12", "-172.16.200.1"]
+
+    :param client_ip: The IP address in question
+    :param policy: A string of single IP addresses, negated IP address and subnets.
+    :return: tuple of (found, excluded)
+    """
+    client_found = False
+    client_excluded = False
+    for ipdef in policy:
+        if ipdef[0] in ['-', '!']:
+            # exclude the client?
+            if IPAddress(client_ip) in IPNetwork(ipdef[1:]):
+                log.debug(u"the client {0!s} is excluded by {1!s}".format(client_ip, ipdef))
+                client_excluded = True
+        elif IPAddress(client_ip) in IPNetwork(ipdef):
+            client_found = True
+    return client_found, client_excluded
+
+
 def reload_db(timestamp, db_ts):
     """
     Check if the configuration database should be reloaded. This is verified


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
@@ -426,6 +426,46 @@ def test_05_detail_messages_condition(self):
         )
         self.assertEqual(r, False)
 
+    def test_06_check_for_client_ip(self):
+        uhandler = BaseEventHandler()
+        builder = EnvironBuilder(method='POST',
+                                 data={'user': "cornelius@realm1",
+                                       "pass": "secret"},
+                                 headers={})
+        g = FakeFlaskG()
+        g.client_ip = "10.0.0.1"
+        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")
+
+        # Check DETAIL_MESSAGE
+        resp = Response()
+        resp.data = """{"result": {"value": true, "status": true},
+                "detail": {"message": "something very special happened"}
+                }
+                """
+
+        r = uhandler.check_condition(
+            {"g": g,
+             "handler_def": {"conditions": {CONDITION.CLIENT_IP: "10.0.0.0/24"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertTrue(r)
+
+        r = uhandler.check_condition(
+            {"g": g,
+             "handler_def": {"conditions": {CONDITION.CLIENT_IP: "10.0.0.0/24, !10.0.0.1"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertFalse(r)
+
 
 class CounterEventTestCase(MyTestCase):
 
@@ -1750,6 +1790,24 @@ def test_06_check_conditions_realm(self):
         # wrong realm
         self.assertEqual(r, False)
 
+        # Check condition resolver
+        r = uhandler.check_condition(
+            {"g": {},
+             "handler_def": {"conditions": {"resolver": "resolver1"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertTrue(r)
+        r = uhandler.check_condition(
+            {"g": {},
+             "handler_def": {"conditions": {"resolver": "resolver2"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertFalse(r)
+
     @smtpmock.activate
     def test_07_locked_token_wrong_pin(self):
         tok = init_token({"serial": "lock2", "type": "spass",
@@ -1839,9 +1897,27 @@ def test_09_check_conditions_tokenrealm(self):
              "response": resp
              }
         )
-        # Serial matches the regexp
+        # realm matches
         self.assertEqual(r, True)
 
+        # test condition tokenresolver
+        r = uhandler.check_condition(
+            {"g": {},
+             "handler_def": {"conditions": {"tokenresolver": "resolver1,reso2"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertTrue(r)
+        r = uhandler.check_condition(
+            {"g": {},
+             "handler_def": {"conditions": {"tokenresolver": "reso2,reso3"}},
+             "request": req,
+             "response": resp
+             }
+        )
+        self.assertFalse(r)
+
     def test_10_check_conditions_tokentype(self):
         uhandler = UserNotificationEventHandler()
         # check if tokenrealm is contained
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
@@ -20,7 +20,8 @@
                                    b32encode_and_unicode, to_bytes,
                                    b64encode_and_unicode, create_png, create_img,
                                    convert_timestamp_to_utc, modhex_encode,
-                                   modhex_decode, checksum, urlsafe_b64encode_and_unicode)
+                                   modhex_decode, checksum, urlsafe_b64encode_and_unicode,
+                                   check_ip_in_policy)
 from datetime import timedelta, datetime
 from netaddr import IPAddress, IPNetwork, AddrFormatError
 from dateutil.tz import tzlocal, tzoffset, gettz
@@ -632,3 +633,12 @@ def test_28_yubikey_utils(self):
         # now test the crc function
         self.assertEquals(checksum(b'\x01\x02\x03\x04'), 0xc66e)
         self.assertEquals(checksum(b'\x01\x02\x03\x04\x919'), 0xf0b8)
+
+    def test_29_check_ip(self):
+        found, excluded = check_ip_in_policy("10.0.1.2", ["10.0.1.0/24", "1.1.1.1"])
+        self.assertTrue(found)
+        self.assertFalse(excluded)
+
+        found, excluded = check_ip_in_policy("10.0.1.2", ["10.0.1.0/24", "!10.0.1.2"])
+        self.assertTrue(excluded)
+        self.assertTrue(found)
\ No newline at end of file