Repository: privacyidea/privacyidea

Issue:
authorization policy is_authorized
**Is your feature request related to a problem? Please describe.**

It is currently not possible to deny the access for a user, if he has a valid OTP token.
We can restrict access by token token but not complete access for the user.

Maybe such **authorization** would usually be done with an application, but in certain cases it can be helpful, if privacyIDEA could also deny access.


**Describe the solution you'd like**

Add a policy

scope: authorization
action: is_authorized= True|False or  "Allow access" | "Deny access"

This way using normal policy priority we could e.g. deny access on a certain IP for *all* users in a realm but allow access for only a limited user group.

**Describe alternatives you've considered**

Working with FreeRADIUS ulang.

# Implementation

Here https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/validate.py#L215 we add a **postpolicy** as decorator to deny access, if the access is not granted by the authorization policy.
Using a postpolicy will also rendet the used OTP value useless.

* [x] define policy in lib/policy.py
* [x] define postpolicy decorator
* [x] decorate /validate/check and /validate/triggerchallenge
authorization policy is_authorized
**Is your feature request related to a problem? Please describe.**

It is currently not possible to deny the access for a user, if he has a valid OTP token.
We can restrict access by token token but not complete access for the user.

Maybe such **authorization** would usually be done with an application, but in certain cases it can be helpful, if privacyIDEA could also deny access.


**Describe the solution you'd like**

Add a policy

scope: authorization
action: is_authorized= True|False or  "Allow access" | "Deny access"

This way using normal policy priority we could e.g. deny access on a certain IP for *all* users in a realm but allow access for only a limited user group.

**Describe alternatives you've considered**

Working with FreeRADIUS ulang.

# Implementation

Here https://github.com/privacyidea/privacyidea/blob/master/privacyidea/api/validate.py#L215 we add a **postpolicy** as decorator to deny access, if the access is not granted by the authorization policy.
Using a postpolicy will also rendet the used OTP value useless.

* [x] define policy in lib/policy.py
* [x] define postpolicy decorator
* [x] decorate /validate/check and /validate/triggerchallenge


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
@@ -44,9 +44,9 @@
 import datetime
 import logging
 import traceback
-from privacyidea.lib.error import PolicyError
+from privacyidea.lib.error import PolicyError, ValidateError
 from flask import g, current_app, make_response
-from privacyidea.lib.policy import SCOPE, ACTION, AUTOASSIGNVALUE
+from privacyidea.lib.policy import SCOPE, ACTION, AUTOASSIGNVALUE, AUTHORIZED
 from privacyidea.lib.policy import DEFAULT_ANDROID_APP_URL, DEFAULT_IOS_APP_URL
 from privacyidea.lib.policy import Match
 from privacyidea.lib.token import get_tokens, assign_token, get_realms_of_token, get_one_token
@@ -737,3 +737,27 @@ def mangle_challenge_response(request, response):
 
     return response
 
+
+def is_authorized(request, response):
+    """
+    This policy decorator is used in the AUTHZ scope to
+    decorate the /validate/check and /validate/triggerchallenge endpoint.
+    I will cause authentication to fail, if the policy
+    authorized=deny_access is set.
+
+    :param request:
+    :param response:
+    :return:
+    """
+    if not response.is_json:
+        # This can happen with the validate/radiuscheck endpoint
+        return response
+
+    authorized_pol = Match.user(g, scope=SCOPE.AUTHZ, action=ACTION.AUTHORIZED,
+                                user_object=request.User).action_values(unique=True, allow_white_space_in_action=True)
+
+    if authorized_pol:
+        if list(authorized_pol)[0] == AUTHORIZED.DENY:
+            raise ValidateError("User is not authorized to authenticate under these conditions.")
+
+    return response
diff --git a/privacyidea/api/validate.py b/privacyidea/api/validate.py
--- a/privacyidea/api/validate.py
+++ b/privacyidea/api/validate.py
@@ -91,7 +91,7 @@
                                             no_detail_on_success, autoassign,
                                             offline_info,
                                             add_user_detail_to_response, construct_radius_response,
-                                            mangle_challenge_response)
+                                            mangle_challenge_response, is_authorized)
 from privacyidea.lib.policy import PolicyClass
 from privacyidea.lib.event import EventConfiguration
 import logging
@@ -190,6 +190,7 @@ def offlinerefill():
 
 @validate_blueprint.route('/check', methods=['POST', 'GET'])
 @validate_blueprint.route('/radiuscheck', methods=['POST', 'GET'])
+@postpolicy(is_authorized, request=request)
 @postpolicy(mangle_challenge_response, request=request)
 @postpolicy(construct_radius_response, request=request)
 @postpolicy(no_detail_on_fail, request=request)
@@ -356,6 +357,7 @@ def check():
 
 
 @validate_blueprint.route('/samlcheck', methods=['POST', 'GET'])
+@postpolicy(is_authorized, request=request)
 @postpolicy(no_detail_on_fail, request=request)
 @postpolicy(no_detail_on_success, request=request)
 @postpolicy(add_user_detail_to_response, request=request)
@@ -468,6 +470,7 @@ def samlcheck():
 
 @validate_blueprint.route('/triggerchallenge', methods=['POST', 'GET'])
 @admin_required
+@postpolicy(is_authorized, request=request)
 @postpolicy(mangle_challenge_response, request=request)
 @check_user_or_serial_in_request(request)
 @prepolicy(check_application_tokentype, request=request)
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -357,6 +357,12 @@ class ACTION(object):
     SHOW_ANDROID_AUTHENTICATOR = "show_android_privacyidea_authenticator"
     SHOW_IOS_AUTHENTICATOR = "show_ios_privacyidea_authenticator"
     SHOW_CUSTOM_AUTHENTICATOR = "show_custom_authenticator"
+    AUTHORIZED = "authorized"
+
+
+class AUTHORIZED(object):
+    ALLOW = "grant_access"
+    DENY = "deny_access"
 
 
 class GROUP(object):
@@ -2005,6 +2011,12 @@ def get_static_policy_definitions(scope=None):
             }
         },
         SCOPE.AUTHZ: {
+            ACTION.AUTHORIZED: {
+                'type': 'str',
+                'desc': _("Allow the user to authenticate (default). If set to '{0!s}', "
+                          "the authentication of the user will be denied.").format(AUTHORIZED.DENY),
+                'value': [AUTHORIZED.ALLOW, AUTHORIZED.DENY]
+            },
             ACTION.APPLICATION_TOKENTYPE: {
                 'type': 'bool',
                 'desc': _("Allow the application to choose which token types should be used "


Test patch:
diff --git a/tests/test_api_lib_policy.py b/tests/test_api_lib_policy.py
--- a/tests/test_api_lib_policy.py
+++ b/tests/test_api_lib_policy.py
@@ -22,7 +22,7 @@
 
 from privacyidea.lib.policy import (set_policy, delete_policy, enable_policy,
                                     PolicyClass, SCOPE, ACTION, REMOTE_USER,
-                                    AUTOASSIGNVALUE)
+                                    AUTOASSIGNVALUE, AUTHORIZED)
 from privacyidea.api.lib.prepolicy import (check_token_upload,
                                            check_base_action, check_token_init,
                                            check_max_token_user,
@@ -54,7 +54,7 @@
                                             get_webui_settings,
                                             save_pin_change,
                                             add_user_detail_to_response,
-                                            mangle_challenge_response)
+                                            mangle_challenge_response, is_authorized)
 from privacyidea.lib.token import (init_token, get_tokens, remove_token,
                                    set_realms, check_user_pass, unassign_token,
                                    enable_token)
@@ -67,7 +67,7 @@
 
 from flask import Request, g, current_app, jsonify
 from werkzeug.test import EnvironBuilder
-from privacyidea.lib.error import PolicyError, RegistrationError
+from privacyidea.lib.error import PolicyError, RegistrationError, ValidateError
 from privacyidea.lib.machineresolver import save_resolver
 from privacyidea.lib.machine import attach_token
 from privacyidea.lib.auth import ROLE
@@ -3907,3 +3907,52 @@ def test_18_challenge_text_header(self):
 
         delete_policy("pol_header")
         delete_policy("pol_footer")
+
+    def test_19_is_authorized(self):
+        # Test authz authorized policy
+        builder = EnvironBuilder(method='POST',
+                                 data={'user': "cornelius",
+                                       "pass": "test123123"},
+                                 headers={})
+        env = builder.get_environ()
+        # Set the remote address so that we can filter for it
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        self.setUp_user_realms()
+        req.User = User("autoassignuser", self.realm1)
+        # The response contains the token type HOTP, successful authentication
+        res = {"jsonrpc": "2.0",
+               "result": {"status": True,
+                          "value": True},
+               "version": "privacyIDEA test",
+               "id": 1,
+               "detail": {"message": "matching 1 tokens",
+                          "serial": "HOTP123456",
+                          "type": "hotp"}}
+        resp = jsonify(res)
+        g.policy_object = PolicyClass()
+
+        # The response is unchanged
+        new_resp = is_authorized(req, resp)
+        self.assertEqual(resp, new_resp)
+
+        # Define a generic policy, that denies the request
+        set_policy("auth01", scope=SCOPE.AUTHZ, action="{0!s}={1!s}".format(ACTION.AUTHORIZED, AUTHORIZED.DENY),
+                   priority=2)
+        g.policy_object = PolicyClass()
+
+        # The request will fail.
+        self.assertRaises(ValidateError, is_authorized, req, resp)
+
+        # Now we set a 2nd policy with a higher priority
+        set_policy("auth02", scope=SCOPE.AUTHZ, action="{0!s}={1!s}".format(ACTION.AUTHORIZED, AUTHORIZED.ALLOW),
+                   priority=1, client="10.0.0.0/8")
+        g.policy_object = PolicyClass()
+
+        # The response is unchanged, authentication successful
+        new_resp = is_authorized(req, resp)
+        self.assertEqual(resp, new_resp)
+
+        delete_policy("auth01")
+        delete_policy("auth02")
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
@@ -14,7 +14,7 @@
 from privacyidea.lib.token import (get_tokens, init_token, remove_token,
                                    reset_token, enable_token, revoke_token,
                                    set_pin, get_one_token)
-from privacyidea.lib.policy import SCOPE, ACTION, set_policy, delete_policy
+from privacyidea.lib.policy import SCOPE, ACTION, set_policy, delete_policy, AUTHORIZED
 from privacyidea.lib.event import set_event
 from privacyidea.lib.event import delete_event
 from privacyidea.lib.error import ERROR
@@ -282,6 +282,40 @@ def test_04_testing_token_with_setrealm(self):
         delete_policy("pol_setrealm_01")
         remove_token("SPASS_04")
 
+    def test_05_is_authorized(self):
+        set_policy(name="auth01", scope=SCOPE.AUTHZ, priority=2,
+                   action="{0!s}={1!s}".format(ACTION.AUTHORIZED, AUTHORIZED.DENY))
+        set_policy(name="auth02", scope=SCOPE.AUTHZ, user="frank", priority=1,
+                   action="{0!s}={1!s}".format(ACTION.AUTHORIZED, AUTHORIZED.ALLOW))
+
+        # The user frank actually has a spass token and is authorized to authenticate by policy auth02
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "frank",
+                                                 "pass": "spass"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertTrue(result.get("status"))
+            self.assertTrue(result.get("value"))
+
+        delete_policy("auth02")
+
+        # If his personal policy is removed, he can not authenticate anymore
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "frank",
+                                                 "pass": "spass"}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 400, res)
+            result = res.json.get("result")
+            self.assertFalse(result.get("status"))
+            self.assertIn("error", result)
+            self.assertEqual(result.get("error").get("message"),
+                             "ERR401: User is not authorized to authenticate under these conditions.")
+
+        delete_policy("auth01")
+
 
 class DisplayTANTestCase(MyApiTestCase):