mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 15:33:02 +00:00
Merge pull request #961 from minrk/get-user-simplified
Enable token authentication in HubAuth
This commit is contained in:
@@ -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'])
|
||||||
|
@@ -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
|
||||||
|
|
||||||
Raises an error if the response failed, otherwise returns the json
|
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)
|
||||||
|
|
||||||
|
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=''),
|
|
||||||
),
|
|
||||||
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)
|
auth_header_name = 'Authorization'
|
||||||
self.cookie_cache[token] = data
|
auth_header_pat = re.compile('token\s+(.+)', re.IGNORECASE)
|
||||||
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):
|
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.
|
||||||
|
|
||||||
|
@@ -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()
|
||||||
|
@@ -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')
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user