Repository: ckan/ckan

Issue:
Inconsistent behaviuor in non-authorized errors (2.10)
**CKAN version**
2.10 (master)


**Describe the bug**

Following up from https://github.com/ckan/ckan/pull/6560, here's what happens when a user is not authorized to perform an action on the UI:

| CKAN Version | Logged in user | HTTP status, result |
| --- | --- | --- |
| <= 2.9 (tested 2.9 and 2.8) | No | 403 - Not authorized message
| <= 2.9 (tested 2.9 and 2.8) | Yes | 403 - Not authorized message
| 2.10, default behaviour | No | 302 + 200, Redirect to login page
| 2.10, default behaviour | Yes |  403 - Not authorized message
| 2.10, if `ckan.redirect_to_login_if_not_authorized` is False | No | 403 - Not authorized message
| 2.10, if `ckan.redirect_to_login_if_not_authorized` is False | Yes | 403 - Not authorized message

It seems that at the very least, we should 

1. fix the inconsistency between logged in users and non-logged in users in 2.10. Looks like we don't need to check `if current_user.is_anonymous` here:

https://github.com/ckan/ckan/blob/ed155dca25265562e1a9125ee6917b832d514b10/ckan/config/middleware/flask_app.py#L542

2. Decide if we want to change the default behaviour to redirect to the login page or keep the 403 error.




Discussion:
another option is to keep the 403 but render a login page on the error response. (bonus points for including a hidden parameter in the form to redirect to the original page after login)
@amercader, @wardi it's true that anonymous users get redirected to the login page if 403 happens, but after they log in, ckan redirects them back to where they were and shows 403 if not authorized.

Isn't that what we needed?
@TomeCirun We discussed this in the dev meeting. It's probably best not to change the status code returned between CKAN versions (I had some tests broken because of this). But we can make easier for non-logged in users to login adding a link to the login page to the 403 or 401 error pages (embedding the form would make things more complicated for extensions overriding the authentication logic). So basically rendering `error_document_template.html` with a custom `content` [here](https://github.com/ckan/ckan/blob/ed155dca25265562e1a9125ee6917b832d514b10/ckan/config/middleware/flask_app.py#L551) instead of redirecting.

And then we can remove the `ckan.redirect_to_login_if_not_authorized` setting and simplify things.

Does this make sense?
Everything coming from the dev meetings makes sense :), btw I remember we wanted the same behavior as on 2.9, you can see a comment from @pdelboca [here](https://github.com/ckan/ckan/pull/6560#issuecomment-1111859579 ) asking for that change, maybe we can check this before going any further?

@amercader 
@TomeCirun I think extensions should gradually migrate to emit 403s instead of 401s to be consistent with the CKAN core behaviour so let's go ahead with the plan (cc @pdelboca)

Resolving patch:
diff --git a/ckan/config/middleware/flask_app.py b/ckan/config/middleware/flask_app.py
--- a/ckan/config/middleware/flask_app.py
+++ b/ckan/config/middleware/flask_app.py
@@ -31,7 +31,7 @@
 from beaker.middleware import SessionMiddleware
 from flask_login import LoginManager
 from flask_wtf.csrf import CSRFProtect
-from ckan.common import CKANConfig, asbool, session
+from ckan.common import CKANConfig, asbool, session, current_user
 
 import ckan.model as model
 from ckan.lib import base
@@ -39,7 +39,7 @@
 from ckan.lib import jinja_extensions
 from ckan.lib import uploader
 from ckan.lib import i18n
-from ckan.common import config, g, request, ungettext, current_user, _
+from ckan.common import config, g, request, ungettext
 from ckan.config.middleware.common_middleware import (TrackingMiddleware,
                                                       HostHeaderMiddleware,
                                                       RootPathMiddleware)
@@ -551,41 +551,20 @@ def error_handler(e: Exception) -> Union[
     ]:
         debug = config.get_value('debug')
         if isinstance(e, HTTPException):
-            # If the current_user.is_anonymous and the
-            # Exception code is 401(Unauthorized)/403(Forbidden)
-            # redirect the users to login page before trying to access it again
-            # If you want to raise a 401 or 403 error instead,
-            # set this setting to `False`
-            if config.get_value('ckan.redirect_to_login_if_not_authorized'):
-                # if the url is not local we dont want to redirect the user
-                # instead, we want to show the actual 403(Forbidden)...
-                # same for user.perform_reset if the current_user.is_deleted()
-                # the view returns 403 hence we want to show the exception.
-                endpoints = tuple(
-                    ['util.internal_redirect', 'user.perform_reset']
-                )
-                if request.endpoint not in endpoints:
-                    if current_user.is_anonymous and type(e) in (
-                        Unauthorized, Forbidden
-                    ):
-                        login_url = h.url_for('user.login', qualified=True)
-                        next_url = request.url
-                        redirect_url = h.make_login_url(
-                            login_url, next_url=next_url
-                        )
-                        h.flash_error(_('Please log in to access this page.'))
-                        return h.redirect_to(redirect_url)
-
             if debug:
                 log.debug(e, exc_info=sys.exc_info)  # type: ignore
             else:
                 log.info(e)
+
+            show_login_redirect_link = current_user.is_anonymous and type(
+                e
+            ) in (Unauthorized, Forbidden)
             extra_vars = {
                 u'code': e.code,
                 u'content': e.description,
-                u'name': e.name
+                u'name': e.name,
+                u'show_login_redirect_link': show_login_redirect_link
             }
-
             return base.render(
                 u'error_document_template.html', extra_vars), e.code
 


Test patch:
diff --git a/ckan/tests/controllers/test_admin.py b/ckan/tests/controllers/test_admin.py
--- a/ckan/tests/controllers/test_admin.py
+++ b/ckan/tests/controllers/test_admin.py
@@ -43,9 +43,7 @@ def test_index(app, user_env, sysadmin_env):
 
     url = url_for("admin.index")
     # Anonymous User
-    response = app.get(url, status=302, follow_redirects=False)
-    # Anonymous users are redirected to login page
-    assert "user/login?next=%2Fckan-admin%2F" in response
+    response = app.get(url, status=403)
 
     # Normal User
     response = app.get(url, extra_environ=user_env, status=403)
@@ -233,10 +231,8 @@ class TestTrashView(object):
     def test_trash_view_anon_user(self, app):
         """An anon user shouldn't be able to access trash view."""
         trash_url = url_for("admin.trash")
-        trash_response = app.get(trash_url, follow_redirects=False)
-
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fckan-admin%2Ftrash" in trash_response
+        trash_response = app.get(trash_url)
+        assert trash_response.status_code == 403
 
     def test_trash_view_normal_user(self, app, user_env):
         """A normal logged in user shouldn't be able to access trash view."""
diff --git a/ckan/tests/controllers/test_group.py b/ckan/tests/controllers/test_group.py
--- a/ckan/tests/controllers/test_group.py
+++ b/ckan/tests/controllers/test_group.py
@@ -18,10 +18,7 @@ def test_bulk_process_throws_403_for_nonexistent_org(self, app):
         bulk_process_url = url_for(
             "organization.bulk_process", id="does-not-exist"
         )
-        res = app.get(url=bulk_process_url, status=302, follow_redirects=False)
-        # Anonymous users are redirected to login page
-        redirect_url = "user/login?next=%2Forganization%2Fbulk_process%2Fdoes-not-exist"
-        assert redirect_url in res
+        app.get(url=bulk_process_url, status=403)
 
     def test_page_thru_list_of_orgs_preserves_sort_order(self, app):
         orgs = sorted([factories.Organization() for _ in range(35)],
@@ -77,9 +74,7 @@ def user():
 @pytest.mark.usefixtures("clean_db", "with_request_context")
 class TestGroupControllerNew(object):
     def test_not_logged_in(self, app):
-        res = app.get(url=url_for("group.new"), status=302, follow_redirects=False)
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fgroup%2Fnew" in res
+        app.get(url=url_for("group.new"), status=403)
 
     def test_name_required(self, app, user):
         env = {"Authorization": user["token"]}
@@ -140,9 +135,7 @@ def test_form_with_initial_data(self, app, user):
 @pytest.mark.usefixtures("non_clean_db", "with_request_context")
 class TestGroupControllerEdit(object):
     def test_not_logged_in(self, app):
-        res = app.get(url=url_for("group.new"), status=302, follow_redirects=False)
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fgroup%2Fnew" in res
+        app.get(url=url_for("group.new"), status=403)
 
     def test_group_doesnt_exist(self, app, user):
         env = {"Authorization": user["token"]}
@@ -301,13 +294,10 @@ def test_non_authorized_user_trying_to_delete_fails(
 
     def test_anon_user_trying_to_delete_fails(self, app):
         group = factories.Group()
-        res = app.get(
+        app.get(
             url=url_for("group.delete", id=group["id"]),
-            status=302,
-            follow_redirects=False
+            status=403,
         )
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fgroup%2Fdelete%2F" in res
 
         group = helpers.call_action(
             "group_show", id=group["id"]
@@ -531,11 +521,9 @@ def test_anonymous_users_cannot_add_members(self, app):
 
         with app.flask_app.test_request_context():
             url = url_for("group.member_new", id=group["id"])
-            res = app.get(url, status=302, follow_redirects=False)
-            # Anonymous users are redirected to login page
-            assert "user/login?next=%2Fgroup%2Fmember_new%2F" in res
+            app.get(url, status=403)
 
-            res = app.post(
+            app.post(
                 url,
                 data={
                     "id": "test",
@@ -543,11 +531,8 @@ def test_anonymous_users_cannot_add_members(self, app):
                     "save": "save",
                     "role": "test",
                 },
-                status=302,
-                follow_redirects=False
+                status=403,
             )
-            # Anonymous users are redirected to login page
-            assert "user/login?next=%2Fgroup%2Fmember_new%2F" in res
 
 
 @pytest.mark.usefixtures("non_clean_db", "with_request_context")
diff --git a/ckan/tests/controllers/test_organization.py b/ckan/tests/controllers/test_organization.py
--- a/ckan/tests/controllers/test_organization.py
+++ b/ckan/tests/controllers/test_organization.py
@@ -25,9 +25,7 @@ def sysadmin():
 class TestOrganizationNew(object):
 
     def test_not_logged_in(self, app):
-        res = app.get(url=url_for("group.new"), status=302, follow_redirects=False)
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fgroup%2Fnew" in res
+        app.get(url=url_for("group.new"), status=403)
 
     def test_name_required(self, app, user):
         url = url_for("organization.new")
@@ -213,15 +211,12 @@ def test_non_authorized_user_trying_to_delete_fails(
 
     def test_anon_user_trying_to_delete_fails(self, app):
         group = factories.Organization()
-        res = app.get(
+        app.get(
             url=url_for(
                 "organization.delete", id=group["id"]
             ),
-            status=302,
-            follow_redirects=False
+            status=403,
         )
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Forganization%2Fdelete%2F" in res
 
         organization = helpers.call_action(
             "organization_show", id=group["id"]
@@ -561,15 +556,12 @@ def test_anonymous_users_cannot_add_members(self, app):
         organization = factories.Organization()
 
         with app.flask_app.test_request_context():
-            res = app.get(
+            app.get(
                 url_for("organization.member_new", id=organization["id"]),
-                status=302,
-                follow_redirects=False
+                status=403,
             )
-            # Anonymous users are redirected to login page
-            assert "user/login?next=%2Forganization%2Fmember_new%2F" in res
 
-            res = app.post(
+            app.post(
                 url_for("organization.member_new", id=organization["id"]),
                 data={
                     "id": "test",
@@ -577,8 +569,7 @@ def test_anonymous_users_cannot_add_members(self, app):
                     "save": "save",
                     "role": "test",
                 },
-                status=302,
-                follow_redirects=False
+                status=403,
             )
 
     def test_create_user_for_user_invite(self, mail_server, sysadmin):
diff --git a/ckan/tests/controllers/test_package.py b/ckan/tests/controllers/test_package.py
--- a/ckan/tests/controllers/test_package.py
+++ b/ckan/tests/controllers/test_package.py
@@ -440,14 +440,11 @@ def test_unauthed_user_creating_dataset(self, app):
 
         # provide REMOTE_ADDR to idenfity as remote user, see
         # ckan.views.identify_user() for details
-        res = app.post(
+        app.post(
             url=url_for("dataset.new"),
             extra_environ={"REMOTE_ADDR": "127.0.0.1"},
-            status=302,
-            follow_redirects=False
+            status=403,
         )
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fdataset%2Fnew" in res
 
     def test_form_without_initial_data(self, app, user):
         url = url_for("dataset.new")
@@ -551,18 +548,13 @@ def test_anonymous_user_cannot_edit(self, app):
         organization = factories.Organization()
         dataset = factories.Dataset(owner_org=organization["id"])
         url = url_for("dataset.edit", id=dataset["name"])
-        res = app.get(url=url, status=302, follow_redirects=False)
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fdataset%2Fedit%2F" in res
+        app.get(url=url, status=403)
 
-        res = app.post(
+        app.post(
             url=url,
             data={"notes": "edited description"},
-            status=302,
-            follow_redirects=False
+            status=403,
         )
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fdataset%2Fedit%2F" in res
 
     def test_validation_errors_for_dataset_name_appear(self, app, user):
         """fill out a bad dataset set name and make sure errors appear"""
@@ -776,10 +768,11 @@ def test_anon_user_cannot_delete_owned_dataset(self, app, user):
             users=[{"name": user["name"], "capacity": "admin"}]
         )
         dataset = factories.Dataset(owner_org=owner_org["id"])
-        url = url_for("dataset.delete", id=dataset["name"])
-        res = app.post(url, status=302, follow_redirects=False)
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fdataset%2Fdelete%2F" in res
+
+        response = app.post(
+            url_for("dataset.delete", id=dataset["name"]), status=403
+        )
+        assert helpers.body_contains(response, "Unauthorized to delete package")
 
         deleted = helpers.call_action("package_show", id=dataset["id"])
         assert "active" == deleted["state"]
@@ -1004,22 +997,19 @@ def test_anonymous_users_cannot_add_new_resource(self, app):
         organization = factories.Organization()
         dataset = factories.Dataset(owner_org=organization["id"])
 
-        res = app.get(
+        app.get(
             url_for(
                 "{}_resource.new".format(dataset["type"]), id=dataset["id"]
-            ), status=302, follow_redirects=False
+            ), status=403
         )
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fdataset%2F" in res
-        res = app.post(
+
+        app.post(
             url_for(
                 "{}_resource.new".format(dataset["type"]), id=dataset["id"]
             ),
             data={"name": "test", "url": "test", "save": "save", "id": ""},
-            status=302, follow_redirects=False
+            status=403
         )
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fdataset%2F" in res
 
     def test_anonymous_users_cannot_edit_resource(self, app):
         organization = factories.Organization()
@@ -1027,30 +1017,24 @@ def test_anonymous_users_cannot_edit_resource(self, app):
         resource = factories.Resource(package_id=dataset["id"])
 
         with app.flask_app.test_request_context():
-            res = app.get(
+            app.get(
                 url_for(
                     "{}_resource.edit".format(dataset["type"]),
                     id=dataset["id"],
                     resource_id=resource["id"],
                 ),
-                status=302,
-                follow_redirects=False
+                status=403,
             )
 
-            # Anonymous users are redirected to login page
-            assert "user/login?next=%2Fdataset%2F" in res
-            res = app.post(
+            app.post(
                 url_for(
                     "{}_resource.edit".format(dataset["type"]),
                     id=dataset["id"],
                     resource_id=resource["id"],
                 ),
                 data={"name": "test", "url": "test", "save": "save", "id": ""},
-                status=302,
-                follow_redirects=False
+                status=403,
             )
-            # Anonymous users are redirected to login page
-            assert "user/login?next=%2Fdataset%2F" in res
 
 
 @pytest.mark.usefixtures("non_clean_db", "with_plugins", "with_request_context")
@@ -1340,17 +1324,14 @@ def test_anon_users_cannot_delete_owned_resources(self, app):
         dataset = factories.Dataset(owner_org=owner_org["id"])
         resource = factories.Resource(package_id=dataset["id"])
 
-        res = app.post(
+        app.post(
             url_for(
                 "{}_resource.delete".format(dataset["type"]),
                 id=dataset["name"],
                 resource_id=resource["id"],
             ),
-            status=302,
-            follow_redirects=False
+            status=403,
         )
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fdataset%2F" in res
 
     def test_logged_in_users_cannot_delete_resources_they_do_not_own(
         self, app, user
@@ -1949,9 +1930,7 @@ def test_resource_listing_premissions_non_auth_user(self, app, user):
     def test_resource_listing_premissions_not_logged_in(self, app):
         pkg = factories.Dataset()
         url = url_for("dataset.resources", id=pkg["name"])
-        res = app.get(url, status=302, follow_redirects=False)
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fdataset%2F" in res
+        app.get(url, status=403)
 
 
 @pytest.mark.usefixtures('clean_db', 'with_request_context')
diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py
--- a/ckan/tests/controllers/test_user.py
+++ b/ckan/tests/controllers/test_user.py
@@ -287,9 +287,7 @@ def test_user_edit_unknown_user(self, app):
         """Attempt to read edit user for an unknown user redirects to login
         page."""
         url = url_for("user.edit", id=factories.User.stub().name)
-        res = app.get(url, status=302, follow_redirects=False)
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fuser%2Fedit%2F" in res
+        app.get(url, status=403)
 
     def test_user_edit_not_logged_in(self, app):
         """Attempt to read edit user for an existing, not-logged in user
@@ -298,9 +296,7 @@ def test_user_edit_not_logged_in(self, app):
         user = factories.User()
         username = user["name"]
         url = url_for("user.edit", id=username)
-        res = app.get(url, status=302, follow_redirects=False)
-        # Anonymous users are redirected to login page
-        assert "user/login?next=%2Fuser%2Fedit%2F" in res
+        app.get(url, status=403)
 
     def test_edit_user(self, app, user):
         env = {"Authorization": user["token"]}