Repository: privacyidea/privacyidea

Issue:
Declined push requests
In the upcoming version of our app 4.1.1, declined push requests are communicated to privacyidea.
Possibilities to use that information:

- Audit log to easily see if this occurs often
- Eventhandler?
- Should an application that is using that push token for authentication cancel the authentication? The info has from /validate/polltransaction, which currently only has a bool: true->accepted, false->not yet accepted.


Discussion:
It seems that, when the smartphone declines the transaction, the transaction will be deleted.

So the API has to return an additional information. `send_result`
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/validate.py#L648

only returns `"value": <bool>`, so either we need to enhance the value or we use e.g. the `"status": False` to indicate, that the challenge does not exist anymore. 404: Transaction not found.

This would tell the client, that the transaction has been canceled by the user.

I would prefer to change/replace the bool to/with a string and keep the HTTP 200 code, instead of changing the HTTP code.
For transitioning in versions, we could keep the bool and add a string that has more options.
We will have to change the behaviour of the push token. We have to add the info that the request is declined to the challenge, instead of deleting the challenge, for sending different results if the challenge is declined and the challenge is not answered.
For backward compatibility, we keep the boolean and add a new field with the extra information (like validate/check).

**ToDo**

- [x] modify the challenge (challenge no longer will be deleted by declining)
- [x] add a field to the result with info for "accept", "declined" or "pending"
- [x] add the info to the audit log

Resolving patch:
diff --git a/privacyidea/api/validate.py b/privacyidea/api/validate.py
--- a/privacyidea/api/validate.py
+++ b/privacyidea/api/validate.py
@@ -608,6 +608,7 @@ def poll_transaction(transaction_id=None):
 
     :jsonparam transaction_id: a transaction ID
     """
+
     if transaction_id is None:
         transaction_id = getParam(request.all_data, "transaction_id", required)
     # Fetch a list of non-exired challenges with the given transaction ID
@@ -616,12 +617,22 @@ def poll_transaction(transaction_id=None):
                            if challenge.is_valid()]
     answered_challenges = extract_answered_challenges(matching_challenges)
 
+    declined_challenges = []
     if answered_challenges:
         result = True
         log_challenges = answered_challenges
+        details = {"challenge_status": "accept"}
     else:
         result = False
-        log_challenges = matching_challenges
+        for challenge in matching_challenges:
+            if challenge.data == "challenge_declined":
+                declined_challenges.append(challenge)
+        if declined_challenges:
+            log_challenges = declined_challenges
+            details = {"challenge_status": "declined"}
+        else:
+            log_challenges = matching_challenges
+            details = {"challenge_status": "pending"}
 
     # We now determine the information that should be written to the audit log:
     # * If there are no answered valid challenges, we log all token serials of challenges matching
@@ -642,8 +653,9 @@ def poll_transaction(transaction_id=None):
 
     # In any case, we log the transaction ID
     g.audit_object.log({
-        "info": "transaction_id: {}".format(transaction_id),
+        "info": "status: {}".format(details.get("challenge_status")),
+        "action_detail": "transaction_id: {}".format(transaction_id),
         "success": result
     })
 
-    return send_result(result)
+    return send_result(result, rid=2, details=details)
diff --git a/privacyidea/lib/tokens/pushtoken.py b/privacyidea/lib/tokens/pushtoken.py
--- a/privacyidea/lib/tokens/pushtoken.py
+++ b/privacyidea/lib/tokens/pushtoken.py
@@ -605,8 +605,7 @@ def _api_endpoint_post(cls, request_data):
                         # The signature was valid
                         log.debug("Found matching challenge {0!s}.".format(chal))
                         if decline:
-                            # If the challenge is decline, we delete it from the DB
-                            chal.delete()
+                            chal.set_data("challenge_declined")
                         else:
                             chal.set_otp_status(True)
                             chal.save()
diff --git a/privacyidea/lib/utils/__init__.py b/privacyidea/lib/utils/__init__.py
--- a/privacyidea/lib/utils/__init__.py
+++ b/privacyidea/lib/utils/__init__.py
@@ -1280,6 +1280,8 @@ def prepare_result(obj, rid=1, details=None):
         elif not obj and details.get("multi_challenge"):
             # We have a challenge authentication
             r_authentication = "CHALLENGE"
+        elif not obj and (details.get("challenge_status") == "declined"):
+            r_authentication = "DECLINED"
         else:
             r_authentication = "REJECT"
         res["result"]["authentication"] = r_authentication


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
@@ -4790,7 +4790,8 @@ def test_12_polltransaction(self):
 
         # check audit log
         entry = self.find_most_recent_audit_entry(action="*/validate/polltransaction*")
-        self.assertEqual(entry["info"], "transaction_id: 123456")
+        self.assertEqual(entry["action_detail"], "transaction_id: 123456")
+        self.assertEqual(entry["info"], "status: pending")
         self.assertEqual(entry["serial"], None)
         self.assertEqual(entry["user"], None)
 
@@ -4804,7 +4805,8 @@ def test_12_polltransaction(self):
 
         # but audit log contains both serials and the user
         entry = self.find_most_recent_audit_entry(action="*/validate/polltransaction*")
-        self.assertEqual(entry["info"], "transaction_id: {}".format(transaction_id))
+        self.assertEqual(entry["action_detail"], "transaction_id: {}".format(transaction_id))
+        self.assertEqual(entry["info"], "status: pending")
         self.assertIn("tok1", entry["serial"])
         self.assertIn("tok2", entry["serial"])
         self.assertFalse(entry["success"])
@@ -4822,7 +4824,8 @@ def test_12_polltransaction(self):
 
         # and the audit log contains no serials and the user
         entry = self.find_most_recent_audit_entry(action="*/validate/polltransaction*")
-        self.assertEqual(entry["info"], "transaction_id: {}".format(old_transaction_id))
+        self.assertEqual(entry["action_detail"], "transaction_id: {}".format(old_transaction_id))
+        self.assertEqual(entry["info"], "status: pending")
         self.assertEqual(entry["serial"], None)
         self.assertFalse(entry["success"])
 
@@ -4839,7 +4842,8 @@ def test_12_polltransaction(self):
             self.assertTrue(res.json["result"]["value"])
 
         entry = self.find_most_recent_audit_entry(action="*/validate/polltransaction*")
-        self.assertEqual(entry["info"], "transaction_id: {}".format(transaction_id))
+        self.assertEqual(entry["action_detail"], "transaction_id: {}".format(transaction_id))
+        self.assertEqual(entry["info"], "status: None")
         # tok2 is not written to the audit log
         self.assertEqual(entry["serial"], "tok1")
         self.assertTrue(entry["success"])
diff --git a/tests/test_lib_tokens_push.py b/tests/test_lib_tokens_push.py
--- a/tests/test_lib_tokens_push.py
+++ b/tests/test_lib_tokens_push.py
@@ -741,6 +741,7 @@ def test_04_api_authenticate_smartphone(self):
             # Result-Value is True
             self.assertTrue(jsonresp.get("result").get("value"))
 
+
     def test_04_decline_auth_request(self):
         # get enrolled push token
         toks = get_tokens(tokentype="push")
@@ -808,7 +809,6 @@ def test_04_decline_auth_request(self):
             rj = res.json
             self.assertTrue(rj['result']['status'])
             self.assertFalse(rj['result']['value'])
-
         # Now decline the auth request for real
         with self.app.test_request_context('/ttype/push',
                                            method='POST',
@@ -820,10 +820,21 @@ def test_04_decline_auth_request(self):
             self.assertTrue(res.status_code == 200, res)
             self.assertTrue(res.json['result']['status'])
             self.assertTrue(res.json['result']['value'])
-        # check, that the challenge does not exist anymore.
+
         challengeobject_list = get_challenges(serial=tokenobj.token.serial,
                                               transaction_id=transaction_id)
-        self.assertEqual(0, len(challengeobject_list))
+        self.assertEqual(1, len(challengeobject_list))
+
+        with self.app.test_request_context('/validate/polltransaction', method='GET',
+                                           data={'transaction_id': transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            self.assertTrue(res.json["result"]["status"])
+            self.assertFalse(res.json["result"]["value"])
+            self.assertEqual(res.json["detail"]["challenge_status"], "declined", res.json["detail"]["challenge_status"])
+
+
+
 
     def test_05_strip_key(self):
         stripped_pubkey = strip_key(self.smartphone_public_key_pem)