Repository: privacyidea/privacyidea

Issue:
Token description requirement prohibits verify_enrollment
### Top-level intent

When description is reqired for a token type the description is also required in the verification state. There is no Field to enter the description there so we can not enrol a token

### Steps to reproduce

1. Set `verify_enrollment` and `require_description` to totp
2. Start enrolling a totp token and provide a description in the first step
3. Provide the correct OTP in the verification step

### Expected outcome

The token should be enrolled

### Actual outcome

I can not enroll the token. I get an error that description is required for totp.

### Configuration

* **privacyIDEA version**: 3.9
* **Installation method**: Pypi
* **Python version**: 3.6.8
* **Operating system**: Red Hat Enterprise Linux 8
* **Webserver**: Apache
* **Token database**: Mariadb

### Fix

Adding the following code after [prepolicy.py#L2234](https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/lib/prepolicy.py#L2234) seems to fix the problem for me:

```
serial = getParam(request.all_data, "serial", optional)
    if serial:
        type_value = get_token_type(serial)
        # Only now, we check if we need to verify
        tokenobj_list = get_tokens(serial=serial)
        if len(tokenobj_list) == 1:
            tokenobj = tokenobj_list[0]
            if tokenobj.rollout_state in [ ROLLOUTSTATE.CLIENTWAIT, ROLLOUTSTATE.VERIFYPENDING ] :
                return;
```



Discussion:
Hi @cbergmann i can confirm this issue. Will look into it.

Resolving patch:
diff --git a/privacyidea/api/lib/prepolicy.py b/privacyidea/api/lib/prepolicy.py
--- a/privacyidea/api/lib/prepolicy.py
+++ b/privacyidea/api/lib/prepolicy.py
@@ -69,7 +69,7 @@
 
 from OpenSSL import crypto
 from privacyidea.lib import _
-from privacyidea.lib.error import PolicyError, RegistrationError, TokenAdminError, ResourceNotFoundError
+from privacyidea.lib.error import PolicyError, RegistrationError, TokenAdminError, ResourceNotFoundError, ParameterError
 from flask import g, current_app
 from privacyidea.lib.policy import SCOPE, ACTION, REMOTE_USER
 from privacyidea.lib.policy import Match, check_pin
@@ -87,6 +87,7 @@
 from privacyidea.lib.config import (get_token_class)
 from privacyidea.lib.tokenclass import ROLLOUTSTATE
 from privacyidea.lib.tokens.certificatetoken import ACTION as CERTIFICATE_ACTION
+from privacyidea.lib.token import get_one_token
 import functools
 import jwt
 import re
@@ -2233,6 +2234,12 @@ def require_description(request=None, action=None):
     token_types = list(action_values.keys())
     type_value = request.all_data.get("type") or 'hotp'
     if type_value in token_types:
-        if not request.all_data.get("description"):
+        tok = None
+        serial = getParam(params, "serial")
+        if serial:
+            tok = get_one_token(serial=serial, rollout_state=ROLLOUTSTATE.VERIFYPENDING, silent_fail=True) or \
+                  get_one_token(serial=serial, rollout_state=ROLLOUTSTATE.CLIENTWAIT, silent_fail=True)
+        # only if no token exists, yet, we need to check the description
+        if not tok and not request.all_data.get("description"):
             log.warning(_("Missing description for {} token.".format(type_value)))
             raise PolicyError(_("Description required for {} token.".format(type_value)))
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -554,17 +554,25 @@ def get_tokens_paginate(tokentype=None, realm=None, assigned=None, user=None,
     return ret
 
 
-def get_one_token(*args, **kwargs):
+def get_one_token(*args, silent_fail=False, **kwargs):
     """
     Fetch exactly one token according to the given filter arguments, which are passed to
     ``get_tokens``. Raise ``ResourceNotFoundError`` if no token was found. Raise
     ``ParameterError`` if more than one token was found.
+
+    :param silent_fail: Instead of raising an exception we return None silently
+    :returns: Token object
     """
     result = get_tokens(*args, **kwargs)
     if not result:
+        if silent_fail:
+            return None
         raise ResourceNotFoundError(_("The requested token could not be found."))
     elif len(result) > 1:
-        raise ParameterError(_("More than one matching token were found."))
+        if silent_fail:
+            log.warning("More than one matching token was found.")
+            return None
+        raise ParameterError(_("More than one matching token was found."))
     else:
         return result[0]
 


Test patch:
diff --git a/tests/test_api_token.py b/tests/test_api_token.py
--- a/tests/test_api_token.py
+++ b/tests/test_api_token.py
@@ -2552,12 +2552,24 @@ def test_34_hide_tokeninfo_admin(self):
     def test_40_init_verify_hotp_token(self):
         set_policy("verify_toks1", scope=SCOPE.ENROLL, action="{0!s}=hotp top".format(ACTION.VERIFY_ENROLLMENT))
         set_policy("verify_toks2", scope=SCOPE.ENROLL, action="{0!s}=HOTP email".format(ACTION.VERIFY_ENROLLMENT))
+        set_policy("require_description", scope=SCOPE.ENROLL, action="{0!s}=hotp".format(ACTION.REQUIRE_DESCRIPTION))
         # Enroll an HOTP token
         with self.app.test_request_context('/token/init',
                                            method='POST',
                                            data={"otpkey": self.otpkey},
                                            headers={'Authorization': self.at}):
             res = self.app.full_dispatch_request()
+            self.assertEqual(403, res.status_code)
+            result = res.json.get("result")
+            self.assertFalse(result.get("status"))
+            self.assertEqual("Description required for hotp token.", result.get("error").get("message"))
+
+        with self.app.test_request_context('/token/init',
+                                           method='POST',
+                                           data={"otpkey": self.otpkey,
+                                                 "description": "something"},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
             detail = res.json.get("detail")
             result = res.json.get("result")
@@ -2577,7 +2589,7 @@ def test_40_init_verify_hotp_token(self):
                                                  'pass': self.valid_otp_values[0]}):
             res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
-            # we fail to authenticate with a token, that in in state verify_pending
+            # we fail to authenticate with a token, that is in state verify_pending
             result = res.json.get("result")
             detail = res.json.get("detail")
             self.assertFalse(result.get("value"))
@@ -2590,7 +2602,7 @@ def test_40_init_verify_hotp_token(self):
                                                  "verify": "111111"},
                                            headers={'Authorization': self.at}):
             res = self.app.full_dispatch_request()
-            self.assertEqual(res.status_code, 400)
+            self.assertEqual(400, res.status_code)
             result = res.json.get("result")
             self.assertFalse(result.get("status"))
             self.assertEqual(result.get("error").get("code"), 905)
@@ -2614,6 +2626,7 @@ def test_40_init_verify_hotp_token(self):
 
         delete_policy("verify_toks1")
         delete_policy("verify_toks2")
+        delete_policy("require_description")
 
     def test_41_init_verify_email_token(self):
         set_policy("verify_toks1", scope=SCOPE.ENROLL, action="{0!s}=email".format(ACTION.VERIFY_ENROLLMENT))