Repository: privacyidea/privacyidea

Issue:
4eyes validation get error :maximum recursion depth exceeded while calling a Python object
It seems privacyidea falls in a indefinite loop  authenticating the 4eyes token itself.

File "/Users/nobody/nobody/privacyidea/privacyidea/lib/tokens/foureyestoken.py", line 249, in authenticate
    res, reply = check_realm_pass(realm, otp)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/log.py", line 154, in log_wrapper
    return func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/token.py", line 1908, in check_realm_pass
    allow_reset_all_tokens=False)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/log.py", line 154, in log_wrapper
    return func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/policydecorators.py", line 93, in policy_wrapper
    return self.decorator_function(wrapped_function, *args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/policydecorators.py", line 638, in reset_all_user_tokens
    r = wrapped_function(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/token.py", line 2120, in check_token_list
    tokenobject.authenticate(passw, user, options=options)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/log.py", line 154, in log_wrapper
    return func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/decorators.py", line 45, in token_locked_wrapper
    f_result = func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/tokens/foureyestoken.py", line 249, in authenticate
    res, reply = check_realm_pass(realm, otp)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/log.py", line 154, in log_wrapper
    return func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/token.py", line 1908, in check_realm_pass
    allow_reset_all_tokens=False)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/log.py", line 154, in log_wrapper
    return func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/policydecorators.py", line 93, in policy_wrapper
    return self.decorator_function(wrapped_function, *args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/policydecorators.py", line 638, in reset_all_user_tokens
    r = wrapped_function(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/token.py", line 2120, in check_token_list
    tokenobject.authenticate(passw, user, options=options)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/log.py", line 154, in log_wrapper
    return func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/decorators.py", line 45, in token_locked_wrapper
    f_result = func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/tokens/foureyestoken.py", line 249, in authenticate
    res, reply = check_realm_pass(realm, otp)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/log.py", line 154, in log_wrapper
    return func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/token.py", line 1908, in check_realm_pass
    allow_reset_all_tokens=False)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/log.py", line 154, in log_wrapper
    return func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/policydecorators.py", line 93, in policy_wrapper
    return self.decorator_function(wrapped_function, *args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/policydecorators.py", line 638, in reset_all_user_tokens
    r = wrapped_function(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/token.py", line 2120, in check_token_list
    tokenobject.authenticate(passw, user, options=options)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/log.py", line 154, in log_wrapper
    return func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/decorators.py", line 45, in token_locked_wrapper
    f_result = func(*args, **kwds)
  File "/Users/nobody/nobody/privacyidea/privacyidea/lib/tokens/foureyestoken.py", line 249, in authenticate


Discussion:
I can confirm this error, i'll look into it.
This happens if the realm of the token is the same as one of the required tokens. In this case the [`check_realm_pass()`](https://github.com/privacyidea/privacyidea/blob/931e3aeeacbd195d9a7f673e704f2375b558e3a5/privacyidea/lib/token.py#L1880) function finds the 4-eys-token again and dives into the loop.
We should pass an option to exclude certain serials and/or users to `check_realm_pass()`.
@plettich When can we expect a fix to this?

Or if you are too busy, maybe you could give a little more information about the solution and I could raise a PR?
> @plettich When can we expect a fix to this?

This should be fixed in the next release (3.3). We just released 3.2 so this will take a little time.

> Or if you are too busy, maybe you could give a little more information about the solution and I could raise a PR?

A very simple fix would be to use users from different realms for the 4-eyes-token.

@safiyat I am not quite sure what we should fix here. As @plettich pointed out, you shoudl have different realms.
The idea is to have realms where "normal" user or "tokenowners" reside. 
What would you use the 3eyes token for? It is ment for a very specific user - probably in a very specific realm. This way you wount stumble into any *logical* problem.
Again: If you can give some pointers, what you actually would expect, we can better evaluate if, what, how we do what.
@cornelinux The requirement is that we want to have 4eyes enabled for the engineers in the DevOps team. The engineers in this context (of 4eyes) are all hierarchically at the same level, and hence we can't have some designated as the superiors. Hence, the requirement that tokens of the same realm can be used for 4eyes, with the caveat that they should not be of the same user.

Currently we are using the tokens from different realms as a workaround for the issue. So, all the engineers have a token each in two realms A & B. One engineer uses the token from realm A and the other uses his/her token from realm B for authentication.

If this is a very specific requirement, we are fine with it not getting fixed.
Thanks for your response @safiyat. This is an interesting scenario although it is not the intended use case for the 4eyes token. If I understand it correctly you are giving each engineer one OTP token and one 4eyes token (preferably in the same realm). Then you want the engineer who is logging in (with his OTP token) to wait for a 2nd engineer to "confirm" the login with the OTP token of the 2nd engineer.

So you have EngineerA, EngineerB and EnigneerC. And EngineerA can only login when EngineerA presents his token and either EnigeerB or EngineerC.

But, the design of the 4eyes token is based on a different concept:
It is ment this way: The "role" SuperuserA can only login if two of EngineerA, EngineerB and EngineerC come an impersonate as SuperuserA.

So even if we fix the issue, that 4eyes tokens should be excluded tokens being available for impersonation for 4eyes tokens you will have this effect: *warning*

EngineerA and EngineerB can come togeather and impersonate as EngineerC. This is why we implicitly assumed that the users who get impersonated are users who are impersonating are disjoint sets. You need to be aware of this and rethink if this is actually what you want!?

However...

# How to fix this

The problem arises here: https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/tokens/foureyestoken.py#L263
We need to exclude 4eyes tokens from being checked in ``check_realm_pass``.
Although it looks like ``check_realm_pass`` is only used in the 4eyes token I would add a parameter (possibly in the ``options``) that contains the allowed (or not allowed) token types to be searched.

Tokens are fetched from the realm here: https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/token.py#L1907
Since ``get_tokens`` only takes *one* token type, we would have to iterate over the tokentypes.



Resolving patch:
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -1886,12 +1886,17 @@ def lost_token(serial, new_serial=None, password=None,
 
 
 @log_with(log)
-def check_realm_pass(realm, passw, options=None):
+def check_realm_pass(realm, passw, options=None,
+                     include_types=None, exclude_types=None):
     """
     This function checks, if the given passw matches any token in the given
     realm. This can be used for the 4-eyes token.
     Only tokens that are assigned are tested.
 
+    The options dictionary may contain a key/value pair 'exclude_types' or
+    'include_types' with the value containing a list of token types to
+    exclude/include from/in the search.
+
     It returns the res True/False and a reply_dict, which contains the
     serial number of the matching token.
 
@@ -1899,23 +1904,37 @@ def check_realm_pass(realm, passw, options=None):
     :param passw: The password containing PIN+OTP
     :param options: Additional options that are passed to the tokens
     :type options: dict
+    :param include_types: List of token types to use for the check
+    :type include_types: list or str
+    :param exclude_types: List to token types *not* to use for the check
+    :type exclude_types: list or str
     :return: tuple of bool and dict
     """
-    res = False
     reply_dict = {}
     # since an attacker does not know, which token is tested, we restrict to
     # only active tokens. He would not guess that the given OTP value is that
     #  of an inactive token.
     tokenobject_list = get_tokens(realm=realm, assigned=True, active=True)
     if not tokenobject_list:
-        res = False
-        reply_dict["message"] = "There is no active and assigned token in " \
-                                "this realm"
+        reply_dict["message"] = "There is no active and assigned token in this realm"
+        return False, reply_dict
     else:
-        res, reply_dict = check_token_list(tokenobject_list, passw,
-                                           options=options,
-                                           allow_reset_all_tokens=False)
-    return res, reply_dict
+        # reduce tokens by type
+        if include_types:
+            incl = include_types if isinstance(include_types, list) else [include_types]
+            tokenobject_list = [tok for tok in tokenobject_list if tok.type in incl]
+        elif exclude_types:
+            excl = exclude_types if isinstance(exclude_types, list) else [exclude_types]
+            tokenobject_list = [tok for tok in tokenobject_list if tok.type not in excl]
+
+        if not tokenobject_list:
+            reply_dict["message"] = 'There is no active and assigned token in ' \
+                                    'this realm, included types: {0!s}, excluded '\
+                                    'types: {1!s}'.format(include_types, exclude_types)
+            return False, reply_dict
+
+        return check_token_list(tokenobject_list, passw, options=options,
+                                allow_reset_all_tokens=False)
 
 
 @log_with(log)
diff --git a/privacyidea/lib/tokens/foureyestoken.py b/privacyidea/lib/tokens/foureyestoken.py
--- a/privacyidea/lib/tokens/foureyestoken.py
+++ b/privacyidea/lib/tokens/foureyestoken.py
@@ -260,7 +260,8 @@ def authenticate(self, passw, user=None, options=None):
         for realm in required_realms.keys():
             found_serials[realm] = []
             for otp in passwords:
-                res, reply = check_realm_pass(realm, otp)
+                res, reply = check_realm_pass(realm, otp,
+                                              exclude_types=[self.get_tokentype()])
                 if res:
                     serial = reply.get("serial")
                     found_serials[realm].append(serial)


Test patch:
diff --git a/tests/test_lib_token.py b/tests/test_lib_token.py
--- a/tests/test_lib_token.py
+++ b/tests/test_lib_token.py
@@ -1022,6 +1022,7 @@ def test_44_validity_period(self):
         self.assertEqual(vp, "2015-05-28T20:22+0200")
 
     def test_45_check_realm_pass(self):
+        self.setUp_user_realms()
         # create a bunch of tokens in the realm
 
         # disabled token
@@ -1078,6 +1079,42 @@ def test_45_check_realm_pass(self):
         # so we get "wrong otp pin"
         self.assertEqual(r[1].get("message"), "matching 1 tokens")
 
+        # try an unknown realm
+        r = check_realm_pass(self.realm2, "assigned" + self.valid_otp_values[2])
+        self.assertFalse(r[0])
+        self.assertEqual(r[1].get("message"), "There is no active and assigned "
+                                              "token in this realm")
+
+        # check for optional parameter exclude/include type
+        r = check_realm_pass(self.realm1, 'assigned' + self.valid_otp_values[2],
+                             exclude_types='hotp')
+        self.assertFalse(r[0])
+        self.assertEqual(r[1].get("message"), "There is no active and assigned "
+                                              "token in this realm, included types: None, "
+                                              "excluded types: hotp")
+
+        r = check_realm_pass(self.realm1, 'assigned' + self.valid_otp_values[2],
+                             include_types='totp')
+        self.assertFalse(r[0])
+        self.assertEqual(r[1].get("message"), "There is no active and assigned "
+                                              "token in this realm, included types: totp, "
+                                              "excluded types: None")
+
+        r = check_realm_pass(self.realm1, 'assigned' + self.valid_otp_values[2],
+                             exclude_types='totp')
+        self.assertTrue(r[0])
+        self.assertEqual(r[1].get("message"), "matching 1 tokens")
+
+        # check that include_types precedes exclude_types
+        r = check_realm_pass(self.realm1, 'assigned' + self.valid_otp_values[3],
+                             include_types='hotp', exclude_types='hotp')
+        self.assertTrue(r[0])
+        self.assertEqual(r[1].get("message"), "matching 1 tokens")
+
+        remove_token(serial='not_assigned')
+        remove_token(serial='inactive')
+        remove_token(serial='assigned')
+
     def test_46_init_with_validity_period(self):
         token = init_token({"type": "hotp",
                             "genkey": 1,
diff --git a/tests/test_lib_tokens_foureyes.py b/tests/test_lib_tokens_foureyes.py
--- a/tests/test_lib_tokens_foureyes.py
+++ b/tests/test_lib_tokens_foureyes.py
@@ -1,3 +1,5 @@
+# -*- coding: utf-8 -*-
+
 """
 This test file tests the lib.tokens.4eyestoken
 This depends on lib.tokenclass
@@ -5,7 +7,7 @@
 
 from .base import MyTestCase
 from privacyidea.lib.tokens.foureyestoken import FourEyesTokenClass
-from privacyidea.lib.token import init_token, check_serial_pass
+from privacyidea.lib.token import init_token, check_serial_pass, remove_token
 from privacyidea.lib.user import User
 
 
@@ -36,25 +38,29 @@ def test_02_create_token(self):
         self.assertEqual(r._get_separator(), "|")
 
     def test_03_authenticate(self):
+        self.setUp_user_realms()
         init_token({"type": "pw",
                     "otpkey": "password1",
-                    "pin": "pin1"},
+                    "pin": "pin1",
+                    "serial": "pwserial1"},
                    user=User("cornelius", self.realm1))
 
         init_token({"type": "pw",
                     "otpkey": "password2",
-                    "pin": "pin2"},
+                    "pin": "pin2",
+                    "serial": "pwserial2"},
                    user=User("cornelius", self.realm1))
 
         init_token({"type": "pw",
                     "otpkey": "password3",
-                    "pin": "pin3"},
+                    "pin": "pin3",
+                    "serial": "pwserial3"},
                    user=User("cornelius", self.realm1))
 
-        init_token({"serial": "eye1",
-                    "type": "4eyes",
-                    "4eyes": "{0!s}:2".format(self.realm1),
-                    "separator": " "})
+        tok = init_token({"serial": "eye1",
+                          "type": "4eyes",
+                          "4eyes": "{0!s}:2".format(self.realm1),
+                          "separator": " "})
 
         r = check_serial_pass("eye1", "pin1password1 pin2password2")
         self.assertEqual(r[0], True)
@@ -63,3 +69,21 @@ def test_03_authenticate(self):
         self.assertEqual(r[0], False)
         self.assertEqual(r[1].get("foureyes"), "Only found 1 tokens in realm "
                                                "realm1")
+
+        # check false separator
+        r = check_serial_pass("eye1", "pin1password1:pin2password2")
+        self.assertFalse(r[0])
+        self.assertEqual(r[1].get("foureyes"), "Only found 0 tokens in realm "
+                                               "realm1")
+
+        # check authentication also works if the 4eyes-token is in the same realm
+        tok.add_user(User('cornelius', self.realm1))
+        r = check_serial_pass("eye1", "pin3password3 pin2password2")
+        self.assertTrue(r[0])
+        self.assertEqual(r[1].get('message'), 'matching 1 tokens')
+
+        # cleanup
+        remove_token(serial='pwserial1')
+        remove_token(serial='pwserial2')
+        remove_token(serial='pwserial3')
+        remove_token(serial='eye1')