mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 13:03:01 +00:00
Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4692d6638d | ||
![]() |
7829070e1c | ||
![]() |
5e4b935322 | ||
![]() |
4c445c7a88 | ||
![]() |
8e2965df6a | ||
![]() |
7a41d24606 | ||
![]() |
5f84a006dc | ||
![]() |
e19296a230 | ||
![]() |
89ba97f413 | ||
![]() |
fe2157130b | ||
![]() |
e3b17e8176 | ||
![]() |
027f2f95c6 | ||
![]() |
210975324a | ||
![]() |
932689f2f8 | ||
![]() |
f91e911d1a | ||
![]() |
b75cce857e | ||
![]() |
62f00690f7 | ||
![]() |
f700ba4154 | ||
![]() |
8b91842eae | ||
![]() |
80a9eb93f4 | ||
![]() |
e1deecbbfb | ||
![]() |
d3142704b7 | ||
![]() |
447edd081a | ||
![]() |
e1531ec277 | ||
![]() |
d12ac4b1f6 |
@@ -31,6 +31,7 @@ contribution on JupyterHub:
|
||||
- JamiesHQ
|
||||
- jbweston
|
||||
- jdavidheiser
|
||||
- jencabral
|
||||
- jhamrick
|
||||
- josephtate
|
||||
- kinuax
|
||||
|
@@ -7,7 +7,7 @@ version_info = (
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
'b3',
|
||||
'b4',
|
||||
)
|
||||
|
||||
__version__ = '.'.join(map(str, version_info))
|
||||
@@ -28,6 +28,7 @@ def _check_version(hub_version, singleuser_version, log):
|
||||
from distutils.version import LooseVersion as V
|
||||
hub_major_minor = V(hub_version).version[:2]
|
||||
singleuser_major_minor = V(singleuser_version).version[:2]
|
||||
extra = ""
|
||||
if singleuser_major_minor == hub_major_minor:
|
||||
# patch-level mismatch or lower, log difference at debug-level
|
||||
# because this should be fine
|
||||
@@ -35,8 +36,11 @@ def _check_version(hub_version, singleuser_version, log):
|
||||
else:
|
||||
# log warning-level for more significant mismatch, such as 0.8 vs 0.9, etc.
|
||||
log_method = log.warning
|
||||
log_method("jupyterhub version %s != jupyterhub-singleuser version %s",
|
||||
hub_version, singleuser_version,
|
||||
extra = " This could cause failure to authenticate and result in redirect loops!"
|
||||
log_method(
|
||||
"jupyterhub version %s != jupyterhub-singleuser version %s." + extra,
|
||||
hub_version,
|
||||
singleuser_version,
|
||||
)
|
||||
else:
|
||||
log.debug("jupyterhub and jupyterhub-singleuser both on version %s" % hub_version)
|
||||
|
@@ -41,15 +41,27 @@ class TokenAPIHandler(APIHandler):
|
||||
# for authenticators where that's possible
|
||||
data = self.get_json_body()
|
||||
try:
|
||||
authenticated = yield self.authenticate(self, data)
|
||||
user = yield self.login_user(data)
|
||||
except Exception as e:
|
||||
self.log.error("Failure trying to authenticate with form data: %s" % e)
|
||||
authenticated = None
|
||||
if authenticated is None:
|
||||
user = None
|
||||
if user is None:
|
||||
raise web.HTTPError(403)
|
||||
user = self.find_user(authenticated['name'])
|
||||
else:
|
||||
data = self.get_json_body()
|
||||
# admin users can request
|
||||
if data and data.get('username') != user.name:
|
||||
if user.admin:
|
||||
user = self.find_user(data['username'])
|
||||
if user is None:
|
||||
raise web.HTTPError(400, "No such user '%s'" % data['username'])
|
||||
else:
|
||||
raise web.HTTPError(403, "Only admins can request tokens for other users.")
|
||||
api_token = user.new_api_token()
|
||||
self.write(json.dumps({'token': api_token}))
|
||||
self.write(json.dumps({
|
||||
'token': api_token,
|
||||
'user': self.user_model(user),
|
||||
}))
|
||||
|
||||
|
||||
class CookieAPIHandler(APIHandler):
|
||||
|
@@ -20,7 +20,7 @@ from .. import __version__
|
||||
from .. import orm
|
||||
from ..objects import Server
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..utils import default_server_name, url_path_join, exponential_backoff
|
||||
from ..utils import default_server_name, url_path_join
|
||||
|
||||
# pattern for the authentication token header
|
||||
auth_header_pat = re.compile(r'^(?:token|bearer)\s+([^\s]+)$', flags=re.IGNORECASE)
|
||||
@@ -347,7 +347,7 @@ class BaseHandler(RequestHandler):
|
||||
else:
|
||||
self.statsd.incr('login.failure')
|
||||
self.statsd.timing('login.authenticate.failure', auth_timer.ms)
|
||||
self.log.warning("Failed login for %s", data.get('username', 'unknown user'))
|
||||
self.log.warning("Failed login for %s", (data or {}).get('username', 'unknown user'))
|
||||
|
||||
|
||||
#---------------------------------------------------------------
|
||||
@@ -675,7 +675,10 @@ class UserSpawnHandler(BaseHandler):
|
||||
return
|
||||
|
||||
# spawn has supposedly finished, check on the status
|
||||
status = yield spawner.poll()
|
||||
if spawner.ready:
|
||||
status = yield spawner.poll()
|
||||
else:
|
||||
status = 0
|
||||
if status is not None:
|
||||
if spawner.options_form:
|
||||
self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'),
|
||||
@@ -693,9 +696,23 @@ class UserSpawnHandler(BaseHandler):
|
||||
self.log.warning("Invalid redirects argument %r", self.get_argument('redirects'))
|
||||
redirects = 0
|
||||
|
||||
if redirects >= self.settings.get('user_redirect_limit', 5):
|
||||
# check redirect limit to prevent browser-enforced limits.
|
||||
# In case of version mismatch, raise on only two redirects.
|
||||
if redirects >= self.settings.get(
|
||||
'user_redirect_limit', 4
|
||||
) or (redirects >= 2 and spawner._jupyterhub_version != __version__):
|
||||
# We stop if we've been redirected too many times.
|
||||
raise web.HTTPError(500, "Redirect loop detected.")
|
||||
msg = "Redirect loop detected."
|
||||
if spawner._jupyterhub_version != __version__:
|
||||
msg += (
|
||||
" Notebook has jupyterhub version {singleuser}, but the Hub expects {hub}."
|
||||
" Try installing jupyterhub=={hub} in the user environment"
|
||||
" if you continue to have problems."
|
||||
).format(
|
||||
singleuser=spawner._jupyterhub_version or 'unknown (likely < 0.8)',
|
||||
hub=__version__,
|
||||
)
|
||||
raise web.HTTPError(500, msg)
|
||||
|
||||
# set login cookie anew
|
||||
self.set_login_cookie(current_user)
|
||||
|
@@ -375,7 +375,7 @@ class Proxy(LoggingConfigurable):
|
||||
self.log.info("Setting up routes on new proxy")
|
||||
yield self.add_hub_route(self.app.hub)
|
||||
yield self.add_all_users(self.app.users)
|
||||
yield self.add_all_services(self.app.services)
|
||||
yield self.add_all_services(self.app._service_map)
|
||||
self.log.info("New proxy back up and good to go")
|
||||
|
||||
|
||||
|
@@ -731,6 +731,19 @@ class HubAuthenticated(object):
|
||||
except Exception:
|
||||
self._hub_auth_user_cache = None
|
||||
raise
|
||||
|
||||
# store ?token=... tokens passed via url in a cookie for future requests
|
||||
url_token = self.get_argument('token', '')
|
||||
if (
|
||||
user_model
|
||||
and url_token
|
||||
and getattr(self, '_token_authenticated', False)
|
||||
and hasattr(self.hub_auth, 'set_cookie')
|
||||
):
|
||||
# authenticated via `?token=`
|
||||
# set a cookie for future requests
|
||||
# hub_auth.set_cookie is only available on HubOAuth
|
||||
self.hub_auth.set_cookie(self, url_token)
|
||||
return self._hub_auth_user_cache
|
||||
|
||||
|
||||
|
@@ -18,7 +18,7 @@ from tempfile import mkdtemp
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from tornado import gen
|
||||
from tornado.ioloop import PeriodicCallback, IOLoop
|
||||
from tornado.ioloop import PeriodicCallback
|
||||
|
||||
from traitlets.config import LoggingConfigurable
|
||||
from traitlets import (
|
||||
@@ -53,6 +53,7 @@ class Spawner(LoggingConfigurable):
|
||||
_stop_pending = False
|
||||
_proxy_pending = False
|
||||
_waiting_for_response = False
|
||||
_jupyterhub_version = None
|
||||
|
||||
@property
|
||||
def _log_name(self):
|
||||
@@ -101,6 +102,7 @@ class Spawner(LoggingConfigurable):
|
||||
authenticator = Any()
|
||||
hub = Any()
|
||||
orm_spawner = Any()
|
||||
db = Any()
|
||||
|
||||
@observe('orm_spawner')
|
||||
def _orm_spawner_changed(self, change):
|
||||
|
@@ -89,7 +89,7 @@ def api_request(app, *api_path, **kwargs):
|
||||
base_url = app.hub.url
|
||||
headers = kwargs.setdefault('headers', {})
|
||||
|
||||
if 'Authorization' not in headers:
|
||||
if 'Authorization' not in headers and not kwargs.pop('noauth', False):
|
||||
headers.update(auth_header(app.db, 'admin'))
|
||||
|
||||
url = ujoin(base_url, 'api', *api_path)
|
||||
@@ -755,16 +755,16 @@ def test_token(app):
|
||||
|
||||
|
||||
@mark.gen_test
|
||||
@mark.parametrize("headers, data, status", [
|
||||
({}, None, 200),
|
||||
({'Authorization': ''}, None, 403),
|
||||
({}, {'username': 'fake', 'password': 'fake'}, 200),
|
||||
@mark.parametrize("headers, status", [
|
||||
({}, 200),
|
||||
({'Authorization': 'token bad'}, 403),
|
||||
])
|
||||
def test_get_new_token(app, headers, data, status):
|
||||
if data:
|
||||
data = json.dumps(data)
|
||||
def test_get_new_token(app, headers, status):
|
||||
# request a new token
|
||||
r = yield api_request(app, 'authorizations', 'token', method='post', data=data, headers=headers)
|
||||
r = yield api_request(app, 'authorizations', 'token',
|
||||
method='post',
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == status
|
||||
if status != 200:
|
||||
return
|
||||
@@ -772,7 +772,61 @@ def test_get_new_token(app, headers, data, status):
|
||||
assert 'token' in reply
|
||||
r = yield api_request(app, 'authorizations', 'token', reply['token'])
|
||||
r.raise_for_status()
|
||||
assert 'name' in r.json()
|
||||
reply = r.json()
|
||||
assert reply['name'] == 'admin'
|
||||
|
||||
|
||||
@mark.gen_test
|
||||
def test_token_formdata(app):
|
||||
"""Create a token for a user with formdata and no auth header"""
|
||||
data = {
|
||||
'username': 'fake',
|
||||
'password': 'fake',
|
||||
}
|
||||
r = yield api_request(app, 'authorizations', 'token',
|
||||
method='post',
|
||||
data=json.dumps(data) if data else None,
|
||||
noauth=True,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert 'token' in reply
|
||||
r = yield api_request(app, 'authorizations', 'token', reply['token'])
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == data['username']
|
||||
|
||||
|
||||
@mark.gen_test
|
||||
@mark.parametrize("as_user, for_user, status", [
|
||||
('admin', 'other', 200),
|
||||
('admin', 'missing', 400),
|
||||
('user', 'other', 403),
|
||||
('user', 'user', 200),
|
||||
])
|
||||
def test_token_as_user(app, as_user, for_user, status):
|
||||
# ensure both users exist
|
||||
u = add_user(app.db, app, name=as_user)
|
||||
if for_user != 'missing':
|
||||
add_user(app.db, app, name=for_user)
|
||||
data = {'username': for_user}
|
||||
headers = {
|
||||
'Authorization': 'token %s' % u.new_api_token(),
|
||||
}
|
||||
r = yield api_request(app, 'authorizations', 'token',
|
||||
method='post',
|
||||
data=json.dumps(data),
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == status
|
||||
reply = r.json()
|
||||
if status != 200:
|
||||
return
|
||||
assert 'token' in reply
|
||||
r = yield api_request(app, 'authorizations', 'token', reply['token'])
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == data['username']
|
||||
|
||||
|
||||
# ---------------
|
||||
|
@@ -279,7 +279,7 @@ def test_hubauth_service_token(app, mockservice_url):
|
||||
name = 'test-api-service'
|
||||
app.service_tokens[token] = name
|
||||
yield app.init_api_tokens()
|
||||
|
||||
|
||||
# token in Authorization header
|
||||
r = yield async_requests.get(public_url(app, mockservice_url) + '/whoami/',
|
||||
headers={
|
||||
@@ -292,6 +292,7 @@ def test_hubauth_service_token(app, mockservice_url):
|
||||
'name': name,
|
||||
'admin': False,
|
||||
}
|
||||
assert not r.cookies
|
||||
|
||||
# token in ?token parameter
|
||||
r = yield async_requests.get(public_url(app, mockservice_url) + '/whoami/?token=%s' % token)
|
||||
@@ -319,7 +320,8 @@ def test_oauth_service(app, mockservice_url):
|
||||
# first request is only going to set login cookie
|
||||
# FIXME: redirect to originating URL (OAuth loses this info)
|
||||
s = requests.Session()
|
||||
s.cookies = yield app.login_user('link')
|
||||
name = 'link'
|
||||
s.cookies = yield app.login_user(name)
|
||||
# run session.get in async_requests thread
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
r = yield s_get(url)
|
||||
@@ -335,3 +337,23 @@ def test_oauth_service(app, mockservice_url):
|
||||
'kind': 'user',
|
||||
}
|
||||
|
||||
# token-authenticated request to HubOAuth
|
||||
token = app.users[name].new_api_token()
|
||||
# token in ?token parameter
|
||||
r = yield async_requests.get(public_url(app, mockservice_url) + 'owhoami/?token=%s' % token)
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == name
|
||||
|
||||
# verify that ?token= requests set a cookie
|
||||
assert len(r.cookies) != 0
|
||||
# ensure cookie works in future requests
|
||||
r = yield async_requests.get(
|
||||
public_url(app, mockservice_url) + 'owhoami/',
|
||||
cookies=r.cookies,
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert reply['name'] == name
|
||||
|
||||
|
@@ -201,6 +201,7 @@ class User(HasTraits):
|
||||
authenticator=self.authenticator,
|
||||
config=self.settings.get('config'),
|
||||
proxy_spec=url_path_join(self.proxy_spec, name, '/'),
|
||||
db=self.db,
|
||||
)
|
||||
# update with kwargs. Mainly for testing.
|
||||
spawn_kwargs.update(kwargs)
|
||||
@@ -472,6 +473,9 @@ class User(HasTraits):
|
||||
else:
|
||||
server_version = resp.headers.get('X-JupyterHub-Version')
|
||||
_check_version(__version__, server_version, self.log)
|
||||
# record the Spawner version for better error messages
|
||||
# if it doesn't work
|
||||
spawner._jupyterhub_version = server_version
|
||||
finally:
|
||||
spawner._waiting_for_response = False
|
||||
spawner._start_pending = False
|
||||
|
@@ -142,7 +142,8 @@ def wait_for_server(ip, port, timeout=10):
|
||||
ip = '127.0.0.1'
|
||||
yield exponential_backoff(
|
||||
lambda: can_connect(ip, port),
|
||||
"Server at {ip}:{port} didn't respond in {timeout} seconds".format(ip=ip, port=port, timeout=timeout)
|
||||
"Server at {ip}:{port} didn't respond in {timeout} seconds".format(ip=ip, port=port, timeout=timeout),
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
@@ -175,7 +176,8 @@ def wait_for_http_server(url, timeout=10):
|
||||
return False
|
||||
re = yield exponential_backoff(
|
||||
is_reachable,
|
||||
"Server at {url} didn't respond in {timeout} seconds".format(url=url, timeout=timeout)
|
||||
"Server at {url} didn't respond in {timeout} seconds".format(url=url, timeout=timeout),
|
||||
timeout=timeout
|
||||
)
|
||||
return re
|
||||
|
||||
|
@@ -32,9 +32,9 @@
|
||||
<tbody>
|
||||
<tr class="user-row add-user-row">
|
||||
<td colspan="12">
|
||||
<a id="add-users" class="col-xs-2 btn btn-default">Add Users</a>
|
||||
<a id="stop-all-servers" class="col-xs-2 col-xs-offset-5 btn btn-danger">Stop All</a>
|
||||
<a id="shutdown-hub" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a>
|
||||
<a id="add-users" role="button" class="col-xs-2 btn btn-default">Add Users</a>
|
||||
<a id="stop-all-servers" role="button" class="col-xs-2 col-xs-offset-5 btn btn-danger">Stop All</a>
|
||||
<a id="shutdown-hub" role="button" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% for u in users %}
|
||||
@@ -44,20 +44,20 @@
|
||||
<td class="admin-col col-sm-2">{% if u.admin %}admin{% endif %}</td>
|
||||
<td class="time-col col-sm-3">{{u.last_activity.isoformat() + 'Z'}}</td>
|
||||
<td class="server-col col-sm-2 text-center">
|
||||
<span class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span>
|
||||
<span class="start-server btn btn-xs btn-success {% if u.running %}hidden{% endif %}">start server</span>
|
||||
<span role="button" class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span>
|
||||
<span role="button" class="start-server btn btn-xs btn-success {% if u.running %}hidden{% endif %}">start server</span>
|
||||
</td>
|
||||
<td class="server-col col-sm-1 text-center">
|
||||
{% if admin_access %}
|
||||
<span class="access-server btn btn-xs btn-success {% if not u.running %}hidden{% endif %}">access server</span>
|
||||
<span role="button" class="access-server btn btn-xs btn-success {% if not u.running %}hidden{% endif %}">access server</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="edit-col col-sm-1 text-center">
|
||||
<span class="edit-user btn btn-xs btn-primary">edit</span>
|
||||
<span role="button" class="edit-user btn btn-xs btn-primary">edit</span>
|
||||
</td>
|
||||
<td class="edit-col col-sm-1 text-center">
|
||||
{% if u.name != user.name %}
|
||||
<span class="delete-user btn btn-xs btn-danger">delete</span>
|
||||
<span role="button" class="delete-user btn btn-xs btn-danger">delete</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endblock user_row %}
|
||||
|
@@ -6,9 +6,9 @@
|
||||
<div class="row">
|
||||
<div class="text-center">
|
||||
{% if user.running %}
|
||||
<a id="stop" class="btn btn-lg btn-danger">Stop My Server</a>
|
||||
<a id="stop" role="button" class="btn btn-lg btn-danger">Stop My Server</a>
|
||||
{% endif %}
|
||||
<a id="start" class="btn btn-lg btn-success" href="{{ url }}">
|
||||
<a id="start"role="button" class="btn btn-lg btn-success" href="{{ url }}">
|
||||
{% if not user.running %}
|
||||
Start
|
||||
{% endif %}
|
||||
@@ -24,4 +24,4 @@
|
||||
<script type="text/javascript">
|
||||
require(["home"]);
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
@@ -11,7 +11,7 @@
|
||||
{{ custom_html }}
|
||||
{% elif login_service %}
|
||||
<div class="service-login">
|
||||
<a class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
|
||||
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
|
||||
Sign in with {{login_service}}
|
||||
</a>
|
||||
</div>
|
||||
|
@@ -99,9 +99,9 @@
|
||||
<ul class="nav navbar-nav">
|
||||
<li><a href="{{base_url}}home">Home</a></li>
|
||||
<li><a href="{{base_url}}token">Token</a></li>
|
||||
{% endif %}
|
||||
{% if user.admin %}
|
||||
<li><a href="{{base_url}}admin">Admin</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
@@ -109,9 +109,9 @@
|
||||
{% block login_widget %}
|
||||
<span id="login_widget">
|
||||
{% if user %}
|
||||
<a id="logout" class="navbar-btn btn-sm btn btn-default" href="{{logout_url}}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a>
|
||||
<a id="logout" role="button" class="navbar-btn btn-sm btn btn-default" href="{{logout_url}}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a>
|
||||
{% else %}
|
||||
<a id="login" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a>
|
||||
<a id="login" role="button" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
@@ -8,7 +8,7 @@
|
||||
<p>Your server is starting up.</p>
|
||||
<p>You will be redirected automatically when it's ready for you.</p>
|
||||
<p><i class="fa fa-spinner fa-pulse fa-fw fa-3x" aria-hidden="true"></i></p>
|
||||
<a id="refresh" class="btn btn-lg btn-primary" href="#">refresh</a>
|
||||
<a role="button" id="refresh" class="btn btn-lg btn-primary" href="#">refresh</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -5,7 +5,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="text-center">
|
||||
<a id="request-token" class="btn btn-lg btn-jupyter" href="#">
|
||||
<a id="request-token" role="button" class="btn btn-lg btn-jupyter" href="#">
|
||||
Request new API token
|
||||
</a>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user