Repository: privacyidea/privacyidea

Issue:
Enhance Authorization policy for tokeninfo field
Should be possible to match for any tokeninfo field in the authorization policy.

This might also be interesting for hardware/software tokens #828 (If this is stored in the tokeninfo at all)


Discussion:
Imagine we define a tokeninfo authorzation policy

     enrollment_state = /^enrolled.*/

which would mean, the authorization is successful, if the tokeninfo field ``enrollment_state`` starts with "enrolled".

@fredreichbier  What should happen if the token does not have such a tokeninfo field? Should authorization fail? 

This can be a valid approach. The administrator would have to take care, that every token has a certain tokeninfo field with some defined value.

Resolving patch:
diff --git a/privacyidea/api/lib/postpolicy.py b/privacyidea/api/lib/postpolicy.py
--- a/privacyidea/api/lib/postpolicy.py
+++ b/privacyidea/api/lib/postpolicy.py
@@ -198,7 +198,7 @@ def check_tokentype(request, response):
     policy_object = g.policy_object
     user_object = request.User
     allowed_tokentypes = policy_object.get_action_values(
-        "tokentype",
+        ACTION.TOKENTYPE,
         scope=SCOPE.AUTHZ,
         user=user_object.login,
         resolver=user_object.resolver,
@@ -230,7 +230,7 @@ def check_serial(request, response):
     policy_object = g.policy_object
     serial = content.get("detail", {}).get("serial")
     # get the serials from a policy definition
-    allowed_serials = policy_object.get_action_values("serial",
+    allowed_serials = policy_object.get_action_values(ACTION.SERIAL,
                                                     scope=SCOPE.AUTHZ,
                                                     client=g.client_ip)
 
@@ -248,6 +248,47 @@ def check_serial(request, response):
     return response
 
 
+def check_tokeninfo(request, response):
+    """
+    This policy function is used as a decorator for the validate API.
+    It checks after a successful authentication if the token has a matching
+    tokeninfo field. If it does not match, authorization is denied. Then
+    a PolicyException is raised.
+
+    :param response: The response of the decorated function
+    :type response: Response object
+    :return: A new modified response
+    """
+    content = json.loads(response.data)
+    policy_object = g.policy_object
+    serial = content.get("detail", {}).get("serial")
+    if serial:
+        tokens = get_tokens(serial=serial)
+        if len(tokens) == 1:
+            token_obj = tokens[0]
+            tokeninfos_pol = policy_object.get_action_values(
+                ACTION.TOKENINFO,
+                scope=SCOPE.AUTHZ,
+                client=g.client_ip,
+                allow_white_space_in_action=True)
+            for tokeninfo_pol in tokeninfos_pol:
+                try:
+                    key, regex, _r = tokeninfo_pol.split("/")
+                    value = token_obj.get_tokeninfo(key, "")
+                    if re.search(regex, value):
+                        log.debug(u"Regular expression {0!s} "
+                                  u"matches the tokeninfo field {1!s}.".format(regex, key))
+                    else:
+                        log.info(u"Tokeninfo field {0!s} with contents {1!s} "
+                                 u"does not match {2!s}".format(key, value, regex))
+                        raise PolicyError("Tokeninfo field {0!s} with contents does not"
+                                          " match regular expression.".format(key))
+                except ValueError:
+                    log.warning(u"invalid tokeinfo policy: {0!s}".format(tokeninfo_pol))
+
+    return response
+
+
 def no_detail_on_success(request, response):
     """
     This policy function is used with the AUTHZ scope.
diff --git a/privacyidea/api/validate.py b/privacyidea/api/validate.py
--- a/privacyidea/api/validate.py
+++ b/privacyidea/api/validate.py
@@ -80,6 +80,7 @@
                                            check_base_action)
 from privacyidea.api.lib.postpolicy import (postpolicy,
                                             check_tokentype, check_serial,
+                                            check_tokeninfo,
                                             no_detail_on_fail,
                                             no_detail_on_success, autoassign,
                                             offline_info,
@@ -164,6 +165,7 @@ def after_request(response):
 @postpolicy(no_detail_on_success, request=request)
 @postpolicy(add_user_detail_to_response, request=request)
 @postpolicy(offline_info, request=request)
+@postpolicy(check_tokeninfo, request=request)
 @postpolicy(check_tokentype, request=request)
 @postpolicy(check_serial, request=request)
 @postpolicy(autoassign, request=request)
@@ -309,6 +311,7 @@ def check():
 @postpolicy(no_detail_on_fail, request=request)
 @postpolicy(no_detail_on_success, request=request)
 @postpolicy(add_user_detail_to_response, request=request)
+@postpolicy(check_tokeninfo, request=request)
 @postpolicy(check_tokentype, request=request)
 @postpolicy(check_serial, request=request)
 @postpolicy(autoassign, request=request)
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,8 @@
 # -*- coding: utf-8 -*-
 #
+#  2018-01-15 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#             Add tokeninfo field policy
+#             Add add_resolver_in_result
 #  2017-11-14 Cornelius Kölbel <cornelius.koelbel@netknights.it>
 #             Add policy action for customization of menu and baseline
 #  2017-01-22 Cornelius Kölbel <cornelius.koelbel@netknights.it>
@@ -270,6 +273,7 @@ class ACTION(object):
     TOKENPAGESIZE = "token_page_size"
     TOKENREALMS = "tokenrealms"
     TOKENTYPE = "tokentype"
+    TOKENINFO = "tokeninfo"
     TOKENWIZARD = "tokenwizard"
     TOKENWIZARD2ND = "tokenwizard_2nd_token"
     TRIGGERCHALLENGE = "triggerchallenge"
@@ -1560,6 +1564,10 @@ def get_static_policy_definitions(scope=None):
                 'type': 'str',
                 'desc': _('The user will only be authenticated if the serial '
                           'number of the token matches this regexp.')},
+            ACTION.TOKENINFO: {
+                'type': 'str',
+                'desc': _("The user will only be authenticated if the tokeninfo "
+                          "field matches the regexp. key/<regexp>/")},
             ACTION.SETREALM: {
                 'type': 'str',
                 'value': realms,


Test patch:
diff --git a/tests/test_api_lib_policy.py b/tests/test_api_lib_policy.py
--- a/tests/test_api_lib_policy.py
+++ b/tests/test_api_lib_policy.py
@@ -24,6 +24,7 @@
                                            required_email, auditlog_age,
                                            papertoken_count, allowed_audit_realm)
 from privacyidea.api.lib.postpolicy import (check_serial, check_tokentype,
+                                            check_tokeninfo,
                                             no_detail_on_success,
                                             no_detail_on_fail, autoassign,
                                             offline_info, sign_response,
@@ -1261,6 +1262,67 @@ def test_01_check_undetermined_tokentype(self):
                           check_tokentype,
                           req, resp)
 
+    def test_03_check_tokeninfo(self):
+        token_obj = init_token({"type": "SPASS", "serial": "PISP0001"})
+        token_obj.set_tokeninfo({"testkey": "testvalue"})
+
+        # http://werkzeug.pocoo.org/docs/0.10/test/#environment-building
+        builder = EnvironBuilder(method='POST',
+                                 data={'serial': "PISP0001"},
+                                 headers={})
+        env = builder.get_environ()
+        # Set the remote address so that we can filter for it
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        # The response contains the token type SPASS
+        res = {"jsonrpc": "2.0",
+               "result": {"status": True,
+                          "value": True},
+               "version": "privacyIDEA test",
+               "id": 1,
+               "detail": {"message": "matching 1 tokens",
+                          "serial": "PISP0001",
+                          "type": "spass"}}
+        resp = Response(json.dumps(res))
+
+        # Set a policy, that does match
+        set_policy(name="pol1",
+                   scope=SCOPE.AUTHZ,
+                   action="tokeninfo=testkey/test.*/", client="10.0.0.0/8")
+        g.policy_object = PolicyClass()
+        r = check_tokeninfo(req, resp)
+        jresult = json.loads(r.data)
+        self.assertTrue(jresult.get("result").get("value"))
+
+        # Set a policy that does NOT match
+        set_policy(name="pol1",
+                   scope=SCOPE.AUTHZ,
+                   action="tokeninfo=testkey/NO.*/", client="10.0.0.0/8")
+        g.policy_object = PolicyClass()
+        self.assertRaises(PolicyError,
+                          check_tokeninfo,
+                          req, resp)
+
+        # Set a policy, but the token has no tokeninfo!
+        # Thus the authorization will fail
+        token_obj.del_tokeninfo("testkey")
+        self.assertRaises(PolicyError,
+                          check_tokeninfo,
+                          req, resp)
+
+        # If we set an invalid policy, authorization will succeed
+        set_policy(name="pol1",
+                   scope=SCOPE.AUTHZ,
+                   action="tokeninfo=testkey/missingslash", client="10.0.0.0/8")
+        g.policy_object = PolicyClass()
+        r = check_tokeninfo(req, resp)
+        jresult = json.loads(r.data)
+        self.assertTrue(jresult.get("result").get("value"))
+
+        delete_policy("pol1")
+        remove_token("PISP0001")
+
     def test_02_check_serial(self):
         # http://werkzeug.pocoo.org/docs/0.10/test/#environment-building
         builder = EnvironBuilder(method='POST',