Repository: jupyterhub/jupyterhub

Issue:
Auth problems with `@` in the username
I have students who keep putting their email address into the username. When they do this, there is an odd state that happens - the auth looks like it may be passing (using PAM), and I can see a single user server start, but the user sees a 500 page after auth. I am guessing that the PAM auth is raising a weird exception that we are not handling properly.



Discussion:
@ellisonbg the traceback will be in your logs. Can you share it?

Ran into the same issue when using emails as usernames (user@test.com, for example):

```
Traceback (most recent call last):
      File "/home/user/venv3/lib/python3.4/site-packages/tornado/web.py", line 1348, in _execute
        result = yield result
      File "/home/user/custom-auth/customauth.py", line 103, in post
        self.set_login_cookie(user)
      File "/home/user/venv3/lib/python3.4/site-packages/jupyterhub/handlers/base.py", line 174, in set_login_cookie
        self.set_server_cookie(user)
      File "/home/user/venv3/lib/python3.4/site-packages/jupyterhub/handlers/base.py", line 160, in set_server_cookie
        path=user.server.base_url,
      File "/home/user/venv3/lib/python3.4/site-packages/tornado/web.py", line 578, in set_secure_cookie
        expires_days=expires_days, **kwargs)
      File "/home/user/venv3/lib/python3.4/site-packages/tornado/web.py", line 513, in set_cookie
        self._new_cookie[name] = value
      File "/usr/lib/python3.4/http/cookies.py", line 489, in __setitem__
        self.__set(key, rval, cval)
      File "/usr/lib/python3.4/http/cookies.py", line 483, in __set
        M.set(key, real_value, coded_value)
      File "/usr/lib/python3.4/http/cookies.py", line 363, in set
        raise CookieError("Illegal key value: %s" % key)
    http.cookies.CookieError: Illegal key value: jupyter-hub-token-user@test.com
```

Apparently the '@' character is illegal in cookie keys.

seems like the fix for this is probably to base64 the username?

Made the base-64 fix in https://github.com/jupyter/jupyterhub/pull/230, but it breaks compatibility with older singleuser servers, as @minrk noted. 

Another option would be to replace all illegal chars with '-' or "_", which wouldn't affect existing singleuser servers, but it would introduce the possibility of colliding usernames ("test_user" and "test@user" would have the same cookie), which is bad. Or JupyterHub could just disallow usernames containing illegal cookie chars altogether (legal chars according to http.cookies https://github.com/python/cpython/blob/3.4/Lib/http/cookies.py#L159: `string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~:"`).

@ksolan _any_ change to the cookie name will require relaunching all single-user servers. It's not a huge deal, but it's significant compared to most updates to the Hub that don't require killing all the user servers..


Resolving patch:
diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py
--- a/jupyterhub/apihandlers/auth.py
+++ b/jupyterhub/apihandlers/auth.py
@@ -4,6 +4,7 @@
 # Distributed under the terms of the Modified BSD License.
 
 import json
+from urllib.parse import quote
 
 from tornado import web
 from .. import orm
@@ -25,6 +26,7 @@ def get(self, token):
 class CookieAPIHandler(APIHandler):
     @token_authenticated
     def get(self, cookie_name, cookie_value=None):
+        cookie_name = quote(cookie_name, safe='')
         if cookie_value is None:
             self.log.warn("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
             cookie_value = self.request.body
diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py
--- a/jupyterhub/orm.py
+++ b/jupyterhub/orm.py
@@ -7,6 +7,7 @@
 import errno
 import json
 import socket
+from urllib.parse import quote
 
 from tornado import gen
 from tornado.log import app_log
@@ -145,7 +146,7 @@ def __repr__(self):
             )
         else:
             return "<%s [unconfigured]>" % self.__class__.__name__
-
+    
     def api_request(self, path, method='GET', body=None, client=None):
         """Make an authenticated API request of the proxy"""
         client = client or AsyncHTTPClient()
@@ -299,6 +300,11 @@ def __repr__(self):
                 name=self.name,
             )
     
+    @property
+    def escaped_name(self):
+        """My name, escaped for use in URLs, cookies, etc."""
+        return quote(self.name, safe='@')
+    
     @property
     def running(self):
         """property for whether a user has a running server"""
@@ -333,9 +339,10 @@ def spawn(self, spawner_class, base_url='/', hub=None, config=None):
         db = inspect(self).session
         if hub is None:
             hub = db.query(Hub).first()
+        
         self.server = Server(
-            cookie_name='%s-%s' % (hub.server.cookie_name, self.name),
-            base_url=url_path_join(base_url, 'user', self.name),
+            cookie_name='%s-%s' % (hub.server.cookie_name, quote(self.name, safe='')),
+            base_url=url_path_join(base_url, 'user', self.escaped_name),
         )
         db.add(self.server)
         db.commit()


Test patch: