Repository: privacyidea/privacyidea

Issue:
max token with enroll via multi challenge
the policies max_active_token and max_token are not considered when enroll_via_multichallenge is used


Discussion:
we should add a log message if enroll_via_multichallenge is not triggered because of one of the other policies
The `autoassign` policy makes use of the functionality of these policies instead of decorating an API endpoint:
https://github.com/privacyidea/privacyidea/blob/f2a09c16de38ad4299a4db7d9d6245a3706f2b89/privacyidea/api/lib/postpolicy.py#L761
https://github.com/privacyidea/privacyidea/tree/4146/policies_enroll_via_multi_challenge



Patch:
diff --git a/privacyidea/api/lib/postpolicy.py b/privacyidea/api/lib/postpolicy.py
index 83bc0c938e..5e35bd8d06 100644
--- a/privacyidea/api/lib/postpolicy.py
+++ b/privacyidea/api/lib/postpolicy.py
@@ -802,9 +802,10 @@ def multichallenge_enroll_via_validate(request, response):
     result = content.get("result")
     # Check if the authentication was successful, only then attempt to enroll a new token
     if result.get("value") and result.get("authentication") == "ACCEPT":
-        # Check if another policy restricts the token count
+        # Check if another policy restricts the token count and exit early if true
         try:
             check_max_token_user(request=request)
+            check_max_token_realm(request=request)
         except PolicyError as e:
             g.audit_object.log({"success": True,
                                 'action_detail': e})


Test patch:
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
index c7a83634c0..3e4a0fc04e 100644
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -50,64 +50,62 @@
 import re
 from . import smtpmock, ldap3mock, radiusmock
 
-
 PWFILE = "tests/testdata/passwords"
 HOSTSFILE = "tests/testdata/hosts"
-DICT_FILE="tests/testdata/dictionary"
+DICT_FILE = "tests/testdata/dictionary"
 
 LDAPDirectory = [{"dn": "cn=alice,ou=example,o=test",
-                 "attributes": {'cn': 'alice',
-                                "sn": "Cooper",
-                                "givenName": "Alice",
-                                'userPassword': 'alicepw',
-                                'oid': "2",
-                                "homeDirectory": "/home/alice",
-                                "email": "alice@test.com",
-                                "memberOf": ["cn=admins,o=test", "cn=users,o=test"],
-                                "accountExpires": 131024988000000000,
-                                "objectGUID": '\xef6\x9b\x03\xc0\xe7\xf3B'
-                                              '\x9b\xf9\xcajl\rM1',
-                                'mobile': ["1234", "45678"]}},
-                {"dn": 'cn=bob,ou=example,o=test',
-                 "attributes": {'cn': 'bob',
-                                "sn": "Marley",
-                                "givenName": "Robert",
-                                "email": "bob@example.com",
-                                "memberOf": ["cn=users,o=test"],
-                                "mobile": "123456",
-                                "homeDirectory": "/home/bob",
-                                'userPassword': 'bobpwééé',
-                                "accountExpires": 9223372036854775807,
-                                "objectGUID": '\xef6\x9b\x03\xc0\xe7\xf3B'
-                                              '\x9b\xf9\xcajl\rMw',
-                                'oid': "3"}},
-                {"dn": 'cn=manager,ou=example,o=test',
-                 "attributes": {'cn': 'manager',
-                                "givenName": "Corny",
-                                "sn": "keule",
-                                "email": "ck@o",
-                                "memberOf": ["cn=helpdesk,o=test", "cn=users,o=test"],
-                                "mobile": "123354",
-                                'userPassword': 'ldaptest',
-                                "accountExpires": 9223372036854775807,
-                                "objectGUID": '\xef6\x9b\x03\xc0\xe7\xf3B'
-                                              '\x9b\xf9\xcajl\rMT',
-                                'oid': "1"}},
-                {"dn": 'cn=frank,ou=sales,o=test',
-                 "attributes": {'cn': 'frank',
-                                "givenName": "Frank",
-                                "sn": "Hause",
-                                "email": "fh@o",
-                                "memberOf": ["cn=users,o=test"],
-                                "mobile": "123354",
-                                'userPassword': 'ldaptest',
-                                "accountExpires": 9223372036854775807,
-                                "objectGUID": '\xef7\x9b\x03\xc0\xe7\xf3B'
-                                              '\x9b\xf9\xcajl\rMT',
-                                'oid': "5"}}
+                  "attributes": {'cn': 'alice',
+                                 "sn": "Cooper",
+                                 "givenName": "Alice",
+                                 'userPassword': 'alicepw',
+                                 'oid': "2",
+                                 "homeDirectory": "/home/alice",
+                                 "email": "alice@test.com",
+                                 "memberOf": ["cn=admins,o=test", "cn=users,o=test"],
+                                 "accountExpires": 131024988000000000,
+                                 "objectGUID": '\xef6\x9b\x03\xc0\xe7\xf3B'
+                                               '\x9b\xf9\xcajl\rM1',
+                                 'mobile': ["1234", "45678"]}},
+                 {"dn": 'cn=bob,ou=example,o=test',
+                  "attributes": {'cn': 'bob',
+                                 "sn": "Marley",
+                                 "givenName": "Robert",
+                                 "email": "bob@example.com",
+                                 "memberOf": ["cn=users,o=test"],
+                                 "mobile": "123456",
+                                 "homeDirectory": "/home/bob",
+                                 'userPassword': 'bobpwééé',
+                                 "accountExpires": 9223372036854775807,
+                                 "objectGUID": '\xef6\x9b\x03\xc0\xe7\xf3B'
+                                               '\x9b\xf9\xcajl\rMw',
+                                 'oid': "3"}},
+                 {"dn": 'cn=manager,ou=example,o=test',
+                  "attributes": {'cn': 'manager',
+                                 "givenName": "Corny",
+                                 "sn": "keule",
+                                 "email": "ck@o",
+                                 "memberOf": ["cn=helpdesk,o=test", "cn=users,o=test"],
+                                 "mobile": "123354",
+                                 'userPassword': 'ldaptest',
+                                 "accountExpires": 9223372036854775807,
+                                 "objectGUID": '\xef6\x9b\x03\xc0\xe7\xf3B'
+                                               '\x9b\xf9\xcajl\rMT',
+                                 'oid': "1"}},
+                 {"dn": 'cn=frank,ou=sales,o=test',
+                  "attributes": {'cn': 'frank',
+                                 "givenName": "Frank",
+                                 "sn": "Hause",
+                                 "email": "fh@o",
+                                 "memberOf": ["cn=users,o=test"],
+                                 "mobile": "123354",
+                                 'userPassword': 'ldaptest',
+                                 "accountExpires": 9223372036854775807,
+                                 "objectGUID": '\xef7\x9b\x03\xc0\xe7\xf3B'
+                                               '\x9b\xf9\xcajl\rMT',
+                                 'oid': "5"}}
                  ]
 
-
 OTPs = ["755224",
         "287082",
         "359152",
@@ -130,6 +128,7 @@ class AuthorizationPolicyTestCase(MyApiTestCase):
     Testcase for issue
     https://github.com/privacyidea/privacyidea/issues/543
     """
+
     @ldap3mock.activate
     def test_00_create_realm(self):
         ldap3mock.setLDAPDirectory(LDAPDirectory)
@@ -140,11 +139,11 @@ def test_00_create_realm(self):
                   'LOGINNAMEATTRIBUTE': 'cn',
                   'LDAPSEARCHFILTER': '(cn=*)',
                   'USERINFO': '{ "username": "cn",'
-                                  '"phone" : "telephoneNumber", '
-                                  '"mobile" : "mobile"'
-                                  ', "email" : "mail", '
-                                  '"surname" : "sn", '
-                                  '"givenname" : "givenName" }',
+                              '"phone" : "telephoneNumber", '
+                              '"mobile" : "mobile"'
+                              ', "email" : "mail", '
+                              '"surname" : "sn", '
+                              '"givenname" : "givenName" }',
                   'UIDTYPE': 'DN',
                   "resolver": "catchall",
                   "type": "ldapresolver"}
@@ -482,15 +481,15 @@ def test_00_create_realms(self):
 
     def test_01_validate_offline(self):
         # create offline app
-        #tokenobj = get_tokens(self.serials[0])[0]
+        # tokenobj = get_tokens(self.serials[0])[0]
         mr_obj = save_machine_resolver({"name": "testresolver",
-                                "type": "hosts",
-                                "filename": HOSTSFILE,
-                                "type.filename": "string",
-                                "desc.filename": "the filename with the "
-                                                 "hosts",
-                                "pw": "secret",
-                                "type.pw": "password"})
+                                        "type": "hosts",
+                                        "filename": HOSTSFILE,
+                                        "type.filename": "string",
+                                        "desc.filename": "the filename with the "
+                                                         "hosts",
+                                        "pw": "secret",
+                                        "type.pw": "password"})
         self.assertTrue(mr_obj > 0)
         # Attach the offline app to pippin
         r = attach_token(self.serials[0], "offline", hostname="pippin",
@@ -1041,9 +1040,9 @@ def test_10_saml_check(self):
                              "user@localhost.localdomain")
             self.assertEqual(attributes.get("givenname"), "Cornelius")
             self.assertEqual(attributes.get("mobile"), "+491111111")
-            self.assertEqual(attributes.get("phone"),  "+491234566")
-            self.assertEqual(attributes.get("realm"),  "realm1")
-            self.assertEqual(attributes.get("username"),  "cornelius")
+            self.assertEqual(attributes.get("phone"), "+491234566")
+            self.assertEqual(attributes.get("realm"), "realm1")
+            self.assertEqual(attributes.get("username"), "cornelius")
 
         # Return SAML attributes On Fail
         with self.app.test_request_context('/validate/samlcheck',
@@ -2016,7 +2015,7 @@ def test_23_pass_no_user_and_pass_no_token(self):
         # Creating a notification event. The non-existing user must
         # still be able to pass!
         eid = set_event("notify", event=["validate_check"], action="sendmail",
-                  handlermodule="UserNotification", conditions={"token_locked": True})
+                        handlermodule="UserNotification", conditions={"token_locked": True})
 
         with self.app.test_request_context('/validate/check',
                                            method='POST',
@@ -2797,8 +2796,9 @@ def test_32_secondary_login_attribute(self):
             json_response = res.json
             self.assertTrue(json_response.get("result").get("status"), res)
             self.assertEqual(json_response.get("result").get("value").get("count"), 1)
-            self.assertTrue("logged in as Cooper." in json_response.get("result").get("value").get("auditdata")[0].get("info"),
-                            json_response.get("result").get("value").get("auditdata"))
+            self.assertTrue(
+                "logged in as Cooper." in json_response.get("result").get("value").get("auditdata")[0].get("info"),
+                json_response.get("result").get("value").get("auditdata"))
 
         self.assertTrue(delete_realm("tr"))
         self.assertTrue(delete_resolver("myLDAPres"))
@@ -3377,7 +3377,6 @@ def test_02_application_specific_password_token(self):
 
 
 class WebAuthn(MyApiTestCase):
-
     username = "selfservice"
     pin = "webauthnpin"
     serial = "WAN0001D434"
@@ -3698,7 +3697,6 @@ def test_20_authenticate_with_token(self):
 
 
 class MultiChallege(MyApiTestCase):
-
     serial = "hotp1"
 
     """
@@ -4163,7 +4161,6 @@ def test_01_push_challenge_tags(self):
 
 
 class AChallengeResponse(MyApiTestCase):
-
     serial = "hotp1"
     serial_email = "email1"
     serial_sms = "sms1"
@@ -4235,7 +4232,8 @@ def test_01_challenge_response_token_deactivate(self):
             self.assertFalse(data.get("result").get("value"))
             self.assertEqual(data.get("result").get("authentication"), "REJECT")
             detail = data.get("detail")
-            self.assertEqual(detail.get("message"), "Challenge matches, but token is not fit for challenge. Token is disabled")
+            self.assertEqual(detail.get("message"),
+                             "Challenge matches, but token is not fit for challenge. Token is disabled")
 
         # The token is still disabled. We are checking, if we can do a challenge response
         # for a disabled token
@@ -5917,7 +5915,6 @@ def test_03_fail_to_enroll_EMail(self):
         toks = get_tokens(user=User("alice", "ldaprealm"), tokentype="email")
         self.assertEqual(0, len(toks))
 
-
     @ldap3mock.activate
     @responses.activate
     def test_04_enroll_SMS(self):
@@ -6085,9 +6082,63 @@ def test_05_enroll_EMail_3rdparty_validator(self):
         toks = get_tokens(user=User("alice", "ldaprealm"), tokentype="email")
         self.assertEqual(0, len(toks))
 
+    def test_06_max_token_policy(self):
+        """
+        There are 5 policies that can limit the number of tokens a user can have, which will deny
+        enroll_via_multichallenge. 4 policies are checked via check_max_token_user:
+        - max_token_per_user
+        - max_active_token_per_user
+        - {type}_max_token_per_user
+        - {type_max_active_token_per_user
+        And 1 via check_max_token_realm:
+        - max_token_per_realm
+        Test that each of these functions correctly deny the enrollment of a token.
+        """
+        self.setUp_user_realms()
+        user = User("hans", "realm1")
+        spass = "12"
+        token1 = init_token({"type": "spass", "pin": spass}, user)
+        self.assertTrue(token1.get_serial())
+        set_policy("enroll_via_multichallenge", scope=SCOPE.AUTH,
+                   action=f"{ACTION.ENROLL_VIA_MULTICHALLENGE}=hotp")
+
+        set_policy("max_token_per_user", scope=SCOPE.ENROLL, action=f"{ACTION.MAXTOKENUSER}=1")
+        self._authenticate_no_token_enrolled(user, spass)
+        delete_policy("max_token_per_user")
 
-class ValidateShortPasswordTestCase(MyApiTestCase):
+        set_policy("max_active_token_per_user", scope=SCOPE.ENROLL, action=f"{ACTION.MAXACTIVETOKENUSER}=1")
+        self._authenticate_no_token_enrolled(user, spass)
+        delete_policy("max_active_token_per_user")
+
+        set_policy("hotp_max_token_per_user", scope=SCOPE.ENROLL, action=f"{ACTION.MAXTOKENUSER}=0")
+        self._authenticate_no_token_enrolled(user, spass)
+        delete_policy("hotp_max_token_per_user")
+
+        set_policy("max_token_per_realm", scope=SCOPE.ENROLL, action=f"{ACTION.MAXTOKENREALM}=1")
+        self._authenticate_no_token_enrolled(user, spass)
+        delete_policy("max_token_per_realm")
+
+        delete_policy("enroll_via_multichallenge")
+        remove_token(token1.get_serial())
 
+    def _authenticate_no_token_enrolled(self, user: User, otp):
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": user.login, "realm": user.realm, "pass": otp}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            data = res.json
+            self.assertIn("result", data)
+            self.assertTrue(data.get("result").get("status"))
+            self.assertTrue(data.get("result").get("value"))
+            self.assertEqual(data.get("result").get("authentication"), "ACCEPT")
+            self.assertIn("detail", data)
+            detail = data.get("detail")
+            self.assertNotIn("transaction_id", detail)
+            self.assertNotIn("multi_challenge", detail)
+
+
+class ValidateShortPasswordTestCase(MyApiTestCase):
     yubi_otpkey = "9163508031b20d2fbb1868954e041729"
 
     public_uid = "ecebeeejedecebeg"
@@ -6154,7 +6205,6 @@ def test_00_setup_tokens(self):
 
 
 class WebAuthnOfflineTestCase(MyApiTestCase):
-
     """
     This Testcase simulates the enrollment and full authentication with a WebAuthn token.
     """