Repository: privacyidea/privacyidea

Issue:
Different behaviour for /validate/check and /auth endpoints -> policy condition
**Is your feature request related to a problem? Please describe.**

In loginmode=privacyIDEA users log in to the webui with their tokens.
This is basically a request against ``/auth`` with the same tokens like they are used in requests against ``/validate/check``.
However, sometimes it is required to have different login logic for these two endpoints, like:

* User is allowed to use EMAIL tokens to login to WebUI (/auth) but not to the VPN (/validate/check)
* User needs to use a PIN when login to WebUI (/auth) but not when login to VPN (/validate/check)

Usually this can be done using the client condition or HTTP headers in the policies. But sometimes this is deemed to complicated.

This sounds like a condition for the specific scopes ``authentication``   and ``authorization``.

**Describe the solution you'd like**

Use client IPs ;-)

**Describe alternatives you've considered**

* Add a condition. But this condition would only hold for two scopes. So suddenly we would have scope dependent conditions. However, we also have scope dependent conditions, if we do tokentypes as conditions! #2590
* ~~Add an extended condition like the *http_header* and *userinfo*. This way it would not be to prominent. However, the user could modify the *http_header* when authenticating against the webui, so this is not really practical!~~

# Concept

In `g.request_headers.environ` we have a lot of environment invormation like the `PATH_INFO` being `validate/check`. This way we could distringuish between `/auth` and `/validate/check`.

![image](https://user-images.githubusercontent.com/1908620/132235140-a5f82717-4467-4c2f-b40e-1d6dfd0563bb.png)

We could add an additional extended condition (to userinfo, token, tokeninfo, http request header) being *HTTP environment*. 
This way we could have an extended condition `PATH_INFO=/validate/check` which would e.g. match the OTPPIN policy only in case of endpoint /validate/check and not /auth.


Discussion:
Not sure if I got your intent fully right, but:

> Add an extended condition like the http_header [...]

actually, I believe I had implemented an according /validate/check distinction this way the last few days and hit #2514 .  I needed different actions using (push) tokens for the same client, always via PAM, depending on the actual application requesting auth/session permissions.
@jmozd for this what you are mentioning, you can use the http_header. It actually has nothing to do with this issue.
@cornelinux The need to distinguish between /auth and /validate/check while doind login_mode=privacyIDEA occured to me several times. So I would vote for this feature.
@jmozd @laclaro After all this is already possible.

In the apache config we can add a header depending on the endpoint:

~~~~
        <Location /auth>
                RequestHeader add PIInfo "auth"
        </Location>

        <Location /validate>
                RequestHeader add PIInfo "validate"
        </Location>
~~~~

This will result in an HTTP header `Piinfo=validate` for `validate/check` and in `Piinfo=auth` for `/auth`.

The extended policy condition can then be set to 

     HTTP Header, Piinfo, validate


Nevertheless we can add an extended condition for HTTP Request Environment.

Resolving patch:
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -1,5 +1,7 @@
 # -*- coding: utf-8 -*-
 #
+#  2021-09-06 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#             Add extended condition for HTTP environment
 #  2021-02-01 Cornelius Kölbel <cornelius.koelbel@netknights.it>
 #             Add custom user attributes
 #  2020-06-05 Cornelius Kölbel <cornelius.koelbel@netknights.it>
@@ -451,6 +453,7 @@ class CONDITION_SECTION(object):
     TOKENINFO = "tokeninfo"
     TOKEN = "token"
     HTTP_REQUEST_HEADER = "HTTP Request header"
+    HTTP_ENVIRONMENT = "HTTP Environment"
 
 
 class CONDITION_CHECK(object):
@@ -806,6 +809,11 @@ def filter_policies_by_conditions(self, policies, user_object=None, request_head
                                                                                  request_headers):
                                 include_policy = False
                                 break
+                        elif section == CONDITION_SECTION.HTTP_ENVIRONMENT:
+                            if not self._policy_matches_request_environ_condition(policy, key, comparator, value,
+                                                                                 request_headers):
+                                include_policy = False
+                                break
                         else:
                             log.warning(u"Policy {!r} has condition with unknown section: {!r}".format(
                                 policy['name'], section
@@ -815,6 +823,38 @@ def filter_policies_by_conditions(self, policies, user_object=None, request_head
                 reduced_policies.append(policy)
         return reduced_policies
 
+    @staticmethod
+    def _policy_matches_request_environ_condition(policy, key, comparator, value, request_headers):
+        """
+        :param request_headers: Request Header object
+        :type request_headers: Can be accessed using .get()
+        """
+        # Now we check the HTTP request headers
+        if request_headers is not None:
+            request_environ = request_headers.environ
+            if request_environ.get(key):
+                try:
+                    environ_value = request_environ.get(key)
+                    return compare_values(environ_value, comparator, value)
+                except Exception as exx:
+                    log.warning(u"Error during handling the condition on HTTP environment {!r} "
+                                u"of policy {!r}: {!r}".format(key, policy['name'], exx))
+                    raise PolicyError(
+                        u"Invalid comparison in the HTTP environment conditions of policy {!r}".format(policy['name']))
+            else:
+                log.warning(u"Unknown HTTP environment key referenced in condition of policy "
+                            u"{!r}: {!r}".format(policy["name"], key))
+                log.warning(u"Available HTTP environment: {!r}".format(request_environ))
+                raise PolicyError(u"Unknown HTTP environment key referenced in condition of policy "
+                                  u"{!r}: {!r}".format(policy["name"], key))
+        else:  # pragma: no cover
+            log.error(u"Policy {!r} has conditions on HTTP environment, but HTTP environment"
+                      u" is not available. This should not happen - possible "
+                      u"programming error {!s}.".format(policy["name"],
+                                                        ''.join(traceback.format_stack())))
+            raise PolicyError(u"Policy {!r} has conditions on environment {!r}, but HTTP environment"
+                              u" is not available".format(policy["name"], key))
+
     @staticmethod
     def _policy_matches_request_header_condition(policy, key, comparator, value, request_headers):
         """
@@ -836,6 +876,7 @@ def _policy_matches_request_header_condition(policy, key, comparator, value, req
             else:
                 log.warning(u"Unknown HTTP header key referenced in condition of policy "
                             u"{!r}: {!r}".format(policy["name"], key))
+                log.warning(u"Available HTTP headers: {!r}".format(request_headers))
                 raise PolicyError(u"Unknown HTTP header key referenced in condition of policy "
                                   u"{!r}: {!r}".format(policy["name"], key))
         else:  # pragma: no cover
@@ -2503,6 +2544,9 @@ def get_policy_condition_sections():
         },
         CONDITION_SECTION.HTTP_REQUEST_HEADER: {
             "description": _("The policy only matches if certain conditions on the HTTP Request header are fulfilled.")
+        },
+        CONDITION_SECTION.HTTP_ENVIRONMENT: {
+            "description": _("The policy only matches if certain conditions on the HTTP Environment are fulfilled.")
         }
     }
 


Test patch:
diff --git a/tests/test_api_policy.py b/tests/test_api_policy.py
--- a/tests/test_api_policy.py
+++ b/tests/test_api_policy.py
@@ -2,6 +2,7 @@
 
 from .base import MyApiTestCase
 from privacyidea.lib.policy import set_policy, SCOPE, ACTION, delete_policy, CONDITION_SECTION
+from privacyidea.lib.token import remove_token
 
 
 class APIPolicyTestCase(MyApiTestCase):
@@ -388,6 +389,7 @@ def test_04_policy_defs(self):
             self.assertIn("Node1", value)
             self.assertIn("Node2", value)
 
+
 class APIPolicyConditionTestCase(MyApiTestCase):
 
     def test_01_check_httpheader_condition(self):
@@ -520,3 +522,85 @@ def test_01_check_httpheader_condition(self):
             # so we only match for a substring
             self.assertIn("has condition with unknown section",
                           result["result"]["error"]["message"], result)
+
+        delete_policy("cond1")
+        remove_token("sp1")
+
+    def test_02_check_httpenvironment_condition(self):
+        self.setUp_user_realms()
+        # enroll a simple pass token
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           json={"type": "spass", "pin": "1234",
+                                                 "serial": "sp1", "user": "cornelius", "realm": "realm1"},
+                                           headers={'PI-Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+
+        # test an auth request
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           json={"pass": "1234", "user": "cornelius", "realm": "realm1"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json
+            self.assertTrue("detail" in result)
+            self.assertEqual(result.get("detail").get("message"), u"matching 1 tokens")
+
+        # set a policy with conditions
+        # Request with a certain request method will not see the user details
+        with self.app.test_request_context('/policy/cond1',
+                                           method='POST',
+                                           json={"action": ACTION.NODETAILSUCCESS,
+                                                 "realm": "realm1",
+                                                 "conditions": [[CONDITION_SECTION.HTTP_ENVIRONMENT,
+                                                                 "REQUEST_METHOD", "equals", "POST", True]],
+                                                 "scope": SCOPE.AUTHZ},
+                                           headers={'PI-Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+
+        # A GET request will contain the details!
+        with self.app.test_request_context('/validate/check',
+                                           method='GET',
+                                           json={"pass": "1234", "user": "cornelius", "realm": "realm1"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json
+            self.assertTrue("detail" in result)
+            self.assertEqual(result.get("detail").get("message"), u"matching 1 tokens")
+
+        # A POST request will NOT contain the details!
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           json={"pass": "1234", "user": "cornelius", "realm": "realm1"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json
+            self.assertFalse("detail" in result)
+
+        delete_policy("cond1")
+        # Now we run a test with a non-existing environment key
+        with self.app.test_request_context('/policy/cond1',
+                                           method='POST',
+                                           json={"action": ACTION.NODETAILSUCCESS,
+                                                 "realm": "realm1",
+                                                 "conditions": [[CONDITION_SECTION.HTTP_ENVIRONMENT,
+                                                                 "NON_EXISTING", "equals", "POST", True]],
+                                                 "scope": SCOPE.AUTHZ},
+                                           headers={'PI-Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           json={"pass": "1234", "user": "cornelius", "realm": "realm1"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 403)
+            result = res.json
+            self.assertIn(u"Unknown HTTP environment key referenced in condition of policy",
+                          result["result"]["error"]["message"])
+            self.assertIn(u"NON_EXISTING", result["result"]["error"]["message"])
+
+        delete_policy("cond1")
+        remove_token("sp1")