Repository: privacyidea/privacyidea

Issue:
Fix challenge response API
There is an inconsistancy in the challenge response API between a user triggered challenge and an administrative triggered challenge.

# User triggers challenge

The user has two tokens, both with the same PIN. So this user actually can give the PIN and it will trigger two challenges:

~~~~json
http POST http://localhost:5000/validate/check user=cornelius pass=test

{
    "detail": {
        "attributes": {
            "state": "01928502550899427322",
            "valid_until": "2018-12-21 13:49:37.107654"
        },
        "message": "please enter otp: , Enter the OTP from the Email:",
        "multi_challenge": [
            {
                "attributes": null,
                "serial": "TOTP0001573B",
                "transaction_id": "01928502550899427322"
            },
            {
                "attributes": {
                    "state": "01928502550899427322",
                    "valid_until": "2018-12-21 13:49:37.107654"
                },
                "serial": "PIEM000024B0",
                "transaction_id": "01928502550899427322"
            }
        ],
        "serial": "PIEM000024B0",
        "threadid": 139947024250624,
        "transaction_id": "01928502550899427322"
    },
    "id": 1,
    "jsonrpc": "2.0",
    "result": {
        "status": true,
        "value": false
    },
    "time": 1545396077.114939,
    "version": "privacyIDEA 2.23.3",
    "versionnumber": "2.23.3"
}
~~~~
In this case all challenges have the same ``transaction_id`` and the response could simply be sent with this very only transaction_id.
privacyIDEA will find the right token.

# administrative trigger

An administrator can trigger a challenge for a user.
In this case no PIN is involved and a challenge for **each** chal resp token of this user will be triggered:

~~~~json
http POST http://localhost:5000/validate/triggerchallenge authorization:$PITOK user=cornelius

{
    "detail": {
        "attributes": {
            "state": null,
            "valid_until": "2018-12-21 13:50:07.834025"
        },
        "messages": [
            "please enter otp: ",
            "Enter the OTP from the Email:"
        ],
        "threadid": 139947024250624,
        "transaction_ids": [
            "01391359713621908840",
            "13838382389650072226"
        ]
    },
    "id": 1,
    "jsonrpc": "2.0",
    "result": {
        "status": true,
        "value": 2
    },
    "time": 1545396107.835951,
    "version": "privacyIDEA 2.23.3",
    "versionnumber": "2.23.3"
~~~~

In this case **each** token has it's own challenge.
The first in the list is from a TOTP token ("please enter otp:"), the second in the list from an email (Enter the OTP from the Email").

In this case the application would have to check both transaction_ids in the list "transaction_ids":

~~~~
http POST http://localhost/validate/check user=cornelius pass=123456 transaction_id=01391359713621908840
http POST http://localhost/validate/check user=cornelius pass=123456 transaction_id=13838382389650072226
~~~~
If one of both requests returns True, then the one challenge was successfully answered.

I think in the long run we should have the triggerchallenge API return the same JSON as the /validate/check - i.e. only **one** transaction_id.

For backward compatiblity we should return the following. i.e. a combination/merge of both:

~~~~
    "detail": {
        "attributes": {
            "state": null,
            "valid_until": "2018-12-21 13:50:07.834025"
        },
        "message": "please enter otp: , Enter the OTP from the Email:",
        "messages": [
            "please enter otp: ",
            "Enter the OTP from the Email:"
        ],
        "multi_challenge": [
            {
                "attributes": null,
                "serial": "TOTP0001573B",
                "transaction_id": "01928502550899427322"
            },
            {
                "attributes": {
                    "state": "01928502550899427322",
                    "valid_until": "2018-12-21 13:49:37.107654"
                },
                "serial": "PIEM000024B0",
                "transaction_id": "01928502550899427322"
            },
        "threadid": 139947024250624,
        "transaction_ids": [
            "01391359713621908840",
            "01391359713621908840"
        ],
        "transaction_id": "01928502550899427322"
    },
~~~~


Discussion:

Resolving patch:
diff --git a/privacyidea/api/validate.py b/privacyidea/api/validate.py
--- a/privacyidea/api/validate.py
+++ b/privacyidea/api/validate.py
@@ -71,7 +71,7 @@
 from .lib.utils import required
 from privacyidea.lib.error import ParameterError
 from privacyidea.lib.token import (check_user_pass, check_serial_pass,
-                                   check_otp)
+                                   check_otp, create_challenges_from_tokens)
 from privacyidea.api.lib.utils import get_all_params
 from privacyidea.lib.config import (return_saml_attributes, get_from_config,
                                     return_saml_attributes_on_fail,
@@ -534,7 +534,6 @@ def trigger_challenge():
     """
     user = request.User
     serial = getParam(request.all_data, "serial")
-    result_obj = 0
     details = {"messages": [],
                "transaction_ids": []}
     options = {"g": g,
@@ -542,21 +541,10 @@ def trigger_challenge():
                "user": user}
 
     token_objs = get_tokens(serial=serial, user=user, active=True, revoked=False, locked=False)
-    for token_obj in token_objs:
-        if "challenge" in token_obj.mode:
-            # If this is a challenge response token, we create a challenge
-            success, return_message, transactionid, attributes = \
-                token_obj.create_challenge(options=options)
-            if attributes:
-                details["attributes"] = attributes
-            if success:
-                result_obj += 1
-                details.get("transaction_ids").append(transactionid)
-                # This will write only the serial of the token that was processed last to the audit log
-                g.audit_object.log({
-                    "serial": token_obj.token.serial,
-                })
-            details.get("messages").append(return_message)
+    # Only use the tokens, that are allowed to do challenge response
+    chal_resp_tokens = [token_obj for token_obj in token_objs if "challenge" in token_obj.mode]
+    create_challenges_from_tokens(chal_resp_tokens, details, options)
+    result_obj = len(details.get("multi_challenge"))
 
     g.audit_object.log({
         "user": user.login,
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -1998,6 +1998,57 @@ def check_user_pass(user, passw, options=None):
     return res, reply_dict
 
 
+def create_challenges_from_tokens(token_list, reply_dict, options=None):
+    """
+    Get a list of active tokens and create challenges for these tokens.
+    The reply_dict is modified accordingly. The transaction_id and
+    the messages are added to the reply_dict.
+
+    :param token_list: The list of the token objects, that can do challenge response
+    :param reply_dict: The dictionary that is passed to the API response
+    :param options: Additional options. Passed from the upper layer
+    :return: None
+    """
+    options = options or {}
+    reply_dict["multi_challenge"] = []
+    transaction_id = None
+    message_list = []
+    for token_obj in token_list:
+        # Check if the max auth is succeeded
+        if token_obj.check_all(message_list):
+            r_chal, message, transaction_id, attributes = \
+                token_obj.create_challenge(
+                    transactionid=transaction_id, options=options)
+            # Add the reply to the response
+            message_list.append(message)
+            if r_chal:
+                challenge_info = {}
+                challenge_info["transaction_id"] = transaction_id
+                challenge_info["attributes"] = attributes
+                challenge_info["serial"] = token_obj.token.serial
+                # If exist, add next pin and next password change
+                next_pin = token_obj.get_tokeninfo(
+                        "next_pin_change")
+                if next_pin:
+                    challenge_info["next_pin_change"] = next_pin
+                    challenge_info["pin_change"] = \
+                        token_obj.is_pin_change()
+                next_passw = token_obj.get_tokeninfo(
+                        "next_password_change")
+                if next_passw:
+                    challenge_info["next_password_change"] = next_passw
+                    challenge_info["password_change"] = \
+                        token_obj.is_pin_change(
+                            password=True)
+                reply_dict.update(challenge_info)
+                reply_dict["multi_challenge"].append(challenge_info)
+    if message_list:
+        reply_dict["message"] = ", ".join(message_list)
+    # TODO: These two lines are deprecated: Add the information for the old administrative triggerchallenge
+    reply_dict["messages"] = message_list
+    reply_dict["transaction_ids"] = [chal.get("transaction_id") for chal in reply_dict.get("multi_challenge", [])]
+
+
 @log_with(log)
 def check_token_list(tokenobject_list, passw, user=None, options=None):
     """
@@ -2197,7 +2248,8 @@ def check_token_list(tokenobject_list, passw, user=None, options=None):
                     tokenobject.reset()
                     # We have one successful authentication, so we bail out
                     break
-                else:
+                else:  # pragma: no cover
+                    # usually check_challenge response would return "False" in case of inactive tokens
                     reply_dict["message"] = "Challenge matches, but token is inactive."
                     log.info("Received a valid response to a "
                              "challenge for inactive token {0!s}".format(tokenobject.token.serial))
@@ -2209,40 +2261,7 @@ def check_token_list(tokenobject_list, passw, user=None, options=None):
         if len(active_challenge_token) == 0:
             reply_dict["message"] = "No active challenge response token found"
         else:
-            reply_dict["multi_challenge"] = []
-            transaction_id = None
-            message_list = []
-            for token_obj in active_challenge_token:
-                # Check if the max auth is succeeded
-                if token_obj.check_all(message_list):
-                    r_chal, message, transaction_id, attributes = \
-                        token_obj.create_challenge(
-                            transactionid=transaction_id, options=options)
-                    # Add the reply to the response
-                    message_list.append(message)
-                    reply_dict["message"] = ", ".join(message_list)
-                    if r_chal:
-                        challenge_info = {}
-                        challenge_info["transaction_id"] = transaction_id
-                        challenge_info["attributes"] = attributes
-                        challenge_info["serial"] = token_obj.token.serial
-                        # If exist, add next pin and next password change
-                        next_pin = challenge_request_token_list[0].get_tokeninfo(
-                                "next_pin_change")
-                        if next_pin:
-                            challenge_info["next_pin_change"] = next_pin
-                            challenge_info["pin_change"] = \
-                                challenge_request_token_list[0].is_pin_change()
-                        next_passw = challenge_request_token_list[0].get_tokeninfo(
-                                "next_password_change")
-                        if next_passw:
-                            challenge_info["next_password_change"] = next_passw
-                            challenge_info["password_change"] = \
-                                challenge_request_token_list[0].is_pin_change(
-                                    password=True)
-                        for k, v in challenge_info.items():
-                            reply_dict[k] = v
-                        reply_dict["multi_challenge"].append(challenge_info)
+            create_challenges_from_tokens(active_challenge_token, reply_dict, options)
 
     elif pin_matching_token_list:
         # We did not find a valid token and no challenge.


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
@@ -787,9 +787,50 @@ def test_34_check_token_list(self):
         # enable the token again
         hotp_tokenobject.enable(True)
 
+        # Set HOTP as challenge response
+        set_policy("check_token_list_CR", scope=SCOPE.AUTH, action="{0!s}=HOTP".format(
+            ACTION.CHALLENGERESPONSE))
+
+        hotp_tokenobject.add_tokeninfo("next_pin_change", u"{0!s}".format(datetime.datetime(2019, 1, 7, 0, 0)))
+        hotp_tokenobject.add_tokeninfo("next_password_change", u"{0!s}".format(datetime.datetime(2019, 1, 7, 0, 0)))
+
+        # Now the HOTP is a valid C/R token
+        res, reply = check_token_list(tokenobject_list, "hotppin")
+        self.assertFalse(res)
+        self.assertTrue("multi_challenge" in reply)
+        transaction_id = reply.get("transaction_id")
+        res, reply = check_token_list(tokenobject_list, "481090", options={"transaction_id": transaction_id})
+        self.assertTrue(res)
+
+        # Create a challenge, but deactivate the token in the meantime:
+        hotp_tokenobject.token.counter = 9
+        hotp_tokenobject.save()
+        res, reply = check_token_list(tokenobject_list, "hotppin")
+        self.assertFalse(res)
+        self.assertTrue("multi_challenge" in reply)
+        transaction_id = reply.get("transaction_id")
+        # deactivate token
+        hotp_tokenobject.enable(False)
+        hotp_tokenobject.save()
+        res, reply = check_token_list(tokenobject_list, "481090", options={"transaction_id": transaction_id})
+        self.assertFalse(res)
+
+        # Have a challenge response token, but it is disabled.
+        hotp_tokenobject.token.counter = 9
+        hotp_tokenobject.save()
+        res, reply = check_token_list(tokenobject_list, "hotppin")
+        self.assertFalse(res)
+        self.assertFalse("multi_challenge" in reply)
+        self.assertEqual(reply.get("message"), "No active challenge response token found")
+
+        hotp_tokenobject.enable()
+        hotp_tokenobject.save()
+        delete_policy("check_token_list_CR")
+
     def test_35_check_serial_pass(self):
         hotp_tokenobject = get_tokens(serial="hotptoken")[0]
         hotp_tokenobject.set_pin("hotppin")
+        hotp_tokenobject.token.count = 10
         hotp_tokenobject.save()
 
         r, reply = check_serial_pass("XXXXXXXXX", "password")