Repository: privacyidea/privacyidea

Issue:
More flexible subscription checking
The subscription checking currently works on the string delimited by the first whitespace:

https://github.com/privacyidea/privacyidea/blob/4713ef94c6b9d78bfef79c6b66926bfd84056d3d/privacyidea/lib/subscriptions.py#L357

A UserAgent could look like this:   `NameOfPlugin/VersionOfPlugin NameOfApplication/VersionOfApplication`.

We should change the code as follows:

~~~~python
plugin_with_version = ua_str.split()[0]
plugin_name = plugin_with_version.split("/")[0]
check_subscription(plugin_name)
~~~~


Discussion:

Resolving patch:
diff --git a/privacyidea/api/lib/utils.py b/privacyidea/api/lib/utils.py
--- a/privacyidea/api/lib/utils.py
+++ b/privacyidea/api/lib/utils.py
@@ -29,7 +29,8 @@
                           AuthError, ERROR)
 from ...lib.log import log_with
 from privacyidea.lib import _
-from privacyidea.lib.utils import prepare_result, get_version, to_unicode
+from privacyidea.lib.utils import (prepare_result, get_version, to_unicode,
+                                   get_plugin_info_from_useragent)
 import time
 import logging
 import json
@@ -50,8 +51,8 @@
 # The following user-agents (with versions) do not need extra unquoting
 # TODO: we should probably switch this when we do not do the extra unquote anymore
 NO_UNQUOTE_USER_AGENTS = {
-    'privacyIDEA-LDAP-Proxy': None,
-    'simpleSAMLphp': None
+    'privacyidea-ldap-proxy': None,
+    'simplesamlphp': None
 }
 
 SESSION_KEY_LENGTH = 32
@@ -260,13 +261,12 @@ def check_unquote(request, data):
     :return: New dictionary with the possibly unquoted values
     :rtype: dict
     """
+    ua_name, ua_version, _ua_comment = get_plugin_info_from_useragent(request.user_agent.string)
     # if no user agent is available, we assume that we must unquote the data
-    if not request.user_agent.string:
+    if not ua_name:
         return {key: unquote(value) for (key, value) in data.items()}
 
-    ua_match = re.match(r'^(?P<agent>[a-zA-Z0-9_-]+)(/(?P<version>\d+[\d.]*)(\s.*)?)?',
-                        request.user_agent.string)
-    if ua_match and not ua_match.group('agent') in NO_UNQUOTE_USER_AGENTS:
+    if ua_name.lower() not in NO_UNQUOTE_USER_AGENTS:
         return {key: unquote(value) for (key, value) in data.items()}
     else:
         return copy(data)
diff --git a/privacyidea/lib/subscriptions.py b/privacyidea/lib/subscriptions.py
--- a/privacyidea/lib/subscriptions.py
+++ b/privacyidea/lib/subscriptions.py
@@ -29,6 +29,7 @@
 import datetime
 import random
 from .log import log_with
+from .utils import get_plugin_info_from_useragent
 from ..models import Subscription
 from privacyidea.lib.error import SubscriptionError
 from privacyidea.lib.token import get_tokens
@@ -351,12 +352,10 @@ def __call__(self, func):
         @functools.wraps(func)
         def check_subscription_wrapper(*args, **kwds):
             request = self.request
-            ua = request.user_agent
-            ua_str = "{0!s}".format(ua) or "unknown"
-            application = ua_str.split()[0]
+            ua_str = str(request.user_agent.string)
+            plugin_name = get_plugin_info_from_useragent(ua_str)[0]
             # check and raise if fails
-            #check_subscription("privacyidea")
-            check_subscription(application)
+            check_subscription(plugin_name)
             f_result = func(*args, **kwds)
             return f_result
 
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
@@ -915,10 +915,10 @@ def parse_legacy_time(ts, return_date=False):
     The new timestrings are of the format YYYY-MM-DDThh:mm+oooo.
     They contain the timezone offset!
 
-    Old legacy time strings are of format DD/MM/YY hh:mm without time zone 
+    Old legacy time strings are of format DD/MM/YY hh:mm without time zone
     offset.
 
-    This function parses string and returns the new formatted time string 
+    This function parses string and returns the new formatted time string
     including the timezone offset.
 
     :param ts:
@@ -1546,3 +1546,28 @@ def convert_imagefile_to_dataimage(imagepath):
     except FileNotFoundError:
         log.warning("The file {0!s} could not be found.".format(imagepath))
         return ""
+
+
+ua_re = re.compile(r'^(?P<agent>[a-zA-Z0-9_-]+)(/(?P<version>\d+[\d.]*))?(\s(?P<comment>.*))?')
+
+
+def get_plugin_info_from_useragent(useragent):
+    """
+    This gives you the plugin name and version from an useragent string.
+    See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent for
+    more information on the user-agent string
+
+    :param useragent: useragent string like 'NameOfPlugin/VersionOfPlugin NameOfApplication/VersionOfApplication'
+    :type useragent: str
+    :return: a tuple with (NameOfPlugin, VersionOfPlugin, Comment). For example: ('ExamplePlugin', 1.0, '')
+    :rtype: tuple
+    """
+    if not useragent:
+        log.info(f"No user-agent string given")
+        return "", None, None
+    ua_match = ua_re.match(useragent)
+    if ua_match:
+        return ua_match.group('agent'), ua_match.group('version'), ua_match.group('comment')
+    else:
+        log.info(f"Could not match user-agent string: {useragent}")
+        return "", None, None


Test patch:
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
@@ -24,7 +24,8 @@
                                    modhex_decode, checksum, urlsafe_b64encode_and_unicode,
                                    check_ip_in_policy, split_pin_pass, create_tag_dict,
                                    check_serial_valid, determine_logged_in_userparams,
-                                   to_list, parse_string_to_dict, convert_imagefile_to_dataimage)
+                                   to_list, parse_string_to_dict, convert_imagefile_to_dataimage,
+                                   get_plugin_info_from_useragent)
 from privacyidea.lib.crypto import generate_password
 from datetime import timedelta, datetime
 from netaddr import IPAddress, IPNetwork, AddrFormatError
@@ -1028,3 +1029,34 @@ def test_36_imagefile_to_dataimage(self):
         dataimage = convert_imagefile_to_dataimage(file)
         self.assertEqual("", dataimage)
 
+    def test_37_useragent_split(self):
+        user_agents = [
+            ("privacyidea-cp", "privacyidea-cp", None, None),
+            ("privacyidea-cp/2.0", "privacyidea-cp", "2.0", None),
+            ("Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 "
+             "Firefox/47.0", "Mozilla", "5.0",
+             "(Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"),
+            ("privacyIDEA-Keycloak", "privacyIDEA-Keycloak", None, None),
+            ("privacyIDEA-Keycloak/20 java/8", "privacyIDEA-Keycloak", "20", "java/8"),
+            ("PrivacyIDEA-ADFS", "PrivacyIDEA-ADFS", None, None),
+            ("simpleSAMLphp", "simpleSAMLphp", None, None),
+            ("simpleSAMLphp/1.2.3", "simpleSAMLphp", "1.2.3", None),
+            ("ownCloud", "ownCloud", None, None),
+            ("privacyIDEA-PAM", "privacyIDEA-PAM", None, None),
+            ("privacyIDEA-Shibboleth", "privacyIDEA-Shibboleth", None, None),
+            ("privacyIDEA-LDAP-Proxy", "privacyIDEA-LDAP-Proxy", None, None),
+            ("privacyIDEA-LDAP-Proxy/10", "privacyIDEA-LDAP-Proxy", "10", None),
+            # matching fails completely
+            ("#test", "", None, None),
+            # matching fails after first dot
+            ("1.2.3", "1", None, None),
+            # matching stops after missing version
+            ("SomePlugin/ Some Comment", "SomePlugin", None, None),
+            # no user-agent string given
+            ("", "", None, None),
+            (None, "", None, None)
+        ]
+
+        for val in user_agents:
+            res = get_plugin_info_from_useragent(val[0])
+            self.assertEqual(res, val[1:], res)