Repository: privacyidea/privacyidea

Issue:
Multi-Challenge: Questionnaire token's support for more than one answers required per challenge
**Is your feature request related to a problem? Please describe.**

The the moment, questionnaire token only prompts for 1 question per challenge, and it will randomize one from the questions configured in that token.

It will be great if questionnaire token can support:
 * Prompts for multiple questions in the token per challenge
 * Requires the user to response with answers for the above prompted questions.

**Describe the solution you'd like**

When the questionnaire token is triggered, based on the number of required answer configured for questionnaire token (new configuration option), the user will be asked that many questions, and will be required to submit the answers for those questions correctly.

This should happen within 1 challenge/response preferably if possible.

**Describe alternatives you've considered**
Alternatively, we are thinking of using multiple questionnaire tokens, with one question each. However, this approach will require diversion in configuration being stored, and also adding more calls to the API.

Best regards,

Quynh Nguyen



Discussion:
Please specify how you think, this could look like?

How should the challenge text look like?

How should the user enter the response?

*I think the more generic approach would be to allow a challenge response to create new challenges after a response was answered. But the current design does not allow for that. So this would probably only happen in version 4.*
For the challenge text, I think the current response has already have a format that can extend to support multiple questions in the challenge:
```
[...]
        "messages": [
            "question 2",
            "question 3",
            "question 4"
        ],
        "multi_challenge": [
            {
                "attributes": null,
                "message": "question 2",
                "serial": "QUST0003C855",
                "transaction_id": "14023462515903980394",
                "type": "question"
            },
            {
                "attributes": null,
                "message": "question 3",
                "serial": "QUST0003C855",
                "transaction_id": "04781040133827397452",
                "type": "question"
            },
            {
                "attributes": null,
                "message": "question 4",
                "serial": "QUST0003C855",
                "transaction_id": "05489273070579907514",
                "type": "question"
            }
        ],
        "serial": "QUST0003C855",
        "threadid": 139760752719872,
        "transaction_id": "05489273070579907514",
        "transaction_ids": [
            "14023462515903980394",
            "04781040133827397452",
            "05489273070579907514"
        ],
        "type": "question"
    },
[...]
```
For the user's response, we can submit something like:
```
[...]
"user" : "jdoe"
"multi_challenge" : [
  {
    "message": "question",
    "pass": "myPass",
    "transaction_id": "04781040133827397452"
  },
  {
    "message": "question 3",
    "pass": "myPass3",
    "transaction_id": "05489273070579907514"
  },
  {
     "message": "question 2",
     "pass": "myPass2",
     "transaction_id": "14023462515903980394"
  }
],
[...]
```
I use multiple transaction ids in the example just for demonstration.

I can see that for client such as RADIUS, there maybe a problem with the above suggestion, or this feature request in general. Though, we don't have a RADIUS priority for this feature request.

Above is just my quick thought on your question.

Thank you for looking into this.

Best regards,


@quynh-axiadids Thanks a for the response.
I mean: How should the text be displayed to the user?

   "What is your mother's maiden name? What is the name of your cat? What is your shoe size?"

What should the user type in?

   "smithhans11/2"?

This is the much bigger question to me.
Hi @cornelinux,
I do not come up with any specific thought of how the question display and the answer input should be like...
But I think the value of the questions can be very flexible and not necessarily ending with `?` as in your example.
Please let me know if you have any idea in mind?
We will do this with multi challenge response like the indexed secret token in #2163 
We could rather do this with multi challenge this way, that the questionaire token will ask for Answer A, then for answer C...
And only after a number of answers were given have the user logged in.
``check_challenge_response`` needs to create new challenges.

We store the number of the question in the ``session`` field in the Challenge, which is completely similar to the indexedsecret token.

Resolving patch:
diff --git a/privacyidea/lib/tokens/questionnairetoken.py b/privacyidea/lib/tokens/questionnairetoken.py
--- a/privacyidea/lib/tokens/questionnairetoken.py
+++ b/privacyidea/lib/tokens/questionnairetoken.py
@@ -1,5 +1,7 @@
 # -*- coding: utf-8 -*-
 #
+#  2020-09-21 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#             Add possibility of multiple questions and answers
 #  http://www.privacyidea.org
 #  2015-12-16 Initial writeup.
 #             Cornelius Kölbel <cornelius@privacyidea.org>
@@ -34,9 +36,10 @@
 from privacyidea.lib.challenge import get_challenges
 from privacyidea.lib import _
 from privacyidea.lib.decorators import check_token_locked
-from privacyidea.lib.policy import SCOPE, ACTION, GROUP
+from privacyidea.lib.policy import SCOPE, ACTION, GROUP, get_action_values_from_options
 import random
 import json
+import datetime
 
 log = logging.getLogger(__name__)
 optional = True
@@ -44,6 +47,10 @@
 DEFAULT_NUM_ANSWERS = 5
 
 
+class QUESTACTION(object):
+    NUM_QUESTIONS = "number"
+
+
 class QuestionnaireTokenClass(TokenClass):
 
     """
@@ -95,6 +102,14 @@ def get_class_info(cls, key=None, ret='all'):
                # This tokentype is enrollable in the UI for...
                'ui_enroll': ["admin", "user"],
                'policy': {
+                   SCOPE.AUTH: {
+                       QUESTACTION.NUM_QUESTIONS: {
+                           'type': 'int',
+                           'desc': _("The user has to answer this number of questions during authentication."),
+                           'group': GROUP.TOKEN,
+                           'value': list(range(1, 31))
+                       }
+                   },
                    SCOPE.ENROLL: {
                        ACTION.MAXTOKENUSER: {
                            'type': 'int',
@@ -198,18 +213,25 @@ def create_challenge(self, transactionid=None, options=None):
         additional ``attributes``, which are displayed in the JSON response.
         """
         options = options or {}
-
-        # Get a random question
-        questions = []
-        tinfo = self.get_tokeninfo()
-        for question, answer in tinfo.items():
-            if question.endswith(".type") and answer == "password":
-                # This is "Question1?.type" of type "password"
-                # So this is actually a question and we add the question to
-                # the list
-                questions.append(question[:-5])
-        message = random.choice(questions)
-        attributes = None
+        questions = {}
+        attributes = {}
+
+        # Get an integer list of the already used questions
+        used_questions = [int(x) for x in options.get("data", "").split(",") if options.get("data")]
+        # Fill the questions of the token
+        for tinfo in self.token.info_list:
+            if tinfo.Type == "password":
+                # Append a tuple of the DB Id and the actual question
+                questions[tinfo.id] = tinfo.Key
+        # if all questions are used up, make a new round
+        if len(questions) == len(used_questions):
+            log.info(u"User has only {0!s} questions in his token. Reusing questions now.".format(len(questions)))
+            used_questions = []
+        # Reduce the allowed questions
+        remaining_questions = {k: v for (k, v) in questions.items() if k not in used_questions}
+        message_id = random.choice(list(remaining_questions))
+        message = remaining_questions[message_id]
+        used_questions = (options.get("data", "") + ",{0!s}".format(message_id)).strip(",")
 
         validity = int(get_from_config('DefaultChallengeValidityTime', 120))
         tokentype = self.get_tokentype().lower()
@@ -220,10 +242,14 @@ def create_challenge(self, transactionid=None, options=None):
         # Create the challenge in the database
         db_challenge = Challenge(self.token.serial,
                                  transaction_id=transactionid,
+                                 data=used_questions,
+                                 session=options.get("session"),
                                  challenge=message,
                                  validitytime=validity)
         db_challenge.save()
-        self.challenge_janitor()
+        expiry_date = datetime.datetime.now() + \
+                      datetime.timedelta(seconds=validity)
+        attributes['valid_until'] = "{0!s}".format(expiry_date)
         return True, message, db_challenge.transaction_id, attributes
 
     def check_answer(self, given_answer, challenge_object):
@@ -267,7 +293,7 @@ def check_challenge_response(self, user=None, passw=None, options=None):
         :rtype: int
         """
         options = options or {}
-        otp_counter = -1
+        r_success = -1
 
         # fetch the transaction_id
         transaction_id = options.get('transaction_id')
@@ -282,18 +308,43 @@ def check_challenge_response(self, user=None, passw=None, options=None):
             for challengeobject in challengeobject_list:
                 if challengeobject.is_valid():
                     # challenge is still valid
-                    otp_counter = self.check_answer(passw, challengeobject)
-                    if otp_counter >= 0:
-                        # We found the matching challenge, so lets return the
-                        #  successful result and delete the challenge object.
-                        challengeobject.delete()
+                    if self.check_answer(passw, challengeobject) > 0:
+                        r_success = 1
+                        # Set valid OTP to true. We must not delete the challenge now,
+                        # Since we need it for further mutlichallenges
+                        challengeobject.set_otp_status(True)
+                        log.debug("The presented answer was correct.")
                         break
                     else:
                         # increase the received_count
                         challengeobject.set_otp_status()
 
         self.challenge_janitor()
-        return otp_counter
+        return r_success
+
+    @log_with(log)
+    def has_further_challenge(self, options=None):
+        """
+        Check if there are still more questions to be asked.
+
+        :param options: Options dict
+        :return: True, if further challenge is required.
+        """
+        transaction_id = options.get('transaction_id')
+        challengeobject_list = get_challenges(serial=self.token.serial,
+                                              transaction_id=transaction_id)
+        question_number = int(get_action_values_from_options(SCOPE.AUTH,
+                                                             "{0!s}_{1!s}".format(self.get_class_type(),
+                                                                                  QUESTACTION.NUM_QUESTIONS),
+                                                             options) or 1)
+        if len(challengeobject_list) == 1:
+            session = int(challengeobject_list[0].session or "0") + 1
+            options["session"] = u"{0!s}".format(session)
+            # write the used questions to the data field
+            options["data"] = challengeobject_list[0].data or ""
+            if session < question_number:
+                return True
+        return False
 
     @staticmethod
     def get_setting_type(key):


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
@@ -3992,6 +3992,64 @@ def test_14_indexed_secret_multichallenge(self):
 
         remove_token(serial)
 
+    def test_15_questionnaire_multichallenge(self):
+        questionnaire = {"Question1": "Answer1",
+                         "Question2": "Answer2",
+                         "Question3": "Answer3",
+                         "Q4": "A4",
+                         "Q5": "A5"}
+        serial = "quest001"
+        found_questions = []
+        tok = init_token({"type": "question", "questions": questionnaire, "pin": "quest", "serial": serial},
+                         user=User("cornelius", self.realm1))
+
+        # We want two questions during authentication
+        set_policy(name="questpol", scope=SCOPE.AUTH, action="question_number=6")
+
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "cornelius", "pass": "quest"}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            self.assertFalse(result.get("value"))
+            detail = res.json.get("detail")
+            transaction_id = detail.get("transaction_id")
+            question = detail.get("message")
+            found_questions.append(question)
+
+        # Run 5 responses that require more
+        for i in range(0, 5):
+            with self.app.test_request_context('/validate/check', method='POST',
+                                               data={"user": "cornelius", "pass": questionnaire.get(question),
+                                                     "transaction_id": transaction_id}):
+                res = self.app.full_dispatch_request()
+                self.assertEqual(res.status_code, 200)
+                result = res.json['result']
+                self.assertFalse(result.get("value"))
+                detail = res.json.get("detail")
+                transaction_id = detail.get("transaction_id")
+                question = detail.get("message")
+                found_questions.append(question)
+
+        self.assertEqual(len(set(found_questions)), 5)
+
+        # Now we run the last resonse. It can be any of the 5 originial questions again.
+
+        # Sixth and last response will be successful
+        with self.app.test_request_context('/validate/check', method='POST',
+                                           data={"user": "cornelius", "pass": questionnaire.get(question),
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertEqual(res.status_code, 200)
+            result = res.json['result']
+            # Successful authentication!
+            self.assertTrue(result.get("value"))
+
+        # But we still have 5 distinct questions
+        self.assertEqual(len(set(found_questions)), 5)
+        remove_token(serial)
+        delete_policy("questpol")
+
 
 class TriggeredPoliciesTestCase(MyApiTestCase):