Repository: privacyidea/privacyidea

Issue:
Create challege even if message could not be submitted to firebase
Can you check that a challenge is generated for a push token even if privacyidea was not able to submit the message to the firebase service?
This happens if the firebase token of the app has changed, but using push-poll, the app is still able to respond to the challenge without using firebase at all.



Discussion:
We raise an error as soon as submitting the message to firebase fails:
https://github.com/privacyidea/privacyidea/blob/cb30ab7bed60edbfe6a4cf3f3cc4e8fb680bc4f5/privacyidea/lib/tokens/pushtoken.py#L731

We could just create and save the challenge in the db before we raise the exception.

Resolving patch:
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
@@ -729,6 +729,32 @@ def create_challenge(self, transactionid=None, options=None):
                                                      challenge, fb_gateway,
                                                      pem_privkey, options)
             res = fb_gateway.submit_message(self.get_tokeninfo("firebase_token"), smartphone_data)
+
+            # Create the challenge in the challenge table if either the message
+            # was successfully submitted to the Firebase API or if polling is
+            # allowed in general or for this specific token.
+            allow_polling = get_action_values_from_options(
+                SCOPE.AUTH, PUSH_ACTION.ALLOW_POLLING, options=options) or PushAllowPolling.ALLOW
+            if ((allow_polling == PushAllowPolling.ALLOW or
+                 (allow_polling == PushAllowPolling.TOKEN and
+                  is_true(self.get_tokeninfo(POLLING_ALLOWED, default='True')))) or res):
+                validity = int(get_from_config('DefaultChallengeValidityTime', 120))
+                tokentype = self.get_tokentype().lower()
+                # Maybe there is a PushChallengeValidityTime...
+                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,
+                                         challenge=challenge,
+                                         data=data,
+                                         session=options.get("session"),
+                                         validitytime=validity)
+                db_challenge.save()
+                self.challenge_janitor()
+
+            # If sending the Push message failed, we still raise an error.
             if not res:
                 raise ValidateError("Failed to submit message to firebase service.")
         else:
@@ -737,21 +763,6 @@ def create_challenge(self, transactionid=None, options=None):
                                                                  PUSH_ACTION.FIREBASE_CONFIG))
             raise ValidateError("The token has no tokeninfo. Can not send via firebase service.")
 
-        validity = int(get_from_config('DefaultChallengeValidityTime', 120))
-        tokentype = self.get_tokentype().lower()
-        # Maybe there is a PushChallengeValidityTime...
-        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,
-                                 challenge=challenge,
-                                 data=data,
-                                 session=options.get("session"),
-                                 validitytime=validity)
-        db_challenge.save()
-        self.challenge_janitor()
         return True, message, db_challenge.transaction_id, attributes
 
     @check_token_locked


Test patch:
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
@@ -357,13 +357,77 @@ def test_03a_api_authenticate_fail(self):
                 jsonresp = res.json
                 self.assertFalse(jsonresp.get("result").get("status"))
                 self.assertEqual(jsonresp.get("result").get("error").get("code"), 401)
-                self.assertEqual(jsonresp.get("result").get("error").get("message"), "ERR401: Failed to submit "
-                                                                                     "message to firebase service.")
+                self.assertEqual(jsonresp.get("result").get("error").get("message"),
+                                 "ERR401: Failed to submit message to firebase service.")
 
-            # Our ServiceAccountCredentials mock has been called once, because no access token has been fetched before
+            # Our ServiceAccountCredentials mock has been called once, because
+            # no access token has been fetched before
             self.assertEqual(len(mySA.from_json_keyfile_name.mock_calls), 1)
             self.assertIn(FIREBASE_FILE, get_app_local_store()["firebase_token"])
 
+            # By default, polling is allowed for push tokens so the corresponding
+            # challenge should be available in the challenge table, even though
+            # the request to firebase failed.
+            chals = get_challenges(serial=tokenobj.token.serial)
+            self.assertEqual(len(chals), 1)
+            chals[0].delete()
+
+            # Now disable polling and check that no challenge is created
+            # disallow polling through a policy
+            set_policy('push_poll', SCOPE.AUTH,
+                       action='{0!s}={1!s}'.format(PUSH_ACTION.ALLOW_POLLING,
+                                                   PushAllowPolling.DENY))
+            with self.app.test_request_context('/validate/check',
+                                               method='POST',
+                                               data={"user": "cornelius",
+                                                     "realm": self.realm1,
+                                                     "pass": "pushpin"}):
+                res = self.app.full_dispatch_request()
+                self.assertTrue(res.status_code == 400, res)
+                jsonresp = res.json
+                self.assertFalse(jsonresp.get("result").get("status"))
+                self.assertEqual(jsonresp.get("result").get("error").get("code"), 401)
+                self.assertEqual(jsonresp.get("result").get("error").get("message"),
+                                 "ERR401: Failed to submit message to firebase service.")
+            self.assertEqual(len(get_challenges(serial=tokenobj.token.serial)), 0)
+            # disallow polling the specific token through a policy
+            set_policy('push_poll', SCOPE.AUTH,
+                       action='{0!s}={1!s}'.format(PUSH_ACTION.ALLOW_POLLING,
+                                                   PushAllowPolling.TOKEN))
+            tokenobj.add_tokeninfo(POLLING_ALLOWED, False)
+            with self.app.test_request_context('/validate/check',
+                                               method='POST',
+                                               data={"user": "cornelius",
+                                                     "realm": self.realm1,
+                                                     "pass": "pushpin"}):
+                res = self.app.full_dispatch_request()
+                self.assertTrue(res.status_code == 400, res)
+                jsonresp = res.json
+                self.assertFalse(jsonresp.get("result").get("status"))
+                self.assertEqual(jsonresp.get("result").get("error").get("code"), 401)
+                self.assertEqual(jsonresp.get("result").get("error").get("message"),
+                                 "ERR401: Failed to submit message to firebase service.")
+            self.assertEqual(len(get_challenges(serial=tokenobj.token.serial)), 0)
+            # Check that the challenge is created if the request to firebase
+            # succeeded even though polling is disabled
+            # add responses, to simulate the successful communication to firebase
+            responses.replace(responses.POST,
+                              'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
+                              body="""{}""",
+                              content_type="application/json")
+            with self.app.test_request_context('/validate/check',
+                                               method='POST',
+                                               data={"user": "cornelius",
+                                                     "realm": self.realm1,
+                                                     "pass": "pushpin"}):
+                res = self.app.full_dispatch_request()
+                self.assertTrue(res.status_code == 200, res)
+                jsonresp = res.json
+                self.assertTrue(jsonresp.get("result").get("status"))
+            self.assertEqual(len(get_challenges(serial=tokenobj.token.serial)), 1)
+            get_challenges(serial=tokenobj.token.serial)[0].delete()
+        delete_policy('push_poll')
+
     @responses.activate
     def test_03b_api_authenticate_client(self):
         # Test the /validate/check endpoints without the smartphone endpoint /ttype/push