Repository: privacyidea/privacyidea

Issue:
Remote Token: use privacyIDEA server instead of URL
Instead of using a URL as ``remote.sever`` for a remote token we could use our privacyIDEA Server definitions. 

This way we keep everything in one place and could also easily check certificates.

Check this for 3.4
Remotes tokens have been used a lot, lately.


Discussion:

Resolving patch:
diff --git a/privacyidea/api/privacyideaserver.py b/privacyidea/api/privacyideaserver.py
--- a/privacyidea/api/privacyideaserver.py
+++ b/privacyidea/api/privacyideaserver.py
@@ -84,13 +84,15 @@ def list_privacyidea():
     server_list = get_privacyideaservers()
     for server in server_list:
         if g.logged_in_user.get("role") == "admin":
-            res[server.config.identifier] = {"url": server.config.url,
+            res[server.config.identifier] = {"id": server.config.id,
+                                             "url": server.config.url,
                                              "tls": server.config.tls,
                                              "description":
                                                  server.config.description}
         else:
-            # We do not pass any information to a normal user!
-            res[server.config.identifier] = {"url": "",
+            # We only pass limited information to the user
+            res[server.config.identifier] = {"id": server.config.id,
+                                             "url": "",
                                              "tls": "",
                                              "description": ""}
 
diff --git a/privacyidea/lib/privacyideaserver.py b/privacyidea/lib/privacyideaserver.py
--- a/privacyidea/lib/privacyideaserver.py
+++ b/privacyidea/lib/privacyideaserver.py
@@ -52,6 +52,35 @@ def __init__(self, db_privacyideaserver_object):
         """
         self.config = db_privacyideaserver_object
 
+    def validate_check(self, user, password, serial=None, realm=None, transaction_id=None, resolver=None):
+        """
+        Perform an HTTP validate/check request to the remote privacyIDEA
+        Server.
+
+        :param serial: The serial number of a token
+        :param user: The username
+        :param password: The password
+        :param realm: an optional realm, if it is not contained in the username
+        :param transaction_id:  an optional transaction_id.
+        :return: Tuple (HTTP response object, JSON response content)
+        """
+        data = {"pass": password}
+        if user:
+            data["user"] = user
+        if serial:
+            data["serial"] = serial
+        if realm:
+            data["realm"] = realm
+        if transaction_id:
+            data["transaction_id"] = transaction_id
+        if resolver:
+            data["resolver"] = resolver
+        response = requests.post(self.config.url + "/validate/check",
+                                 data=data,
+                                 verify=self.config.tls)
+
+        return response
+
     @staticmethod
     def request(config, user, password):
         """
@@ -85,16 +114,17 @@ def request(config, user, password):
 
 
 @log_with(log)
-def get_privacyideaserver(identifier):
+def get_privacyideaserver(identifier=None, id=None):
     """
-    This returns the RADIUSServer object of the RADIUSServer definition
+    This returns the RADIUSServer object of the privacyIDEA Server definition
     "identifier".
     In case the identifier does not exist, an exception is raised.
 
-    :param identifier: The name of the RADIUSserver definition
-    :return: A RADIUSServer Object
+    :param identifier: The name of the privacyIDEA Server definition
+    :param id: The database ID of the privacyIDEA Server definition
+    :return: A privacyIDEAServer Object
     """
-    server_list = get_privacyideaservers(identifier=identifier)
+    server_list = get_privacyideaservers(identifier=identifier, id=id)
     if not server_list:
         raise ConfigAdminError("The specified privacyIDEA Server configuration "
                                "does not exist.")
@@ -102,7 +132,7 @@ def get_privacyideaserver(identifier):
 
 
 @log_with(log)
-def get_privacyideaservers(identifier=None, url=None):
+def get_privacyideaservers(identifier=None, url=None, id=None):
     """
     This returns a list of all privacyIDEA Servers matching the criterion.
     If no identifier or url is provided, it will return a list of all 
@@ -113,15 +143,19 @@ def get_privacyideaservers(identifier=None, url=None):
         As the identifier is unique, providing an identifier will return a
         list with either one or no RADIUSServer
     :type identifier: basestring
-    :param server: The FQDN or IP address of the RADIUSServer
-    :type server: basestring
+    :param url: The FQDN or IP address of the RADIUSServer
+    :type url: basestring
+    :param id: The database id of the server
+    :type id: integer
     :return: list of RADIUSServer Objects.
     """
     res = []
     sql_query = PrivacyIDEAServerDB.query
-    if identifier:
+    if id is not None:
+        sql_query = sql_query.filter(PrivacyIDEAServerDB.id == id)
+    elif identifier:
         sql_query = sql_query.filter(PrivacyIDEAServerDB.identifier == identifier)
-    if url:
+    elif url:
         sql_query = sql_query.filter(PrivacyIDEAServerDB.server == url)
 
     for row in sql_query.all():
diff --git a/privacyidea/lib/tokens/remotetoken.py b/privacyidea/lib/tokens/remotetoken.py
--- a/privacyidea/lib/tokens/remotetoken.py
+++ b/privacyidea/lib/tokens/remotetoken.py
@@ -33,7 +33,7 @@
 __doc__ = """This is the implementation of the remote token. The remote token
 forwards an authentication request to another privacyidea server.
 
-To do this it uses the parameters remote.server, remote.realm,
+To do this it uses the parameters remote.server_id, remote.realm,
 remote.resolver, remote.user or remote.serial.
 The parameter remote.local_checkpin determines, whether the PIN should be
 checked locally or remotely.
@@ -51,6 +51,7 @@
 from privacyidea.lib.log import log_with
 from privacyidea.lib.policydecorators import challenge_response_allowed
 from privacyidea.lib.tokenclass import TokenClass, TOKENKIND
+from privacyidea.lib.privacyideaserver import get_privacyideaserver
 from privacyidea.lib import _
 from privacyidea.lib.policy import SCOPE, ACTION, GROUP
 
@@ -151,8 +152,15 @@ def update(self, param):
         self.set_otplen(6)
         TokenClass.update(self, param)
 
-        remoteServer = getParam(param, "remote.server", required)
-        self.add_tokeninfo("remote.server", remoteServer)
+        remote_server_id = getParam(param, "remote.server_id", optional)
+
+        if remote_server_id is not None:
+            if get_privacyideaserver(id=remote_server_id):
+                self.add_tokeninfo("remote.server_id", remote_server_id)
+        else:
+            remoteServer = getParam(param, "remote.server", required)
+            self.add_tokeninfo("remote.server", remoteServer)
+            log.warning("Using remote.server for remote tokens is deprecated. Use remote.server_id!")
 
         val = getParam(param, "remote.local_checkpin", optional) or 0
         self.add_tokeninfo("remote.local_checkpin", val)
@@ -233,49 +241,57 @@ def check_otp(self, otpval, counter=None, window=None, options=None):
         :rtype: int
         """
         otp_count = -1
+        pi_server_obj = None
+        remoteServer = None
 
-        remoteServer = self.get_tokeninfo("remote.server") or ""
-
-        # in preparation of the ability to relocate privacyidea urls,
-        # we introduce the remote url path
-        remotePath = self.get_tokeninfo("remote.path") or ""
-        remotePath = remotePath.strip()
+        remote_server_id = self.get_tokeninfo("remote.server_id")
+        if remote_server_id:
+            pi_server_obj = get_privacyideaserver(id=int(remote_server_id))
+        else:
+            # Deprecated
+            remoteServer = self.get_tokeninfo("remote.server") or ""
+            log.warning("Using remote.server for remote tokens is deprecated. Use remote.server_id!")
+            ssl_verify = get_from_config("remote.verify_ssl_certificate",
+                                         False, return_bool=True) or False
+            # in preparation of the ability to relocate privacyidea urls,
+            # we introduce the remote url path
+            remotePath = self.get_tokeninfo("remote.path") or ""
+            remotePath = remotePath.strip()
+            remotePath = remotePath or "/validate/check"
 
         remoteSerial = self.get_tokeninfo("remote.serial") or ""
-
         remoteUser = self.get_tokeninfo("remote.user") or ""
-
         remoteRealm = self.get_tokeninfo("remote.realm") or ""
-
         remoteResolver = self.get_tokeninfo("remote.resolver") or ""
 
-        ssl_verify = get_from_config("remote.verify_ssl_certificate",
-                                     False, return_bool=True) or False
-
         # here we also need to check for remote.user and so on....
         log.debug("checking OTP len:%r remotely on server: %r,"
                   " serial: %r, user: %r" %
                   (len(otpval), remoteServer, remoteSerial, remoteUser))
         params = {}
 
-        remotePath = remotePath or "/validate/check"
         if remoteSerial:
             params['serial'] = remoteSerial
         elif remoteUser:
             params['user'] = remoteUser
             params['realm'] = remoteRealm
             params['resolver'] = remoteResolver
-
         else:
             log.warning("The remote token does neither contain a "
                         "remote.serial nor a remote.user.")
             return otp_count
 
-        params['pass'] = otpval
-        request_url = "{0!s}{1!s}".format(remoteServer, remotePath)
-
         try:
-            r = requests.post(request_url, data=params, verify=ssl_verify)
+            if remoteServer:
+                # Deprecated
+                params['pass'] = otpval
+                request_url = "{0!s}{1!s}".format(remoteServer, remotePath)
+                r = requests.post(request_url, data=params, verify=ssl_verify)
+            elif pi_server_obj:
+                r = pi_server_obj.validate_check(remoteUser, otpval,
+                                                 serial=remoteSerial,
+                                                 realm=remoteRealm,
+                                                 resolver=remoteResolver)
 
             if r.status_code == requests.codes.ok:
                 response = r.json()


Test patch:
diff --git a/tests/test_lib_privacyideaserver.py b/tests/test_lib_privacyideaserver.py
--- a/tests/test_lib_privacyideaserver.py
+++ b/tests/test_lib_privacyideaserver.py
@@ -132,3 +132,77 @@ def test_04_privacyidea_request(self):
         r = PrivacyIDEAServer.request(pi.config, "user", "password")
         self.assertFalse(r)
 
+    @responses.activate
+    def test_05_privacyidea_validate_check(self):
+        responses.add(responses.POST, "https://privacyidea/pi/validate/check",
+                      body="""{
+            "jsonrpc": "2.0",
+            "signature": "8714492288983608958721372435263469282038130269793819687538718333085851022315074567013564786433032592569773009757668260857150988825993253128403096686276017572870299270974318705442428477018018734211619614135162719525545735285162164985627482472020309913143284756699606758573589339750891246114721488327919685939018812698986042837837048205963507243718362073386749929275433723467277740468538209437683755941724140343215877868596281187733952567488886126455218397004400817126119660003078762499546137083926344365458736163867631154552432520453852071998486914168310985851091111203094188983006153929089352703802214328258347608348",
+            "detail": null,
+            "version": "privacyIDEA 2.20.dev2",
+            "result": {
+              "status": true,
+              "value": true},
+            "time": 1503561105.028947,
+            "id": 1
+                }""",
+                      content_type="application/json")
+
+        responses.add(responses.POST, "https://privacyidea/pi2/validate/check",
+                      body="""{
+        "jsonrpc": "2.0",
+        "signature": "8714492288983608958721372435263469282038130269793819687538718333085851022315074567013564786433032592569773009757668260857150988825993253128403096686276017572870299270974318705442428477018018734211619614135162719525545735285162164985627482472020309913143284756699606758573589339750891246114721488327919685939018812698986042837837048205963507243718362073386749929275433723467277740468538209437683755941724140343215877868596281187733952567488886126455218397004400817126119660003078762499546137083926344365458736163867631154552432520453852071998486914168310985851091111203094188983006153929089352703802214328258347608348",
+        "detail": null,
+        "version": "privacyIDEA 2.20.dev2",
+        "result": {
+          "status": false},
+        "time": 1503561105.028947,
+        "id": 1
+        }""",
+                      content_type="application/json",
+                      status=404)
+
+        responses.add(responses.POST, "https://privacyidea/pi3/validate/check",
+                      body="""{
+                "jsonrpc": "2.0",
+                "signature": "8714492288983608958721372435263469282038130269793819687538718333085851022315074567013564786433032592569773009757668260857150988825993253128403096686276017572870299270974318705442428477018018734211619614135162719525545735285162164985627482472020309913143284756699606758573589339750891246114721488327919685939018812698986042837837048205963507243718362073386749929275433723467277740468538209437683755941724140343215877868596281187733952567488886126455218397004400817126119660003078762499546137083926344365458736163867631154552432520453852071998486914168310985851091111203094188983006153929089352703802214328258347608348",
+                "detail": null,
+                "version": "privacyIDEA 2.20.dev2",
+                "result": {
+                  "status": true,
+                  "value": false},
+                "time": 1503561105.028947,
+                "id": 1
+                }""",
+                      content_type="application/json",
+                      status=404)
+
+        # successful authentication
+        r = add_privacyideaserver(identifier="pi", url="https://privacyidea/pi",
+                                  tls=False)
+        self.assertTrue(r > 0)
+        pi = get_privacyideaserver("pi")
+        r = pi.validate_check("user", "password")
+        self.assertEqual(r.status_code, 200)
+        self.assertTrue(r.json().get("result").get("value"))
+
+        # failed  authentication 404
+        r = add_privacyideaserver(identifier="pi2",
+                                  url="https://privacyidea/pi2",
+                                  tls=False)
+        self.assertTrue(r > 0)
+        pi = get_privacyideaserver("pi2")
+        r = pi.validate_check("user", "password", realm="realmA")
+        self.assertEqual(r.status_code, 404)
+        self.assertFalse(r.json().get("result").get("value"))
+
+        # failed  authentication value=false
+        r = add_privacyideaserver(identifier="pi3",
+                                  url="https://privacyidea/pi3",
+                                  tls=False)
+        self.assertTrue(r > 0)
+        pi = get_privacyideaserver("pi3")
+        r = pi.validate_check("user", "password", transaction_id=102)
+        self.assertEqual(404, r.status_code)
+        self.assertFalse(r.json().get("result").get("value"))
+
diff --git a/tests/test_lib_tokens_remote.py b/tests/test_lib_tokens_remote.py
--- a/tests/test_lib_tokens_remote.py
+++ b/tests/test_lib_tokens_remote.py
@@ -9,6 +9,9 @@
 import responses
 import json
 from privacyidea.lib.config import set_privacyidea_config
+from privacyidea.lib.token import remove_token
+from privacyidea.lib.error import ConfigAdminError
+from privacyidea.lib.privacyideaserver import add_privacyideaserver
 
 
 class RemoteTokenTestCase(MyTestCase):
@@ -204,3 +207,35 @@ def test_11_check_challenge_response(self):
                                            options={"transactionid": "1234"})
 
         self.assertTrue(r)
+
+    def test_20_create_remote_token_non_existing_remote_server(self):
+        remove_token(self.serial3)
+        db_token = Token(self.serial3, tokentype="remote")
+        db_token.save()
+        token = RemoteTokenClass(db_token)
+        # Updating the token with a non-existing remote.server_id raises an error
+        self.assertRaises(ConfigAdminError, token.update, {"remote.server_id": 12})
+
+    @responses.activate
+    def test_21_create_remote_token_with_remote_server_id(self):
+        r_server_id = add_privacyideaserver("myRemote", "https://localhost")
+        self.assertTrue(r_server_id >= 0)
+        remove_token(self.serial3)
+        db_token = Token(self.serial3, tokentype="remote")
+        db_token.save()
+        token = RemoteTokenClass(db_token)
+        token.update({"remote.server_id": r_server_id,
+                      "remote.serial": "123456"})
+        ti = token.get_tokeninfo()
+        self.assertEqual(r_server_id, int(ti.get("remote.server_id")))
+
+        # now we verify the remote token with a server id.
+        responses.add(responses.POST,
+                      "https://localhost/validate/check",
+                      body=json.dumps(self.success_body),
+                      content_type="application/json")
+        r = token.authenticate("test")
+        # Result is True
+        self.assertEqual(True, r[0])
+        # OTP counter is 1
+        self.assertEqual(1, r[1])
\ No newline at end of file