Repository: privacyidea/privacyidea

Issue:
Unset validity_period_end (and other tokeninfo)
It would be useful to be able to unset the validity_period_time (and probably other fields if it's easy enough).
My use case is: there are two steps to enroll a U2F device. I'd like to set an expiration date on the token for the first step so that I can remove all U2F enrollments that never completed easily. When enrollment is completed, I want to unset that expiration time.
A workaround would be to set it to a date far in the future, but that's not very elegant.


Discussion:
There are at least two possibilities to do this.

Either setting an empty value (but this would probably leave the tokeninfo entry, just with an empty value) or deleting the entry with a call like

    DELETE /token/set/<serial>?key=tokeninfo_key

or 

    DELETE /token/set/<serial>/<tokeninfo_key>

Resolving patch:
diff --git a/privacyidea/api/token.py b/privacyidea/api/token.py
--- a/privacyidea/api/token.py
+++ b/privacyidea/api/token.py
@@ -65,7 +65,7 @@
                          set_hashlib, set_max_failcount, set_realms,
                          copy_token_user, copy_token_pin, lost_token,
                          get_serial_by_otp, get_tokens,
-                         set_validity_period_end, set_validity_period_start)
+                         set_validity_period_end, set_validity_period_start, add_tokeninfo, delete_tokeninfo)
 from werkzeug.datastructures import FileStorage
 from cgi import FieldStorage
 from privacyidea.lib.error import (ParameterError, TokenAdminError)
@@ -997,3 +997,49 @@ def get_serial_by_otp_api(otp=None):
 
     return send_result({"serial": serial,
                         "count": count})
+
+
+@token_blueprint.route('/info/<serial>/<key>', methods=['POST'])
+@prepolicy(check_base_action, request, action=ACTION.SETTOKENINFO)
+@event("token_info", request, g)
+@log_with(log)
+@admin_required
+def set_tokeninfo_api(serial, key):
+    """
+    Add a specific tokeninfo entry to a token. Already existing entries
+    with the same key are overwritten.
+
+    :param serial: the serial number/identifier of the token
+    :param key: token info key that should be set
+    :query value: token info value that should be set
+    :return: returns value=True in case the token info could be set
+    :rtype: bool
+    """
+    value = getParam(request.all_data, "value", required)
+    g.audit_object.log({"serial": serial})
+    count = add_tokeninfo(serial, key, value)
+    success = count > 0
+    g.audit_object.log({"success": success})
+    return send_result(success)
+
+
+@token_blueprint.route('/info/<serial>/<key>', methods=['DELETE'])
+@prepolicy(check_base_action, request, action=ACTION.SETTOKENINFO)
+@event("token_info", request, g)
+@log_with(log)
+@admin_required
+def delete_tokeninfo_api(serial, key):
+    """
+    Delete a specific tokeninfo entry of a token.
+
+    :param serial: the serial number/identifier of the token
+    :param key: token info key that should be deleted
+    :return: returns value=True in case a matching token was found, which does not necessarily mean
+    that the matching token had a tokeninfo value set in the first place.
+    :rtype: bool
+    """
+    g.audit_object.log({"serial": serial})
+    count = delete_tokeninfo(serial, key)
+    success = count > 0
+    g.audit_object.log({"success": success})
+    return send_result(success)
\ No newline at end of file
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -261,6 +261,7 @@ class ACTION(object):
     SYSTEMDELETE = "configdelete"
     SYSTEMWRITE = "configwrite"
     CONFIGDOCUMENTATION = "system_documentation"
+    SETTOKENINFO = "settokeninfo"
     TOKENISSUER = "tokenissuer"
     TOKENLABEL = "tokenlabel"
     TOKENPAGESIZE = "token_page_size"
@@ -1026,6 +1027,10 @@ def get_static_policy_definitions(scope=None):
                                 'tokens.'),
                             'mainmenu': [MAIN_MENU.TOKENS],
                             'group': GROUP.TOKEN},
+            ACTION.SETTOKENINFO: {'type': 'bool',
+                               'desc': _('Admin is allowed to manually set and delete token info.'),
+                               'mainmenu': [MAIN_MENU.TOKENS],
+                               'group': GROUP.TOKEN},
             ACTION.ENROLLPIN: {'type': 'bool',
                                "desc": _("Admin is allowed to set the OTP "
                                          "PIN during enrollment."),
diff --git a/privacyidea/lib/token.py b/privacyidea/lib/token.py
--- a/privacyidea/lib/token.py
+++ b/privacyidea/lib/token.py
@@ -1437,6 +1437,30 @@ def add_tokeninfo(serial, info, value=None,
     return len(tokenobject_list)
 
 
+@log_with(log)
+@check_user_or_serial
+def delete_tokeninfo(serial, key, user=None):
+    """
+    Delete a specific token info field in the database.
+
+    :param serial: The serial number of the token
+    :type serial: basestring
+    :param key: The key of the info in the dict
+    :param value: The value of the info
+    :param user: The owner of the tokens, that should be modified
+    :type user: User object
+    :return: the number of tokens matching the serial and user. This number also includes tokens that did not have
+    the token info *key* set in the first place!
+    :rtype: int
+    """
+    tokenobject_list = get_tokens(serial=serial, user=user)
+    for tokenobject in tokenobject_list:
+        tokenobject.del_tokeninfo(key)
+        tokenobject.save()
+
+    return len(tokenobject_list)
+
+
 @log_with(log)
 @check_user_or_serial
 def set_validity_period_start(serial, user, start):


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
@@ -1183,3 +1183,107 @@ def test_23_change_pin_on_first_use(self):
             self.assertEqual(ti, ndate)
 
         delete_policy("firstuse")
+
+    def test_24_modify_tokeninfo(self):
+        self._create_temp_token("INF001")
+        # Set two tokeninfo values
+        with self.app.test_request_context('/token/info/INF001/key1',
+                                            method="POST",
+                                            data={"value": "value 1"},
+                                            headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertTrue(result.get("value"), result)
+        with self.app.test_request_context('/token/info/INF001/key2',
+                                            method="POST",
+                                            data={"value": "value 2"},
+                                            headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertTrue(result.get("value"), result)
+
+        with self.app.test_request_context('/token/',
+                                           method="GET",
+                                           query_string=urlencode(
+                                                   {"serial": "INF001"}),
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            value = result.get("value")
+            token = value.get("tokens")[0]
+            self.assertTrue(value.get("count") == 1, result)
+
+            tokeninfo = token.get("info")
+            self.assertDictContainsSubset({'key1': 'value 1', 'key2': 'value 2'}, tokeninfo)
+
+        # Overwrite an existing tokeninfo value
+        with self.app.test_request_context('/token/info/INF001/key1',
+                                            method="POST",
+                                            data={"value": 'value 1 new'},
+                                            headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertTrue(result.get("value"), result)
+
+        with self.app.test_request_context('/token/',
+                                           method="GET",
+                                           query_string=urlencode(
+                                                   {"serial": "INF001"}),
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            value = result.get("value")
+            token = value.get("tokens")[0]
+            self.assertTrue(value.get("count") == 1, result)
+
+            tokeninfo = token.get("info")
+            self.assertDictContainsSubset({'key1': 'value 1 new', 'key2': 'value 2'}, tokeninfo)
+
+        # Delete an existing tokeninfo value
+        with self.app.test_request_context('/token/info/INF001/key1',
+                                           method="DELETE",
+                                           headers={'Authorization': self.at}):
+           res = self.app.full_dispatch_request()
+           self.assertTrue(res.status_code == 200, res)
+           result = json.loads(res.data).get("result")
+           self.assertTrue(result.get("value"), result)
+
+        # Delete a non-existing tokeninfo value
+        with self.app.test_request_context('/token/info/INF001/key1',
+                                            method="DELETE",
+                                            headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertTrue(result.get("value"), result)
+
+        # Try to delete with an unknown serial
+        with self.app.test_request_context('/token/info/UNKNOWN/key1',
+                                            method="DELETE",
+                                            headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            self.assertTrue(result.get("value") == False, result)
+
+        # Check that the tokeninfo is correct
+        with self.app.test_request_context('/token/',
+                                           method="GET",
+                                           query_string=urlencode(
+                                               {"serial": "INF001"}),
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = json.loads(res.data).get("result")
+            value = result.get("value")
+            token = value.get("tokens")[0]
+            self.assertTrue(value.get("count") == 1, result)
+
+            tokeninfo = token.get("info")
+            self.assertDictContainsSubset({'key2': 'value 2'}, tokeninfo)
+            self.assertNotIn('key1', tokeninfo)
\ No newline at end of file
diff --git a/tests/test_lib_token.py b/tests/test_lib_token.py
--- a/tests/test_lib_token.py
+++ b/tests/test_lib_token.py
@@ -51,7 +51,7 @@
                                    get_dynamic_policy_definitions,
                                    get_tokens_paginate,
                                    set_validity_period_end,
-                                   set_validity_period_start, remove_token)
+                                   set_validity_period_start, remove_token, delete_tokeninfo)
 
 from privacyidea.lib.error import (TokenAdminError, ParameterError,
                                    privacyIDEAError)
@@ -515,13 +515,26 @@ def test_24_set_count_auth(self):
         self.assertTrue(tinfo.get("count_auth_success") == "102", tinfo)
         self.assertTrue(tinfo.get("count_auth_success_max") == "103", tinfo)
 
-    def test_25_add_tokeninfo(self):
+    def test_25_add_delete_tokeninfo(self):
         serial = "t1"
         tokenobject = init_token({"serial": serial, "genkey": 1})
         r = add_tokeninfo(serial, "something", "new")
         self.assertTrue(r == 1, r)
-        tinfo = tokenobject.token.get_info()
-        self.assertTrue(tinfo.get("something") == "new", tinfo)
+        tinfo1 = tokenobject.token.get_info()
+        self.assertTrue(tinfo1.get("something") == "new", tinfo1)
+        # delete existing tokeninfo entry
+        r = delete_tokeninfo(serial, "something")
+        self.assertEqual(r, 1)
+        tinfo2 = tokenobject.token.get_info()
+        self.assertNotIn("something", tinfo2)
+        # delete non-existing tokeninfo entry
+        r = delete_tokeninfo(serial, "somethingelse")
+        self.assertEqual(r, 1) # this still returns 1, because 1 token was matched!
+        # tokeninfo has not changed
+        self.assertEqual(tokenobject.token.get_info(), tinfo2)
+        # try to delete non-existing tokeninfo
+        r = delete_tokeninfo('UNKNOWN-SERIAL', 'something')
+        self.assertEqual(r, 0)
         remove_token(serial)
 
     def test_26_set_sync_window(self):