Repository: pypi/warehouse

Issue:
Redirect users to the login view when they don't have permission
We need to configure Pyramid so that it will redirect users to the login view whenever they don't have permission to do something (maybe conditionally on if they are already logged in).

Set login view as forbidden_view_config
Fixes #417 



Discussion:
I'll pick this one up.

@dstufft – do we have a list of places that need redirecting?

@ghickman no, but Pyramid has a built in method for this where you can tell it to do something like this for the forbidden view. I think it's a view decoratored like:

`@view_config(context=HTTPForbidden)`

which will cause that view to be used anytime a `HTTPForbidden` exception is raised.

I've run out of time to get a patch out for this unfortunately. There's a working view in my [branch](https://github.com/ghickman/warehouse/tree/redirect-unauthed-users) but it's untested. If anyone else wants to pick up this issue then please feel free.

I don't think we should just call the login view, I think we should have a separate view which does nothing but redirects to the login view. However we also need a predicate on this so that it doesn't happen for anything registered with `warehouse.legacy.action_routing.add_pypi_action_route`. So probably we just need a custom predicate that looks something like:

``` python
def not_legacy_pypi_action(info, request):
    return not request.path.startswith("/pypi") and ":action" not in request.params
```

A better idea is to do something like:

``` python
@forbidden_view_config()
def redirect_to_login(request):
    return Redirect To Login

@forbidden_view_config(request_param=":action")
def do403(request):
    return Do a 403
```

Cool! I will update my pull request then.


Resolving patch:
diff --git a/warehouse/legacy/api/pypi.py b/warehouse/legacy/api/pypi.py
--- a/warehouse/legacy/api/pypi.py
+++ b/warehouse/legacy/api/pypi.py
@@ -23,7 +23,7 @@
 
 from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPGone
 from pyramid.response import Response
-from pyramid.view import view_config
+from pyramid.view import forbidden_view_config, view_config
 from sqlalchemy import func
 from sqlalchemy.orm.exc import NoResultFound
 
@@ -694,3 +694,11 @@ def submit(request):
         HTTPGone,
         "This API is no longer supported, instead simply upload the file.",
     )
+
+
+@forbidden_view_config(request_param=":action")
+def forbidden_legacy(exc, request):
+    # We're not going to do anything amazing here, this just exists to override
+    # the default forbidden handler we have which does redirects to the login
+    # view, which we do not want on this API.
+    return exc
diff --git a/warehouse/views.py b/warehouse/views.py
--- a/warehouse/views.py
+++ b/warehouse/views.py
@@ -10,12 +10,31 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from pyramid.view import view_config
+from pyramid.httpexceptions import HTTPSeeOther
+from pyramid.view import forbidden_view_config, view_config
 
+from warehouse.accounts import REDIRECT_FIELD_NAME
 from warehouse.packaging.models import Project, Release, File
 from warehouse.accounts.models import User
 
 
+@forbidden_view_config()
+def forbidden(exc, request):
+    # If the forbidden error is because the user isn't logged in, then we'll
+    # redirect them to the log in page.
+    if request.authenticated_userid is None:
+        url = request.route_url(
+            "accounts.login",
+            _query={REDIRECT_FIELD_NAME: request.path_qs},
+        )
+        return HTTPSeeOther(url)
+
+    # If we've reached here, then the user is logged in and they are genuinely
+    # not allowed to access this page.
+    # TODO: Style the forbidden page.
+    return exc
+
+
 @view_config(
     route_name="index",
     renderer="index.html",


Test patch:
diff --git a/tests/unit/legacy/api/test_pypi.py b/tests/unit/legacy/api/test_pypi.py
--- a/tests/unit/legacy/api/test_pypi.py
+++ b/tests/unit/legacy/api/test_pypi.py
@@ -1149,3 +1149,9 @@ def test_submit(pyramid_request):
     assert resp.status_code == 410
     assert resp.status == \
         "410 This API is no longer supported, instead simply upload the file."
+
+
+def test_forbidden_legacy():
+    exc, request = pretend.stub(), pretend.stub()
+    resp = pypi.forbidden_legacy(exc, request)
+    assert resp is exc
diff --git a/tests/unit/test_views.py b/tests/unit/test_views.py
--- a/tests/unit/test_views.py
+++ b/tests/unit/test_views.py
@@ -9,9 +9,12 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+
 import datetime
 
-from warehouse.views import index
+import pretend
+
+from warehouse.views import forbidden, index
 
 from ..common.db.packaging import (
     ProjectFactory, ReleaseFactory, FileFactory,
@@ -19,6 +22,30 @@
 from ..common.db.accounts import UserFactory
 
 
+class TestForbiddenView:
+
+    def test_logged_in_returns_exception(self):
+        exc, request = pretend.stub(), pretend.stub(authenticated_userid=1)
+        resp = forbidden(exc, request)
+        assert resp is exc
+
+    def test_logged_out_redirects_login(self):
+        exc = pretend.stub()
+        request = pretend.stub(
+            authenticated_userid=None,
+            path_qs="/foo/bar/?b=s",
+            route_url=pretend.call_recorder(
+                lambda route, _query: "/accounts/login/?next=/foo/bar/%3Fb%3Ds"
+            ),
+        )
+
+        resp = forbidden(exc, request)
+
+        assert resp.status_code == 303
+        assert resp.headers["Location"] == \
+            "/accounts/login/?next=/foo/bar/%3Fb%3Ds"
+
+
 class TestIndex:
 
     def test_index(self, db_request):