Compare commits

...

25 Commits

Author SHA1 Message Date
Min RK
4692d6638d 0.8.0b4 2017-08-31 16:47:12 +02:00
Carol Willing
7829070e1c Merge pull request #1383 from minrk/singleuser-token-cookie
set cookie on singleuser when authenticated with ?token=...
2017-08-31 09:31:35 -05:00
Min RK
5e4b935322 only HubOAuth can set token cookie 2017-08-31 16:04:54 +02:00
Carol Willing
4c445c7a88 Add jencabral to contributors 2017-08-31 07:52:08 -05:00
Carol Willing
8e2965df6a Merge pull request #1384 from minrk/spawner-db
restore db access on Spawner
2017-08-31 07:50:18 -05:00
Min RK
7a41d24606 set cookie on singleuser when authenticated with ?token=...
Allows `/user/name?token=...` URL to login users for more than one request.

matches token behavior of regular notebook server.
2017-08-31 13:53:48 +02:00
Min RK
5f84a006dc restore db access on Spawner
Shouldn’t be strictly necessary, but doesn’t hurt
2017-08-31 10:03:44 +02:00
Carol Willing
e19296a230 Merge pull request #1382 from minrk/request-token
let admins request tokens for other users
2017-08-31 00:04:59 -04:00
Min RK
89ba97f413 exercise more token API cases
separate parametrize cases for clarity
2017-08-30 14:38:00 +02:00
Min RK
fe2157130b Merge pull request #1381 from minrk/log-fix
fix logging error when login_user is called with no form data and login fails
2017-08-30 14:09:52 +02:00
Min RK
e3b17e8176 Merge pull request #1379 from ding-c3/master
Pass timeout value to exponential_backoff in wait functions
2017-08-30 14:05:42 +02:00
Min RK
027f2f95c6 let admins request tokens for other users 2017-08-30 12:31:41 +02:00
Min RK
210975324a fix logging error when login_user is called with no form data and login fails 2017-08-30 11:31:44 +02:00
Alex Ding
932689f2f8 Pass timeout value to exponential_backoff in wait functions 2017-08-29 17:45:21 -07:00
Min RK
f91e911d1a Merge pull request #1375 from lsst-sqre/master
Prevent "extra" from being used before definition.
2017-08-29 08:36:25 -04:00
Adam Thornton
b75cce857e Merge pull request #1 from lsst-sqre/ticket/DM-11663
Fix "extra" so it isn't used before definition.
2017-08-28 19:00:17 -04:00
adam
62f00690f7 Fix "extra" so it isn't used before definition. 2017-08-28 15:58:31 -07:00
Yuvi Panda
f700ba4154 Merge pull request #1368 from minrk/check-version-error
Provide more detailed error message in case of version mismatch
2017-08-28 13:27:00 -04:00
Min RK
8b91842eae Merge pull request #1369 from minrk/template-typo
typo in navbar template
2017-08-27 16:41:44 -04:00
Min RK
80a9eb93f4 Merge pull request #1370 from yuvipanda/button-roles
Add role=button attribute to all <a> & <span> buttons
2017-08-27 15:39:04 -04:00
yuvipanda
e1deecbbfb Add role=button attribute to all <a> & <span> buttons
Simple accessibility win - screen readers will now be
able to properly present these as buttons than links.
2017-08-27 11:17:22 -04:00
Min RK
d3142704b7 typo in navbar template
mixed up elements causing funky alignment on some pages
2017-08-26 22:42:17 -04:00
Min RK
447edd081a Provide more detailed error message in case of version mismatch
this is the most likely cause of redirect loops when using docker,
so record the spawner version and check it when a redirect is detected.

In the event of a redirect and mismatch, fail with a message explaining the version mismatch and how to fix it.
2017-08-26 22:41:24 -04:00
Min RK
e1531ec277 Merge pull request #1366 from minrk/typo
typo in proxy recovery
2017-08-26 20:21:51 -04:00
Min RK
d12ac4b1f6 typo in proxy recovery
should have been the dict of instantiated services, not the list of service configurations
2017-08-26 15:25:17 -04:00
17 changed files with 177 additions and 46 deletions

View File

@@ -31,6 +31,7 @@ contribution on JupyterHub:
- JamiesHQ
- jbweston
- jdavidheiser
- jencabral
- jhamrick
- josephtate
- kinuax

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -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):

View File

@@ -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']
# ---------------

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>