Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTP header values as extended policy conditions #1904

Merged
merged 7 commits into from Nov 18, 2019
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 14 additions & 0 deletions doc/policies/conditions.rst
Expand Up @@ -106,6 +106,20 @@ If the userinfo of the user that is trying to log in does not contain attributes
``email`` or ``groups`` (due to a resolver misconfiguration, for example), privacyIDEA
throws an error and the request is aborted.

``HTTP Request Header``
^^^^^^^^^^^^^^^^^^^^^^^

The section ``HTTP Request header`` can be used to define conditions that are checked against
the request header key-value pairs.

The ``Key`` specifies the request header key. It is case-sensitive.

privacyIDEA uses the ``Comparator`` to check if the value of a header is equal or a substring
of the required value.

.. note:: privacyIDEA raises an error if ``Key`` refers to an unknown request header.
plettich marked this conversation as resolved.
Show resolved Hide resolved


Comparators
~~~~~~~~~~~

Expand Down
2 changes: 2 additions & 0 deletions privacyidea/api/before_after.py
Expand Up @@ -159,6 +159,8 @@ def before_request():
# access_route contains the ip adresses of all clients, hops and proxies.
g.client_ip = get_client_ip(request,
get_from_config(SYSCONF.OVERRIDECLIENT))
# Save the HTTP header in the localproxy object
g.request_headers = request.headers
privacyidea_server = current_app.config.get("PI_AUDIT_SERVERNAME") or \
request.host
# Already get some typical parameters to log
Expand Down
6 changes: 6 additions & 0 deletions privacyidea/api/policy.py
Expand Up @@ -150,6 +150,12 @@ def set_policy_api(name=None):
realm=realm1
action=enroll, disable

The policy POST request can also take the parameter of conditions. This is a list of conditions sets:
[ [ "userinfo", "memberOf", "equals", "groupA", "true" ], [ ... ] ]
Being the ``section``, the ``key``, the ``comparator``, the ``value`` and ``active``.
plettich marked this conversation as resolved.
Show resolved Hide resolved
For more on conditions see :ref:`policy_conditions`.


**Example response**:

.. sourcecode:: http
Expand Down
2 changes: 2 additions & 0 deletions privacyidea/api/validate.py
Expand Up @@ -133,6 +133,8 @@ def before_request():
g.event_config = EventConfiguration()
# access_route contains the ip addresses of all clients, hops and proxies.
g.client_ip = get_client_ip(request, get_from_config(SYSCONF.OVERRIDECLIENT))
# Save the HTTP header in the localproxy object
g.request_headers = request.headers
g.audit_object.log({"success": False,
"action_detail": "",
"client": g.client_ip,
Expand Down
53 changes: 49 additions & 4 deletions privacyidea/lib/policy.py
Expand Up @@ -411,6 +411,7 @@ class TIMEOUT_ACTION(object):
class CONDITION_SECTION(object):
__doc__ = """This is a list of available sections for conditions of policies """
USERINFO = "userinfo"
HTTP_REQUEST_HEADER = "HTTP Request header"


class PolicyClass(object):
Expand Down Expand Up @@ -615,7 +616,7 @@ def list_policies(self, name=None, scope=None, realm=None, active=None,
def match_policies(self, name=None, scope=None, realm=None, active=None,
resolver=None, user=None, user_object=None,
client=None, action=None, adminrealm=None, time=None,
sort_by_priority=True, audit_data=None):
sort_by_priority=True, audit_data=None, request_headers=None):
"""
Return all policies matching the given context.
Optionally, write the matching policies to the audit log.
Expand Down Expand Up @@ -645,6 +646,7 @@ def match_policies(self, name=None, scope=None, realm=None, active=None,
:param audit_data: A dictionary with audit data collected during a request. This
method will add found policies to the dictionary.
:type audit_data: dict or None
:param request_headers: A dict with HTTP headers
:return: a list of policy dictionaries
"""
if user_object is not None:
Expand All @@ -668,7 +670,7 @@ def match_policies(self, name=None, scope=None, realm=None, active=None,
reduced_policies))

# filter policies by the policy conditions
reduced_policies = self.filter_policies_by_conditions(reduced_policies, user_object)
reduced_policies = self.filter_policies_by_conditions(reduced_policies, user_object, request_headers)
log.debug("Policies after matching conditions".format(
reduced_policies))

Expand All @@ -678,13 +680,15 @@ def match_policies(self, name=None, scope=None, realm=None, active=None,

return reduced_policies

def filter_policies_by_conditions(self, policies, user_object=None):
def filter_policies_by_conditions(self, policies, user_object=None, request_headers=None):
"""
Given a list of policy dictionaries and a current user object (if any),
return a list of all policies whose conditions match the given user object.
Raises a PolicyError if a condition references an unknown section.
:param policies: a list of policy dictionaries
:param user_object: a User object, or None if there is no current user
:param request_headers: The HTTP headers
:type request_headers: Request object
:return: generates a list of policy dictionaries
"""
reduced_policies = []
Expand All @@ -696,6 +700,11 @@ def filter_policies_by_conditions(self, policies, user_object=None):
if not self._policy_matches_userinfo_condition(policy, key, comparator, value, user_object):
include_policy = False
break
elif section == CONDITION_SECTION.HTTP_REQUEST_HEADER:
if not self._policy_matches_request_header_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
Expand All @@ -705,6 +714,35 @@ def filter_policies_by_conditions(self, policies, user_object=None):
reduced_policies.append(policy)
return reduced_policies

@staticmethod
def _policy_matches_request_header_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:
if request_headers.get(key):
try:
header_value = request_headers.get(key)
return compare_values(header_value, comparator, value)
except Exception as exx:
log.warning(u"Error during handling the condition on HTTP header {!r} of policy {!r}: {!r}".format(
key, policy['name'], exx
))
raise PolicyError(
u"Invalid comparison in the HTTP header conditions of policy {!r}".format(policy['name']))
else:
log.warning(u"Unknown HTTP header key referenced in condition of policy "
u"{!r}: {!r}".format(policy["name"], key))
raise PolicyError(u"Unknown HTTP header 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 headers {!r}, but http header"
u" is not available. This should not happen.".format(policy["name"], key))
raise PolicyError(u"Policy {!r} has conditions on headers {!r}, but http header"
u" is not available".format(policy["name"], key))

@staticmethod
def _policy_matches_userinfo_condition(policy, key, comparator, value, user_object):
"""
Expand Down Expand Up @@ -2126,6 +2164,9 @@ def get_policy_condition_sections():
return {
CONDITION_SECTION.USERINFO: {
"description": _("The policy only matches if certain conditions on the user info are fulfilled.")
},
CONDITION_SECTION.HTTP_REQUEST_HEADER: {
"description": _("The policy only matches if certain conditions on the HTTP Request header are fulfilled.")
}
}

Expand Down Expand Up @@ -2178,7 +2219,11 @@ def policies(self, write_to_audit_log=True):
audit_data = self._g.audit_object.audit_data
else:
audit_data = None
return self._g.policy_object.match_policies(audit_data=audit_data,
if hasattr(self._g, "request_headers"):
request_headers = self._g.request_headers
else:
request_headers = None
return self._g.policy_object.match_policies(audit_data=audit_data, request_headers=request_headers,
**self._match_kwargs)

def any(self, write_to_audit_log=True):
Expand Down
Expand Up @@ -51,11 +51,19 @@
</span>
</td>
<td style="text-align: right;">
<button class="btn btn-default" ng-click="editCondition($index)">
<button ng-show="editIndex != $index"
class="btn btn-primary" ng-click="editCondition($index)">
<span class="glyphicon glyphicon-edit"></span>
</button>
<button class="btn btn-default" ng-click="deleteCondition($index)">
<span class="glyphicon glyphicon-remove"></span>
<span translate>Edit</span>
</button>
<button ng-show="editIndex == $index"
class="btn btn-primary" ng-click="editCondition($index)">
<span class="glyphicon glyphicon-save"></span>
<span translate>Save</span>
</button>
<button class="btn btn-danger" ng-click="deleteCondition($index)">
<span class="glyphicon glyphicon-trash"></span>
<span translate>Delete</span>
</button>
</td>
</tr>
Expand Down
1 change: 1 addition & 0 deletions tests/base.py
Expand Up @@ -22,6 +22,7 @@ class FakeFlaskG(object):
logged_in_user = {}
audit_object = None
client_ip = None
request_headers = None


class FakeAudit(Audit):
Expand Down
96 changes: 90 additions & 6 deletions tests/test_api_policy.py
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-

import json
from .base import MyApiTestCase
from privacyidea.lib.policy import set_policy, SCOPE, ACTION, delete_policy
from privacyidea.lib.policy import set_policy, SCOPE, ACTION, delete_policy, CONDITION_SECTION


class APIPolicyTestCase(MyApiTestCase):
Expand Down Expand Up @@ -119,7 +118,8 @@ def test_02_set_policy_conditions(self):
"realm": "realm1",
"conditions": [
["userinfo", "groups", "contains", "group1", True],
["userinfo", "type", "equals", "secure", False]
["userinfo", "type", "equals", "secure", False],
["HTTP header", "Origin", "equals", "https://localhost", True]
]},
headers={'Authorization': self.at}):
res = self.app.full_dispatch_request()
Expand All @@ -136,10 +136,11 @@ def test_02_set_policy_conditions(self):
value = res.json['result']['value']
cond1 = value[0]
self.assertEqual(cond1["realm"], ["realm1"])
self.assertEqual(len(cond1["conditions"]), 2)
self.assertEqual(len(cond1["conditions"]), 3)
# order of conditions is not guaranteed
self.assertIn(["userinfo", "groups", "contains", "group1", True], cond1["conditions"])
self.assertIn(["userinfo", "type", "equals", "secure", False], cond1["conditions"])
self.assertIn(["HTTP header", "Origin", "equals", "https://localhost", True], cond1["conditions"])

# update the policy, but not its conditions
with self.app.test_request_context('/policy/cond1',
Expand All @@ -162,10 +163,11 @@ def test_02_set_policy_conditions(self):
value = res.json['result']['value']
cond1 = value[0]
self.assertEqual(cond1["realm"], ["realm2"])
self.assertEqual(len(cond1["conditions"]), 2)
self.assertEqual(len(cond1["conditions"]), 3)
# order of conditions is not guaranteed
self.assertIn(["userinfo", "groups", "contains", "group1", True], cond1["conditions"])
self.assertIn(["userinfo", "type", "equals", "secure", False], cond1["conditions"])
self.assertIn(["HTTP header", "Origin", "equals", "https://localhost", True], cond1["conditions"])

# update the policy conditions
with self.app.test_request_context('/policy/cond1',
Expand Down Expand Up @@ -264,4 +266,86 @@ def test_02_set_policy_conditions(self):
method='DELETE',
headers={'Authorization': self.at}):
res = self.app.full_dispatch_request()
self.assertEqual(res.status_code, 200)
self.assertEqual(res.status_code, 200)


class APIPolicyConditionTestCase(MyApiTestCase):

def test_01_check_httpheader_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 from a certain user agent will not see the detail
with self.app.test_request_context('/policy/cond1',
method='POST',
json={"action": ACTION.NODETAILSUCCESS,
"realm": "realm1",
"conditions" : [[CONDITION_SECTION.HTTP_REQUEST_HEADER,
"User-Agent", "equals", "SpecialApp", True]],
"scope": SCOPE.AUTHZ},
headers={'PI-Authorization': self.at}):
res = self.app.full_dispatch_request()
result = res.json
self.assertEqual(res.status_code, 200)

# A request with another header will display the details
with self.app.test_request_context('/validate/check',
method='POST',
headers={"User-Agent": "somethingelse"},
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 request with the dedicated header will not display the details
with self.app.test_request_context('/validate/check',
method='POST',
headers={'User-Agent': 'SpecialApp'},
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)

# A request without such a header
with self.app.test_request_context('/validate/check',
method='POST',
headers={"Another": "header"},
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 header key referenced in condition of policy",
result["result"]["error"]["message"])
self.assertIn(u"User-Agent", result["result"]["error"]["message"])

# A request without such a specific header - always has a header
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 header key referenced in condition of policy",
result["result"]["error"]["message"])
self.assertIn(u"User-Agent", result["result"]["error"]["message"])