Repository: privacyidea/privacyidea

Issue:
PassOnNoToken user receives false offline information
<!--
Thank you for filing a bug within privacyIDEA. The more information you provide, the easier it is to identify the problem!

Issues are not meant for asking questions! You're welcome to ask questions in our community forum: https://community.privacyidea.org

privacyIDEA is licensed under the AGPL and comes without warranty. If you require reliable, professional help, please consider getting either
- consultancy 
  (https://netknights.it/en/leistungen/one-time-services/)
- or the privacyIDEA Enterprise Edition 
  (https://netknights.it/en/leistungen/service-level-agreements/)
-->

### Top-level intent

Use offline HOTP tokens in combination with users, who have no tokens.
To do so configure a passOnNoToken policy.

### Steps to reproduce

1. Use 3.7.3 (This needs to be a fix in 3.7! Works with 3.8, but we should also add tests and fix in 3.8)
2. Create UserA with HOTP offline
3. Create UserB with HOTP offline
4. Create policy PassOnNoToken
5. authenticate as UserC without token => This will return all offline information of userA and UserB!

### Expected outcome

UserC should get no offline information

### Actual outcome

UserC received the offline information.


PassOnNoToken user receives false offline information
<!--
Thank you for filing a bug within privacyIDEA. The more information you provide, the easier it is to identify the problem!

Issues are not meant for asking questions! You're welcome to ask questions in our community forum: https://community.privacyidea.org

privacyIDEA is licensed under the AGPL and comes without warranty. If you require reliable, professional help, please consider getting either
- consultancy 
  (https://netknights.it/en/leistungen/one-time-services/)
- or the privacyIDEA Enterprise Edition 
  (https://netknights.it/en/leistungen/service-level-agreements/)
-->

### Top-level intent

Use offline HOTP tokens in combination with users, who have no tokens.
To do so configure a passOnNoToken policy.

### Steps to reproduce

1. Use 3.7.3 (This needs to be a fix in 3.7! Works with 3.8, but we should also add tests and fix in 3.8)
2. Create UserA with HOTP offline
3. Create UserB with HOTP offline
4. Create policy PassOnNoToken
5. authenticate as UserC without token => This will return all offline information of userA and UserB!

### Expected outcome

UserC should get no offline information

### Actual outcome

UserC received the offline information.




Discussion:

Resolving patch:
diff --git a/privacyidea/api/lib/postpolicy.py b/privacyidea/api/lib/postpolicy.py
--- a/privacyidea/api/lib/postpolicy.py
+++ b/privacyidea/api/lib/postpolicy.py
@@ -455,14 +455,17 @@ def offline_info(request, response):
     if content.get("result").get("value") is True and g.client_ip:
         # check if there is a MachineToken definition
         serial = content.get("detail", {}).get("serial")
-        try:
-            auth_items = get_auth_items(serial=serial, application="offline",
-                                        challenge=request.all_data.get("pass"))
-            if auth_items:
-                content["auth_items"] = auth_items
-                response.set_data(json.dumps(content))
-        except Exception as exx:
-            log.info(exx)
+        if serial:
+            try:
+                auth_items = get_auth_items(serial=serial, application="offline",
+                                            challenge=request.all_data.get("pass"))
+                if auth_items:
+                    content["auth_items"] = auth_items
+                    response.set_data(json.dumps(content))
+                    # Also update JSON in the response object
+                    response.get_jsons()
+            except Exception as exx:
+                log.info(exx)
     return response
 
 
diff --git a/privacyidea/api/validate.py b/privacyidea/api/validate.py
--- a/privacyidea/api/validate.py
+++ b/privacyidea/api/validate.py
@@ -186,7 +186,8 @@ def offlinerefill():
                 response = send_result(True)
                 content = response.json
                 content["auth_items"] = {"offline": [{"refilltoken": refilltoken,
-                                                      "response": otps}]}
+                                                      "response": otps,
+                                                      "serial": serial}]}
                 response.set_data(json.dumps(content))
                 return response
         raise ParameterError("Token is not an offline token or refill token is incorrect")
diff --git a/privacyidea/lib/machine.py b/privacyidea/lib/machine.py
--- a/privacyidea/lib/machine.py
+++ b/privacyidea/lib/machine.py
@@ -462,6 +462,7 @@ def get_auth_items(hostname=None, ip=None, application=None,
             # Add the options the the auth_item
             for k, v in mtoken.get("options", {}).items():
                 auth_item[k] = v
+            auth_item["serial"] = serial
 
             # append the auth_item to the list
             auth_items[mtoken.get("application")].append(auth_item)


Test patch:
diff --git a/tests/test_api_offline_no_token.py b/tests/test_api_offline_no_token.py
new file mode 100644
--- /dev/null
+++ b/tests/test_api_offline_no_token.py
@@ -0,0 +1,108 @@
+# -*- coding: utf-8 -*-
+from .base import MyApiTestCase
+from privacyidea.lib.user import (User)
+from privacyidea.lib.token import init_token
+from privacyidea.lib.policy import SCOPE, ACTION, set_policy
+from privacyidea.lib.machine import attach_token
+
+
+class OfflinePassNoTokenTestCase(MyApiTestCase):
+    """
+    Ensure that an user with no tokens do not receive any offline values
+    https://github.com/privacyidea/privacyidea/issues/3333
+    """
+
+    def test_00_setup(self):
+        self.setUp_user_realms()
+
+    def test_01_create_tokens(self):
+        # create token for 1st user
+
+        userA = User("shadow", self.realm1, self.resolvername1)
+        userB = User("cornelius", self.realm1, self.resolvername1)
+        userC = User("usernotoken", self.realm1, self.resolvername1)
+        tokA = init_token({"otpkey": self.otpkey, "serial": "tokA"},
+                          user=userA)
+        tokB = init_token({"otpkey": self.otpkey, "serial": "tokB"},
+                          user=userB)
+        # attach tokens to offline
+        attach_token("tokA", "offline")
+        attach_token("tokB", "offline")
+        # set passonnotoken
+        set_policy(name="pol1", scope=SCOPE.AUTH, action=ACTION.PASSNOTOKEN)
+
+        # Validate userB
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "cornelius",
+                                                 "pass": self.valid_otp_values[1]},
+                                           environ_base={'REMOTE_ADDR': '192.168.0.2'}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = res.json
+            result = data.get("result")
+            self.assertEqual("ACCEPT", result.get("authentication"))
+            detail = data.get("detail")
+            self.assertEqual("matching 1 tokens", detail.get("message"))
+            auth_items = data.get("auth_items")
+            self.assertIn("offline", auth_items)
+            offline = auth_items.get("offline")
+            # One offline entry
+            self.assertEqual(1, len(offline))
+            self.assertEqual("cornelius", offline[0].get("username"))
+            serial = offline[0].get("serial")
+            self.assertEqual("tokB", serial)
+            refilltoken = offline[0].get("refilltoken")
+
+        # Test refill
+        with self.app.test_request_context('/validate/offlinerefill',
+                                           method='POST',
+                                           data={"serial": serial,
+                                                 "refilltoken": refilltoken,
+                                                 "pass": self.valid_otp_values[3]},
+                                           environ_base={'REMOTE_ADDR': '192.168.0.2'}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = res.json
+            auth_items = data.get("auth_items")
+            self.assertIn("offline", auth_items)
+            offline = auth_items.get("offline")
+            self.assertEqual(1, len(offline))
+            self.assertIn("serial", offline[0])
+
+        # PassOnNoToken userC
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "usernotoken",
+                                                 "pass": "sthelse"},
+                                           environ_base={'REMOTE_ADDR': '192.168.0.2'}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = res.json
+            result = data.get("result")
+            self.assertTrue(result.get("value"))
+            detail = data.get("detail")
+            self.assertEqual("user has no token, accepted due to 'pol1'",
+                             detail.get("message"))
+            # No AuthItems for this user
+            self.assertNotIn("auth_items", data)
+
+        # Validate userA
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "shadow",
+                                                 "pass": self.valid_otp_values[1]},
+                                           environ_base={'REMOTE_ADDR': '192.168.0.2'}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = res.json
+            result = data.get("result")
+            self.assertEqual("ACCEPT", result.get("authentication"))
+            detail = data.get("detail")
+            self.assertEqual("matching 1 tokens", detail.get("message"))
+            auth_items = data.get("auth_items")
+            self.assertIn("offline", auth_items)
+            offline = auth_items.get("offline")
+            # One offline entry
+            self.assertEqual(1, len(offline))
+            self.assertEqual("shadow", offline[0].get("username"))
\ No newline at end of file