Repository: jupyterhub/jupyterhub

Issue:
audit default URLs and redirects
Currently, there are too many different initial states that can affect how users arrive at the Hub and where they end up. In particular, the biggest source of confusion is the "if the server is running, default to their server, otherwise default to /hub/home" behavior. This is implemented separately and slightly differently in the root handler and the login handler.

The current state of things, as far as I can tell:

- `/` -> `/hub/`
  - not logged in: redirect to `/hub/login`
  - logged in
    - ready: send to running server at `/user/:name`
    - not ready: send to /hub/home
- `/hub/login`
  - not running: trigger launch
  - ready: send to running server at `/user/:name`
  - not ready (e.g. slow spawn): send to `/hub/home`

Two common sources of confusion with this:

1. where the user ends up on login is sensitive to whether spawn is quick or not
2. visiting the same URL with the Hub in the same state sends the user to a different location, depending on whether the user was already logged in or not:
  1. not logged in -> login -> triggers spawn -> /user/:name
  2. logged in -> doesn't trigger spawn -> /hub/home

Proposal:

- always or never send users to their server from `/hub/` and `/hub/login`. #1648 added `.redirect_to_server` setting which covers the 'never' case. We can use this to implement the 'always' case as well, rather than the current state-dependent behavior.
- ensure /hub/ and /hub/login use the same code for picking destinations (`.get_next_url()`)
- remove implicit spawn from /hub/login, instead relying on `/hub/user/:name` handler to trigger spawn (or render spawn form), so that requests sending users to /hub/home don't trigger spawn

This should result in more consistent behavior that's easier for everyone to follow.

cc @ppLorins who brought up default-url confusion most recently


Discussion:

Resolving patch:
diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py
--- a/jupyterhub/handlers/base.py
+++ b/jupyterhub/handlers/base.py
@@ -392,20 +392,35 @@ def authenticate(self, data):
     def get_next_url(self, user=None):
         """Get the next_url for login redirect
 
-        Defaults to hub base_url /hub/ if user is not running,
-        otherwise user.url.
+        Default URL after login:
+
+        - if redirect_to_server (default): send to user's own server
+        - else: /hub/home
         """
         next_url = self.get_argument('next', default='')
         if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)):
             # treat absolute URLs for our host as absolute paths:
             next_url = urlparse(next_url).path
-        if not next_url.startswith('/'):
+        if next_url and not next_url.startswith('/'):
+            self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
             next_url = ''
+        if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
+            # add /hub/ prefix, to ensure we redirect to the right user's server.
+            # The next request will be handled by SpawnHandler,
+            # ultimately redirecting to the logged-in user's server.
+            without_prefix = next_url[len(self.base_url):]
+            next_url = url_path_join(self.hub.base_url, without_prefix)
+            self.log.warning("Redirecting %s to %s. For sharing public links, use /user-redirect/",
+                self.request.uri, next_url,
+            )
+
         if not next_url:
-            if user and user.running and self.redirect_to_server:
+            # default URL after login
+            # if self.redirect_to_server, default login URL initiates spawn
+            if user and self.redirect_to_server:
                 next_url = user.url
             else:
-                next_url = self.hub.base_url
+                next_url = url_path_join(self.hub.base_url, 'home')
         return next_url
 
     async def login_user(self, data=None):
diff --git a/jupyterhub/handlers/login.py b/jupyterhub/handlers/login.py
--- a/jupyterhub/handlers/login.py
+++ b/jupyterhub/handlers/login.py
@@ -82,15 +82,9 @@ async def post(self):
         auth_timer.stop(send=False)
 
         if user:
-            already_running = False
-            if user.spawner.ready:
-                status = await user.spawner.poll()
-                already_running = (status is None)
-            if not already_running and not user.spawner.options_form \
-                    and not user.spawner.pending:
-                # logging in triggers spawn
-                await self.spawn_single_user(user)
-            self.redirect(self.get_next_url())
+            # register current user for subsequent requests to user (e.g. logging the request)
+            self.get_current_user = lambda: user
+            self.redirect(self.get_next_url(user))
         else:
             html = self._render(
                 login_error='Invalid username or password',
diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py
--- a/jupyterhub/handlers/pages.py
+++ b/jupyterhub/handlers/pages.py
@@ -22,37 +22,15 @@ class RootHandler(BaseHandler):
 
     If logged in, redirects to:
 
-    - single-user server if running
+    - single-user server if settings.redirect_to_server (default)
     - hub home, otherwise
 
     Otherwise, renders login page.
     """
     def get(self):
-        next_url = self.get_argument('next', '')
-        if next_url and not next_url.startswith('/'):
-            self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
-            next_url = ''
-        if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
-            # add /hub/ prefix, to ensure we redirect to the right user's server.
-            # The next request will be handled by SpawnHandler,
-            # ultimately redirecting to the logged-in user's server.
-            without_prefix = next_url[len(self.base_url):]
-            next_url = url_path_join(self.hub.base_url, without_prefix)
-            self.log.warning("Redirecting %s to %s. For sharing public links, use /user-redirect/",
-                self.request.uri, next_url,
-            )
-            self.redirect(next_url)
-            return
         user = self.get_current_user()
         if user:
-            url = url_path_join(self.hub.base_url, 'home')
-            if user.running:
-                if self.redirect_to_server:
-                    url = user.url
-                self.log.debug("User is running: %s", user.url)
-                self.set_login_cookie(user) # set cookie
-            else:
-                self.log.debug("User is not running: %s", url)
+            url = self.get_next_url(user)
         else:
             url = self.settings['login_url']
         self.redirect(url)
@@ -71,7 +49,7 @@ async def get(self):
         # send the user to /spawn if they aren't running or pending a spawn,
         # to establish that this is an explicit spawn request rather
         # than an implicit one, which can be caused by any link to `/user/:name`
-        url = user.url if user.running or user.spawner.pending else url_path_join(self.hub.base_url, 'spawn')
+        url = user.url if user.spawner.active else url_path_join(self.hub.base_url, 'spawn')
         html = self.render_template('home.html',
             user=user,
             url=url,


Test patch:
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -143,10 +143,6 @@ def test_referer_check(app):
     if user is None:
         user = add_user(app.db, name='admin', admin=True)
     cookies = yield app.login_user('admin')
-    app_user = app.users[user]
-    # stop the admin's server so we don't mess up future tests
-    yield app.proxy.delete_user(app_user)
-    yield app_user.stop()
 
     r = yield api_request(app, 'users',
         headers={
diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py
--- a/jupyterhub/tests/test_named_servers.py
+++ b/jupyterhub/tests/test_named_servers.py
@@ -99,13 +99,13 @@ def test_create_named_server(app, named_servers):
         'kind': 'user',
         'admin': False,
         'pending': None,
-        'server': user.url,
+        'server': None,
         'servers': {
-            name: {
+            servername: {
                 'name': name,
                 'url': url_path_join(user.url, name, '/'),
             }
-            for name in ['', servername]
+            for name in [servername]
         },
     }
 
@@ -127,7 +127,7 @@ def test_delete_named_server(app, named_servers):
 
     r = yield api_request(app, 'users', username)
     r.raise_for_status()
-    
+
     user_model = r.json()
     user_model.pop('last_activity')
     assert user_model == {
@@ -136,13 +136,13 @@ def test_delete_named_server(app, named_servers):
         'kind': 'user',
         'admin': False,
         'pending': None,
-        'server': user.url,
+        'server': None,
         'servers': {
             name: {
                 'name': name,
                 'url': url_path_join(user.url, name, '/'),
             }
-            for name in ['']
+            for name in []
         },
     }
 
diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py
--- a/jupyterhub/tests/test_pages.py
+++ b/jupyterhub/tests/test_pages.py
@@ -38,7 +38,7 @@ def test_root_auth(app):
     cookies = yield app.login_user('river')
     r = yield async_requests.get(public_url(app), cookies=cookies)
     r.raise_for_status()
-    assert r.url == public_url(app, app.users['river'])
+    assert r.url.startswith(public_url(app, app.users['river']))
 
 
 @pytest.mark.gen_test
@@ -341,7 +341,7 @@ def mock_authenticate(handler, data):
             data=form_data,
             allow_redirects=False,
         )
-    
+
     assert called_with == [form_data]
 
 
@@ -361,7 +361,7 @@ def test_login_redirect(app):
     r = yield get_page('login', app, cookies=cookies, allow_redirects=False)
     r.raise_for_status()
     assert r.status_code == 302
-    assert '/hub/' in r.headers['Location']
+    assert '/user/river' in r.headers['Location']
 
     # next URL given, use it
     r = yield get_page('login?next=/hub/admin', app, cookies=cookies, allow_redirects=False)
diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py
--- a/jupyterhub/tests/test_singleuser.py
+++ b/jupyterhub/tests/test_singleuser.py
@@ -2,6 +2,7 @@
 
 from subprocess import check_output
 import sys
+from urllib.parse import urlparse
 
 import pytest
 
@@ -17,25 +18,25 @@ def test_singleuser_auth(app):
     # use StubSingleUserSpawner to launch a single-user app in a thread
     app.spawner_class = StubSingleUserSpawner
     app.tornado_settings['spawner_class'] = StubSingleUserSpawner
-    
+
     # login, start the server
     cookies = yield app.login_user('nandy')
     user = app.users['nandy']
     if not user.running:
         yield user.spawn()
     url = public_url(app, user)
-    
+
     # no cookies, redirects to login page
     r = yield async_requests.get(url)
     r.raise_for_status()
     assert '/hub/login' in r.url
-    
+
     # with cookies, login successful
     r = yield async_requests.get(url, cookies=cookies)
     r.raise_for_status()
-    assert r.url.rstrip('/').endswith('/user/nandy/tree')
+    assert urlparse(r.url).path.rstrip('/').endswith('/user/nandy/tree')
     assert r.status_code == 200
-    
+
     # logout
     r = yield async_requests.get(url_path_join(url, 'logout'), cookies=cookies)
     assert len(r.cookies) == 0