Repository: privacyidea/privacyidea

Issue:
RADIUS token should support RADIUS challenge response.
The RADIUS token forwards the authentication request to a RADIUS server.
The RADIUS server can answer with a ``AccessAccept`` or ``AccessReject``. privacyIDEA will allow authentication or not.

But a RADIUS server could also answer with ``AccessChallenge`` and return a state. Currently we do not support this.

We should support the RADIUS challenge response, the returned state would be written to the challenge database as data, the privacyIDEA client will get it's own transaction_id.

When the second request is sent to the RADIUS server, this ``AccessRequest`` will contain the ``state`` that was stored in the challenge database.


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/tokens/radiustoken.py b/privacyidea/lib/tokens/radiustoken.py
--- a/privacyidea/lib/tokens/radiustoken.py
+++ b/privacyidea/lib/tokens/radiustoken.py
@@ -1,5 +1,9 @@
 # -*- coding: utf-8 -*-
 #
+#  2019-08-15 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#             Allow RADIUS challenge / response
+#             Credits to @droobah, who provided the first pull request
+#             https://github.com/privacyidea/privacyidea/pull/1389
 #  2018-01-21 Cornelius Kölbel <cornelius.koelbel@netknights.it>
 #             Add tokenkind
 #  2016-02-22 Cornelius Kölbel <cornelius@privacyidea.org>
@@ -46,20 +50,23 @@
 import traceback
 import binascii
 from privacyidea.lib.utils import is_true, to_bytes, hexlify_and_unicode, to_unicode
-from privacyidea.lib.tokenclass import TokenClass, TOKENKIND
 from privacyidea.lib.tokens.remotetoken import RemoteTokenClass
+from privacyidea.lib.tokenclass import TokenClass, TOKENKIND
 from privacyidea.api.lib.utils import getParam, ParameterError
 from privacyidea.lib.log import log_with
 from privacyidea.lib.config import get_from_config
 from privacyidea.lib.decorators import check_token_locked
 from privacyidea.lib.radiusserver import get_radius
+from privacyidea.models import Challenge
+from privacyidea.lib.challenge import get_challenges
+from privacyidea.lib.policydecorators import challenge_response_allowed
 
 import pyrad.packet
 from pyrad.client import Client
 from pyrad.dictionary import Dictionary
+from pyrad.packet import AccessChallenge, AccessAccept, AccessReject
 from privacyidea.lib import _
 
-
 optional = True
 required = False
 
@@ -99,7 +106,7 @@ def get_class_info(key=None, ret='all'):
                'title': 'RADIUS Token',
                'description': _('RADIUS: Forward authentication request to a '
                                 'RADIUS server.'),
-               'user':  ['enroll'],
+               'user': ['enroll'],
                # This tokentype is enrollable in the UI for...
                'ui_enroll': ["admin", "user"],
                'policy': {},
@@ -113,6 +120,7 @@ def get_class_info(key=None, ret='all'):
 
         return ret
 
+    @log_with(log)
     def update(self, param):
         # New value
         radius_identifier = getParam(param, "radius.identifier")
@@ -137,11 +145,190 @@ def update(self, param):
         TokenClass.update(self, param)
         val = getParam(param, "radius.local_checkpin", optional) or 0
         self.add_tokeninfo("radius.local_checkpin", val)
-
         val = getParam(param, "radius.user", required)
         self.add_tokeninfo("radius.user", val)
         self.add_tokeninfo("tokenkind", TOKENKIND.VIRTUAL)
 
+    @log_with(log)
+    @challenge_response_allowed
+    def is_challenge_request(self, passw, user=None, options=None):
+        """
+        This method checks, if this is a request, that triggers a challenge.
+        It depends on the way, the pin is checked - either locally or remotely.
+        In addition, the RADIUS token has to be configured to allow challenge response.
+
+        communication with RADIUS server: yes
+        modification of options: The communication with the RADIUS server can
+            change the options, radius_state, radius_result, radius_message
+
+        :param passw: password, which might be pin or pin+otp
+        :type passw: string
+        :param user: The user from the authentication request
+        :type user: User object
+        :param options: dictionary of additional request parameters
+        :type options: dict
+
+        :return: true or false
+        """
+        if options is None:
+            options = {}
+
+        # should we check the pin locally?
+        if self.check_pin_local:
+            # With a local PIN the challenge response is always a privacyIDEA challenge response!
+            res = self.check_pin(passw)
+            return res
+
+        else:
+            state = options.get('radius_state')
+            # The pin is checked remotely
+            res = options.get('radius_result')
+            if res is None:
+                res = self._check_radius(passw, options=options, radius_state=state)
+
+            return res == AccessChallenge
+
+    @log_with(log)
+    def create_challenge(self, transactionid=None, options=None):
+        """
+        create a challenge, which is submitted to the user
+
+        This method is called after ``is_challenge_request`` has verified,
+        that a challenge needs to be created.
+
+        communication with RADIUS server: no
+        modification of options: no
+
+        :param transactionid: the id of this challenge
+        :param options: the request context parameters / data
+        :return: tuple of (bool, message and data)
+                 bool, if submit was successful
+                 message is submitted to the user
+                 data is preserved in the challenge
+                 attributes - additional attributes, which are displayed in the
+                    output
+        """
+        if options is None:
+            options = {}
+        message = options.get('radius_message') or "Enter your RADIUS tokencode:"
+        state = binascii.hexlify(options.get('radius_state') or b'')
+        attributes = {'state': transactionid}
+        validity = int(get_from_config('DefaultChallengeValidityTime', 120))
+
+        db_challenge = Challenge(self.token.serial,
+                                 transaction_id=transactionid,
+                                 data=state,
+                                 challenge=message,
+                                 validitytime=validity)
+        db_challenge.save()
+        self.challenge_janitor()
+        return True, message, db_challenge.transaction_id, attributes
+
+    @log_with(log)
+    def is_challenge_response(self, passw, user=None, options=None):
+        """
+        This method checks, if this is a request, that is the response to
+        a previously sent challenge. But we do not query the RADIUS server.
+
+        This is the first method in the loop ``check_token_list``.
+
+        communication with RADIUS server: no
+        modification of options: The "radius_result" key is set to None
+
+        :param passw: password, which might be pin or pin+otp
+        :type passw: string
+        :param user: the requesting user
+        :type user: User object
+        :param options: dictionary of additional request parameters
+        :type options: dict
+        :return: true or false
+        :rtype: bool
+        """
+        if options is None:
+            options = {}
+
+        challenge_response = False
+
+        # clear the radius_result since this is the first function called in the chain
+        # this value will be utilized to ensure we do not _check_radius more than once in the loop
+        options.update({'radius_result': None})
+
+        # fetch the transaction_id
+        transaction_id = options.get('transaction_id')
+        if transaction_id is None:
+            transaction_id = options.get('state')
+
+        if transaction_id:
+            # get the challenges for this transaction ID
+            challengeobject_list = get_challenges(serial=self.token.serial,
+                                                  transaction_id=transaction_id)
+
+            for challengeobject in challengeobject_list:
+                if challengeobject.is_valid():
+                    challenge_response = True
+
+        return challenge_response
+
+    @log_with(log)
+    @check_token_locked
+    def check_challenge_response(self, user=None, passw=None, options=None):
+        """
+        This method verifies if there is a matching question for the given
+        passw and also verifies if the answer is correct.
+
+        It then returns the the otp_counter = 1
+
+        :param user: the requesting user
+        :type user: User object
+        :param passw: the password - in fact it is the answer to the question
+        :type passw: string
+        :param options: additional arguments from the request, which could
+                        be token specific. Usually "transaction_id"
+        :type options: dict
+        :return: return otp_counter. If -1, challenge does not match
+        :rtype: int
+        """
+        if options is None:
+            options = {}
+        otp_counter = -1
+
+        # fetch the transaction_id
+        transaction_id = options.get('transaction_id') or options.get('state')
+
+        # get the challenges for this transaction ID
+        if transaction_id is not None:
+            challengeobject_list = get_challenges(serial=self.token.serial,
+                                                  transaction_id=transaction_id)
+
+            for challengeobject in challengeobject_list:
+                if challengeobject.is_valid():
+                    state = binascii.unhexlify(challengeobject.data)
+
+                    # challenge is still valid
+                    radius_response = self._check_radius(passw, options=options, radius_state=state)
+                    if radius_response == AccessAccept:
+                        # We found the matching challenge,
+                        # and the RADIUS server returned AccessAccept
+                        challengeobject.delete()
+                        otp_counter = 1
+                        break
+                    elif radius_response == AccessChallenge:
+                        # The response was valid but triggered a new challenge
+                        # Note: The second challenge currently does not work correctly
+                        # see https://github.com/privacyidea/privacyidea/issues/1792
+                        challengeobject.delete()
+                        _, _, transaction_id, _ = self.create_challenge(options=options)
+                        options["transaction_id"] = transaction_id
+                        otp_counter = -1
+                        break
+                    else:
+                        otp_counter = -1
+                        # increase the received_count
+                        challengeobject.set_otp_status()
+
+        self.challenge_janitor()
+        return otp_counter
+
     @property
     def check_pin_local(self):
         """
@@ -168,24 +355,93 @@ def split_pin_pass(self, passw, user=None, options=None):
 
         return res, pin, otpval
 
+    @log_with(log)
+    @check_token_locked
+    def authenticate(self, passw, user=None, options=None):
+        """
+        do the authentication on base of password / otp and user and
+        options, the request parameters.
+
+        This is only called after it is verified, that the upper level is no challenge-request
+        or challenge-response
+
+        The "options" are read-only in this method. They are not modified here. authenticate
+        is the last method in the loop ``check_token_list``.
+
+        communication with RADIUS server: yes, if is no previous "radius_result"
+            If there is a "radius" result in the options, we do not query the radius server
+        modification of options: options can be modified if we query the radius server.
+            However, this is not important since authenticate is the last call.
+
+        :param passw: the password / otp
+        :param user: the requesting user
+        :param options: the additional request parameters
+
+        :return: tuple of (success, otp_count - 0 or -1, reply)
+
+        """
+        options = options or {}
+        res = False
+        otp_counter = -1
+        reply = None
+        otpval = passw
+
+        # should we check the pin locally?
+        if self.check_pin_local:
+            (_res, pin, otpval) = self.split_pin_pass(passw, user,
+                                                      options=options)
+
+            if not self.check_pin(pin):
+                return False, -1, {'message': "Wrong PIN"}
+
+        # attempt to retrieve saved state/result
+        state = options.get('radius_state')
+        result = options.get('radius_result')
+        if result is None:
+            radius_response = self._check_radius(otpval, options=options, radius_state=state)
+        else:
+            radius_response = result
+
+        if radius_response == AccessAccept:
+            res = True
+            otp_counter = 1
+
+        return res, otp_counter, reply
+
     @log_with(log)
     @check_token_locked
     def check_otp(self, otpval, counter=None, window=None, options=None):
+        """
+        Originally check_otp returns an OTP counter. I.e. in a failed attempt
+        we return -1. In case of success we return 1
+        :param otpval:
+        :param counter:
+        :param window:
+        :param options:
+        :return:
+        """
+        res = self._check_radius(otpval, options=options)
+        if res == AccessAccept:
+            return 1
+        else:
+            return -1
+
+    @log_with(log)
+    @check_token_locked
+    def _check_radius(self, otpval, options=None, radius_state=None):
         """
         run the RADIUS request against the RADIUS server
 
         :param otpval: the OTP value
-        :param counter: The counter for counter based otp values
-        :type counter: int
-        :param window: a counter window
-        :type counter: int
         :param options: additional token specific options
         :type options: dict
         :return: counter of the matching OTP value.
-        :rtype: int
+        :rtype: AccessAccept, AccessReject, AccessChallenge
         """
-        otp_count = -1
-        options = options or {}
+        result = AccessReject
+        radius_message = None
+        if options is None:
+            options = {}
 
         radius_dictionary = None
         radius_identifier = self.get_tokeninfo("radius.identifier")
@@ -248,34 +504,40 @@ def check_otp(self, otpval, counter=None, window=None, options=None):
                                        NAS_Identifier=nas_identifier.encode('ascii'))
 
             req["User-Password"] = req.PwCrypt(otpval)
-            if "transactionid" in options:
-                req["State"] = str(options.get("transactionid"))
+
+            if radius_state:
+                req["State"] = radius_state
+                log.info(u"Sending saved challenge to radius server: {0!r} ".format(radius_state))
 
             response = srv.SendPacket(req)
-            # TODO: handle the RADIUS challenge
-            """
+            # handle the RADIUS challenge
             if response.code == pyrad.packet.AccessChallenge:
-                opt = {}
-                for attr in response.keys():
-                    opt[attr] = response[attr]
-                res = False
-                log.debug("challenge returned %r " % opt)
                 # now we map this to a privacyidea challenge
-                if "State" in opt:
-                    reply["transactionid"] = opt["State"][0]
-                if "Reply-Message" in opt:
-                    reply["message"] = opt["Reply-Message"][0]
-            """
-            if response.code == pyrad.packet.AccessAccept:
-                log.info("Radiusserver %s granted "
-                         "access to user %s." % (r_server, radius_user))
-                otp_count = 0
+                if "State" in response:
+                    radius_state = response["State"][0]
+                if "Reply-Message" in response:
+                    radius_message = response["Reply-Message"][0]
+
+                result = AccessChallenge
+            elif response.code == pyrad.packet.AccessAccept:
+                radius_state = '<SUCCESS>'
+                radius_message = 'RADIUS authentication succeeded'
+                log.info(u"RADIUS server {0!s} granted "
+                         u"access to user {1!s}.".format(r_server, radius_user))
+                result = AccessAccept
             else:
-                log.warning("Radiusserver %s rejected "
-                            "access to user %s." % (r_server, radius_user))
+                radius_state = '<REJECTED>'
+                radius_message = 'RADIUS authentication failed'
+                log.debug(u'radius response code {0!s}'.format(response.code))
+                log.info(u"Radiusserver {0!s} "
+                         u"rejected access to user {1!s}.".format(r_server, radius_user))
+                result = AccessReject
 
         except Exception as ex:  # pragma: no cover
             log.error("Error contacting radius Server: {0!r}".format((ex)))
-            log.debug("{0!s}".format(traceback.format_exc()))
+            log.info("{0!s}".format(traceback.format_exc()))
 
-        return otp_count
+        options.update({'radius_result': result})
+        options.update({'radius_state': radius_state})
+        options.update({'radius_message': radius_message})
+        return result


Test patch:
diff --git a/tests/radiusmock.py b/tests/radiusmock.py
--- a/tests/radiusmock.py
+++ b/tests/radiusmock.py
@@ -1,5 +1,7 @@
 # -*- coding: utf-8 -*-
 """
+2019-08-15 More sophisticated radiusmock
+        Cornelius Kölbel <cornelius.koelbel@netknights.it>
 2017-10-30 Add mocking the timeout
         Cornelius Kölbel <cornelius.koelbel@netknights.it>
 2015-01-29 Change responses.py to be able to run with RADIUS
@@ -25,8 +27,8 @@
     absolute_import, print_function, division, unicode_literals
 )
 
-import re
 import six
+from pyrad.packet import AccessChallenge, AccessAccept, AccessReject
 
 if six.PY2:
     try:
@@ -41,6 +43,7 @@
 from functools import update_wrapper
 from pyrad import packet
 from pyrad.client import Timeout
+from pyrad.packet import AccessChallenge, AccessReject, AccessAccept
 
 Call = namedtuple('Call', ['request', 'response'])
 
@@ -105,11 +108,12 @@ def reset(self):
         self._request_data = {}
         self._calls.reset()
 
-    def setdata(self, server=None, rpacket=None, success=True, timeout=False):
+    def setdata(self, server=None, rpacket=None, response=AccessReject, response_data=None, timeout=False):
         self._request_data = {
             'server': server,
             'packet': rpacket,
-            'success': success,
+            'response': response,
+            'response_data': response_data or {},
             'timeout': timeout
         }
 
@@ -147,13 +151,10 @@ def _on_request(self, client_instance, pkt):
               255       Reserved
         """
         #reply = pkt.CreateReply(packet=rawreply)
-        reply = pkt.CreateReply()
         if self._request_data.get("timeout"):
             raise Timeout()
-        if self._request_data.get("success"):
-            reply.code = packet.AccessAccept
-        else:
-            reply.code = packet.AccessReject
+        reply = pkt.CreateReply(**self._request_data.get("response_data"))
+        reply.code = self._request_data.get("response", AccessReject)
         return reply
 
     def start(self):
diff --git a/tests/test_api_radiusserver.py b/tests/test_api_radiusserver.py
--- a/tests/test_api_radiusserver.py
+++ b/tests/test_api_radiusserver.py
@@ -71,7 +71,7 @@ def test_01_create_server(self):
     @radiusmock.activate
     def test_02_send_test_email(self):
         set_privacyidea_config("radius.dictfile", DICT_FILE)
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
 
         with self.app.test_request_context('/radiusserver/test_request',
                                            method='POST',
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -19,16 +19,18 @@
 from privacyidea.lib.resolver import save_resolver, get_resolver_list, delete_resolver
 from privacyidea.lib.realm import set_realm, set_default_realm, delete_realm
 from privacyidea.lib.smsprovider.SMSProvider import set_smsgateway
+from privacyidea.lib.radiusserver import add_radius, delete_radius
 from privacyidea.lib import _
 
 import datetime
 import time
 import responses
-from . import smtpmock, ldap3mock
+from . import smtpmock, ldap3mock, radiusmock
 
 
 PWFILE = "tests/testdata/passwords"
 HOSTSFILE = "tests/testdata/hosts"
+DICT_FILE="tests/testdata/dictionary"
 
 LDAPDirectory = [{"dn": "cn=alice,ou=example,o=test",
                  "attributes": {'cn': 'alice',
@@ -3239,6 +3241,93 @@ def test_10_unique_transaction_id(self):
         remove_token("tok1")
         remove_token("tok2")
 
+    @radiusmock.activate
+    def test_11_validate_radiustoken(self):
+        # A RADIUS token with RADIUS challenge response
+        # remove all tokens of user Cornelius
+        user_obj = User("cornelius", self.realm1)
+        remove_token(user=user_obj)
+
+        r = add_radius(identifier="myserver", server="1.2.3.4",
+                       secret="testing123", dictionary=DICT_FILE)
+        self.assertTrue(r > 0)
+        token = init_token({"type": "radius",
+                            "serial": "rad1",
+                            "radius.identifier": "myserver",
+                            "radius.local_checkpin": False,
+                            "radius.user": u"nönäscii"},
+                           user=user_obj)
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessChallenge)
+        with self.app.test_request_context('/validate/check',
+                                           method="POST",
+                                           data={"user": "cornelius",
+                                                 "pass": "radiuspassword"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            transaction_id = data.get("detail").get("transaction_id")
+
+        # Now we send the response to this request but the wrong response!
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessReject)
+        with self.app.test_request_context('/validate/check',
+                                           method="POST",
+                                           data={"user": "cornelius",
+                                                 "pass": "wrongPW",
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+            t = data.get("detail").get("transaction_id")
+            # No transaction_id
+            self.assertIsNone(t)
+
+        # Finally we succeed
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessAccept)
+        with self.app.test_request_context('/validate/check',
+                                           method="POST",
+                                           data={"user": "cornelius",
+                                                 "pass": "correctPW",
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertTrue(data.get("result").get("value"))
+            t = data.get("detail").get("transaction_id")
+            # No transaction_id
+            self.assertIsNone(t)
+
+        # A second request tries to use the same transaction_id, but the RADIUS server
+        # responds with a Reject
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessReject)
+        with self.app.test_request_context('/validate/check',
+                                           method="POST",
+                                           data={"user": "cornelius",
+                                                 "pass": "correctPW",
+                                                 "transaction_id": transaction_id}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertFalse(data.get("result").get("value"))
+
+        # And finally a single shot authentication, no chal resp, no transaction_id
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessAccept)
+        with self.app.test_request_context('/validate/check',
+                                           method="POST",
+                                           data={"user": "cornelius",
+                                                 "pass": "correctPW"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = json.loads(res.data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertTrue(data.get("result").get("value"))
+        remove_token("rad1")
+
 
 class TriggeredPoliciesTestCase(MyApiTestCase):
 
diff --git a/tests/test_lib_policydecorator.py b/tests/test_lib_policydecorator.py
--- a/tests/test_lib_policydecorator.py
+++ b/tests/test_lib_policydecorator.py
@@ -294,7 +294,7 @@ def test_06_passthru(self):
                          u"against userstore due to 'pol1'")
 
         # Now set a PASSTHRU policy to a RADIUS config (new style)
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         set_policy(name="pol1",
                    scope=SCOPE.AUTH,
                    action="{0!s}=radiusconfig1".format(ACTION.PASSTHRU))
@@ -659,7 +659,7 @@ def test_13_passthru_priorities(self):
                          u"against userstore due to 'pol1'")
 
         # Now add a PASSTHRU policy to a RADIUS config
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         set_policy(name="pol2",
                    scope=SCOPE.AUTH,
                    action="{0!s}=radiusconfig1".format(ACTION.PASSTHRU))
@@ -841,7 +841,7 @@ def test_16_passthru_assign(self):
                          "The user has no tokens assigned")
 
         # Now add a PASSTHRU policy to a RADIUS config
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         set_policy(name="pol1",
                    scope=SCOPE.AUTH,
                    action="{0!s}=radiusconfig1".format(ACTION.PASSTHRU))
diff --git a/tests/test_lib_radiusserver.py b/tests/test_lib_radiusserver.py
--- a/tests/test_lib_radiusserver.py
+++ b/tests/test_lib_radiusserver.py
@@ -57,7 +57,7 @@ def test_03_missing_configuration(self):
 
     @radiusmock.activate
     def test_04_RADIUS_request(self):
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         r = add_radius(identifier="myserver", server="1.2.3.4",
                        secret="testing123", dictionary=DICT_FILE)
         self.assertTrue(r > 0)
@@ -65,13 +65,13 @@ def test_04_RADIUS_request(self):
         r = RADIUSServer.request(radius.config, "user", "password")
         self.assertEqual(r, True)
 
-        radiusmock.setdata(success=False)
+        radiusmock.setdata(response=radiusmock.AccessReject)
         r = RADIUSServer.request(radius.config, "user", "password")
         self.assertEqual(r, False)
 
     @radiusmock.activate
     def test_05_RADIUS_request(self):
-        radiusmock.setdata(success=True, timeout=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept, timeout=True)
         r = add_radius(identifier="myserver", server="1.2.3.4",
                        secret="testing123", dictionary=DICT_FILE)
         self.assertTrue(r > 0)
@@ -82,13 +82,13 @@ def test_05_RADIUS_request(self):
 
     @radiusmock.activate
     def test_06_test_radius(self):
-        radiusmock.setdata(success=False)
+        radiusmock.setdata(response=radiusmock.AccessReject)
         r = test_radius(identifier="myserver", server="1.2.3.4",
                         user="user", password="password",
                         secret="testing123", dictionary=DICT_FILE)
         self.assertFalse(r)
 
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         r = test_radius(identifier="myserver", server="1.2.3.4",
                         user="user", password="password",
                         secret="testing123", dictionary=DICT_FILE)
@@ -103,7 +103,7 @@ def test_06_test_radius(self):
 
     @radiusmock.activate
     def test_07_non_ascii(self):
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         r = add_radius(identifier="myserver", server="1.2.3.4",
                        secret="testing123", dictionary=DICT_FILE)
         self.assertTrue(r > 0)
diff --git a/tests/test_lib_tokens_radius.py b/tests/test_lib_tokens_radius.py
--- a/tests/test_lib_tokens_radius.py
+++ b/tests/test_lib_tokens_radius.py
@@ -6,6 +6,7 @@
 
 from .base import MyTestCase
 from privacyidea.lib.tokens.radiustoken import RadiusTokenClass
+from privacyidea.lib.challenge import get_challenges
 from privacyidea.models import Token
 from privacyidea.lib.error import ParameterError
 from privacyidea.lib.config import set_privacyidea_config
@@ -102,7 +103,7 @@ def test_03_check_pin_local(self):
 
     @radiusmock.activate
     def test_04_do_request_success(self):
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         set_privacyidea_config("radius.dictfile", DICT_FILE)
         db_token = Token.query.filter(Token.serial == self.serial1).first()
         token = RadiusTokenClass(db_token)
@@ -112,7 +113,7 @@ def test_04_do_request_success(self):
 
     @radiusmock.activate
     def test_05_do_request_fail(self):
-        radiusmock.setdata(success=False)
+        radiusmock.setdata(response=radiusmock.AccessReject)
         db_token = Token.query.filter(Token.serial == self.serial1).first()
         token = RadiusTokenClass(db_token)
         otpcount = token.check_otp("123456")
@@ -120,7 +121,7 @@ def test_05_do_request_fail(self):
 
     @radiusmock.activate
     def test_08_authenticate_local_pin(self):
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         db_token = Token.query.filter(Token.serial == self.serial1).first()
         token = RadiusTokenClass(db_token)
         # wrong PIN
@@ -132,24 +133,22 @@ def test_08_authenticate_local_pin(self):
         r = token.authenticate(self.otppin+"123456")
         self.assertTrue(r[0], r)
         self.assertTrue(r[1] >= 0, r)
-        self.assertTrue(r[2].get("message") == "matching 1 tokens", r)
 
     @radiusmock.activate
     def test_09_authenticate_radius_pin(self):
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         db_token = Token.query.filter(Token.serial == self.serial2).first()
         token = RadiusTokenClass(db_token)
         token.set_pin("")
         r = token.authenticate("radiusPIN123456")
         self.assertTrue(r[0], r)
         self.assertTrue(r[1] >= 0, r)
-        self.assertTrue(r[2].get("message") == "matching 1 tokens", r)
 
     @radiusmock.activate
     def test_10_authenticate_system_radius_settings(self):
         set_privacyidea_config("radius.server", "my.other.radiusserver:1812")
         set_privacyidea_config("radius.secret", "testing123")
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         token = init_token({"type": "radius",
                             "radius.system_settings": True,
                             "radius.user": "user1",
@@ -157,13 +156,12 @@ def test_10_authenticate_system_radius_settings(self):
                             "radius.secret": ""})
         r = token.authenticate("radiuspassword")
         self.assertEqual(r[0], True)
-        self.assertEqual(r[1], 0)
-        self.assertEqual(r[2].get("message"), "matching 1 tokens")
+        self.assertEqual(r[1], 1)
 
     @radiusmock.activate
     def test_11_RADIUS_request(self):
         set_privacyidea_config("radius.dictfile", DICT_FILE)
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         r = add_radius(identifier="myserver", server="1.2.3.4",
                        secret="testing123", dictionary=DICT_FILE)
         self.assertTrue(r > 0)
@@ -172,13 +170,12 @@ def test_11_RADIUS_request(self):
                             "radius.user": "user1"})
         r = token.authenticate("radiuspassword")
         self.assertEqual(r[0], True)
-        self.assertEqual(r[1], 0)
-        self.assertEqual(r[2].get("message"), "matching 1 tokens")
+        self.assertEqual(r[1], 1)
 
     @radiusmock.activate
     def test_12_non_ascii(self):
         set_privacyidea_config("radius.dictfile", DICT_FILE)
-        radiusmock.setdata(success=True)
+        radiusmock.setdata(response=radiusmock.AccessAccept)
         r = add_radius(identifier="myserver", server="1.2.3.4",
                        secret="testing123", dictionary=DICT_FILE)
         self.assertTrue(r > 0)
@@ -187,5 +184,203 @@ def test_12_non_ascii(self):
                             "radius.user": u"nönäscii"})
         r = token.authenticate(u"passwörd")
         self.assertEqual(r[0], True)
-        self.assertEqual(r[1], 0)
-        self.assertEqual(r[2].get("message"), "matching 1 tokens")
+        self.assertEqual(r[1], 1)
+
+    @radiusmock.activate
+    def test_00_test_check_radius(self):
+        r = add_radius(identifier="myserver", server="1.2.3.4",
+                       secret="testing123", dictionary=DICT_FILE)
+        self.assertTrue(r > 0)
+        token = init_token({"type": "radius",
+                            "radius.identifier": "myserver",
+                            "radius.local_checkpin": True,
+                            "radius.user": u"nönäscii"})
+
+        # check the working of internal _check_radius
+        radiusmock.setdata(response=radiusmock.AccessChallenge)
+        r = token._check_radius("123456")
+        self.assertEqual(r, radiusmock.AccessChallenge)
+
+        radiusmock.setdata(response=radiusmock.AccessAccept)
+        r = token._check_radius("123456")
+        self.assertEqual(r, radiusmock.AccessAccept)
+
+        radiusmock.setdata(response=radiusmock.AccessReject)
+        r = token._check_radius("123456")
+        self.assertEqual(r, radiusmock.AccessReject)
+
+    @radiusmock.activate
+    def test_13_privacyidea_challenge_response(self):
+        # This tests the challenge response with the privacyIDEA PIN.
+        # First an authentication request with only the local PIN of the
+        # radius token is sent.
+        r = add_radius(identifier="myserver", server="1.2.3.4",
+                       secret="testing123", dictionary=DICT_FILE)
+        self.assertTrue(r > 0)
+        token = init_token({"type": "radius",
+                            "pin": "local",
+                            "radius.identifier": "myserver",
+                            "radius.local_checkpin": True,
+                            "radius.user": u"nönäscii"})
+
+        r = token.is_challenge_request("local")
+        self.assertTrue(r)
+
+        # create challenge of privacyidea
+        r, message, transaction_id, _attr = token.create_challenge()
+        self.assertTrue(r)
+        self.assertEqual("Enter your RADIUS tokencode:", message)
+
+        # check, if there is a challenge in the DB
+        chals = get_challenges(token.token.serial)
+        self.assertEqual(len(chals), 1)
+        self.assertEqual(chals[0].transaction_id, transaction_id)
+
+        # check if this is a response to a previously sent challenge
+        r = token.is_challenge_response("radiuscode", options={"transaction_id": transaction_id})
+        self.assertTrue(r)
+
+        # Now check, if the answer for the challenge is correct
+        radiusmock.setdata(response=radiusmock.AccessAccept)
+        r = token.check_challenge_response(passw="radiuscode",options={"transaction_id": transaction_id})
+        self.assertTrue(r)
+
+    @radiusmock.activate
+    def test_14_simple_challenge_response_in_radius_server(self):
+        # In this case we test a simple challenge response in
+        # the radius server. The PIN is checked locally.
+        # A AccessRequest is sent to the RADIUS server, the RADIUS server
+        # answers with an AccessChallenge, which creates a transaction id
+        # in privacyIDEA.
+        # This is answered and the RADIUS server sends an AccessAccept
+        r = add_radius(identifier="myserver", server="1.2.3.4",
+                       secret="testing123", dictionary=DICT_FILE)
+        self.assertTrue(r > 0)
+        token = init_token({"type": "radius",
+                            "radius.identifier": "myserver",
+                            "radius.local_checkpin": False,
+                            "radius.user": u"nönäscii"})
+
+        # Check if the remote PIN would create a RADIUS challenge
+        state1 = [b"123456"]
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessChallenge,
+                           response_data={"State": state1,
+                                          "Reply_Message": ["Please provide more information."]})
+        opts = {}
+        r = token.is_challenge_request("some_remote_value", options=opts)
+        self.assertTrue(r)
+        self.assertEqual(opts.get("radius_message"), "Please provide more information.")
+        self.assertEqual(opts.get("radius_result"), radiusmock.AccessChallenge)
+        self.assertEqual(opts.get("radius_state"), state1[0])
+
+        # Creating the challenge within privacyIDEA
+        r, message, transaction_id, _attr = token.create_challenge(options=opts)
+        self.assertTrue(r)
+        self.assertEqual(message, "Please provide more information.")
+
+        # Check if a challenge is created
+        chals = get_challenges(token.token.serial)
+        self.assertEqual(len(chals), 1)
+        self.assertEqual(chals[0].transaction_id, transaction_id)
+
+        # Checking, if this is the answer attempt to a challenge
+        r = token.is_challenge_response("some_response", options={"transaction_id": transaction_id})
+        self.assertTrue(r)
+
+        # Check what happens if the RADIUS server rejects the response
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessReject)
+        r = token.check_challenge_response(passw="some_response", options={"transaction_id": transaction_id})
+        self.assertLess(r, 0)
+
+        # Now checking the response to the challenge and we issue a RADIUS request
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessAccept)
+        r = token.check_challenge_response(passw="some_response", options={"transaction_id": transaction_id})
+        self.assertGreaterEqual(r, 0)
+
+    @radiusmock.activate
+    def test_15_multi_challenge_response_in_radius_server(self):
+        # The RADIUS server issues a AccessChallenge on the first and on the
+        # second request
+        r = add_radius(identifier="myserver", server="1.2.3.4",
+                       secret="testing123", dictionary=DICT_FILE)
+        self.assertTrue(r > 0)
+        token = init_token({"type": "radius",
+                            "radius.identifier": "myserver",
+                            "radius.local_checkpin": False,
+                            "radius.user": u"nönäscii"})
+
+        # Check if the remote PIN would create a RADIUS challenge
+        state1 = [b"123456"]
+        state2 = [b"999999"]
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessChallenge,
+                           response_data={"State": state1,
+                                          "Reply_Message": ["Please provide more information."]})
+        opts = {}
+        r = token.is_challenge_request("some_remote_value", options=opts)
+        self.assertTrue(r)
+        self.assertEqual(opts.get("radius_message"), "Please provide more information.")
+        self.assertEqual(opts.get("radius_result"), radiusmock.AccessChallenge)
+        self.assertEqual(opts.get("radius_state"), state1[0])
+
+        # Creating the challenge within privacyIDEA
+        r, message, transaction_id, _attr = token.create_challenge(options=opts)
+        self.assertTrue(r)
+        self.assertEqual(message, "Please provide more information.")
+
+        # Check if a challenge is created
+        chals = get_challenges(token.token.serial)
+        self.assertEqual(len(chals), 1)
+        self.assertEqual(chals[0].transaction_id, transaction_id)
+
+        # Checking, if this is the answer attempt to a challenge
+        r = token.is_challenge_response("some_response", options={"transaction_id": transaction_id})
+        self.assertTrue(r)
+
+        # Now checking the response to the challenge and we issue a RADIUS request
+        # But the RADIUS server answers with a second AccessChallenge
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessChallenge,
+                                              response_data={"State": state2,
+                                                             "Reply_Message": ["Please provide even more information."]})
+        opts2 = {"transaction_id": transaction_id}
+        r = token.check_challenge_response(passw="some_response", options=opts2)
+        # The answer might be correct, but since the RADIUS server want to get more answers, we get a -1
+        self.assertEqual(r, -1)
+        # but we get a new Challenge!
+        self.assertEqual(opts2.get("radius_result"), radiusmock.AccessChallenge)
+        self.assertEqual(opts2.get("radius_state"), state2[0])
+        self.assertEqual(opts2.get("radius_message"), "Please provide even more information.")
+        transaction_id2 = opts2.get("transaction_id")
+
+        # Finally we send the last auth request
+        opts3 = {"transaction_id": transaction_id2}
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessAccept)
+        r = token.check_challenge_response(passw="some_other_response",
+                                           options=opts3)
+        # The answer is correct,
+        self.assertEqual(r, 1)
+        # and we do not get a new challenge, it is the same as before
+        self.assertEqual(opts3.get("radius_result"), radiusmock.AccessAccept)
+        transaction_id3 = opts3.get("transaction_id")
+        self.assertEqual(transaction_id3, transaction_id2)
+
+    @radiusmock.activate
+    def test_16_single_shot_radius(self):
+        # This is a single shot authentication, no challenge response.
+        # One single auth request against the radius server.
+        r = add_radius(identifier="myserver", server="1.2.3.4",
+                       secret="testing123", dictionary=DICT_FILE)
+        self.assertTrue(r > 0)
+        token = init_token({"type": "radius",
+                            "radius.identifier": "myserver",
+                            "radius.local_checkpin": False,
+                            "radius.user": u"nönäscii"})
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessAccept)
+        r, otp_count, _rpl = token.authenticate("some_remote_value")
+        self.assertTrue(r)
+        self.assertTrue(otp_count > 0)
+
+        radiusmock.setdata(timeout=False, response=radiusmock.AccessReject)
+        r, otp_count, _rpl = token.authenticate("some_remote_value")
+        self.assertFalse(r)
+        self.assertTrue(otp_count < 0)
+