Repository: jupyterhub/jupyterhub

Issue:
hub doesn't check if the reused token is valid
This is a follow-up of a discussion with @minrk in gitter.

Observed when starting a container using `DockerSpawner` that failed on changing permissions due to mounting a read-only volume in `/home/jovyan` using hub 0.7.2 and dockerspawner 0.7.0.

The initial spawn failed to start, that triggered token removal since the spawn failed, but on the other hand the container was still reused by the spawner and still used the old token.

If I understand @minrk's explanation correctly, the hub should check that the reused hub token is still valid.


Discussion:

Resolving patch:
diff --git a/jupyterhub/user.py b/jupyterhub/user.py
--- a/jupyterhub/user.py
+++ b/jupyterhub/user.py
@@ -385,12 +385,31 @@ def spawn(self, server_name='', options=None):
                 # prior to 0.7, spawners had to store this info in user.server themselves.
                 # Handle < 0.7 behavior with a warning, assuming info was stored in db by the Spawner.
                 self.log.warning("DEPRECATION: Spawner.start should return (ip, port) in JupyterHub >= 0.7")
-            if spawner.api_token != api_token:
+            if spawner.api_token and spawner.api_token != api_token:
                 # Spawner re-used an API token, discard the unused api_token
                 orm_token = orm.APIToken.find(self.db, api_token)
                 if orm_token is not None:
                     self.db.delete(orm_token)
                     self.db.commit()
+                # check if the re-used API token is valid
+                found = orm.APIToken.find(self.db, spawner.api_token)
+                if found:
+                    if found.user is not self.orm_user:
+                        self.log.error("%s's server is using %s's token! Revoking this token.",
+                            self.name, (found.user or found.service).name)
+                        self.db.delete(found)
+                        self.db.commit()
+                        raise ValueError("Invalid token for %s!" % self.name)
+                else:
+                    # Spawner.api_token has changed, but isn't in the db.
+                    # What happened? Maybe something unclean in a resumed container.
+                    self.log.warning("%s's server specified its own API token that's not in the database",
+                        self.name
+                    )
+                    # use generated=False because we don't trust this token
+                    # to have been generated properly
+                    self.new_api_token(spawner.api_token, generated=False)
+
         except Exception as e:
             if isinstance(e, gen.TimeoutError):
                 self.log.warning("{user}'s server failed to start in {s} seconds, giving up".format(


Test patch:
diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py
--- a/jupyterhub/tests/mocking.py
+++ b/jupyterhub/tests/mocking.py
@@ -7,8 +7,6 @@
 from unittest import mock
 from urllib.parse import urlparse
 
-import requests
-
 from tornado import gen
 from tornado.concurrent import Future
 from tornado.ioloop import IOLoop
@@ -58,6 +56,13 @@ def user_env(self, env):
     def _cmd_default(self):
         return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
 
+    use_this_api_token = None
+    def start(self):
+        if self.use_this_api_token:
+            self.api_token = self.use_this_api_token
+        elif self.will_resume:
+            self.use_this_api_token = self.api_token
+        return super().start()
 
 class SlowSpawner(MockSpawner):
     """A spawner that takes a few seconds to start"""
diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py
--- a/jupyterhub/tests/test_spawner.py
+++ b/jupyterhub/tests/test_spawner.py
@@ -15,11 +15,13 @@
 import pytest
 from tornado import gen
 
-from ..user import User
 from ..objects import Hub, Server
+from .. import orm
 from .. import spawner as spawnermod
 from ..spawner import LocalProcessSpawner, Spawner
-from .. import orm
+from ..user import User
+from ..utils import new_token
+from .test_api import add_user
 from .utils import async_requests
 
 _echo_sleep = """
@@ -270,3 +272,77 @@ def stop():
 
         def poll():
             pass
+
+
+@pytest.mark.gen_test
+def test_spawner_reuse_api_token(db, app):
+    # setup: user with no tokens, whose spawner has set the .will_resume flag
+    user = add_user(app.db, app, name='snoopy')
+    spawner = user.spawner
+    assert user.api_tokens == []
+    # will_resume triggers reuse of tokens
+    spawner.will_resume = True
+    # first start: gets a new API token
+    yield user.spawn()
+    api_token = spawner.api_token
+    found = orm.APIToken.find(app.db, api_token)
+    assert found
+    assert found.user.name == user.name
+    assert user.api_tokens == [found]
+    yield user.stop()
+    # second start: should reuse the token
+    yield user.spawn()
+    # verify re-use of API token
+    assert spawner.api_token == api_token
+    # verify that a new token was not created
+    assert user.api_tokens == [found]
+
+
+@pytest.mark.gen_test
+def test_spawner_insert_api_token(db, app):
+    """Token provided by spawner is not in the db
+    
+    Insert token into db as a user-provided token.
+    """
+    # setup: new user, double check that they don't have any tokens registered
+    user = add_user(app.db, app, name='tonkee')
+    spawner = user.spawner
+    assert user.api_tokens == []
+
+    # setup: spawner's going to use a token that's not in the db
+    api_token = new_token()
+    assert not orm.APIToken.find(app.db, api_token)
+    user.spawner.use_this_api_token = api_token
+    # The spawner's provided API token would already be in the db
+    # unless there is a bug somewhere else (in the Spawner),
+    # but handle it anyway.
+    yield user.spawn()
+    assert spawner.api_token == api_token
+    found = orm.APIToken.find(app.db, api_token)
+    assert found
+    assert found.user.name == user.name
+    assert user.api_tokens == [found]
+    yield user.stop()
+
+
+@pytest.mark.gen_test
+def test_spawner_bad_api_token(db, app):
+    """Tokens are revoked when a Spawner gets another user's token"""
+    # we need two users for this one
+    user = add_user(app.db, app, name='antimone')
+    spawner = user.spawner
+    other_user = add_user(app.db, app, name='alabaster')
+    assert user.api_tokens == []
+    assert other_user.api_tokens == []
+
+    # create a token owned by alabaster that antimone's going to try to use
+    other_token = other_user.new_api_token()
+    spawner.use_this_api_token = other_token
+    assert len(other_user.api_tokens) == 1
+
+    # starting a user's server with another user's token
+    # should revoke it
+    with pytest.raises(ValueError):
+        yield user.spawn()
+    assert orm.APIToken.find(app.db, other_token) is None
+    assert other_user.api_tokens == []