Repository: privacyidea/privacyidea

Issue:
Allow several SMS/Email challenges at the same time
It should be possible to trigger several SMS challenges and then answer all these challenges independently.


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/tokenclass.py b/privacyidea/lib/tokenclass.py
--- a/privacyidea/lib/tokenclass.py
+++ b/privacyidea/lib/tokenclass.py
@@ -1432,6 +1432,7 @@ def check_challenge_response(self, user=None, passw=None, options=None):
                     # challenge is still valid
                     # Add the challenge to the options for check_otp
                     options["challenge"] = challengeobject.challenge
+                    options["data"] = challengeobject.data
                     # Now see if the OTP matches:
                     otp_counter = self.check_otp(passw, options=options)
                     if otp_counter >= 0:
diff --git a/privacyidea/lib/tokens/emailtoken.py b/privacyidea/lib/tokens/emailtoken.py
--- a/privacyidea/lib/tokens/emailtoken.py
+++ b/privacyidea/lib/tokens/emailtoken.py
@@ -270,9 +270,14 @@ def create_challenge(self, transactionid=None, options=None):
                                                                    "Your OTP")
 
                 # Create the challenge in the database
+                if is_true(get_from_config("email.concurrent_challenges")):
+                    data = self.get_otp()[2]
+                else:
+                    data = None
                 db_challenge = Challenge(self.token.serial,
                                          transaction_id=transactionid,
                                          challenge=options.get("challenge"),
+                                         data=data,
                                          session=options.get("session"),
                                          validitytime=validity)
                 db_challenge.save()
@@ -313,6 +318,10 @@ def check_otp(self, anOtpVal, counter=None, window=None, options=None):
         """
         options = options or {}
         ret = HotpTokenClass.check_otp(self, anOtpVal, counter, window, options)
+        if ret < 0 and is_true(get_from_config("email.concurrent_challenges")):
+            if options.get("data") == anOtpVal:
+                # We authenticate from the saved challenge
+                ret = 1
         if ret >= 0 and self._get_auto_email(options):
             message, mimetype = self._get_email_text_or_subject(options)
             subject, _ = self._get_email_text_or_subject(options,
diff --git a/privacyidea/lib/tokens/smstoken.py b/privacyidea/lib/tokens/smstoken.py
--- a/privacyidea/lib/tokens/smstoken.py
+++ b/privacyidea/lib/tokens/smstoken.py
@@ -301,9 +301,14 @@ def create_challenge(self, transactionid=None, options=None):
                     message=message_template)
 
                 # Create the challenge in the database
+                if is_true(get_from_config("sms.concurrent_challenges")):
+                    data = self.get_otp()[2]
+                else:
+                    data = None
                 db_challenge = Challenge(self.token.serial,
                                          transaction_id=transactionid,
                                          challenge=options.get("challenge"),
+                                         data=data,
                                          session=options.get("session"),
                                          validitytime=validity)
                 db_challenge.save()
@@ -338,6 +343,10 @@ def check_otp(self, anOtpVal, counter=None, window=None, options=None):
         """
         options = options or {}
         ret = HotpTokenClass.check_otp(self, anOtpVal, counter, window, options)
+        if ret < 0 and is_true(get_from_config("sms.concurrent_challenges")):
+            if options.get("data") == anOtpVal:
+                # We authenticate from the saved challenge
+                ret = 1
         if ret >= 0 and self._get_auto_sms(options):
             message = self._get_sms_text(options)
             self.inc_otp_counter(ret, reset=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
@@ -75,6 +75,17 @@
                  ]
 
 
+OTPs = ["755224",
+        "287082",
+        "359152",
+        "969429",
+        "338314",
+        "254676",
+        "287922",
+        "162583",
+        "399871",
+        "520489"]
+
 class AuthorizationPolicyTestCase(MyTestCase):
     """
     This tests the catch all resolvers and resolvers which also contain the
@@ -503,6 +514,27 @@ class ValidateAPITestCase(MyTestCase):
     """
     test the api.validate endpoints
     """
+    def setup_sms_gateway(self):
+        from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway
+        post_url = "http://smsgateway.com/sms_send_api.cgi"
+        success_body = "ID 12345"
+
+        identifier = "myGW"
+        provider_module = "privacyidea.lib.smsprovider.HttpSMSProvider" \
+                          ".HttpSMSProvider"
+        id = set_smsgateway(identifier, provider_module, description="test",
+                            options={"HTTP_METHOD": "POST",
+                                     "URL": post_url,
+                                     "RETURN_SUCCESS": "ID",
+                                     "text": "{otp}",
+                                     "phone": "{phone}"})
+        self.assertTrue(id > 0)
+        # set config sms.identifier = myGW
+        r = set_privacyidea_config("sms.identifier", identifier)
+        self.assertTrue(r in ["insert", "update"])
+        responses.add(responses.POST,
+                      post_url,
+                      body=success_body)
 
     def test_00_create_realms(self):
         self.setUp_user_realms()
@@ -1643,28 +1675,8 @@ def test_23a_pass_no_user_resolver(self):
 
     @responses.activate
     def test_24_trigger_challenge(self):
-        from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway
         from privacyidea.lib.config import set_privacyidea_config
-        post_url = "http://smsgateway.com/sms_send_api.cgi"
-        success_body = "ID 12345"
-
-        identifier = "myGW"
-        provider_module = "privacyidea.lib.smsprovider.HttpSMSProvider" \
-                          ".HttpSMSProvider"
-        id = set_smsgateway(identifier, provider_module, description="test",
-                            options={"HTTP_METHOD": "POST",
-                                     "URL": post_url,
-                                     "RETURN_SUCCESS": "ID",
-                                     "text": "{otp}",
-                                     "phone": "{phone}"})
-        self.assertTrue(id > 0)
-        # set config sms.identifier = myGW
-        r = set_privacyidea_config("sms.identifier", identifier)
-        self.assertEqual(r, "insert")
-
-        responses.add(responses.POST,
-                      post_url,
-                      body=success_body)
+        self.setup_sms_gateway()
 
         self.setUp_user_realms()
         self.setUp_user_realm2()
@@ -2106,29 +2118,8 @@ def test_30_challenge_text(self):
         smtpmock.setdata(response={"hallo@example.com": (200, 'OK')})
 
         # Configure the SMS Gateway
-        from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway
+        self.setup_sms_gateway()
         from privacyidea.lib.config import set_privacyidea_config
-        post_url = "http://smsgateway.com/sms_send_api.cgi"
-        success_body = "ID 12345"
-
-        identifier = "myGW"
-        provider_module = "privacyidea.lib.smsprovider.HttpSMSProvider" \
-                          ".HttpSMSProvider"
-        id = set_smsgateway(identifier, provider_module, description="test",
-                            options={"HTTP_METHOD": "POST",
-                                     "URL": post_url,
-                                     "RETURN_SUCCESS": "ID",
-                                     "text": "{otp}",
-                                     "phone": "{phone}"})
-        self.assertTrue(id > 0)
-        # set config sms.identifier = myGW
-        r = set_privacyidea_config("sms.identifier", identifier)
-        self.assertTrue(r in ["insert", "update"])
-        responses.add(responses.POST,
-                      post_url,
-                      body=success_body)
-
-
 
         self.setUp_user_realms()
         user = User("cornelius", self.realm1)
@@ -2264,11 +2255,34 @@ class AChallengeResponse(MyTestCase):
 
     serial = "hotp1"
     serial_email = "email1"
+    serial_sms= "sms1"
 
     def setUp(self):
         MyTestCase.setUp(self)
         self.setUp_user_realms()
 
+    def setup_sms_gateway(self):
+        from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway
+        post_url = "http://smsgateway.com/sms_send_api.cgi"
+        success_body = "ID 12345"
+
+        identifier = "myGW"
+        provider_module = "privacyidea.lib.smsprovider.HttpSMSProvider" \
+                          ".HttpSMSProvider"
+        id = set_smsgateway(identifier, provider_module, description="test",
+                            options={"HTTP_METHOD": "POST",
+                                     "URL": post_url,
+                                     "RETURN_SUCCESS": "ID",
+                                     "text": "{otp}",
+                                     "phone": "{phone}"})
+        self.assertTrue(id > 0)
+        # set config sms.identifier = myGW
+        r = set_privacyidea_config("sms.identifier", identifier)
+        self.assertTrue(r in ["insert", "update"])
+        responses.add(responses.POST,
+                      post_url,
+                      body=success_body)
+
     def test_01_challenge_response_token_deactivate(self):
         # New token for the user "selfservice"
         Token("hotp1", "hotp", otpkey=self.otpkey, userid=1004, resolver=self.resolvername1,
@@ -2389,4 +2403,302 @@ def test_02_two_challenge_response_tokens(self):
             self.assertEqual(data.get("result").get("value"), 1)
             detail = data.get("detail")
             # Only the email token is active and creates a challenge!
-            self.assertEqual(u"Enter the OTP from the Email:", detail.get("messages")[0])
\ No newline at end of file
+            self.assertEqual(u"Enter the OTP from the Email:", detail.get("messages")[0])
+        remove_token(self.serial_email)
+
+    @smtpmock.activate
+    def test_03_two_challenges_from_one_email_token(self):
+        set_privacyidea_config("email.concurrent_challenges", "True")
+        smtpmock.setdata(response={"bla@example.com": (200, 'OK')})
+        # remove tokens for user cornelius
+        remove_token(user=User("cornelius", self.realm1))
+        # Enroll an Email-Token to the user
+        init_token(user=User("cornelius", self.realm1),
+                   param={"serial": self.serial_email,
+                          "type": "email",
+                          "email": "bla@example.com",
+                          "otpkey": self.otpkey})
+        set_pin(self.serial_email, "pin")
+
+        toks = get_tokens(user=User("cornelius", self.realm1))
+        self.assertEqual(len(toks), 1)
+
+        # Now we create the first challenge
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            # Only the email token is active and creates a challenge!
+            self.assertEqual(u"Enter the OTP from the Email:", detail.get("message"))
+            transaction_id1 = detail.get("transaction_id")
+
+        # Now we create the second challenge
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            # Only the email token is active and creates a challenge!
+            self.assertEqual(u"Enter the OTP from the Email:", detail.get("message"))
+            transaction_id2 = detail.get("transaction_id")
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id": transaction_id2,
+                                                 "pass": OTPs[2]}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertTrue(data.get("result").get("value"))
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id": transaction_id1,
+                                                 "pass": OTPs[1]}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertTrue(data.get("result").get("value"))
+
+        remove_token(self.serial_email)
+        delete_privacyidea_config("email.concurrent_challenges")
+
+    @smtpmock.activate
+    def test_04_only_last_challenge_from_one_email_token(self):
+        set_privacyidea_config("email.concurrent_challenges", "False")
+        smtpmock.setdata(response={"bla@example.com": (200, 'OK')})
+        # remove tokens for user cornelius
+        remove_token(user=User("cornelius", self.realm1))
+        # Enroll an Email-Token to the user
+        init_token(user=User("cornelius", self.realm1),
+                   param={"serial": self.serial_email,
+                          "type": "email",
+                          "email": "bla@example.com",
+                          "otpkey": self.otpkey})
+        set_pin(self.serial_email, "pin")
+
+        toks = get_tokens(user=User("cornelius", self.realm1))
+        self.assertEqual(len(toks), 1)
+
+        # Now we create the first challenge
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            # Only the email token is active and creates a challenge!
+            self.assertEqual(u"Enter the OTP from the Email:", detail.get("message"))
+            transaction_id1 = detail.get("transaction_id")
+
+        # Now we create the second challenge
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            # Only the email token is active and creates a challenge!
+            self.assertEqual(u"Enter the OTP from the Email:", detail.get("message"))
+            transaction_id2 = detail.get("transaction_id")
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id": transaction_id1,
+                                                 "pass": OTPs[1]}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            # The first challenge will not authenticate anymore, since the OTP is not stored in the challenge data
+            # and the token counter was increased
+            self.assertFalse(data.get("result").get("value"))
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id": transaction_id2,
+                                                 "pass": OTPs[2]}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertTrue(data.get("result").get("value"))
+
+        remove_token(self.serial_email)
+        delete_privacyidea_config("email.concurrent_challenges")
+
+    @responses.activate
+    def test_05_two_challenges_from_one_sms_token(self):
+        # Configure the SMS Gateway
+        self.setup_sms_gateway()
+
+        ### Now do the enrollment and authentication
+        set_privacyidea_config("sms.concurrent_challenges", "True")
+        # remove tokens for user cornelius
+        remove_token(user=User("cornelius", self.realm1))
+        # Enroll an Email-Token to the user
+        init_token(user=User("cornelius", self.realm1),
+                   param={"serial": self.serial_sms,
+                          "type": "sms",
+                          "phone": "1234567",
+                          "otpkey": self.otpkey})
+        set_pin(self.serial_sms, "pin")
+
+        toks = get_tokens(user=User("cornelius", self.realm1))
+        self.assertEqual(len(toks), 1)
+
+        # Now we create the first challenge
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            self.assertEqual(u"Enter the OTP from the SMS:", detail.get("message"))
+            transaction_id1 = detail.get("transaction_id")
+
+        # Now we create the second challenge
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            self.assertEqual(u"Enter the OTP from the SMS:", detail.get("message"))
+            transaction_id2 = detail.get("transaction_id")
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id": transaction_id2,
+                                                 "pass": OTPs[2]}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertTrue(data.get("result").get("value"))
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id": transaction_id1,
+                                                 "pass": OTPs[1]}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertTrue(data.get("result").get("value"))
+
+        remove_token(self.serial_sms)
+        delete_privacyidea_config("sms.concurrent_challenges")
+
+    @responses.activate
+    def test_06_only_last_challenges_from_one_sms_token(self):
+        # Configure the SMS Gateway
+        self.setup_sms_gateway()
+
+        ### Now do the enrollment and authentication
+        try:
+            delete_privacyidea_config("sms.concurrent_challenges")
+        except AttributeError:
+            pass
+
+        # remove tokens for user cornelius
+        remove_token(user=User("cornelius", self.realm1))
+        # Enroll an Email-Token to the user
+        init_token(user=User("cornelius", self.realm1),
+                   param={"serial": self.serial_sms,
+                          "type": "sms",
+                          "phone": "1234567",
+                          "otpkey": self.otpkey})
+        set_pin(self.serial_sms, "pin")
+
+        toks = get_tokens(user=User("cornelius", self.realm1))
+        self.assertEqual(len(toks), 1)
+
+        # Now we create the first challenge
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            self.assertEqual(u"Enter the OTP from the SMS:", detail.get("message"))
+            transaction_id1 = detail.get("transaction_id")
+
+        # Now we create the second challenge
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": "pin"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            detail = data.get("detail")
+            self.assertEqual(u"Enter the OTP from the SMS:", detail.get("message"))
+            transaction_id2 = detail.get("transaction_id")
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id": transaction_id1,
+                                                 "pass": OTPs[1]}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            # First OTP fails, since the counter increased
+            self.assertFalse(data.get("result").get("value"))
+
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "transaction_id": transaction_id2,
+                                                 "pass": OTPs[2]}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            # Last value succeeds
+            self.assertTrue(data.get("result").get("value"))
+
+        remove_token(self.serial_sms)
\ No newline at end of file