Repository: privacyidea/privacyidea

Issue:
Allow to add 3rd party token classes
**Is your feature request related to a problem? Please describe.**

It should be easy to enhance privacyIDEA with new token classes without changing the core code.

**Describe the solution you'd like**

The available token classes or rather token modules are currently gathered in ``lib/config.py`` in the function get_token_list.
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/config.py#L693

We could add a config parameter in the pi.cfg and provide a list of arbitrary 3rd party token modules.

The list of token modules already is reflected in the UI in the enrollment dialog.
And it is reflected in the policy in ``enroll<TOKENTYPE>``.

The enrollment UI is contained in the files in static/components/token/views:

* token.enroll.<TOKENTYPE>.html
* token.enrolled.<TOKENTYPE>.html

Thus adding a 3rd party token (without the requirement for more sophisticated configuration) would simply result in:

* place a python token module "somewhere" and add it in pi.cfg
* optionally provide the *enroll* template and place it in the privacyIDEA template directory
* optionally provide the *enrolled* template and place it in the privacyIDEA template directory

Since the temaplates have destinct names, that do not collide with the core code, updates would easily be possible.



Discussion:

Resolving patch:
diff --git a/privacyidea/lib/config.py b/privacyidea/lib/config.py
--- a/privacyidea/lib/config.py
+++ b/privacyidea/lib/config.py
@@ -43,6 +43,7 @@
 from ..models import (Config, db, Resolver, Realm, PRIVACYIDEA_TIMESTAMP,
                       save_config_timestamp, Policy, EventHandler)
 from privacyidea.lib.framework import get_request_local_store, get_app_config_value, get_app_local_store
+from privacyidea.lib.utils import to_list
 from .crypto import encryptPassword
 from .crypto import decryptPassword
 from .resolvers.UserIdResolver import UserIdResolver
@@ -689,7 +690,6 @@ def get_token_list():
     """
     module_list = set()
 
-    # TODO: migrate the implementations and uncomment
     module_list.add("privacyidea.lib.tokens.daplugtoken")
     module_list.add("privacyidea.lib.tokens.hotptoken")
     module_list.add("privacyidea.lib.tokens.motptoken")
@@ -716,24 +716,13 @@ def get_token_list():
     module_list.add("privacyidea.lib.tokens.pushtoken")
     module_list.add("privacyidea.lib.tokens.indexedsecrettoken")
     module_list.add("privacyidea.lib.tokens.webauthntoken")
-    #module_list.add(".tokens.tagespassworttoken")
-    #module_list.add(".tokens.vascotoken")
 
-    # Dynamic Resolver modules
-    # TODO: Migration
-    # config_modules = config.get("privacyideaResolverModules", '')
-    config_modules = None
-    log.debug("{0!s}".format(config_modules))
-    if config_modules:
-        # in the config *.ini files we have some line continuation slashes,
-        # which will result in ugly module names, but as they are followed by
-        # \n they could be separated as single entries by the following two
-        # lines
-        lines = config_modules.splitlines()
-        coco = ",".join(lines)
-        for module in coco.split(','):
-            if module.strip() != '\\':
-                module_list.add(module.strip())
+    # Dynamic token modules
+    dynamic_token_modules = get_app_config_value("PI_TOKEN_MODULES")
+    if dynamic_token_modules:
+        # In the pi.cfg you can specify a list or set of 3rd party token modules like
+        #    PI_TOKEN_MODULES = [ "myproj.tokens.tok1", "myproj.tokens.tok2" ]
+        module_list.update(to_list(dynamic_token_modules))
 
     return module_list
 
diff --git a/privacyidea/lib/utils/__init__.py b/privacyidea/lib/utils/__init__.py
--- a/privacyidea/lib/utils/__init__.py
+++ b/privacyidea/lib/utils/__init__.py
@@ -1434,3 +1434,18 @@ def determine_logged_in_userparams(logged_in_user, params):
         raise PolicyError(u"Unknown role: {}".format(role))
 
     return role, username, realm, admin_user, admin_realm
+
+
+def to_list(input):
+    """
+    Returns a list if either a list, a set or a single string is given.
+    If a single string is given, then it returns a list with this one element.
+
+    :param input: Can be a list a set or a string
+    :return: list of elements
+    """
+    if isinstance(input, list):
+        return input
+    if isinstance(input, set):
+        return list(input)
+    return [input]
\ No newline at end of file


Test patch:
diff --git a/tests/test_lib_config.py b/tests/test_lib_config.py
--- a/tests/test_lib_config.py
+++ b/tests/test_lib_config.py
@@ -152,6 +152,20 @@ def test_02_token(self):
         self.assertTrue(TotpTokenClass in r, r)
         self.assertTrue(HotpTokenClass in r, r)
 
+        # Test custom token types
+        with self.app_context:
+            self.app.config['PI_TOKEN_MODULES'] = 'tests.testdata.fancytoken'
+            r = get_token_list()
+            self.assertIn("tests.testdata.fancytoken", r, r)
+            mlist = get_token_module_list()
+            mod = importlib.import_module('tests.testdata.fancytoken')
+            self.assertTrue(mod in mlist, mlist)
+            (classes, types) = get_token_class_dict()
+            self.assertIn('tests.testdata.fancytoken.FancyTokenClass', classes, classes)
+            self.assertIn('tests.testdata.fancytoken.FancyTokenClass', types, types)
+            self.assertEqual(types['tests.testdata.fancytoken.FancyTokenClass'], 'fancy', types)
+            self.app.config.pop('PI_TOKEN_MODULES')
+
     def test_03_token_prefix(self):
         prefix = get_token_prefix("totp")
         self.assertTrue(prefix == "TOTP", prefix)
diff --git a/tests/test_lib_utils.py b/tests/test_lib_utils.py
--- a/tests/test_lib_utils.py
+++ b/tests/test_lib_utils.py
@@ -23,7 +23,8 @@
                                    convert_timestamp_to_utc, modhex_encode,
                                    modhex_decode, checksum, urlsafe_b64encode_and_unicode,
                                    check_ip_in_policy, split_pin_pass, create_tag_dict,
-                                   check_serial_valid, determine_logged_in_userparams)
+                                   check_serial_valid, determine_logged_in_userparams,
+                                   to_list)
 from privacyidea.lib.crypto import generate_password
 from datetime import timedelta, datetime
 from netaddr import IPAddress, IPNetwork, AddrFormatError
@@ -958,3 +959,15 @@ def mock_attribute(key):
         # Wrong entry, that cannot be processed
         self.assertRaises(Exception, compare_generic_condition,
                           "b!~100", mock_attribute, "Error {0!s}")
+
+    def test_34_to_list(self):
+        # Simple string
+        self.assertEqual(["Hallo"], to_list("Hallo"))
+        # check for a list
+        r = to_list(["Hallo", "Du", "da"])
+        self.assertIsInstance(r, list)
+        self.assertEqual(3, len(r))
+        # Check for a set
+        r = to_list({"Hallo", "Du", "da"})
+        self.assertIsInstance(r, list)
+        self.assertEqual(3, len(r))
diff --git a/tests/testdata/__init__.py b/tests/testdata/__init__.py
new file mode 100644
diff --git a/tests/testdata/fancytoken.py b/tests/testdata/fancytoken.py
new file mode 100644
--- /dev/null
+++ b/tests/testdata/fancytoken.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+
+""" Test module for a fancy token class """
+
+from privacyidea.lib.tokenclass import TokenClass
+
+
+class FancyTokenClass(TokenClass):
+    def __init__(self, db_token):
+        TokenClass.__init__(self, db_token)
+        self.set_type(u"fancy")
+        self.mode = ['authenticate']
+        self.hKeyRequired = False
+
+    @staticmethod
+    def get_class_type():
+        return 'fancy'
+
+    @staticmethod
+    def get_class_prefix():
+        return "PIFA"