Repository: privacyidea/privacyidea

Issue:
Push challenge is not created when ERR401: Failed to submit message to firebase service occurs
For push to work without firebase, the push challenge should be created even if firebase is unavailable (or there is a firebase token mismatch etc.). That way, the smartphone can still poll the challenge directly and answer it without firebase being involved.
Currently, the server returns
{"detail": null, "id": 1, "jsonrpc": "2.0", "result": {"error": {"code": 401, "message": "ERR401: Failed to submit message to firebase service."}, "status": false}, ...
in that case.

1. The challenge is not created and cannot be polled by the smartphone.
2. The plugin does not know (even if the challenge was created on the server side) that it should offer push as a login method.
3. Other challenges are not sent either, this problem completely blocks the login with challenge-response type token.


Discussion:
This happens here:
https://github.com/privacyidea/privacyidea/blob/a89b0dabed312e8b9d1e3b343f9a27573fc86524/privacyidea/lib/tokens/pushtoken.py#L866
and here
https://github.com/privacyidea/privacyidea/blob/a89b0dabed312e8b9d1e3b343f9a27573fc86524/privacyidea/lib/tokens/pushtoken.py#L873

The tokens Email and SMS actually only raise an exception if the options/parameter "exception" is given.
See here: https://github.com/privacyidea/privacyidea/blob/2b075edc8ff0f4f481de4c36411dc648f114ce01/privacyidea/lib/tokens/emailtoken.py#L253

Otherwise they simply pass and write to the log file.

I think the same behaviour for the `ValidateError` in the pushtoken case makes sense:

~~~~Python
 # If sending the Push message failed, we log a warning
            if not res:
                log.warning(u"Failed to submit message to Firebase service for token {0!s}."
                            .format(self.token.serial))
                if is_true(options.get("exception")):
                    raise ValidateError("Failed to submit message to Firebase service.")
~~~~
@nilsbehlen The problem is, we have no interactive message for the user. Should we add the note, that the submit to firebase failed, add to the challenge text?

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
@@ -68,6 +68,8 @@
 log = logging.getLogger(__name__)
 
 DEFAULT_CHALLENGE_TEXT = _("Please confirm the authentication on your mobile device!")
+ERROR_CHALLENGE_TEXT = _("Use the polling feature of your privacyIDEA Authenticator App"
+                         " to check for a new Login request.")
 DEFAULT_MOBILE_TEXT = _("Do you want to confirm the login?")
 PRIVATE_KEY_SERVER = "private_key_server"
 PUBLIC_KEY_SERVER = "public_key_server"
@@ -860,19 +862,24 @@ def create_challenge(self, transactionid=None, options=None):
                                          validitytime=validity)
                 db_challenge.save()
                 self.challenge_janitor()
+                transactionid = db_challenge.transaction_id
 
-            # If sending the Push message failed, we still raise an error and a warning.
+            # If sending the Push message failed, we log a warning
             if not res:
                 log.warning(u"Failed to submit message to Firebase service for token {0!s}."
                             .format(self.token.serial))
-                raise ValidateError("Failed to submit message to Firebase service.")
+                message += " " + ERROR_CHALLENGE_TEXT
+                if is_true(options.get("exception")):
+                    raise ValidateError("Failed to submit message to Firebase service.")
         else:
             log.warning(u"The token {0!s} has no tokeninfo {1!s}. "
                         u"The message could not be sent.".format(self.token.serial,
                                                                  PUSH_ACTION.FIREBASE_CONFIG))
-            raise ValidateError("The token has no tokeninfo. Can not send via Firebase service.")
+            message += " " + ERROR_CHALLENGE_TEXT
+            if is_true(options.get("exception")):
+                raise ValidateError("The token has no tokeninfo. Can not send via Firebase service.")
 
-        return True, message, db_challenge.transaction_id, reply_dict
+        return True, message, transactionid, reply_dict
 
     @check_token_locked
     def authenticate(self, passw, user=None, options=None):


Test patch:
diff --git a/tests/test_api_ttype.py b/tests/test_api_ttype.py
--- a/tests/test_api_ttype.py
+++ b/tests/test_api_ttype.py
@@ -273,18 +273,21 @@ def test_02_api_push_poll(self):
 
             # Send the first authentication request to trigger the challenge.
             # No push notification is submitted to firebase, but a challenge is created anyway
-            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.")
+            with mock.patch("logging.Logger.warning") as mock_log:
+                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)
+                    result = res.json.get("result")
+                    self.assertTrue(result.get("status"))
+                    self.assertFalse(result.get("value"))
+                    self.assertEqual("CHALLENGE", result.get("authentication"))
+                    # Check that the warning was written to the log file.
+                    mock_log.assert_called_with("Failed to submit message to Firebase service for token {0!s}."
+                                                .format(serial))
 
         # first create a signature
         ts = datetime.utcnow().isoformat()
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
@@ -253,18 +253,26 @@ def test_03a_api_authenticate_fail(self):
                           content_type="application/json")
 
             # Send the first authentication request to trigger the challenge
-            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.")
+            with mock.patch("logging.Logger.warning") as mock_log:
+                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)
+                    result = res.json.get("result")
+                    self.assertTrue(result.get("status"))
+                    self.assertFalse(result.get("value"))
+                    self.assertEqual("CHALLENGE", result.get("authentication"))
+                    # Check that the warning was written to the log file.
+                    mock_log.assert_called_with("Failed to submit message to Firebase service for token {0!s}."
+                                                .format(serial))
+                    # Check that the user was informed about the need to poll
+                    detail = res.json.get("detail")
+                    self.assertEqual("Please confirm the authentication on your mobile device! "
+                                     "Use the polling feature of your privacyIDEA Authenticator App "
+                                     "to check for a new Login request.", detail.get("message"))
 
             # Our ServiceAccountCredentials mock has been called once, because
             # no access token has been fetched before
@@ -278,42 +286,85 @@ def test_03a_api_authenticate_fail(self):
             self.assertEqual(len(chals), 1, chals)
             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))
+            # Do the same with the parameter "exception", so that we receive an Error on HTTP
             with self.app.test_request_context('/validate/check',
                                                method='POST',
                                                data={"user": "cornelius",
                                                      "realm": self.realm1,
+                                                     "exception": 1,
                                                      "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(400, res.status_code)
+                result = res.json.get("result")
+                self.assertFalse(result.get("status"))
+                error = result.get("error")
+                self.assertEqual(401, error.get("code"))
+                self.assertEqual("ERR401: Failed to submit message to Firebase service.", error.get("message"))
+
+            # Remove the created challenge
+            chals = get_challenges(serial=tokenobj.token.serial)
+            self.assertEqual(len(chals), 1, chals)
+            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 mock.patch("logging.Logger.warning") as mock_log:
+                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)
+                    result = res.json.get("result")
+                    self.assertTrue(result.get("status"))
+                    self.assertFalse(result.get("value"))
+                    self.assertEqual("CHALLENGE", result.get("authentication"))
+                    # Check that the warning was written to the log file.
+                    mock_log.assert_called_with("Failed to submit message to Firebase service for token {0!s}."
+                                                .format(serial))
             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 mock.patch("logging.Logger.warning") as mock_log:
+                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)
+                    result = res.json.get("result")
+                    self.assertTrue(result.get("status"))
+                    self.assertFalse(result.get("value"))
+                    self.assertEqual("CHALLENGE", result.get("authentication"))
+                    # Check that the warning was written to the log file.
+                    mock_log.assert_called_with("Failed to submit message to Firebase service for token {0!s}."
+                                                .format(serial))
+            self.assertEqual(len(get_challenges(serial=tokenobj.token.serial)), 0)
+
+            # Do the same with the parameter "exception", so that we receive an Error on HTTP
             with self.app.test_request_context('/validate/check',
                                                method='POST',
                                                data={"user": "cornelius",
                                                      "realm": self.realm1,
+                                                     "exception": 1,
                                                      "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)
+                self.assertEqual(400, res.status_code)
+                result = res.json.get("result")
+                self.assertFalse(result.get("status"))
+                error = result.get("error")
+                self.assertEqual(401, error.get("code"))
+                self.assertEqual("ERR401: Failed to submit message to Firebase service.", error.get("message"))
+
             # 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
@@ -332,6 +383,7 @@ def test_03a_api_authenticate_fail(self):
                 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()
+
         remove_token(serial=serial)
         delete_smsgateway(self.firebase_config_name)
         delete_policy('push_poll')