Repository: comic/grand-challenge.org

Issue:
Redirect to signout page after successful login
I noticed a minor issue caused by some unintended behavior of the redirect after login functionality when I visited the users of the retina workstation. It causes the users to be logged out directly after logging in because of the redirect URL. It occurs after a user is logged out by going to `/accounts/signout/` without passing `next=...` as a GET parameter and then tries to login again.

**Steps to reproduce**
1. Login normally
2. Go to `/accounts/signout/` (=`settings.LOGOUT_URL` )
3. Click on "SIGN IN / REGISTER" in the top bar
4. Now you end up at `https://grand-challenge.org/accounts/signin/?next=https://grand-challenge.org/accounts/signout/` and, of course, when you now login you will be redirected to the signout page and immediately be logged out again.
5. Go to step 3 and repeat for an endless loop

**Options to resolve**
* Change `settings.LOGOUT_URL` to `"/accounts/signout/?next=/"`
* Prevent redirecting to the signout page in `login_redirect()` view in `profiles\views.py` 
* Change the logout URL in the retina workstation to "{% url 'userena_signout' %}?next=/" similar to `core\templates\base.html`

@jmsmkn Which option do you suggest or do you have an alternative?



Discussion:
Sounds fine:

* Change `settings.LOGOUT_URL` to `"/accounts/signout/?next=/"`
  * Generally don't do this - it should be a plain URL and not have query parameters already
* Prevent redirecting to the signout page in `login_redirect()` view in `profiles\views.py` 
  * Would be OK
* Change the logout URL in the retina workstation to "{% url 'userena_signout' %}?next=/" similar to `core\templates\base.html`
  * Would be OK

My preference would be for the 2nd if it works, then we can remove statements like `?next=/` in the templates.

Resolving patch:
diff --git a/app/grandchallenge/profiles/urls.py b/app/grandchallenge/profiles/urls.py
--- a/app/grandchallenge/profiles/urls.py
+++ b/app/grandchallenge/profiles/urls.py
@@ -7,6 +7,7 @@
     profile_edit_redirect,
     profile,
     signup,
+    signin,
     signup_complete,
     profile_edit,
 )
@@ -18,6 +19,7 @@
         {"signup_form": SignupFormExtra},
         name="profile_signup",
     ),
+    path("signin/", signin, name="profile_signin"),
     path("signup_complete/", signup_complete, name="profile_signup_complete"),
     path("login-redirect/", login_redirect, name="login_redirect"),
     path("profile/edit/", profile_edit_redirect, name="profile_redirect_edit"),
diff --git a/app/grandchallenge/profiles/utils.py b/app/grandchallenge/profiles/utils.py
new file mode 100644
--- /dev/null
+++ b/app/grandchallenge/profiles/utils.py
@@ -0,0 +1,24 @@
+from django.conf import settings
+
+from grandchallenge.subdomains.utils import reverse
+
+
+def signin_redirect(redirect=None, user=None):
+    """
+    Redirect user after successful sign in.
+    First looks for a ``requested_redirect``. If not supplied will fall-back to
+    the user specific account page. If all fails, will fall-back to redirect to
+    the homepage. Returns a string defining the URI to go next.
+    :param redirect:
+        A value normally supplied by ``next`` form field. Gets preference
+        before the default view which requires the user.
+    :param user:
+        A ``User`` object specifying the user who has just signed in.
+    :return: String containing the URI to redirect to.
+    """
+    if redirect and settings.LOGOUT_URL not in redirect:
+        return redirect
+    elif user is not None and user.is_authenticated:
+        return reverse("profile_redirect")
+    else:
+        return reverse("home")
diff --git a/app/grandchallenge/profiles/views.py b/app/grandchallenge/profiles/views.py
--- a/app/grandchallenge/profiles/views.py
+++ b/app/grandchallenge/profiles/views.py
@@ -4,11 +4,13 @@
 
 from grandchallenge.profiles.forms import EditProfileForm
 from grandchallenge.subdomains.utils import reverse
+from grandchallenge.profiles.utils import signin_redirect
 
 
 def login_redirect(request):
-    next = request.GET.get("next", "/")
-    return redirect(next)
+    next_uri = request.GET.get("next")
+    redirect_uri = signin_redirect(redirect=next_uri, user=request.user)
+    return redirect(redirect_uri)
 
 
 def profile(request):
@@ -58,6 +60,16 @@ def signup(request, extra_context=None, **kwargs):
     return response
 
 
+def signin(request, **kwargs):
+    redirect_signin_function = signin_redirect
+    response = userena_views.signin(
+        request=request,
+        redirect_signin_function=redirect_signin_function,
+        **kwargs,
+    )
+    return response
+
+
 def signup_complete(request):
     response = render(request, "userena/signup_complete.html")
     return response


Test patch:
diff --git a/app/tests/profiles_tests/test_views.py b/app/tests/profiles_tests/test_views.py
new file mode 100644
--- /dev/null
+++ b/app/tests/profiles_tests/test_views.py
@@ -0,0 +1,90 @@
+import pytest
+
+from rest_framework import status
+from django.conf import settings
+
+from grandchallenge.subdomains.utils import reverse
+from tests.factories import UserFactory
+
+
+@pytest.mark.django_db
+class TestLoginRedirect:
+    def test_default_redirect(self, client):
+        url = reverse("login_redirect")
+        response = client.get(url, follow=True)
+        assert response.redirect_chain[0][1] == status.HTTP_302_FOUND
+        assert response.redirect_chain[0][0] == reverse("home")
+        assert response.status_code == status.HTTP_200_OK
+
+    def test_normal_redirect(self, client):
+        redirect_to = "/challenges/"
+        url = reverse("login_redirect") + f"?next={redirect_to}"
+        response = client.get(url, follow=True)
+        assert response.redirect_chain[0][1] == status.HTTP_302_FOUND
+        assert response.redirect_chain[0][0] == redirect_to
+        assert response.status_code == status.HTTP_200_OK
+
+    def test_redirect_to_logout_url(self, client):
+        url = reverse("login_redirect") + f"?next={settings.LOGOUT_URL}"
+        response = client.get(url, follow=True)
+        assert response.redirect_chain[0][1] == status.HTTP_302_FOUND
+        assert response.redirect_chain[0][0] == reverse("home")
+        assert response.status_code == status.HTTP_200_OK
+
+    def test_redirect_to_logout_url_absolute(self, client):
+        url = (
+            reverse("login_redirect")
+            + f"?next={reverse('home')}{settings.LOGOUT_URL[1:]}"
+        )
+        response = client.get(url, follow=True)
+        assert response.redirect_chain[0][1] == status.HTTP_302_FOUND
+        assert response.redirect_chain[0][0] == reverse("home")
+        assert response.status_code == status.HTTP_200_OK
+
+
+@pytest.mark.django_db
+class TestSignInRedirect:
+    def get_redirect_response(self, client, next=None):
+        password = "password"
+        self.user = UserFactory()
+        self.user.set_password(password)
+        self.user.save()
+        url = reverse("userena_signin")
+        if next:
+            url += f"?next={next}"
+
+        return client.post(
+            url,
+            data={"identification": self.user.username, "password": password},
+            follow=True,
+        )
+
+    def test_default_redirect(self, client):
+        response = self.get_redirect_response(client)
+        assert response.redirect_chain[0][1] == status.HTTP_302_FOUND
+        assert response.redirect_chain[0][0] == reverse("profile_redirect")
+        assert response.status_code == status.HTTP_200_OK
+
+    def test_redirect(self, client):
+        expected_url = "/challenges/"
+        response = self.get_redirect_response(client, expected_url)
+        assert response.redirect_chain[0][1] == status.HTTP_302_FOUND
+        assert response.status_code == status.HTTP_200_OK
+        assert response.redirect_chain[0][0] == expected_url
+
+    def test_no_logout_redirect(self, client):
+        response = self.get_redirect_response(client, settings.LOGOUT_URL)
+        assert response.redirect_chain[0][1] == status.HTTP_302_FOUND
+        assert response.redirect_chain[0][0] == reverse("profile_redirect")
+        assert response.status_code == status.HTTP_200_OK
+
+
+@pytest.mark.django_db
+class TestUrlEncodedUsername:
+    def test_special_username(self, client):
+        user = UserFactory(username="tést")
+        url = reverse("profile_redirect")
+        client.force_login(user)
+        response = client.get(url, follow=True)
+        assert response.status_code == status.HTTP_200_OK
+        assert "t%C3%A9st" in response.redirect_chain[0][0]