Compare commits

...

43 Commits

Author SHA1 Message Date
Min RK
6c89de082f 0.8.0b5 2017-09-08 11:19:25 +02:00
Carol Willing
6fb31cc613 Merge pull request #1393 from minrk/spawn-future
improve reporting of spawn failure
2017-09-07 10:20:38 -07:00
Carol Willing
cfb22baf05 Merge pull request #1399 from minrk/trailing-slash
add trailing slash on /user/name
2017-09-07 09:59:58 -07:00
Min RK
2d0c1ff0a8 Merge pull request #1404 from minrk/sqla-11
we require sqlalchemy 1.1
2017-09-07 16:48:13 +02:00
Min RK
7789e13879 we require sqlalchemy 1.1
for enum support

[ref](http://docs.sqlalchemy.org/en/latest/changelog/changelog_11.html#change-9d6d98d7acabc8564b8eebb11c28a624)
2017-09-07 15:10:48 +02:00
Yuvi Panda
f7b90e2c09 Merge pull request #1400 from minrk/auth-custom-html
allow Authenticator.custom_html to be HTML
2017-09-06 11:56:14 -07:00
Carol Willing
ccb29167dd Merge pull request #1392 from minrk/rm-extra-log
update docs to preferred method of writing to log file
2017-09-06 07:32:25 -07:00
Min RK
4ef1eca3c9 allow Authenticator.custom_html to be HTML 2017-09-06 15:14:26 +02:00
Min RK
c26ede30b9 Point users to /hub/home to retry spawn on spawn failure 2017-09-06 15:03:26 +02:00
Min RK
64c69a3164 update docs to preferred method of writing to log file
extra_log_files config is unreliable and doesn't capture all output.

Piping output is much more robust and reliable.
2017-09-06 14:38:33 +02:00
Min RK
ad7867ff11 add trailing slash on /user/name
proxies may not route `/user/name` correctly, only `/user/name/...`, so make sure that `/user/name` is redirected to `/user/name/`

this manifests as a redirect loop between /user/name and /hub/user/name when a route exists but /user/name is still
being routed to the Hub
2017-09-06 12:37:22 +02:00
Yuvi Panda
14fc1588f8 Merge pull request #1380 from minrk/cull-idle-users
add —cull-users to cull_idle_servers
2017-09-05 12:48:24 -07:00
Min RK
7e5a925f4f raise original spawn failure on implicit spawn
so the error message is the same, however it was arrived at.

potential downside: it could look like the current request is spawning and failing,
rather than the reality that a previous spawn failed and we are just re-presenting the earlier error.
It's possible for there to have been a long time in between spawn and error.
2017-09-04 14:27:01 +02:00
Min RK
3c61e422da prevent implicit spawn on /user/:name if previous spawn failed
require users to visit /hub/home and click 'Start My Server' to get a new server

Visits to /hub/user/:name will get an error if the previous spawn failed,
rather than triggering a new spawn.
This should guarantee that a user sees an error if their spawn failed,
regardless of when the failure occurred and how long it took.
Some cases of slow errors could result in triggering a new spawn indefinitely without
the user seeing an error message.

/hub/spawn was a simple redirect to /user/:name in the absence of a spawn form,
but now clears the `_spawn_future` prior to redirect
to signal that a new spawn has been explicitly requested in the case of a prior failure.
2017-09-04 14:17:24 +02:00
Min RK
0e2cf37981 point to single-user logs when spawner fails to start 2017-09-04 13:14:07 +02:00
Min RK
503d5e389f render pending page if triggered spawn doesn't finish
instead of redirecting, which starts redirect loop counter
2017-09-04 12:02:40 +02:00
Min RK
7b1e61ab2c allow waiting for pending spawn via spawner._spawn_future
avoids losing errors when visiting `/hub/user/:name` during a pending spawn
2017-09-04 11:53:42 +02:00
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
Min RK
f9a90d2494 add —cull-users to cull_idle_servers
allows deleting idle users in addition to servers for temp-user cases such as binder/tmpnb
2017-08-30 10: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
24 changed files with 332 additions and 81 deletions

View File

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

View File

@@ -49,9 +49,6 @@ c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
# put the log file in /var/log
c.JupyterHub.extra_log_file = '/var/log/jupyterhub.log'
# use GitHub OAuthenticator for local users
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
@@ -79,7 +76,8 @@ export GITHUB_CLIENT_ID=github_id
export GITHUB_CLIENT_SECRET=github_secret
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
export CONFIGPROXY_AUTH_TOKEN=super-secret
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py
# append log output to log file /var/log/jupyterhub.log
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
```
## Using nginx reverse proxy

View File

@@ -40,8 +40,11 @@ from tornado.options import define, options, parse_command_line
@coroutine
def cull_idle(url, api_token, timeout):
"""cull idle single-user servers"""
def cull_idle(url, api_token, timeout, cull_users=False):
"""Shutdown idle single-user servers
If cull_users, inactive *users* will be deleted as well.
"""
auth_header = {
'Authorization': 'token %s' % api_token
}
@@ -54,26 +57,50 @@ def cull_idle(url, api_token, timeout):
resp = yield client.fetch(req)
users = json.loads(resp.body.decode('utf8', 'replace'))
futures = []
for user in users:
last_activity = parse_date(user['last_activity'])
if user['server'] and last_activity < cull_limit:
app_log.info("Culling %s (inactive since %s)", user['name'], last_activity)
@coroutine
def cull_one(user, last_activity):
"""cull one user"""
# shutdown server first. Hub doesn't allow deleting users with running servers.
if user['server']:
app_log.info("Culling server for %s (inactive since %s)", user['name'], last_activity)
req = HTTPRequest(url=url + '/users/%s/server' % user['name'],
method='DELETE',
headers=auth_header,
)
futures.append((user['name'], client.fetch(req)))
elif user['server'] and last_activity > cull_limit:
yield client.fetch(req)
if cull_users:
app_log.info("Culling user %s (inactive since %s)", user['name'], last_activity)
req = HTTPRequest(url=url + '/users/%s' % user['name'],
method='DELETE',
headers=auth_header,
)
yield client.fetch(req)
for user in users:
if not user['server'] and not cull_users:
# server not running and not culling users, nothing to do
continue
last_activity = parse_date(user['last_activity'])
if last_activity < cull_limit:
futures.append((user['name'], cull_one(user, last_activity)))
else:
app_log.debug("Not culling %s (active since %s)", user['name'], last_activity)
for (name, f) in futures:
yield f
app_log.debug("Finished culling %s", name)
if __name__ == '__main__':
define('url', default=os.environ.get('JUPYTERHUB_API_URL'), help="The JupyterHub API URL")
define('timeout', default=600, help="The idle timeout (in seconds)")
define('cull_every', default=0, help="The interval (in seconds) for checking for idle servers to cull")
define('cull_users', default=False,
help="""Cull users in addition to servers.
This is for use in temporary-user cases such as tmpnb.""",
)
parse_command_line()
if not options.cull_every:
@@ -82,7 +109,7 @@ if __name__ == '__main__':
api_token = os.environ['JUPYTERHUB_API_TOKEN']
loop = IOLoop.current()
cull = lambda : cull_idle(options.url, api_token, options.timeout)
cull = lambda : cull_idle(options.url, api_token, options.timeout, options.cull_users)
# run once before scheduling periodic call
loop.run_sync(cull)
# schedule periodic cull

View File

@@ -7,7 +7,7 @@ version_info = (
0,
8,
0,
'b3',
'b5',
)
__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

@@ -801,12 +801,10 @@ class JupyterHub(Application):
self.handlers = self.add_url_prefix(self.hub_prefix, h)
# some extra handlers, outside hub_prefix
self.handlers.extend([
(r"%s" % self.hub_prefix.rstrip('/'), web.RedirectHandler,
{
"url": self.hub_prefix,
"permanent": False,
}
),
# add trailing / to `/hub`
(self.hub_prefix.rstrip('/'), handlers.AddSlashHandler),
# add trailing / to ``/user|services/:name`
(r"%s(user|services)/([^/]+)" % self.base_url, handlers.AddSlashHandler),
(r"(?!%s).*" % self.hub_prefix, handlers.PrefixRedirectHandler),
(r'(.*)', handlers.Template404),
])

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'))
#---------------------------------------------------------------
@@ -376,6 +376,9 @@ class BaseHandler(RequestHandler):
@gen.coroutine
def spawn_single_user(self, user, server_name='', options=None):
# in case of error, include 'try again from /hub/home' message
self.extra_error_html = self.spawn_home_error
user_server_name = user.name
if self.allow_named_servers and not server_name:
server_name = default_server_name(user)
@@ -440,11 +443,7 @@ class BaseHandler(RequestHandler):
otherwise it is called immediately.
"""
# wait for spawn Future
try:
yield spawn_future
except Exception:
spawner._spawn_pending = False
raise
yield spawn_future
toc = IOLoop.current().time()
self.log.info("User %s took %.3f seconds to start", user_server_name, toc-tic)
self.statsd.timing('spawner.success', (toc - tic) * 1000)
@@ -459,10 +458,22 @@ class BaseHandler(RequestHandler):
spawner.add_poll_callback(self.user_stopped, user, server_name)
finally:
spawner._proxy_pending = False
spawner._spawn_pending = False
# hook up spawner._spawn_future so that other requests can await
# this result
finish_spawn_future = spawner._spawn_future = finish_user_spawn()
def _clear_spawn_future(f):
# clear spawner._spawn_future when it's done
# keep an exception around, though, to prevent repeated implicit spawns
# if spawn is failing
if f.exception() is None:
spawner._spawn_future = None
# Now we're all done. clear _spawn_pending flag
spawner._spawn_pending = False
finish_spawn_future.add_done_callback(_clear_spawn_future)
try:
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_user_spawn())
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future)
except gen.TimeoutError:
# waiting_for_response indicates server process has started,
# but is yet to become responsive.
@@ -479,7 +490,8 @@ class BaseHandler(RequestHandler):
if status is not None:
toc = IOLoop.current().time()
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
raise web.HTTPError(500, "Spawner failed to start [status=%s]. The logs for %s may contain details." % (
status, spawner._log_name))
if spawner._waiting_for_response:
# hit timeout waiting for response, but server's running.
@@ -549,6 +561,19 @@ class BaseHandler(RequestHandler):
# template rendering
#---------------------------------------------------------------
@property
def spawn_home_error(self):
"""Extra message pointing users to try spawning again from /hub/home.
Should be added to `self.extra_error_html` for any handler
that could serve a failed spawn message.
"""
home = url_path_join(self.hub.base_url, 'home')
return (
"You can try restarting your server from the "
"<a href='{home}'>home page</a>.".format(home=home)
)
def get_template(self, name):
"""Return the jinja template object for a given name"""
return self.settings['jinja2_env'].get_template(name)
@@ -596,6 +621,7 @@ class BaseHandler(RequestHandler):
status_code=status_code,
status_message=status_message,
message=message,
extra_error_html=getattr(self, 'extra_error_html', ''),
exception=exception,
)
@@ -649,10 +675,13 @@ class UserSpawnHandler(BaseHandler):
current_user = self.get_current_user()
if current_user and current_user.name == name:
# if spawning fails for any reason, point users to /hub/home to retry
self.extra_error_html = self.spawn_home_error
# If people visit /user/:name directly on the Hub,
# the redirects will just loop, because the proxy is bypassed.
# Try to check for that and warn,
# though the user-facing behavior is unchainged
# though the user-facing behavior is unchanged
host_info = urlparse(self.request.full_url())
port = host_info.port
if not port:
@@ -664,8 +693,34 @@ class UserSpawnHandler(BaseHandler):
Make sure to connect to the proxied public URL %s
""", self.request.full_url(), self.proxy.public_url)
# logged in as correct user, spawn the server
# logged in as correct user, check for pending spawn
spawner = current_user.spawner
# First, check for previous failure.
if (
not spawner.active
and spawner._spawn_future
and spawner._spawn_future.done()
and spawner._spawn_future.exception()
):
# Condition: spawner not active and _spawn_future exists and contains an Exception
# Implicit spawn on /user/:name is not allowed if the user's last spawn failed.
# We should point the user to Home if the most recent spawn failed.
self.log.error("Preventing implicit spawn for %s because last spawn failed: %s",
spawner._log_name, spawner._spawn_future.exception())
raise spawner._spawn_future.exception()
# check for pending spawn
if spawner.pending and spawner._spawn_future:
# wait on the pending spawn
self.log.debug("Waiting for %s pending %s", spawner._log_name, spawner.pending)
try:
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), spawner._spawn_future)
except gen.TimeoutError:
self.log.info("Pending spawn for %s didn't finish in %.1f seconds", spawner._log_name, self.slow_spawn_timeout)
pass
# we may have waited above, check pending again:
if spawner.pending:
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
# spawn has started, but not finished
@@ -675,7 +730,12 @@ 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
# server is not running, trigger spawn
if status is not None:
if spawner.options_form:
self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'),
@@ -684,6 +744,15 @@ class UserSpawnHandler(BaseHandler):
else:
yield self.spawn_single_user(current_user)
# spawn didn't finish, show pending page
if spawner.pending:
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
# spawn has started, but not finished
self.statsd.incr('redirects.user_spawn_pending', 1)
html = self.render_template("spawn_pending.html", user=current_user)
self.finish(html)
return
# We do exponential backoff here - since otherwise we can get stuck in a redirect loop!
# This is important in many distributed proxy implementations - those are often eventually
# consistent and can take upto a couple of seconds to actually apply throughout the cluster.
@@ -693,9 +762,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)
@@ -769,6 +852,13 @@ class CSPReportHandler(BaseHandler):
self.statsd.incr('csp_report')
class AddSlashHandler(BaseHandler):
"""Handler for adding trailing slash to URLs that need them"""
def get(self, *args):
src = urlparse(self.request.uri)
dest = src._replace(path=src.path + '/')
self.redirect(urlunparse(dest))
default_handlers = [
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
(r'/user-redirect/(.*)?', UserRedirectHandler),

View File

@@ -67,9 +67,13 @@ class HomeHandler(BaseHandler):
if user.running:
# trigger poll_and_notify event in case of a server that died
yield user.spawner.poll_and_notify()
# send the user to /spawn if they aren't running,
# to establish that this is an explicit spawn request rather
# than an implicit one, which can be caused by any link to `/user/:name`
url = user.url if user.running else url_path_join(self.hub.base_url, 'spawn')
html = self.render_template('home.html',
user=user,
url=user.url,
url=url,
)
self.finish(html)
@@ -92,7 +96,10 @@ class SpawnHandler(BaseHandler):
@web.authenticated
def get(self):
"""GET renders form for spawning with user-specified options"""
"""GET renders form for spawning with user-specified options
or triggers spawn via redirect if there is no form.
"""
user = self.get_current_user()
if not self.allow_named_servers and user.running:
url = user.url
@@ -102,7 +109,12 @@ class SpawnHandler(BaseHandler):
if user.spawner.options_form:
self.finish(self._render_form())
else:
# not running, no form. Trigger spawn.
# Explicit spawn request: clear _spawn_future
# which may have been saved to prevent implicit spawns
# after a failure.
if user.spawner._spawn_future and user.spawner._spawn_future.done():
user.spawner._spawn_future = None
# not running, no form. Trigger spawn by redirecting to /user/:name
self.redirect(user.url)
@web.authenticated

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,8 @@ class Spawner(LoggingConfigurable):
_stop_pending = False
_proxy_pending = False
_waiting_for_response = False
_jupyterhub_version = None
_spawn_future = None
@property
def _log_name(self):
@@ -101,6 +103,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

@@ -134,6 +134,12 @@ def test_spawn_redirect(app):
path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/user/%s/' % name)
# test handing of trailing slash on `/user/name`
r = yield get_page('user/' + name, app, cookies=cookies)
r.raise_for_status()
path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/user/%s/' % name)
@pytest.mark.gen_test
def test_spawn_page(app):

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

@@ -4,5 +4,5 @@ tornado>=4.1
jinja2
pamela
python-oauth2>=1.0
SQLAlchemy>=1.0
SQLAlchemy>=1.1
requests

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

@@ -22,6 +22,11 @@
{{message_html | safe}}
</p>
{% endif %}
{% if extra_error_html %}
<p>
{{extra_error_html | safe}}
</p>
{% endif %}
{% endblock error_detail %}
</div>

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

@@ -8,10 +8,10 @@
{% block login %}
<div id="login-main" class="container">
{% if custom_html %}
{{ custom_html }}
{{ custom_html | safe }}
{% 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>