Re-sync with master

This commit is contained in:
Min RK
2017-06-21 15:33:01 +02:00
44 changed files with 1256 additions and 832 deletions

View File

@@ -4,9 +4,10 @@
# W: style warnings # W: style warnings
# C: complexity # C: complexity
# F401: module imported but unused # F401: module imported but unused
# F403: import *
# F811: redefinition of unused `name` from line `N` # F811: redefinition of unused `name` from line `N`
# F841: local variable assigned but never used # F841: local variable assigned but never used
ignore = E, C, W, F401, F811, F841 ignore = E, C, W, F401, F403, F811, F841
exclude = exclude =
.cache, .cache,

View File

@@ -1,5 +1,5 @@
# http://travis-ci.org/#!/jupyter/jupyterhub
language: python language: python
sudo: false
python: python:
- nightly - nightly
- 3.6 - 3.6
@@ -12,6 +12,7 @@ before_install:
- npm install - npm install
- npm install -g configurable-http-proxy - npm install -g configurable-http-proxy
install: install:
- pip install -U pip
- pip install --pre -r dev-requirements.txt . - pip install --pre -r dev-requirements.txt .
# running tests # running tests

View File

@@ -116,11 +116,12 @@ To start the Hub on a specific url and port ``10.0.1.2:443`` with **https**:
### Authenticators ### Authenticators
| Authenticator | Description | | Authenticator | Description |
| -------------------------------------------------------------------- | ------------------------------------------------- | | --------------------------------------------------------------------------- | ------------------------------------------------- |
| PAMAuthenticator | Default, built-in authenticator | | PAMAuthenticator | Default, built-in authenticator |
| [OAuthenticator](https://github.com/jupyterhub/oauthenticator) | OAuth + JupyterHub Authenticator = OAuthenticator | | [OAuthenticator](https://github.com/jupyterhub/oauthenticator) | OAuth + JupyterHub Authenticator = OAuthenticator |
| [ldapauthenticator](https://github.com/jupyterhub/ldapauthenticator) | Simple LDAP Authenticator Plugin for JupyterHub | | [ldapauthenticator](https://github.com/jupyterhub/ldapauthenticator) | Simple LDAP Authenticator Plugin for JupyterHub |
| [kdcAuthenticator](https://github.com/bloomberg/jupyterhub-kdcauthenticator)| Kerberos Authenticator Plugin for JupyterHub |
### Spawners ### Spawners

View File

@@ -10,7 +10,9 @@ dependencies:
- sqlalchemy>=1 - sqlalchemy>=1
- tornado>=4.1 - tornado>=4.1
- traitlets>=4.1 - traitlets>=4.1
- sphinx>=1.3.6,!=1.5.4 - sphinx>=1.4, !=1.5.4
- sphinx_rtd_theme - sphinx_rtd_theme
- pip: - pip:
- recommonmark==0.4.0 - jupyter_alabaster_theme
- python-oauth2
- recommonmark==0.4.0

View File

@@ -1,3 +1,3 @@
-r ../requirements.txt -r ../requirements.txt
sphinx>=1.3.6 sphinx>=1.4
recommonmark==0.4.0 recommonmark==0.4.0

View File

@@ -8,7 +8,7 @@ import shlex
import recommonmark.parser import recommonmark.parser
# Set paths # Set paths
#sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------ # -- General configuration ------------------------------------------------
@@ -21,6 +21,7 @@ extensions = [
'sphinx.ext.intersphinx', 'sphinx.ext.intersphinx',
'sphinx.ext.napoleon', 'sphinx.ext.napoleon',
'autodoc_traits', 'autodoc_traits',
'jupyter_alabaster_theme',
] ]
templates_path = ['_templates'] templates_path = ['_templates']
@@ -66,7 +67,7 @@ source_suffix = ['.rst', '.md']
# -- Options for HTML output ---------------------------------------------- # -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. # The theme to use for HTML and HTML Help pages.
html_theme = 'sphinx_rtd_theme' html_theme = 'jupyter_alabaster_theme'
#html_theme_options = {} #html_theme_options = {}
#html_theme_path = [] #html_theme_path = []
@@ -163,17 +164,15 @@ epub_exclude_files = ['search.html']
# -- Intersphinx ---------------------------------------------------------- # -- Intersphinx ----------------------------------------------------------
intersphinx_mapping = {'https://docs.python.org/': None} intersphinx_mapping = {'https://docs.python.org/3/': None}
# -- Read The Docs -------------------------------------------------------- # -- Read The Docs --------------------------------------------------------
on_rtd = os.environ.get('READTHEDOCS', None) == 'True' on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
if not on_rtd: if not on_rtd:
# only import and set the theme if we're building docs locally import jupyter_alabaster_theme
import sphinx_rtd_theme html_theme = 'jupyter_alabaster_theme'
html_theme = 'sphinx_rtd_theme' html_theme_path = [jupyter_alabaster_theme.get_path()]
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
else: else:
# readthedocs.org uses their theme by default, so no need to specify it # readthedocs.org uses their theme by default, so no need to specify it
# build rest-api, since RTD doesn't run make # build rest-api, since RTD doesn't run make

View File

@@ -118,8 +118,8 @@ server {
server_name HUB.DOMAIN.TLD; server_name HUB.DOMAIN.TLD;
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on; ssl_prefer_server_ciphers on;

View File

@@ -0,0 +1,12 @@
Configuration Guide
===================
.. toctree::
:maxdepth: 2
authenticators
spawners
services
config-examples
upgrading
troubleshooting

View File

@@ -29,7 +29,7 @@ JupyterHub's basic flow of operations includes:
- The Hub configures the proxy to forward URL prefixes to the single-user notebook servers - The Hub configures the proxy to forward URL prefixes to the single-user notebook servers
For convenient administration of the Hub, its users, and :doc:`services` For convenient administration of the Hub, its users, and :doc:`services`
(added in version 7.0), JupyterHub also provides a (added in version 0.7), JupyterHub also provides a
`REST API <http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default>`__. `REST API <http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default>`__.
Contents Contents
@@ -43,16 +43,6 @@ Contents
* :doc:`websecurity` * :doc:`websecurity`
* :doc:`rest` * :doc:`rest`
.. toctree::
:maxdepth: 2
:hidden:
:caption: User Guide
quickstart
getting-started
howitworks
websecurity
rest
**Configuration Guide** **Configuration Guide**
@@ -60,35 +50,14 @@ Contents
* :doc:`spawners` * :doc:`spawners`
* :doc:`services` * :doc:`services`
* :doc:`config-examples` * :doc:`config-examples`
* :doc:`jupyterhub-deployment-aws`
* :doc:`upgrading` * :doc:`upgrading`
* :doc:`troubleshooting` * :doc:`troubleshooting`
.. toctree::
:maxdepth: 2
:hidden:
:caption: Configuration Guide
authenticators
spawners
services
config-examples
jupyterhub-deployment-aws
upgrading
troubleshooting
**API Reference** **API Reference**
* :doc:`api/index` * :doc:`api/index`
.. toctree::
:maxdepth: 2
:hidden:
:caption: API Reference
api/index
**About JupyterHub** **About JupyterHub**
@@ -96,15 +65,6 @@ Contents
* :doc:`contributor-list` * :doc:`contributor-list`
* :doc:`gallery-jhub-deployments` * :doc:`gallery-jhub-deployments`
.. toctree::
:maxdepth: 2
:hidden:
:caption: About JupyterHub
changelog
contributor-list
gallery-jhub-deployments
Indices and tables Indices and tables
------------------ ------------------
@@ -118,3 +78,18 @@ Questions? Suggestions?
- `Jupyter mailing list <https://groups.google.com/forum/#!forum/jupyter>`_ - `Jupyter mailing list <https://groups.google.com/forum/#!forum/jupyter>`_
- `Jupyter website <https://jupyter.org>`_ - `Jupyter website <https://jupyter.org>`_
.. _contents:
Full Table of Contents
----------------------
.. toctree::
:maxdepth: 2
user-guide
configuration-guide
api/index
changelog
contributor-list
gallery-jhub-deployments

View File

@@ -1,12 +1,13 @@
# JupyterHub Deployment on AWS # JupyterHub Deployment on AWS
Documentation on deploying JupyterHub on an AWS EC2 Instance using NGINX Plus.
>CAUTION: Document is a work-in-progress. Information found on this page is partially incomplete and may require additional research. Documentation on deploying JupyterHub on an AWS EC2 Instance using NGINX Plus.
>CAUTION: Document is a work-in-progress. Information found on this page is partially incomplete and may require additional research.
## Setting Up Amazon EC2 Instance ## Setting Up Amazon EC2 Instance
### AMI ### AMI
Choose one of the following Amazon Machine Images that are compatible with NGINX Plus: Choose one of the following Amazon Machine Images that are compatible with NGINX Plus:
* NGINX Plus Amazon Linux AMI (HVM) * NGINX Plus Amazon Linux AMI (HVM)
* NGINX Plus Ubuntu AMI (HVM) * NGINX Plus Ubuntu AMI (HVM)
@@ -16,21 +17,21 @@ Choose one of the following Amazon Machine Images that are compatible with NGINX
Refer to the [NGINX AMI Installation Guide](https://www.nginx.com/resources/admin-guide/setting-nginx-plus-environment-amazon-ec2/) for more information. Refer to the [NGINX AMI Installation Guide](https://www.nginx.com/resources/admin-guide/setting-nginx-plus-environment-amazon-ec2/) for more information.
### Instance Type & Storage ### Instance Type & Storage
Instance type selection depends heavily on memory usage. Amazon Compute Optimized instances are recommended. Instance type selection depends heavily on memory usage. Amazon Compute Optimized instances are recommended.
As a rule of thumb consider **100-200 MB/user** plus **5x-10x the amount of data you are loading from disk**, depending on the kind of analysis. After selecting your instance, you can add more memory and select memory type (GP2/IO1) in the 'Add Storage' page. As a rule of thumb consider **100-200 MB/user** plus **5x-10x the amount of data you are loading from disk**, depending on the kind of analysis. After selecting your instance, you can add more memory and select memory type (GP2/IO1) in the 'Add Storage' page.
(Pictured below: c4.2xlarge) (Pictured below: c4.2xlarge)
![Instance Type](images/instance.png) ![Instance Type](images/instance.png)
### Configure Security Group ### Configure Security Group
The standard HTTPS and HTTP ports (80, 443) need to be opened to allow JupyterHub to be proxied by NGINX. The standard HTTPS and HTTP ports (80, 443) need to be opened to allow JupyterHub to be proxied by NGINX.
Additionally, in order to enable Docker containers to connect to JupyterHub port 8081 will need to be opened. Open a new 'Custom TCP Rule' and set the Source in CIDR Block Notation to: Additionally, in order to enable Docker containers to connect to JupyterHub port 8081 will need to be opened. Open a new 'Custom TCP Rule' and set the Source in CIDR Block Notation to:
> <Netword IP Address>/24 > <Netword IP Address>/24
Below is a reference image for the security group set-up. Depending on specific use-cases, port rules may differ and likely should not be open to 'anywhere'. Your network IP will also differ. Below is a reference image for the security group set-up. Depending on specific use-cases, port rules may differ and likely should not be open to 'anywhere'. Your network IP will also differ.
![Security Group](images/security.png) ![Security Group](images/security.png)
@@ -39,7 +40,6 @@ Refer to the [Amazon EC2 Security Groups for Linux Instances Page](http://docs.a
---- ----
## To-Do Sections ## To-Do Sections
- [x] Setting Up Amazon EC2 Instance - [x] Setting Up Amazon EC2 Instance
- [ ] Setting Up JupyterHub & Web Server on EC2 VM - [ ] Setting Up JupyterHub & Web Server on EC2 VM
- [ ] Setting Up Docker Spawner - [ ] Setting Up Docker Spawner

View File

@@ -0,0 +1,11 @@
JupyterHub User Guide
=====================
.. toctree::
:maxdepth: 3
quickstart
getting-started
howitworks
websecurity
rest

View File

@@ -18,6 +18,8 @@ class TokenAPIHandler(APIHandler):
@token_authenticated @token_authenticated
def get(self, token): def get(self, token):
orm_token = orm.APIToken.find(self.db, token) orm_token = orm.APIToken.find(self.db, token)
if orm_token is None:
orm_token = orm.OAuthAccessToken.find(self.db, token)
if orm_token is None: if orm_token is None:
raise web.HTTPError(404) raise web.HTTPError(404)
if orm_token.user: if orm_token.user:

View File

@@ -32,7 +32,7 @@ class APIHandler(BaseHandler):
self.log.warning("Blocking API request with no referer") self.log.warning("Blocking API request with no referer")
return False return False
host_path = url_path_join(host, self.hub.server.base_url) host_path = url_path_join(host, self.hub.base_url)
referer_path = referer.split('://', 1)[-1] referer_path = referer.split('://', 1)[-1]
if not (referer_path + '/').startswith(host_path): if not (referer_path + '/').startswith(host_path):
self.log.warning("Blocking Cross Origin API request. Referer: %s, Host: %s", self.log.warning("Blocking Cross Origin API request. Referer: %s, Host: %s",

View File

@@ -4,6 +4,7 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import json import json
from urllib.parse import urlparse
from tornado import gen, web from tornado import gen, web
@@ -21,7 +22,7 @@ class ProxyAPIHandler(APIHandler):
This is the same as fetching the routing table directly from the proxy, This is the same as fetching the routing table directly from the proxy,
but without clients needing to maintain separate but without clients needing to maintain separate
""" """
routes = yield self.proxy.get_routes() routes = yield self.proxy.get_all_routes()
self.write(json.dumps(routes)) self.write(json.dumps(routes))
@admin_only @admin_only
@@ -48,17 +49,11 @@ class ProxyAPIHandler(APIHandler):
if not isinstance(model, dict): if not isinstance(model, dict):
raise web.HTTPError(400, "Request body must be JSON dict") raise web.HTTPError(400, "Request body must be JSON dict")
server = self.proxy.api_server if 'api_url' in model:
if 'ip' in model: self.proxy.api_url = model['api_url']
server.ip = model['ip']
if 'port' in model:
server.port = model['port']
if 'protocol' in model:
server.proto = model['protocol']
if 'auth_token' in model: if 'auth_token' in model:
self.proxy.auth_token = model['auth_token'] self.proxy.auth_token = model['auth_token']
self.db.commit() self.log.info("Updated proxy at %s", self.proxy)
self.log.info("Updated proxy at %s", server.bind_url)
yield self.proxy.check_routes(self.users, self.services) yield self.proxy.check_routes(self.users, self.services)

View File

@@ -20,6 +20,11 @@ class SelfAPIHandler(APIHandler):
@web.authenticated @web.authenticated
def get(self): def get(self):
user = self.get_current_user() user = self.get_current_user()
if user is None:
# whoami can be accessed via oauth token
user = self.get_current_user_oauth_token()
if user is None:
raise web.HTTPError(403)
self.write(json.dumps(self.user_model(user))) self.write(json.dumps(self.user_model(user)))
@@ -219,7 +224,7 @@ class UserCreateNamedServerAPIHandler(APIHandler):
def post(self, name): def post(self, name):
user = self.find_user(name) user = self.find_user(name)
if user is None: if user is None:
raise HTTPError(404, "No such user %r" % name) raise web.HTTPError(404, "No such user %r" % name)
#if user.running: #if user.running:
# # include notify, so that a server that died is noticed immediately # # include notify, so that a server that died is noticed immediately
# state = yield user.spawner.poll_and_notify() # state = yield user.spawner.poll_and_notify()

View File

@@ -30,7 +30,6 @@ from sqlalchemy.orm import scoped_session
import tornado.httpserver import tornado.httpserver
import tornado.options import tornado.options
from tornado.httpclient import HTTPError
from tornado.ioloop import IOLoop, PeriodicCallback from tornado.ioloop import IOLoop, PeriodicCallback
from tornado.log import app_log, access_log, gen_log from tornado.log import app_log, access_log, gen_log
from tornado import gen, web from tornado import gen, web
@@ -54,6 +53,7 @@ from .user import User, UserDict
from .oauth.store import make_provider from .oauth.store import make_provider
from ._data import DATA_FILES_PATH from ._data import DATA_FILES_PATH
from .log import CoroutineLogFormatter, log_request from .log import CoroutineLogFormatter, log_request
from .proxy import Proxy, ConfigurableHTTPProxy
from .traitlets import URLPrefix, Command from .traitlets import URLPrefix, Command
from .utils import ( from .utils import (
url_path_join, url_path_join,
@@ -62,6 +62,7 @@ from .utils import (
# classes for config # classes for config
from .auth import Authenticator, PAMAuthenticator from .auth import Authenticator, PAMAuthenticator
from .spawner import Spawner, LocalProcessSpawner from .spawner import Spawner, LocalProcessSpawner
from .objects import Hub
# For faking stats # For faking stats
from .emptyclass import EmptyClass from .emptyclass import EmptyClass
@@ -140,7 +141,6 @@ class NewToken(Application):
hub = JupyterHub(parent=self) hub = JupyterHub(parent=self)
hub.load_config_file(hub.config_file) hub.load_config_file(hub.config_file)
hub.init_db() hub.init_db()
hub.hub = hub.db.query(orm.Hub).first()
hub.init_users() hub.init_users()
user = orm.User.find(hub.db, self.name) user = orm.User.find(hub.db, self.name)
if user is None: if user is None:
@@ -217,6 +217,8 @@ class JupyterHub(Application):
aliases = Dict(aliases) aliases = Dict(aliases)
flags = Dict(flags) flags = Dict(flags)
raise_config_file_errors = True
subcommands = { subcommands = {
'token': (NewToken, "Generate an API token for a user"), 'token': (NewToken, "Generate an API token for a user"),
'upgrade-db': (UpgradeDB, "Upgrade your JupyterHub state database to the current version."), 'upgrade-db': (UpgradeDB, "Upgrade your JupyterHub state database to the current version."),
@@ -349,52 +351,67 @@ class JupyterHub(Application):
help="Supply extra arguments that will be passed to Jinja environment." help="Supply extra arguments that will be passed to Jinja environment."
).tag(config=True) ).tag(config=True)
proxy_cmd = Command('configurable-http-proxy', proxy_class = Type(ConfigurableHTTPProxy, Proxy,
help="""The command to start the http proxy. help="""Select the Proxy API implementation."""
).tag(config=True)
Only override if configurable-http-proxy is not on your PATH proxy_cmd = Command([], config=True,
""" help="DEPRECATED. Use ConfigurableHTTPProxy.command",
).tag(config=True) ).tag(config=True)
debug_proxy = Bool(False, debug_proxy = Bool(False,
help="show debug output in configurable-http-proxy" help="DEPRECATED: Use ConfigurableHTTPProxy.debug",
).tag(config=True) ).tag(config=True)
proxy_auth_token = Unicode( proxy_auth_token = Unicode(
help="""The Proxy Auth token. help="DEPRECATED: Use ConfigurableHTTPProxy.auth_token"
Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default.
"""
).tag(config=True) ).tag(config=True)
@default('proxy_auth_token') _proxy_config_map = {
def _proxy_auth_token_default(self): 'proxy_cmd': 'command',
token = os.environ.get('CONFIGPROXY_AUTH_TOKEN', None) 'debug_proxy': 'debug',
if not token: 'proxy_auth_token': 'auth_token',
self.log.warning('\n'.join([ }
"", @observe(*_proxy_config_map)
"Generating CONFIGPROXY_AUTH_TOKEN. Restarting the Hub will require restarting the proxy.", def _deprecated_proxy_config(self, change):
"Set CONFIGPROXY_AUTH_TOKEN env or JupyterHub.proxy_auth_token config to avoid this message.", dest = self._proxy_config_map[change.name]
"", self.log.warning("JupyterHub.%s is deprecated in JupyterHub 0.8, use ConfigurableHTTPProxy.%s", change.name, dest)
])) self.config.ConfigurableHTTPProxy[dest] = change.new
token = orm.new_token()
return token
proxy_api_ip = Unicode('127.0.0.1', proxy_api_ip = Unicode(
help="The ip for the proxy API handlers" help="DEPRECATED: Use ConfigurableHTTPProxy.api_url"
).tag(config=True) ).tag(config=True)
proxy_api_port = Integer( proxy_api_port = Integer(
help="The port for the proxy API handlers" help="DEPRECATED: Use ConfigurableHTTPProxy.api_url"
).tag(config=True) ).tag(config=True)
@observe('proxy_api_port', 'proxy_api_ip')
@default('proxy_api_port') def _deprecated_proxy_api(self, change):
def _proxy_api_port_default(self): self.log.warning("JupyterHub.%s is deprecated in JupyterHub 0.8, use ConfigurableHTTPProxy.api_url", change.name)
return self.port + 1 self.config.ConfigurableHTTPProxy.api_url = 'http://{}:{}'.format(
self.proxy_api_ip or '127.0.0.1',
self.proxy_api_port or self.port + 1,
)
hub_port = Integer(8081, hub_port = Integer(8081,
help="The port for this process" help="The port for the Hub process"
).tag(config=True) ).tag(config=True)
hub_ip = Unicode('127.0.0.1', hub_ip = Unicode('127.0.0.1',
help="The ip for this process" help="""The ip address for the Hub process to *bind* to.
See `hub_connect_ip` for cases where the bind and connect address should differ.
"""
).tag(config=True) ).tag(config=True)
hub_connect_ip = Unicode('',
help="""The ip or hostname for proxies and spawners to use
for connecting to the Hub.
Use when the bind address (`hub_ip`) is 0.0.0.0 or otherwise different
from the connect address.
Default: when `hub_ip` is 0.0.0.0, use `socket.gethostname()`, otherwise use `hub_ip`.
.. versionadded:: 0.8
"""
)
hub_prefix = URLPrefix('/hub/', hub_prefix = URLPrefix('/hub/',
help="The prefix for the hub server. Always /base_url/hub/" help="The prefix for the hub server. Always /base_url/hub/"
) )
@@ -682,10 +699,6 @@ class JupyterHub(Application):
def init_ports(self): def init_ports(self):
if self.hub_port == self.port: if self.hub_port == self.port:
raise TraitError("The hub and proxy cannot both listen on port %i" % self.port) raise TraitError("The hub and proxy cannot both listen on port %i" % self.port)
if self.hub_port == self.proxy_api_port:
raise TraitError("The hub and proxy API cannot both listen on port %i" % self.hub_port)
if self.proxy_api_port == self.port:
raise TraitError("The proxy's public and API ports cannot both be %i" % self.port)
@staticmethod @staticmethod
def add_url_prefix(prefix, handlers): def add_url_prefix(prefix, handlers):
@@ -805,36 +818,6 @@ class JupyterHub(Application):
self._local.db = scoped_session(self.session_factory)() self._local.db = scoped_session(self.session_factory)()
return self._local.db return self._local.db
@property
def hub(self):
if not getattr(self._local, 'hub', None):
q = self.db.query(orm.Hub)
assert q.count() <= 1
self._local.hub = q.first()
if self.subdomain_host and self._local.hub:
self._local.hub.host = self.subdomain_host
return self._local.hub
@hub.setter
def hub(self, hub):
self._local.hub = hub
if hub and self.subdomain_host:
hub.host = self.subdomain_host
@property
def proxy(self):
if not getattr(self._local, 'proxy', None):
q = self.db.query(orm.Proxy)
assert q.count() <= 1
p = self._local.proxy = q.first()
if p:
p.auth_token = self.proxy_auth_token
return self._local.proxy
@proxy.setter
def proxy(self, proxy):
self._local.proxy = proxy
def init_db(self): def init_db(self):
"""Create the database connection""" """Create the database connection"""
self.log.debug("Connecting to db: %s", self.db_url) self.log.debug("Connecting to db: %s", self.db_url)
@@ -861,28 +844,15 @@ class JupyterHub(Application):
def init_hub(self): def init_hub(self):
"""Load the Hub config into the database""" """Load the Hub config into the database"""
self.hub = self.db.query(orm.Hub).first() self.hub = Hub(
if self.hub is None: ip=self.hub_ip,
self.hub = orm.Hub( port=self.hub_port,
server=orm.Server( base_url=self.hub_prefix,
ip=self.hub_ip, cookie_name='jupyter-hub-token',
port=self.hub_port, public_host=self.subdomain_host,
base_url=self.hub_prefix, )
cookie_name='jupyter-hub-token', if self.hub_connect_ip:
) self.hub.connect_ip = self.hub_connect_ip
)
self.db.add(self.hub)
else:
server = self.hub.server
server.ip = self.hub_ip
server.port = self.hub_port
server.base_url = self.hub_prefix
if self.subdomain_host:
if not self.subdomain_host:
raise ValueError("Must specify subdomain_host when using subdomains."
" This should be the public domain[:port] of the Hub.")
self.db.commit()
@gen.coroutine @gen.coroutine
def init_users(self): def init_users(self):
@@ -954,11 +924,20 @@ class JupyterHub(Application):
try: try:
yield gen.maybe_future(self.authenticator.add_user(user)) yield gen.maybe_future(self.authenticator.add_user(user))
except Exception: except Exception:
# TODO: Review approach to synchronize whitelist with db
# known cause of the exception is a user who has already been removed from the system
# but the user still exists in the hub's user db
self.log.exception("Error adding user %r already in db", user.name) self.log.exception("Error adding user %r already in db", user.name)
db.commit() # can add_user touch the db? if self.authenticator.delete_invalid_users:
self.log.warning("Deleting invalid user %r from the Hub database", user.name)
db.delete(user)
else:
self.log.warning(dedent("""
You can set
c.Authenticator.delete_invalid_users = True
to automatically delete users from the Hub database that no longer pass
Authenticator validation,
such as when user accounts are deleted from the external system
without notifying JupyterHub.
"""))
db.commit()
# The whitelist set and the users in the db are now the same. # The whitelist set and the users in the db are now the same.
# From this point on, any user changes should be done simultaneously # From this point on, any user changes should be done simultaneously
@@ -1058,7 +1037,6 @@ class JupyterHub(Application):
base_url=self.base_url, base_url=self.base_url,
db=self.db, orm=orm_service, db=self.db, orm=orm_service,
domain=domain, host=host, domain=domain, host=host,
hub_api_url=self.hub.api_url,
hub=self.hub, hub=self.hub,
) )
@@ -1151,7 +1129,14 @@ class JupyterHub(Application):
self.users[orm_user.id] = user = User(orm_user, self.tornado_settings) self.users[orm_user.id] = user = User(orm_user, self.tornado_settings)
self.log.debug("Loading state for %s from db", user.name) self.log.debug("Loading state for %s from db", user.name)
spawner = user.spawner spawner = user.spawner
status = yield spawner.poll() status = 0
if user.server:
try:
status = yield spawner.poll()
except Exception:
self.log.exception("Failed to poll spawner for %s, assuming the spawner is not running.", user.name)
status = -1
if status is None: if status is None:
self.log.info("%s still running", user.name) self.log.info("%s still running", user.name)
spawner.add_poll_callback(user_stopped, user) spawner.add_poll_callback(user_stopped, user)
@@ -1173,131 +1158,37 @@ class JupyterHub(Application):
db.commit() db.commit()
def init_oauth(self): def init_oauth(self):
base_url = self.hub.server.base_url
self.oauth_provider = make_provider( self.oauth_provider = make_provider(
self.session_factory, self.session_factory,
url_prefix=url_path_join(self.hub.server.base_url, 'api/oauth2'), url_prefix=url_path_join(base_url, 'api/oauth2'),
login_url=self.authenticator.login_url(self.hub.server.base_url), login_url=url_path_join(base_url, 'login')
,
) )
def init_proxy(self): def init_proxy(self):
"""Load the Proxy config into the database""" """Load the Proxy config"""
self.proxy = self.db.query(orm.Proxy).first() # FIXME: handle deprecated config here
if self.proxy is None: public_url = 'http{s}://{ip}:{port}{base_url}'.format(
self.proxy = orm.Proxy( s='s' if self.ssl_cert else '',
public_server=orm.Server(), ip=self.ip,
api_server=orm.Server(), port=self.port,
) base_url=self.base_url,
self.db.add(self.proxy) )
self.db.commit() self.proxy = self.proxy_class(
self.proxy.auth_token = self.proxy_auth_token # not persisted db=self.db,
self.proxy.log = self.log public_url=public_url,
self.proxy.public_server.ip = self.ip parent=self,
self.proxy.public_server.port = self.port app=self,
self.proxy.public_server.base_url = self.base_url log=self.log,
self.proxy.api_server.ip = self.proxy_api_ip hub=self.hub,
self.proxy.api_server.port = self.proxy_api_port ssl_cert=self.ssl_cert,
self.proxy.api_server.base_url = '/api/routes/' ssl_key=self.ssl_key,
self.db.commit()
@gen.coroutine
def start_proxy(self):
"""Actually start the configurable-http-proxy"""
# check for proxy
if self.proxy.public_server.is_up() or self.proxy.api_server.is_up():
# check for *authenticated* access to the proxy (auth token can change)
try:
routes = yield self.proxy.get_routes()
except (HTTPError, OSError, socket.error) as e:
if isinstance(e, HTTPError) and e.code == 403:
msg = "Did CONFIGPROXY_AUTH_TOKEN change?"
else:
msg = "Is something else using %s?" % self.proxy.public_server.bind_url
self.log.error("Proxy appears to be running at %s, but I can't access it (%s)\n%s",
self.proxy.public_server.bind_url, e, msg)
self.exit(1)
return
else:
self.log.info("Proxy already running at: %s", self.proxy.public_server.bind_url)
yield self.proxy.check_routes(self.users, self._service_map, routes)
self.proxy_process = None
return
env = os.environ.copy()
env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token
cmd = self.proxy_cmd + [
'--ip', self.proxy.public_server.ip,
'--port', str(self.proxy.public_server.port),
'--api-ip', self.proxy.api_server.ip,
'--api-port', str(self.proxy.api_server.port),
'--default-target', self.hub.server.host,
'--error-target', url_path_join(self.hub.server.url, 'error'),
]
if self.subdomain_host:
cmd.append('--host-routing')
if self.debug_proxy:
cmd.extend(['--log-level', 'debug'])
if self.ssl_key:
cmd.extend(['--ssl-key', self.ssl_key])
if self.ssl_cert:
cmd.extend(['--ssl-cert', self.ssl_cert])
if self.statsd_host:
cmd.extend([
'--statsd-host', self.statsd_host,
'--statsd-port', str(self.statsd_port),
'--statsd-prefix', self.statsd_prefix + '.chp'
])
# Warn if SSL is not used
if ' --ssl' not in ' '.join(cmd):
self.log.warning("Running JupyterHub without SSL."
" I hope there is SSL termination happening somewhere else...")
self.log.info("Starting proxy @ %s", self.proxy.public_server.bind_url)
self.log.debug("Proxy cmd: %s", cmd)
try:
self.proxy_process = Popen(cmd, env=env, start_new_session=True)
except FileNotFoundError as e:
self.log.error(
"Failed to find proxy %r\n"
"The proxy can be installed with `npm install -g configurable-http-proxy`"
% self.proxy_cmd
)
self.exit(1)
def _check():
status = self.proxy_process.poll()
if status is not None:
e = RuntimeError("Proxy failed to start with exit code %i" % status)
# py2-compatible `raise e from None`
e.__cause__ = None
raise e
for server in (self.proxy.public_server, self.proxy.api_server):
for i in range(10):
_check()
try:
yield server.wait_up(1)
except TimeoutError:
continue
else:
break
yield server.wait_up(1)
self.log.debug("Proxy started and appears to be up")
@gen.coroutine
def check_proxy(self):
if self.proxy_process.poll() is None:
return
self.log.error("Proxy stopped with exit code %r",
'unknown' if self.proxy_process is None else self.proxy_process.poll()
) )
yield self.start_proxy()
self.log.info("Setting up routes on new proxy")
yield self.proxy.add_all_users(self.users)
yield self.proxy.add_all_services(self.services)
self.log.info("New proxy back up, and good to go")
def init_tornado_settings(self): def init_tornado_settings(self):
"""Set up the tornado settings dict.""" """Set up the tornado settings dict."""
base_url = self.hub.server.base_url base_url = self.hub.base_url
jinja_options = dict( jinja_options = dict(
autoescape=True, autoescape=True,
) )
@@ -1307,7 +1198,7 @@ class JupyterHub(Application):
**jinja_options **jinja_options
) )
login_url = self.authenticator.login_url(base_url) login_url = url_path_join(base_url, 'login')
logout_url = self.authenticator.logout_url(base_url) logout_url = self.authenticator.logout_url(base_url)
# if running from git, disable caching of require.js # if running from git, disable caching of require.js
@@ -1335,7 +1226,7 @@ class JupyterHub(Application):
login_url=login_url, login_url=login_url,
logout_url=logout_url, logout_url=logout_url,
static_path=os.path.join(self.data_files_path, 'static'), static_path=os.path.join(self.data_files_path, 'static'),
static_url_prefix=url_path_join(self.hub.server.base_url, 'static/'), static_url_prefix=url_path_join(self.hub.base_url, 'static/'),
static_handler_class=CacheControlStaticFilesHandler, static_handler_class=CacheControlStaticFilesHandler,
template_path=self.template_paths, template_path=self.template_paths,
jinja2_env=jinja_env, jinja2_env=jinja_env,
@@ -1420,13 +1311,8 @@ class JupyterHub(Application):
# clean up proxy while single-user servers are shutting down # clean up proxy while single-user servers are shutting down
if self.cleanup_proxy: if self.cleanup_proxy:
if self.proxy_process: if self.proxy.should_start:
self.log.info("Cleaning up proxy[%i]...", self.proxy_process.pid) yield gen.maybe_future(self.proxy.stop())
if self.proxy_process.poll() is None:
try:
self.proxy_process.terminate()
except Exception as e:
self.log.error("Failed to terminate proxy process: %s", e)
else: else:
self.log.info("I didn't start the proxy, I can't clean it up") self.log.info("I didn't start the proxy, I can't clean it up")
else: else:
@@ -1482,26 +1368,30 @@ class JupyterHub(Application):
@gen.coroutine @gen.coroutine
def update_last_activity(self): def update_last_activity(self):
"""Update User.last_activity timestamps from the proxy""" """Update User.last_activity timestamps from the proxy"""
routes = yield self.proxy.get_routes() routes = yield self.proxy.get_all_routes()
users_count = 0 users_count = 0
active_users_count = 0 active_users_count = 0
for prefix, route in routes.items(): for prefix, route in routes.items():
if 'user' not in route: route_data = route['data']
if 'user' not in route_data:
# not a user route, ignore it # not a user route, ignore it
continue continue
user = orm.User.find(self.db, route['user']) users_count += 1
if 'last_activity' not in route_data:
# no last activity data (possibly proxy other than CHP)
continue
user = orm.User.find(self.db, route_data['user'])
if user is None: if user is None:
self.log.warning("Found no user for route: %s", route) self.log.warning("Found no user for route: %s", route)
continue continue
try: try:
dt = datetime.strptime(route['last_activity'], ISO8601_ms) dt = datetime.strptime(route_data['last_activity'], ISO8601_ms)
except Exception: except Exception:
dt = datetime.strptime(route['last_activity'], ISO8601_s) dt = datetime.strptime(route_data['last_activity'], ISO8601_s)
user.last_activity = max(user.last_activity, dt) user.last_activity = max(user.last_activity, dt)
# FIXME: Make this configurable duration. 30 minutes for now! # FIXME: Make this configurable duration. 30 minutes for now!
if (datetime.now() - user.last_activity).total_seconds() < 30 * 60: if (datetime.now() - user.last_activity).total_seconds() < 30 * 60:
active_users_count += 1 active_users_count += 1
users_count += 1
self.statsd.gauge('users.running', users_count) self.statsd.gauge('users.running', users_count)
self.statsd.gauge('users.active', active_users_count) self.statsd.gauge('users.active', active_users_count)
@@ -1528,17 +1418,20 @@ class JupyterHub(Application):
try: try:
self.http_server.listen(self.hub_port, address=self.hub_ip) self.http_server.listen(self.hub_port, address=self.hub_ip)
except Exception: except Exception:
self.log.error("Failed to bind hub to %s", self.hub.server.bind_url) self.log.error("Failed to bind hub to %s", self.hub.bind_url)
raise raise
else: else:
self.log.info("Hub API listening on %s", self.hub.server.bind_url) self.log.info("Hub API listening on %s", self.hub.bind_url)
# start the proxy # start the proxy
try: if self.proxy.should_start:
yield self.start_proxy() try:
except Exception as e: yield self.proxy.start()
self.log.critical("Failed to start proxy", exc_info=True) except Exception as e:
self.exit(1) self.log.critical("Failed to start proxy", exc_info=True)
self.exit(1)
else:
self.log.info("Not starting proxy")
# start the service(s) # start the service(s)
for service_name, service in self._service_map.items(): for service_name, service in self._service_map.items():
@@ -1572,12 +1465,6 @@ class JupyterHub(Application):
loop.add_callback(self.proxy.add_all_users, self.users) loop.add_callback(self.proxy.add_all_users, self.users)
loop.add_callback(self.proxy.add_all_services, self._service_map) loop.add_callback(self.proxy.add_all_services, self._service_map)
if self.proxy_process:
# only check / restart the proxy if we started it in the first place.
# this means a restarted Hub cannot restart a Proxy that its
# predecessor started.
pc = PeriodicCallback(self.check_proxy, 1e3 * self.proxy_check_interval)
pc.start()
if self.service_check_interval and any(s.url for s in self._service_map.values()): if self.service_check_interval and any(s.url for s in self._service_map.values()):
pc = PeriodicCallback(self.check_services_health, 1e3 * self.service_check_interval) pc = PeriodicCallback(self.check_services_health, 1e3 * self.service_check_interval)
@@ -1587,7 +1474,7 @@ class JupyterHub(Application):
pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval) pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval)
pc.start() pc.start()
self.log.info("JupyterHub is now running at %s", self.proxy.public_server.url) self.log.info("JupyterHub is now running at %s", self.proxy.public_url)
# register cleanup on both TERM and INT # register cleanup on both TERM and INT
atexit.register(self.atexit) atexit.register(self.atexit)
self.init_signal() self.init_signal()

View File

@@ -31,7 +31,7 @@ class Authenticator(LoggingConfigurable):
help=""" help="""
Set of users that will have admin rights on this JupyterHub. Set of users that will have admin rights on this JupyterHub.
Admin users have extra privilages: Admin users have extra privileges:
- Use the admin panel to see list of users logged in - Use the admin panel to see list of users logged in
- Add / remove users in some authenticators - Add / remove users in some authenticators
- Restart / halt the hub - Restart / halt the hub
@@ -125,6 +125,23 @@ class Authenticator(LoggingConfigurable):
""" """
).tag(config=True) ).tag(config=True)
delete_invalid_users = Bool(False,
help="""Delete any users from the database that do not pass validation
When JupyterHub starts, `.add_user` will be called
on each user in the database to verify that all users are still valid.
If `delete_invalid_users` is True,
any users that do not pass validation will be deleted from the database.
Use this if users might be deleted from an external system,
such as local user accounts.
If False (default), invalid users remain in the Hub's database
and a warning will be issued.
This is the default to avoid data loss due to config changes.
"""
)
def normalize_username(self, username): def normalize_username(self, username):
"""Normalize the given username and return it """Normalize the given username and return it
@@ -250,10 +267,23 @@ class Authenticator(LoggingConfigurable):
""" """
self.whitelist.discard(user.name) self.whitelist.discard(user.name)
auto_login = Bool(False, config=True,
help="""Automatically begin the login process
rather than starting with a "Login with..." link at `/hub/login`
To work, `.login_url()` must give a URL other than the default `/hub/login`,
such as an oauth handler or another automatic login handler,
registered with `.get_handlers()`.
.. versionadded:: 0.8
"""
)
def login_url(self, base_url): def login_url(self, base_url):
"""Override this when registering a custom login handler """Override this when registering a custom login handler
Generally used by authenticators that do not use simple form based authentication. Generally used by authenticators that do not use simple form-based authentication.
The subclass overriding this is responsible for making sure there is a handler The subclass overriding this is responsible for making sure there is a handler
available to handle the URL returned from this method, using the `get_handlers` available to handle the URL returned from this method, using the `get_handlers`

View File

@@ -89,5 +89,4 @@ def _alembic(*args):
if __name__ == '__main__': if __name__ == '__main__':
import sys
_alembic(*sys.argv[1:]) _alembic(*sys.argv[1:])

View File

@@ -17,8 +17,9 @@ from tornado.web import RequestHandler
from tornado import gen, web from tornado import gen, web
from .. import orm from .. import orm
from ..user import User from ..objects import Server
from ..spawner import LocalProcessSpawner from ..spawner import LocalProcessSpawner
from ..user import User
from ..utils import url_path_join from ..utils import url_path_join
# pattern for the authentication token header # pattern for the authentication token header
@@ -103,7 +104,7 @@ class BaseHandler(RequestHandler):
@property @property
def csp_report_uri(self): def csp_report_uri(self):
return self.settings.get('csp_report_uri', return self.settings.get('csp_report_uri',
url_path_join(self.hub.server.base_url, 'security/csp-report') url_path_join(self.hub.base_url, 'security/csp-report')
) )
@property @property
@@ -141,13 +142,35 @@ class BaseHandler(RequestHandler):
def cookie_max_age_days(self): def cookie_max_age_days(self):
return self.settings.get('cookie_max_age_days', None) return self.settings.get('cookie_max_age_days', None)
def get_current_user_token(self): def get_auth_token(self):
"""get_current_user from Authorization header token""" """Get the authorization token from Authorization header"""
auth_header = self.request.headers.get('Authorization', '') auth_header = self.request.headers.get('Authorization', '')
match = auth_header_pat.match(auth_header) match = auth_header_pat.match(auth_header)
if not match: if not match:
return None return None
token = match.group(1) return match.group(1)
def get_current_user_oauth_token(self):
"""Get the current user identified by OAuth access token
Separate from API token because OAuth access tokens
can only be used for identifying users,
not using the API.
"""
token = self.get_auth_token()
if token is None:
return None
orm_token = orm.OAuthAccessToken.find(self.db, token)
if orm_token is None:
return None
else:
return self._user_from_orm(orm_token.user)
def get_current_user_token(self):
"""get_current_user from Authorization header token"""
token = self.get_auth_token()
if token is None:
return None
orm_token = orm.APIToken.find(self.db, token) orm_token = orm.APIToken.find(self.db, token)
if orm_token is None: if orm_token is None:
return None return None
@@ -162,7 +185,7 @@ class BaseHandler(RequestHandler):
max_age_days=self.cookie_max_age_days, max_age_days=self.cookie_max_age_days,
) )
def clear(): def clear():
self.clear_cookie(cookie_name, path=self.hub.server.base_url) self.clear_cookie(cookie_name, path=self.hub.base_url)
if cookie_id is None: if cookie_id is None:
if self.get_cookie(cookie_name): if self.get_cookie(cookie_name):
@@ -186,7 +209,7 @@ class BaseHandler(RequestHandler):
def get_current_user_cookie(self): def get_current_user_cookie(self):
"""get_current_user from a cookie token""" """get_current_user from a cookie token"""
return self._user_for_cookie(self.hub.server.cookie_name) return self._user_for_cookie(self.hub.cookie_name)
def get_current_user(self): def get_current_user(self):
"""get current username""" """get current username"""
@@ -223,9 +246,7 @@ class BaseHandler(RequestHandler):
kwargs = {} kwargs = {}
if self.subdomain_host: if self.subdomain_host:
kwargs['domain'] = self.domain kwargs['domain'] = self.domain
if user and user.server: self.clear_cookie(self.hub.cookie_name, path=self.hub.base_url, **kwargs)
self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs)
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs)
self.clear_cookie('jupyterhub-services', path=url_path_join(self.base_url, 'services')) self.clear_cookie('jupyterhub-services', path=url_path_join(self.base_url, 'services'))
def _set_user_cookie(self, user, server): def _set_user_cookie(self, user, server):
@@ -414,7 +435,7 @@ class BaseHandler(RequestHandler):
def template_namespace(self): def template_namespace(self):
user = self.get_current_user() user = self.get_current_user()
return dict( return dict(
base_url=self.hub.server.base_url, base_url=self.hub.base_url,
prefix=self.base_url, prefix=self.base_url,
user=user, user=user,
login_url=self.settings['login_url'], login_url=self.settings['login_url'],
@@ -480,7 +501,7 @@ class PrefixRedirectHandler(BaseHandler):
else: else:
path = self.request.path path = self.request.path
self.redirect(url_path_join( self.redirect(url_path_join(
self.hub.server.base_url, path, self.hub.base_url, path,
), permanent=False) ), permanent=False)
@@ -506,12 +527,12 @@ class UserSpawnHandler(BaseHandler):
port = host_info.port port = host_info.port
if not port: if not port:
port = 443 if host_info.scheme == 'https' else 80 port = 443 if host_info.scheme == 'https' else 80
if port != self.proxy.public_server.port and port == self.hub.server.port: if port != Server.from_url(self.proxy.public_url).port and port == self.hub.port:
self.log.warning(""" self.log.warning("""
Detected possible direct connection to Hub's private ip: %s, bypassing proxy. Detected possible direct connection to Hub's private ip: %s, bypassing proxy.
This will result in a redirect loop. This will result in a redirect loop.
Make sure to connect to the proxied public URL %s Make sure to connect to the proxied public URL %s
""", self.request.full_url(), self.proxy.public_server.url) """, self.request.full_url(), self.proxy.public_url)
# logged in as correct user, spawn the server # logged in as correct user, spawn the server
if current_user.spawner: if current_user.spawner:
@@ -526,14 +547,14 @@ class UserSpawnHandler(BaseHandler):
status = yield current_user.spawner.poll() status = yield current_user.spawner.poll()
if status is not None: if status is not None:
if current_user.spawner.options_form: if current_user.spawner.options_form:
self.redirect(url_concat(url_path_join(self.hub.server.base_url, 'spawn'), self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'),
{'next': self.request.uri})) {'next': self.request.uri}))
return return
else: else:
yield self.spawn_single_user(current_user) yield self.spawn_single_user(current_user)
# set login cookie anew # set login cookie anew
self.set_login_cookie(current_user) self.set_login_cookie(current_user)
without_prefix = self.request.uri[len(self.hub.server.base_url):] without_prefix = self.request.uri[len(self.hub.base_url):]
target = url_path_join(self.base_url, without_prefix) target = url_path_join(self.base_url, without_prefix)
if self.subdomain_host: if self.subdomain_host:
target = current_user.host + target target = current_user.host + target

View File

@@ -7,8 +7,8 @@ from urllib.parse import urlparse
from tornado.escape import url_escape from tornado.escape import url_escape
from tornado import gen from tornado import gen
from tornado.httputil import url_concat
from ..utils import url_path_join
from .base import BaseHandler from .base import BaseHandler
@@ -19,12 +19,11 @@ class LogoutHandler(BaseHandler):
if user: if user:
self.log.info("User logged out: %s", user.name) self.log.info("User logged out: %s", user.name)
self.clear_login_cookie() self.clear_login_cookie()
for name in user.other_user_cookies:
self.clear_login_cookie(name)
user.other_user_cookies = set([])
self.statsd.incr('logout') self.statsd.incr('logout')
if self.authenticator.auto_login:
self.redirect(url_path_join(self.hub.server.base_url, 'login'), permanent=False) self.render('logout.html')
else:
self.redirect(self.settings['login_url'], permanent=False)
class LoginHandler(BaseHandler): class LoginHandler(BaseHandler):
@@ -37,6 +36,10 @@ class LoginHandler(BaseHandler):
login_error=login_error, login_error=login_error,
custom_html=self.authenticator.custom_html, custom_html=self.authenticator.custom_html,
login_url=self.settings['login_url'], login_url=self.settings['login_url'],
authenticator_login_url=url_concat(
self.authenticator.login_url(self.hub.server.base_url),
{'next': self.get_argument('next', '')},
),
) )
def get(self): def get(self):
@@ -54,12 +57,22 @@ class LoginHandler(BaseHandler):
if user.running: if user.running:
next_url = user.url next_url = user.url
else: else:
next_url = self.hub.server.base_url next_url = self.hub.base_url
# set new login cookie # set new login cookie
# because single-user cookie may have been cleared or incorrect # because single-user cookie may have been cleared or incorrect
self.set_login_cookie(self.get_current_user()) self.set_login_cookie(self.get_current_user())
self.redirect(next_url, permanent=False) self.redirect(next_url, permanent=False)
else: else:
if self.authenticator.auto_login:
auto_login_url = self.authenticator.login_url(self.hub.server.base_url)
if auto_login_url == self.settings['login_url']:
self.authenticator.auto_login = False
self.log.warning("Authenticator.auto_login cannot be used without a custom login_url")
else:
if next_url:
auto_login_url = url_concat(auto_login_url, {'next': next_url})
self.redirect(auto_login_url)
return
username = self.get_argument('username', default='') username = self.get_argument('username', default='')
self.finish(self._render(username=username)) self.finish(self._render(username=username))
@@ -68,7 +81,7 @@ class LoginHandler(BaseHandler):
# parse the arguments dict # parse the arguments dict
data = {} data = {}
for arg in self.request.arguments: for arg in self.request.arguments:
data[arg] = self.get_argument(arg) data[arg] = self.get_argument(arg, strip=False)
auth_timer = self.statsd.timer('login.authenticate').start() auth_timer = self.statsd.timer('login.authenticate').start()
username = yield self.authenticate(data) username = yield self.authenticate(data)
@@ -88,7 +101,7 @@ class LoginHandler(BaseHandler):
next_url = self.get_argument('next', default='') next_url = self.get_argument('next', default='')
if not next_url.startswith('/'): if not next_url.startswith('/'):
next_url = '' next_url = ''
next_url = next_url or self.hub.server.base_url next_url = next_url or self.hub.base_url
self.redirect(next_url) self.redirect(next_url)
self.log.info("User logged in: %s", username) self.log.info("User logged in: %s", username)
else: else:

View File

@@ -37,7 +37,7 @@ class RootHandler(BaseHandler):
# The next request will be handled by UserSpawnHandler, # The next request will be handled by UserSpawnHandler,
# ultimately redirecting to the logged-in user's server. # ultimately redirecting to the logged-in user's server.
without_prefix = next_url[len(self.base_url):] without_prefix = next_url[len(self.base_url):]
next_url = url_path_join(self.hub.server.base_url, without_prefix) next_url = url_path_join(self.hub.base_url, without_prefix)
self.log.warning("Redirecting %s to %s. For sharing public links, use /user-redirect/", self.log.warning("Redirecting %s to %s. For sharing public links, use /user-redirect/",
self.request.uri, next_url, self.request.uri, next_url,
) )
@@ -50,10 +50,10 @@ class RootHandler(BaseHandler):
self.log.debug("User is running: %s", url) self.log.debug("User is running: %s", url)
self.set_login_cookie(user) # set cookie self.set_login_cookie(user) # set cookie
else: else:
url = url_path_join(self.hub.server.base_url, 'home') url = url_path_join(self.hub.base_url, 'home')
self.log.debug("User is not running: %s", url) self.log.debug("User is not running: %s", url)
else: else:
url = url_path_join(self.hub.server.base_url, 'login') url = self.settings['login_url']
self.redirect(url) self.redirect(url)
@@ -67,13 +67,9 @@ class HomeHandler(BaseHandler):
if user.running: if user.running:
# trigger poll_and_notify event in case of a server that died # trigger poll_and_notify event in case of a server that died
yield user.spawner.poll_and_notify() yield user.spawner.poll_and_notify()
url = user.url
else:
url = url_concat(url_path_join(self.base_url, 'spawn'),
{'next': self.request.uri})
html = self.render_template('home.html', html = self.render_template('home.html',
user=user, user=user,
url=url, url=user.url,
) )
self.finish(html) self.finish(html)
@@ -215,7 +211,7 @@ class ProxyErrorHandler(BaseHandler):
status_message = responses.get(status_code, 'Unknown HTTP Error') status_message = responses.get(status_code, 'Unknown HTTP Error')
# build template namespace # build template namespace
hub_home = url_path_join(self.hub.server.base_url, 'home') hub_home = url_path_join(self.hub.base_url, 'home')
message_html = '' message_html = ''
if status_code == 503: if status_code == 503:
message_html = ' '.join([ message_html = ' '.join([

View File

@@ -6,7 +6,7 @@ import json
import traceback import traceback
from tornado.log import LogFormatter, access_log from tornado.log import LogFormatter, access_log
from tornado.web import StaticFileHandler from tornado.web import StaticFileHandler, HTTPError
def coroutine_traceback(typ, value, tb): def coroutine_traceback(typ, value, tb):
@@ -85,16 +85,38 @@ def log_request(handler):
headers = _scrub_headers(request.headers) headers = _scrub_headers(request.headers)
request_time = 1000.0 * handler.request.request_time() request_time = 1000.0 * handler.request.request_time()
user = handler.get_current_user()
try:
user = handler.get_current_user()
except HTTPError:
username = ''
else:
if user is None:
username = ''
elif isinstance(user, str):
username = user
elif isinstance(user, dict):
username = user['name']
else:
username = user.name
ns = dict( ns = dict(
status=status, status=status,
method=request.method, method=request.method,
ip=request.remote_ip, ip=request.remote_ip,
uri=uri, uri=uri,
request_time=request_time, request_time=request_time,
user=user.name if user else '' user=username,
location='',
) )
msg = "{status} {method} {uri} ({user}@{ip}) {request_time:.2f}ms" msg = "{status} {method} {uri}{location} ({user}@{ip}) {request_time:.2f}ms"
if status >= 500 and status != 502: if status >= 500 and status != 502:
log_method(json.dumps(headers, indent=2)) log_method(json.dumps(headers, indent=2))
elif status in {301, 302}:
# log redirect targets
# FIXME: _headers is private, but there doesn't appear to be a public way
# to get headers from tornado
location = handler._headers.get('Location')
if location:
ns['location'] = '{}'.format(location)
log_method(msg.format(**ns)) log_method(msg.format(**ns))

View File

@@ -5,8 +5,8 @@ implements https://python-oauth2.readthedocs.io/en/latest/store.html
import threading import threading
from oauth2.datatype import Client, AccessToken, AuthorizationCode from oauth2.datatype import Client, AuthorizationCode
from oauth2.error import AccessTokenNotFound, AuthCodeNotFound, ClientNotFoundError, UserNotAuthenticated from oauth2.error import AuthCodeNotFound, ClientNotFoundError, UserNotAuthenticated
from oauth2.grant import AuthorizationCodeGrant from oauth2.grant import AuthorizationCodeGrant
from oauth2.web import AuthorizationCodeGrantSiteAdapter from oauth2.web import AuthorizationCodeGrantSiteAdapter
import oauth2.store import oauth2.store
@@ -17,7 +17,6 @@ from sqlalchemy.orm import scoped_session
from tornado.escape import url_escape from tornado.escape import url_escape
from .. import orm from .. import orm
from jupyterhub.orm import APIToken
from ..utils import url_path_join, hash_token, compare_token from ..utils import url_path_join, hash_token, compare_token
@@ -66,17 +65,6 @@ class HubDBMixin(object):
class AccessTokenStore(HubDBMixin, oauth2.store.AccessTokenStore): class AccessTokenStore(HubDBMixin, oauth2.store.AccessTokenStore):
"""OAuth2 AccessTokenStore, storing data in the Hub database""" """OAuth2 AccessTokenStore, storing data in the Hub database"""
def _access_token_from_orm(self, orm_token):
"""Transform an ORM AccessToken record into an oauth2 AccessToken instance"""
return AccessToken(
client_id=orm_token.client_id,
grant_type=orm_token.grant_type,
expires_at=orm_token.expires_at,
refresh_token=orm_token.refresh_token,
refresh_expires_at=orm_token.refresh_expires_at,
user_id=orm_token.user_id,
)
def save_token(self, access_token): def save_token(self, access_token):
""" """
Stores an access token in the database. Stores an access token in the database.
@@ -86,17 +74,14 @@ class AccessTokenStore(HubDBMixin, oauth2.store.AccessTokenStore):
""" """
user = self.db.query(orm.User).filter(orm.User.id == access_token.user_id).first() user = self.db.query(orm.User).filter(orm.User.id == access_token.user_id).first()
token = user.new_api_token(access_token.token)
orm_api_token = APIToken.find(self.db, token, kind='user')
orm_access_token = orm.OAuthAccessToken( orm_access_token = orm.OAuthAccessToken(
client_id=access_token.client_id, client_id=access_token.client_id,
grant_type=access_token.grant_type, grant_type=access_token.grant_type,
expires_at=access_token.expires_at, expires_at=access_token.expires_at,
refresh_token=access_token.refresh_token, refresh_token=access_token.refresh_token,
refresh_expires_at=access_token.refresh_expires_at, refresh_expires_at=access_token.refresh_expires_at,
token=access_token.token,
user=user, user=user,
api_token=orm_api_token,
) )
self.db.add(orm_access_token) self.db.add(orm_access_token)
self.db.commit() self.db.commit()

149
jupyterhub/objects.py Normal file
View File

@@ -0,0 +1,149 @@
"""Some general objects for use in JupyterHub"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import socket
from urllib.parse import urlparse
from tornado import gen
from traitlets import (
HasTraits, Instance, Integer, Unicode,
default, observe,
)
from . import orm
from .utils import (
url_path_join, can_connect, wait_for_server,
wait_for_http_server, random_port,
)
class Server(HasTraits):
"""An object representing an HTTP endpoint.
*Some* of these reside in the database (user servers),
but others (Hub, proxy) are in-memory only.
"""
orm_server = Instance(orm.Server, allow_none=True)
ip = Unicode()
connect_ip = Unicode()
proto = Unicode('http')
port = Integer()
base_url = Unicode('/')
cookie_name = Unicode('')
@property
def _connect_ip(self):
"""The address to use when connecting to this server
When `ip` is set to a real ip address, the same value is used.
When `ip` refers to 'all interfaces' (e.g. '0.0.0.0'),
clients connect via hostname by default.
Setting `connect_ip` explicitly overrides any default behavior.
"""
if self.connect_ip:
return self.connect_ip
elif self.ip in {'', '0.0.0.0'}:
# if listening on all interfaces, default to hostname for connect
return socket.gethostname()
else:
return self.ip
@classmethod
def from_url(cls, url):
"""Create a Server from a given URL"""
urlinfo = urlparse(url)
proto = urlinfo.scheme
ip = urlinfo.hostname or ''
port = urlinfo.port
if not port:
if proto == 'https':
port = 443
else:
port = 80
return cls(proto=proto, ip=ip, port=port, base_url=urlinfo.path)
@default('port')
def _default_port(self):
return random_port()
@observe('orm_server')
def _orm_server_changed(self, change):
"""When we get an orm_server, get attributes from there."""
obj = change.new
self.proto = obj.proto
self.ip = obj.ip
self.port = obj.port
self.base_url = obj.base_url
self.cookie_name = obj.cookie_name
# setter to pass through to the database
@observe('ip', 'proto', 'port', 'base_url', 'cookie_name')
def _change(self, change):
if self.orm_server:
setattr(self.orm_server, change.name, change.new)
@property
def host(self):
return "{proto}://{ip}:{port}".format(
proto=self.proto,
ip=self._connect_ip,
port=self.port,
)
@property
def url(self):
return "{host}{uri}".format(
host=self.host,
uri=self.base_url,
)
@property
def bind_url(self):
"""representation of URL used for binding
Never used in APIs, only logging,
since it can be non-connectable value, such as '', meaning all interfaces.
"""
if self.ip in {'', '0.0.0.0'}:
return self.url.replace(self._connect_ip, self.ip or '*', 1)
return self.url
@gen.coroutine
def wait_up(self, timeout=10, http=False):
"""Wait for this server to come up"""
if http:
yield wait_for_http_server(self.url, timeout=timeout)
else:
yield wait_for_server(self._connect_ip, self.port, timeout=timeout)
def is_up(self):
"""Is the server accepting connections?"""
return can_connect(self.ip or '127.0.0.1', self.port)
class Hub(Server):
"""Bring it all together at the hub.
The Hub is a server, plus its API path suffix
the api_url is the full URL plus the api_path suffix on the end
of the server base_url.
"""
@property
def server(self):
"""backward-compat"""
return self
public_host = Unicode()
@property
def api_url(self):
"""return the full API url (with proto://host...)"""
return url_path_join(self.url, 'api')
def __repr__(self):
return "<%s %s:%s>" % (
self.__class__.__name__, self.server.ip, self.server.port,
)

View File

@@ -79,251 +79,6 @@ class Server(Base):
def __repr__(self): def __repr__(self):
return "<Server(%s:%s)>" % (self.ip, self.port) return "<Server(%s:%s)>" % (self.ip, self.port)
@property
def host(self):
ip = self.ip
if ip in {'', '0.0.0.0'}:
# when listening on all interfaces, connect to localhost
ip = '127.0.0.1'
return "{proto}://{ip}:{port}".format(
proto=self.proto,
ip=ip,
port=self.port,
)
@property
def url(self):
return "{host}{uri}".format(
host=self.host,
uri=self.base_url,
)
@property
def bind_url(self):
"""representation of URL used for binding
Never used in APIs, only logging,
since it can be non-connectable value, such as '', meaning all interfaces.
"""
if self.ip in {'', '0.0.0.0'}:
return self.url.replace('127.0.0.1', self.ip or '*', 1)
return self.url
@gen.coroutine
def wait_up(self, timeout=10, http=False):
"""Wait for this server to come up"""
if http:
yield wait_for_http_server(self.url, timeout=timeout)
else:
yield wait_for_server(self.ip or '127.0.0.1', self.port, timeout=timeout)
def is_up(self):
"""Is the server accepting connections?"""
return can_connect(self.ip or '127.0.0.1', self.port)
class Proxy(Base):
"""A configurable-http-proxy instance.
A proxy consists of the API server info and the public-facing server info,
plus an auth token for configuring the proxy table.
"""
__tablename__ = 'proxies'
id = Column(Integer, primary_key=True)
auth_token = None
_public_server_id = Column(Integer, ForeignKey('servers.id'))
public_server = relationship(Server, primaryjoin=_public_server_id == Server.id)
_api_server_id = Column(Integer, ForeignKey('servers.id'))
api_server = relationship(Server, primaryjoin=_api_server_id == Server.id)
def __repr__(self):
if self.public_server:
return "<%s %s:%s>" % (
self.__class__.__name__, self.public_server.ip, self.public_server.port,
)
else:
return "<%s [unconfigured]>" % self.__class__.__name__
def api_request(self, path, method='GET', body=None, client=None):
"""Make an authenticated API request of the proxy"""
client = client or AsyncHTTPClient()
url = url_path_join(self.api_server.url, path)
if isinstance(body, dict):
body = json.dumps(body)
self.log.debug("Fetching %s %s", method, url)
req = HTTPRequest(url,
method=method,
headers={'Authorization': 'token {}'.format(self.auth_token)},
body=body,
)
return client.fetch(req)
@gen.coroutine
def add_service(self, service, client=None):
"""Add a service's server to the proxy table."""
if not service.server:
raise RuntimeError(
"Service %s does not have an http endpoint to add to the proxy.", service.name)
self.log.info("Adding service %s to proxy %s => %s",
service.name, service.proxy_path, service.server.host,
)
yield self.api_request(service.proxy_path,
method='POST',
body=dict(
target=service.server.host,
service=service.name,
),
client=client,
)
@gen.coroutine
def delete_service(self, service, client=None):
"""Remove a service's server from the proxy table."""
self.log.info("Removing service %s from proxy", service.name)
yield self.api_request(service.proxy_path,
method='DELETE',
client=client,
)
# FIX-ME
# we need to add a reference to a specific server
@gen.coroutine
def add_user(self, user, client=None):
"""Add a user's server to the proxy table."""
self.log.info("Adding user %s to proxy %s => %s",
user.name, user.proxy_path, user.server.host,
)
if user.spawn_pending:
raise RuntimeError(
"User %s's spawn is pending, shouldn't be added to the proxy yet!", user.name)
yield self.api_request(user.proxy_path,
method='POST',
body=dict(
target=user.server.host,
user=user.name,
),
client=client,
)
@gen.coroutine
def delete_user(self, user, client=None):
"""Remove a user's server from the proxy table."""
self.log.info("Removing user %s from proxy", user.name)
yield self.api_request(user.proxy_path,
method='DELETE',
client=client,
)
@gen.coroutine
def add_all_services(self, service_dict):
"""Update the proxy table from the database.
Used when loading up a new proxy.
"""
db = inspect(self).session
futures = []
for orm_service in db.query(Service):
service = service_dict[orm_service.name]
if service.server:
futures.append(self.add_service(service))
# wait after submitting them all
for f in futures:
yield f
@gen.coroutine
def add_all_users(self, user_dict):
"""Update the proxy table from the database.
Used when loading up a new proxy.
"""
db = inspect(self).session
futures = []
for orm_user in db.query(User):
user = user_dict[orm_user]
if user.running:
futures.append(self.add_user(user))
# wait after submitting them all
for f in futures:
yield f
@gen.coroutine
def get_routes(self, client=None):
"""Fetch the proxy's routes"""
resp = yield self.api_request('', client=client)
return json.loads(resp.body.decode('utf8', 'replace'))
# FIX-ME
# we need to add a reference to a specific server
@gen.coroutine
def check_routes(self, user_dict, service_dict, routes=None):
"""Check that all users are properly routed on the proxy"""
if not routes:
routes = yield self.get_routes()
user_routes = { r['user'] for r in routes.values() if 'user' in r }
futures = []
db = inspect(self).session
for orm_user in db.query(User):
user = user_dict[orm_user]
if user.running:
if user.name not in user_routes:
self.log.warning("Adding missing route for %s (%s)", user.name, user.server)
futures.append(self.add_user(user))
else:
# User not running, make sure it's not in the table
if user.name in user_routes:
self.log.warning("Removing route for not running %s", user.name)
futures.append(self.delete_user(user))
# check service routes
service_routes = { r['service'] for r in routes.values() if 'service' in r }
for orm_service in db.query(Service).filter(Service.server != None):
service = service_dict[orm_service.name]
if service.server is None:
# This should never be True, but seems to be on rare occasion.
# catch filter bug, either in sqlalchemy or my understanding of its behavior
self.log.error("Service %s has no server, but wasn't filtered out.", service)
continue
if service.name not in service_routes:
self.log.warning("Adding missing route for %s (%s)", service.name, service.server)
futures.append(self.add_service(service))
for f in futures:
yield f
class Hub(Base):
"""Bring it all together at the hub.
The Hub is a server, plus its API path suffix
the api_url is the full URL plus the api_path suffix on the end
of the server base_url.
"""
__tablename__ = 'hubs'
id = Column(Integer, primary_key=True)
_server_id = Column(Integer, ForeignKey('servers.id'))
server = relationship(Server, primaryjoin=_server_id == Server.id)
host = ''
@property
def api_url(self):
"""return the full API url (with proto://host...)"""
return url_path_join(self.server.url, 'api')
def __repr__(self):
if self.server:
return "<%s %s:%s>" % (
self.__class__.__name__, self.server.ip, self.server.port,
)
else:
return "<%s [unconfigured]>" % self.__class__.__name__
# user:group many:many mapping table # user:group many:many mapping table
user_group_map = Table('user_group_map', Base.metadata, user_group_map = Table('user_group_map', Base.metadata,
@@ -393,25 +148,14 @@ class User(Base):
# group mapping # group mapping
groups = relationship('Group', secondary='user_group_map', back_populates='users') groups = relationship('Group', secondary='user_group_map', back_populates='users')
other_user_cookies = set([])
@property
def server(self):
"""Returns the first element of servers.
Returns None if the list is empty.
"""
if len(self.servers) == 0:
return None
else:
return self.servers[0]
def __repr__(self): def __repr__(self):
if self.server: if self.servers:
server = self.servers[0]
return "<{cls}({name}@{ip}:{port})>".format( return "<{cls}({name}@{ip}:{port})>".format(
cls=self.__class__.__name__, cls=self.__class__.__name__,
name=self.name, name=self.name,
ip=self.server.ip, ip=server.ip,
port=self.server.port, port=server.port,
) )
else: else:
return "<{cls}({name} [unconfigured])>".format( return "<{cls}({name} [unconfigured])>".format(
@@ -508,8 +252,65 @@ class Service(Base):
""" """
return db.query(cls).filter(cls.name == name).first() return db.query(cls).filter(cls.name == name).first()
class Hashed(object):
"""Mixin for tables with hashed tokens"""
prefix_length = 4
algorithm = "sha512"
rounds = 16384
salt_bytes = 8
min_length = 8
class APIToken(Base): @property
def token(self):
raise AttributeError("token is write-only")
@token.setter
def token(self, token):
"""Store the hashed value and prefix for a token"""
self.prefix = token[:self.prefix_length]
self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm)
def match(self, token):
"""Is this my token?"""
return compare_token(self.hashed, token)
@classmethod
def check_token(cls, db, token):
"""Check if a token is acceptable"""
if len(token) < cls.min_length:
raise ValueError("Tokens must be at least %i characters, got %r" % (
cls.min_length, token)
)
found = cls.find(db, token)
if found:
raise ValueError("Collision on token: %s..." % token[:cls.prefix_length])
@classmethod
def find_prefix(cls, db, token):
"""Start the query for matching token.
Returns an SQLAlchemy query already filtered by prefix-matches.
"""
prefix = token[:cls.prefix_length]
# since we can't filter on hashed values, filter on prefix
# so we aren't comparing with all tokens
return db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix))
@classmethod
def find(cls, db, token):
"""Find a token object by value.
Returns None if not found.
`kind='user'` only returns API tokens for users
`kind='service'` only returns API tokens for services
"""
prefix_match = cls.find_prefix(db, token)
for orm_token in prefix_match:
if orm_token.match(token):
return orm_token
class APIToken(Hashed, Base):
"""An API token""" """An API token"""
__tablename__ = 'api_tokens' __tablename__ = 'api_tokens'
@@ -523,21 +324,7 @@ class APIToken(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
hashed = Column(Unicode(1023)) hashed = Column(Unicode(1023))
prefix = Column(Unicode(1023)) prefix = Column(Unicode(16))
prefix_length = 4
algorithm = "sha512"
rounds = 16384
salt_bytes = 8
@property
def token(self):
raise AttributeError("token is write-only")
@token.setter
def token(self, token):
"""Store the hashed value and prefix for a token"""
self.prefix = token[:self.prefix_length]
self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm)
def __repr__(self): def __repr__(self):
if self.user is not None: if self.user is not None:
@@ -566,10 +353,7 @@ class APIToken(Base):
`kind='user'` only returns API tokens for users `kind='user'` only returns API tokens for users
`kind='service'` only returns API tokens for services `kind='service'` only returns API tokens for services
""" """
prefix = token[:cls.prefix_length] prefix_match = cls.find_prefix(db, token)
# since we can't filter on hashed values, filter on prefix
# so we aren't comparing with all tokens
prefix_match = db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix))
if kind == 'user': if kind == 'user':
prefix_match = prefix_match.filter(cls.user_id != None) prefix_match = prefix_match.filter(cls.user_id != None)
elif kind == 'service': elif kind == 'service':
@@ -580,10 +364,6 @@ class APIToken(Base):
if orm_token.match(token): if orm_token.match(token):
return orm_token return orm_token
def match(self, token):
"""Is this my token?"""
return compare_token(self.hashed, token)
@classmethod @classmethod
def new(cls, token=None, user=None, service=None): def new(cls, token=None, user=None, service=None):
"""Generate a new API token for a user or service""" """Generate a new API token for a user or service"""
@@ -593,12 +373,8 @@ class APIToken(Base):
if token is None: if token is None:
token = new_token() token = new_token()
else: else:
if len(token) < 8: cls.check_token(db, token)
raise ValueError("Tokens must be at least 8 characters, got %r" % token) orm_token = cls(token=token)
found = APIToken.find(db, token)
if found:
raise ValueError("Collision on token: %s..." % token[:4])
orm_token = APIToken(token=token)
if user: if user:
assert user.id is not None assert user.id is not None
orm_token.user_id = user.id orm_token.user_id = user.id
@@ -624,19 +400,29 @@ class GrantType(enum.Enum):
refresh_token = 'refresh_token' refresh_token = 'refresh_token'
class OAuthAccessToken(Base): class OAuthAccessToken(Hashed, Base):
__tablename__ = 'oauth_access_tokens' __tablename__ = 'oauth_access_tokens'
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
client_id = Column(Unicode(1023)) client_id = Column(Unicode(1023))
grant_type = Column(Enum(GrantType), nullable=False) grant_type = Column(Enum(GrantType), nullable=False)
expires_at = Column(Integer) expires_at = Column(Integer)
refresh_token = Column(Unicode(36)) refresh_token = Column(Unicode(64))
refresh_expires_at = Column(Integer) refresh_expires_at = Column(Integer)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE')) user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
user = relationship(User) user = relationship(User)
api_token_id = Column(Integer, ForeignKey('api_tokens.id', ondelete='CASCADE')) session = None # for API-equivalence with APIToken
api_token = relationship(APIToken, backref='oauth_token')
# from Hashed
hashed = Column(Unicode(64))
prefix = Column(Unicode(16))
def __repr__(self):
return "<{cls}('{prefix}...', user='{user}'>".format(
cls=self.__class__.__name__,
user=self.user and self.user.name,
prefix=self.prefix,
)
class OAuthCode(Base): class OAuthCode(Base):

416
jupyterhub/proxy.py Normal file
View File

@@ -0,0 +1,416 @@
"""API for JupyterHub's proxy."""
# Copyright (c) IPython Development Team.
# Distributed under the terms of the Modified BSD License.
import json
import os
from subprocess import Popen
import time
from tornado import gen
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.ioloop import PeriodicCallback
from traitlets import (
Any, Bool, Instance, Integer, Unicode,
default,
)
from jupyterhub.traitlets import Command
from traitlets.config import LoggingConfigurable
from .objects import Server
from .orm import Service, User
from . import utils
from .utils import url_path_join
class Proxy(LoggingConfigurable):
"""Base class for configurable proxies that JupyterHub can use."""
db = Any()
app = Any()
hub = Any()
public_url = Unicode()
ssl_key = Unicode()
ssl_cert = Unicode()
should_start = Bool(True, config=True,
help="""Should the Hub start the proxy.
If True, the Hub will start the proxy and stop it.
Set to False if the proxy is managed externally,
such as by systemd, docker, or another service manager.
""")
def start(self):
"""Start the proxy.
Will be called during startup if should_start is True.
"""
def stop(self):
"""Stop the proxy.
Will be called during teardown if should_start is True.
"""
@gen.coroutine
def add_route(self, routespec, target, data):
"""Add a route to the proxy.
Args:
routespec (str): A specification for which this route will be matched.
Could be either a url_prefix or a fqdn.
target (str): A URL that will be the target of this route.
data (dict): A JSONable dict that will be associated with this route, and will
be returned when retrieving information about this route.
Will raise an appropriate Exception (FIXME: find what?) if the route could
not be added.
The proxy implementation should also have a way to associate the fact that a
route came from JupyterHub.
"""
pass
@gen.coroutine
def delete_route(self, routespec):
"""Delete a route with a given routespec if it exists."""
pass
@gen.coroutine
def get_route(self, routespec):
"""Return the route info for a given routespec.
Args:
routespec (str): The route specification that was used to add this routespec
Returns:
result (dict): with the following keys:
`routespec`: The normalized route specification passed in to add_route
`target`: The target for this route
`data`: The arbitrary data that was passed in by JupyterHub when adding this
route.
None: if there are no routes matching the given routespec
"""
pass
@gen.coroutine
def get_all_routes(self):
"""Fetch and return all the routes associated by JupyterHub from the
proxy.
Should return a dictionary of routes, where the keys are
routespecs and each value is the dict that would be returned by
`get_route(routespec)`.
"""
pass
# Most basic implementers must only implement above methods
@gen.coroutine
def add_service(self, service, client=None):
"""Add a service's server to the proxy table."""
if not service.server:
raise RuntimeError(
"Service %s does not have an http endpoint to add to the proxy.", service.name)
self.log.info("Adding service %s to proxy %s => %s",
service.name, service.proxy_path, service.server.host,
)
yield self.add_route(
service.proxy_path,
service.server.host,
{'service': service.name}
)
@gen.coroutine
def delete_service(self, service, client=None):
"""Remove a service's server from the proxy table."""
self.log.info("Removing service %s from proxy", service.name)
yield self.delete_route(service.proxy_path)
@gen.coroutine
def add_user(self, user, client=None):
"""Add a user's server to the proxy table."""
self.log.info("Adding user %s to proxy %s => %s",
user.name, user.proxy_path, user.server.host,
)
if user.spawn_pending:
raise RuntimeError(
"User %s's spawn is pending, shouldn't be added to the proxy yet!", user.name)
yield self.add_route(
user.proxy_path,
user.server.host,
{'user': user.name}
)
@gen.coroutine
def delete_user(self, user):
"""Remove a user's server from the proxy table."""
self.log.info("Removing user %s from proxy", user.name)
yield self.delete_route(user.proxy_path)
@gen.coroutine
def add_all_services(self, service_dict):
"""Update the proxy table from the database.
Used when loading up a new proxy.
"""
db = self.db
futures = []
for orm_service in db.query(Service):
service = service_dict[orm_service.name]
if service.server:
futures.append(self.add_service(service))
# wait after submitting them all
for f in futures:
yield f
@gen.coroutine
def add_all_users(self, user_dict):
"""Update the proxy table from the database.
Used when loading up a new proxy.
"""
db = self.db
futures = []
for orm_user in db.query(User):
user = user_dict[orm_user]
if user.running:
futures.append(self.add_user(user))
# wait after submitting them all
for f in futures:
yield f
@gen.coroutine
def check_routes(self, user_dict, service_dict, routes=None):
"""Check that all users are properly routed on the proxy."""
if not routes:
routes = yield self.get_all_routes()
user_routes = {r['data']['user'] for r in routes.values() if 'user' in r['data']}
futures = []
db = self.db
for orm_user in db.query(User):
user = user_dict[orm_user]
if user.running:
if user.name not in user_routes:
self.log.warning(
"Adding missing route for %s (%s)", user.name, user.server)
futures.append(self.add_user(user))
else:
# User not running, make sure it's not in the table
if user.name in user_routes:
self.log.warning(
"Removing route for not running %s", user.name)
futures.append(self.delete_user(user))
# check service routes
service_routes = {r['data']['service']
for r in routes.values() if 'service' in r['data']}
for orm_service in db.query(Service).filter(
Service.server is not None):
service = service_dict[orm_service.name]
if service.server is None:
# This should never be True, but seems to be on rare occasion.
# catch filter bug, either in sqlalchemy or my understanding of
# its behavior
self.log.error(
"Service %s has no server, but wasn't filtered out.", service)
continue
if service.name not in service_routes:
self.log.warning("Adding missing route for %s (%s)",
service.name, service.server)
futures.append(self.add_service(service))
for f in futures:
yield f
@gen.coroutine
def restore_routes(self):
self.log.info("Setting up routes on new proxy")
yield self.add_all_users(self.app.users)
yield self.add_all_services(self.app.services)
self.log.info("New proxy back up, and good to go")
class ConfigurableHTTPProxy(Proxy):
"""Proxy implementation for the default configurable-http-proxy."""
proxy_process = Any()
client = Instance(AsyncHTTPClient, ())
debug = Bool(False, help="Add debug-level logging to the Proxy", config=True)
auth_token = Unicode(
help="""The Proxy Auth token.
Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default.
""",
).tag(config=True)
check_running_interval = Integer(5, config=True)
@default('auth_token')
def _auth_token_default(self):
token = os.environ.get('CONFIGPROXY_AUTH_TOKEN', None)
if not token:
self.log.warning('\n'.join([
"",
"Generating CONFIGPROXY_AUTH_TOKEN. Restarting the Hub will require restarting the proxy.",
"Set CONFIGPROXY_AUTH_TOKEN env or JupyterHub.proxy_auth_token config to avoid this message.",
"",
]))
token = utils.new_token()
return token
api_url = Unicode('http://127.0.0.1:8001', config=True,
help="""The ip (or hostname) of the proxy's API endpoint"""
)
command = Command('configurable-http-proxy', config=True,
help="""The command to start the proxy"""
)
@gen.coroutine
def start(self):
public_server = Server.from_url(self.public_url)
api_server = Server.from_url(self.api_url)
env = os.environ.copy()
env['CONFIGPROXY_AUTH_TOKEN'] = self.auth_token
cmd = self.command + [
'--ip', public_server.ip,
'--port', str(public_server.port),
'--api-ip', api_server.ip,
'--api-port', str(api_server.port),
'--default-target', self.hub.host,
'--error-target', url_path_join(self.hub.url, 'error'),
]
if self.app.subdomain_host:
cmd.append('--host-routing')
if self.debug:
cmd.extend(['--log-level', 'debug'])
if self.ssl_key:
cmd.extend(['--ssl-key', self.ssl_key])
if self.ssl_cert:
cmd.extend(['--ssl-cert', self.ssl_cert])
if self.app.statsd_host:
cmd.extend([
'--statsd-host', self.app.statsd_host,
'--statsd-port', str(self.app.statsd_port),
'--statsd-prefix', self.app.statsd_prefix + '.chp'
])
# Warn if SSL is not used
if ' --ssl' not in ' '.join(cmd):
self.log.warning("Running JupyterHub without SSL."
" I hope there is SSL termination happening somewhere else...")
self.log.info("Starting proxy @ %s", public_server.bind_url)
self.log.debug("Proxy cmd: %s", cmd)
try:
self.proxy_process = Popen(cmd, env=env, start_new_session=True)
except FileNotFoundError as e:
self.log.error(
"Failed to find proxy %r\n"
"The proxy can be installed with `npm install -g configurable-http-proxy`"
% self.cmd
)
self.exit(1)
def _check_process():
status = self.proxy_process.poll()
if status is not None:
e = RuntimeError(
"Proxy failed to start with exit code %i" % status)
# py2-compatible `raise e from None`
e.__cause__ = None
raise e
for server in (public_server, api_server):
for i in range(10):
_check_process()
try:
yield server.wait_up(1)
except TimeoutError:
continue
else:
break
yield server.wait_up(1)
time.sleep(1)
_check_process()
self.log.debug("Proxy started and appears to be up")
pc = PeriodicCallback(self.check_running, 1e3 * self.check_running_interval)
pc.start()
def stop(self):
self.log.info("Cleaning up proxy[%i]...", self.proxy_process.pid)
if self.proxy_process.poll() is None:
try:
self.proxy_process.terminate()
except Exception as e:
self.log.error("Failed to terminate proxy process: %s", e)
@gen.coroutine
def check_running(self):
"""Check if the proxy is still running"""
if self.proxy_process.poll() is None:
return
self.log.error("Proxy stopped with exit code %r",
'unknown' if self.proxy_process is None else self.proxy_process.poll()
)
yield self.start()
yield self.restore_routes()
def api_request(self, path, method='GET', body=None, client=None):
"""Make an authenticated API request of the proxy."""
client = client or AsyncHTTPClient()
url = url_path_join(self.api_url, 'api/routes', path)
if isinstance(body, dict):
body = json.dumps(body)
self.log.debug("Proxy: Fetching %s %s", method, url)
req = HTTPRequest(url,
method=method,
headers={'Authorization': 'token {}'.format(
self.auth_token)},
body=body,
)
return client.fetch(req)
def add_route(self, routespec, target, data=None):
body = data or {}
body['target'] = target
return self.api_request(routespec,
method='POST',
body=body,
)
def delete_route(self, routespec):
return self.api_request(routespec, method='DELETE')
def _reformat_routespec(self, routespec, chp_data):
"""Reformat CHP data format to JupyterHub's proxy API."""
target = chp_data.pop('target')
return {
'routespec': routespec,
'target': target,
'data': chp_data,
}
@gen.coroutine
def get_route(self, routespec):
chp_data = yield self.api_request(routespec, method='DELETE')
return self._reformat_routespec(routespec, chp_data)
@gen.coroutine
def get_all_routes(self, client=None):
"""Fetch the proxy's routes."""
resp = yield self.api_request('', client=client)
chp_routes = json.loads(resp.body.decode('utf8', 'replace'))
all_routes = {}
for routespec, chp_data in chp_routes.items():
all_routes[routespec] = self._reformat_routespec(
routespec, chp_data)
return all_routes

View File

@@ -7,7 +7,6 @@ HubAuth can be used in any application, even outside tornado.
HubAuthenticated is a mixin class for tornado handlers that should authenticate with the Hub. HubAuthenticated is a mixin class for tornado handlers that should authenticate with the Hub.
""" """
import json
import os import os
import re import re
import socket import socket
@@ -492,7 +491,19 @@ class HubOAuth(HubAuth):
def clear_cookie(self, handler): def clear_cookie(self, handler):
"""Clear the OAuth cookie""" """Clear the OAuth cookie"""
handler.clear_cookie(self.cookie_name, path=self.base_url) handler.clear_cookie(self.cookie_name, path=self.base_url)
class UserNotAllowed(Exception):
"""Exception raised when a user is identified and not allowed"""
def __init__(self, model):
self.model = model
def __str__(self):
return '<{cls} {kind}={name}>'.format(
cls=self.__class__.__name__,
kind=self.model['kind'],
name=self.model['name'],
)
class HubAuthenticated(object): class HubAuthenticated(object):
@@ -568,7 +579,7 @@ class HubAuthenticated(object):
""" """
name = model['name'] name = model['name']
kind = model.get('kind', 'user') kind = model.setdefault('kind', 'user')
if self.allow_all: if self.allow_all:
app_log.debug("Allowing Hub %s %s (all Hub users and services allowed)", kind, name) app_log.debug("Allowing Hub %s %s (all Hub users and services allowed)", kind, name)
return model return model
@@ -584,7 +595,7 @@ class HubAuthenticated(object):
return model return model
else: else:
app_log.warning("Not allowing Hub service %s", name) app_log.warning("Not allowing Hub service %s", name)
return None raise UserNotAllowed(model)
if self.hub_users and name in self.hub_users: if self.hub_users and name in self.hub_users:
# user in whitelist # user in whitelist
@@ -597,7 +608,7 @@ class HubAuthenticated(object):
return model return model
else: else:
app_log.warning("Not allowing Hub user %s", name) app_log.warning("Not allowing Hub user %s", name)
return None raise UserNotAllowed(model)
def get_current_user(self): def get_current_user(self):
"""Tornado's authentication method """Tornado's authentication method
@@ -611,7 +622,15 @@ class HubAuthenticated(object):
if not user_model: if not user_model:
self._hub_auth_user_cache = None self._hub_auth_user_cache = None
return return
self._hub_auth_user_cache = self.check_hub_user(user_model) try:
self._hub_auth_user_cache = self.check_hub_user(user_model)
except UserNotAllowed as e:
# cache None, in case get_user is called again while processing the error
self._hub_auth_user_cache = None
raise HTTPError(403, "{kind} {name} is not allowed.".format(**e.model))
except Exception:
self._hub_auth_user_cache = None
raise
return self._hub_auth_user_cache return self._hub_auth_user_cache
@@ -638,6 +657,8 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
# TODO: make async (in a Thread?) # TODO: make async (in a Thread?)
token = self.hub_auth.token_for_code(code) token = self.hub_auth.token_for_code(code)
user_model = self.hub_auth.user_for_token(token) user_model = self.hub_auth.user_for_token(token)
if user_model is None:
raise HTTPError(500, "oauth callback failed to identify a user")
app_log.info("Logged-in user %s", user_model) app_log.info("Logged-in user %s", user_model)
self.hub_auth.set_cookie(self, token) self.hub_auth.set_cookie(self, token)
next_url = self.get_argument('next', '') or self.hub_auth.base_url next_url = self.get_argument('next', '') or self.hub_auth.base_url

View File

@@ -39,7 +39,6 @@ A hub-managed service with no URL:
} }
""" """
from getpass import getuser
import pipes import pipes
import shutil import shutil
from subprocess import Popen from subprocess import Popen
@@ -52,6 +51,7 @@ from traitlets import (
from traitlets.config import LoggingConfigurable from traitlets.config import LoggingConfigurable
from .. import orm from .. import orm
from ..objects import Server
from ..traitlets import Command from ..traitlets import Command
from ..spawner import LocalProcessSpawner, set_user_setuid from ..spawner import LocalProcessSpawner, set_user_setuid
from ..utils import url_path_join from ..utils import url_path_join
@@ -60,7 +60,7 @@ class _MockUser(HasTraits):
name = Unicode() name = Unicode()
server = Instance(orm.Server, allow_none=True) server = Instance(orm.Server, allow_none=True)
state = Dict() state = Dict()
service = Instance(__module__ + '.Service') service = Instance(__name__ + '.Service')
host = Unicode() host = Unicode()
@property @property
@@ -71,6 +71,12 @@ class _MockUser(HasTraits):
return self.host + self.server.base_url return self.host + self.server.base_url
else: else:
return self.server.base_url return self.server.base_url
@property
def base_url(self):
if not self.server:
return ''
return self.server.base_url
# We probably shouldn't use a Spawner here, # We probably shouldn't use a Spawner here,
# but there are too many concepts to share. # but there are too many concepts to share.
@@ -84,11 +90,17 @@ class _ServiceSpawner(LocalProcessSpawner):
cmd = Command(minlen=0) cmd = Command(minlen=0)
def make_preexec_fn(self, name): def make_preexec_fn(self, name):
if not name or name == getuser(): if not name:
# no setuid if no name # no setuid if no name
return return
return set_user_setuid(name, chdir=False) return set_user_setuid(name, chdir=False)
def user_env(self, env):
if not self.user.name:
return env
else:
return super().user_env(env)
def start(self): def start(self):
"""Start the process""" """Start the process"""
env = self.get_env() env = self.get_env()
@@ -167,7 +179,7 @@ class Service(LoggingConfigurable):
def managed(self): def managed(self):
"""Am I managed by the Hub?""" """Am I managed by the Hub?"""
return bool(self.command) return bool(self.command)
@property @property
def kind(self): def kind(self):
"""The name of the kind of service as a string """The name of the kind of service as a string
@@ -188,7 +200,7 @@ class Service(LoggingConfigurable):
Only used if the Hub is spawning the service. Only used if the Hub is spawning the service.
""" """
).tag(input=True) ).tag(input=True)
user = Unicode(getuser(), user = Unicode("",
help="""The user to become when launching the service. help="""The user to become when launching the service.
If unspecified, run the service as the same user as the Hub. If unspecified, run the service as the same user as the Hub.
@@ -221,7 +233,10 @@ class Service(LoggingConfigurable):
@property @property
def server(self): def server(self):
return self.orm.server if self.orm.server:
return Server(orm_server=self.orm.server)
else:
return None
@property @property
def prefix(self): def prefix(self):
@@ -252,9 +267,6 @@ class Service(LoggingConfigurable):
env.update(self.environment) env.update(self.environment)
env['JUPYTERHUB_SERVICE_NAME'] = self.name env['JUPYTERHUB_SERVICE_NAME'] = self.name
env['JUPYTERHUB_API_TOKEN'] = self.api_token
env['JUPYTERHUB_API_URL'] = self.hub_api_url
env['JUPYTERHUB_BASE_URL'] = self.base_url
if self.url: if self.url:
env['JUPYTERHUB_SERVICE_URL'] = self.url env['JUPYTERHUB_SERVICE_URL'] = self.url
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url

View File

@@ -5,12 +5,13 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import os import os
from textwrap import dedent
from urllib.parse import urlparse from urllib.parse import urlparse
from jinja2 import ChoiceLoader, FunctionLoader from jinja2 import ChoiceLoader, FunctionLoader
from tornado import ioloop from tornado import ioloop
from textwrap import dedent from tornado.web import HTTPError
try: try:
import notebook import notebook
@@ -37,6 +38,7 @@ from notebook.auth.logout import LogoutHandler
from notebook.base.handlers import IPythonHandler from notebook.base.handlers import IPythonHandler
from jupyterhub import __version__ from jupyterhub import __version__
from .log import log_request
from .services.auth import HubOAuth, HubOAuthenticated, HubOAuthCallbackHandler from .services.auth import HubOAuth, HubOAuthenticated, HubOAuthCallbackHandler
from .utils import url_path_join from .utils import url_path_join
@@ -119,6 +121,8 @@ class OAuthCallbackHandler(HubOAuthCallbackHandler, IPythonHandler):
# TODO: make async (in a Thread?) # TODO: make async (in a Thread?)
token = self.hub_auth.token_for_code(code) token = self.hub_auth.token_for_code(code)
user_model = self.hub_auth.user_for_token(token) user_model = self.hub_auth.user_for_token(token)
if user_model is None:
raise HTTPError(500, "oauth callback failed to identify a user")
self.log.info("Logged-in user %s", user_model) self.log.info("Logged-in user %s", user_model)
self.hub_auth.set_cookie(self, token) self.hub_auth.set_cookie(self, token)
next_url = self.get_argument('next', '') or self.base_url next_url = self.get_argument('next', '') or self.base_url
@@ -189,6 +193,14 @@ class SingleUserNotebookApp(NotebookApp):
user = CUnicode().tag(config=True) user = CUnicode().tag(config=True)
group = CUnicode().tag(config=True) group = CUnicode().tag(config=True)
@default('user')
def _default_user(self):
return os.environ.get('JUPYTERHUB_USER') or ''
@default('group')
def _default_group(self):
return os.environ.get('JUPYTERHUB_GROUP') or ''
@observe('user') @observe('user')
def _user_changed(self, change): def _user_changed(self, change):
@@ -225,23 +237,25 @@ class SingleUserNotebookApp(NotebookApp):
value = value + '/' value = value + '/'
return value return value
@default('cookie_name')
def _cookie_name_default(self):
if os.environ.get('JUPYTERHUB_SERVICE_NAME'):
# if I'm a service, use the services cookie name
return 'jupyterhub-services'
@default('port') @default('port')
def _port_default(self): def _port_default(self):
if os.environ.get('JUPYTERHUB_SERVICE_URL'): if os.environ.get('JUPYTERHUB_SERVICE_URL'):
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL']) url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
return url.port if url.port:
return url.port
elif url.scheme == 'http':
return 80
elif url.scheme == 'https':
return 443
return 8888
@default('ip') @default('ip')
def _ip_default(self): def _ip_default(self):
if os.environ.get('JUPYTERHUB_SERVICE_URL'): if os.environ.get('JUPYTERHUB_SERVICE_URL'):
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL']) url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
return url.hostname if url.hostname:
return url.hostname
return '127.0.0.1'
aliases = aliases aliases = aliases
flags = flags flags = flags
@@ -348,6 +362,7 @@ class SingleUserNotebookApp(NotebookApp):
# load the hub-related settings into the tornado settings dict # load the hub-related settings into the tornado settings dict
self.init_hub_auth() self.init_hub_auth()
s = self.tornado_settings s = self.tornado_settings
s['log_function'] = log_request
s['user'] = self.user s['user'] = self.user
s['group'] = self.group s['group'] = self.group
s['hub_prefix'] = self.hub_prefix s['hub_prefix'] = self.hub_prefix

View File

@@ -65,7 +65,7 @@ class Spawner(LoggingConfigurable):
""" """
) )
ip = Unicode('127.0.0.1', ip = Unicode('',
help=""" help="""
The IP address (or hostname) the single-user server should listen on. The IP address (or hostname) the single-user server should listen on.
@@ -431,9 +431,16 @@ class Spawner(LoggingConfigurable):
env['JUPYTERHUB_ADMIN_ACCESS'] = '1' env['JUPYTERHUB_ADMIN_ACCESS'] = '1'
# OAuth settings # OAuth settings
env['JUPYTERHUB_CLIENT_ID'] = self.oauth_client_id env['JUPYTERHUB_CLIENT_ID'] = self.oauth_client_id
env['JUPYTERHUB_HOST'] = self.hub.host env['JUPYTERHUB_HOST'] = self.hub.public_host
env['JUPYTERHUB_OAUTH_CALLBACK_URL'] = \ env['JUPYTERHUB_OAUTH_CALLBACK_URL'] = \
url_path_join(self.user.url, 'oauth_callback') url_path_join(self.user.url, 'oauth_callback')
# Info previously passed on args
env['JUPYTERHUB_USER'] = self.user.name
env['JUPYTERHUB_API_URL'] = self.hub.api_url
env['JUPYTERHUB_BASE_URL'] = self.hub.base_url[:-4]
if self.server:
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
# Put in limit and guarantee info if they exist. # Put in limit and guarantee info if they exist.
# Note that this is for use by the humans / notebook extensions in the # Note that this is for use by the humans / notebook extensions in the
@@ -493,13 +500,6 @@ class Spawner(LoggingConfigurable):
Doesn't expect shell expansion to happen. Doesn't expect shell expansion to happen.
""" """
args = [
'--user="%s"' % self.user.name,
'--base-url="%s"' % self.server.base_url,
'--hub-host="%s"' % self.hub.host,
'--hub-prefix="%s"' % self.hub.server.base_url,
'--hub-api-url="%s"' % self.hub.api_url,
]
if self.ip: if self.ip:
args.append('--ip="%s"' % self.ip) args.append('--ip="%s"' % self.ip)
@@ -539,10 +539,13 @@ class Spawner(LoggingConfigurable):
def stop(self, now=False): def stop(self, now=False):
"""Stop the single-user server """Stop the single-user server
If `now` is set to `False`, do not wait for the server to stop. Otherwise, wait for If `now` is False (default), shutdown the server as gracefully as possible,
the server to stop before returning. e.g. starting with SIGINT, then SIGTERM, then SIGKILL.
If `now` is True, terminate the server immediately.
Must be a Tornado coroutine. The coroutine should return when the single-user server process is no longer running.
Must be a coroutine.
""" """
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.") raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
@@ -616,7 +619,10 @@ class Spawner(LoggingConfigurable):
self.stop_polling() self.stop_polling()
for callback in self._callbacks: # clear callbacks list
self._callbacks, callbacks = ([], self._callbacks)
for callback in callbacks:
try: try:
yield gen.maybe_future(callback()) yield gen.maybe_future(callback())
except Exception: except Exception:
@@ -917,8 +923,11 @@ class LocalProcessSpawner(Spawner):
def stop(self, now=False): def stop(self, now=False):
"""Stop the single-user server process for the current user. """Stop the single-user server process for the current user.
If `now` is set to True, do not wait for the process to die. If `now` is False (default), shutdown the server as gracefully as possible,
Otherwise, it'll wait. e.g. starting with SIGINT, then SIGTERM, then SIGKILL.
If `now` is True, terminate the server immediately.
The coroutine should return when the process is no longer running.
""" """
if not now: if not now:
status = yield self.poll() status = yield self.poll()

View File

@@ -9,7 +9,7 @@ from subprocess import TimeoutExpired
import time import time
from unittest import mock from unittest import mock
from pytest import fixture, raises from pytest import fixture, raises
from tornado import ioloop from tornado import ioloop, gen
from .. import orm from .. import orm
from ..utils import random_port from ..utils import random_port
@@ -32,11 +32,7 @@ def db():
name=getuser(), name=getuser(),
) )
user.servers.append(orm.Server()) user.servers.append(orm.Server())
hub = orm.Hub(
server=orm.Server(),
)
_db.add(user) _db.add(user)
_db.add(hub)
_db.commit() _db.commit()
return _db return _db
@@ -57,6 +53,9 @@ def app(request):
def fin(): def fin():
# disconnect logging during cleanup because pytest closes captured FDs prematurely
mocked_app.log.handlers = []
MockHub.clear_instance() MockHub.clear_instance()
mocked_app.stop() mocked_app.stop()
request.addfinalizer(fin) request.addfinalizer(fin)
@@ -85,10 +84,14 @@ def _mockservice(request, app, url=False):
with mock.patch.object(jupyterhub.services.service, '_ServiceSpawner', MockServiceSpawner): with mock.patch.object(jupyterhub.services.service, '_ServiceSpawner', MockServiceSpawner):
app.services = [spec] app.services = [spec]
app.init_services() app.init_services()
app.io_loop.add_callback(app.proxy.add_all_services, app._service_map)
assert name in app._service_map assert name in app._service_map
service = app._service_map[name] service = app._service_map[name]
app.io_loop.add_callback(service.start) @gen.coroutine
def start():
# wait for proxy to be updated before starting the service
yield app.proxy.add_all_services(app._service_map)
service.start()
app.io_loop.add_callback(start)
def cleanup(): def cleanup():
service.stop() service.stop()
app.services[:] = [] app.services[:] = []
@@ -100,6 +103,8 @@ def _mockservice(request, app, url=False):
# ensure process finishes starting # ensure process finishes starting
with raises(TimeoutExpired): with raises(TimeoutExpired):
service.proc.wait(1) service.proc.wait(1)
if url:
ioloop.IOLoop().run_sync(service.server.wait_up)
return service return service

View File

@@ -4,7 +4,6 @@ import os
import sys import sys
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
import threading import threading
from unittest import mock from unittest import mock
import requests import requests
@@ -18,9 +17,10 @@ from traitlets import default
from ..app import JupyterHub from ..app import JupyterHub
from ..auth import PAMAuthenticator from ..auth import PAMAuthenticator
from .. import orm from .. import orm
from ..objects import Server
from ..spawner import LocalProcessSpawner from ..spawner import LocalProcessSpawner
from ..singleuser import SingleUserNotebookApp from ..singleuser import SingleUserNotebookApp
from ..utils import random_port from ..utils import random_port, url_path_join
from pamela import PAMError from pamela import PAMError
@@ -165,7 +165,7 @@ class MockHub(JupyterHub):
self.db.add(user) self.db.add(user)
self.db.commit() self.db.commit()
yield super(MockHub, self).start() yield super(MockHub, self).start()
yield self.hub.server.wait_up(http=True) yield self.hub.wait_up(http=True)
self.io_loop.add_callback(evt.set) self.io_loop.add_callback(evt.set)
def _start(): def _start():
@@ -207,19 +207,24 @@ def public_host(app):
if app.subdomain_host: if app.subdomain_host:
return app.subdomain_host return app.subdomain_host
else: else:
return app.proxy.public_server.host return Server.from_url(app.proxy.public_url).host
def public_url(app, user_or_service=None): def public_url(app, user_or_service=None, path=''):
"""Return the full, public base URL (including prefix) of the given JupyterHub instance.""" """Return the full, public base URL (including prefix) of the given JupyterHub instance."""
if user_or_service: if user_or_service:
if app.subdomain_host: if app.subdomain_host:
host = user_or_service.host host = user_or_service.host
else: else:
host = public_host(app) host = public_host(app)
return host + user_or_service.server.base_url prefix = user_or_service.server.base_url
else: else:
return public_host(app) + app.proxy.public_server.base_url host = public_host(app)
prefix = Server.from_url(app.proxy.public_url).base_url
if path:
return host + url_path_join(prefix, path)
else:
return host + prefix
# single-user-server mocking: # single-user-server mocking:
@@ -241,7 +246,8 @@ class StubSingleUserSpawner(MockSpawner):
_thread = None _thread = None
@gen.coroutine @gen.coroutine
def start(self): def start(self):
self.user.server.port = random_port() ip = self.ip = '127.0.0.1'
port = self.port = random_port()
env = self.get_env() env = self.get_env()
args = self.get_args() args = self.get_args()
evt = threading.Event() evt = threading.Event()
@@ -262,6 +268,7 @@ class StubSingleUserSpawner(MockSpawner):
self._thread.start() self._thread.start()
ready = evt.wait(timeout=3) ready = evt.wait(timeout=3)
assert ready assert ready
return (ip, port)
@gen.coroutine @gen.coroutine
def stop(self): def stop(self):

View File

@@ -83,7 +83,7 @@ def auth_header(db, name):
@check_db_locks @check_db_locks
def api_request(app, *api_path, **kwargs): def api_request(app, *api_path, **kwargs):
"""Make an API request""" """Make an API request"""
base_url = app.hub.server.url base_url = app.hub.url
headers = kwargs.setdefault('headers', {}) headers = kwargs.setdefault('headers', {})
if 'Authorization' not in headers: if 'Authorization' not in headers:
@@ -94,7 +94,7 @@ def api_request(app, *api_path, **kwargs):
f = getattr(requests, method) f = getattr(requests, method)
resp = f(url, **kwargs) resp = f(url, **kwargs)
assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy'] assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy']
assert ujoin(app.hub.server.base_url, "security/csp-report") in resp.headers['Content-Security-Policy'] assert ujoin(app.hub.base_url, "security/csp-report") in resp.headers['Content-Security-Policy']
assert 'http' not in resp.headers['Content-Security-Policy'] assert 'http' not in resp.headers['Content-Security-Policy']
return resp return resp
@@ -132,7 +132,7 @@ def test_auth_api(app):
def test_referer_check(app, io_loop): def test_referer_check(app, io_loop):
url = ujoin(public_host(app), app.hub.server.base_url) url = ujoin(public_host(app), app.hub.base_url)
host = urlparse(url).netloc host = urlparse(url).netloc
user = find_user(app.db, 'admin') user = find_user(app.db, 'admin')
if user is None: if user is None:
@@ -423,10 +423,13 @@ def test_spawn(app, io_loop):
r = requests.get(ujoin(url, 'args')) r = requests.get(ujoin(url, 'args'))
assert r.status_code == 200 assert r.status_code == 200
argv = r.json() argv = r.json()
for expected in ['--user="%s"' % name, '--base-url="%s"' % user.server.base_url]: assert '--port' in ' '.join(argv)
assert expected in argv r = requests.get(ujoin(url, 'env'))
env = r.json()
for expected in ['JUPYTERHUB_USER', 'JUPYTERHUB_BASE_URL', 'JUPYTERHUB_API_TOKEN']:
assert expected in env
if app.subdomain_host: if app.subdomain_host:
assert '--hub-host="%s"' % app.subdomain_host in argv assert env['JUPYTERHUB_HOST'] == app.subdomain_host
r = api_request(app, 'users', name, 'server', method='delete') r = api_request(app, 'users', name, 'server', method='delete')
assert r.status_code == 204 assert r.status_code == 204
@@ -779,7 +782,7 @@ def test_get_service(app, mockservice_url):
def test_root_api(app): def test_root_api(app):
base_url = app.hub.server.url base_url = app.hub.url
url = ujoin(base_url, 'api') url = ujoin(base_url, 'api')
r = requests.get(url) r = requests.get(url)
r.raise_for_status() r.raise_for_status()

View File

@@ -3,10 +3,13 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import socket
import pytest import pytest
from tornado import gen from tornado import gen
from .. import orm from .. import orm
from .. import objects
from ..user import User from ..user import User
from .mocking import MockSpawner from .mocking import MockSpawner
@@ -20,53 +23,25 @@ def test_server(db):
assert server.proto == 'http' assert server.proto == 'http'
assert isinstance(server.port, int) assert isinstance(server.port, int)
assert isinstance(server.cookie_name, str) assert isinstance(server.cookie_name, str)
assert server.host == 'http://127.0.0.1:%i' % server.port
# test wrapper
server = objects.Server(orm_server=server)
assert server.host == 'http://%s:%i' % (socket.gethostname(), server.port)
assert server.url == server.host + '/' assert server.url == server.host + '/'
assert server.bind_url == 'http://*:%i/' % server.port assert server.bind_url == 'http://*:%i/' % server.port
server.ip = '127.0.0.1' server.ip = '127.0.0.1'
assert server.host == 'http://127.0.0.1:%i' % server.port assert server.host == 'http://127.0.0.1:%i' % server.port
assert server.url == server.host + '/' assert server.url == server.host + '/'
server.connect_ip = 'hub'
def test_proxy(db): assert server.host == 'http://hub:%i' % server.port
proxy = orm.Proxy( assert server.url == server.host + '/'
auth_token='abc-123',
public_server=orm.Server(
ip='192.168.1.1',
port=8000,
),
api_server=orm.Server(
ip='127.0.0.1',
port=8001,
),
)
db.add(proxy)
db.commit()
assert proxy.public_server.ip == '192.168.1.1'
assert proxy.api_server.ip == '127.0.0.1'
assert proxy.auth_token == 'abc-123'
def test_hub(db):
hub = orm.Hub(
server=orm.Server(
ip = '1.2.3.4',
port = 1234,
base_url='/hubtest/',
),
)
db.add(hub)
db.commit()
assert hub.server.ip == '1.2.3.4'
assert hub.server.port == 1234
assert hub.api_url == 'http://1.2.3.4:1234/hubtest/api'
def test_user(db): def test_user(db):
user = orm.User(name='kaylee', user = User(orm.User(name='kaylee',
state={'pid': 4234}, state={'pid': 4234},
) ))
server = orm.Server() server = orm.Server()
user.servers.append(server) user.servers.append(server)
db.add(user) db.add(user)

View File

@@ -3,9 +3,12 @@
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
import requests import requests
from tornado import gen
from ..handlers import BaseHandler
from ..utils import url_path_join as ujoin from ..utils import url_path_join as ujoin
from .. import orm from .. import orm
from ..auth import Authenticator
import mock import mock
from .mocking import FormSpawner, public_url, public_host from .mocking import FormSpawner, public_url, public_host
@@ -13,7 +16,7 @@ from .test_api import api_request
def get_page(path, app, hub=True, **kw): def get_page(path, app, hub=True, **kw):
if hub: if hub:
prefix = app.hub.server.base_url prefix = app.hub.base_url
else: else:
prefix = app.base_url prefix = app.base_url
base_url = ujoin(public_host(app), prefix) base_url = ujoin(public_host(app), prefix)
@@ -21,11 +24,11 @@ def get_page(path, app, hub=True, **kw):
return requests.get(ujoin(base_url, path), **kw) return requests.get(ujoin(base_url, path), **kw)
def test_root_no_auth(app, io_loop): def test_root_no_auth(app, io_loop):
print(app.hub.server.is_up()) print(app.hub.is_up())
routes = io_loop.run_sync(app.proxy.get_routes) routes = io_loop.run_sync(app.proxy.get_all_routes)
print(routes) print(routes)
print(app.hub.server) print(app.hub.server)
url = ujoin(public_host(app), app.hub.server.base_url) url = ujoin(public_host(app), app.hub.base_url)
print(url) print(url)
r = requests.get(url) r = requests.get(url)
r.raise_for_status() r.raise_for_status()
@@ -120,7 +123,7 @@ def test_spawn_page(app):
def test_spawn_form(app, io_loop): def test_spawn_form(app, io_loop):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_host(app), app.hub.server.base_url) base_url = ujoin(public_host(app), app.hub.base_url)
cookies = app.login_user('jones') cookies = app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones') orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u] u = app.users[orm_u]
@@ -142,7 +145,7 @@ def test_spawn_form(app, io_loop):
def test_spawn_form_with_file(app, io_loop): def test_spawn_form_with_file(app, io_loop):
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_host(app), app.hub.server.base_url) base_url = ujoin(public_host(app), app.hub.base_url)
cookies = app.login_user('jones') cookies = app.login_user('jones')
orm_u = orm.User.find(app.db, 'jones') orm_u = orm.User.find(app.db, 'jones')
u = app.users[orm_u] u = app.users[orm_u]
@@ -178,7 +181,7 @@ def test_user_redirect(app):
assert path == ujoin(app.base_url, '/hub/login') assert path == ujoin(app.base_url, '/hub/login')
query = urlparse(r.url).query query = urlparse(r.url).query
assert query == urlencode({ assert query == urlencode({
'next': ujoin(app.hub.server.base_url, '/user-redirect/tree/top/') 'next': ujoin(app.hub.base_url, '/user-redirect/tree/top/')
}) })
r = get_page('/user-redirect/notebooks/test.ipynb', app, cookies=cookies) r = get_page('/user-redirect/notebooks/test.ipynb', app, cookies=cookies)
@@ -229,6 +232,27 @@ def test_login_fail(app):
assert not r.cookies assert not r.cookies
def test_login_strip(app):
"""Test that login form doesn't strip whitespace from passwords"""
form_data = {
'username': 'spiff',
'password': ' space man ',
}
base_url = public_url(app)
called_with = []
@gen.coroutine
def mock_authenticate(handler, data):
called_with.append(data)
with mock.patch.object(app.authenticator, 'authenticate', mock_authenticate):
r = requests.post(base_url + 'hub/login',
data=form_data,
allow_redirects=False,
)
assert called_with == [form_data]
def test_login_redirect(app, io_loop): def test_login_redirect(app, io_loop):
cookies = app.login_user('river') cookies = app.login_user('river')
user = app.users['river'] user = app.users['river']
@@ -253,6 +277,28 @@ def test_login_redirect(app, io_loop):
assert r.headers['Location'].endswith('/hub/admin') assert r.headers['Location'].endswith('/hub/admin')
def test_auto_login(app, io_loop, request):
class DummyLoginHandler(BaseHandler):
def get(self):
self.write('ok!')
base_url = public_url(app) + '/'
app.tornado_application.add_handlers(".*$", [
(ujoin(app.hub.server.base_url, 'dummy'), DummyLoginHandler),
])
# no auto_login: end up at /hub/login
r = requests.get(base_url)
assert r.url == public_url(app, path='hub/login')
# enable auto_login: redirect from /hub/login to /hub/dummy
authenticator = Authenticator(auto_login=True)
authenticator.login_url = lambda base_url: ujoin(base_url, 'dummy')
with mock.patch.dict(app.tornado_application.settings, {
'authenticator': authenticator,
}):
r = requests.get(base_url)
assert r.url == public_url(app, path='hub/dummy')
def test_logout(app): def test_logout(app):
name = 'wash' name = 'wash'
cookies = app.login_user(name) cookies = app.login_user(name)
@@ -274,7 +320,7 @@ def test_login_no_whitelist_adds_user(app):
def test_static_files(app): def test_static_files(app):
base_url = ujoin(public_host(app), app.hub.server.base_url) base_url = ujoin(public_host(app), app.hub.base_url)
r = requests.get(ujoin(base_url, 'logo')) r = requests.get(ujoin(base_url, 'logo'))
r.raise_for_status() r.raise_for_status()
assert r.headers['content-type'] == 'image/png' assert r.headers['content-type'] == 'image/png'

View File

@@ -6,6 +6,8 @@ from queue import Queue
from subprocess import Popen from subprocess import Popen
from urllib.parse import urlparse, unquote from urllib.parse import urlparse, unquote
from traitlets.config import Config
import pytest import pytest
from .. import orm from .. import orm
@@ -19,12 +21,12 @@ def test_external_proxy(request, io_loop):
auth_token = 'secret!' auth_token = 'secret!'
proxy_ip = '127.0.0.1' proxy_ip = '127.0.0.1'
proxy_port = 54321 proxy_port = 54321
cfg = Config()
cfg.ConfigurableHTTPProxy.auth_token = auth_token
cfg.ConfigurableHTTPProxy.api_url = 'http://%s:%i' % (proxy_ip, proxy_port)
cfg.ConfigurableHTTPProxy.should_start = False
app = MockHub.instance( app = MockHub.instance(config=cfg)
proxy_api_ip=proxy_ip,
proxy_api_port=proxy_port,
proxy_auth_token=auth_token,
)
def fin(): def fin():
MockHub.clear_instance() MockHub.clear_instance()
@@ -35,7 +37,8 @@ def test_external_proxy(request, io_loop):
# configures and starts proxy process # configures and starts proxy process
env = os.environ.copy() env = os.environ.copy()
env['CONFIGPROXY_AUTH_TOKEN'] = auth_token env['CONFIGPROXY_AUTH_TOKEN'] = auth_token
cmd = app.proxy_cmd + [ cmd = [
'configurable-http-proxy',
'--ip', app.ip, '--ip', app.ip,
'--port', str(app.port), '--port', str(app.port),
'--api-ip', proxy_ip, '--api-ip', proxy_ip,
@@ -57,10 +60,10 @@ def test_external_proxy(request, io_loop):
wait_for_proxy() wait_for_proxy()
app.start([]) app.start([])
assert app.proxy_process is None assert app.proxy.proxy_process is None
# test if api service has a root route '/' # test if api service has a root route '/'
routes = io_loop.run_sync(app.proxy.get_routes) routes = io_loop.run_sync(app.proxy.get_all_routes)
assert list(routes.keys()) == ['/'] assert list(routes.keys()) == ['/']
# add user to the db and start a single user server # add user to the db and start a single user server
@@ -70,7 +73,7 @@ def test_external_proxy(request, io_loop):
r = api_request(app, 'users', name, 'server', method='post') r = api_request(app, 'users', name, 'server', method='post')
r.raise_for_status() r.raise_for_status()
routes = io_loop.run_sync(app.proxy.get_routes) routes = io_loop.run_sync(app.proxy.get_all_routes)
# sets the desired path result # sets the desired path result
user_path = unquote(ujoin(app.base_url, 'user/river')) user_path = unquote(ujoin(app.base_url, 'user/river'))
if app.subdomain_host: if app.subdomain_host:
@@ -83,7 +86,8 @@ def test_external_proxy(request, io_loop):
proxy = Popen(cmd, env=env) proxy = Popen(cmd, env=env)
wait_for_proxy() wait_for_proxy()
routes = io_loop.run_sync(app.proxy.get_routes) routes = io_loop.run_sync(app.proxy.get_all_routes)
assert list(routes.keys()) == ['/'] assert list(routes.keys()) == ['/']
# poke the server to update the proxy # poke the server to update the proxy
@@ -91,7 +95,7 @@ def test_external_proxy(request, io_loop):
r.raise_for_status() r.raise_for_status()
# check that the routes are correct # check that the routes are correct
routes = io_loop.run_sync(app.proxy.get_routes) routes = io_loop.run_sync(app.proxy.get_all_routes)
assert sorted(routes.keys()) == ['/', user_path] assert sorted(routes.keys()) == ['/', user_path]
# teardown the proxy, and start a new one with different auth and port # teardown the proxy, and start a new one with different auth and port
@@ -99,10 +103,10 @@ def test_external_proxy(request, io_loop):
new_auth_token = 'different!' new_auth_token = 'different!'
env['CONFIGPROXY_AUTH_TOKEN'] = new_auth_token env['CONFIGPROXY_AUTH_TOKEN'] = new_auth_token
proxy_port = 55432 proxy_port = 55432
cmd = app.proxy_cmd + [ cmd = ['configurable-http-proxy',
'--ip', app.ip, '--ip', app.ip,
'--port', str(app.port), '--port', str(app.port),
'--api-ip', app.proxy_api_ip, '--api-ip', proxy_ip,
'--api-port', str(proxy_port), '--api-port', str(proxy_port),
'--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port), '--default-target', 'http://%s:%i' % (app.hub_ip, app.hub_port),
] ]
@@ -112,14 +116,13 @@ def test_external_proxy(request, io_loop):
wait_for_proxy() wait_for_proxy()
# tell the hub where the new proxy is # tell the hub where the new proxy is
new_api_url = 'http://{}:{}'.format(proxy_ip, proxy_port)
r = api_request(app, 'proxy', method='patch', data=json.dumps({ r = api_request(app, 'proxy', method='patch', data=json.dumps({
'port': proxy_port, 'api_url': new_api_url,
'protocol': 'http',
'ip': app.ip,
'auth_token': new_auth_token, 'auth_token': new_auth_token,
})) }))
r.raise_for_status() r.raise_for_status()
assert app.proxy.api_server.port == proxy_port assert app.proxy.api_url == new_api_url
# get updated auth token from main thread # get updated auth token from main thread
def get_app_proxy_token(): def get_app_proxy_token():
@@ -131,7 +134,7 @@ def test_external_proxy(request, io_loop):
app.proxy.auth_token = new_auth_token app.proxy.auth_token = new_auth_token
# check that the routes are correct # check that the routes are correct
routes = io_loop.run_sync(app.proxy.get_routes) routes = io_loop.run_sync(app.proxy.get_all_routes)
assert sorted(routes.keys()) == ['/', user_path] assert sorted(routes.keys()) == ['/', user_path]
@@ -152,18 +155,18 @@ def test_check_routes(app, io_loop, username, endpoints):
# check a valid route exists for user # check a valid route exists for user
test_user = app.users[username] test_user = app.users[username]
before = sorted(io_loop.run_sync(app.proxy.get_routes)) before = sorted(io_loop.run_sync(app.proxy.get_all_routes))
assert unquote(test_user.proxy_path) in before assert unquote(test_user.proxy_path) in before
# check if a route is removed when user deleted # check if a route is removed when user deleted
io_loop.run_sync(lambda: app.proxy.check_routes(app.users, app._service_map)) io_loop.run_sync(lambda: app.proxy.check_routes(app.users, app._service_map))
io_loop.run_sync(lambda: proxy.delete_user(test_user)) io_loop.run_sync(lambda: proxy.delete_user(test_user))
during = sorted(io_loop.run_sync(app.proxy.get_routes)) during = sorted(io_loop.run_sync(app.proxy.get_all_routes))
assert unquote(test_user.proxy_path) not in during assert unquote(test_user.proxy_path) not in during
# check if a route exists for user # check if a route exists for user
io_loop.run_sync(lambda: app.proxy.check_routes(app.users, app._service_map)) io_loop.run_sync(lambda: app.proxy.check_routes(app.users, app._service_map))
after = sorted(io_loop.run_sync(app.proxy.get_routes)) after = sorted(io_loop.run_sync(app.proxy.get_all_routes))
assert unquote(test_user.proxy_path) in after assert unquote(test_user.proxy_path) in after
# check that before and after state are the same # check that before and after state are the same

View File

@@ -25,7 +25,7 @@ def external_service(app, name='mockservice'):
env = { env = {
'JUPYTERHUB_API_TOKEN': hexlify(os.urandom(5)), 'JUPYTERHUB_API_TOKEN': hexlify(os.urandom(5)),
'JUPYTERHUB_SERVICE_NAME': name, 'JUPYTERHUB_SERVICE_NAME': name,
'JUPYTERHUB_API_URL': url_path_join(app.hub.server.url, 'api/'), 'JUPYTERHUB_API_URL': url_path_join(app.hub.url, 'api/'),
'JUPYTERHUB_SERVICE_URL': 'http://127.0.0.1:%i' % random_port(), 'JUPYTERHUB_SERVICE_URL': 'http://127.0.0.1:%i' % random_port(),
} }
proc = Popen(mockservice_cmd, env=env) proc = Popen(mockservice_cmd, env=env)
@@ -64,7 +64,7 @@ def test_managed_service(mockservice):
def test_proxy_service(app, mockservice_url, io_loop): def test_proxy_service(app, mockservice_url, io_loop):
service = mockservice_url service = mockservice_url
name = service.name name = service.name
io_loop.run_sync(app.proxy.get_routes) io_loop.run_sync(app.proxy.get_all_routes)
url = public_url(app, service) + '/foo' url = public_url(app, service) + '/foo'
r = requests.get(url, allow_redirects=False) r = requests.get(url, allow_redirects=False)
path = '/services/{}/foo'.format(name) path = '/services/{}/foo'.format(name)

View File

@@ -195,9 +195,7 @@ def test_hub_authenticated(request):
cookies={'jubal': 'early'}, cookies={'jubal': 'early'},
allow_redirects=False, allow_redirects=False,
) )
r.raise_for_status() assert r.status_code == 403
assert r.status_code == 302
assert auth.login_url in r.headers['Location']
# pass group whitelist # pass group whitelist
TestHandler.hub_groups = {'lions'} TestHandler.hub_groups = {'lions'}
@@ -214,9 +212,7 @@ def test_hub_authenticated(request):
cookies={'jubal': 'early'}, cookies={'jubal': 'early'},
allow_redirects=False, allow_redirects=False,
) )
r.raise_for_status() assert r.status_code == 403
assert r.status_code == 302
assert auth.login_url in r.headers['Location']
def test_hubauth_cookie(app, mockservice_url): def test_hubauth_cookie(app, mockservice_url):

View File

@@ -37,6 +37,12 @@ def test_singleuser_auth(app, io_loop):
r = requests.get(url_path_join(url, 'logout'), cookies=cookies) r = requests.get(url_path_join(url, 'logout'), cookies=cookies)
assert len(r.cookies) == 0 assert len(r.cookies) == 0
# another user accessing should get 403, not redirect to login
cookies = app.login_user('burgess')
r = requests.get(url, cookies=cookies)
assert r.status_code == 403
assert 'burgess' in r.text
def test_disable_user_config(app, io_loop): def test_disable_user_config(app, io_loop):
# use StubSingleUserSpawner to launch a single-user app in a thread # use StubSingleUserSpawner to launch a single-user app in a thread

View File

@@ -17,6 +17,7 @@ import requests
from tornado import gen from tornado import gen
from ..user import User from ..user import User
from ..objects import Hub
from .. import spawner as spawnermod from .. import spawner as spawnermod
from ..spawner import LocalProcessSpawner from ..spawner import LocalProcessSpawner
from .. import orm from .. import orm
@@ -43,8 +44,8 @@ def setup():
def new_spawner(db, **kwargs): def new_spawner(db, **kwargs):
kwargs.setdefault('cmd', [sys.executable, '-c', _echo_sleep]) kwargs.setdefault('cmd', [sys.executable, '-c', _echo_sleep])
kwargs.setdefault('hub', Hub())
kwargs.setdefault('user', User(db.query(orm.User).first(), {})) kwargs.setdefault('user', User(db.query(orm.User).first(), {}))
kwargs.setdefault('hub', db.query(orm.Hub).first())
kwargs.setdefault('notebook_dir', os.getcwd()) kwargs.setdefault('notebook_dir', os.getcwd())
kwargs.setdefault('default_url', '/user/{username}/lab') kwargs.setdefault('default_url', '/user/{username}/lab')
kwargs.setdefault('INTERRUPT_TIMEOUT', 1) kwargs.setdefault('INTERRUPT_TIMEOUT', 1)

View File

@@ -9,9 +9,10 @@ from sqlalchemy import inspect
from tornado import gen from tornado import gen
from tornado.log import app_log from tornado.log import app_log
from .utils import url_path_join, default_server_name, new_token from .utils import url_path_join, default_server_name
from . import orm from . import orm
from .objects import Server
from traitlets import HasTraits, Any, Dict, observe, default from traitlets import HasTraits, Any, Dict, observe, default
from .spawner import LocalProcessSpawner from .spawner import LocalProcessSpawner
@@ -112,24 +113,21 @@ class User(HasTraits):
return self.settings.get('spawner_class', LocalProcessSpawner) return self.settings.get('spawner_class', LocalProcessSpawner)
def __init__(self, orm_user, settings, **kwargs): def __init__(self, orm_user, settings=None, **kwargs):
self.orm_user = orm_user self.orm_user = orm_user
self.settings = settings self.settings = settings or {}
self._instances = {} self._instances = {}
super().__init__(**kwargs) super().__init__(**kwargs)
hub = self.db.query(orm.Hub).first()
self.allow_named_servers = self.settings.get('allow_named_servers', False) self.allow_named_servers = self.settings.get('allow_named_servers', False)
self.cookie_name = '%s-%s' % (hub.server.cookie_name, quote(self.name, safe=''))
self.base_url = url_path_join( self.base_url = url_path_join(
self.settings.get('base_url', '/'), 'user', self.escaped_name) self.settings.get('base_url', '/'), 'user', self.escaped_name)
self.spawner = self.spawner_class( self.spawner = self.spawner_class(
user=self, user=self,
db=self.db, db=self.db,
hub=hub, hub=self.settings.get('hub'),
authenticator=self.authenticator, authenticator=self.authenticator,
config=self.settings.get('config'), config=self.settings.get('config'),
) )
@@ -173,6 +171,13 @@ class User(HasTraits):
if self.server is None: if self.server is None:
return False return False
return True return True
@property
def server(self):
if len(self.servers) == 0:
return None
else:
return Server(orm_server=self.servers[0])
@property @property
def escaped_name(self): def escaped_name(self):
@@ -239,18 +244,17 @@ class User(HasTraits):
server_name = '' server_name = ''
base_url = self.base_url base_url = self.base_url
server = orm.Server( orm_server = orm.Server(
name = server_name, name=server_name,
cookie_name=self.cookie_name,
base_url=base_url, base_url=base_url,
) )
self.servers.append(server) self.servers.append(orm_server)
db.add(self)
db.commit()
api_token = self.new_api_token() api_token = self.new_api_token()
db.commit() db.commit()
server = Server(orm_server=orm_server)
spawner = self.spawner spawner = self.spawner
# Save spawner's instance inside self._instances # Save spawner's instance inside self._instances
@@ -283,7 +287,7 @@ class User(HasTraits):
client_store.add_client(client_id, api_token, client_store.add_client(client_id, api_token,
url_path_join(self.url, 'oauth_callback'), url_path_join(self.url, 'oauth_callback'),
) )
db.commit() db.commit()
# trigger pre-spawn hook on authenticator # trigger pre-spawn hook on authenticator
authenticator = self.authenticator authenticator = self.authenticator
@@ -299,7 +303,7 @@ class User(HasTraits):
ip_port = yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f) ip_port = yield gen.with_timeout(timedelta(seconds=spawner.start_timeout), f)
if ip_port: if ip_port:
# get ip, port info from return value of start() # get ip, port info from return value of start()
self.server.ip, self.server.port = ip_port server.ip, server.port = ip_port
else: else:
# prior to 0.7, spawners had to store this info in user.server themselves. # prior to 0.7, spawners had to store this info in user.server themselves.
# Handle < 0.7 behavior with a warning, assuming info was stored in db by the Spawner. # Handle < 0.7 behavior with a warning, assuming info was stored in db by the Spawner.
@@ -337,14 +341,14 @@ class User(HasTraits):
db.commit() db.commit()
self.waiting_for_response = True self.waiting_for_response = True
try: try:
yield self.server.wait_up(http=True, timeout=spawner.http_timeout) yield server.wait_up(http=True, timeout=spawner.http_timeout)
except Exception as e: except Exception as e:
if isinstance(e, TimeoutError): if isinstance(e, TimeoutError):
self.log.warning( self.log.warning(
"{user}'s server never showed up at {url} " "{user}'s server never showed up at {url} "
"after {http_timeout} seconds. Giving up".format( "after {http_timeout} seconds. Giving up".format(
user=self.name, user=self.name,
url=self.server.url, url=server.url,
http_timeout=spawner.http_timeout, http_timeout=spawner.http_timeout,
) )
) )
@@ -352,7 +356,7 @@ class User(HasTraits):
else: else:
e.reason = 'error' e.reason = 'error'
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format( self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
user=self.name, url=self.server.url, error=e, user=self.name, url=server.url, error=e,
)) ))
try: try:
yield self.stop() yield self.stop()

View File

@@ -37,8 +37,10 @@ def can_connect(ip, port):
Return True if we can connect, False otherwise. Return True if we can connect, False otherwise.
""" """
if ip in {'', '0.0.0.0'}:
ip = '127.0.0.1'
try: try:
socket.create_connection((ip, port)) socket.create_connection((ip, port)).close()
except socket.error as e: except socket.error as e:
if e.errno not in {errno.ECONNREFUSED, errno.ETIMEDOUT}: if e.errno not in {errno.ECONNREFUSED, errno.ETIMEDOUT}:
app_log.error("Unexpected error connecting to %s:%i %s", ip, port, e) app_log.error("Unexpected error connecting to %s:%i %s", ip, port, e)
@@ -50,6 +52,8 @@ def can_connect(ip, port):
@gen.coroutine @gen.coroutine
def wait_for_server(ip, port, timeout=10): def wait_for_server(ip, port, timeout=10):
"""Wait for any server to show up at ip:port.""" """Wait for any server to show up at ip:port."""
if ip in {'', '0.0.0.0'}:
ip = '127.0.0.1'
loop = ioloop.IOLoop.current() loop = ioloop.IOLoop.current()
tic = loop.time() tic = loop.time()
while loop.time() - tic < timeout: while loop.time() - tic < timeout:

View File

@@ -11,7 +11,7 @@
{{ custom_html }} {{ custom_html }}
{% elif login_service %} {% elif login_service %}
<div class="service-login"> <div class="service-login">
<a class='btn btn-jupyter btn-lg' href='{{login_url}}'> <a class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
Sign in with {{login_service}} Sign in with {{login_service}}
</a> </a>
</div> </div>

View File

@@ -0,0 +1,9 @@
{% extends "page.html" %}
{% block main %}
<div id="logout-main" class="container">
<p>
Successfully logged out.
</p>
</div>
{% endblock %}