Repository: jupyterhub/jupyterhub

Issue:
Automatically expire JupyterHub and single-user server cookies at configurable intervals
Right now, once a user has logged in to their single-user server, they're considered validated forever unless they log out.  In particular, even if their hub token is revoked, the single-user server never learns about this fact because it happily accepts their existing single-user token.

I think the obvious fix for this is to set PeriodicCallbacks that purge token caches.  It might also make sense to set TTL values for the tornado cookies themselves.



Discussion:
Makes sense

Since this will require adding an expiry to each token in the db, it's a great time to explore alembic for db migration. I've been waiting for a db change to do that.


Resolving patch:
diff --git a/jupyterhub/app.py b/jupyterhub/app.py
--- a/jupyterhub/app.py
+++ b/jupyterhub/app.py
@@ -37,7 +37,7 @@
 
 from IPython.utils.traitlets import (
     Unicode, Integer, Dict, TraitError, List, Bool, Any,
-    Type, Set, Instance, Bytes,
+    Type, Set, Instance, Bytes, Float,
 )
 from IPython.config import Application, catch_config_error
 
@@ -185,6 +185,11 @@ class JupyterHub(Application):
         Useful for daemonizing jupyterhub.
         """
     )
+    cookie_max_age_days = Float(14, config=True,
+        help="""Number of days for a login cookie to be valid.
+        Default is two weeks.
+        """
+    )
     last_activity_interval = Integer(300, config=True,
         help="Interval (in seconds) at which to update last-activity timestamps."
     )
@@ -797,6 +802,7 @@ def init_tornado_settings(self):
             spawner_class=self.spawner_class,
             base_url=self.base_url,
             cookie_secret=self.cookie_secret,
+            cookie_max_age_days=self.cookie_max_age_days,
             login_url=login_url,
             logout_url=logout_url,
             static_path=os.path.join(self.data_files_path, 'static'),
diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py
--- a/jupyterhub/handlers/base.py
+++ b/jupyterhub/handlers/base.py
@@ -71,6 +71,10 @@ def finish(self, *args, **kwargs):
     @property
     def admin_users(self):
         return self.settings.setdefault('admin_users', set())
+    
+    @property
+    def cookie_max_age_days(self):
+        return self.settings.get('cookie_max_age_days', None)
 
     def get_current_user_token(self):
         """get_current_user from Authorization header token"""
@@ -87,16 +91,25 @@ def get_current_user_token(self):
     
     def _user_for_cookie(self, cookie_name, cookie_value=None):
         """Get the User for a given cookie, if there is one"""
-        cookie_id = self.get_secure_cookie(cookie_name, cookie_value)
+        cookie_id = self.get_secure_cookie(
+            cookie_name,
+            cookie_value,
+            max_age_days=self.cookie_max_age_days,
+        )
+        def clear():
+            self.clear_cookie(cookie_name, path=self.hub.server.base_url)
+        
         if cookie_id is None:
+            if self.get_cookie(cookie_name):
+                self.log.warn("Invalid or expired cookie token")
+                clear()
             return
         cookie_id = cookie_id.decode('utf8', 'replace')
         user = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
         if user is None:
-            # don't log the token itself
             self.log.warn("Invalid cookie token")
             # have cookie, but it's not valid. Clear it and start over.
-            self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
+            clear()
         return user
     
     def get_current_user_cookie(self):


Test patch: