diff --git a/docs/environment.yml b/docs/environment.yml index 35b5c405..0b613bad 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -2,15 +2,18 @@ # if you change the dependencies of JupyterHub in the various `requirements.txt` name: jhub_docs channels: - - conda-forge + # use metachannel for faster solves / less memory on RTD + # which has memory issues with full conda-forge + # only need to list 'leaf' dependencies here + - https://metachannel.conda-forge.org/conda-forge/jupyterhub,sphinx,recommonmark dependencies: - pip -- nodejs -- python=3.6 +- nodejs=12 +- python=3.7 - alembic - jinja2 - pamela -- recommonmark==0.6.0 +- recommonmark>=0.6 - requests - sqlalchemy>=1 - tornado>=5.0 diff --git a/docs/requirements.txt b/docs/requirements.txt index 01d85db1..4934ec4d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,7 +4,7 @@ alabaster_jupyterhub autodoc-traits git+https://github.com/pandas-dev/pandas-sphinx-theme.git@master -recommonmark==0.5.0 +recommonmark>=0.6 sphinx-copybutton sphinx-jsonschema sphinx>=1.7 diff --git a/docs/source/contributing/tests.rst b/docs/source/contributing/tests.rst index 46360eb7..c607df92 100644 --- a/docs/source/contributing/tests.rst +++ b/docs/source/contributing/tests.rst @@ -64,5 +64,5 @@ Troubleshooting Test Failures All the tests are failing ------------------------- -Make sure you have completed all the steps in :ref:`contributing/setup` sucessfully, and +Make sure you have completed all the steps in :ref:`contributing/setup` successfully, and can launch ``jupyterhub`` from the terminal. diff --git a/docs/source/getting-started/faq.md b/docs/source/getting-started/faq.md new file mode 100644 index 00000000..ae912847 --- /dev/null +++ b/docs/source/getting-started/faq.md @@ -0,0 +1,36 @@ +# Frequently asked questions + + +### How do I share links to notebooks? + +In short, where you see `/user/name/notebooks/foo.ipynb` use `/hub/user-redirect/notebooks/foo.ipynb` (replace `/user/name` with `/hub/user-redirect`). + +Sharing links to notebooks is a common activity, +and can look different based on what you mean. +Your first instinct might be to copy the URL you see in the browser, +e.g. `hub.jupyter.org/user/yourname/notebooks/coolthing.ipynb`. +However, let's break down what this URL means: + +`hub.jupyter.org/user/yourname/` is the URL prefix handled by *your server*, +which means that sharing this URL is asking the person you share the link with +to come to *your server* and look at the exact same file. +In most circumstances, this is forbidden by permissions because the person you share with does not have access to your server. +What actually happens when someone visits this URL will depend on whether your server is running and other factors. + +But what is our actual goal? +A typical situation is that you have some shared or common filesystem, +such that the same path corresponds to the same document +(either the exact same document or another copy of it). +Typically, what folks want when they do sharing like this +is for each visitor to open the same file *on their own server*, +so Breq would open `/user/breq/notebooks/foo.ipynb` and +Seivarden would open `/user/seivarden/notebooks/foo.ipynb`, etc. + +JupyterHub has a special URL that does exactly this! +It's called `/hub/user-redirect/...` and after the visitor logs in, +So if you replace `/user/yourname` in your URL bar +with `/hub/user-redirect` any visitor should get the same +URL on their own server, rather than visiting yours. + +In JupyterLab 2.0, this should also be the result of the "Copy Shareable Link" +action in the file browser. diff --git a/docs/source/getting-started/index.rst b/docs/source/getting-started/index.rst index 00bcdfbc..bae95f8f 100644 --- a/docs/source/getting-started/index.rst +++ b/docs/source/getting-started/index.rst @@ -15,4 +15,5 @@ own JupyterHub. authenticators-users-basics spawners-basics services-basics + faq institutional-faq diff --git a/docs/source/getting-started/security-basics.rst b/docs/source/getting-started/security-basics.rst index 80996555..87007311 100644 --- a/docs/source/getting-started/security-basics.rst +++ b/docs/source/getting-started/security-basics.rst @@ -80,6 +80,49 @@ To achieve this, simply omit the configuration settings ``c.JupyterHub.ssl_key`` and ``c.JupyterHub.ssl_cert`` (setting them to ``None`` does not have the same effect, and is an error). +.. _authentication-token: + +Proxy authentication token +-------------------------- + +The Hub authenticates its requests to the Proxy using a secret token that +the Hub and Proxy agree upon. Note that this applies to the default +``ConfigurableHTTPProxy`` implementation. Not all proxy implementations +use an auth token. + +The value of this token should be a random string (for example, generated by +``openssl rand -hex 32``). You can store it in the configuration file or an +environment variable + +Generating and storing token in the configuration file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can set the value in the configuration file, ``jupyterhub_config.py``: + +.. code-block:: python + + c.ConfigurableHTTPProxy.api_token = 'abc123...' # any random string + +Generating and storing as an environment variable +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can pass this value of the proxy authentication token to the Hub and Proxy +using the ``CONFIGPROXY_AUTH_TOKEN`` environment variable: + +.. code-block:: bash + + export CONFIGPROXY_AUTH_TOKEN=$(openssl rand -hex 32) + +This environment variable needs to be visible to the Hub and Proxy. + +Default if token is not set +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you don't set the Proxy authentication token, the Hub will generate a random +key itself, which means that any time you restart the Hub you **must also +restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen +automatically (this is the default configuration). + .. _cookie-secret: Cookie secret @@ -146,41 +189,73 @@ itself, ``jupyterhub_config.py``, as a binary string: If the cookie secret value changes for the Hub, all single-user notebook servers must also be restarted. +.. _cookies: -.. _authentication-token: +Cookies used by JupyterHub authentication +----------------------------------------- -Proxy authentication token --------------------------- +The following cookies are used by the Hub for handling user authentication. -The Hub authenticates its requests to the Proxy using a secret token that -the Hub and Proxy agree upon. The value of this string should be a random -string (for example, generated by ``openssl rand -hex 32``). +This section was created based on this post_ from Discourse. -Generating and storing token in the configuration file -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. _post: https://discourse.jupyter.org/t/how-to-force-re-login-for-users/1998/6 -Or you can set the value in the configuration file, ``jupyterhub_config.py``: +jupyterhub-hub-login +~~~~~~~~~~~~~~~~~~~~ -.. code-block:: python +This is the login token used when visiting Hub-served pages that are +protected by authentication such as the main home, the spawn form, etc. +If this cookie is set, then the user is logged in. - c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5' +Resetting the Hub cookie secret effectively revokes this cookie. -Generating and storing as an environment variable -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This cookie is restricted to the path ``/hub/``. -You can pass this value of the proxy authentication token to the Hub and Proxy -using the ``CONFIGPROXY_AUTH_TOKEN`` environment variable: +jupyterhub-user- +~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. code-block:: bash +This is the cookie used for authenticating with a single-user server. +It is set by the single-user server after OAuth with the Hub. - export CONFIGPROXY_AUTH_TOKEN=$(openssl rand -hex 32) +Effectively the same as ``jupyterhub-hub-login``, but for the +single-user server instead of the Hub. It contains an OAuth access token, +which is checked with the Hub to authenticate the browser. -This environment variable needs to be visible to the Hub and Proxy. +Each OAuth access token is associated with a session id (see ``jupyterhub-session-id`` section +below). -Default if token is not set -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +To avoid hitting the Hub on every request, the authentication response +is cached. And to avoid a stale cache the cache key is comprised of both +the token and session id. -If you don't set the Proxy authentication token, the Hub will generate a random -key itself, which means that any time you restart the Hub you **must also -restart the Proxy**. If the proxy is a subprocess of the Hub, this should happen -automatically (this is the default configuration). +Resetting the Hub cookie secret effectively revokes this cookie. + +This cookie is restricted to the path ``/user/``, so that +only the user’s server receives it. + +jupyterhub-session-id +~~~~~~~~~~~~~~~~~~~~~ + +This is a random string, meaningless in itself, and the only cookie +shared by the Hub and single-user servers. + +Its sole purpose is to coordinate logout of the multiple OAuth cookies. + +This cookie is set to ``/`` so all endpoints can receive it, or clear it, etc. + +jupyterhub-user--oauth-state +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A short-lived cookie, used solely to store and validate OAuth state. +It is only set while OAuth between the single-user server and the Hub +is processing. + +If you use your browser development tools, you should see this cookie +for a very brief moment before your are logged in, +with an expiration date shorter than ``jupyterhub-hub-login`` or +``jupyterhub-user-``. + +This cookie should not exist after you have successfully logged in. + +This cookie is restricted to the path ``/user/``, so that only +the user’s server receives it. diff --git a/docs/source/index.rst b/docs/source/index.rst index 1f819278..c9722089 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,7 +3,7 @@ JupyterHub ========== `JupyterHub`_ is the best way to serve `Jupyter notebook`_ for multiple users. -It can be used in a classes of students, a corporate data science group or scientific +It can be used in a class of students, a corporate data science group or scientific research group. It is a multi-user **Hub** that spawns, manages, and proxies multiple instances of the single-user `Jupyter notebook`_ server. diff --git a/docs/source/reference/services.md b/docs/source/reference/services.md index 7686d4a4..ec446c8e 100644 --- a/docs/source/reference/services.md +++ b/docs/source/reference/services.md @@ -360,7 +360,7 @@ and taking note of the following process: An example of using an Externally-Managed Service and authentication is in [nbviewer README][nbviewer example] section on securing the notebook viewer, -and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/master/nbviewer/providers/base.py#L94). +and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/ed942b10a52b6259099e2dd687930871dc8aac22/nbviewer/providers/base.py#L95). nbviewer can also be run as a Hub-Managed Service as described [nbviewer README][nbviewer example] section on securing the notebook viewer. diff --git a/docs/source/troubleshooting.md b/docs/source/troubleshooting.md index 617a5f44..dc222942 100644 --- a/docs/source/troubleshooting.md +++ b/docs/source/troubleshooting.md @@ -269,7 +269,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key. Then restart JupyterHub. -See also [JupyterHub SSL encryption](getting-started.md#ssl-encryption). +See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption). ### Install JupyterHub without a network connection diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 0bd57de7..dda4c127 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -576,6 +576,22 @@ class JupyterHub(Application): """, ).tag(config=True) + @validate('bind_url') + def _validate_bind_url(self, proposal): + """ensure protocol field of bind_url matches ssl""" + v = proposal['value'] + proto, sep, rest = v.partition('://') + if self.ssl_cert and proto != 'https': + return 'https' + sep + rest + elif proto != 'http' and not self.ssl_cert: + return 'http' + sep + rest + return v + + @default('bind_url') + def _bind_url_default(self): + proto = 'https' if self.ssl_cert else 'http' + return proto + '://:8000' + subdomain_host = Unicode( '', help="""Run single-user servers on subdomains of this host. @@ -917,6 +933,25 @@ class JupyterHub(Application): def _authenticator_default(self): return self.authenticator_class(parent=self, db=self.db) + implicit_spawn_seconds = Float( + 0, + help="""Trigger implicit spawns after this many seconds. + + When a user visits a URL for a server that's not running, + they are shown a page indicating that the requested server + is not running with a button to spawn the server. + + Setting this to a positive value will redirect the user + after this many seconds, effectively clicking this button + automatically for the users, + automatically beginning the spawn process. + + Warning: this can result in errors and surprising behavior + when sharing access URLs to actual servers, + since the wrong server is likely to be started. + """, + ).tag(config=True) + allow_named_servers = Bool( False, help="Allow named single-user servers per user" ).tag(config=True) @@ -1670,9 +1705,12 @@ class JupyterHub(Application): # This lets whitelist be used to set up initial list, # but changes to the whitelist can occur in the database, # and persist across sessions. + total_users = 0 for user in db.query(orm.User): try: - await maybe_future(self.authenticator.add_user(user)) + f = self.authenticator.add_user(user) + if f: + await maybe_future(f) except Exception: self.log.exception("Error adding user %s already in db", user.name) if self.authenticator.delete_invalid_users: @@ -1694,6 +1732,7 @@ class JupyterHub(Application): ) ) else: + total_users += 1 # handle database upgrades where user.created is undefined. # we don't want to allow user.created to be undefined, # so initialize it to last_activity (if defined) or now. @@ -1705,6 +1744,8 @@ class JupyterHub(Application): # From this point on, any user changes should be done simultaneously # to the whitelist set and user db, unless the whitelist is empty (all users allowed). + TOTAL_USERS.set(total_users) + async def init_groups(self): """Load predefined groups into the database""" db = self.db @@ -2005,21 +2046,23 @@ class JupyterHub(Application): spawner._check_pending = False # parallelize checks for running Spawners + # run query on extant Server objects + # so this is O(running servers) not O(total users) check_futures = [] - for orm_user in db.query(orm.User): - user = self.users[orm_user] - self.log.debug("Loading state for %s from db", user.name) - for name, orm_spawner in user.orm_spawners.items(): - if orm_spawner.server is not None: - # spawner should be running - # instantiate Spawner wrapper and check if it's still alive - spawner = user.spawners[name] - # signal that check is pending to avoid race conditions - spawner._check_pending = True - f = asyncio.ensure_future(check_spawner(user, name, spawner)) - check_futures.append(f) - - TOTAL_USERS.set(len(self.users)) + for orm_server in db.query(orm.Server): + orm_spawners = orm_server.spawner + if not orm_spawners: + continue + orm_spawner = orm_spawners[0] + # instantiate Spawner wrapper and check if it's still alive + # spawner should be running + user = self.users[orm_spawner.user] + spawner = user.spawners[orm_spawner.name] + self.log.debug("Loading state for %s from db", spawner._log_name) + # signal that check is pending to avoid race conditions + spawner._check_pending = True + f = asyncio.ensure_future(check_spawner(user, spawner.name, spawner)) + check_futures.append(f) # it's important that we get here before the first await # so that we know all spawners are instantiated and in the check-pending state @@ -2158,6 +2201,7 @@ class JupyterHub(Application): subdomain_host=self.subdomain_host, domain=self.domain, statsd=self.statsd, + implicit_spawn_seconds=self.implicit_spawn_seconds, allow_named_servers=self.allow_named_servers, default_server_name=self._default_server_name, named_server_limit_per_user=self.named_server_limit_per_user, diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 77c2ae27..9606fa61 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -1426,11 +1426,12 @@ class UserUrlHandler(BaseHandler): # serve a page prompting for spawn and 503 error # visiting /user/:name no longer triggers implicit spawn # without explicit user action - self.set_status(503) spawn_url = url_concat( url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name), {"next": self.request.uri}, ) + self.set_status(503) + auth_state = await user.get_auth_state() html = self.render_template( "not_running.html", @@ -1438,6 +1439,7 @@ class UserUrlHandler(BaseHandler): server_name=server_name, spawn_url=spawn_url, auth_state=auth_state, + implicit_spawn_seconds=self.settings.get("implicit_spawn_seconds", 0), ) self.finish(html) diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 3198a046..4e151092 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -230,7 +230,7 @@ class Spawner(Base): user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE')) server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL')) - server = relationship(Server, cascade="all") + server = relationship(Server, backref='spawner', cascade="all") state = Column(JSONDict) name = Column(Unicode(255)) @@ -282,7 +282,7 @@ class Service(Base): # service-specific interface _server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL')) - server = relationship(Server, cascade='all') + server = relationship(Server, backref='service', cascade='all') pid = Column(Integer) def new_api_token(self, token=None, **kwargs): diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index 177c1da0..fa0cbeab 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -103,7 +103,8 @@ async def wait_for_spawner(spawner, timeout=10): async def test_single_user_spawner(app, request): - user = next(iter(app.users.values()), None) + orm_user = app.db.query(orm.User).first() + user = app.users[orm_user] spawner = user.spawner spawner.cmd = ['jupyterhub-singleuser'] await user.spawn() diff --git a/share/jupyterhub/templates/not_running.html b/share/jupyterhub/templates/not_running.html index 182e7ba0..fa493d97 100644 --- a/share/jupyterhub/templates/not_running.html +++ b/share/jupyterhub/templates/not_running.html @@ -23,7 +23,14 @@ {% endif %} Would you like to retry starting it? {% else %} - Your server {{ server_name }} is not running. Would you like to start it? + Your server {{ server_name }} is not running. + {% if implicit_spawn_seconds %} + It will be restarted automatically. + If you are not redirected in a few seconds, + click below to launch your server. + {% else %} + Would you like to start it? + {% endif %} {% endif %}

{% endblock %} @@ -42,3 +49,18 @@ {% endblock %} +{% block script %} +{{ super () }} +{% if implicit_spawn_seconds %} + +{% endif %} +{% endblock script %} diff --git a/share/jupyterhub/templates/page.html b/share/jupyterhub/templates/page.html index 50f910c5..bc701ce4 100644 --- a/share/jupyterhub/templates/page.html +++ b/share/jupyterhub/templates/page.html @@ -34,6 +34,9 @@ {% block stylesheet %} {% endblock %} + {% block favicon %} + + {% endblock %} {% block scripts %}