Repository: jupyterhub/jupyterhub

Issue:
Support token auth in single-user servers
The default LoginHandler will support token auth in 4.3: https://github.com/jupyter/notebook/pull/1831 . Since JupyterHub replaces the LoginHandler, token auth won't work with JupyterHub without modifications. We should add support for the Authorization header in the single-user server so that JupyterHub tokens can be used to access single-user servers via the Authorization header.

This will also likely drive demand for creation of new API tokens via UI, but that can be a separate step.

cf https://github.com/jupyterhub/jupyterhub-deploy-docker/issues/30


Discussion:
@minrk ... is there anything I might be able to help with here, in order to be able to get the notebook API working against user servers running under Jupyterhub?
If you'd like to make a PR, that would be great! The first thing to do is get the token auth from [here](https://github.com/jupyter/notebook/blob/4.x/notebook/auth/login.py#L100) and include the token checking (Authorization header and possibly `?token` parameter) in the single-user auth [here](https://github.com/jupyterhub/jupyterhub/blob/0.7.0/jupyterhub/services/auth.py#L192). It will need to check the *token* auth endpoint, rather than cookie.
@minrk .... not unwilling to help ... but I'm not sure my python skills are up to that!  
If you'd like, I can help you through it if you want to start a PR and ask questions (feel free to open a PR early and ask for help!). If you think it's too much, don't worry; we can take care of it, too.
@minrk ... I'm being a bit lame, I realise ... but I expect you are able to implement this more expeditiously than I could manage!
Not at all! Feel free to tackle a problem you are more comfortable with. We got this covered. And don't hesitate to ask for help.
Question:

Jupyter notebooks offered through JupyterHub are already "secured" using the hub for the login and the cookie.

What is the point to add this support ?

is not easier to set 

`c.NotebookApp.token = ''`

`c.NotebookApp.password = ''`

?

[This comment above](https://github.com/jupyterhub/jupyterhub/issues/891#issuecomment-265150188) is the main thing to implement. Everything's in place in that JupyterHub has tokens, and an API for asking the Hub who a token corresponds to (`GET /authorizations/token/:token`). What's needed is for the HubAuthenticated wrapper to look for tokens in the Authorization header and url parameters, in addition to the cookie it supports now. Everything else ought to be set.

Right now, we have:

- `HubAuth.get_user` which calls `HubAuth.user_for_cookie` to identify the user

We need to add checking for tokens to `HubAuth.get_user`, with:

```python
class HubAuth():
    def _get_token(self, handler):
        """Return the token from the Authorization header or ?token url query parameter

        returns None if no token found
        """

    def user_for_token(self, token):
        """Ask the Hub for the user identified by a token

        returns user model (dict) if a user is identified, None otherwise
        """
        # same as user_for_cookie, but use the token API instead of the cookie API
```


Resolving patch:
diff --git a/examples/service-whoami-flask/whoami.py b/examples/service-whoami-flask/whoami.py
--- a/examples/service-whoami-flask/whoami.py
+++ b/examples/service-whoami-flask/whoami.py
@@ -29,7 +29,7 @@ def main():
     app = Application([
         (os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', WhoAmIHandler),
         (r'.*', WhoAmIHandler),
-    ], login_url='/hub/login')
+    ])
     
     http_server = HTTPServer(app)
     url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py
--- a/jupyterhub/services/auth.py
+++ b/jupyterhub/services/auth.py
@@ -8,6 +8,7 @@
 """
 
 import os
+import re
 import socket
 import time
 from urllib.parse import quote
@@ -18,10 +19,11 @@
 from tornado.web import HTTPError
 
 from traitlets.config import Configurable
-from traitlets import Unicode, Integer, Instance, default
+from traitlets import Unicode, Integer, Instance, default, observe
 
 from ..utils import url_path_join
 
+
 class _ExpiringDict(dict):
     """Dict-like cache for Hub API requests
 
@@ -124,8 +126,14 @@ class HubAuth(Configurable):
     cookie_name = Unicode('jupyterhub-services',
         help="""The name of the cookie I should be looking for"""
     ).tag(config=True)
-    cookie_cache_max_age = Integer(300,
-        help="""The maximum time (in seconds) to cache the Hub's response for cookie authentication.
+    cookie_cache_max_age = Integer(help="DEPRECATED. Use cache_max_age")
+    @observe('cookie_cache_max_age')
+    def _deprecated_cookie_cache(self, change):
+        warnings.warn("cookie_cache_max_age is deprecated in JupyterHub 0.8. Use cache_max_age instead.")
+        self.cache_max_age = change.new
+
+    cache_max_age = Integer(300,
+        help="""The maximum time (in seconds) to cache the Hub's responses for authentication.
 
         A larger value reduces load on the Hub and occasional response lag.
         A smaller value reduces propagation time of changes on the Hub (rare).
@@ -133,19 +141,51 @@ class HubAuth(Configurable):
         Default: 300 (five minutes)
         """
     ).tag(config=True)
-    cookie_cache = Instance(_ExpiringDict, allow_none=False)
-    @default('cookie_cache')
-    def _cookie_cache(self):
-        return _ExpiringDict(self.cookie_cache_max_age)
+    cache = Instance(_ExpiringDict, allow_none=False)
+    @default('cache')
+    def _default_cache(self):
+        return _ExpiringDict(self.cache_max_age)
+
+    def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
+        """Identify a user with the Hub
+        
+        Args:
+            url (str): The API URL to check the Hub for authorization
+                       (e.g. http://127.0.0.1:8081/hub/api/authorizations/token/abc-def)
+            cache_key (str): The key for checking the cache
+            use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
 
-    def _check_response_for_authorization(self, r):
-        """Verify the response for authorizing a user
+        Returns:
+            user_model (dict): The user model, if a user is identified, None if authentication fails.
 
-        Raises an error if the response failed, otherwise returns the json
+        Raises an HTTPError if the request failed for a reason other than no such user.
         """
+        if use_cache:
+            if cache_key is None:
+                raise ValueError("cache_key is required when using cache")
+            # check for a cached reply, so we don't check with the Hub if we don't have to
+            cached = self.cache.get(cache_key)
+            if cached is not None:
+                return cached
+
+        try:
+            r = requests.get(url,
+                headers = {
+                    'Authorization' : 'token %s' % self.api_token,
+                },
+            )
+        except requests.ConnectionError:
+            msg = "Failed to connect to Hub API at %r." % self.api_url
+            msg += "  Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname()
+            if '127.0.0.1' in self.api_url:
+                msg += "  Make sure to set c.JupyterHub.hub_ip to an IP accessible to" + \
+                       " single-user servers if the servers are not on the same host as the Hub."
+            raise HTTPError(500, msg)
+
+        data = None
+        print(r.status_code)
         if r.status_code == 404:
             app_log.warning("No Hub user identified for request")
-            data = None
         elif r.status_code == 403:
             app_log.error("I don't have permission to check authorization with JupyterHub, my auth token may have expired: [%i] %s", r.status_code, r.reason)
             raise HTTPError(500, "Permission failure checking authorization, I may need a new token")
@@ -158,7 +198,12 @@ def _check_response_for_authorization(self, r):
         else:
             data = r.json()
             app_log.debug("Received request from Hub user %s", data)
-            return data
+
+        if use_cache:
+            # cache result
+            self.cache[cache_key] = data
+        return data
+
 
     def user_for_cookie(self, encrypted_cookie, use_cache=True):
         """Ask the Hub to identify the user for a given cookie.
@@ -172,32 +217,14 @@ def user_for_cookie(self, encrypted_cookie, use_cache=True):
 
             The 'name' field contains the user's name.
         """
-        if use_cache:
-            cached = self.cookie_cache.get(encrypted_cookie)
-            if cached is not None:
-                return cached
-        try:
-            r = requests.get(
-                url_path_join(self.api_url,
-                              "authorizations/cookie",
-                              self.cookie_name,
-                              quote(encrypted_cookie, safe=''),
-                ),
-                headers = {
-                    'Authorization' : 'token %s' % self.api_token,
-                },
-            )
-        except requests.ConnectionError:
-            msg = "Failed to connect to Hub API at %r." % self.api_url
-            msg += "  Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname()
-            if '127.0.0.1' in self.api_url:
-                msg += "  Make sure to set c.JupyterHub.hub_ip to an IP accessible to" + \
-                       " single-user servers if the servers are not on the same host as the Hub."
-            raise HTTPError(500, msg)
-
-        data = self._check_response_for_authorization(r)
-        self.cookie_cache[encrypted_cookie] = data
-        return data
+        return self._check_hub_authorization(
+            url=url_path_join(self.api_url,
+                          "authorizations/cookie",
+                          self.cookie_name,
+                          quote(encrypted_cookie, safe='')),
+            cache_key='cookie:%s' % encrypted_cookie,
+            use_cache=use_cache,
+        )
 
     def user_for_token(self, token, use_cache=True):
         """Ask the Hub to identify the user for a given token.
@@ -211,31 +238,31 @@ def user_for_token(self, token, use_cache=True):
 
             The 'name' field contains the user's name.
         """
-        if use_cache:
-            cached = self.cookie_cache.get(token)
-            if cached is not None:
-                return cached
-        try:
-            r = requests.get(
-                url_path_join(self.api_url,
-                              "authorizations/token",
-                              quote(token, safe=''),
-                ),
-                headers = {
-                    'Authorization' : 'token %s' % self.api_token,
-                },
-            )
-        except requests.ConnectionError:
-            msg = "Failed to connect to Hub API at %r." % self.api_url
-            msg += "  Is the Hub accessible at this URL (from host: %s)?" % socket.gethostname()
-            if '127.0.0.1' in self.api_url:
-                msg += "  Make sure to set c.JupyterHub.hub_ip to an IP accessible to" + \
-                       " single-user servers if the servers are not on the same host as the Hub."
-            raise HTTPError(500, msg)
+        return self._check_hub_authorization(
+            url=url_path_join(self.api_url,
+                "authorizations/token",
+                quote(token, safe='')),
+            cache_key='token:%s' % token,
+            use_cache=use_cache,
+        )
+    
+    auth_header_name = 'Authorization'
+    auth_header_pat = re.compile('token\s+(.+)', re.IGNORECASE)
 
-        data = self._check_response_for_authorization(r)
-        self.cookie_cache[token] = data
-        return data
+    def get_token(self, handler):
+        """Get the user token from a request
+
+        - in URL parameters: ?token=<token>
+        - in header: Authorization: token <token>
+        """
+
+        user_token = handler.get_argument('token', '')
+        if not user_token:
+            # get it from Authorization header
+            m = self.auth_header_pat.match(handler.request.headers.get(self.auth_header_name, ''))
+            if m:
+                user_token = m.group(1)
+        return user_token
 
     def get_user(self, handler):
         """Get the Hub user for a given tornado handler.
@@ -257,15 +284,26 @@ def get_user(self, handler):
         if hasattr(handler, '_cached_hub_user'):
             return handler._cached_hub_user
 
-        handler._cached_hub_user = None
-        encrypted_cookie = handler.get_cookie(self.cookie_name)
-        if encrypted_cookie:
-            user_model = self.user_for_cookie(encrypted_cookie)
-            handler._cached_hub_user = user_model
-            return user_model
-        else:
-            app_log.debug("No token cookie")
-            return None
+        handler._cached_hub_user = user_model = None
+
+        # check token first
+        token = self.get_token(handler)
+        if token:
+            user_model = self.user_for_token(token)
+            if user_model:
+                handler._token_authenticated = True
+
+        # no token, check cookie
+        if user_model is None:
+            encrypted_cookie = handler.get_cookie(self.cookie_name)
+            if encrypted_cookie:
+                user_model = self.user_for_cookie(encrypted_cookie)
+
+        # cache result
+        handler._cached_hub_user = user_model
+        if not user_model:
+            app_log.debug("No user identified")
+        return user_model
 
 
 class HubAuthenticated(object):
@@ -310,6 +348,10 @@ def hub_auth(self):
     def hub_auth(self, auth):
         self._hub_auth = auth
 
+    def get_login_url(self):
+        """Return the Hub's login URL"""
+        return self.hub_auth.login_url
+
     def check_hub_user(self, user_model):
         """Check whether Hub-authenticated user should be allowed.
 
diff --git a/jupyterhub/singleuser.py b/jupyterhub/singleuser.py
--- a/jupyterhub/singleuser.py
+++ b/jupyterhub/singleuser.py
@@ -62,6 +62,14 @@ class JupyterHubLoginHandler(LoginHandler):
     def login_available(settings):
         return True
 
+    @staticmethod
+    def is_token_authenticated(handler):
+        """Is the request token-authenticated?"""
+        if getattr(handler, '_cached_hub_user', None) is None:
+            # ensure get_user has been called, so we know if we're token-authenticated
+            handler.get_current_user()
+        return getattr(handler, '_token_authenticated', False)
+
     @staticmethod
     def get_user(handler):
         """alternative get_current_user to query the Hub"""
@@ -290,7 +298,7 @@ def init_webapp(self):
         s['hub_prefix'] = self.hub_prefix
         s['hub_host'] = self.hub_host
         s['hub_auth'] = self.hub_auth
-        s['login_url'] = self.hub_host + self.hub_prefix
+        self.hub_auth.login_url = self.hub_host + self.hub_prefix
         s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report')
         super(SingleUserNotebookApp, self).init_webapp()
         self.patch_templates()


Test patch:
diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py
--- a/jupyterhub/tests/test_services_auth.py
+++ b/jupyterhub/tests/test_services_auth.py
@@ -4,6 +4,7 @@
 from threading import Thread
 import time
 from unittest import mock
+from urllib.parse import urlparse
 
 from pytest import raises
 import requests
@@ -16,6 +17,7 @@
 from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated
 from ..utils import url_path_join
 from .mocking import public_url
+from .test_api import add_user
 
 # mock for sending monotonic counter way into the future
 monotonic_future = mock.patch('time.monotonic', lambda : sys.maxsize)
@@ -227,3 +229,41 @@ def test_service_cookie_auth(app, mockservice_url):
         'admin': False,
     }
 
+
+def test_service_token_auth(app, mockservice_url):
+    u = add_user(app.db, name='river')
+    token = u.new_api_token()
+    app.db.commit()
+
+    # token in Authorization header
+    r = requests.get(public_url(app, mockservice_url) + '/whoami/',
+        headers={
+            'Authorization': 'token %s' % token,
+        })
+    r.raise_for_status()
+    reply = r.json()
+    sub_reply = { key: reply.get(key, 'missing') for key in ['name', 'admin']}
+    assert sub_reply == {
+        'name': 'river',
+        'admin': False,
+    }
+
+    # token in ?token parameter
+    r = requests.get(public_url(app, mockservice_url) + '/whoami/?token=%s' % token)
+    r.raise_for_status()
+    reply = r.json()
+    sub_reply = { key: reply.get(key, 'missing') for key in ['name', 'admin']}
+    assert sub_reply == {
+        'name': 'river',
+        'admin': False,
+    }
+
+    r = requests.get(public_url(app, mockservice_url) + '/whoami/?token=no-such-token',
+        allow_redirects=False,
+    )
+    assert r.status_code == 302
+    assert 'Location' in r.headers
+    location = r.headers['Location']
+    path = urlparse(location).path
+    assert path.endswith('/hub/login')
+