Compare commits

...

15 Commits

Author SHA1 Message Date
Min RK
6bbfcdfe4f 0.8.0rc2 2017-09-25 11:20:01 +02:00
Min RK
25662285af Merge pull request #1442 from DeepHorizons/add_more_spawner_statsd
[WIP] Added additional statsd collection for the spawner
2017-09-25 10:43:33 +02:00
Joshua Milas
84d12e8d72 Mock out the statsd object for testing 2017-09-22 12:57:41 -04:00
Joshua Milas
c317cbce36 Added additional statsd info for the spawner
spawner.failure coutner collects the number of failures for various reasons:
spawner.stop timer for seeing how long it takes a user server to stop
2017-09-22 12:13:15 -04:00
Min RK
d279604fac Merge pull request #1439 from minrk/oauth-state-cookie
avoid oauth state cookie collisions
2017-09-22 17:33:27 +02:00
Min RK
70fc4ef886 test concurrent oauth login state 2017-09-21 14:38:10 +02:00
Min RK
24ff91eef5 avoid oauth state cookie collisions
in case of multiple simultaneous

- state arg is strictly required now
- default cookie name in case of no collision is unchanged
- in case of collision, randomize cookie name with a suffix and store cookie_name in state
- expire state cookies after 10 minutes, not 1 day
2017-09-21 14:32:47 +02:00
Min RK
afc6789c74 Merge pull request #1441 from minrk/test-trailing-slash-wtf
debug intermittent failure on Travis
2017-09-21 14:18:08 +02:00
Min RK
819e5e222a stop server before testing trailing-slash handling
ensures `/user/name` is handled by the Hub without relying on CHP bug that was fixed in 3.0
2017-09-21 14:08:08 +02:00
Min RK
e1a4f37bbc cache pip packages on travis 2017-09-21 14:08:08 +02:00
Carol Willing
a73477feed Merge pull request #1435 from Analect/named-server-docs
Adding a short description ref starting/stopping named-servers via API
2017-09-20 21:29:11 -07:00
analect
89722ee2f3 Added in necessity to set c.JupyterHub.allow_named_servers = True 2017-09-20 10:27:28 +01:00
analect
ca4fce7ffb Add Analect to contributor list 2017-09-19 16:18:09 +01:00
analect
018b2daace Fixing typo. 2017-09-19 16:17:54 +01:00
analect
fd01165cf6 Adding a short description ref starting/stopping named-servers via API 2017-09-19 14:33:20 +01:00
11 changed files with 172 additions and 22 deletions

View File

@@ -1,5 +1,7 @@
language: python
sudo: false
cache:
- pip
python:
- nightly
- 3.6

View File

@@ -3,6 +3,7 @@
Project Jupyter thanks the following people for their help and
contribution on JupyterHub:
- Analect
- anderbubble
- apetresc
- barrachri

View File

@@ -119,6 +119,55 @@ token does **not** authorize access to the [Jupyter Notebook REST API][]
provided by notebook servers managed by JupyterHub. A different token is used
to access the **Jupyter Notebook** API.
## Enabling users to spawn multiple named-servers via the API
With JupyterHub version 0.8, support for multiple servers per user has landed.
Prior to that, each user could only launch a single default server via the API
like this:
```bash
curl -X POST -H "Authorization: token <token>" "http://127.0.0.1:8081/hub/api/users/<user>/server"
```
With the named-server functionality, it's now possible to launch more than one
specifically named servers against a given user. This could be used, for instance,
to launch each server based on a different image.
First you must enable named-servers by including the following setting in the `jupyterhub_config.py` file.
`c.JupyterHub.allow_named_servers = True`
If using the [zero-to-jupyterhub-k8s](https://github.com/jupyterhub/zero-to-jupyterhub-k8s) set-up to run JupyterHub,
then instead of editing the `jupyterhub_config.py` file directly, you could pass
the following as part of the `config.yaml` file, as per the [tutorial](https://zero-to-jupyterhub.readthedocs.io/en/latest/):
```bash
hub:
extraConfig: |
c.JupyterHub.allow_named_servers = True
```
With that setting in place, a new named-server is activated like this:
```bash
curl -X POST -H "Authorization: token <token>" "http://127.0.0.1:8081/hub/api/users/<user>/servers/<serverA>"
curl -X POST -H "Authorization: token <token>" "http://127.0.0.1:8081/hub/api/users/<user>/servers/<serverB>"
```
The same servers can be stopped by substituting `DELETE` for `POST` above.
### Some caveats for using named-servers
The named-server capabilities are not fully implemented for JupyterHub as yet.
While it's possible to start/stop a server via the API, the UI on the
JupyterHub control-panel has not been implemented, and so it may not be obvious
to those viewing the panel that a named-server may be running for a given user.
For named-servers via the API to work, the spawner used to spawn these servers
will need to be able to handle the case of multiple servers per user and ensure
uniqueness of names, particularly if servers are spawned via docker containers
or kubernetes pods.
## Learn more about the API
You can see the full [JupyterHub REST API][] for details. This REST API Spec can

View File

@@ -59,7 +59,7 @@ def oauth_callback():
# validate state field
arg_state = request.args.get('state', None)
cookie_state = request.cookies.get(auth.state_cookie_name)
if arg_state != cookie_state:
if arg_state is None or arg_state != cookie_state:
# state doesn't match
return 403

View File

@@ -7,7 +7,7 @@ version_info = (
0,
8,
0,
'rc1',
'rc2',
)
__version__ = '.'.join(map(str, version_info))

View File

@@ -545,6 +545,7 @@ class BaseHandler(RequestHandler):
spawner._stop_pending = False
toc = IOLoop.current().time()
self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic)
self.statsd.timing('spawner.stop', (toc - tic) * 1000)
try:
yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), stop())

View File

@@ -13,8 +13,10 @@ authenticate with the Hub.
import base64
import json
import os
import random
import re
import socket
import string
import time
from urllib.parse import quote, urlencode
import uuid
@@ -531,22 +533,37 @@ class HubOAuth(HubAuth):
-------
state (str): The OAuth state that has been stored in the cookie (url safe, base64-encoded)
"""
b64_state = self.generate_state(next_url)
extra_state = {}
if handler.get_cookie(self.state_cookie_name):
# oauth state cookie is already set
# use a randomized cookie suffix to avoid collisions
# in case of concurrent logins
app_log.warning("Detected unused OAuth state cookies")
cookie_suffix = ''.join(random.choice(string.ascii_letters) for i in range(8))
cookie_name = '{}-{}'.format(self.state_cookie_name, cookie_suffix)
extra_state['cookie_name'] = cookie_name
else:
cookie_name = self.state_cookie_name
b64_state = self.generate_state(next_url, **extra_state)
kwargs = {
'path': self.base_url,
'httponly': True,
'expires_days': 1,
# Expire oauth state cookie in ten minutes.
# Usually this will be cleared by completed login
# in less than a few seconds.
# OAuth that doesn't complete shouldn't linger too long.
'max_age': 600,
}
if handler.request.protocol == 'https':
kwargs['secure'] = True
handler.set_secure_cookie(
self.state_cookie_name,
cookie_name,
b64_state,
**kwargs
)
return b64_state
def generate_state(self, next_url=None):
def generate_state(self, next_url=None, **extra_state):
"""Generate a state string, given a next_url redirect target
Parameters
@@ -557,16 +574,27 @@ class HubOAuth(HubAuth):
-------
state (str): The base64-encoded state string.
"""
return self._encode_state({
state = {
'uuid': uuid.uuid4().hex,
'next_url': next_url
})
'next_url': next_url,
}
state.update(extra_state)
return self._encode_state(state)
def get_next_url(self, b64_state=''):
"""Get the next_url for redirection, given an encoded OAuth state"""
state = self._decode_state(b64_state)
return state.get('next_url') or self.base_url
def get_state_cookie_name(self, b64_state=''):
"""Get the cookie name for oauth state, given an encoded OAuth state
Cookie name is stored in the state itself because the cookie name
is randomized to deal with races between concurrent oauth sequences.
"""
state = self._decode_state(b64_state)
return state.get('cookie_name') or self.state_cookie_name
def set_cookie(self, handler, access_token):
"""Set a cookie recording OAuth result"""
kwargs = {
@@ -769,18 +797,19 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
# validate OAuth state
arg_state = self.get_argument("state", None)
cookie_state = self.get_secure_cookie(self.hub_auth.state_cookie_name)
next_url = None
if arg_state or cookie_state:
# clear cookie state now that we've consumed it
self.clear_cookie(self.hub_auth.state_cookie_name, path=self.hub_auth.base_url)
if isinstance(cookie_state, bytes):
cookie_state = cookie_state.decode('ascii', 'replace')
# check that state matches
if arg_state != cookie_state:
app_log.debug("oauth state %r != %r", arg_state, cookie_state)
raise HTTPError(403, "oauth state does not match")
next_url = self.hub_auth.get_next_url(cookie_state)
if arg_state is None:
raise HTTPError("oauth state is missing. Try logging in again.")
cookie_name = self.hub_auth.get_state_cookie_name(arg_state)
cookie_state = self.get_secure_cookie(cookie_name)
# clear cookie state now that we've consumed it
self.clear_cookie(cookie_name, path=self.hub_auth.base_url)
if isinstance(cookie_state, bytes):
cookie_state = cookie_state.decode('ascii', 'replace')
# check that state matches
if arg_state != cookie_state:
app_log.warning("oauth state %r != %r", arg_state, cookie_state)
raise HTTPError(403, "oauth state does not match. Try logging in again.")
next_url = self.hub_auth.get_next_url(cookie_state)
# TODO: make async (in a Thread?)
token = self.hub_auth.token_for_code(code)
user_model = self.hub_auth.user_for_token(token)

View File

@@ -14,6 +14,7 @@ from .. import objects
from .. import crypto
from ..user import User
from .mocking import MockSpawner
from ..emptyclass import EmptyClass
def test_server(db):
@@ -167,6 +168,7 @@ def test_spawn_fails(db):
user = User(orm_user, {
'spawner_class': BadSpawner,
'config': None,
'statsd': EmptyClass(),
})
with pytest.raises(RuntimeError) as exc:

View File

@@ -134,8 +134,12 @@ def test_spawn_redirect(app):
path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/user/%s/' % name)
# stop server to ensure /user/name is handled by the Hub
r = yield api_request(app, 'users', name, 'server', method='delete', cookies=cookies)
r.raise_for_status()
# test handing of trailing slash on `/user/name`
r = yield get_page('user/' + name, app, cookies=cookies)
r = yield get_page('user/' + name, app, hub=False, cookies=cookies)
r.raise_for_status()
path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/user/%s/' % name)

View File

@@ -368,3 +368,61 @@ def test_oauth_service(app, mockservice_url):
reply = r.json()
assert reply['name'] == name
@pytest.mark.gen_test
def test_oauth_cookie_collision(app, mockservice_url):
service = mockservice_url
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/')
print(url)
s = requests.Session()
name = 'mypha'
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)
state_cookie_name = 'service-%s-oauth-state' % service.name
service_cookie_name = 'service-%s' % service.name
oauth_1 = yield s_get(url, allow_redirects=False)
print(oauth_1.headers)
print(oauth_1.cookies, oauth_1.url, url)
assert state_cookie_name in s.cookies
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
# only one state cookie
assert state_cookies == [state_cookie_name]
state_1 = s.cookies[state_cookie_name]
# start second oauth login before finishing the first
oauth_2 = yield s_get(url, allow_redirects=False)
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
assert len(state_cookies) == 2
# get the random-suffix cookie name
state_cookie_2 = sorted(state_cookies)[-1]
# we didn't clobber the default cookie
assert s.cookies[state_cookie_name] == state_1
# finish oauth 2
url = oauth_2.headers['Location']
if not urlparse(url).netloc:
url = public_host(app) + url
r = yield s_get(url)
r.raise_for_status()
# after finishing, state cookie is cleared
assert state_cookie_2 not in s.cookies
# service login cookie is set
assert service_cookie_name in s.cookies
service_cookie_2 = s.cookies[service_cookie_name]
# finish oauth 1
url = oauth_1.headers['Location']
if not urlparse(url).netloc:
url = public_host(app) + url
r = yield s_get(url)
r.raise_for_status()
# after finishing, state cookie is cleared (again)
assert state_cookie_name not in s.cookies
# service login cookie is set (again, to a different value)
assert service_cookie_name in s.cookies
assert s.cookies[service_cookie_name] != service_cookie_2
# after completing both OAuth logins, no OAuth state cookies remain
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
assert state_cookies == []

View File

@@ -421,10 +421,12 @@ class User(HasTraits):
user=self.name, s=spawner.start_timeout,
))
e.reason = 'timeout'
self.settings['statsd'].incr('spawner.failure.timeout')
else:
self.log.error("Unhandled error starting {user}'s server: {error}".format(
user=self.name, error=e,
))
self.settings['statsd'].incr('spawner.failure.error')
e.reason = 'error'
try:
yield self.stop()
@@ -457,11 +459,13 @@ class User(HasTraits):
)
)
e.reason = 'timeout'
self.settings['statsd'].incr('spawner.failure.http_timeout')
else:
e.reason = 'error'
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
user=self.name, url=server.url, error=e,
))
self.settings['statsd'].incr('spawner.failure.http_error')
try:
yield self.stop()
except Exception: