Merge pull request #961 from minrk/get-user-simplified

Enable token authentication in HubAuth
This commit is contained in:
Min RK
2017-01-26 09:55:35 +01:00
committed by GitHub
4 changed files with 163 additions and 73 deletions

View File

@@ -29,7 +29,7 @@ def main():
app = Application([ app = Application([
(os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', WhoAmIHandler), (os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', WhoAmIHandler),
(r'.*', WhoAmIHandler), (r'.*', WhoAmIHandler),
], login_url='/hub/login') ])
http_server = HTTPServer(app) http_server = HTTPServer(app)
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL']) url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])

View File

@@ -8,6 +8,7 @@ HubAuthenticated is a mixin class for tornado handlers that should authenticate
""" """
import os import os
import re
import socket import socket
import time import time
from urllib.parse import quote from urllib.parse import quote
@@ -18,10 +19,11 @@ from tornado.log import app_log
from tornado.web import HTTPError from tornado.web import HTTPError
from traitlets.config import Configurable 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 from ..utils import url_path_join
class _ExpiringDict(dict): class _ExpiringDict(dict):
"""Dict-like cache for Hub API requests """Dict-like cache for Hub API requests
@@ -124,8 +126,14 @@ class HubAuth(Configurable):
cookie_name = Unicode('jupyterhub-services', cookie_name = Unicode('jupyterhub-services',
help="""The name of the cookie I should be looking for""" help="""The name of the cookie I should be looking for"""
).tag(config=True) ).tag(config=True)
cookie_cache_max_age = Integer(300, cookie_cache_max_age = Integer(help="DEPRECATED. Use cache_max_age")
help="""The maximum time (in seconds) to cache the Hub's response for cookie authentication. @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 larger value reduces load on the Hub and occasional response lag.
A smaller value reduces propagation time of changes on the Hub (rare). A smaller value reduces propagation time of changes on the Hub (rare).
@@ -133,19 +141,51 @@ class HubAuth(Configurable):
Default: 300 (five minutes) Default: 300 (five minutes)
""" """
).tag(config=True) ).tag(config=True)
cookie_cache = Instance(_ExpiringDict, allow_none=False) cache = Instance(_ExpiringDict, allow_none=False)
@default('cookie_cache') @default('cache')
def _cookie_cache(self): def _default_cache(self):
return _ExpiringDict(self.cookie_cache_max_age) return _ExpiringDict(self.cache_max_age)
def _check_response_for_authorization(self, r): def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
"""Verify the response for authorizing a user """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)
Raises an error if the response failed, otherwise returns the json Returns:
user_model (dict): The user model, if a user is identified, None if authentication fails.
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: if r.status_code == 404:
app_log.warning("No Hub user identified for request") app_log.warning("No Hub user identified for request")
data = None
elif r.status_code == 403: 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) 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") raise HTTPError(500, "Permission failure checking authorization, I may need a new token")
@@ -158,7 +198,12 @@ class HubAuth(Configurable):
else: else:
data = r.json() data = r.json()
app_log.debug("Received request from Hub user %s", data) 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): def user_for_cookie(self, encrypted_cookie, use_cache=True):
"""Ask the Hub to identify the user for a given cookie. """Ask the Hub to identify the user for a given cookie.
@@ -172,32 +217,14 @@ class HubAuth(Configurable):
The 'name' field contains the user's name. The 'name' field contains the user's name.
""" """
if use_cache: return self._check_hub_authorization(
cached = self.cookie_cache.get(encrypted_cookie) url=url_path_join(self.api_url,
if cached is not None: "authorizations/cookie",
return cached self.cookie_name,
try: quote(encrypted_cookie, safe='')),
r = requests.get( cache_key='cookie:%s' % encrypted_cookie,
url_path_join(self.api_url, use_cache=use_cache,
"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
def user_for_token(self, token, use_cache=True): def user_for_token(self, token, use_cache=True):
"""Ask the Hub to identify the user for a given token. """Ask the Hub to identify the user for a given token.
@@ -211,31 +238,31 @@ class HubAuth(Configurable):
The 'name' field contains the user's name. The 'name' field contains the user's name.
""" """
if use_cache: return self._check_hub_authorization(
cached = self.cookie_cache.get(token) url=url_path_join(self.api_url,
if cached is not None: "authorizations/token",
return cached quote(token, safe='')),
try: cache_key='token:%s' % token,
r = requests.get( use_cache=use_cache,
url_path_join(self.api_url, )
"authorizations/token",
quote(token, safe=''), auth_header_name = 'Authorization'
), auth_header_pat = re.compile('token\s+(.+)', re.IGNORECASE)
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) def get_token(self, handler):
self.cookie_cache[token] = data """Get the user token from a request
return data
- 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): def get_user(self, handler):
"""Get the Hub user for a given tornado handler. """Get the Hub user for a given tornado handler.
@@ -257,15 +284,26 @@ class HubAuth(Configurable):
if hasattr(handler, '_cached_hub_user'): if hasattr(handler, '_cached_hub_user'):
return handler._cached_hub_user return handler._cached_hub_user
handler._cached_hub_user = None handler._cached_hub_user = user_model = None
encrypted_cookie = handler.get_cookie(self.cookie_name)
if encrypted_cookie: # check token first
user_model = self.user_for_cookie(encrypted_cookie) token = self.get_token(handler)
handler._cached_hub_user = user_model if token:
return user_model user_model = self.user_for_token(token)
else: if user_model:
app_log.debug("No token cookie") handler._token_authenticated = True
return None
# 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): class HubAuthenticated(object):
@@ -310,6 +348,10 @@ class HubAuthenticated(object):
def hub_auth(self, auth): def hub_auth(self, auth):
self._hub_auth = 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): def check_hub_user(self, user_model):
"""Check whether Hub-authenticated user should be allowed. """Check whether Hub-authenticated user should be allowed.

View File

@@ -62,6 +62,14 @@ class JupyterHubLoginHandler(LoginHandler):
def login_available(settings): def login_available(settings):
return True 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 @staticmethod
def get_user(handler): def get_user(handler):
"""alternative get_current_user to query the Hub""" """alternative get_current_user to query the Hub"""
@@ -290,7 +298,7 @@ class SingleUserNotebookApp(NotebookApp):
s['hub_prefix'] = self.hub_prefix s['hub_prefix'] = self.hub_prefix
s['hub_host'] = self.hub_host s['hub_host'] = self.hub_host
s['hub_auth'] = self.hub_auth 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') s['csp_report_uri'] = self.hub_host + url_path_join(self.hub_prefix, 'security/csp-report')
super(SingleUserNotebookApp, self).init_webapp() super(SingleUserNotebookApp, self).init_webapp()
self.patch_templates() self.patch_templates()

View File

@@ -4,6 +4,7 @@ import sys
from threading import Thread from threading import Thread
import time import time
from unittest import mock from unittest import mock
from urllib.parse import urlparse
from pytest import raises from pytest import raises
import requests import requests
@@ -16,6 +17,7 @@ from tornado.web import RequestHandler, Application, authenticated, HTTPError
from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated
from ..utils import url_path_join from ..utils import url_path_join
from .mocking import public_url from .mocking import public_url
from .test_api import add_user
# mock for sending monotonic counter way into the future # mock for sending monotonic counter way into the future
monotonic_future = mock.patch('time.monotonic', lambda : sys.maxsize) monotonic_future = mock.patch('time.monotonic', lambda : sys.maxsize)
@@ -227,3 +229,41 @@ def test_service_cookie_auth(app, mockservice_url):
'admin': False, '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')