Repository: privacyidea/privacyidea

Issue:
Multi-Challenge: Rework 4eyes token as multi challenge response
See #2137 and #2163 
We will do the 4eyes token as multi challenge response, just like the index secret token and the questionaire token.


Discussion:
This can be done similar to the indexed secret token and lately the questionnaire token in #2398.
However, with the 4eyes token it is important, that we also store the information, which token from which realm was already used. This information needs to be stored in the *Challenge*, since it represents the current authentication process.

We could either 
* store the serials or IDs of the already used tokens or
* store the IDs of the already used realms or
* the uid of the already authenticated users.

We will store the overall number again in the ``Challenge.session`` so we can store the above mentioned information in ``Challenge.data``.

The configuration of the token is stored like this in the ``tokeninfo``: ``4eyes: helpdesk:1,adminrealm:2``.
We could thus store it in a similar way in the ``Challenge.data`` like ``helpdesk:;adminrealm:17,23``. 17 and 23 being the database IDs of the tokens, that were already successfully used. This way we would know, that one token from the helpdesk realm is missing and we would only have to check in this realm.

## Challenge texts

Possible challenge texts could be:

* We still need 1 token from realmA, 2 tokens from realmB, 1 token from realmC.
* We still need 1 token from realmA, 2 tokens from realmB.
* We still need 2 token from realmB.
* ...

So we also need to pass the "remaining" tokens between the authentication steps.
And as above mentioned we also need to store, which tokens already have authenticated.

Resolving patch:
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
@@ -3,6 +3,9 @@
 #  License:  AGPLv3
 #  contact:  http://www.privacyidea.org
 #
+#  2020-09-22 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#             Use Multi-Challenge to ask for several tokens one after
+#             the other.
 #  2018-01-21 Cornelius Kölbel <cornelius.koelbel@netknights.it>
 #             Add tokenkind
 #  2015-08-28 Initial writeup of the 4eyes token
@@ -35,13 +38,17 @@
 """
 import logging
 from privacyidea.api.lib.utils import getParam
+from privacyidea.lib.config import get_from_config
 from privacyidea.lib.log import log_with
 from privacyidea.lib.tokenclass import TokenClass, TOKENKIND
 from privacyidea.lib.error import ParameterError
 from privacyidea.lib.token import check_realm_pass
 from privacyidea.lib.decorators import check_token_locked
 from privacyidea.lib import _
-from privacyidea.lib.policy import ACTION, SCOPE, GROUP
+from privacyidea.lib.policy import ACTION, SCOPE, GROUP, get_action_values_from_options
+from privacyidea.lib.challenge import get_challenges, Challenge
+import json
+import datetime
 
 log = logging.getLogger(__name__)
 optional = True
@@ -91,8 +98,6 @@ def __init__(self, db_token):
         """
         TokenClass.__init__(self, db_token)
         self.set_type(u"4eyes")
-        # We can not do challenge response
-        self.mode = ['authenticate']
 
     @staticmethod
     def get_class_type():
@@ -207,6 +212,17 @@ def _get_realms(self):
         """
         return self.convert_realms(self.get_tokeninfo("4eyes"))
 
+    @staticmethod
+    def _dict_diff(required, used):
+        """
+        Subtract the "used" dict from the "required" dict
+        :return: diff dict
+        """
+        result = {}
+        for k, v in required.items():
+            result[k] = v - used.get(k, 0)
+        return result
+
     def _get_separator(self):
         return self.get_tokeninfo("separator") or " "
 
@@ -230,6 +246,45 @@ def update(self, param):
         self.add_tokeninfo("4eyes", realms)
         self.add_tokeninfo("tokenkind", TOKENKIND.VIRTUAL)
 
+    def _authenticate_in_realm(self, realm, password):
+        """
+        This method tries to authenticate a given credential (can be PIN + OTP)
+        against tokens in a realm and returns the serial of the token, if successful.
+        Otherwise None
+
+        :param realm: The realm where to authenticate
+        :param password: The PIN + OTP
+        :return: token_id or None
+        """
+        serial = None
+        res, reply = check_realm_pass(realm, password,
+                                      exclude_types=[self.get_tokentype()])
+        if res:
+            serial = reply.get("serial")
+        return serial
+
+    def _authenticate_remaining_realms(self, passw, remaining_realms, used_tokens, options):
+        r_success = -1
+        for realm in remaining_realms:
+            # check for token in realm
+            serial = self._authenticate_in_realm(realm, passw)
+            if serial:
+                # check that not the same token is used again
+                if serial in used_tokens.get(realm, []):
+                    log.info("The same token {0!s} was already used. "
+                             "You can not use a token twice.".format(serial))
+                else:
+                    # Add the serial to the used tokens.
+                    if realm in used_tokens:
+                        used_tokens[realm].append(serial)
+                    else:
+                        used_tokens[realm] = [serial]
+                    options["data"] = used_tokens
+                    log.debug("Partially authenticated with token {0!s}.".format(serial))
+                    r_success = 1
+                    break
+        return r_success
+
     @log_with(log)
     @check_token_locked
     def authenticate(self, passw, user=None, options=None):
@@ -260,10 +315,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,
-                                              exclude_types=[self.get_tokentype()])
-                if res:
-                    serial = reply.get("serial")
+                serial = self._authenticate_in_realm(realm, otp)
+                if serial:
                     found_serials[realm].append(serial)
             # uniquify the serials in the list
             found_serials[realm] = list(set(found_serials[realm]))
@@ -277,3 +330,179 @@ def authenticate(self, passw, user=None, options=None):
                 otp_counter = 1
 
         return pin_match, otp_counter, reply
+
+    def _get_remaining_tokens(self, used_tokens_with_serials):
+        """
+        Takes a dictionary like
+
+        {"realm1": ["serial1", "serial2"],
+         "realm3": ["serialB"]
+
+        and returns a dictionary of the tokens that remain for authentication like
+
+        {"realm1": 1, "realm3": 0}
+
+        :param used_tokens_with_serials: dict with used serials
+        :return: dict
+        """
+        required_tokens = self._get_realms()
+        used_tokens = {k: len(v) for (k, v) in used_tokens_with_serials.items()}
+        # compare the dict of required tokens with the dict of used tokens
+        remaining_tokens = self._dict_diff(required_tokens, used_tokens)
+        return remaining_tokens
+
+    def _get_remaining_realms(self, used_tokens_with_serials):
+        """
+        Return the list of the remaining realms, from which an authentication is needed.
+        :param used_tokens_with_serials:
+        :return:
+        """
+        remaining_tokens = self._get_remaining_tokens(used_tokens_with_serials)
+        remaining_realms = [r for (r, toks) in remaining_tokens.items() if toks > 0]
+        return remaining_realms
+
+    @log_with(log)
+    def has_further_challenge(self, options=None):
+        """
+        Check if there are still more tokens to be authenticated
+        :param options: Options dict
+        :return: True, if further challenge is required.
+        """
+        transaction_id = options.get('transaction_id')
+        challengeobject_list = get_challenges(serial=self.token.serial,
+                                              transaction_id=transaction_id)
+        if len(challengeobject_list) == 1:
+            remaining_realms = self._get_remaining_realms(options.get("data", {}))
+            if remaining_realms:
+                options["data"] = json.dumps(options.get("data", {}))
+                options["message"] = "Remaining tokens: {0!s}".format(remaining_realms)
+                return True
+        return False
+
+    @check_token_locked
+    def check_challenge_response(self, user=None, passw=None, options=None):
+        """
+        This method verifies if the given response is the PIN + OTP of one of the
+        remaining tokens.
+        It then returns the the otp_counter = 1
+
+        :param user: the requesting user
+        :type user: User object
+        :param passw: the password: PIN + OTP
+        :type passw: string
+        :param options: additional arguments from the request, which could
+                        be token specific. Usually "transaction_id"
+        :type options: dict
+        :return: return 1 if the answer to the challenge is correct, -1 otherwise.
+        :rtype: int
+        """
+        options = options or {}
+        r_success = -1
+
+        # fetch the transaction_id
+        transaction_id = options.get('transaction_id')
+        if transaction_id is None:
+            transaction_id = options.get('state')
+
+        # get the challenges for this transaction ID
+        if transaction_id is not None:
+            challengeobject_list = get_challenges(serial=self.token.serial,
+                                                  transaction_id=transaction_id)
+
+            for challengeobject in challengeobject_list:
+                if challengeobject.is_valid():
+                    # challenge is still valid
+                    used_tokens = json.loads(challengeobject_list[0].data or json.dumps({}))
+                    remaining_realms = self._get_remaining_realms(used_tokens)
+                    r_success = self._authenticate_remaining_realms(passw, remaining_realms, used_tokens, options)
+
+                    if r_success:
+                        challengeobject.set_otp_status(True)
+                    if not r_success:
+                        # increase the received_count
+                        challengeobject.set_otp_status()
+
+        self.challenge_janitor()
+        return r_success
+
+    def create_challenge(self, transactionid=None, options=None):
+        """
+        This method creates a challenge, which is submitted to the user.
+        The submitted challenge will be preserved in the challenge
+        database.
+
+        If no transaction id is given, the system will create a transaction
+        id and return it, so that the response can refer to this transaction.
+
+        :param transactionid: the id of this challenge
+        :param options: the request context parameters / data
+        :type options: dict
+        :return: tuple of (bool, message, transactionid, attributes)
+        :rtype: tuple
+
+        The return tuple builds up like this:
+        ``bool`` if submit was successful;
+        ``message`` which is displayed in the JSON response;
+        additional ``attributes``, which are displayed in the JSON response.
+        """
+        options = options or {}
+        attributes = {}
+        message = ""
+        if type(options.get("data")) == dict:
+            # In the special first chal-resp case we do not have jsonified data, yet. So we need to convert
+            options["data"] = json.dumps(options.get("data"))
+        used_tokens = json.loads(options.get("data", json.dumps({})))
+        remaining_realms = self._get_remaining_realms(used_tokens)
+        if remaining_realms:
+            message = "Please authenticate with another token from " \
+                      "either realm: {0!s}.".format(", ".join(remaining_realms))
+
+        validity = int(get_from_config('DefaultChallengeValidityTime', 120))
+        tokentype = self.get_tokentype().lower()
+        # Maybe there is a 4EYESChallengeValidityTime...
+        lookup_for = tokentype.capitalize() + 'ChallengeValidityTime'
+        validity = int(get_from_config(lookup_for, validity))
+
+        # Create the challenge in the database
+        db_challenge = Challenge(self.token.serial,
+                                 transaction_id=transactionid,
+                                 data=options.get("data"),
+                                 session=options.get("session"),
+                                 challenge=message,
+                                 validitytime=validity)
+        db_challenge.save()
+        expiry_date = datetime.datetime.now() + \
+                      datetime.timedelta(seconds=validity)
+        attributes['valid_until'] = "{0!s}".format(expiry_date)
+        return True, message, db_challenge.transaction_id, attributes
+
+    def is_challenge_request(self, passw, user=None, options=None):
+        """
+        The 4eyes token can act as a challenge response token, either
+        * if the first passw given is the PIN of the 4eyes token or
+        * if the first passw given is the complete PIN+OTP from one of
+          the admintokens.
+
+        :param passw: password, which might be pin or pin+otp
+        :type passw: string
+        :param user: The user from the authentication request
+        :type user: User object
+        :param options: dictionary of additional request parameters
+        :type options: dict
+
+        :return: true or false
+        :rtype: bool
+        """
+        options = options or {}
+        pin_match = self.check_pin(passw, user=user, options=options)
+        if pin_match:
+            return True
+        elif "transaction_id" not in options:
+            # The special case, when the *first* admin token already matches, we will
+            # start a multi challenge chain
+            used_tokens = {}
+            remaining_realms = self._get_remaining_realms({})
+            r_success = self._authenticate_remaining_realms(passw, remaining_realms, used_tokens, options)
+            return r_success >= 0
+
+        return False


Test patch:
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
@@ -20,7 +20,7 @@
 from privacyidea.lib.event import delete_event
 from privacyidea.lib.error import ERROR
 from privacyidea.lib.resolver import save_resolver, get_resolver_list, delete_resolver
-from privacyidea.lib.realm import set_realm, set_default_realm, delete_realm
+from privacyidea.lib.realm import set_realm, set_default_realm, delete_realm, get_realms
 from privacyidea.lib.radiusserver import add_radius
 from privacyidea.lib.challenge import get_challenges
 from privacyidea.lib import _
@@ -4089,6 +4089,169 @@ def test_15_questionnaire_multichallenge(self):
         remove_token(serial)
         delete_policy("questpol")
 
+    def test_16_4eyes_multichallenge_with_pin(self):
+        # We require 1 token in realm1 and 2 tokens in realm2
+        required_tokens = {"realm1": {"selected": True,
+                                      "count": 1},
+                           "realm3": {"selected": True,
+                                      "count": 2}}
+        serial = "4eyes001"
+        # We want more than one realm
+        self.setUp_user_realm3()
+        tok = init_token({"type": "4eyes", "4eyes": required_tokens, "pin": "pin", "serial": serial},
+                         user=User("root", self.realm3))
+        self.assertTrue(tok.get_tokeninfo("4eyes"), "realm1:1,realm3:2")
+
+        # Now we enroll some tokens for the 3 admins.
+        # user: cornelius@realm1
+        tok = init_token({"type": "hotp", "otpkey": self.otpkey, "pin": "adminpin1", "serial": "admintok1"},
+                         user=User("cornelius", self.realm1))
+        self.assertTrue(tok.get_tokeninfo("tokenkind"), "software")
+        # user: cornelius@realm3
+        tok = init_token({"type": "hotp", "otpkey": self.otpkey, "pin": "adminpin2", "serial": "admintok2"},
+                         user=User("cornelius", self.realm3))
+        self.assertTrue(tok.get_tokeninfo("tokenkind"), "software")
+        # user: privacyidea@realm3
+        tok = init_token({"type": "hotp", "otpkey": self.otpkey, "pin": "adminpin3", "serial": "admintok3"},
+                         user=User("privacyidea", self.realm3))
+        self.assertTrue(tok.get_tokeninfo("tokenkind"), "software")
+
+        # Start the authentication with the PIN of the 4eyes token
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "root", "realm": self.realm3, "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            detail = res.json.get("detail")
+            self.assertFalse(result.get("value"))
+            transaction_id = detail.get("transaction_id")
+            self.assertTrue(transaction_id)
+
+        # Authenticate with the first admin token
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "root@realm3",
+                                                 "pass": "adminpin1" + self.valid_otp_values[1],
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            self.assertFalse(result.get("value"))
+            detail = res.json.get("detail")
+            transaction_id = detail.get("transaction_id")
+            self.assertEqual("Please authenticate with another token from either realm: realm3.",
+                             detail.get("message"))
+
+        # Authenticate with the second admin token
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "root@realm3",
+                                                 "pass": "adminpin2" + self.valid_otp_values[1],
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            self.assertFalse(result.get("value"))
+            detail = res.json.get("detail")
+            transaction_id = detail.get("transaction_id")
+            self.assertEqual("Please authenticate with another token from either realm: realm3.",
+                             detail.get("message"))
+
+        # If we would use the 2nd token *again*, then the authentication fails
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "root@realm3",
+                                                 "pass": "adminpin2" + self.valid_otp_values[2],
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            self.assertFalse(result.get("value"))
+            detail = res.json.get("detail")
+            self.assertNotIn("transaction_id", detail)
+            self.assertEqual("Response did not match the challenge.", detail.get("message"))
+
+        # Authenticate with the third admin token
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "root@realm3",
+                                                 "pass": "adminpin3" + self.valid_otp_values[1],
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            self.assertTrue(result.get("value"))
+            detail = res.json.get("detail")
+
+        remove_token(serial)
+        remove_token("admintok1")
+        remove_token("admintok2")
+        remove_token("admintok3")
+
+    def test_17_4eyes_multichallenge(self):
+        # We require 1 token in realm1 and 2 tokens in realm2
+        required_tokens = {"realm1": {"selected": True,
+                                      "count": 1},
+                           "realm3": {"selected": True,
+                                      "count": 2}}
+        serial = "4eyes001"
+        # We want more than one realm
+        self.setUp_user_realm3()
+        # Init 4eyes token without PIN
+        tok = init_token({"type": "4eyes", "4eyes": required_tokens, "serial": serial},
+                         user=User("root", self.realm3))
+        self.assertTrue(tok.get_tokeninfo("4eyes"), "realm1:1,realm3:2")
+
+        # Now we enroll some tokens for the 3 admins.
+        # user: cornelius@realm1
+        tok = init_token({"type": "hotp", "otpkey": self.otpkey, "pin": "adminpin1", "serial": "admintok1"},
+                         user=User("cornelius", self.realm1))
+        self.assertTrue(tok.get_tokeninfo("tokenkind"), "software")
+        # user: cornelius@realm3
+        tok = init_token({"type": "hotp", "otpkey": self.otpkey, "pin": "adminpin2", "serial": "admintok2"},
+                         user=User("cornelius", self.realm3))
+        self.assertTrue(tok.get_tokeninfo("tokenkind"), "software")
+        # user: privacyidea@realm3
+        tok = init_token({"type": "hotp", "otpkey": self.otpkey, "pin": "adminpin3", "serial": "admintok3"},
+                         user=User("privacyidea", self.realm3))
+        self.assertTrue(tok.get_tokeninfo("tokenkind"), "software")
+
+        # Start the authentication with one of the tokens!
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "root@realm3",
+                                                 "pass": "adminpin2" + self.valid_otp_values[1]}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            detail = res.json.get("detail")
+            self.assertFalse(result.get("value"))
+            transaction_id = detail.get("transaction_id")
+            self.assertTrue(transaction_id)
+
+        # Authenticate with the 2nd admin token
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "root@realm3",
+                                                 "pass": "adminpin1" + self.valid_otp_values[1],
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            self.assertFalse(result.get("value"))
+            detail = res.json.get("detail")
+            transaction_id = detail.get("transaction_id")
+            self.assertTrue(transaction_id)
+
+        # Authenticate with the second admin token
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "root@realm3",
+                                                 "pass": "adminpin3" + self.valid_otp_values[1],
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            self.assertTrue(result.get("value"))
+
+        remove_token(serial)
+        remove_token("admintok1")
+        remove_token("admintok2")
+        remove_token("admintok3")
+
 
 class TriggeredPoliciesTestCase(MyApiTestCase):
 
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
@@ -39,6 +39,7 @@ def test_02_create_token(self):
 
     def test_03_authenticate(self):
         self.setUp_user_realms()
+
         init_token({"type": "pw",
                     "otpkey": "password1",
                     "pin": "pin1",
@@ -65,16 +66,16 @@ def test_03_authenticate(self):
         r = check_serial_pass("eye1", "pin1password1 pin2password2")
         self.assertEqual(r[0], True)
 
+        # This triggers the challenge for the next token
         r = check_serial_pass("eye1", "pin1password1")
         self.assertEqual(r[0], False)
-        self.assertEqual(r[1].get("foureyes"), "Only found 1 tokens in realm "
-                                               "realm1")
+        self.assertTrue("transaction_id" in r[1])
+        self.assertEqual(r[1].get("message"), 'Please authenticate with another token from either 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")
+        self.assertEqual(r[1].get("foureyes"), "Only found 0 tokens in realm {0!s}".format(self.realm1))
 
         # check authentication also works if the 4eyes-token is in the same realm
         tok.add_user(User('cornelius', self.realm1))