Repository: privacyidea/privacyidea

Issue:
conflicts in check_tokentype and passthru policies
The fix  2f1357412f9bfeae8e7e0c8a9e54175960e7d87d does introduce another problem:

If the user has **no token**,  this policy raises an exception. But then a possible ``passthru`` policy would not work anymore.

So in the ``check_tokentype`` decorator, we have to test more thoroughly, that the tokentype failed.

Maybe we check for the number of matching tokens, if this would be bigger than 1...

The ``passthru``, ``pass_on_no_token`` and ``pass_on_no_user`` are all evaluated _before_ the check_token postpolicy. So the postpolicy will always see a successful authentication in case of ``passthru``...

We can not use the return-value.

We could however avoid this whole situation and try to determin the tokentype:

If there is more than one token, that led to a successful authentication this would be:

* probably two spass tokens
* very unlikely two HOTP or two TOTP tokens
* extremely unlikely a spass Token and an HOTP token or a HOTP and a TOTP token.

Nevertheless there are the possibilities:

* all tokens, that result in a successful authentication are of the same type: -> add this type to the response
* tokens, that result in a successful authentication are of different types -> set the tokentype to ``undetermined``. 




Discussion:
We are setting the tokentype here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2031

But we could also set the tokentype in the loop running over the successful tokens:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L2018

~~~python
if not reply_dict.get("type"):
    reply_dict["type"] = token_obj.token.tokentype
if reply_dict["type"] != token_obj.token.tokentype:
    reply_dict["undetermined"]
~~~


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
@@ -204,14 +204,13 @@ def check_tokentype(request, response):
         resolver=user_object.resolver,
         realm=user_object.realm,
         client=g.client_ip)
-    if allowed_tokentypes:
-        if not tokentype or (tokentype not in allowed_tokentypes):
-            # If we have tokentype policies, but either no tokentypes
-            # or the tokentype is not allowed, we raise an exception
-            g.audit_object.log({"success": False,
-                                'action_detail': "Tokentype {0!r} not allowed for "
-                                                 "authentication".format(tokentype)})
-            raise PolicyError("Tokentype not allowed for authentication!")
+    if tokentype and allowed_tokentypes and tokentype not in allowed_tokentypes:
+        # If we have tokentype policies, but
+        # the tokentype is not allowed, we raise an exception
+        g.audit_object.log({"success": False,
+                            'action_detail': "Tokentype {0!r} not allowed for "
+                                             "authentication".format(tokentype)})
+        raise PolicyError("Tokentype not allowed for authentication!")
     return response
 
 
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -2016,6 +2016,10 @@ def check_token_list(tokenobject_list, passw, user=None, options=None):
             if token_obj.check_all(message_list):
                 # The token is active and the auth counters are ok.
                 res = True
+                if not reply_dict.get("type"):
+                    reply_dict["type"] = token_obj.token.tokentype
+                if reply_dict["type"] != token_obj.token.tokentype:
+                    reply_dict["type"] = "undetermined"
                 # reset the failcounter of valid token
                 try:
                     token_obj.reset()


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
@@ -1227,7 +1227,7 @@ def test_01_check_tokentype(self):
         jresult = json.loads(r.data)
         self.assertTrue(jresult.get("result").get("value"))
 
-    def test_01_check_tokentype_no_type(self):
+    def test_01_check_undetermined_tokentype(self):
         # If there is a tokentype policy but the type can not be
         # determined, authentication fails.
         builder = EnvironBuilder(method='POST',
@@ -1245,7 +1245,8 @@ def test_01_check_tokentype_no_type(self):
                           "value": True},
                "version": "privacyIDEA test",
                "id": 1,
-               "detail": {"message": "matching 2 tokens"}}
+               "detail": {"message": "matching 2 tokens",
+                          "type": "undetermined"}}
         resp = Response(json.dumps(res))
 
         # Set a policy, that does not allow the tokentype
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -1770,3 +1770,105 @@ def test_29_several_CR_one_locked(self):
         delete_policy("test48")
         remove_token("CR2A")
         remove_token("CR2B")
+
+    def test_30_return_different_tokentypes(self):
+        """
+        Return different tokentypes
+
+        If there are more than one matching tokens, the check_token_list in lib/token.py
+        returns a tokentype:
+        1. a specific tokentype if all matching tokens are of the same type
+        2. an "undetermined" tokentype, if the matching tokens are of
+           different type.
+        """
+        self.setUp_user_realms()
+        user = User("cornelius", self.realm1)
+
+        # two different token types
+        init_token({"serial": "SPASS1",
+                    "type": "spass",
+                    "pin": "hallo123"}, user)
+        init_token({"serial": "PW1",
+                    "type": "pw",
+                    "otpkey": "123",
+                    "pin": "hallo"}, user)
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "hallo123"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertEqual(result.get("value"), True)
+            detail = json.loads(res.data).get("detail")
+            self.assertEqual(detail.get("type"), "undetermined")
+
+        # two same token types.
+        remove_token("PW1")
+        init_token({"serial": "SPASS2",
+                    "type": "spass",
+                    "pin": "hallo123"}, user)
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "hallo123"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertEqual(result.get("value"), True)
+            detail = json.loads(res.data).get("detail")
+            self.assertEqual(detail.get("type"), "spass")
+
+        # A user has one HOTP token and two spass tokens.
+        init_token({"serial": "HOTP1",
+                    "type": "hotp",
+                    "otpkey": self.otpkey,
+                    "pin": "hallo"}, user)
+        # Without policy he can authenticate with the spass token
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "hallo123"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertEqual(result.get("value"), True)
+            detail = json.loads(res.data).get("detail")
+            self.assertEqual(detail.get("type"), "spass")
+
+        # policy only allows HOTP.
+        set_policy("onlyHOTP", scope=SCOPE.AUTHZ,
+                   action="{0!s}=hotp".format(ACTION.TOKENTYPE))
+
+        # He can not authenticate with the spass token!
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "hallo123"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 403, res)
+            result = json.loads(res.data).get("result")
+            self.assertEqual(result.get("status"), False)
+            detail = json.loads(res.data).get("detail")
+            self.assertEqual(detail, None)
+
+        # Define a passthru policy
+        set_policy("passthru", scope=SCOPE.AUTH,
+                   action="{0!s}=userstore".format(ACTION.PASSTHRU))
+
+        # A user with a passthru policy can authenticate, since he has not tokentype
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "passthru",
+                                                 "pass": "pthru"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertEqual(result.get("value"), True)
+
+        delete_policy("onlyHOTP")
+        delete_policy("passthru")
+        remove_token("SPASS1")
+        remove_token("SPASS2")
+        remove_token("HOTP1")
\ No newline at end of file