mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 15:03:02 +00:00
Merge branch 'master' into end-to-end-ssl
This commit is contained in:
2
.flake8
2
.flake8
@@ -10,7 +10,7 @@
|
|||||||
# E402: module level import not at top of file
|
# E402: module level import not at top of file
|
||||||
# I100: Import statements are in the wrong order
|
# I100: Import statements are in the wrong order
|
||||||
# I101: Imported names are in the wrong order. Should be
|
# I101: Imported names are in the wrong order. Should be
|
||||||
ignore = E, C, W, F401, F403, F811, F841, E402, I100, I101
|
ignore = E, C, W, F401, F403, F811, F841, E402, I100, I101, D400
|
||||||
|
|
||||||
exclude =
|
exclude =
|
||||||
.cache,
|
.cache,
|
||||||
|
18
.travis.yml
18
.travis.yml
@@ -17,6 +17,7 @@ services:
|
|||||||
|
|
||||||
# installing dependencies
|
# installing dependencies
|
||||||
before_install:
|
before_install:
|
||||||
|
- set -e
|
||||||
- nvm install 6; nvm use 6
|
- nvm install 6; nvm use 6
|
||||||
- npm install
|
- npm install
|
||||||
- npm install -g configurable-http-proxy
|
- npm install -g configurable-http-proxy
|
||||||
@@ -40,20 +41,25 @@ install:
|
|||||||
script:
|
script:
|
||||||
- |
|
- |
|
||||||
# run tests
|
# run tests
|
||||||
set -e
|
if [[ "$TEST" != "docs" ]]; then
|
||||||
pytest -v --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
pytest -v --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||||
|
fi
|
||||||
- |
|
- |
|
||||||
# build docs
|
# build docs
|
||||||
pushd docs
|
if [[ "$TEST" == "docs" ]]; then
|
||||||
pip install -r requirements.txt
|
pushd docs
|
||||||
make html
|
pip install -r requirements.txt
|
||||||
popd
|
make html
|
||||||
|
popd
|
||||||
|
fi
|
||||||
after_success:
|
after_success:
|
||||||
- codecov
|
- codecov
|
||||||
|
|
||||||
matrix:
|
matrix:
|
||||||
fast_finish: true
|
fast_finish: true
|
||||||
include:
|
include:
|
||||||
|
- python: 3.6
|
||||||
|
env: TEST=docs
|
||||||
- python: 3.6
|
- python: 3.6
|
||||||
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://localhost.jovyan.org:8000
|
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://localhost.jovyan.org:8000
|
||||||
- python: 3.6
|
- python: 3.6
|
||||||
|
@@ -1,98 +1,5 @@
|
|||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
Welcome! As a [Jupyter](https://jupyter.org) project, we follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html).
|
|
||||||
|
|
||||||
|
See the [contribution guide](https://jupyterhub.readthedocs.io/en/latest/index.html#contributor) section
|
||||||
## Set up your development system
|
at the JupyterHub documentation.
|
||||||
|
|
||||||
For a development install, clone the [repository](https://github.com/jupyterhub/jupyterhub)
|
|
||||||
and then install from source:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/jupyterhub/jupyterhub
|
|
||||||
cd jupyterhub
|
|
||||||
npm install -g configurable-http-proxy
|
|
||||||
pip3 install -r dev-requirements.txt -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Troubleshooting a development install
|
|
||||||
|
|
||||||
If the `pip3 install` command fails and complains about `lessc` being
|
|
||||||
unavailable, you may need to explicitly install some additional JavaScript
|
|
||||||
dependencies:
|
|
||||||
|
|
||||||
npm install
|
|
||||||
|
|
||||||
This will fetch client-side JavaScript dependencies necessary to compile CSS.
|
|
||||||
|
|
||||||
You may also need to manually update JavaScript and CSS after some development
|
|
||||||
updates, with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 setup.py js # fetch updated client-side js
|
|
||||||
python3 setup.py css # recompile CSS from LESS sources
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the test suite
|
|
||||||
|
|
||||||
We use [pytest](http://doc.pytest.org/en/latest/) for running tests.
|
|
||||||
|
|
||||||
1. Set up a development install as described above.
|
|
||||||
|
|
||||||
2. Set environment variable for `ASYNC_TEST_TIMEOUT` to 15 seconds:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ASYNC_TEST_TIMEOUT=15
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run tests.
|
|
||||||
|
|
||||||
To run all the tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest -v jupyterhub/tests
|
|
||||||
```
|
|
||||||
|
|
||||||
To run an individual test file (i.e. `test_api.py`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest -v jupyterhub/tests/test_api.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Troubleshooting tests
|
|
||||||
|
|
||||||
If you see test failures because of timeouts, you may wish to increase the
|
|
||||||
`ASYNC_TEST_TIMEOUT` used by the
|
|
||||||
[pytest-tornado-plugin](https://github.com/eugeniy/pytest-tornado/blob/c79f68de2222eb7cf84edcfe28650ebf309a4d0c/README.rst#markers)
|
|
||||||
from the default of 5 seconds:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ASYNC_TEST_TIMEOUT=15
|
|
||||||
```
|
|
||||||
|
|
||||||
If you see many test errors and failures, double check that you have installed
|
|
||||||
`configurable-http-proxy`.
|
|
||||||
|
|
||||||
## Building the Docs locally
|
|
||||||
|
|
||||||
1. Install the development system as described above.
|
|
||||||
|
|
||||||
2. Install the dependencies for documentation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m pip install -r docs/requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Build the docs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd docs
|
|
||||||
make clean
|
|
||||||
make html
|
|
||||||
```
|
|
||||||
|
|
||||||
4. View the docs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
open build/html/index.html
|
|
||||||
```
|
|
10
README.md
10
README.md
@@ -11,8 +11,8 @@
|
|||||||
|
|
||||||
|
|
||||||
[](https://pypi.python.org/pypi/jupyterhub)
|
[](https://pypi.python.org/pypi/jupyterhub)
|
||||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
[](https://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||||
[](http://jupyterhub.readthedocs.io/en/0.7.2/?badge=0.7.2)
|
[](https://jupyterhub.readthedocs.io/en/0.7.2/?badge=0.7.2)
|
||||||
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
||||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
||||||
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
||||||
@@ -124,7 +124,7 @@ more configuration of the system.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The [Getting Started](http://jupyterhub.readthedocs.io/en/latest/getting-started/index.html) section of the
|
The [Getting Started](https://jupyterhub.readthedocs.io/en/latest/getting-started/index.html) section of the
|
||||||
documentation explains the common steps in setting up JupyterHub.
|
documentation explains the common steps in setting up JupyterHub.
|
||||||
|
|
||||||
The [**JupyterHub tutorial**](https://github.com/jupyterhub/jupyterhub-tutorial)
|
The [**JupyterHub tutorial**](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||||
@@ -233,11 +233,13 @@ our JupyterHub [Gitter](https://gitter.im/jupyterhub/jupyterhub) channel.
|
|||||||
|
|
||||||
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
||||||
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||||
- [Documentation for JupyterHub](http://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
- [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
||||||
- [Documentation for JupyterHub's REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
- [Documentation for JupyterHub's REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
||||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
||||||
- [Project Jupyter website](https://jupyter.org)
|
- [Project Jupyter website](https://jupyter.org)
|
||||||
|
|
||||||
|
JupyterHub follows the Jupyter [Community Guides](https://jupyter.readthedocs.io/en/latest/community/content-community.html).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**[Technical Overview](#technical-overview)** |
|
**[Technical Overview](#technical-overview)** |
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
mock
|
mock
|
||||||
|
beautifulsoup4
|
||||||
codecov
|
codecov
|
||||||
cryptography
|
cryptography
|
||||||
pytest-cov
|
pytest-cov
|
||||||
@@ -8,3 +9,6 @@ pytest>=3.3
|
|||||||
notebook
|
notebook
|
||||||
requests-mock
|
requests-mock
|
||||||
virtualenv
|
virtualenv
|
||||||
|
# temporary pin of attrs for jsonschema 0.3.0a1
|
||||||
|
# seems to be a pip bug
|
||||||
|
attrs>=17.4.0
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
# ReadTheDocs uses the `environment.yaml` so make sure to update that as well
|
||||||
|
# if you change the dependencies of JupyterHub in the various `requirements.txt`
|
||||||
name: jhub_docs
|
name: jhub_docs
|
||||||
channels:
|
channels:
|
||||||
- conda-forge
|
- conda-forge
|
||||||
@@ -13,7 +15,9 @@ dependencies:
|
|||||||
- traitlets>=4.1
|
- traitlets>=4.1
|
||||||
- sphinx>=1.7
|
- sphinx>=1.7
|
||||||
- pip:
|
- pip:
|
||||||
- python-oauth2
|
- oauthlib>=2.0
|
||||||
- recommonmark==0.4.0
|
- recommonmark==0.4.0
|
||||||
- async_generator
|
- async_generator
|
||||||
- prometheus_client
|
- prometheus_client
|
||||||
|
- attrs>=17.4.0
|
||||||
|
- sphinx-copybutton
|
||||||
|
@@ -1,3 +1,6 @@
|
|||||||
|
# ReadTheDocs uses the `environment.yaml` so make sure to update that as well
|
||||||
|
# if you change this file
|
||||||
-r ../requirements.txt
|
-r ../requirements.txt
|
||||||
sphinx>=1.7
|
sphinx>=1.7
|
||||||
recommonmark==0.4.0
|
recommonmark==0.4.0
|
||||||
|
sphinx-copybutton
|
||||||
|
@@ -217,6 +217,13 @@ paths:
|
|||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
- name: remove
|
||||||
|
description: |
|
||||||
|
Whether to fully remove the server, rather than just stop it.
|
||||||
|
Removing a server deletes things like the state of the stopped server.
|
||||||
|
in: body
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: The user's notebook named-server has started
|
description: The user's notebook named-server has started
|
||||||
|
159
docs/source/admin/upgrading.rst
Normal file
159
docs/source/admin/upgrading.rst
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
.. _admin/upgrading:
|
||||||
|
|
||||||
|
====================
|
||||||
|
Upgrading JupyterHub
|
||||||
|
====================
|
||||||
|
|
||||||
|
JupyterHub offers easy upgrade pathways between minor versions. This
|
||||||
|
document describes how to do these upgrades.
|
||||||
|
|
||||||
|
If you are using :ref:`a JupyterHub distribution <index/distributions>`, you
|
||||||
|
should consult the distribution's documentation on how to upgrade. This
|
||||||
|
document is if you have set up your own JupyterHub without using a
|
||||||
|
distribution.
|
||||||
|
|
||||||
|
It is long because is pretty detailed! Most likely, upgrading
|
||||||
|
JupyterHub is painless, quick and with minimal user interruption.
|
||||||
|
|
||||||
|
Read the Changelog
|
||||||
|
==================
|
||||||
|
|
||||||
|
The `changelog <changelog.html>`_ contains information on what has
|
||||||
|
changed with the new JupyterHub release, and any deprecation warnings.
|
||||||
|
Read these notes to familiarize yourself with the coming changes. There
|
||||||
|
might be new releases of authenticators & spawners you are using, so
|
||||||
|
read the changelogs for those too!
|
||||||
|
|
||||||
|
Notify your users
|
||||||
|
=================
|
||||||
|
|
||||||
|
If you are using the default configuration where ``configurable-http-proxy``
|
||||||
|
is managed by JupyterHub, your users will see service disruption during
|
||||||
|
the upgrade process. You should notify them, and pick a time to do the
|
||||||
|
upgrade where they will be least disrupted.
|
||||||
|
|
||||||
|
If you are using a different proxy, or running ``configurable-http-proxy``
|
||||||
|
independent of JupyterHub, your users will be able to continue using notebook
|
||||||
|
servers they had already launched, but will not be able to launch new servers
|
||||||
|
nor sign in.
|
||||||
|
|
||||||
|
|
||||||
|
Backup database & config
|
||||||
|
========================
|
||||||
|
|
||||||
|
Before doing an upgrade, it is critical to back up:
|
||||||
|
|
||||||
|
#. Your JupyterHub database (sqlite by default, or MySQL / Postgres
|
||||||
|
if you used those). If you are using sqlite (the default), you
|
||||||
|
should backup the ``jupyterhub.sqlite`` file.
|
||||||
|
#. Your ``jupyterhub_config.py`` file.
|
||||||
|
#. Your user's home directories. This is unlikely to be affected directly by
|
||||||
|
a JupyterHub upgrade, but we recommend a backup since user data is very
|
||||||
|
critical.
|
||||||
|
|
||||||
|
|
||||||
|
Shutdown JupyterHub
|
||||||
|
===================
|
||||||
|
|
||||||
|
Shutdown the JupyterHub process. This would vary depending on how you
|
||||||
|
have set up JupyterHub to run. Most likely, it is using a process
|
||||||
|
supervisor of some sort (``systemd`` or ``supervisord`` or even ``docker``).
|
||||||
|
Use the supervisor specific command to stop the JupyterHub process.
|
||||||
|
|
||||||
|
Upgrade JupyterHub packages
|
||||||
|
===========================
|
||||||
|
|
||||||
|
There are two environments where the ``jupyterhub`` package is installed:
|
||||||
|
|
||||||
|
#. The *hub environment*, which is where the JupyterHub server process
|
||||||
|
runs. This is started with the ``jupyterhub`` command, and is what
|
||||||
|
people generally think of as JupyterHub.
|
||||||
|
|
||||||
|
#. The *notebook user environments*. This is where the user notebook
|
||||||
|
servers are launched from, and is probably custom to your own
|
||||||
|
installation. This could be just one environment (different from the
|
||||||
|
hub environment) that is shared by all users, one environment
|
||||||
|
per user, or same environment as the hub environment. The hub
|
||||||
|
launched the ``jupyterhub-singleuser`` command in this environment,
|
||||||
|
which in turn starts the notebook server.
|
||||||
|
|
||||||
|
You need to make sure the version of the ``jupyterhub`` package matches
|
||||||
|
in both these environments. If you installed ``jupyterhub`` with pip,
|
||||||
|
you can upgrade it with:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
python3 -m pip install --upgrade jupyterhub==<version>
|
||||||
|
|
||||||
|
Where ``<version>`` is the version of JupyterHub you are upgrading to.
|
||||||
|
|
||||||
|
If you used ``conda`` to install ``jupyterhub``, you should upgrade it
|
||||||
|
with:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
conda install -c conda-forge jupyterhub==<version>
|
||||||
|
|
||||||
|
Where ``<version>`` is the version of JupyterHub you are upgrading to.
|
||||||
|
|
||||||
|
You should also check for new releases of the authenticator & spawner you
|
||||||
|
are using. You might wish to upgrade those packages too along with JupyterHub,
|
||||||
|
or upgrade them separately.
|
||||||
|
|
||||||
|
Upgrade JupyterHub database
|
||||||
|
===========================
|
||||||
|
|
||||||
|
Once new packages are installed, you need to upgrade the JupyterHub
|
||||||
|
database. From the hub environment, in the same directory as your
|
||||||
|
``jupyterhub_config.py`` file, you should run:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
jupyterhub upgrade-db
|
||||||
|
|
||||||
|
This should find the location of your database, and run necessary upgrades
|
||||||
|
for it.
|
||||||
|
|
||||||
|
SQLite database disadvantages
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
SQLite has some disadvantages when it comes to upgrading JupyterHub. These
|
||||||
|
are:
|
||||||
|
|
||||||
|
- ``upgrade-db`` may not work, and you may need delete your database
|
||||||
|
and start with a fresh one.
|
||||||
|
- ``downgrade-db`` **will not** work if you want to rollback to an
|
||||||
|
earlier version, so backup the ``jupyterhub.sqlite`` file before
|
||||||
|
upgrading
|
||||||
|
|
||||||
|
What happens if I delete my database?
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
Losing the Hub database is often not a big deal. Information that
|
||||||
|
resides only in the Hub database includes:
|
||||||
|
|
||||||
|
- active login tokens (user cookies, service tokens)
|
||||||
|
- users added via JupyterHub UI, instead of config files
|
||||||
|
- info about running servers
|
||||||
|
|
||||||
|
If the following conditions are true, you should be fine clearing the
|
||||||
|
Hub database and starting over:
|
||||||
|
|
||||||
|
- users specified in config file, or login using an external
|
||||||
|
authentication provider (Google, GitHub, LDAP, etc)
|
||||||
|
- user servers are stopped during upgrade
|
||||||
|
- don't mind causing users to login again after upgrade
|
||||||
|
|
||||||
|
Start JupyterHub
|
||||||
|
================
|
||||||
|
|
||||||
|
Once the database upgrade is completed, start the ``jupyterhub``
|
||||||
|
process again.
|
||||||
|
|
||||||
|
#. Log-in and start the server to make sure things work as
|
||||||
|
expected.
|
||||||
|
#. Check the logs for any errors or deprecation warnings. You
|
||||||
|
might have to update your ``jupyterhub_config.py`` file to
|
||||||
|
deal with any deprecated options.
|
||||||
|
|
||||||
|
Congratulations, your JupyterHub has been upgraded!
|
@@ -26,3 +26,8 @@ Module: :mod:`jupyterhub.auth`
|
|||||||
|
|
||||||
.. autoconfigurable:: PAMAuthenticator
|
.. autoconfigurable:: PAMAuthenticator
|
||||||
|
|
||||||
|
:class:`DummyAuthenticator`
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
.. autoconfigurable:: DummyAuthenticator
|
||||||
|
|
||||||
|
@@ -9,6 +9,40 @@ command line for details.
|
|||||||
|
|
||||||
## 0.9
|
## 0.9
|
||||||
|
|
||||||
|
### [0.9.4] 2018-09-24
|
||||||
|
|
||||||
|
JupyterHub 0.9.4 is a small bugfix release.
|
||||||
|
|
||||||
|
- Fixes an issue that required all running user servers to be restarted
|
||||||
|
when performing an upgrade from 0.8 to 0.9.
|
||||||
|
- Fixes content-type for API endpoints back to `application/json`.
|
||||||
|
It was `text/html` in 0.9.0-0.9.3.
|
||||||
|
|
||||||
|
### [0.9.3] 2018-09-12
|
||||||
|
|
||||||
|
JupyterHub 0.9.3 contains small bugfixes and improvements
|
||||||
|
|
||||||
|
- Fix token page and model handling of `expires_at`.
|
||||||
|
This field was missing from the REST API model for tokens
|
||||||
|
and could cause the token page to not render
|
||||||
|
- Add keep-alive to progress event stream to avoid proxies dropping
|
||||||
|
the connection due to inactivity
|
||||||
|
- Documentation and example improvements
|
||||||
|
- Disable quit button when using notebook 5.6
|
||||||
|
- Prototype new feature (may change prior to 1.0):
|
||||||
|
pass requesting Handler to Spawners during start,
|
||||||
|
accessible as `self.handler`
|
||||||
|
|
||||||
|
### [0.9.2] 2018-08-10
|
||||||
|
|
||||||
|
JupyterHub 0.9.2 contains small bugfixes and improvements.
|
||||||
|
|
||||||
|
- Documentation and example improvements
|
||||||
|
- Add `Spawner.consecutive_failure_limit` config for aborting the Hub if too many spawns fail in a row.
|
||||||
|
- Fix for handling SIGTERM when run with asyncio (tornado 5)
|
||||||
|
- Windows compatibility fixes
|
||||||
|
|
||||||
|
|
||||||
### [0.9.1] 2018-07-04
|
### [0.9.1] 2018-07-04
|
||||||
|
|
||||||
JupyterHub 0.9.1 contains a number of small bugfixes on top of 0.9.
|
JupyterHub 0.9.1 contains a number of small bugfixes on top of 0.9.
|
||||||
@@ -108,7 +142,7 @@ and tornado < 5.0.
|
|||||||
- Added "Start All" button to admin page for launching all user servers at once.
|
- Added "Start All" button to admin page for launching all user servers at once.
|
||||||
- Services have an `info` field which is a dictionary.
|
- Services have an `info` field which is a dictionary.
|
||||||
This is accessible via the REST API.
|
This is accessible via the REST API.
|
||||||
- `JupyterHub.extra_handlers` allows defining additonal tornado RequestHandlers attached to the Hub.
|
- `JupyterHub.extra_handlers` allows defining additional tornado RequestHandlers attached to the Hub.
|
||||||
- API tokens may now expire.
|
- API tokens may now expire.
|
||||||
Expiry is available in the REST model as `expires_at`,
|
Expiry is available in the REST model as `expires_at`,
|
||||||
and settable when creating API tokens by specifying `expires_in`.
|
and settable when creating API tokens by specifying `expires_in`.
|
||||||
@@ -392,7 +426,10 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
|||||||
First preview release
|
First preview release
|
||||||
|
|
||||||
|
|
||||||
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.9.1...HEAD
|
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.9.4...HEAD
|
||||||
|
[0.9.4]: https://github.com/jupyterhub/jupyterhub/compare/0.9.3...0.9.4
|
||||||
|
[0.9.3]: https://github.com/jupyterhub/jupyterhub/compare/0.9.2...0.9.3
|
||||||
|
[0.9.2]: https://github.com/jupyterhub/jupyterhub/compare/0.9.1...0.9.2
|
||||||
[0.9.1]: https://github.com/jupyterhub/jupyterhub/compare/0.9.0...0.9.1
|
[0.9.1]: https://github.com/jupyterhub/jupyterhub/compare/0.9.0...0.9.1
|
||||||
[0.9.0]: https://github.com/jupyterhub/jupyterhub/compare/0.8.1...0.9.0
|
[0.9.0]: https://github.com/jupyterhub/jupyterhub/compare/0.8.1...0.9.0
|
||||||
[0.8.1]: https://github.com/jupyterhub/jupyterhub/compare/0.8.0...0.8.1
|
[0.8.1]: https://github.com/jupyterhub/jupyterhub/compare/0.8.0...0.8.1
|
||||||
|
@@ -21,6 +21,7 @@ extensions = [
|
|||||||
'sphinx.ext.intersphinx',
|
'sphinx.ext.intersphinx',
|
||||||
'sphinx.ext.napoleon',
|
'sphinx.ext.napoleon',
|
||||||
'autodoc_traits',
|
'autodoc_traits',
|
||||||
|
'sphinx_copybutton'
|
||||||
]
|
]
|
||||||
|
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
|
25
docs/source/contributing/community.rst
Normal file
25
docs/source/contributing/community.rst
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
.. _contributing/community:
|
||||||
|
|
||||||
|
================================
|
||||||
|
Community communication channels
|
||||||
|
================================
|
||||||
|
|
||||||
|
We use `Gitter <https://gitter.im>`_ for online, real-time text chat. The
|
||||||
|
primary channel for JupyterHub is `jupyterhub/jupyterhub <https://gitter.im/jupyterhub/jupyterhub>`_.
|
||||||
|
Remember that our community is distributed across the world in various
|
||||||
|
timezones, so be patient if you do not get an answer immediately!
|
||||||
|
|
||||||
|
GitHub issues are used for most long-form project discussions, bug reports
|
||||||
|
and feature requests. Issues related to a specific authenticator or
|
||||||
|
spawner should be directed to the appropriate repository for the
|
||||||
|
authenticator or spawner. If you are using a specific JupyterHub
|
||||||
|
distribution (such as `Zero to JupyterHub on Kubernetes <http://github.com/jupyterhub/zero-to-jupyterhub-k8s>`_
|
||||||
|
or `The Littlest JupyterHub <http://github.com/jupyterhub/the-littlest-jupyterhub/>`_),
|
||||||
|
you should open issues directly in their repository. If you can not
|
||||||
|
find a repository to open your issue in, do not worry! Create it in the `main
|
||||||
|
JupyterHub repository <https://github.com/jupyterhub/jupyterhub/>`_ and our
|
||||||
|
community will help you figure it out.
|
||||||
|
|
||||||
|
A `mailing list <https://groups.google.com/forum/#!forum/jupyter>`_ for all
|
||||||
|
of Project Jupyter exists, along with one for `teaching with Jupyter
|
||||||
|
<https://groups.google.com/forum/#!forum/jupyter-education>`_.
|
78
docs/source/contributing/docs.rst
Normal file
78
docs/source/contributing/docs.rst
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
.. _contributing/docs:
|
||||||
|
|
||||||
|
==========================
|
||||||
|
Contributing Documentation
|
||||||
|
==========================
|
||||||
|
|
||||||
|
Documentation is often more important than code. This page helps
|
||||||
|
you get set up on how to contribute documentation to JupyterHub.
|
||||||
|
|
||||||
|
Building documentation locally
|
||||||
|
==============================
|
||||||
|
|
||||||
|
We use `sphinx <http://sphinx-doc.org>`_ to build our documentation. It takes
|
||||||
|
our documentation source files (written in `markdown
|
||||||
|
<https://daringfireball.net/projects/markdown/>`_ or `reStructuredText
|
||||||
|
<http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html>`_ &
|
||||||
|
stored under the ``docs/source`` directory) and converts it into various
|
||||||
|
formats for people to read. To make sure the documentation you write or
|
||||||
|
change renders correctly, it is good practice to test it locally.
|
||||||
|
|
||||||
|
#. Make sure you have successfuly completed :ref:`contributing/setup`.
|
||||||
|
|
||||||
|
#. Install the packages required to build the docs.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
python3 -m pip install -r docs/requirements.txt
|
||||||
|
|
||||||
|
#. Build the html version of the docs. This is the most commonly used
|
||||||
|
output format, so verifying it renders as you should is usually good
|
||||||
|
enough.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
cd docs
|
||||||
|
make html
|
||||||
|
|
||||||
|
This step will display any syntax or formatting errors in the documentation,
|
||||||
|
along with the filename / line number in which they occurred. Fix them,
|
||||||
|
and re-run the ``make html`` command to re-render the documentation.
|
||||||
|
|
||||||
|
#. View the rendered documentation by opening ``build/html/index.html`` in
|
||||||
|
a web browser.
|
||||||
|
|
||||||
|
.. tip::
|
||||||
|
|
||||||
|
On macOS, you can open a file from the terminal with ``open <path-to-file>``.
|
||||||
|
On Linux, you can do the same with ``xdg-open <path-to-file>``.
|
||||||
|
|
||||||
|
|
||||||
|
.. _contributing/docs/conventions:
|
||||||
|
|
||||||
|
Documentation conventions
|
||||||
|
=========================
|
||||||
|
|
||||||
|
This section lists various conventions we use in our documentation. This is a
|
||||||
|
living document that grows over time, so feel free to add to it / change it!
|
||||||
|
|
||||||
|
Our entire documentation does not yet fully conform to these conventions yet,
|
||||||
|
so help in making it so would be appreciated!
|
||||||
|
|
||||||
|
``pip`` invocation
|
||||||
|
------------------
|
||||||
|
|
||||||
|
There are many ways to invoke a ``pip`` command, we recommend the following
|
||||||
|
approach:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
python3 -m pip
|
||||||
|
|
||||||
|
This invokes pip explicitly using the python3 binary that you are
|
||||||
|
currently using. This is the **recommended way** to invoke pip
|
||||||
|
in our documentation, since it is least likely to cause problems
|
||||||
|
with python3 and pip being from different environments.
|
||||||
|
|
||||||
|
For more information on how to invoke ``pip`` commands, see
|
||||||
|
`the pip documentation <https://pip.pypa.io/en/stable/>`_.
|
177
docs/source/contributing/setup.rst
Normal file
177
docs/source/contributing/setup.rst
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
.. _contributing/setup:
|
||||||
|
|
||||||
|
================================
|
||||||
|
Setting up a development install
|
||||||
|
================================
|
||||||
|
|
||||||
|
System requirements
|
||||||
|
===================
|
||||||
|
|
||||||
|
JupyterHub can only run on MacOS or Linux operating systems. If you are
|
||||||
|
using Windows, we recommend using `VirtualBox <https://virtualbox.org>`_
|
||||||
|
or a similar system to run `Ubuntu Linux <https://ubuntu.com>`_ for
|
||||||
|
development.
|
||||||
|
|
||||||
|
Install Python
|
||||||
|
--------------
|
||||||
|
|
||||||
|
JupyterHub is written in the `Python <https://python.org>`_ programming language, and
|
||||||
|
requires you have at least version 3.5 installed locally. If you haven’t
|
||||||
|
installed Python before, the recommended way to install it is to use
|
||||||
|
`miniconda <https://conda.io/miniconda.html>`_. Remember to get the ‘Python 3’ version,
|
||||||
|
and **not** the ‘Python 2’ version!
|
||||||
|
|
||||||
|
Install nodejs
|
||||||
|
--------------
|
||||||
|
|
||||||
|
``configurable-http-proxy``, the default proxy implementation for
|
||||||
|
JupyterHub, is written in Javascript to run on `NodeJS
|
||||||
|
<https://nodejs.org/en/>`_. If you have not installed nodejs before, we
|
||||||
|
recommend installing it in the ``miniconda`` environment you set up for
|
||||||
|
Python. You can do so with ``conda install nodejs``.
|
||||||
|
|
||||||
|
Install git
|
||||||
|
-----------
|
||||||
|
|
||||||
|
JupyterHub uses `git <https://git-scm.com>`_ & `GitHub <https://github.com>`_
|
||||||
|
for development & collaboration. You need to `install git
|
||||||
|
<https://git-scm.com/book/en/v2/Getting-Started-Installing-Git>`_ to work on
|
||||||
|
JupyterHub. We also recommend getting a free account on GitHub.com.
|
||||||
|
|
||||||
|
Setting up a development install
|
||||||
|
================================
|
||||||
|
|
||||||
|
When developing JupyterHub, you need to make changes to the code & see
|
||||||
|
their effects quickly. You need to do a developer install to make that
|
||||||
|
happen.
|
||||||
|
|
||||||
|
1. Clone the `JupyterHub git repository <https://github.com/jupyterhub/jupyterhub>`_
|
||||||
|
to your computer.
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
git clone https://github.com/jupyterhub/jupyterhub
|
||||||
|
cd jupyterhub
|
||||||
|
|
||||||
|
2. Make sure the ``python`` you installed and the ``npm`` you installed
|
||||||
|
are available to you on the command line.
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
python -V
|
||||||
|
|
||||||
|
This should return a version number greater than or equal to 3.5.
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
npm -v
|
||||||
|
|
||||||
|
This should return a version number greater than or equal to 5.0.
|
||||||
|
|
||||||
|
3. Install ``configurable-http-proxy``. This is required to run
|
||||||
|
JupyterHub.
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
npm install -g configurable-http-proxy
|
||||||
|
|
||||||
|
If you get an error that says ``Error: EACCES: permission denied``,
|
||||||
|
you might need to prefix the command with ``sudo``. If you do not
|
||||||
|
have access to sudo, you may instead run the following commands:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
npm install configurable-http-proxy
|
||||||
|
export PATH=$PATH:$(pwd)/node_modules/.bin
|
||||||
|
|
||||||
|
The second line needs to be run every time you open a new terminal.
|
||||||
|
|
||||||
|
4. Install the python packages required for JupyterHub development.
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
python3 -m pip install -r dev-requirements.txt
|
||||||
|
python3 -m pip install -r requirements.txt
|
||||||
|
|
||||||
|
5. Install the development version of JupyterHub. This lets you edit
|
||||||
|
JupyterHub code in a text editor & restart the JupyterHub process to
|
||||||
|
see your code changes immediately.
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
python3 -m pip install --editable .
|
||||||
|
|
||||||
|
6. You are now ready to start JupyterHub!
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
jupyterhub
|
||||||
|
|
||||||
|
7. You can access JupyterHub from your browser at
|
||||||
|
``http://localhost:8000`` now.
|
||||||
|
|
||||||
|
Happy developing!
|
||||||
|
|
||||||
|
Using DummyAuthenticator & SimpleSpawner
|
||||||
|
========================================
|
||||||
|
|
||||||
|
To simplify testing of JupyterHub, it’s helpful to use
|
||||||
|
:class:`~jupyterhub.auth.DummyAuthenticator` instead of the default JupyterHub
|
||||||
|
authenticator and `SimpleSpawner <https://github.com/jupyterhub/simplespawner>`_
|
||||||
|
instead of the default spawner.
|
||||||
|
|
||||||
|
There is a sample configuration file that does this in
|
||||||
|
``testing/jupyterhub_config.py``. To launch jupyterhub with this
|
||||||
|
configuration:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
pip install jupyterhub-simplespawner
|
||||||
|
jupyterhub -f testing/jupyterhub_config.py
|
||||||
|
|
||||||
|
The default JupyterHub `authenticator
|
||||||
|
<https://jupyterhub.readthedocs.io/en/stable/reference/authenticators.html#the-default-pam-authenticator>`_
|
||||||
|
& `spawner
|
||||||
|
<https://jupyterhub.readthedocs.io/en/stable/api/spawner.html#localprocessspawner>`_
|
||||||
|
require your system to have user accounts for each user you want to log in to
|
||||||
|
JupyterHub as.
|
||||||
|
|
||||||
|
DummyAuthenticator allows you to log in with any username & password,
|
||||||
|
while SimpleSpawner allows you to start servers without having to
|
||||||
|
create a unix user for each JupyterHub user. Together, these make it
|
||||||
|
much easier to test JupyterHub.
|
||||||
|
|
||||||
|
Tip: If you are working on parts of JupyterHub that are common to all
|
||||||
|
authenticators & spawners, we recommend using both DummyAuthenticator &
|
||||||
|
SimpleSpawner. If you are working on just authenticator related parts,
|
||||||
|
use only SimpleSpawner. Similarly, if you are working on just spawner
|
||||||
|
related parts, use only DummyAuthenticator.
|
||||||
|
|
||||||
|
Troubleshooting
|
||||||
|
===============
|
||||||
|
|
||||||
|
This section lists common ways setting up your development environment may
|
||||||
|
fail, and how to fix them. Please add to the list if you encounter yet
|
||||||
|
another way it can fail!
|
||||||
|
|
||||||
|
``lessc`` not found
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
If the ``python3 -m pip install --editable .`` command fails and complains about
|
||||||
|
``lessc`` being unavailable, you may need to explicitly install some
|
||||||
|
additional JavaScript dependencies:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
npm install
|
||||||
|
|
||||||
|
This will fetch client-side JavaScript dependencies necessary to compile
|
||||||
|
CSS.
|
||||||
|
|
||||||
|
You may also need to manually update JavaScript and CSS after some
|
||||||
|
development updates, with:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
python3 setup.py js # fetch updated client-side js
|
||||||
|
python3 setup.py css # recompile CSS from LESS sources
|
78
docs/source/contributing/tests.rst
Normal file
78
docs/source/contributing/tests.rst
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
.. _contributing/tests:
|
||||||
|
|
||||||
|
==================
|
||||||
|
Testing JupyterHub
|
||||||
|
==================
|
||||||
|
|
||||||
|
Unit test help validate that JupyterHub works the way we think it does,
|
||||||
|
and continues to do so when changes occur. They also help communicate
|
||||||
|
precisely what we expect our code to do.
|
||||||
|
|
||||||
|
JupyterHub uses `pytest <https://pytest.org>`_ for all our tests. You
|
||||||
|
can find them under ``jupyterhub/tests`` directory in the git repository.
|
||||||
|
|
||||||
|
Running the tests
|
||||||
|
==================
|
||||||
|
|
||||||
|
#. Make sure you have completed :ref:`contributing/setup`. You should be able
|
||||||
|
to start ``jupyterhub`` from the commandline & access it from your
|
||||||
|
web browser. This ensures that the dev environment is properly set
|
||||||
|
up for tests to run.
|
||||||
|
|
||||||
|
#. You can run all tests in JupyterHub
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
pytest --async-test-timeout 15 -v jupyterhub/tests
|
||||||
|
|
||||||
|
This should display progress as it runs all the tests, printing
|
||||||
|
information about any test failures as they occur.
|
||||||
|
|
||||||
|
The ``--async-test-timeout`` parameter is used by `pytest-tornado
|
||||||
|
<https://github.com/eugeniy/pytest-tornado#markers>`_ to set the
|
||||||
|
asynchronous test timeout to 15 seconds rather than the default 5,
|
||||||
|
since some of our tests take longer than 5s to execute.
|
||||||
|
|
||||||
|
#. You can also run tests in just a specific file:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
pytest --async-test-timeout 15 -v jupyterhub/tests/<test-file-name>
|
||||||
|
|
||||||
|
#. To run a specific test only, you can do:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
pytest --async-test-timeout 15 -v jupyterhub/tests/<test-file-name>::<test-name>
|
||||||
|
|
||||||
|
This runs the test with function name ``<test-name>`` defined in
|
||||||
|
``<test-file-name>``. This is very useful when you are iteratively
|
||||||
|
developing a single test.
|
||||||
|
|
||||||
|
For example, to run the test ``test_shutdown`` in the file ``test_api.py``,
|
||||||
|
you would run:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
pytest -v jupyterhub/tests/test_api.py::test_shutdown
|
||||||
|
|
||||||
|
|
||||||
|
Troubleshooting Test Failures
|
||||||
|
=============================
|
||||||
|
|
||||||
|
All the tests are failing
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
Make sure you have completed all the steps in :ref:`contributing/setup` sucessfully, and
|
||||||
|
can launch ``jupyterhub`` from the terminal.
|
||||||
|
|
||||||
|
Tests are timing out
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
The ``--async-test-timeout`` parameter to ``pytest`` is used by
|
||||||
|
`pytest-tornado <https://github.com/eugeniy/pytest-tornado#markers>`_ to set
|
||||||
|
the asynchronous test timeout to a higher value than the default of 5s,
|
||||||
|
since some of our tests take longer than 5s to execute. If the tests
|
||||||
|
are still timing out, try increasing that value even more. You can
|
||||||
|
also set an environment variable ``ASYNC_TEST_TIMEOUT`` instead of
|
||||||
|
passing ``--async-test-timeout`` to each invocation of pytest.
|
@@ -95,5 +95,16 @@ popular services:
|
|||||||
A generic implementation, which you can use for OAuth authentication
|
A generic implementation, which you can use for OAuth authentication
|
||||||
with any provider, is also available.
|
with any provider, is also available.
|
||||||
|
|
||||||
|
## Use DummyAuthenticator for testing
|
||||||
|
|
||||||
|
The :class:`~jupyterhub.auth.DummyAuthenticator` is a simple authenticator that
|
||||||
|
allows for any username/password unless if a global password has been set. If
|
||||||
|
set, it will allow for any username as long as the correct password is provided.
|
||||||
|
To set a global password, add this to the config file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.DummyAuthenticator.password = "some_password"
|
||||||
|
```
|
||||||
|
|
||||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||||
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||||
|
@@ -45,7 +45,7 @@ is important that these files be put in a secure location on your server, where
|
|||||||
they are not readable by regular users.
|
they are not readable by regular users.
|
||||||
|
|
||||||
If you are using a **chain certificate**, see also chained certificate for SSL
|
If you are using a **chain certificate**, see also chained certificate for SSL
|
||||||
in the JupyterHub `troubleshooting FAQ <troubleshooting>`_.
|
in the JupyterHub `Troubleshooting FAQ <../troubleshooting.html>`_.
|
||||||
|
|
||||||
Using letsencrypt
|
Using letsencrypt
|
||||||
~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
==========
|
||||||
JupyterHub
|
JupyterHub
|
||||||
==========
|
==========
|
||||||
|
|
||||||
@@ -28,75 +29,139 @@ JupyterHub performs the following functions:
|
|||||||
For convenient administration of the Hub, its users, and services,
|
For convenient administration of the Hub, its users, and services,
|
||||||
JupyterHub also provides a `REST API`_.
|
JupyterHub also provides a `REST API`_.
|
||||||
|
|
||||||
|
The JupyterHub team and Project Jupyter value our community, and JupyterHub
|
||||||
|
follows the Jupyter [Community Guides](https://jupyter.readthedocs.io/en/latest/community/content-community.html).
|
||||||
|
|
||||||
Contents
|
Contents
|
||||||
--------
|
========
|
||||||
|
|
||||||
**Installation Guide**
|
.. _index/distributions:
|
||||||
|
|
||||||
* :doc:`installation-guide`
|
Distributions
|
||||||
* :doc:`quickstart`
|
-------------
|
||||||
* :doc:`quickstart-docker`
|
|
||||||
* :doc:`installation-basics`
|
|
||||||
|
|
||||||
**Getting Started**
|
A JupyterHub **distribution** is tailored towards a particular set of
|
||||||
|
use cases. These are generally easier to set up than setting up
|
||||||
|
JupyterHub from scratch, assuming they fit your use case.
|
||||||
|
|
||||||
* :doc:`getting-started/index`
|
The two popular ones are:
|
||||||
* :doc:`getting-started/config-basics`
|
|
||||||
* :doc:`getting-started/networking-basics`
|
|
||||||
* :doc:`getting-started/security-basics`
|
|
||||||
* :doc:`getting-started/authenticators-users-basics`
|
|
||||||
* :doc:`getting-started/spawners-basics`
|
|
||||||
* :doc:`getting-started/services-basics`
|
|
||||||
|
|
||||||
**Technical Reference**
|
* `Zero to JupyterHub on Kubernetes <http://z2jh.jupyter.org>`_, for
|
||||||
|
running JupyterHub on top of `Kubernetes <https://k8s.io>`_. This
|
||||||
|
can scale to large number of machines & users.
|
||||||
|
* `The Littlest JupyterHub <http://tljh.jupyter.org>`_, for an easy
|
||||||
|
to set up & run JupyterHub supporting 1-100 users on a single machine.
|
||||||
|
|
||||||
* :doc:`reference/index`
|
Installation Guide
|
||||||
* :doc:`reference/technical-overview`
|
------------------
|
||||||
* :doc:`reference/websecurity`
|
|
||||||
* :doc:`reference/authenticators`
|
|
||||||
* :doc:`reference/spawners`
|
|
||||||
* :doc:`reference/services`
|
|
||||||
* :doc:`reference/rest`
|
|
||||||
* :doc:`reference/upgrading`
|
|
||||||
* :doc:`reference/templates`
|
|
||||||
* :doc:`reference/config-user-env`
|
|
||||||
* :doc:`reference/config-examples`
|
|
||||||
* :doc:`reference/config-ghoauth`
|
|
||||||
* :doc:`reference/config-proxy`
|
|
||||||
* :doc:`reference/config-sudo`
|
|
||||||
|
|
||||||
**API Reference**
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
* :doc:`api/index`
|
installation-guide
|
||||||
|
quickstart
|
||||||
|
quickstart-docker
|
||||||
|
installation-basics
|
||||||
|
|
||||||
**Tutorials**
|
Getting Started
|
||||||
|
---------------
|
||||||
|
|
||||||
* :doc:`tutorials/index`
|
.. toctree::
|
||||||
* :doc:`tutorials/upgrade-dot-eight`
|
:maxdepth: 1
|
||||||
* `Zero to JupyterHub with Kubernetes <https://zero-to-jupyterhub.readthedocs.io/en/latest/>`_
|
|
||||||
|
|
||||||
**Troubleshooting**
|
getting-started/index
|
||||||
|
getting-started/config-basics
|
||||||
|
getting-started/networking-basics
|
||||||
|
getting-started/security-basics
|
||||||
|
getting-started/authenticators-users-basics
|
||||||
|
getting-started/spawners-basics
|
||||||
|
getting-started/services-basics
|
||||||
|
|
||||||
* :doc:`troubleshooting`
|
Technical Reference
|
||||||
|
-------------------
|
||||||
|
|
||||||
**About JupyterHub**
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
* :doc:`contributor-list`
|
reference/index
|
||||||
* :doc:`gallery-jhub-deployments`
|
reference/technical-overview
|
||||||
|
reference/websecurity
|
||||||
|
reference/authenticators
|
||||||
|
reference/spawners
|
||||||
|
reference/services
|
||||||
|
reference/rest
|
||||||
|
reference/templates
|
||||||
|
reference/config-user-env
|
||||||
|
reference/config-examples
|
||||||
|
reference/config-ghoauth
|
||||||
|
reference/config-proxy
|
||||||
|
reference/config-sudo
|
||||||
|
|
||||||
**Changelog**
|
Contributing
|
||||||
|
------------
|
||||||
|
|
||||||
* :doc:`changelog`
|
We want you to contribute to JupyterHub in ways that are most exciting
|
||||||
|
& useful to you. We value documentation, testing, bug reporting & code equally,
|
||||||
|
and are glad to have your contributions in whatever form you wish :)
|
||||||
|
|
||||||
|
Our `Code of Conduct <https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md>`_
|
||||||
|
(`reporting guidelines <https://github.com/jupyter/governance/blob/master/conduct/reporting_online.md>`_)
|
||||||
|
helps keep our community welcoming to as many people as possible.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
|
contributing/community
|
||||||
|
contributing/setup
|
||||||
|
contributing/docs
|
||||||
|
contributing/tests
|
||||||
|
|
||||||
|
Upgrading JupyterHub
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
We try to make upgrades between minor versions as painless as possible.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
|
admin/upgrading
|
||||||
|
changelog
|
||||||
|
|
||||||
|
API Reference
|
||||||
|
-------------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
|
api/index
|
||||||
|
|
||||||
|
Troubleshooting
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
|
troubleshooting
|
||||||
|
|
||||||
|
About JupyterHub
|
||||||
|
----------------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
|
contributor-list
|
||||||
|
changelog
|
||||||
|
gallery-jhub-deployments
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
------------------
|
==================
|
||||||
|
|
||||||
* :ref:`genindex`
|
* :ref:`genindex`
|
||||||
* :ref:`modindex`
|
* :ref:`modindex`
|
||||||
|
|
||||||
|
|
||||||
Questions? Suggestions?
|
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>`_
|
||||||
@@ -104,7 +169,7 @@ Questions? Suggestions?
|
|||||||
.. _contents:
|
.. _contents:
|
||||||
|
|
||||||
Full Table of Contents
|
Full Table of Contents
|
||||||
----------------------
|
======================
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
@@ -113,7 +178,6 @@ Full Table of Contents
|
|||||||
getting-started/index
|
getting-started/index
|
||||||
reference/index
|
reference/index
|
||||||
api/index
|
api/index
|
||||||
tutorials/index
|
|
||||||
troubleshooting
|
troubleshooting
|
||||||
contributor-list
|
contributor-list
|
||||||
gallery-jhub-deployments
|
gallery-jhub-deployments
|
||||||
|
@@ -5,8 +5,8 @@ Hub and single user notebook servers.
|
|||||||
|
|
||||||
## The default PAM Authenticator
|
## The default PAM Authenticator
|
||||||
|
|
||||||
JupyterHub ships only with the default [PAM][]-based Authenticator,
|
JupyterHub ships with the default [PAM][]-based Authenticator, for
|
||||||
for logging in with local user accounts via a username and password.
|
logging in with local user accounts via a username and password.
|
||||||
|
|
||||||
## The OAuthenticator
|
## The OAuthenticator
|
||||||
|
|
||||||
@@ -34,12 +34,17 @@ popular services:
|
|||||||
A generic implementation, which you can use for OAuth authentication
|
A generic implementation, which you can use for OAuth authentication
|
||||||
with any provider, is also available.
|
with any provider, is also available.
|
||||||
|
|
||||||
|
## The Dummy Authenticator
|
||||||
|
|
||||||
|
When testing, it may be helpful to use the
|
||||||
|
:class:`~jupyterhub.auth.DummyAuthenticator`. This allows for any username and
|
||||||
|
password unless if a global password has been set. Once set, any username will
|
||||||
|
still be accepted but the correct password will need to be provided.
|
||||||
|
|
||||||
## Additional Authenticators
|
## Additional Authenticators
|
||||||
|
|
||||||
- ldapauthenticator for LDAP
|
A partial list of other authenticators is available on the
|
||||||
- tmpauthenticator for temporary accounts
|
[JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||||
- For Shibboleth, [jhub_shibboleth_auth](https://github.com/gesiscss/jhub_shibboleth_auth)
|
|
||||||
and [jhub_remote_user_authenticator](https://github.com/cwaldbieser/jhub_remote_user_authenticator)
|
|
||||||
|
|
||||||
## Technical Overview of Authentication
|
## Technical Overview of Authentication
|
||||||
|
|
||||||
@@ -70,7 +75,6 @@ Writing an Authenticator that looks up passwords in a dictionary
|
|||||||
requires only overriding this one method:
|
requires only overriding this one method:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from tornado import gen
|
|
||||||
from IPython.utils.traitlets import Dict
|
from IPython.utils.traitlets import Dict
|
||||||
from jupyterhub.auth import Authenticator
|
from jupyterhub.auth import Authenticator
|
||||||
|
|
||||||
@@ -80,8 +84,7 @@ class DictionaryAuthenticator(Authenticator):
|
|||||||
help="""dict of username:password for authentication"""
|
help="""dict of username:password for authentication"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@gen.coroutine
|
async def authenticate(self, handler, data):
|
||||||
def authenticate(self, handler, data):
|
|
||||||
if self.passwords.get(data['username']) == data['password']:
|
if self.passwords.get(data['username']) == data['password']:
|
||||||
return data['username']
|
return data['username']
|
||||||
```
|
```
|
||||||
@@ -138,6 +141,41 @@ See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/
|
|||||||
If you are interested in writing a custom authenticator, you can read
|
If you are interested in writing a custom authenticator, you can read
|
||||||
[this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html).
|
[this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html).
|
||||||
|
|
||||||
|
### Registering custom Authenticators via entry points
|
||||||
|
|
||||||
|
As of JupyterHub 1.0, custom authenticators can register themselves via
|
||||||
|
the `jupyterhub.authenticators` entry point metadata.
|
||||||
|
To do this, in your `setup.py` add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
setup(
|
||||||
|
...
|
||||||
|
entry_points={
|
||||||
|
'jupyterhub.authenticators': [
|
||||||
|
'myservice = mypackage:MyAuthenticator',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have added this metadata to your package,
|
||||||
|
users can select your authenticator with the configuration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.authenticator_class = 'myservice'
|
||||||
|
```
|
||||||
|
|
||||||
|
instead of the full
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.authenticator_class = 'mypackage:MyAuthenticator'
|
||||||
|
```
|
||||||
|
|
||||||
|
previously required.
|
||||||
|
Additionally, configurable attributes for your spawner will
|
||||||
|
appear in jupyterhub help output and auto-generated configuration files
|
||||||
|
via `jupyterhub --generate-config`.
|
||||||
|
|
||||||
|
|
||||||
### Authentication state
|
### Authentication state
|
||||||
|
|
||||||
@@ -226,5 +264,5 @@ Beginning with version 0.8, JupyterHub is an OAuth provider.
|
|||||||
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||||
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||||
[pre_spawn_start(user, spawner)]: http://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
|
[pre_spawn_start(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
|
||||||
[post_spawn_stop(user, spawner)]: http://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
[post_spawn_stop(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
||||||
|
@@ -79,4 +79,4 @@ export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
|
|||||||
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
||||||
# append log output to log file /var/log/jupyterhub.log
|
# append log output to log file /var/log/jupyterhub.log
|
||||||
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
|
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
|
||||||
```
|
```
|
||||||
|
@@ -37,7 +37,7 @@ Next, you will need [sudospawner](https://github.com/jupyter/sudospawner)
|
|||||||
to enable monitoring the single-user servers with sudo:
|
to enable monitoring the single-user servers with sudo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo pip install sudospawner
|
sudo python3 -m pip install sudospawner
|
||||||
```
|
```
|
||||||
|
|
||||||
Now we have to configure sudo to allow the Hub user (`rhea`) to launch
|
Now we have to configure sudo to allow the Hub user (`rhea`) to launch
|
||||||
@@ -70,7 +70,7 @@ Cmnd_Alias JUPYTER_CMD = /usr/local/bin/sudospawner
|
|||||||
rhea ALL=(JUPYTER_USERS) NOPASSWD:JUPYTER_CMD
|
rhea ALL=(JUPYTER_USERS) NOPASSWD:JUPYTER_CMD
|
||||||
```
|
```
|
||||||
|
|
||||||
It might be useful to modifiy `secure_path` to add commands in path.
|
It might be useful to modify `secure_path` to add commands in path.
|
||||||
|
|
||||||
As an alternative to adding every user to the `/etc/sudoers` file, you can
|
As an alternative to adding every user to the `/etc/sudoers` file, you can
|
||||||
use a group in the last line above, instead of `JUPYTER_USERS`:
|
use a group in the last line above, instead of `JUPYTER_USERS`:
|
||||||
|
@@ -125,7 +125,7 @@ sure are available, I can install their specs system-wide (in /usr/local) with:
|
|||||||
There are two broad categories of user environments that depend on what
|
There are two broad categories of user environments that depend on what
|
||||||
Spawner you choose:
|
Spawner you choose:
|
||||||
|
|
||||||
- Multi-user hosts (shared sytem)
|
- Multi-user hosts (shared system)
|
||||||
- Container-based
|
- Container-based
|
||||||
|
|
||||||
How you configure user environments for each category can differ a bit
|
How you configure user environments for each category can differ a bit
|
||||||
|
@@ -12,7 +12,6 @@ Technical Reference
|
|||||||
proxy
|
proxy
|
||||||
rest
|
rest
|
||||||
database
|
database
|
||||||
upgrading
|
|
||||||
templates
|
templates
|
||||||
config-user-env
|
config-user-env
|
||||||
config-examples
|
config-examples
|
||||||
|
@@ -204,10 +204,10 @@ which implements the requests to the Hub.
|
|||||||
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
||||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||||
|
|
||||||
Most of the logic for authentication implementation is found in the
|
Most of the logic for authentication implementation is found in the
|
||||||
[`HubAuth.user_for_cookie`](services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie)
|
[`HubAuth.user_for_cookie`][HubAuth.user_for_cookie]
|
||||||
and in the
|
and in the
|
||||||
[`HubAuth.user_for_token`](services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token)
|
[`HubAuth.user_for_token`][HubAuth.user_for_token]
|
||||||
methods, which makes a request of the Hub, and returns:
|
methods, which makes a request of the Hub, and returns:
|
||||||
|
|
||||||
- None, if no user could be identified, or
|
- None, if no user could be identified, or
|
||||||
@@ -359,14 +359,16 @@ and taking note of the following process:
|
|||||||
```
|
```
|
||||||
|
|
||||||
An example of using an Externally-Managed Service and authentication is
|
An example of using an Externally-Managed Service and authentication is
|
||||||
in [nbviewer README]_ section on securing the notebook viewer,
|
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/master/nbviewer/providers/base.py#L94).
|
||||||
nbviewer can also be run as a Hub-Managed Service as described [nbviewer README]_
|
nbviewer can also be run as a Hub-Managed Service as described [nbviewer README][nbviewer example]
|
||||||
section on securing the notebook viewer.
|
section on securing the notebook viewer.
|
||||||
|
|
||||||
|
|
||||||
[requests]: http://docs.python-requests.org/en/master/
|
[requests]: http://docs.python-requests.org/en/master/
|
||||||
[services_auth]: ../api/services.auth.html
|
[services_auth]: ../api/services.auth.html
|
||||||
[HubAuth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
[HubAuth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
||||||
|
[HubAuth.user_for_cookie]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie
|
||||||
|
[HubAuth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
||||||
[HubAuthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
[HubAuthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||||
|
@@ -10,6 +10,7 @@ and a custom Spawner needs to be able to take three actions:
|
|||||||
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Spawners).
|
Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Spawners).
|
||||||
Some examples include:
|
Some examples include:
|
||||||
|
|
||||||
@@ -174,6 +175,42 @@ When `Spawner.start` is called, this dictionary is accessible as `self.user_opti
|
|||||||
|
|
||||||
If you are interested in building a custom spawner, you can read [this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/spawners.html).
|
If you are interested in building a custom spawner, you can read [this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/spawners.html).
|
||||||
|
|
||||||
|
### Registering custom Spawners via entry points
|
||||||
|
|
||||||
|
As of JupyterHub 1.0, custom Spawners can register themselves via
|
||||||
|
the `jupyterhub.spawners` entry point metadata.
|
||||||
|
To do this, in your `setup.py` add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
setup(
|
||||||
|
...
|
||||||
|
entry_points={
|
||||||
|
'jupyterhub.spawners': [
|
||||||
|
'myservice = mypackage:MySpawner',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have added this metadata to your package,
|
||||||
|
users can select your authenticator with the configuration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.spawner_class = 'myservice'
|
||||||
|
```
|
||||||
|
|
||||||
|
instead of the full
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.spawner_class = 'mypackage:MySpawner'
|
||||||
|
```
|
||||||
|
|
||||||
|
previously required.
|
||||||
|
Additionally, configurable attributes for your spawner will
|
||||||
|
appear in jupyterhub help output and auto-generated configuration files
|
||||||
|
via `jupyterhub --generate-config`.
|
||||||
|
|
||||||
|
|
||||||
## Spawners, resource limits, and guarantees (Optional)
|
## Spawners, resource limits, and guarantees (Optional)
|
||||||
|
|
||||||
Some spawners of the single-user notebook servers allow setting limits or
|
Some spawners of the single-user notebook servers allow setting limits or
|
||||||
@@ -196,7 +233,7 @@ allocate. Attempting to use more memory than this limit will cause errors. The
|
|||||||
single-user notebook server can discover its own memory limit by looking at
|
single-user notebook server can discover its own memory limit by looking at
|
||||||
the environment variable `MEM_LIMIT`, which is specified in absolute bytes.
|
the environment variable `MEM_LIMIT`, which is specified in absolute bytes.
|
||||||
|
|
||||||
`c.Spawner.mem_guarantee`: Sometimes, a **guarantee** of a *minumum amount of
|
`c.Spawner.mem_guarantee`: Sometimes, a **guarantee** of a *minimum amount of
|
||||||
memory* is desirable. In this case, you can set `c.Spawner.mem_guarantee` to
|
memory* is desirable. In this case, you can set `c.Spawner.mem_guarantee` to
|
||||||
to provide a guarantee that at minimum this much memory will always be
|
to provide a guarantee that at minimum this much memory will always be
|
||||||
available for the single-user notebook server to use. The environment variable
|
available for the single-user notebook server to use. The environment variable
|
||||||
|
@@ -75,7 +75,7 @@ the top of all pages. The more specific variables
|
|||||||
`announcement_login`, `announcement_spawn`, `announcement_home`, and
|
`announcement_login`, `announcement_spawn`, `announcement_home`, and
|
||||||
`announcement_logout` are more specific and only show on their
|
`announcement_logout` are more specific and only show on their
|
||||||
respective pages (overriding the global `announcement` variable).
|
respective pages (overriding the global `announcement` variable).
|
||||||
Note that changing these varables require a restart, unlike direct
|
Note that changing these variables require a restart, unlike direct
|
||||||
template extension.
|
template extension.
|
||||||
|
|
||||||
You can get the same effect by extending templates, which allows you
|
You can get the same effect by extending templates, which allows you
|
||||||
|
@@ -1,98 +0,0 @@
|
|||||||
# Upgrading JupyterHub and its database
|
|
||||||
|
|
||||||
From time to time, you may wish to upgrade JupyterHub to take advantage
|
|
||||||
of new releases. Much of this process is automated using scripts,
|
|
||||||
such as those generated by alembic for database upgrades. Whether you
|
|
||||||
are using the default SQLite database or an RDBMS, such as PostgreSQL or
|
|
||||||
MySQL, the process follows similar steps.
|
|
||||||
|
|
||||||
**Before upgrading a JupyterHub deployment**, it's critical to backup your data
|
|
||||||
and configurations before shutting down the JupyterHub process and server.
|
|
||||||
|
|
||||||
## Note about upgrading the SQLite database
|
|
||||||
|
|
||||||
When used in production systems, SQLite has some disadvantages when it
|
|
||||||
comes to upgrading JupyterHub. These are:
|
|
||||||
|
|
||||||
- `upgrade-db` may not work, and you may need to start with a fresh database
|
|
||||||
- `downgrade-db` **will not** work if you want to rollback to an earlier
|
|
||||||
version, so backup the `jupyterhub.sqlite` file before upgrading
|
|
||||||
|
|
||||||
## The upgrade process
|
|
||||||
|
|
||||||
Five fundamental process steps are needed when upgrading JupyterHub and its
|
|
||||||
database:
|
|
||||||
|
|
||||||
1. Backup JupyterHub database
|
|
||||||
2. Backup JupyterHub configuration file
|
|
||||||
3. Shutdown the Hub
|
|
||||||
4. Upgrade JupyterHub
|
|
||||||
5. Upgrade the database using run `jupyterhub upgrade-db`
|
|
||||||
|
|
||||||
Let's take a closer look at each step in the upgrade process as well as some
|
|
||||||
additional information about JupyterHub databases.
|
|
||||||
|
|
||||||
### Backup JupyterHub database
|
|
||||||
|
|
||||||
To prevent unintended loss of data or configuration information, you should
|
|
||||||
back up the JupyterHub database (the default SQLite database or a RDBMS
|
|
||||||
database using PostgreSQL, MySQL, or others supported by SQLAlchemy):
|
|
||||||
|
|
||||||
- If using the default SQLite database, back up the `jupyterhub.sqlite`
|
|
||||||
database.
|
|
||||||
- If using an RDBMS database such as PostgreSQL, MySQL, or other supported by
|
|
||||||
SQLAlchemy, back up the JupyterHub database.
|
|
||||||
|
|
||||||
Losing the Hub database is often not a big deal. Information that resides only
|
|
||||||
in the Hub database includes:
|
|
||||||
|
|
||||||
- active login tokens (user cookies, service tokens)
|
|
||||||
- users added via GitHub UI, instead of config files
|
|
||||||
- info about running servers
|
|
||||||
|
|
||||||
If the following conditions are true, you should be fine clearing the Hub
|
|
||||||
database and starting over:
|
|
||||||
|
|
||||||
- users specified in config file
|
|
||||||
- user servers are stopped during upgrade
|
|
||||||
- don't mind causing users to login again after upgrade
|
|
||||||
|
|
||||||
### Backup JupyterHub configuration file
|
|
||||||
|
|
||||||
Additionally, backing up your configuration file, `jupyterhub_config.py`, to
|
|
||||||
a secure location.
|
|
||||||
|
|
||||||
### Shutdown JupyterHub
|
|
||||||
|
|
||||||
Prior to shutting down JupyterHub, you should notify the Hub users of the
|
|
||||||
scheduled downtime. This gives users the opportunity to finish any outstanding
|
|
||||||
work in process.
|
|
||||||
|
|
||||||
Next, shutdown the JupyterHub service.
|
|
||||||
|
|
||||||
### Upgrade JupyterHub
|
|
||||||
|
|
||||||
Follow directions that correspond to your package manager, `pip` or `conda`,
|
|
||||||
for the new JupyterHub release. These directions will guide you to the
|
|
||||||
specific command. In general, `pip install -U jupyterhub` or
|
|
||||||
`conda upgrade jupyterhub`
|
|
||||||
|
|
||||||
### Upgrade JupyterHub databases
|
|
||||||
|
|
||||||
To run the upgrade process for JupyterHub databases, enter:
|
|
||||||
|
|
||||||
```
|
|
||||||
jupyterhub upgrade-db
|
|
||||||
```
|
|
||||||
|
|
||||||
## Upgrade checklist
|
|
||||||
|
|
||||||
1. Backup JupyterHub database:
|
|
||||||
- `jupyterhub.sqlite` when using the default sqlite database
|
|
||||||
- Your JupyterHub database when using an RDBMS
|
|
||||||
2. Backup JupyterHub configuration file: `jupyterhub_config.py`
|
|
||||||
3. Shutdown the Hub
|
|
||||||
4. Upgrade JupyterHub
|
|
||||||
- `pip install -U jupyterhub` when using `pip`
|
|
||||||
- `conda upgrade jupyterhub` when using `conda`
|
|
||||||
5. Upgrade the database using run `jupyterhub upgrade-db`
|
|
@@ -166,7 +166,7 @@ startup
|
|||||||
statsd
|
statsd
|
||||||
stdin
|
stdin
|
||||||
stdout
|
stdout
|
||||||
stoppped
|
stopped
|
||||||
subclasses
|
subclasses
|
||||||
subcommand
|
subcommand
|
||||||
subdomain
|
subdomain
|
||||||
|
@@ -204,7 +204,7 @@ from there instead of the internet.
|
|||||||
For instance, you can install JupyterHub with pip and configurable-http-proxy
|
For instance, you can install JupyterHub with pip and configurable-http-proxy
|
||||||
with npmbox:
|
with npmbox:
|
||||||
|
|
||||||
pip wheel jupyterhub
|
python3 -m pip wheel jupyterhub
|
||||||
npmbox configurable-http-proxy
|
npmbox configurable-http-proxy
|
||||||
|
|
||||||
### I want access to the whole filesystem, but still default users to their home directory
|
### I want access to the whole filesystem, but still default users to their home directory
|
||||||
@@ -236,7 +236,7 @@ then you can change the default URL to `/lab`.
|
|||||||
|
|
||||||
For instance:
|
For instance:
|
||||||
|
|
||||||
pip install jupyterlab
|
python3 -m pip install jupyterlab
|
||||||
jupyter serverextension enable --py jupyterlab --sys-prefix
|
jupyter serverextension enable --py jupyterlab --sys-prefix
|
||||||
|
|
||||||
The important thing is that jupyterlab is installed and enabled in the
|
The important thing is that jupyterlab is installed and enabled in the
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
Tutorials
|
|
||||||
=========
|
|
||||||
|
|
||||||
This section provides links to documentation that helps a user do a specific
|
|
||||||
task.
|
|
||||||
|
|
||||||
* :doc:`upgrade-dot-eight`
|
|
||||||
* `Zero to JupyterHub with Kubernetes <https://zero-to-jupyterhub.readthedocs.io/en/latest/>`_
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 1
|
|
||||||
:hidden:
|
|
||||||
|
|
||||||
upgrade-dot-eight
|
|
@@ -1,93 +0,0 @@
|
|||||||
.. _upgrade-dot-eight:
|
|
||||||
|
|
||||||
Upgrading to JupyterHub version 0.8
|
|
||||||
===================================
|
|
||||||
|
|
||||||
This document will assist you in upgrading an existing JupyterHub deployment
|
|
||||||
from version 0.7 to version 0.8.
|
|
||||||
|
|
||||||
Upgrade checklist
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
0. Review the release notes. Review any deprecated features and pay attention
|
|
||||||
to any backwards incompatible changes
|
|
||||||
1. Backup JupyterHub database:
|
|
||||||
- ``jupyterhub.sqlite`` when using the default sqlite database
|
|
||||||
- Your JupyterHub database when using an RDBMS
|
|
||||||
2. Backup the existing JupyterHub configuration file: ``jupyterhub_config.py``
|
|
||||||
3. Shutdown the Hub
|
|
||||||
4. Upgrade JupyterHub
|
|
||||||
- ``pip install -U jupyterhub`` when using ``pip``
|
|
||||||
- ``conda upgrade jupyterhub`` when using ``conda``
|
|
||||||
5. Upgrade the database using run ```jupyterhub upgrade-db``
|
|
||||||
6. Update the JupyterHub configuration file ``jupyterhub_config.py``
|
|
||||||
|
|
||||||
Backup JupyterHub database
|
|
||||||
--------------------------
|
|
||||||
|
|
||||||
To prevent unintended loss of data or configuration information, you should
|
|
||||||
back up the JupyterHub database (the default SQLite database or a RDBMS
|
|
||||||
database using PostgreSQL, MySQL, or others supported by SQLAlchemy):
|
|
||||||
|
|
||||||
- If using the default SQLite database, back up the ``jupyterhub.sqlite``
|
|
||||||
database.
|
|
||||||
- If using an RDBMS database such as PostgreSQL, MySQL, or other supported by
|
|
||||||
SQLAlchemy, back up the JupyterHub database.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
Losing the Hub database is often not a big deal. Information that resides only
|
|
||||||
in the Hub database includes:
|
|
||||||
|
|
||||||
- active login tokens (user cookies, service tokens)
|
|
||||||
- users added via GitHub UI, instead of config files
|
|
||||||
- info about running servers
|
|
||||||
|
|
||||||
If the following conditions are true, you should be fine clearing the Hub
|
|
||||||
database and starting over:
|
|
||||||
|
|
||||||
- users specified in config file
|
|
||||||
- user servers are stopped during upgrade
|
|
||||||
- don't mind causing users to login again after upgrade
|
|
||||||
|
|
||||||
Backup JupyterHub configuration file
|
|
||||||
------------------------------------
|
|
||||||
|
|
||||||
Backup up your configuration file, ``jupyterhub_config.py``, to a secure
|
|
||||||
location.
|
|
||||||
|
|
||||||
Shutdown JupyterHub
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
- Prior to shutting down JupyterHub, you should notify the Hub users of the
|
|
||||||
scheduled downtime.
|
|
||||||
- Shutdown the JupyterHub service.
|
|
||||||
|
|
||||||
Upgrade JupyterHub
|
|
||||||
------------------
|
|
||||||
|
|
||||||
Follow directions that correspond to your package manager, ``pip`` or ``conda``,
|
|
||||||
for the new JupyterHub release:
|
|
||||||
|
|
||||||
- ``pip install -U jupyterhub`` for ``pip``
|
|
||||||
- ``conda upgrade jupyterhub`` for ``conda``
|
|
||||||
|
|
||||||
Upgrade the proxy, authenticator, or spawner if needed.
|
|
||||||
|
|
||||||
Upgrade JupyterHub database
|
|
||||||
---------------------------
|
|
||||||
|
|
||||||
To run the upgrade process for JupyterHub databases, enter::
|
|
||||||
|
|
||||||
jupyterhub upgrade-db
|
|
||||||
|
|
||||||
Update the JupyterHub configuration file
|
|
||||||
----------------------------------------
|
|
||||||
|
|
||||||
Create a new JupyterHub configuration file or edit a copy of the existing
|
|
||||||
file ``jupyterhub_config.py``.
|
|
||||||
|
|
||||||
Start JupyterHub
|
|
||||||
----------------
|
|
||||||
|
|
||||||
Start JupyterHub with the same command that you used before the upgrade.
|
|
@@ -59,7 +59,31 @@ def create_dir_hook(spawner):
|
|||||||
c.Spawner.pre_spawn_hook = create_dir_hook
|
c.Spawner.pre_spawn_hook = create_dir_hook
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example #2 - Run a shell script
|
### Example #2 - Run `mkhomedir_helper`
|
||||||
|
|
||||||
|
Many Linux distributions provide a script that is responsible for user homedir bootstrapping: `/sbin/mkhomedir_helper`. To make use of it, you can use
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_dir_hook(spawner):
|
||||||
|
username = spawner.user.name
|
||||||
|
if not os.path.exists(os.path.join('/volumes/jupyterhub', username)):
|
||||||
|
subprocess.call(["sudo", "/sbin/mkhomedir_helper", spawner.user.name])
|
||||||
|
|
||||||
|
# attach the hook function to the spawner
|
||||||
|
c.Spawner.pre_spawn_hook = create_dir_hook
|
||||||
|
```
|
||||||
|
|
||||||
|
and make sure to add
|
||||||
|
|
||||||
|
```
|
||||||
|
jupyterhub ALL = (root) NOPASSWD: /sbin/mkhomedir_helper
|
||||||
|
```
|
||||||
|
|
||||||
|
in a new file in `/etc/sudoers.d`, or simply in `/etc/sudoers`.
|
||||||
|
|
||||||
|
All new home directories will be created from `/etc/skel`, so make sure to place any custom homedir-contents in there.
|
||||||
|
|
||||||
|
### Example #3 - Run a shell script
|
||||||
|
|
||||||
You can specify a plain ole' shell script (or any other executable) to be run
|
You can specify a plain ole' shell script (or any other executable) to be run
|
||||||
by the bootstrap process.
|
by the bootstrap process.
|
||||||
@@ -130,4 +154,4 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
```
|
```
|
||||||
|
@@ -1,9 +1,14 @@
|
|||||||
# Example for a Spawner.pre_spawn_hook
|
"""
|
||||||
# create a directory for the user before the spawner starts
|
Example for a Spawner.pre_spawn_hook
|
||||||
|
create a directory for the user before the spawner starts
|
||||||
|
"""
|
||||||
|
# pylint: disable=import-error
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
from jupyter_client.localinterfaces import public_ips
|
||||||
|
|
||||||
def create_dir_hook(spawner):
|
def create_dir_hook(spawner):
|
||||||
|
""" Create directory """
|
||||||
username = spawner.user.name # get the username
|
username = spawner.user.name # get the username
|
||||||
volume_path = os.path.join('/volumes/jupyterhub', username)
|
volume_path = os.path.join('/volumes/jupyterhub', username)
|
||||||
if not os.path.exists(volume_path):
|
if not os.path.exists(volume_path):
|
||||||
@@ -12,23 +17,24 @@ def create_dir_hook(spawner):
|
|||||||
# ...
|
# ...
|
||||||
|
|
||||||
def clean_dir_hook(spawner):
|
def clean_dir_hook(spawner):
|
||||||
|
""" Delete directory """
|
||||||
username = spawner.user.name # get the username
|
username = spawner.user.name # get the username
|
||||||
temp_path = os.path.join('/volumes/jupyterhub', username, 'temp')
|
temp_path = os.path.join('/volumes/jupyterhub', username, 'temp')
|
||||||
if os.path.exists(temp_path) and os.path.isdir(temp_path):
|
if os.path.exists(temp_path) and os.path.isdir(temp_path):
|
||||||
shutil.rmtree(temp_path)
|
shutil.rmtree(temp_path)
|
||||||
|
|
||||||
# attach the hook functions to the spawner
|
# attach the hook functions to the spawner
|
||||||
|
# pylint: disable=undefined-variable
|
||||||
c.Spawner.pre_spawn_hook = create_dir_hook
|
c.Spawner.pre_spawn_hook = create_dir_hook
|
||||||
c.Spawner.post_stop_hook = clean_dir_hook
|
c.Spawner.post_stop_hook = clean_dir_hook
|
||||||
|
|
||||||
# Use the DockerSpawner to serve your users' notebooks
|
# Use the DockerSpawner to serve your users' notebooks
|
||||||
c.JupyterHub.spawner_class = 'dockerspawner.DockerSpawner'
|
c.JupyterHub.spawner_class = 'dockerspawner.DockerSpawner'
|
||||||
from jupyter_client.localinterfaces import public_ips
|
|
||||||
c.JupyterHub.hub_ip = public_ips()[0]
|
c.JupyterHub.hub_ip = public_ips()[0]
|
||||||
c.DockerSpawner.hub_ip_connect = public_ips()[0]
|
c.DockerSpawner.hub_ip_connect = public_ips()[0]
|
||||||
c.DockerSpawner.container_ip = "0.0.0.0"
|
c.DockerSpawner.container_ip = "0.0.0.0"
|
||||||
|
|
||||||
# You can now mount the volume to the docker container as we've
|
# You can now mount the volume to the docker container as we've
|
||||||
# made sure the directory exists
|
# made sure the directory exists
|
||||||
|
# pylint: disable=bad-whitespace
|
||||||
c.DockerSpawner.volumes = { '/volumes/jupyterhub/{username}/': '/home/jovyan/work' }
|
c.DockerSpawner.volumes = { '/volumes/jupyterhub/{username}/': '/home/jovyan/work' }
|
||||||
|
|
||||||
|
@@ -186,10 +186,16 @@ def cull_idle(url, api_token, inactive_limit, cull_users=False, max_age=0, concu
|
|||||||
log_name, format_td(age), format_td(inactive))
|
log_name, format_td(age), format_td(inactive))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if server_name:
|
||||||
|
# culling a named server
|
||||||
|
delete_url = url + "/users/%s/servers/%s" % (
|
||||||
|
quote(user['name']), quote(server['name'])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
delete_url = url + '/users/%s/server' % quote(user['name'])
|
||||||
|
|
||||||
req = HTTPRequest(
|
req = HTTPRequest(
|
||||||
url=url + '/users/%s/server' % quote(user['name']),
|
url=delete_url, method='DELETE', headers=auth_header,
|
||||||
method='DELETE',
|
|
||||||
headers=auth_header,
|
|
||||||
)
|
)
|
||||||
resp = yield fetch(req)
|
resp = yield fetch(req)
|
||||||
if resp.code == 202:
|
if resp.code == 202:
|
||||||
|
60
examples/service-announcement/README.md
Normal file
60
examples/service-announcement/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
|
||||||
|
# Simple Announcement Service Example
|
||||||
|
|
||||||
|
This is a simple service that allows administrators to manage announcements
|
||||||
|
that appear when JupyterHub renders pages.
|
||||||
|
|
||||||
|
To run the service as a hub-managed service simply include in your JupyterHub
|
||||||
|
configuration file something like:
|
||||||
|
|
||||||
|
c.JupyterHub.services = [
|
||||||
|
{
|
||||||
|
'name': 'announcement',
|
||||||
|
'url': 'http://127.0.0.1:8888',
|
||||||
|
'command': ["python", "-m", "announcement"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
This starts the announcements service up at `/services/announcement` when
|
||||||
|
JupyterHub launches. By default the announcement text is empty.
|
||||||
|
|
||||||
|
The `announcement` module has a configurable port (default 8888) and an API
|
||||||
|
prefix setting. By default the API prefix is `JUPYTERHUB_SERVICE_PREFIX` if
|
||||||
|
that environment variable is set or `/` if it is not.
|
||||||
|
|
||||||
|
## Managing the Announcement
|
||||||
|
|
||||||
|
Admin users can set the announcement text with an API token:
|
||||||
|
|
||||||
|
$ curl -X POST -H "Authorization: token <token>" \
|
||||||
|
-d "{'announcement':'JupyterHub will be upgraded on August 14!'}" \
|
||||||
|
https://.../services/announcement
|
||||||
|
|
||||||
|
Anyone can read the announcement:
|
||||||
|
|
||||||
|
$ curl https://.../services/announcement | python -m json.tool
|
||||||
|
{
|
||||||
|
announcement: "JupyterHub will be upgraded on August 14!",
|
||||||
|
timestamp: "...",
|
||||||
|
user: "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
The time the announcement was posted is recorded in the `timestamp` field and
|
||||||
|
the user who posted the announcement is recorded in the `user` field.
|
||||||
|
|
||||||
|
To clear the announcement text, just DELETE. Only admin users can do this.
|
||||||
|
|
||||||
|
$ curl -X POST -H "Authorization: token <token>" \
|
||||||
|
https://.../services/announcement
|
||||||
|
|
||||||
|
## Seeing the Announcement in JupyterHub
|
||||||
|
|
||||||
|
To be able to render the announcement, include the provide `page.html` template
|
||||||
|
that extends the base `page.html` template. Set `c.JupyterHub.template_paths`
|
||||||
|
in JupyterHub's configuration to include the path to the extending template.
|
||||||
|
The template changes the `announcement` element and does a JQuery `$.get()` call
|
||||||
|
to retrieve the announcement text.
|
||||||
|
|
||||||
|
JupyterHub's configurable announcement template variables can be set for various
|
||||||
|
pages like login, logout, spawn, and home. Including the template provided in
|
||||||
|
this example overrides all of those.
|
73
examples/service-announcement/announcement.py
Normal file
73
examples/service-announcement/announcement.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from jupyterhub.services.auth import HubAuthenticated
|
||||||
|
from tornado import escape, gen, ioloop, web
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementRequestHandler(HubAuthenticated, web.RequestHandler):
|
||||||
|
"""Dynamically manage page announcements"""
|
||||||
|
|
||||||
|
hub_users = []
|
||||||
|
allow_admin = True
|
||||||
|
|
||||||
|
def initialize(self, storage):
|
||||||
|
"""Create storage for announcement text"""
|
||||||
|
self.storage = storage
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def post(self):
|
||||||
|
"""Update announcement"""
|
||||||
|
doc = escape.json_decode(self.request.body)
|
||||||
|
self.storage["announcement"] = doc["announcement"]
|
||||||
|
self.storage["timestamp"] = datetime.datetime.now().isoformat()
|
||||||
|
self.storage["user"] = user["name"]
|
||||||
|
self.write_to_json(self.storage)
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""Retrieve announcement"""
|
||||||
|
self.write_to_json(self.storage)
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def delete(self):
|
||||||
|
"""Clear announcement"""
|
||||||
|
self.storage["announcement"] = ""
|
||||||
|
self.write_to_json(self.storage)
|
||||||
|
|
||||||
|
def write_to_json(self, doc):
|
||||||
|
"""Write dictionary document as JSON"""
|
||||||
|
self.set_header("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
self.write(escape.utf8(json.dumps(doc)))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_arguments()
|
||||||
|
application = create_application(**vars(args))
|
||||||
|
application.listen(args.port)
|
||||||
|
ioloop.IOLoop.current().start()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--api-prefix", "-a",
|
||||||
|
default=os.environ.get("JUPYTERHUB_SERVICE_PREFIX", "/"),
|
||||||
|
help="application API prefix")
|
||||||
|
parser.add_argument("--port", "-p",
|
||||||
|
default=8888,
|
||||||
|
help="port for API to listen on",
|
||||||
|
type=int)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def create_application(api_prefix="/",
|
||||||
|
handler=AnnouncementRequestHandler,
|
||||||
|
**kwargs):
|
||||||
|
storage = dict(announcement="", timestamp="", user="")
|
||||||
|
return web.Application([(api_prefix, handler, dict(storage=storage))])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
15
examples/service-announcement/jupyterhub_config.py
Normal file
15
examples/service-announcement/jupyterhub_config.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
# To run the announcement service managed by the hub, add this.
|
||||||
|
|
||||||
|
c.JupyterHub.services = [
|
||||||
|
{
|
||||||
|
'name': 'announcement',
|
||||||
|
'url': 'http://127.0.0.1:8888',
|
||||||
|
'command': ["python", "-m", "announcement"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# The announcements need to get on the templates somehow, see page.html
|
||||||
|
# for an example of how to do this.
|
||||||
|
|
||||||
|
c.JupyterHub.template_paths = ["templates"]
|
14
examples/service-announcement/templates/page.html
Normal file
14
examples/service-announcement/templates/page.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "templates/page.html" %}
|
||||||
|
{% block announcement %}
|
||||||
|
<div class="container text-center announcement">
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
$.get("/services/announcement/", function(data) {
|
||||||
|
$(".announcement").html(data["announcement"]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
@@ -11,12 +11,16 @@ function get_hub_version() {
|
|||||||
hub_xyz=$(cat hub_version)
|
hub_xyz=$(cat hub_version)
|
||||||
split=( ${hub_xyz//./ } )
|
split=( ${hub_xyz//./ } )
|
||||||
hub_xy="${split[0]}.${split[1]}"
|
hub_xy="${split[0]}.${split[1]}"
|
||||||
|
# add .dev on hub_xy so it's 1.0.dev
|
||||||
|
if [[ ! -z "${split[3]}" ]]; then
|
||||||
|
hub_xy="${hub_xy}.${split[3]}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
get_hub_version
|
get_hub_version
|
||||||
|
|
||||||
# when building master, push 0.9.0 as well
|
# when building master, push 0.9.0.dev as well
|
||||||
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xyz
|
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xyz
|
||||||
docker push $DOCKER_REPO:$hub_xyz
|
docker push $DOCKER_REPO:$hub_xyz
|
||||||
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:$hub_xyz
|
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:$hub_xyz
|
||||||
|
@@ -7,7 +7,7 @@ version_info = (
|
|||||||
1,
|
1,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
"", # release (b1, rc1, or "" for final)
|
"", # release (b1, rc1, or "" for final or dev)
|
||||||
"dev", # dev or nothing
|
"dev", # dev or nothing
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -5,14 +5,20 @@
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
from urllib.parse import quote
|
from urllib.parse import (
|
||||||
|
parse_qsl,
|
||||||
|
quote,
|
||||||
|
urlencode,
|
||||||
|
urlparse,
|
||||||
|
urlunparse,
|
||||||
|
)
|
||||||
|
|
||||||
from oauth2.web.tornado import OAuth2Handler
|
from oauthlib import oauth2
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..user import User
|
from ..user import User
|
||||||
from ..utils import token_authenticated
|
from ..utils import token_authenticated, compare_token
|
||||||
from .base import BaseHandler, APIHandler
|
from .base import BaseHandler, APIHandler
|
||||||
|
|
||||||
|
|
||||||
@@ -46,7 +52,7 @@ class TokenAPIHandler(APIHandler):
|
|||||||
" Use /hub/api/users/:user/tokens instead."
|
" Use /hub/api/users/:user/tokens instead."
|
||||||
) % self.request.uri
|
) % self.request.uri
|
||||||
self.log.warning(warn_msg)
|
self.log.warning(warn_msg)
|
||||||
requester = user = self.get_current_user()
|
requester = user = self.current_user
|
||||||
if user is None:
|
if user is None:
|
||||||
# allow requesting a token with username and password
|
# allow requesting a token with username and password
|
||||||
# for authenticators where that's possible
|
# for authenticators where that's possible
|
||||||
@@ -98,24 +104,190 @@ class CookieAPIHandler(APIHandler):
|
|||||||
self.write(json.dumps(self.user_model(user)))
|
self.write(json.dumps(self.user_model(user)))
|
||||||
|
|
||||||
|
|
||||||
class OAuthHandler(BaseHandler, OAuth2Handler):
|
class OAuthHandler:
|
||||||
"""Implement OAuth provider handlers
|
def extract_oauth_params(self):
|
||||||
|
"""extract oauthlib params from a request
|
||||||
|
|
||||||
OAuth2Handler sets `self.provider` in initialize,
|
Returns:
|
||||||
but we are already passing the Provider object via settings.
|
|
||||||
"""
|
|
||||||
@property
|
|
||||||
def provider(self):
|
|
||||||
return self.settings['oauth_provider']
|
|
||||||
|
|
||||||
def initialize(self):
|
(uri, http_method, body, headers)
|
||||||
pass
|
"""
|
||||||
|
return (
|
||||||
|
self.request.uri,
|
||||||
|
self.request.method,
|
||||||
|
self.request.body,
|
||||||
|
self.request.headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_absolute_redirect_uri(self, uri):
|
||||||
|
"""Make absolute redirect URIs
|
||||||
|
|
||||||
|
internal redirect uris, e.g. `/user/foo/oauth_handler`
|
||||||
|
are allowed in jupyterhub, but oauthlib prohibits them.
|
||||||
|
Add `$HOST` header to redirect_uri to make them acceptable.
|
||||||
|
|
||||||
|
Currently unused in favor of monkeypatching
|
||||||
|
oauthlib.is_absolute_uri to skip the check
|
||||||
|
"""
|
||||||
|
redirect_uri = self.get_argument('redirect_uri')
|
||||||
|
if not redirect_uri or not redirect_uri.startswith('/'):
|
||||||
|
return uri
|
||||||
|
# make absolute local redirects full URLs
|
||||||
|
# to satisfy oauthlib's absolute URI requirement
|
||||||
|
redirect_uri = self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri
|
||||||
|
parsed_url = urlparse(uri)
|
||||||
|
query_list = parse_qsl(parsed_url.query, keep_blank_values=True)
|
||||||
|
for idx, item in enumerate(query_list):
|
||||||
|
if item[0] == 'redirect_uri':
|
||||||
|
query_list[idx] = ('redirect_uri', redirect_uri)
|
||||||
|
break
|
||||||
|
|
||||||
|
return urlunparse(
|
||||||
|
urlparse(uri)
|
||||||
|
._replace(query=urlencode(query_list))
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_credentials(self, credentials=None):
|
||||||
|
"""Add oauth credentials
|
||||||
|
|
||||||
|
Adds user, session_id, client to oauth credentials
|
||||||
|
"""
|
||||||
|
if credentials is None:
|
||||||
|
credentials = {}
|
||||||
|
else:
|
||||||
|
credentials = credentials.copy()
|
||||||
|
|
||||||
|
session_id = self.get_session_cookie()
|
||||||
|
if session_id is None:
|
||||||
|
session_id = self.set_session_cookie()
|
||||||
|
|
||||||
|
user = self.current_user
|
||||||
|
|
||||||
|
# Extra credentials we need in the validator
|
||||||
|
credentials.update({
|
||||||
|
'user': user,
|
||||||
|
'handler': self,
|
||||||
|
'session_id': session_id,
|
||||||
|
})
|
||||||
|
return credentials
|
||||||
|
|
||||||
|
def send_oauth_response(self, headers, body, status):
|
||||||
|
"""Send oauth response from provider return values
|
||||||
|
|
||||||
|
Provider methods return headers, body, and status
|
||||||
|
to be set on the response.
|
||||||
|
|
||||||
|
This method applies these values to the Handler
|
||||||
|
and sends the response.
|
||||||
|
"""
|
||||||
|
self.set_status(status)
|
||||||
|
for key, value in headers.items():
|
||||||
|
self.set_header(key, value)
|
||||||
|
if body:
|
||||||
|
self.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||||
|
"""Implement OAuth authorization endpoint(s)"""
|
||||||
|
|
||||||
|
def _complete_login(self, uri, headers, scopes, credentials):
|
||||||
|
try:
|
||||||
|
headers, body, status = self.oauth_provider.create_authorization_response(
|
||||||
|
uri, 'POST', '', headers, scopes, credentials)
|
||||||
|
|
||||||
|
except oauth2.FatalClientError as e:
|
||||||
|
# TODO: human error page
|
||||||
|
raise
|
||||||
|
self.send_oauth_response(headers, body, status)
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def get(self):
|
||||||
|
"""GET /oauth/authorization
|
||||||
|
|
||||||
|
Render oauth confirmation page:
|
||||||
|
"Server at ... would like permission to ...".
|
||||||
|
|
||||||
|
Users accessing their own server will skip confirmation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
uri, http_method, body, headers = self.extract_oauth_params()
|
||||||
|
try:
|
||||||
|
scopes, credentials = self.oauth_provider.validate_authorization_request(
|
||||||
|
uri, http_method, body, headers)
|
||||||
|
credentials = self.add_credentials(credentials)
|
||||||
|
client = self.oauth_provider.fetch_by_client_id(credentials['client_id'])
|
||||||
|
if client.redirect_uri.startswith(self.current_user.url):
|
||||||
|
self.log.debug(
|
||||||
|
"Skipping oauth confirmation for %s accessing %s",
|
||||||
|
self.current_user, client.description,
|
||||||
|
)
|
||||||
|
# access to my own server doesn't require oauth confirmation
|
||||||
|
# this is the pre-1.0 behavior for all oauth
|
||||||
|
self._complete_login(uri, headers, scopes, credentials)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Render oauth 'Authorize application...' page
|
||||||
|
self.write(
|
||||||
|
self.render_template(
|
||||||
|
"oauth.html",
|
||||||
|
scopes=scopes,
|
||||||
|
oauth_client=client,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Errors that should be shown to the user on the provider website
|
||||||
|
except oauth2.FatalClientError as e:
|
||||||
|
raise web.HTTPError(e.status_code, e.description)
|
||||||
|
|
||||||
|
# Errors embedded in the redirect URI back to the client
|
||||||
|
except oauth2.OAuth2Error as e:
|
||||||
|
self.log.error("OAuth error: %s", e.description)
|
||||||
|
self.redirect(e.in_uri(e.redirect_uri))
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def post(self):
|
||||||
|
uri, http_method, body, headers = self.extract_oauth_params()
|
||||||
|
referer = self.request.headers.get('Referer', 'no referer')
|
||||||
|
full_url = self.request.full_url()
|
||||||
|
if referer != full_url:
|
||||||
|
# OAuth post must be made to the URL it came from
|
||||||
|
self.log.error("OAuth POST from %s != %s", referer, full_url)
|
||||||
|
raise web.HTTPError(403, "Authorization form must be sent from authorization page")
|
||||||
|
|
||||||
|
# The scopes the user actually authorized, i.e. checkboxes
|
||||||
|
# that were selected.
|
||||||
|
scopes = self.get_arguments('scopes')
|
||||||
|
# credentials we need in the validator
|
||||||
|
credentials = self.add_credentials()
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers, body, status = self.oauth_provider.create_authorization_response(
|
||||||
|
uri, http_method, body, headers, scopes, credentials,
|
||||||
|
)
|
||||||
|
except oauth2.FatalClientError as e:
|
||||||
|
raise web.HTTPError(e.status_code, e.description)
|
||||||
|
else:
|
||||||
|
self.send_oauth_response(headers, body, status)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthTokenHandler(OAuthHandler, APIHandler):
|
||||||
|
def post(self):
|
||||||
|
uri, http_method, body, headers = self.extract_oauth_params()
|
||||||
|
credentials = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers, body, status = self.oauth_provider.create_token_response(
|
||||||
|
uri, http_method, body, headers, credentials)
|
||||||
|
except oauth2.FatalClientError as e:
|
||||||
|
raise web.HTTPError(e.status_code, e.description)
|
||||||
|
else:
|
||||||
|
self.send_oauth_response(headers, body, status)
|
||||||
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
||||||
(r"/api/authorizations/token", TokenAPIHandler),
|
(r"/api/authorizations/token", TokenAPIHandler),
|
||||||
(r"/api/oauth2/authorize", OAuthHandler),
|
(r"/api/oauth2/authorize", OAuthAuthorizeHandler),
|
||||||
(r"/api/oauth2/token", OAuthHandler),
|
(r"/api/oauth2/token", OAuthTokenHandler),
|
||||||
]
|
]
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
@@ -13,12 +14,25 @@ from .. import orm
|
|||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
from ..utils import isoformat, url_path_join
|
from ..utils import isoformat, url_path_join
|
||||||
|
|
||||||
|
|
||||||
class APIHandler(BaseHandler):
|
class APIHandler(BaseHandler):
|
||||||
|
"""Base class for API endpoints
|
||||||
|
|
||||||
|
Differences from page handlers:
|
||||||
|
|
||||||
|
- JSON responses and errors
|
||||||
|
- strict referer checking for Cookie-authenticated requests
|
||||||
|
- strict content-security-policy
|
||||||
|
- methods for REST API models
|
||||||
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def content_security_policy(self):
|
def content_security_policy(self):
|
||||||
return '; '.join([super().content_security_policy, "default-src 'none'"])
|
return '; '.join([super().content_security_policy, "default-src 'none'"])
|
||||||
|
|
||||||
|
def get_content_type(self):
|
||||||
|
return 'application/json'
|
||||||
|
|
||||||
def check_referer(self):
|
def check_referer(self):
|
||||||
"""Check Origin for cross-site API requests.
|
"""Check Origin for cross-site API requests.
|
||||||
|
|
||||||
@@ -156,6 +170,7 @@ class APIHandler(BaseHandler):
|
|||||||
'kind': kind,
|
'kind': kind,
|
||||||
'created': isoformat(token.created),
|
'created': isoformat(token.created),
|
||||||
'last_activity': isoformat(token.last_activity),
|
'last_activity': isoformat(token.last_activity),
|
||||||
|
'expires_at': isoformat(expires_at),
|
||||||
}
|
}
|
||||||
model.update(extra)
|
model.update(extra)
|
||||||
return model
|
return model
|
||||||
@@ -253,3 +268,13 @@ class APIHandler(BaseHandler):
|
|||||||
|
|
||||||
def options(self, *args, **kwargs):
|
def options(self, *args, **kwargs):
|
||||||
self.finish()
|
self.finish()
|
||||||
|
|
||||||
|
|
||||||
|
class API404(APIHandler):
|
||||||
|
"""404 for API requests
|
||||||
|
|
||||||
|
Ensures JSON 404 errors for malformed URLs
|
||||||
|
"""
|
||||||
|
async def prepare(self):
|
||||||
|
await super().prepare()
|
||||||
|
raise web.HTTPError(404)
|
||||||
|
@@ -36,7 +36,7 @@ class ServiceListAPIHandler(APIHandler):
|
|||||||
def admin_or_self(method):
|
def admin_or_self(method):
|
||||||
"""Decorator for restricting access to either the target service or admin"""
|
"""Decorator for restricting access to either the target service or admin"""
|
||||||
def decorated_method(self, name):
|
def decorated_method(self, name):
|
||||||
current = self.get_current_user()
|
current = self.current_user
|
||||||
if current is None:
|
if current is None:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
if not current.admin:
|
if not current.admin:
|
||||||
|
@@ -24,7 +24,7 @@ class SelfAPIHandler(APIHandler):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
user = self.get_current_user()
|
user = self.current_user
|
||||||
if user is None:
|
if user is None:
|
||||||
# whoami can be accessed via oauth token
|
# whoami can be accessed via oauth token
|
||||||
user = self.get_current_user_oauth_token()
|
user = self.get_current_user_oauth_token()
|
||||||
@@ -99,7 +99,7 @@ class UserListAPIHandler(APIHandler):
|
|||||||
def admin_or_self(method):
|
def admin_or_self(method):
|
||||||
"""Decorator for restricting access to either the target user or admin"""
|
"""Decorator for restricting access to either the target user or admin"""
|
||||||
def m(self, name, *args, **kwargs):
|
def m(self, name, *args, **kwargs):
|
||||||
current = self.get_current_user()
|
current = self.current_user
|
||||||
if current is None:
|
if current is None:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
if not (current.name == name or current.admin):
|
if not (current.name == name or current.admin):
|
||||||
@@ -117,13 +117,13 @@ class UserAPIHandler(APIHandler):
|
|||||||
@admin_or_self
|
@admin_or_self
|
||||||
async def get(self, name):
|
async def get(self, name):
|
||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
model = self.user_model(user, include_servers=True, include_state=self.get_current_user().admin)
|
model = self.user_model(user, include_servers=True, include_state=self.current_user.admin)
|
||||||
# auth state will only be shown if the requestor is an admin
|
# auth state will only be shown if the requester is an admin
|
||||||
# this means users can't see their own auth state unless they
|
# this means users can't see their own auth state unless they
|
||||||
# are admins, Hub admins often are also marked as admins so they
|
# are admins, Hub admins often are also marked as admins so they
|
||||||
# will see their auth state but normal users won't
|
# will see their auth state but normal users won't
|
||||||
requestor = self.get_current_user()
|
requester = self.current_user
|
||||||
if requestor.admin:
|
if requester.admin:
|
||||||
model['auth_state'] = await user.get_auth_state()
|
model['auth_state'] = await user.get_auth_state()
|
||||||
self.write(json.dumps(model))
|
self.write(json.dumps(model))
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ class UserAPIHandler(APIHandler):
|
|||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
if user.name == self.get_current_user().name:
|
if user.name == self.current_user.name:
|
||||||
raise web.HTTPError(400, "Cannot delete yourself!")
|
raise web.HTTPError(400, "Cannot delete yourself!")
|
||||||
if user.spawner._stop_pending:
|
if user.spawner._stop_pending:
|
||||||
raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name)
|
raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name)
|
||||||
@@ -237,7 +237,7 @@ class UserTokenListAPIHandler(APIHandler):
|
|||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
raise web.HTTPError(400, "Body must be a JSON dict or empty")
|
raise web.HTTPError(400, "Body must be a JSON dict or empty")
|
||||||
|
|
||||||
requester = self.get_current_user()
|
requester = self.current_user
|
||||||
if requester is None:
|
if requester is None:
|
||||||
# defer to Authenticator for identifying the user
|
# defer to Authenticator for identifying the user
|
||||||
# can be username+password or an upstream auth token
|
# can be username+password or an upstream auth token
|
||||||
@@ -378,29 +378,52 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
@admin_or_self
|
@admin_or_self
|
||||||
async def delete(self, name, server_name=''):
|
async def delete(self, name, server_name=''):
|
||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
|
options = self.get_json_body()
|
||||||
|
remove = (options or {}).get('remove', False)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_spawner(f=None):
|
||||||
|
if f and f.exception():
|
||||||
|
return
|
||||||
|
self.log.info("Deleting spawner %s", spawner._log_name)
|
||||||
|
self.db.delete(spawner.orm_spawner)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
if server_name:
|
if server_name:
|
||||||
if not self.allow_named_servers:
|
if not self.allow_named_servers:
|
||||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||||
if server_name not in user.spawners:
|
if server_name not in user.orm_spawners:
|
||||||
raise web.HTTPError(404, "%s has no server named '%s'" % (name, server_name))
|
raise web.HTTPError(404, "%s has no server named '%s'" % (name, server_name))
|
||||||
|
elif remove:
|
||||||
|
raise web.HTTPError(400, "Cannot delete the default server")
|
||||||
|
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.spawners[server_name]
|
||||||
if spawner.pending == 'stop':
|
if spawner.pending == 'stop':
|
||||||
self.log.debug("%s already stopping", spawner._log_name)
|
self.log.debug("%s already stopping", spawner._log_name)
|
||||||
self.set_header('Content-Type', 'text/plain')
|
self.set_header('Content-Type', 'text/plain')
|
||||||
self.set_status(202)
|
self.set_status(202)
|
||||||
|
if remove:
|
||||||
|
spawner._stop_future.add_done_callback(_remove_spawner)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not spawner.ready:
|
if spawner.pending:
|
||||||
raise web.HTTPError(
|
raise web.HTTPError(
|
||||||
400, "%s is not running %s" %
|
400, "%s is pending %s, please wait" % (spawner._log_name, spawner.pending)
|
||||||
(spawner._log_name, '(pending: %s)' % spawner.pending if spawner.pending else '')
|
|
||||||
)
|
)
|
||||||
# include notify, so that a server that died is noticed immediately
|
|
||||||
status = await spawner.poll_and_notify()
|
stop_future = None
|
||||||
if status is not None:
|
if spawner.ready:
|
||||||
raise web.HTTPError(400, "%s is not running" % spawner._log_name)
|
# include notify, so that a server that died is noticed immediately
|
||||||
await self.stop_single_user(user, server_name)
|
status = await spawner.poll_and_notify()
|
||||||
|
if status is None:
|
||||||
|
stop_future = await self.stop_single_user(user, server_name)
|
||||||
|
|
||||||
|
if remove:
|
||||||
|
if stop_future:
|
||||||
|
stop_future.add_done_callback(_remove_spawner)
|
||||||
|
else:
|
||||||
|
_remove_spawner()
|
||||||
|
|
||||||
status = 202 if spawner._stop_pending else 204
|
status = 202 if spawner._stop_pending else 204
|
||||||
self.set_header('Content-Type', 'text/plain')
|
self.set_header('Content-Type', 'text/plain')
|
||||||
self.set_status(status)
|
self.set_status(status)
|
||||||
@@ -415,7 +438,7 @@ class UserAdminAccessAPIHandler(APIHandler):
|
|||||||
def post(self, name):
|
def post(self, name):
|
||||||
self.log.warning("Deprecated in JupyterHub 0.8."
|
self.log.warning("Deprecated in JupyterHub 0.8."
|
||||||
" Admin access API is not needed now that we use OAuth.")
|
" Admin access API is not needed now that we use OAuth.")
|
||||||
current = self.get_current_user()
|
current = self.current_user
|
||||||
self.log.warning("Admin user %s has requested access to %s's server",
|
self.log.warning("Admin user %s has requested access to %s's server",
|
||||||
current.name, name,
|
current.name, name,
|
||||||
)
|
)
|
||||||
@@ -428,6 +451,9 @@ class UserAdminAccessAPIHandler(APIHandler):
|
|||||||
|
|
||||||
class SpawnProgressAPIHandler(APIHandler):
|
class SpawnProgressAPIHandler(APIHandler):
|
||||||
"""EventStream handler for pending spawns"""
|
"""EventStream handler for pending spawns"""
|
||||||
|
|
||||||
|
keepalive_interval = 8
|
||||||
|
|
||||||
def get_content_type(self):
|
def get_content_type(self):
|
||||||
return 'text/event-stream'
|
return 'text/event-stream'
|
||||||
|
|
||||||
@@ -440,6 +466,31 @@ class SpawnProgressAPIHandler(APIHandler):
|
|||||||
# raise Finish to halt the handler
|
# raise Finish to halt the handler
|
||||||
raise web.Finish()
|
raise web.Finish()
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
super().initialize()
|
||||||
|
self._finish_future = asyncio.Future()
|
||||||
|
|
||||||
|
def on_finish(self):
|
||||||
|
self._finish_future.set_result(None)
|
||||||
|
|
||||||
|
async def keepalive(self):
|
||||||
|
"""Write empty lines periodically
|
||||||
|
|
||||||
|
to avoid being closed by intermediate proxies
|
||||||
|
when there's a large gap between events.
|
||||||
|
"""
|
||||||
|
while not self._finish_future.done():
|
||||||
|
try:
|
||||||
|
self.write("\n\n")
|
||||||
|
await self.flush()
|
||||||
|
except (StreamClosedError, RuntimeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.wait(
|
||||||
|
[self._finish_future],
|
||||||
|
timeout=self.keepalive_interval,
|
||||||
|
)
|
||||||
|
|
||||||
@admin_or_self
|
@admin_or_self
|
||||||
async def get(self, username, server_name=''):
|
async def get(self, username, server_name=''):
|
||||||
self.set_header('Cache-Control', 'no-cache')
|
self.set_header('Cache-Control', 'no-cache')
|
||||||
@@ -453,6 +504,9 @@ class SpawnProgressAPIHandler(APIHandler):
|
|||||||
# user has no such server
|
# user has no such server
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.spawners[server_name]
|
||||||
|
|
||||||
|
# start sending keepalive to avoid proxies closing the connection
|
||||||
|
asyncio.ensure_future(self.keepalive())
|
||||||
# cases:
|
# cases:
|
||||||
# - spawner already started and ready
|
# - spawner already started and ready
|
||||||
# - spawner not running at all
|
# - spawner not running at all
|
||||||
|
@@ -41,7 +41,7 @@ from traitlets import (
|
|||||||
Tuple, Type, Set, Instance, Bytes, Float,
|
Tuple, Type, Set, Instance, Bytes, Float,
|
||||||
observe, default,
|
observe, default,
|
||||||
)
|
)
|
||||||
from traitlets.config import Application, catch_config_error
|
from traitlets.config import Application, Configurable, catch_config_error
|
||||||
|
|
||||||
here = os.path.dirname(__file__)
|
here = os.path.dirname(__file__)
|
||||||
|
|
||||||
@@ -53,11 +53,11 @@ from .services.service import Service
|
|||||||
from . import crypto
|
from . import crypto
|
||||||
from . import dbutil, orm
|
from . import dbutil, orm
|
||||||
from .user import UserDict
|
from .user import UserDict
|
||||||
from .oauth.store import make_provider
|
from .oauth.provider 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 .proxy import Proxy, ConfigurableHTTPProxy
|
||||||
from .traitlets import URLPrefix, Command
|
from .traitlets import URLPrefix, Command, EntryPointType
|
||||||
from .utils import (
|
from .utils import (
|
||||||
maybe_future,
|
maybe_future,
|
||||||
url_path_join,
|
url_path_join,
|
||||||
@@ -229,13 +229,19 @@ class JupyterHub(Application):
|
|||||||
'upgrade-db': (UpgradeDB, "Upgrade your JupyterHub state database to the current version."),
|
'upgrade-db': (UpgradeDB, "Upgrade your JupyterHub state database to the current version."),
|
||||||
}
|
}
|
||||||
|
|
||||||
classes = List([
|
classes = List()
|
||||||
Spawner,
|
@default('classes')
|
||||||
LocalProcessSpawner,
|
def _load_classes(self):
|
||||||
Authenticator,
|
classes = [Spawner, Authenticator, CryptKeeper]
|
||||||
PAMAuthenticator,
|
for name, trait in self.traits(config=True).items():
|
||||||
CryptKeeper,
|
# load entry point groups into configurable class list
|
||||||
])
|
# so that they show up in config files, etc.
|
||||||
|
if isinstance(trait, EntryPointType):
|
||||||
|
for key, entry_point in trait.load_entry_points().items():
|
||||||
|
cls = entry_point.load()
|
||||||
|
if cls not in classes and isinstance(cls, Configurable):
|
||||||
|
classes.append(cls)
|
||||||
|
return classes
|
||||||
|
|
||||||
load_groups = Dict(List(Unicode()),
|
load_groups = Dict(List(Unicode()),
|
||||||
help="""Dict of 'group': ['usernames'] to load at startup.
|
help="""Dict of 'group': ['usernames'] to load at startup.
|
||||||
@@ -750,20 +756,25 @@ class JupyterHub(Application):
|
|||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
_service_map = Dict()
|
_service_map = Dict()
|
||||||
|
|
||||||
authenticator_class = Type(PAMAuthenticator, Authenticator,
|
authenticator_class = EntryPointType(
|
||||||
|
default_value=PAMAuthenticator,
|
||||||
|
klass=Authenticator,
|
||||||
|
entry_point_group="jupyterhub.authenticators",
|
||||||
help="""Class for authenticating users.
|
help="""Class for authenticating users.
|
||||||
|
|
||||||
This should be a class with the following form:
|
This should be a subclass of :class:`jupyterhub.auth.Authenticator`
|
||||||
|
|
||||||
- constructor takes one kwarg: `config`, the IPython config object.
|
with an :meth:`authenticate` method that:
|
||||||
|
|
||||||
with an authenticate method that:
|
|
||||||
|
|
||||||
- is a coroutine (asyncio or tornado)
|
- is a coroutine (asyncio or tornado)
|
||||||
- returns username on success, None on failure
|
- returns username on success, None on failure
|
||||||
- takes two arguments: (handler, data),
|
- takes two arguments: (handler, data),
|
||||||
where `handler` is the calling web.RequestHandler,
|
where `handler` is the calling web.RequestHandler,
|
||||||
and `data` is the POST form data from the login page.
|
and `data` is the POST form data from the login page.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.0
|
||||||
|
authenticators may be registered via entry points,
|
||||||
|
e.g. `c.JupyterHub.authenticator_class = 'pam'`
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@@ -778,10 +789,17 @@ class JupyterHub(Application):
|
|||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
# class for spawning single-user servers
|
# class for spawning single-user servers
|
||||||
spawner_class = Type(LocalProcessSpawner, Spawner,
|
spawner_class = EntryPointType(
|
||||||
|
default_value=LocalProcessSpawner,
|
||||||
|
klass=Spawner,
|
||||||
|
entry_point_group="jupyterhub.spawners",
|
||||||
help="""The class to use for spawning single-user servers.
|
help="""The class to use for spawning single-user servers.
|
||||||
|
|
||||||
Should be a subclass of Spawner.
|
Should be a subclass of :class:`jupyterhub.spawner.Spawner`.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.0
|
||||||
|
spawners may be registered via entry points,
|
||||||
|
e.g. `c.JupyterHub.spawner_class = 'localprocess'`
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@@ -1072,6 +1090,8 @@ class JupyterHub(Application):
|
|||||||
h.extend(self.extra_handlers)
|
h.extend(self.extra_handlers)
|
||||||
|
|
||||||
h.append((r'/logo', LogoHandler, {'path': self.logo_file}))
|
h.append((r'/logo', LogoHandler, {'path': self.logo_file}))
|
||||||
|
h.append((r'/api/(.*)', apihandlers.base.API404))
|
||||||
|
|
||||||
self.handlers = self.add_url_prefix(self.hub_prefix, h)
|
self.handlers = self.add_url_prefix(self.hub_prefix, h)
|
||||||
# some extra handlers, outside hub_prefix
|
# some extra handlers, outside hub_prefix
|
||||||
self.handlers.extend([
|
self.handlers.extend([
|
||||||
@@ -1519,7 +1539,7 @@ class JupyterHub(Application):
|
|||||||
host = '%s://services.%s' % (parsed.scheme, parsed.netloc)
|
host = '%s://services.%s' % (parsed.scheme, parsed.netloc)
|
||||||
else:
|
else:
|
||||||
domain = host = ''
|
domain = host = ''
|
||||||
client_store = self.oauth_provider.client_authenticator.client_store
|
|
||||||
for spec in self.services:
|
for spec in self.services:
|
||||||
if 'name' not in spec:
|
if 'name' not in spec:
|
||||||
raise ValueError('service spec must have a name: %r' % spec)
|
raise ValueError('service spec must have a name: %r' % spec)
|
||||||
@@ -1578,7 +1598,7 @@ class JupyterHub(Application):
|
|||||||
service.orm.server = None
|
service.orm.server = None
|
||||||
|
|
||||||
if service.oauth_available:
|
if service.oauth_available:
|
||||||
client_store.add_client(
|
self.oauth_provider.add_client(
|
||||||
client_id=service.oauth_client_id,
|
client_id=service.oauth_client_id,
|
||||||
client_secret=service.api_token,
|
client_secret=service.api_token,
|
||||||
redirect_uri=service.oauth_redirect_uri,
|
redirect_uri=service.oauth_redirect_uri,
|
||||||
@@ -1678,9 +1698,9 @@ class JupyterHub(Application):
|
|||||||
def init_oauth(self):
|
def init_oauth(self):
|
||||||
base_url = self.hub.base_url
|
base_url = self.hub.base_url
|
||||||
self.oauth_provider = make_provider(
|
self.oauth_provider = make_provider(
|
||||||
lambda : self.db,
|
lambda: self.db,
|
||||||
url_prefix=url_path_join(base_url, 'api/oauth2'),
|
url_prefix=url_path_join(base_url, 'api/oauth2'),
|
||||||
login_url=url_path_join(base_url, 'login')
|
login_url=url_path_join(base_url, 'login'),
|
||||||
)
|
)
|
||||||
|
|
||||||
def cleanup_oauth_clients(self):
|
def cleanup_oauth_clients(self):
|
||||||
@@ -1695,8 +1715,11 @@ class JupyterHub(Application):
|
|||||||
for user in self.users.values():
|
for user in self.users.values():
|
||||||
for spawner in user.spawners.values():
|
for spawner in user.spawners.values():
|
||||||
oauth_client_ids.add(spawner.oauth_client_id)
|
oauth_client_ids.add(spawner.oauth_client_id)
|
||||||
|
# avoid deleting clients created by 0.8
|
||||||
|
# 0.9 uses `jupyterhub-user-...` for the client id, while
|
||||||
|
# 0.8 uses just `user-...`
|
||||||
|
oauth_client_ids.add(spawner.oauth_client_id.split('-', 1)[1])
|
||||||
|
|
||||||
client_store = self.oauth_provider.client_authenticator.client_store
|
|
||||||
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
|
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
|
||||||
if oauth_client.identifier not in oauth_client_ids:
|
if oauth_client.identifier not in oauth_client_ids:
|
||||||
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
|
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
|
||||||
@@ -2144,8 +2167,7 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
def sigterm(self, signum, frame):
|
def sigterm(self, signum, frame):
|
||||||
self.log.critical("Received SIGTERM, shutting down")
|
self.log.critical("Received SIGTERM, shutting down")
|
||||||
self.io_loop.stop()
|
raise SystemExit(128 + signum)
|
||||||
self.atexit()
|
|
||||||
|
|
||||||
_atexit_ran = False
|
_atexit_ran = False
|
||||||
|
|
||||||
@@ -2155,6 +2177,7 @@ class JupyterHub(Application):
|
|||||||
return
|
return
|
||||||
self._atexit_ran = True
|
self._atexit_ran = True
|
||||||
# run the cleanup step (in a new loop, because the interrupted one is unclean)
|
# run the cleanup step (in a new loop, because the interrupted one is unclean)
|
||||||
|
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||||
IOLoop.clear_current()
|
IOLoop.clear_current()
|
||||||
loop = IOLoop()
|
loop = IOLoop()
|
||||||
loop.make_current()
|
loop.make_current()
|
||||||
|
@@ -287,10 +287,40 @@ class Authenticator(LoggingConfigurable):
|
|||||||
self.log.warning("User %r not in whitelist.", username)
|
self.log.warning("User %r not in whitelist.", username)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
async def refresh_user(self, user):
|
||||||
|
"""Refresh auth data for a given user
|
||||||
|
|
||||||
|
Allows refreshing or invalidating auth data.
|
||||||
|
|
||||||
|
Only override if your authenticator needs
|
||||||
|
to refresh its data about users once in a while.
|
||||||
|
|
||||||
|
.. versionadded: 1.0
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user (User): the user to refresh
|
||||||
|
Returns:
|
||||||
|
auth_data (bool or dict):
|
||||||
|
Return **True** if auth data for the user is up-to-date
|
||||||
|
and no updates are required.
|
||||||
|
|
||||||
|
Return **False** if the user's auth data has expired,
|
||||||
|
and they should be required to login again.
|
||||||
|
|
||||||
|
Return a **dict** of auth data if some values should be updated.
|
||||||
|
This dict should have the same structure as that returned
|
||||||
|
by :meth:`.authenticate()` when it returns a dict.
|
||||||
|
Any fields present will refresh the value for the user.
|
||||||
|
Any fields not present will be left unchanged.
|
||||||
|
This can include updating `.admin` or `.auth_state` fields.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
async def authenticate(self, handler, data):
|
async def authenticate(self, handler, data):
|
||||||
"""Authenticate a user with login form data
|
"""Authenticate a user with login form data
|
||||||
|
|
||||||
This must be a tornado gen.coroutine.
|
This must be a coroutine.
|
||||||
|
|
||||||
It must return the username on successful authentication,
|
It must return the username on successful authentication,
|
||||||
and return None on failed authentication.
|
and return None on failed authentication.
|
||||||
|
|
||||||
@@ -304,12 +334,14 @@ class Authenticator(LoggingConfigurable):
|
|||||||
data (dict): The formdata of the login form.
|
data (dict): The formdata of the login form.
|
||||||
The default form has 'username' and 'password' fields.
|
The default form has 'username' and 'password' fields.
|
||||||
Returns:
|
Returns:
|
||||||
user (str or dict or None): The username of the authenticated user,
|
user (str or dict or None):
|
||||||
|
The username of the authenticated user,
|
||||||
or None if Authentication failed.
|
or None if Authentication failed.
|
||||||
|
|
||||||
The Authenticator may return a dict instead, which MUST have a
|
The Authenticator may return a dict instead, which MUST have a
|
||||||
key 'name' holding the username, and may have two optional keys
|
key `name` holding the username, and MAY have two optional keys
|
||||||
set - 'auth_state', a dictionary of of auth state that will be
|
set: `auth_state`, a dictionary of of auth state that will be
|
||||||
persisted; and 'admin', the admin setting value for the user.
|
persisted; and `admin`, the admin setting value for the user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def pre_spawn_start(self, user, spawner):
|
def pre_spawn_start(self, user, spawner):
|
||||||
@@ -654,3 +686,31 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
self.log.warning("Failed to close PAM session for %s: %s", user.name, e)
|
self.log.warning("Failed to close PAM session for %s: %s", user.name, e)
|
||||||
self.log.warning("Disabling PAM sessions from now on.")
|
self.log.warning("Disabling PAM sessions from now on.")
|
||||||
self.open_sessions = False
|
self.open_sessions = False
|
||||||
|
|
||||||
|
|
||||||
|
class DummyAuthenticator(Authenticator):
|
||||||
|
"""Dummy Authenticator for testing
|
||||||
|
|
||||||
|
By default, any username + password is allowed
|
||||||
|
If a non-empty password is set, any username will be allowed
|
||||||
|
if it logs in with that password.
|
||||||
|
|
||||||
|
.. versionadded:: 1.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
password = Unicode(
|
||||||
|
config=True,
|
||||||
|
help="""
|
||||||
|
Set a global password for all users wanting to log in.
|
||||||
|
|
||||||
|
This allows users with any username to log in with the same static password.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
async def authenticate(self, handler, data):
|
||||||
|
"""Checks against a global password if it's been set. If not, allow any user/pass combo"""
|
||||||
|
if self.password:
|
||||||
|
if data['password'] == self.password:
|
||||||
|
return data['username']
|
||||||
|
return None
|
||||||
|
return data['username']
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
# 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 asyncio
|
||||||
import copy
|
import copy
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from http.client import responses
|
from http.client import responses
|
||||||
@@ -20,7 +21,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.httputil import url_concat, HTTPHeaders
|
from tornado.httputil import url_concat, HTTPHeaders
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
from tornado.web import RequestHandler
|
from tornado.web import RequestHandler, MissingArgumentError
|
||||||
from tornado import gen, web
|
from tornado import gen, web
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
@@ -31,6 +32,7 @@ from ..utils import maybe_future, url_path_join
|
|||||||
from ..metrics import (
|
from ..metrics import (
|
||||||
SERVER_SPAWN_DURATION_SECONDS, ServerSpawnStatus,
|
SERVER_SPAWN_DURATION_SECONDS, ServerSpawnStatus,
|
||||||
PROXY_ADD_DURATION_SECONDS, ProxyAddStatus,
|
PROXY_ADD_DURATION_SECONDS, ProxyAddStatus,
|
||||||
|
RUNNING_SERVERS
|
||||||
)
|
)
|
||||||
|
|
||||||
# pattern for the authentication token header
|
# pattern for the authentication token header
|
||||||
@@ -51,6 +53,26 @@ SESSION_COOKIE_NAME = 'jupyterhub-session-id'
|
|||||||
class BaseHandler(RequestHandler):
|
class BaseHandler(RequestHandler):
|
||||||
"""Base Handler class with access to common methods and properties."""
|
"""Base Handler class with access to common methods and properties."""
|
||||||
|
|
||||||
|
async def prepare(self):
|
||||||
|
"""Identify the user during the prepare stage of each request
|
||||||
|
|
||||||
|
`.prepare()` runs prior to all handler methods,
|
||||||
|
e.g. `.get()`, `.post()`.
|
||||||
|
|
||||||
|
Checking here allows `.get_current_user` to be async without requiring
|
||||||
|
every current user check to be made async.
|
||||||
|
|
||||||
|
The current user (None if not logged in) may be accessed
|
||||||
|
via the `self.current_user` property during the handling of any request.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.get_current_user()
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Failed to get current user")
|
||||||
|
self._jupyterhub_user = None
|
||||||
|
|
||||||
|
return await maybe_future(super().prepare())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def log(self):
|
def log(self):
|
||||||
"""I can't seem to avoid typing self.log"""
|
"""I can't seem to avoid typing self.log"""
|
||||||
@@ -209,6 +231,55 @@ class BaseHandler(RequestHandler):
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return self._user_from_orm(orm_token.user)
|
return self._user_from_orm(orm_token.user)
|
||||||
|
|
||||||
|
async def refresh_user_auth(self, user, force=False):
|
||||||
|
"""Refresh user authentication info
|
||||||
|
|
||||||
|
Calls `authenticator.refresh_user(user)`
|
||||||
|
|
||||||
|
Called at most once per user per request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user (User): the user whose auth info is to be refreshed
|
||||||
|
force (bool): force a refresh instead of checking last refresh time
|
||||||
|
Returns:
|
||||||
|
user (User): the user having been refreshed,
|
||||||
|
or None if the user must login again to refresh auth info.
|
||||||
|
"""
|
||||||
|
if not force: # TODO: and it's sufficiently recent
|
||||||
|
return user
|
||||||
|
|
||||||
|
# refresh a user at most once per request
|
||||||
|
if not hasattr(self, '_refreshed_users'):
|
||||||
|
self._refreshed_users = set()
|
||||||
|
if user.name in self._refreshed_users:
|
||||||
|
# already refreshed during this request
|
||||||
|
return user
|
||||||
|
self._refreshed_users.add(user.name)
|
||||||
|
|
||||||
|
self.log.debug("Refreshing auth for %s", user.name)
|
||||||
|
auth_info = await self.authenticator.refresh_user(user)
|
||||||
|
|
||||||
|
if not auth_info:
|
||||||
|
self.log.warning(
|
||||||
|
"User %s has stale auth info. Login is required to refresh.",
|
||||||
|
user.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if auth_info == True:
|
||||||
|
# refresh_user confirmed that it's up-to-date,
|
||||||
|
# nothing to refresh
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Ensure name field is set. It cannot be updated.
|
||||||
|
auth_info['name'] = user.name
|
||||||
|
|
||||||
|
if 'auth_state' not in auth_info:
|
||||||
|
# refresh didn't specify auth_state,
|
||||||
|
# so preserve previous value to avoid clearing it
|
||||||
|
auth_info['auth_state'] = await user.get_auth_state()
|
||||||
|
return await self.auth_to_user(auth_info, user)
|
||||||
|
|
||||||
def get_current_user_token(self):
|
def get_current_user_token(self):
|
||||||
"""get_current_user from Authorization header token"""
|
"""get_current_user from Authorization header token"""
|
||||||
token = self.get_auth_token()
|
token = self.get_auth_token()
|
||||||
@@ -217,15 +288,18 @@ class BaseHandler(RequestHandler):
|
|||||||
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
|
||||||
else:
|
|
||||||
# record token activity
|
|
||||||
now = datetime.utcnow()
|
|
||||||
orm_token.last_activity = now
|
|
||||||
if orm_token.user:
|
|
||||||
orm_token.user.last_activity = now
|
|
||||||
|
|
||||||
self.db.commit()
|
# record token activity
|
||||||
return orm_token.service or self._user_from_orm(orm_token.user)
|
now = datetime.utcnow()
|
||||||
|
orm_token.last_activity = now
|
||||||
|
if orm_token.user:
|
||||||
|
orm_token.user.last_activity = now
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
if orm_token.service:
|
||||||
|
return orm_token.service
|
||||||
|
|
||||||
|
return self._user_from_orm(orm_token.user)
|
||||||
|
|
||||||
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
||||||
"""Get the User for a given cookie, if there is one"""
|
"""Get the User for a given cookie, if there is one"""
|
||||||
@@ -265,18 +339,30 @@ class BaseHandler(RequestHandler):
|
|||||||
"""get_current_user from a cookie token"""
|
"""get_current_user from a cookie token"""
|
||||||
return self._user_for_cookie(self.hub.cookie_name)
|
return self._user_for_cookie(self.hub.cookie_name)
|
||||||
|
|
||||||
def get_current_user(self):
|
async def get_current_user(self):
|
||||||
"""get current username"""
|
"""get current username"""
|
||||||
if not hasattr(self, '_jupyterhub_user'):
|
if not hasattr(self, '_jupyterhub_user'):
|
||||||
try:
|
try:
|
||||||
user = self.get_current_user_token()
|
user = self.get_current_user_token()
|
||||||
if user is None:
|
if user is None:
|
||||||
user = self.get_current_user_cookie()
|
user = self.get_current_user_cookie()
|
||||||
|
if user:
|
||||||
|
user = await self.refresh_user_auth(user)
|
||||||
self._jupyterhub_user = user
|
self._jupyterhub_user = user
|
||||||
except Exception:
|
except Exception:
|
||||||
# don't let errors here raise more than once
|
# don't let errors here raise more than once
|
||||||
self._jupyterhub_user = None
|
self._jupyterhub_user = None
|
||||||
raise
|
self.log.exception("Error getting current user")
|
||||||
|
return self._jupyterhub_user
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_user(self):
|
||||||
|
"""Override .current_user accessor from tornado
|
||||||
|
|
||||||
|
Allows .get_current_user to be async.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, '_jupyterhub_user'):
|
||||||
|
raise RuntimeError("Must call async get_current_user first!")
|
||||||
return self._jupyterhub_user
|
return self._jupyterhub_user
|
||||||
|
|
||||||
def find_user(self, name):
|
def find_user(self, name):
|
||||||
@@ -324,7 +410,6 @@ class BaseHandler(RequestHandler):
|
|||||||
self.log.debug("Deleted %s access tokens for %s", count, user.name)
|
self.log.debug("Deleted %s access tokens for %s", count, user.name)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
|
|
||||||
# clear hub cookie
|
# clear hub cookie
|
||||||
self.clear_cookie(self.hub.cookie_name, path=self.hub.base_url, **kwargs)
|
self.clear_cookie(self.hub.cookie_name, path=self.hub.base_url, **kwargs)
|
||||||
# clear services cookie
|
# clear services cookie
|
||||||
@@ -467,6 +552,43 @@ class BaseHandler(RequestHandler):
|
|||||||
next_url = url_path_join(self.hub.base_url, 'home')
|
next_url = url_path_join(self.hub.base_url, 'home')
|
||||||
return next_url
|
return next_url
|
||||||
|
|
||||||
|
async def auth_to_user(self, authenticated, user=None):
|
||||||
|
"""Persist data from .authenticate() or .refresh_user() to the User database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authenticated(dict): return data from .authenticate or .refresh_user
|
||||||
|
user(User, optional): the User object to refresh, if refreshing
|
||||||
|
Return:
|
||||||
|
user(User): the constructed User object
|
||||||
|
"""
|
||||||
|
if isinstance(authenticated, str):
|
||||||
|
authenticated = {'name': authenticated}
|
||||||
|
username = authenticated['name']
|
||||||
|
auth_state = authenticated.get('auth_state')
|
||||||
|
admin = authenticated.get('admin')
|
||||||
|
refreshing = user is not None
|
||||||
|
|
||||||
|
if user and username != user.name:
|
||||||
|
raise ValueError("Username doesn't match! %s != %s" % (username, user.name))
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
new_user = username not in self.users
|
||||||
|
user = self.user_from_username(username)
|
||||||
|
if new_user:
|
||||||
|
await maybe_future(self.authenticator.add_user(user))
|
||||||
|
# Only set `admin` if the authenticator returned an explicit value.
|
||||||
|
if admin is not None and admin != user.admin:
|
||||||
|
user.admin = admin
|
||||||
|
self.db.commit()
|
||||||
|
# always set auth_state and commit,
|
||||||
|
# because there could be key-rotation or clearing of previous values
|
||||||
|
# going on.
|
||||||
|
if not self.authenticator.enable_auth_state:
|
||||||
|
# auth_state is not enabled. Force None.
|
||||||
|
auth_state = None
|
||||||
|
await user.save_auth_state(auth_state)
|
||||||
|
return user
|
||||||
|
|
||||||
async def login_user(self, data=None):
|
async def login_user(self, data=None):
|
||||||
"""Login a user"""
|
"""Login a user"""
|
||||||
auth_timer = self.statsd.timer('login.authenticate').start()
|
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||||
@@ -474,29 +596,11 @@ class BaseHandler(RequestHandler):
|
|||||||
auth_timer.stop(send=False)
|
auth_timer.stop(send=False)
|
||||||
|
|
||||||
if authenticated:
|
if authenticated:
|
||||||
username = authenticated['name']
|
user = await self.auth_to_user(authenticated)
|
||||||
auth_state = authenticated.get('auth_state')
|
|
||||||
admin = authenticated.get('admin')
|
|
||||||
new_user = username not in self.users
|
|
||||||
user = self.user_from_username(username)
|
|
||||||
if new_user:
|
|
||||||
await maybe_future(self.authenticator.add_user(user))
|
|
||||||
# Only set `admin` if the authenticator returned an explicit value.
|
|
||||||
if admin is not None and admin != user.admin:
|
|
||||||
user.admin = admin
|
|
||||||
self.db.commit()
|
|
||||||
# always set auth_state and commit,
|
|
||||||
# because there could be key-rotation or clearing of previous values
|
|
||||||
# going on.
|
|
||||||
if not self.authenticator.enable_auth_state:
|
|
||||||
# auth_state is not enabled. Force None.
|
|
||||||
auth_state = None
|
|
||||||
await user.save_auth_state(auth_state)
|
|
||||||
self.db.commit()
|
|
||||||
self.set_login_cookie(user)
|
self.set_login_cookie(user)
|
||||||
self.statsd.incr('login.success')
|
self.statsd.incr('login.success')
|
||||||
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
||||||
self.log.info("User logged in: %s", username)
|
self.log.info("User logged in: %s", user.name)
|
||||||
return user
|
return user
|
||||||
else:
|
else:
|
||||||
self.statsd.incr('login.failure')
|
self.statsd.incr('login.failure')
|
||||||
@@ -602,7 +706,7 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
self.log.debug("Initiating spawn for %s", user_server_name)
|
self.log.debug("Initiating spawn for %s", user_server_name)
|
||||||
|
|
||||||
spawn_future = user.spawn(server_name, options)
|
spawn_future = user.spawn(server_name, options, handler=self)
|
||||||
|
|
||||||
self.log.debug("%i%s concurrent spawns",
|
self.log.debug("%i%s concurrent spawns",
|
||||||
spawn_pending_count,
|
spawn_pending_count,
|
||||||
@@ -627,6 +731,7 @@ class BaseHandler(RequestHandler):
|
|||||||
toc = IOLoop.current().time()
|
toc = IOLoop.current().time()
|
||||||
self.log.info("User %s took %.3f seconds to start", user_server_name, toc-tic)
|
self.log.info("User %s took %.3f seconds to start", user_server_name, toc-tic)
|
||||||
self.statsd.timing('spawner.success', (toc - tic) * 1000)
|
self.statsd.timing('spawner.success', (toc - tic) * 1000)
|
||||||
|
RUNNING_SERVERS.inc()
|
||||||
SERVER_SPAWN_DURATION_SECONDS.labels(
|
SERVER_SPAWN_DURATION_SECONDS.labels(
|
||||||
status=ServerSpawnStatus.success
|
status=ServerSpawnStatus.success
|
||||||
).observe(time.perf_counter() - spawn_start_time)
|
).observe(time.perf_counter() - spawn_start_time)
|
||||||
@@ -657,6 +762,7 @@ class BaseHandler(RequestHandler):
|
|||||||
# hook up spawner._spawn_future so that other requests can await
|
# hook up spawner._spawn_future so that other requests can await
|
||||||
# this result
|
# this result
|
||||||
finish_spawn_future = spawner._spawn_future = maybe_future(finish_user_spawn())
|
finish_spawn_future = spawner._spawn_future = maybe_future(finish_user_spawn())
|
||||||
|
|
||||||
def _clear_spawn_future(f):
|
def _clear_spawn_future(f):
|
||||||
# clear spawner._spawn_future when it's done
|
# clear spawner._spawn_future when it's done
|
||||||
# keep an exception around, though, to prevent repeated implicit spawns
|
# keep an exception around, though, to prevent repeated implicit spawns
|
||||||
@@ -665,10 +771,44 @@ class BaseHandler(RequestHandler):
|
|||||||
spawner._spawn_future = None
|
spawner._spawn_future = None
|
||||||
# Now we're all done. clear _spawn_pending flag
|
# Now we're all done. clear _spawn_pending flag
|
||||||
spawner._spawn_pending = False
|
spawner._spawn_pending = False
|
||||||
|
|
||||||
finish_spawn_future.add_done_callback(_clear_spawn_future)
|
finish_spawn_future.add_done_callback(_clear_spawn_future)
|
||||||
|
|
||||||
|
# when spawn finishes (success or failure)
|
||||||
|
# update failure count and abort if consecutive failure limit
|
||||||
|
# is reached
|
||||||
|
def _track_failure_count(f):
|
||||||
|
if f.exception() is None:
|
||||||
|
# spawn succeeded, reset failure count
|
||||||
|
self.settings['failure_count'] = 0
|
||||||
|
return
|
||||||
|
# spawn failed, increment count and abort if limit reached
|
||||||
|
self.settings.setdefault('failure_count', 0)
|
||||||
|
self.settings['failure_count'] += 1
|
||||||
|
failure_count = self.settings['failure_count']
|
||||||
|
failure_limit = spawner.consecutive_failure_limit
|
||||||
|
if failure_limit and 1 < failure_count < failure_limit:
|
||||||
|
self.log.warning(
|
||||||
|
"%i consecutive spawns failed. "
|
||||||
|
"Hub will exit if failure count reaches %i before succeeding",
|
||||||
|
failure_count, failure_limit,
|
||||||
|
)
|
||||||
|
if failure_limit and failure_count >= failure_limit:
|
||||||
|
self.log.critical(
|
||||||
|
"Aborting due to %i consecutive spawn failures", failure_count
|
||||||
|
)
|
||||||
|
# abort in 2 seconds to allow pending handlers to resolve
|
||||||
|
# mostly propagating errors for the current failures
|
||||||
|
def abort():
|
||||||
|
raise SystemExit(1)
|
||||||
|
IOLoop.current().call_later(2, abort)
|
||||||
|
|
||||||
|
finish_spawn_future.add_done_callback(_track_failure_count)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future)
|
await gen.with_timeout(
|
||||||
|
timedelta(seconds=self.slow_spawn_timeout), finish_spawn_future
|
||||||
|
)
|
||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
# waiting_for_response indicates server process has started,
|
# waiting_for_response indicates server process has started,
|
||||||
# but is yet to become responsive.
|
# but is yet to become responsive.
|
||||||
@@ -717,10 +857,10 @@ class BaseHandler(RequestHandler):
|
|||||||
await self.proxy.delete_user(user, server_name)
|
await self.proxy.delete_user(user, server_name)
|
||||||
await user.stop(server_name)
|
await user.stop(server_name)
|
||||||
|
|
||||||
async def stop_single_user(self, user, name=''):
|
async def stop_single_user(self, user, server_name=''):
|
||||||
if name not in user.spawners:
|
if server_name not in user.spawners:
|
||||||
raise KeyError("User %s has no such spawner %r", user.name, name)
|
raise KeyError("User %s has no such spawner %r", user.name, server_name)
|
||||||
spawner = user.spawners[name]
|
spawner = user.spawners[server_name]
|
||||||
if spawner.pending:
|
if spawner.pending:
|
||||||
raise RuntimeError("%s pending %s" % (spawner._log_name, spawner.pending))
|
raise RuntimeError("%s pending %s" % (spawner._log_name, spawner.pending))
|
||||||
# set user._stop_pending before doing anything async
|
# set user._stop_pending before doing anything async
|
||||||
@@ -736,19 +876,27 @@ class BaseHandler(RequestHandler):
|
|||||||
"""
|
"""
|
||||||
tic = IOLoop.current().time()
|
tic = IOLoop.current().time()
|
||||||
try:
|
try:
|
||||||
await self.proxy.delete_user(user, name)
|
await self.proxy.delete_user(user, server_name)
|
||||||
await user.stop(name)
|
await user.stop(server_name)
|
||||||
finally:
|
finally:
|
||||||
|
spawner._stop_future = None
|
||||||
spawner._stop_pending = False
|
spawner._stop_pending = False
|
||||||
|
|
||||||
toc = IOLoop.current().time()
|
toc = IOLoop.current().time()
|
||||||
self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic)
|
self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic)
|
||||||
self.statsd.timing('spawner.stop', (toc - tic) * 1000)
|
self.statsd.timing('spawner.stop', (toc - tic) * 1000)
|
||||||
|
RUNNING_SERVERS.dec()
|
||||||
|
|
||||||
|
future = spawner._stop_future = asyncio.ensure_future(stop())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), stop())
|
await gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), future)
|
||||||
except gen.TimeoutError:
|
except gen.TimeoutError:
|
||||||
# hit timeout, but stop is still pending
|
# hit timeout, but stop is still pending
|
||||||
self.log.warning("User %s:%s server is slow to stop", user.name, name)
|
self.log.warning("User %s:%s server is slow to stop", user.name, server_name)
|
||||||
|
|
||||||
|
# return handle on the future for hooking up callbacks
|
||||||
|
return future
|
||||||
|
|
||||||
#---------------------------------------------------------------
|
#---------------------------------------------------------------
|
||||||
# template rendering
|
# template rendering
|
||||||
@@ -780,7 +928,7 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def template_namespace(self):
|
def template_namespace(self):
|
||||||
user = self.get_current_user()
|
user = self.current_user
|
||||||
ns = dict(
|
ns = dict(
|
||||||
base_url=self.hub.base_url,
|
base_url=self.hub.base_url,
|
||||||
prefix=self.base_url,
|
prefix=self.base_url,
|
||||||
@@ -855,7 +1003,8 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
class Template404(BaseHandler):
|
class Template404(BaseHandler):
|
||||||
"""Render our 404 template"""
|
"""Render our 404 template"""
|
||||||
def prepare(self):
|
async def prepare(self):
|
||||||
|
await super().prepare()
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
|
|
||||||
|
|
||||||
@@ -866,6 +1015,11 @@ class PrefixRedirectHandler(BaseHandler):
|
|||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
uri = self.request.uri
|
uri = self.request.uri
|
||||||
|
# Since self.base_url will end with trailing slash.
|
||||||
|
# Ensure uri will end with trailing slash when matching
|
||||||
|
# with self.base_url.
|
||||||
|
if not uri.endswith('/'):
|
||||||
|
uri += '/'
|
||||||
if uri.startswith(self.base_url):
|
if uri.startswith(self.base_url):
|
||||||
path = self.request.uri[len(self.base_url):]
|
path = self.request.uri[len(self.base_url):]
|
||||||
else:
|
else:
|
||||||
@@ -876,7 +1030,7 @@ class PrefixRedirectHandler(BaseHandler):
|
|||||||
|
|
||||||
|
|
||||||
class UserSpawnHandler(BaseHandler):
|
class UserSpawnHandler(BaseHandler):
|
||||||
"""Redirect requests to /user/name/* handled by the Hub.
|
"""Redirect requests to /user/user_name/* handled by the Hub.
|
||||||
|
|
||||||
If logged in, spawn a single-user server and redirect request.
|
If logged in, spawn a single-user server and redirect request.
|
||||||
If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb,
|
If a user, alice, requests /user/bob/notebooks/mynotebook.ipynb,
|
||||||
@@ -892,21 +1046,21 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
self.write(json.dumps({"message": "%s is not running" % user.name}))
|
self.write(json.dumps({"message": "%s is not running" % user.name}))
|
||||||
self.finish()
|
self.finish()
|
||||||
|
|
||||||
async def get(self, name, user_path):
|
async def get(self, user_name, user_path):
|
||||||
if not user_path:
|
if not user_path:
|
||||||
user_path = '/'
|
user_path = '/'
|
||||||
current_user = self.get_current_user()
|
current_user = self.current_user
|
||||||
if (
|
if (
|
||||||
current_user
|
current_user
|
||||||
and current_user.name != name
|
and current_user.name != user_name
|
||||||
and current_user.admin
|
and current_user.admin
|
||||||
and self.settings.get('admin_access', False)
|
and self.settings.get('admin_access', False)
|
||||||
):
|
):
|
||||||
# allow admins to spawn on behalf of users
|
# allow admins to spawn on behalf of users
|
||||||
user = self.find_user(name)
|
user = self.find_user(user_name)
|
||||||
if user is None:
|
if user is None:
|
||||||
# no such user
|
# no such user
|
||||||
raise web.HTTPError(404, "No such user %s" % name)
|
raise web.HTTPError(404, "No such user %s" % user_name)
|
||||||
self.log.info("Admin %s requesting spawn on behalf of %s",
|
self.log.info("Admin %s requesting spawn on behalf of %s",
|
||||||
current_user.name, user.name)
|
current_user.name, user.name)
|
||||||
admin_spawn = True
|
admin_spawn = True
|
||||||
@@ -916,7 +1070,7 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
admin_spawn = False
|
admin_spawn = False
|
||||||
# For non-admins, we should spawn if the user matches
|
# For non-admins, we should spawn if the user matches
|
||||||
# otherwise redirect users to their own server
|
# otherwise redirect users to their own server
|
||||||
should_spawn = (current_user and current_user.name == name)
|
should_spawn = (current_user and current_user.name == user_name)
|
||||||
|
|
||||||
if "api" in user_path.split("/") and not user.active:
|
if "api" in user_path.split("/") and not user.active:
|
||||||
# API request for not-running server (e.g. notebook UI left open)
|
# API request for not-running server (e.g. notebook UI left open)
|
||||||
@@ -928,7 +1082,7 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
# if spawning fails for any reason, point users to /hub/home to retry
|
# if spawning fails for any reason, point users to /hub/home to retry
|
||||||
self.extra_error_html = self.spawn_home_error
|
self.extra_error_html = self.spawn_home_error
|
||||||
|
|
||||||
# If people visit /user/:name directly on the Hub,
|
# If people visit /user/:user_name directly on the Hub,
|
||||||
# the redirects will just loop, because the proxy is bypassed.
|
# the redirects will just loop, because the proxy is bypassed.
|
||||||
# Try to check for that and warn,
|
# Try to check for that and warn,
|
||||||
# though the user-facing behavior is unchanged
|
# though the user-facing behavior is unchanged
|
||||||
@@ -944,7 +1098,11 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
""", self.request.full_url(), self.proxy.public_url)
|
""", self.request.full_url(), self.proxy.public_url)
|
||||||
|
|
||||||
# logged in as valid user, check for pending spawn
|
# logged in as valid user, check for pending spawn
|
||||||
spawner = user.spawner
|
if self.allow_named_servers:
|
||||||
|
server_name = self.get_argument('server', '')
|
||||||
|
else:
|
||||||
|
server_name = ''
|
||||||
|
spawner = user.spawners[server_name]
|
||||||
|
|
||||||
# First, check for previous failure.
|
# First, check for previous failure.
|
||||||
if (
|
if (
|
||||||
@@ -1009,7 +1167,7 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
{'next': self.request.uri}))
|
{'next': self.request.uri}))
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await self.spawn_single_user(user)
|
await self.spawn_single_user(user, server_name)
|
||||||
|
|
||||||
# spawn didn't finish, show pending page
|
# spawn didn't finish, show pending page
|
||||||
if spawner.pending:
|
if spawner.pending:
|
||||||
@@ -1065,7 +1223,7 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
url_parts = urlparse(target)
|
url_parts = urlparse(target)
|
||||||
query_parts = parse_qs(url_parts.query)
|
query_parts = parse_qs(url_parts.query)
|
||||||
query_parts['redirects'] = redirects + 1
|
query_parts['redirects'] = redirects + 1
|
||||||
url_parts = url_parts._replace(query=urlencode(query_parts))
|
url_parts = url_parts._replace(query=urlencode(query_parts, doseq=True))
|
||||||
target = urlunparse(url_parts)
|
target = urlunparse(url_parts)
|
||||||
else:
|
else:
|
||||||
target = url_concat(target, {'redirects': 1})
|
target = url_concat(target, {'redirects': 1})
|
||||||
@@ -1103,7 +1261,7 @@ class UserRedirectHandler(BaseHandler):
|
|||||||
"""
|
"""
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
def get(self, path):
|
def get(self, path):
|
||||||
user = self.get_current_user()
|
user = self.current_user
|
||||||
url = url_path_join(user.url, path)
|
url = url_path_join(user.url, path)
|
||||||
if self.request.query:
|
if self.request.query:
|
||||||
# FIXME: use urlunparse instead?
|
# FIXME: use urlunparse instead?
|
||||||
@@ -1133,7 +1291,7 @@ class AddSlashHandler(BaseHandler):
|
|||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r'', AddSlashHandler), # add trailing / to `/hub`
|
(r'', AddSlashHandler), # add trailing / to `/hub`
|
||||||
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
(r'/user/(?P<user_name>[^/]+)(?P<user_path>/.*)?', UserSpawnHandler),
|
||||||
(r'/user-redirect/(.*)?', UserRedirectHandler),
|
(r'/user-redirect/(.*)?', UserRedirectHandler),
|
||||||
(r'/security/csp-report', CSPReportHandler),
|
(r'/security/csp-report', CSPReportHandler),
|
||||||
]
|
]
|
||||||
|
@@ -14,7 +14,7 @@ from .base import BaseHandler
|
|||||||
class LogoutHandler(BaseHandler):
|
class LogoutHandler(BaseHandler):
|
||||||
"""Log a user out by clearing their login cookie."""
|
"""Log a user out by clearing their login cookie."""
|
||||||
def get(self):
|
def get(self):
|
||||||
user = self.get_current_user()
|
user = self.current_user
|
||||||
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()
|
||||||
@@ -44,11 +44,11 @@ class LoginHandler(BaseHandler):
|
|||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
self.statsd.incr('login.request')
|
self.statsd.incr('login.request')
|
||||||
user = self.get_current_user()
|
user = self.current_user
|
||||||
if user:
|
if user:
|
||||||
# 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(user)
|
||||||
self.redirect(self.get_next_url(user), permanent=False)
|
self.redirect(self.get_next_url(user), permanent=False)
|
||||||
else:
|
else:
|
||||||
if self.authenticator.auto_login:
|
if self.authenticator.auto_login:
|
||||||
@@ -83,7 +83,7 @@ class LoginHandler(BaseHandler):
|
|||||||
|
|
||||||
if user:
|
if user:
|
||||||
# register current user for subsequent requests to user (e.g. logging the request)
|
# register current user for subsequent requests to user (e.g. logging the request)
|
||||||
self.get_current_user = lambda: user
|
self._jupyterhub_user = user
|
||||||
self.redirect(self.get_next_url(user))
|
self.redirect(self.get_next_url(user))
|
||||||
else:
|
else:
|
||||||
html = self._render(
|
html = self._render(
|
||||||
|
@@ -30,7 +30,7 @@ class RootHandler(BaseHandler):
|
|||||||
Otherwise, renders login page.
|
Otherwise, renders login page.
|
||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
user = self.get_current_user()
|
user = self.current_user
|
||||||
if self.default_url:
|
if self.default_url:
|
||||||
url = self.default_url
|
url = self.default_url
|
||||||
elif user:
|
elif user:
|
||||||
@@ -45,18 +45,23 @@ class HomeHandler(BaseHandler):
|
|||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
async def get(self):
|
async def get(self):
|
||||||
user = self.get_current_user()
|
user = self.current_user
|
||||||
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
|
||||||
await user.spawner.poll_and_notify()
|
await user.spawner.poll_and_notify()
|
||||||
|
|
||||||
# send the user to /spawn if they aren't running or pending a spawn,
|
# send the user to /spawn if they have no active servers,
|
||||||
# to establish that this is an explicit spawn request rather
|
# to establish that this is an explicit spawn request rather
|
||||||
# than an implicit one, which can be caused by any link to `/user/:name`
|
# than an implicit one, which can be caused by any link to `/user/:name(/:server_name)`
|
||||||
url = user.url if user.spawner.active else url_path_join(self.hub.base_url, 'spawn')
|
url = url_path_join(self.hub.base_url, 'user', user.name) if user.active else url_path_join(self.hub.base_url, 'spawn')
|
||||||
html = self.render_template('home.html',
|
html = self.render_template('home.html',
|
||||||
user=user,
|
user=user,
|
||||||
url=url,
|
url=url,
|
||||||
|
allow_named_servers=self.allow_named_servers,
|
||||||
|
url_path_join=url_path_join,
|
||||||
|
# can't use user.spawners because the stop method of User pops named servers from user.spawners when they're stopped
|
||||||
|
spawners = user.orm_user._orm_spawners,
|
||||||
|
default_server = user.spawner,
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
@@ -87,7 +92,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
|
|
||||||
or triggers spawn via redirect if there is no form.
|
or triggers spawn via redirect if there is no form.
|
||||||
"""
|
"""
|
||||||
user = current_user = self.get_current_user()
|
user = current_user = self.current_user
|
||||||
if for_user is not None and for_user != user.name:
|
if for_user is not None and for_user != user.name:
|
||||||
if not user.admin:
|
if not user.admin:
|
||||||
raise web.HTTPError(403, "Only admins can spawn on behalf of other users")
|
raise web.HTTPError(403, "Only admins can spawn on behalf of other users")
|
||||||
@@ -111,12 +116,16 @@ class SpawnHandler(BaseHandler):
|
|||||||
if user.spawner._spawn_future and user.spawner._spawn_future.done():
|
if user.spawner._spawn_future and user.spawner._spawn_future.done():
|
||||||
user.spawner._spawn_future = None
|
user.spawner._spawn_future = None
|
||||||
# not running, no form. Trigger spawn by redirecting to /user/:name
|
# not running, no form. Trigger spawn by redirecting to /user/:name
|
||||||
self.redirect(user.url)
|
url = user.url
|
||||||
|
if self.request.query:
|
||||||
|
# add query params
|
||||||
|
url += '?' + self.request.query
|
||||||
|
self.redirect(url)
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
async def post(self, for_user=None):
|
async def post(self, for_user=None):
|
||||||
"""POST spawns with user-specified options"""
|
"""POST spawns with user-specified options"""
|
||||||
user = current_user = self.get_current_user()
|
user = current_user = self.current_user
|
||||||
if for_user is not None and for_user != user.name:
|
if for_user is not None and for_user != user.name:
|
||||||
if not user.admin:
|
if not user.admin:
|
||||||
raise web.HTTPError(403, "Only admins can spawn on behalf of other users")
|
raise web.HTTPError(403, "Only admins can spawn on behalf of other users")
|
||||||
@@ -207,14 +216,18 @@ class AdminHandler(BaseHandler):
|
|||||||
|
|
||||||
users = self.db.query(orm.User).outerjoin(orm.Spawner).order_by(*ordered)
|
users = self.db.query(orm.User).outerjoin(orm.Spawner).order_by(*ordered)
|
||||||
users = [ self._user_from_orm(u) for u in users ]
|
users = [ self._user_from_orm(u) for u in users ]
|
||||||
running = [ u for u in users if u.running ]
|
from itertools import chain
|
||||||
|
running = []
|
||||||
|
for u in users:
|
||||||
|
running.extend(s for s in u.spawners.values() if s.active)
|
||||||
|
|
||||||
html = self.render_template('admin.html',
|
html = self.render_template('admin.html',
|
||||||
user=self.get_current_user(),
|
current_user=self.current_user,
|
||||||
admin_access=self.settings.get('admin_access', False),
|
admin_access=self.settings.get('admin_access', False),
|
||||||
users=users,
|
users=users,
|
||||||
running=running,
|
running=running,
|
||||||
sort={s:o for s,o in zip(sorts, orders)},
|
sort={s:o for s,o in zip(sorts, orders)},
|
||||||
|
allow_named_servers=self.allow_named_servers,
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
|
|
||||||
@@ -226,7 +239,7 @@ class TokenPageHandler(BaseHandler):
|
|||||||
def get(self):
|
def get(self):
|
||||||
never = datetime(1900, 1, 1)
|
never = datetime(1900, 1, 1)
|
||||||
|
|
||||||
user = self.get_current_user()
|
user = self.current_user
|
||||||
def sort_key(token):
|
def sort_key(token):
|
||||||
return (
|
return (
|
||||||
token.last_activity or never,
|
token.last_activity or never,
|
||||||
@@ -243,9 +256,11 @@ class TokenPageHandler(BaseHandler):
|
|||||||
api_tokens.append(token)
|
api_tokens.append(token)
|
||||||
|
|
||||||
# group oauth client tokens by client id
|
# group oauth client tokens by client id
|
||||||
|
# AccessTokens have expires_at as an integer timestamp
|
||||||
|
now_timestamp = now.timestamp()
|
||||||
oauth_tokens = defaultdict(list)
|
oauth_tokens = defaultdict(list)
|
||||||
for token in user.oauth_tokens:
|
for token in user.oauth_tokens:
|
||||||
if token.expires_at and token.expires_at < now:
|
if token.expires_at and token.expires_at < now_timestamp:
|
||||||
self.log.warning("Deleting expired token")
|
self.log.warning("Deleting expired token")
|
||||||
self.db.delete(token)
|
self.db.delete(token)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
@@ -123,8 +123,8 @@ def log_request(handler):
|
|||||||
request_time = 1000.0 * handler.request.request_time()
|
request_time = 1000.0 * handler.request.request_time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = handler.get_current_user()
|
user = handler.current_user
|
||||||
except HTTPError:
|
except (HTTPError, RuntimeError):
|
||||||
username = ''
|
username = ''
|
||||||
else:
|
else:
|
||||||
if user is None:
|
if user is None:
|
||||||
|
@@ -18,6 +18,7 @@ them manually here.
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from prometheus_client import Histogram
|
from prometheus_client import Histogram
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
REQUEST_DURATION_SECONDS = Histogram(
|
REQUEST_DURATION_SECONDS = Histogram(
|
||||||
'request_duration_seconds',
|
'request_duration_seconds',
|
||||||
@@ -34,6 +35,13 @@ SERVER_SPAWN_DURATION_SECONDS = Histogram(
|
|||||||
buckets=[0.5, 1, 2.5, 5, 10, 15, 30, 60, 120, float("inf")]
|
buckets=[0.5, 1, 2.5, 5, 10, 15, 30, 60, 120, float("inf")]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RUNNING_SERVERS = Gauge(
|
||||||
|
'running_servers',
|
||||||
|
'the number of user servers currently running',
|
||||||
|
)
|
||||||
|
|
||||||
|
RUNNING_SERVERS.set(0)
|
||||||
|
|
||||||
class ServerSpawnStatus(Enum):
|
class ServerSpawnStatus(Enum):
|
||||||
"""
|
"""
|
||||||
Possible values for 'status' label of SERVER_SPAWN_DURATION_SECONDS
|
Possible values for 'status' label of SERVER_SPAWN_DURATION_SECONDS
|
||||||
|
591
jupyterhub/oauth/provider.py
Normal file
591
jupyterhub/oauth/provider.py
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
"""Utilities for hooking up oauth2 to JupyterHub's database
|
||||||
|
|
||||||
|
implements https://oauthlib.readthedocs.io/en/latest/oauth2/server.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from oauthlib.oauth2 import RequestValidator, WebApplicationServer
|
||||||
|
|
||||||
|
from sqlalchemy.orm import scoped_session
|
||||||
|
from tornado.escape import url_escape
|
||||||
|
from tornado.log import app_log
|
||||||
|
from tornado import web
|
||||||
|
|
||||||
|
from .. import orm
|
||||||
|
from ..utils import url_path_join, hash_token, compare_token
|
||||||
|
|
||||||
|
|
||||||
|
# patch absolute-uri check
|
||||||
|
# because we want to allow relative uri oauth
|
||||||
|
# for internal services
|
||||||
|
from oauthlib.oauth2.rfc6749.grant_types import authorization_code
|
||||||
|
authorization_code.is_absolute_uri = lambda uri: True
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterHubRequestValidator(RequestValidator):
|
||||||
|
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def authenticate_client(self, request, *args, **kwargs):
|
||||||
|
"""Authenticate client through means outside the OAuth 2 spec.
|
||||||
|
Means of authentication is negotiated beforehand and may for example
|
||||||
|
be `HTTP Basic Authentication Scheme`_ which utilizes the Authorization
|
||||||
|
header.
|
||||||
|
Headers may be accesses through request.headers and parameters found in
|
||||||
|
both body and query can be obtained by direct attribute access, i.e.
|
||||||
|
request.client_id for client_id in the URL query.
|
||||||
|
:param request: oauthlib.common.Request
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Resource Owner Password Credentials Grant (may be disabled)
|
||||||
|
- Client Credentials Grant
|
||||||
|
- Refresh Token Grant
|
||||||
|
.. _`HTTP Basic Authentication Scheme`: https://tools.ietf.org/html/rfc1945#section-11.1
|
||||||
|
"""
|
||||||
|
app_log.debug("authenticate_client %s", request)
|
||||||
|
client_id = request.client_id
|
||||||
|
client_secret = request.client_secret
|
||||||
|
oauth_client = (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=client_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if oauth_client is None:
|
||||||
|
return False
|
||||||
|
if not compare_token(oauth_client.secret, client_secret):
|
||||||
|
app_log.warning("Client secret mismatch for %s", client_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
request.client = oauth_client
|
||||||
|
return True
|
||||||
|
|
||||||
|
def authenticate_client_id(self, client_id, request, *args, **kwargs):
|
||||||
|
"""Ensure client_id belong to a non-confidential client.
|
||||||
|
A non-confidential client is one that is not required to authenticate
|
||||||
|
through other means, such as using HTTP Basic.
|
||||||
|
Note, while not strictly necessary it can often be very convenient
|
||||||
|
to set request.client to the client object associated with the
|
||||||
|
given client_id.
|
||||||
|
:param request: oauthlib.common.Request
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
"""
|
||||||
|
orm_client = (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=client_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if orm_client is None:
|
||||||
|
app_log.warning("No such oauth client %s", client_id)
|
||||||
|
return False
|
||||||
|
request.client = orm_client
|
||||||
|
return True
|
||||||
|
|
||||||
|
def confirm_redirect_uri(self, client_id, code, redirect_uri, client,
|
||||||
|
*args, **kwargs):
|
||||||
|
"""Ensure that the authorization process represented by this authorization
|
||||||
|
code began with this 'redirect_uri'.
|
||||||
|
If the client specifies a redirect_uri when obtaining code then that
|
||||||
|
redirect URI must be bound to the code and verified equal in this
|
||||||
|
method, according to RFC 6749 section 4.1.3. Do not compare against
|
||||||
|
the client's allowed redirect URIs, but against the URI used when the
|
||||||
|
code was saved.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param code: Unicode authorization_code.
|
||||||
|
:param redirect_uri: Unicode absolute URI
|
||||||
|
:param client: Client object set by you, see authenticate_client.
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant (during token request)
|
||||||
|
"""
|
||||||
|
# TODO: record redirect_uri used during oauth
|
||||||
|
# if we ever support multiple destinations
|
||||||
|
app_log.debug("confirm_redirect_uri: client_id=%s, redirect_uri=%s",
|
||||||
|
client_id, redirect_uri,
|
||||||
|
)
|
||||||
|
if redirect_uri == client.redirect_uri:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
app_log.warning("Redirect uri %s != %s", redirect_uri, client.redirect_uri)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
|
||||||
|
"""Get the default redirect URI for the client.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: The default redirect URI for the client
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Implicit Grant
|
||||||
|
"""
|
||||||
|
orm_client = (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=client_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if orm_client is None:
|
||||||
|
raise KeyError(client_id)
|
||||||
|
return orm_client.redirect_uri
|
||||||
|
|
||||||
|
def get_default_scopes(self, client_id, request, *args, **kwargs):
|
||||||
|
"""Get the default scopes for the client.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: List of default scopes
|
||||||
|
Method is used by all core grant types:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Implicit Grant
|
||||||
|
- Resource Owner Password Credentials Grant
|
||||||
|
- Client Credentials grant
|
||||||
|
"""
|
||||||
|
return ['identify']
|
||||||
|
|
||||||
|
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
|
||||||
|
"""Get the list of scopes associated with the refresh token.
|
||||||
|
:param refresh_token: Unicode refresh token
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: List of scopes.
|
||||||
|
Method is used by:
|
||||||
|
- Refresh token grant
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def is_within_original_scope(self, request_scopes, refresh_token, request, *args, **kwargs):
|
||||||
|
"""Check if requested scopes are within a scope of the refresh token.
|
||||||
|
When access tokens are refreshed the scope of the new token
|
||||||
|
needs to be within the scope of the original token. This is
|
||||||
|
ensured by checking that all requested scopes strings are on
|
||||||
|
the list returned by the get_original_scopes. If this check
|
||||||
|
fails, is_within_original_scope is called. The method can be
|
||||||
|
used in situations where returning all valid scopes from the
|
||||||
|
get_original_scopes is not practical.
|
||||||
|
:param request_scopes: A list of scopes that were requested by client
|
||||||
|
:param refresh_token: Unicode refresh_token
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Refresh token grant
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||||
|
"""Invalidate an authorization code after use.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param code: The authorization code grant (request.code).
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
"""
|
||||||
|
app_log.debug("Deleting oauth code %s... for %s", code[:3], client_id)
|
||||||
|
orm_code = self.db.query(orm.OAuthCode).filter_by(code=code).first()
|
||||||
|
if orm_code is not None:
|
||||||
|
self.db.delete(orm_code)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def revoke_token(self, token, token_type_hint, request, *args, **kwargs):
|
||||||
|
"""Revoke an access or refresh token.
|
||||||
|
:param token: The token string.
|
||||||
|
:param token_type_hint: access_token or refresh_token.
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
Method is used by:
|
||||||
|
- Revocation Endpoint
|
||||||
|
"""
|
||||||
|
app_log.debug("Revoking %s %s", token_type_hint, token[:3] + '...')
|
||||||
|
raise NotImplementedError('Subclasses must implement this method.')
|
||||||
|
|
||||||
|
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||||
|
"""Persist the authorization_code.
|
||||||
|
The code should at minimum be stored with:
|
||||||
|
- the client_id (client_id)
|
||||||
|
- the redirect URI used (request.redirect_uri)
|
||||||
|
- a resource owner / user (request.user)
|
||||||
|
- the authorized scopes (request.scopes)
|
||||||
|
- the client state, if given (code.get('state'))
|
||||||
|
The 'code' argument is actually a dictionary, containing at least a
|
||||||
|
'code' key with the actual authorization code:
|
||||||
|
{'code': 'sdf345jsdf0934f'}
|
||||||
|
It may also have a 'state' key containing a nonce for the client, if it
|
||||||
|
chose to send one. That value should be saved and used in
|
||||||
|
'validate_code'.
|
||||||
|
It may also have a 'claims' parameter which, when present, will be a dict
|
||||||
|
deserialized from JSON as described at
|
||||||
|
http://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter
|
||||||
|
This value should be saved in this method and used again in 'validate_code'.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param code: A dict of the authorization code grant and, optionally, state.
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
"""
|
||||||
|
log_code = code.get('code', 'undefined')[:3] + '...'
|
||||||
|
app_log.debug("Saving authorization code %s, %s, %s, %s", client_id, log_code, args, kwargs)
|
||||||
|
orm_client = (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=client_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if orm_client is None:
|
||||||
|
raise ValueError("No such client: %s" % client_id)
|
||||||
|
|
||||||
|
orm_code = orm.OAuthCode(
|
||||||
|
client=orm_client,
|
||||||
|
code=code['code'],
|
||||||
|
# oauth has 5 minutes to complete
|
||||||
|
expires_at=int(datetime.utcnow().timestamp() + 300),
|
||||||
|
# TODO: persist oauth scopes
|
||||||
|
# scopes=request.scopes,
|
||||||
|
user=request.user.orm_user,
|
||||||
|
redirect_uri=orm_client.redirect_uri,
|
||||||
|
session_id=request.session_id,
|
||||||
|
)
|
||||||
|
self.db.add(orm_code)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
||||||
|
""" Extracts scopes from saved authorization code.
|
||||||
|
The scopes returned by this method is used to route token requests
|
||||||
|
based on scopes passed to Authorization Code requests.
|
||||||
|
With that the token endpoint knows when to include OpenIDConnect
|
||||||
|
id_token in token response only based on authorization code scopes.
|
||||||
|
Only code param should be sufficient to retrieve grant code from
|
||||||
|
any storage you are using, `client_id` and `redirect_uri` can gave a
|
||||||
|
blank value `""` don't forget to check it before using those values
|
||||||
|
in a select query if a database is used.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param code: Unicode authorization code grant
|
||||||
|
:param redirect_uri: Unicode absolute URI
|
||||||
|
:return: A list of scope
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Token Grant Dispatcher
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("TODO")
|
||||||
|
|
||||||
|
def save_token(self, token, request, *args, **kwargs):
|
||||||
|
"""Persist the token with a token type specific method.
|
||||||
|
Currently, only save_bearer_token is supported.
|
||||||
|
"""
|
||||||
|
return self.save_bearer_token(token, request, *args, **kwargs)
|
||||||
|
|
||||||
|
def save_bearer_token(self, token, request, *args, **kwargs):
|
||||||
|
"""Persist the Bearer token.
|
||||||
|
The Bearer token should at minimum be associated with:
|
||||||
|
- a client and it's client_id, if available
|
||||||
|
- a resource owner / user (request.user)
|
||||||
|
- authorized scopes (request.scopes)
|
||||||
|
- an expiration time
|
||||||
|
- a refresh token, if issued
|
||||||
|
- a claims document, if present in request.claims
|
||||||
|
The Bearer token dict may hold a number of items::
|
||||||
|
{
|
||||||
|
'token_type': 'Bearer',
|
||||||
|
'access_token': 'askfjh234as9sd8',
|
||||||
|
'expires_in': 3600,
|
||||||
|
'scope': 'string of space separated authorized scopes',
|
||||||
|
'refresh_token': '23sdf876234', # if issued
|
||||||
|
'state': 'given_by_client', # if supplied by client
|
||||||
|
}
|
||||||
|
Note that while "scope" is a string-separated list of authorized scopes,
|
||||||
|
the original list is still available in request.scopes.
|
||||||
|
The token dict is passed as a reference so any changes made to the dictionary
|
||||||
|
will go back to the user. If additional information must return to the client
|
||||||
|
user, and it is only possible to get this information after writing the token
|
||||||
|
to storage, it should be added to the token dictionary. If the token
|
||||||
|
dictionary must be modified but the changes should not go back to the user,
|
||||||
|
a copy of the dictionary must be made before making the changes.
|
||||||
|
Also note that if an Authorization Code grant request included a valid claims
|
||||||
|
parameter (for OpenID Connect) then the request.claims property will contain
|
||||||
|
the claims dict, which should be saved for later use when generating the
|
||||||
|
id_token and/or UserInfo response content.
|
||||||
|
:param token: A Bearer token dict
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: The default redirect URI for the client
|
||||||
|
Method is used by all core grant types issuing Bearer tokens:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Implicit Grant
|
||||||
|
- Resource Owner Password Credentials Grant (might not associate a client)
|
||||||
|
- Client Credentials grant
|
||||||
|
"""
|
||||||
|
log_token = {}
|
||||||
|
log_token.update(token)
|
||||||
|
scopes = token['scope'].split(' ')
|
||||||
|
# TODO:
|
||||||
|
if scopes != ['identify']:
|
||||||
|
raise ValueError("Only 'identify' scope is supported")
|
||||||
|
# redact sensitive keys in log
|
||||||
|
for key in ('access_token', 'refresh_token', 'state'):
|
||||||
|
if key in token:
|
||||||
|
value = token[key]
|
||||||
|
if isinstance(value, str):
|
||||||
|
log_token[key] = 'REDACTED'
|
||||||
|
app_log.debug("Saving bearer token %s", log_token)
|
||||||
|
if request.user is None:
|
||||||
|
raise ValueError("No user for access token: %s" % request.user)
|
||||||
|
client = self.db.query(orm.OAuthClient).filter_by(identifier=request.client.client_id).first()
|
||||||
|
orm_access_token = orm.OAuthAccessToken(
|
||||||
|
client=client,
|
||||||
|
grant_type=orm.GrantType.authorization_code,
|
||||||
|
expires_at=datetime.utcnow().timestamp() + token['expires_in'],
|
||||||
|
refresh_token=token['refresh_token'],
|
||||||
|
# TODO: save scopes,
|
||||||
|
# scopes=scopes,
|
||||||
|
token=token['access_token'],
|
||||||
|
session_id=request.session_id,
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
|
self.db.add(orm_access_token)
|
||||||
|
self.db.commit()
|
||||||
|
return client.redirect_uri
|
||||||
|
|
||||||
|
def validate_bearer_token(self, token, scopes, request):
|
||||||
|
"""Ensure the Bearer token is valid and authorized access to scopes.
|
||||||
|
:param token: A string of random characters.
|
||||||
|
:param scopes: A list of scopes associated with the protected resource.
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
A key to OAuth 2 security and restricting impact of leaked tokens is
|
||||||
|
the short expiration time of tokens, *always ensure the token has not
|
||||||
|
expired!*.
|
||||||
|
Two different approaches to scope validation:
|
||||||
|
1) all(scopes). The token must be authorized access to all scopes
|
||||||
|
associated with the resource. For example, the
|
||||||
|
token has access to ``read-only`` and ``images``,
|
||||||
|
thus the client can view images but not upload new.
|
||||||
|
Allows for fine grained access control through
|
||||||
|
combining various scopes.
|
||||||
|
2) any(scopes). The token must be authorized access to one of the
|
||||||
|
scopes associated with the resource. For example,
|
||||||
|
token has access to ``read-only-images``.
|
||||||
|
Allows for fine grained, although arguably less
|
||||||
|
convenient, access control.
|
||||||
|
A powerful way to use scopes would mimic UNIX ACLs and see a scope
|
||||||
|
as a group with certain privileges. For a restful API these might
|
||||||
|
map to HTTP verbs instead of read, write and execute.
|
||||||
|
Note, the request.user attribute can be set to the resource owner
|
||||||
|
associated with this token. Similarly the request.client and
|
||||||
|
request.scopes attribute can be set to associated client object
|
||||||
|
and authorized scopes. If you then use a decorator such as the
|
||||||
|
one provided for django these attributes will be made available
|
||||||
|
in all protected views as keyword arguments.
|
||||||
|
:param token: Unicode Bearer token
|
||||||
|
:param scopes: List of scopes (defined by you)
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: True or False
|
||||||
|
Method is indirectly used by all core Bearer token issuing grant types:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Implicit Grant
|
||||||
|
- Resource Owner Password Credentials Grant
|
||||||
|
- Client Credentials Grant
|
||||||
|
"""
|
||||||
|
raise NotImplementedError('Subclasses must implement this method.')
|
||||||
|
|
||||||
|
def validate_client_id(self, client_id, request, *args, **kwargs):
|
||||||
|
"""Ensure client_id belong to a valid and active client.
|
||||||
|
Note, while not strictly necessary it can often be very convenient
|
||||||
|
to set request.client to the client object associated with the
|
||||||
|
given client_id.
|
||||||
|
:param request: oauthlib.common.Request
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Implicit Grant
|
||||||
|
"""
|
||||||
|
app_log.debug("Validating client id %s", client_id)
|
||||||
|
orm_client = (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=client_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if orm_client is None:
|
||||||
|
return False
|
||||||
|
request.client = orm_client
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||||
|
"""Verify that the authorization_code is valid and assigned to the given
|
||||||
|
client.
|
||||||
|
Before returning true, set the following based on the information stored
|
||||||
|
with the code in 'save_authorization_code':
|
||||||
|
- request.user
|
||||||
|
- request.state (if given)
|
||||||
|
- request.scopes
|
||||||
|
- request.claims (if given)
|
||||||
|
OBS! The request.user attribute should be set to the resource owner
|
||||||
|
associated with this authorization code. Similarly request.scopes
|
||||||
|
must also be set.
|
||||||
|
The request.claims property, if it was given, should assigned a dict.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param code: Unicode authorization code
|
||||||
|
:param client: Client object set by you, see authenticate_client.
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
"""
|
||||||
|
orm_code = (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthCode)
|
||||||
|
.filter_by(code=code)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if orm_code is None:
|
||||||
|
app_log.debug("No such code: %s", code)
|
||||||
|
return False
|
||||||
|
if orm_code.client_id != client_id:
|
||||||
|
app_log.debug(
|
||||||
|
"OAuth code client id mismatch: %s != %s",
|
||||||
|
client_id, orm_code.client_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
request.user = orm_code.user
|
||||||
|
request.session_id = orm_code.session_id
|
||||||
|
# TODO: record state on oauth codes
|
||||||
|
# TODO: specify scopes
|
||||||
|
request.scopes = ['identify']
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
|
||||||
|
"""Ensure client is authorized to use the grant_type requested.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param grant_type: Unicode grant type, i.e. authorization_code, password.
|
||||||
|
:param client: Client object set by you, see authenticate_client.
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Resource Owner Password Credentials Grant
|
||||||
|
- Client Credentials Grant
|
||||||
|
- Refresh Token Grant
|
||||||
|
"""
|
||||||
|
return grant_type == 'authorization_code'
|
||||||
|
|
||||||
|
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
|
||||||
|
"""Ensure client is authorized to redirect to the redirect_uri requested.
|
||||||
|
All clients should register the absolute URIs of all URIs they intend
|
||||||
|
to redirect to. The registration is outside of the scope of oauthlib.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param redirect_uri: Unicode absolute URI
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Implicit Grant
|
||||||
|
"""
|
||||||
|
app_log.debug("validate_redirect_uri: client_id=%s, redirect_uri=%s",
|
||||||
|
client_id, redirect_uri,
|
||||||
|
)
|
||||||
|
orm_client = (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=client_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if orm_client is None:
|
||||||
|
app_log.warning("No such oauth client %s", client_id)
|
||||||
|
return False
|
||||||
|
if redirect_uri == orm_client.redirect_uri:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
app_log.warning("Redirect uri %s != %s", redirect_uri, orm_client.redirect_uri)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs):
|
||||||
|
"""Ensure the Bearer token is valid and authorized access to scopes.
|
||||||
|
OBS! The request.user attribute should be set to the resource owner
|
||||||
|
associated with this refresh token.
|
||||||
|
:param refresh_token: Unicode refresh token
|
||||||
|
:param client: Client object set by you, see authenticate_client.
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant (indirectly by issuing refresh tokens)
|
||||||
|
- Resource Owner Password Credentials Grant (also indirectly)
|
||||||
|
- Refresh Token Grant
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
raise NotImplementedError('Subclasses must implement this method.')
|
||||||
|
|
||||||
|
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs):
|
||||||
|
"""Ensure client is authorized to use the response_type requested.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param response_type: Unicode response type, i.e. code, token.
|
||||||
|
:param client: Client object set by you, see authenticate_client.
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Implicit Grant
|
||||||
|
"""
|
||||||
|
# TODO
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||||
|
"""Ensure the client is authorized access to requested scopes.
|
||||||
|
:param client_id: Unicode client identifier
|
||||||
|
:param scopes: List of scopes (defined by you)
|
||||||
|
:param client: Client object set by you, see authenticate_client.
|
||||||
|
:param request: The HTTP Request (oauthlib.common.Request)
|
||||||
|
:rtype: True or False
|
||||||
|
Method is used by all core grant types:
|
||||||
|
- Authorization Code Grant
|
||||||
|
- Implicit Grant
|
||||||
|
- Resource Owner Password Credentials Grant
|
||||||
|
- Client Credentials Grant
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterHubOAuthServer(WebApplicationServer):
|
||||||
|
def __init__(self, db, validator, *args, **kwargs):
|
||||||
|
self.db = db
|
||||||
|
super().__init__(validator, *args, **kwargs)
|
||||||
|
|
||||||
|
def add_client(self, client_id, client_secret, redirect_uri, description=''):
|
||||||
|
"""Add a client
|
||||||
|
|
||||||
|
hash its client_secret before putting it in the database.
|
||||||
|
"""
|
||||||
|
# clear existing clients with same ID
|
||||||
|
for orm_client in (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)\
|
||||||
|
.filter_by(identifier=client_id)
|
||||||
|
):
|
||||||
|
self.db.delete(orm_client)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
orm_client = orm.OAuthClient(
|
||||||
|
identifier=client_id,
|
||||||
|
secret=hash_token(client_secret),
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
self.db.add(orm_client)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def fetch_by_client_id(self, client_id):
|
||||||
|
"""Find a client by its id"""
|
||||||
|
return (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=client_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_provider(session_factory, url_prefix, login_url):
|
||||||
|
"""Make an OAuth provider"""
|
||||||
|
db = session_factory()
|
||||||
|
validator = JupyterHubRequestValidator(db)
|
||||||
|
server = JupyterHubOAuthServer(db, validator)
|
||||||
|
return server
|
||||||
|
|
@@ -1,262 +0,0 @@
|
|||||||
"""Utilities for hooking up oauth2 to JupyterHub's database
|
|
||||||
|
|
||||||
implements https://python-oauth2.readthedocs.io/en/latest/store.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from oauth2.datatype import Client, AuthorizationCode
|
|
||||||
from oauth2.error import AuthCodeNotFound, ClientNotFoundError, UserNotAuthenticated
|
|
||||||
from oauth2.grant import AuthorizationCodeGrant
|
|
||||||
from oauth2.web import AuthorizationCodeGrantSiteAdapter
|
|
||||||
import oauth2.store
|
|
||||||
from oauth2 import Provider
|
|
||||||
from oauth2.tokengenerator import Uuid4 as UUID4
|
|
||||||
|
|
||||||
from sqlalchemy.orm import scoped_session
|
|
||||||
from tornado.escape import url_escape
|
|
||||||
|
|
||||||
from .. import orm
|
|
||||||
from ..utils import url_path_join, hash_token, compare_token
|
|
||||||
|
|
||||||
|
|
||||||
class JupyterHubSiteAdapter(AuthorizationCodeGrantSiteAdapter):
|
|
||||||
"""
|
|
||||||
This adapter renders a confirmation page so the user can confirm the auth
|
|
||||||
request.
|
|
||||||
"""
|
|
||||||
def __init__(self, login_url):
|
|
||||||
self.login_url = login_url
|
|
||||||
|
|
||||||
def render_auth_page(self, request, response, environ, scopes, client):
|
|
||||||
"""Auth page is a redirect to login page"""
|
|
||||||
response.status_code = 302
|
|
||||||
response.headers['Location'] = self.login_url + '?next={}'.format(
|
|
||||||
url_escape(request.handler.request.path + '?' + request.handler.request.query)
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
def authenticate(self, request, environ, scopes, client):
|
|
||||||
handler = request.handler
|
|
||||||
user = handler.get_current_user()
|
|
||||||
# ensure session_id is set
|
|
||||||
session_id = handler.get_session_cookie()
|
|
||||||
if session_id is None:
|
|
||||||
session_id = handler.set_session_cookie()
|
|
||||||
if user:
|
|
||||||
return {'session_id': session_id}, user.id
|
|
||||||
else:
|
|
||||||
raise UserNotAuthenticated()
|
|
||||||
|
|
||||||
def user_has_denied_access(self, request):
|
|
||||||
# user can't deny access
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class HubDBMixin(object):
|
|
||||||
"""Mixin for connecting to the hub database"""
|
|
||||||
def __init__(self, session_factory):
|
|
||||||
self.db = session_factory()
|
|
||||||
|
|
||||||
|
|
||||||
class AccessTokenStore(HubDBMixin, oauth2.store.AccessTokenStore):
|
|
||||||
"""OAuth2 AccessTokenStore, storing data in the Hub database"""
|
|
||||||
|
|
||||||
def save_token(self, access_token):
|
|
||||||
"""
|
|
||||||
Stores an access token in the database.
|
|
||||||
|
|
||||||
:param access_token: An instance of :class:`oauth2.datatype.AccessToken`.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = self.db.query(orm.User).filter_by(id=access_token.user_id).first()
|
|
||||||
if user is None:
|
|
||||||
raise ValueError("No user for access token: %s" % access_token.user_id)
|
|
||||||
client = self.db.query(orm.OAuthClient).filter_by(identifier=access_token.client_id).first()
|
|
||||||
orm_access_token = orm.OAuthAccessToken(
|
|
||||||
client=client,
|
|
||||||
grant_type=access_token.grant_type,
|
|
||||||
expires_at=access_token.expires_at,
|
|
||||||
refresh_token=access_token.refresh_token,
|
|
||||||
refresh_expires_at=access_token.refresh_expires_at,
|
|
||||||
token=access_token.token,
|
|
||||||
session_id=access_token.data['session_id'],
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
self.db.add(orm_access_token)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class AuthCodeStore(HubDBMixin, oauth2.store.AuthCodeStore):
|
|
||||||
"""
|
|
||||||
OAuth2 AuthCodeStore, storing data in the Hub database
|
|
||||||
"""
|
|
||||||
def fetch_by_code(self, code):
|
|
||||||
"""
|
|
||||||
Returns an AuthorizationCode fetched from a storage.
|
|
||||||
|
|
||||||
:param code: The authorization code.
|
|
||||||
:return: An instance of :class:`oauth2.datatype.AuthorizationCode`.
|
|
||||||
:raises: :class:`oauth2.error.AuthCodeNotFound` if no data could be retrieved for
|
|
||||||
given code.
|
|
||||||
|
|
||||||
"""
|
|
||||||
orm_code = (
|
|
||||||
self.db
|
|
||||||
.query(orm.OAuthCode)
|
|
||||||
.filter_by(code=code)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if orm_code is None:
|
|
||||||
raise AuthCodeNotFound()
|
|
||||||
else:
|
|
||||||
return AuthorizationCode(
|
|
||||||
client_id=orm_code.client_id,
|
|
||||||
code=code,
|
|
||||||
expires_at=orm_code.expires_at,
|
|
||||||
redirect_uri=orm_code.redirect_uri,
|
|
||||||
scopes=[],
|
|
||||||
user_id=orm_code.user_id,
|
|
||||||
data={'session_id': orm_code.session_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def save_code(self, authorization_code):
|
|
||||||
"""
|
|
||||||
Stores the data belonging to an authorization code token.
|
|
||||||
|
|
||||||
:param authorization_code: An instance of
|
|
||||||
:class:`oauth2.datatype.AuthorizationCode`.
|
|
||||||
"""
|
|
||||||
orm_client = (
|
|
||||||
self.db
|
|
||||||
.query(orm.OAuthClient)
|
|
||||||
.filter_by(identifier=authorization_code.client_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if orm_client is None:
|
|
||||||
raise ValueError("No such client: %s" % authorization_code.client_id)
|
|
||||||
|
|
||||||
orm_user = (
|
|
||||||
self.db
|
|
||||||
.query(orm.User)
|
|
||||||
.filter_by(id=authorization_code.user_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if orm_user is None:
|
|
||||||
raise ValueError("No such user: %s" % authorization_code.user_id)
|
|
||||||
|
|
||||||
orm_code = orm.OAuthCode(
|
|
||||||
client=orm_client,
|
|
||||||
code=authorization_code.code,
|
|
||||||
expires_at=authorization_code.expires_at,
|
|
||||||
user=orm_user,
|
|
||||||
redirect_uri=authorization_code.redirect_uri,
|
|
||||||
session_id=authorization_code.data.get('session_id', ''),
|
|
||||||
)
|
|
||||||
self.db.add(orm_code)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def delete_code(self, code):
|
|
||||||
"""
|
|
||||||
Deletes an authorization code after its use per section 4.1.2.
|
|
||||||
|
|
||||||
http://tools.ietf.org/html/rfc6749#section-4.1.2
|
|
||||||
|
|
||||||
:param code: The authorization code.
|
|
||||||
"""
|
|
||||||
orm_code = self.db.query(orm.OAuthCode).filter_by(code=code).first()
|
|
||||||
if orm_code is not None:
|
|
||||||
self.db.delete(orm_code)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class HashComparable:
|
|
||||||
"""An object for storing hashed tokens
|
|
||||||
|
|
||||||
Overrides `==` so that it compares as equal to its unhashed original
|
|
||||||
|
|
||||||
Needed for storing hashed client_secrets
|
|
||||||
because python-oauth2 uses::
|
|
||||||
|
|
||||||
secret == client.client_secret
|
|
||||||
|
|
||||||
and we don't want to store unhashed secrets in the database.
|
|
||||||
"""
|
|
||||||
def __init__(self, hashed_token):
|
|
||||||
self.hashed_token = hashed_token
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "<{} '{}'>".format(self.__class__.__name__, self.hashed_token)
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return compare_token(self.hashed_token, other)
|
|
||||||
|
|
||||||
|
|
||||||
class ClientStore(HubDBMixin, oauth2.store.ClientStore):
|
|
||||||
"""OAuth2 ClientStore, storing data in the Hub database"""
|
|
||||||
|
|
||||||
def fetch_by_client_id(self, client_id):
|
|
||||||
"""Retrieve a client by its identifier.
|
|
||||||
|
|
||||||
:param client_id: Identifier of a client app.
|
|
||||||
:return: An instance of :class:`oauth2.datatype.Client`.
|
|
||||||
:raises: :class:`oauth2.error.ClientNotFoundError` if no data could be retrieved for
|
|
||||||
given client_id.
|
|
||||||
"""
|
|
||||||
orm_client = (
|
|
||||||
self.db
|
|
||||||
.query(orm.OAuthClient)
|
|
||||||
.filter_by(identifier=client_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if orm_client is None:
|
|
||||||
raise ClientNotFoundError()
|
|
||||||
return Client(identifier=client_id,
|
|
||||||
redirect_uris=[orm_client.redirect_uri],
|
|
||||||
secret=HashComparable(orm_client.secret),
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_client(self, client_id, client_secret, redirect_uri, description=''):
|
|
||||||
"""Add a client
|
|
||||||
|
|
||||||
hash its client_secret before putting it in the database.
|
|
||||||
"""
|
|
||||||
# clear existing clients with same ID
|
|
||||||
for orm_client in (
|
|
||||||
self.db
|
|
||||||
.query(orm.OAuthClient)\
|
|
||||||
.filter_by(identifier=client_id)
|
|
||||||
):
|
|
||||||
self.db.delete(orm_client)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
orm_client = orm.OAuthClient(
|
|
||||||
identifier=client_id,
|
|
||||||
secret=hash_token(client_secret),
|
|
||||||
redirect_uri=redirect_uri,
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
self.db.add(orm_client)
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def make_provider(session_factory, url_prefix, login_url):
|
|
||||||
"""Make an OAuth provider"""
|
|
||||||
token_store = AccessTokenStore(session_factory)
|
|
||||||
code_store = AuthCodeStore(session_factory)
|
|
||||||
client_store = ClientStore(session_factory)
|
|
||||||
|
|
||||||
provider = Provider(
|
|
||||||
access_token_store=token_store,
|
|
||||||
auth_code_store=code_store,
|
|
||||||
client_store=client_store,
|
|
||||||
token_generator=UUID4(),
|
|
||||||
)
|
|
||||||
provider.token_path = url_path_join(url_prefix, 'token')
|
|
||||||
provider.authorize_path = url_path_join(url_prefix, 'authorize')
|
|
||||||
site_adapter = JupyterHubSiteAdapter(login_url=login_url)
|
|
||||||
provider.add_grant(AuthorizationCodeGrant(site_adapter=site_adapter))
|
|
||||||
return provider
|
|
||||||
|
|
@@ -209,6 +209,15 @@ class Spawner(Base):
|
|||||||
started = Column(DateTime)
|
started = Column(DateTime)
|
||||||
last_activity = Column(DateTime, nullable=True)
|
last_activity = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# properties on the spawner wrapper
|
||||||
|
# some APIs get these low-level objects
|
||||||
|
# when the spawner isn't running,
|
||||||
|
# for which these should all be False
|
||||||
|
active = running = ready = False
|
||||||
|
pending = None
|
||||||
|
@property
|
||||||
|
def orm_spawner(self):
|
||||||
|
return self
|
||||||
|
|
||||||
class Service(Base):
|
class Service(Base):
|
||||||
"""A service run with JupyterHub
|
"""A service run with JupyterHub
|
||||||
@@ -469,6 +478,7 @@ class OAuthAccessToken(Hashed, Base):
|
|||||||
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(255))
|
refresh_token = Column(Unicode(255))
|
||||||
|
# TODO: drop refresh_expires_at. Refresh tokens shouldn't expire
|
||||||
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'))
|
||||||
service = None # for API-equivalence with APIToken
|
service = None # for API-equivalence with APIToken
|
||||||
@@ -513,6 +523,7 @@ class OAuthCode(Base):
|
|||||||
expires_at = Column(Integer)
|
expires_at = Column(Integer)
|
||||||
redirect_uri = Column(Unicode(1023))
|
redirect_uri = Column(Unicode(1023))
|
||||||
session_id = Column(Unicode(255))
|
session_id = Column(Unicode(255))
|
||||||
|
# state = Column(Unicode(1023))
|
||||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||||
|
|
||||||
|
|
||||||
@@ -524,6 +535,10 @@ class OAuthClient(Base):
|
|||||||
secret = Column(Unicode(255))
|
secret = Column(Unicode(255))
|
||||||
redirect_uri = Column(Unicode(1023))
|
redirect_uri = Column(Unicode(1023))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_id(self):
|
||||||
|
return self.identifier
|
||||||
|
|
||||||
access_tokens = relationship(
|
access_tokens = relationship(
|
||||||
OAuthAccessToken,
|
OAuthAccessToken,
|
||||||
backref='client',
|
backref='client',
|
||||||
@@ -746,7 +761,7 @@ def new_session_factory(url="sqlite:///:memory:",
|
|||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
# We set expire_on_commit=False, since we don't actually need
|
# We set expire_on_commit=False, since we don't actually need
|
||||||
# SQLAlchemy to expire objects after commiting - we don't expect
|
# SQLAlchemy to expire objects after committing - we don't expect
|
||||||
# concurrent runs of the hub talking to the same db. Turning
|
# concurrent runs of the hub talking to the same db. Turning
|
||||||
# this off gives us a major performance boost
|
# this off gives us a major performance boost
|
||||||
session_factory = sessionmaker(bind=engine,
|
session_factory = sessionmaker(bind=engine,
|
||||||
|
@@ -460,6 +460,14 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
|
|
||||||
_check_running_callback = Any(help="PeriodicCallback to check if the proxy is running")
|
_check_running_callback = Any(help="PeriodicCallback to check if the proxy is running")
|
||||||
|
|
||||||
|
def _check_pid(self, pid):
|
||||||
|
if os.name == 'nt':
|
||||||
|
import psutil
|
||||||
|
if not psutil.pid_exists(pid):
|
||||||
|
raise ProcessLookupError
|
||||||
|
else:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
# check for required token if proxy is external
|
# check for required token if proxy is external
|
||||||
@@ -484,7 +492,7 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.kill(pid, 0)
|
self._check_pid(pid)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
self.log.warning("Proxy no longer running at pid=%s", pid)
|
self.log.warning("Proxy no longer running at pid=%s", pid)
|
||||||
self._remove_pid_file()
|
self._remove_pid_file()
|
||||||
@@ -492,19 +500,24 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
|
|
||||||
# if we got here, CHP is still running
|
# if we got here, CHP is still running
|
||||||
self.log.warning("Proxy still running at pid=%s", pid)
|
self.log.warning("Proxy still running at pid=%s", pid)
|
||||||
for i, sig in enumerate([signal.SIGTERM] * 2 + [signal.SIGKILL]):
|
if os.name != 'nt':
|
||||||
|
sig_list = [signal.SIGTERM] * 2 + [signal.SIGKILL]
|
||||||
|
for i in range(3):
|
||||||
try:
|
try:
|
||||||
os.kill(pid, signal.SIGTERM)
|
if os.name == 'nt':
|
||||||
|
self._terminate_win(pid)
|
||||||
|
else:
|
||||||
|
os.kill(pid,sig_list[i])
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
try:
|
try:
|
||||||
os.kill(pid, 0)
|
self._check_pid(pid)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.kill(pid, 0)
|
self._check_pid(pid)
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
self.log.warning("Stopped proxy at pid=%s", pid)
|
self.log.warning("Stopped proxy at pid=%s", pid)
|
||||||
self._remove_pid_file()
|
self._remove_pid_file()
|
||||||
@@ -627,18 +640,21 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
self._check_running_callback = pc
|
self._check_running_callback = pc
|
||||||
pc.start()
|
pc.start()
|
||||||
|
|
||||||
|
def _terminate_win(self, pid):
|
||||||
|
# On Windows we spawned a shell on Popen, so we need to
|
||||||
|
# terminate all child processes as well
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
parent = psutil.Process(pid)
|
||||||
|
children = parent.children(recursive=True)
|
||||||
|
for child in children:
|
||||||
|
child.kill()
|
||||||
|
psutil.wait_procs(children, timeout=5)
|
||||||
|
|
||||||
def _terminate(self):
|
def _terminate(self):
|
||||||
"""Terminate our process"""
|
"""Terminate our process"""
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
# On Windows we spawned a shell on Popen, so we need to
|
self._terminate_win(self.proxy_process.pid)
|
||||||
# terminate all child processes as well
|
|
||||||
import psutil
|
|
||||||
|
|
||||||
parent = psutil.Process(self.proxy_process.pid)
|
|
||||||
children = parent.children(recursive=True)
|
|
||||||
for child in children:
|
|
||||||
child.kill()
|
|
||||||
psutil.wait_procs(children, timeout=5)
|
|
||||||
else:
|
else:
|
||||||
self.proxy_process.terminate()
|
self.proxy_process.terminate()
|
||||||
|
|
||||||
|
@@ -327,7 +327,15 @@ class HubAuth(SingletonConfigurable):
|
|||||||
elif r.status_code >= 400:
|
elif r.status_code >= 400:
|
||||||
app_log.warning("Failed to check authorization: [%i] %s", r.status_code, r.reason)
|
app_log.warning("Failed to check authorization: [%i] %s", r.status_code, r.reason)
|
||||||
app_log.warning(r.text)
|
app_log.warning(r.text)
|
||||||
raise HTTPError(500, "Failed to check authorization")
|
msg = "Failed to check authorization"
|
||||||
|
# pass on error_description from oauth failure
|
||||||
|
try:
|
||||||
|
description = r.json().get("error_description")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
msg += ": " + description
|
||||||
|
raise HTTPError(500, msg)
|
||||||
else:
|
else:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
|
|
||||||
@@ -872,6 +880,11 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
|
|||||||
|
|
||||||
@coroutine
|
@coroutine
|
||||||
def get(self):
|
def get(self):
|
||||||
|
error = self.get_argument("error", False)
|
||||||
|
if error:
|
||||||
|
msg = self.get_argument("error_description", error)
|
||||||
|
raise HTTPError(400, "Error in oauth: %s" % msg)
|
||||||
|
|
||||||
code = self.get_argument("code", False)
|
code = self.get_argument("code", False)
|
||||||
if not code:
|
if not code:
|
||||||
raise HTTPError(400, "oauth callback made without a token")
|
raise HTTPError(400, "oauth callback made without a token")
|
||||||
|
@@ -310,6 +310,7 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
# disble some single-user configurables
|
# disble some single-user configurables
|
||||||
token = ''
|
token = ''
|
||||||
open_browser = False
|
open_browser = False
|
||||||
|
quit_button = False
|
||||||
trust_xheaders = True
|
trust_xheaders = True
|
||||||
login_handler_class = JupyterHubLoginHandler
|
login_handler_class = JupyterHubLoginHandler
|
||||||
logout_handler_class = JupyterHubLogoutHandler
|
logout_handler_class = JupyterHubLogoutHandler
|
||||||
|
@@ -134,6 +134,10 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
proxy_spec = Unicode()
|
proxy_spec = Unicode()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_activity(self):
|
||||||
|
return self.orm_spawner.last_activity
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def server(self):
|
def server(self):
|
||||||
if hasattr(self, '_server'):
|
if hasattr(self, '_server'):
|
||||||
@@ -167,6 +171,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
admin_access = Bool(False)
|
admin_access = Bool(False)
|
||||||
api_token = Unicode()
|
api_token = Unicode()
|
||||||
oauth_client_id = Unicode()
|
oauth_client_id = Unicode()
|
||||||
|
handler = Any()
|
||||||
|
|
||||||
will_resume = Bool(False,
|
will_resume = Bool(False,
|
||||||
help="""Whether the Spawner will resume on next start
|
help="""Whether the Spawner will resume on next start
|
||||||
@@ -201,6 +206,19 @@ class Spawner(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
consecutive_failure_limit = Integer(
|
||||||
|
0,
|
||||||
|
help="""
|
||||||
|
Maximum number of consecutive failures to allow before
|
||||||
|
shutting down JupyterHub.
|
||||||
|
|
||||||
|
This helps JupyterHub recover from a certain class of problem preventing launch
|
||||||
|
in contexts where the Hub is automatically restarted (e.g. systemd, docker, kubernetes).
|
||||||
|
|
||||||
|
A limit of 0 means no limit and consecutive failures will not be tracked.
|
||||||
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
start_timeout = Integer(60,
|
start_timeout = Integer(60,
|
||||||
help="""
|
help="""
|
||||||
Timeout (in seconds) before giving up on starting of single-user server.
|
Timeout (in seconds) before giving up on starting of single-user server.
|
||||||
@@ -849,7 +867,8 @@ class Spawner(LoggingConfigurable):
|
|||||||
This method is always an async generator and will always yield at least one event.
|
This method is always an async generator and will always yield at least one event.
|
||||||
"""
|
"""
|
||||||
if not self._spawn_pending:
|
if not self._spawn_pending:
|
||||||
raise RuntimeError("Spawn not pending, can't generate progress")
|
self.log.warning("Spawn not pending, can't generate progress for %s", self._log_name)
|
||||||
|
return
|
||||||
|
|
||||||
await yield_({
|
await yield_({
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
|
@@ -26,6 +26,7 @@ Other components
|
|||||||
- public_url
|
- public_url
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import os
|
import os
|
||||||
@@ -74,18 +75,20 @@ def mock_open_session(username, service, encoding):
|
|||||||
|
|
||||||
class MockSpawner(LocalProcessSpawner):
|
class MockSpawner(LocalProcessSpawner):
|
||||||
"""Base mock spawner
|
"""Base mock spawner
|
||||||
|
|
||||||
- disables user-switching that we need root permissions to do
|
- disables user-switching that we need root permissions to do
|
||||||
- spawns `jupyterhub.tests.mocksu` instead of a full single-user server
|
- spawns `jupyterhub.tests.mocksu` instead of a full single-user server
|
||||||
"""
|
"""
|
||||||
def make_preexec_fn(self, *a, **kw):
|
def make_preexec_fn(self, *a, **kw):
|
||||||
# skip the setuid stuff
|
# skip the setuid stuff
|
||||||
return
|
return
|
||||||
|
|
||||||
def _set_user_changed(self, name, old, new):
|
def _set_user_changed(self, name, old, new):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def user_env(self, env):
|
def user_env(self, env):
|
||||||
|
if self.handler:
|
||||||
|
env['HANDLER_ARGS'] = self.handler.request.query
|
||||||
return env
|
return env
|
||||||
|
|
||||||
@default('cmd')
|
@default('cmd')
|
||||||
@@ -150,9 +153,8 @@ class BadSpawner(MockSpawner):
|
|||||||
class SlowBadSpawner(MockSpawner):
|
class SlowBadSpawner(MockSpawner):
|
||||||
"""Spawner that fails after a short delay"""
|
"""Spawner that fails after a short delay"""
|
||||||
|
|
||||||
@gen.coroutine
|
async def start(self):
|
||||||
def start(self):
|
await asyncio.sleep(0.5)
|
||||||
yield gen.sleep(0.1)
|
|
||||||
raise RuntimeError("I don't work!")
|
raise RuntimeError("I don't work!")
|
||||||
|
|
||||||
|
|
||||||
|
@@ -29,7 +29,7 @@ class ArgsHandler(web.RequestHandler):
|
|||||||
self.write(json.dumps(sys.argv))
|
self.write(json.dumps(sys.argv))
|
||||||
|
|
||||||
def main(args):
|
def main(args):
|
||||||
|
|
||||||
app = web.Application([
|
app = web.Application([
|
||||||
(r'.*/args', ArgsHandler),
|
(r'.*/args', ArgsHandler),
|
||||||
(r'.*/env', EnvHandler),
|
(r'.*/env', EnvHandler),
|
||||||
|
@@ -103,6 +103,8 @@ def api_request(app, *api_path, **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.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']
|
||||||
|
if not kwargs.get('stream', False) and resp.content:
|
||||||
|
assert resp.headers.get('content-type') == 'application/json'
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@@ -611,6 +613,32 @@ def test_spawn(app):
|
|||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@mark.gen_test
|
||||||
|
def test_spawn_handler(app):
|
||||||
|
"""Test that the requesting Handler is passed to Spawner.handler"""
|
||||||
|
db = app.db
|
||||||
|
name = 'salmon'
|
||||||
|
user = add_user(db, app=app, name=name)
|
||||||
|
app_user = app.users[name]
|
||||||
|
|
||||||
|
# spawn via API with ?foo=bar
|
||||||
|
r = yield api_request(app, 'users', name, 'server', method='post', params={'foo': 'bar'})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
# verify that request params got passed down
|
||||||
|
# implemented in MockSpawner
|
||||||
|
url = public_url(app, user)
|
||||||
|
r = yield async_requests.get(ujoin(url, 'env'))
|
||||||
|
env = r.json()
|
||||||
|
assert 'HANDLER_ARGS' in env
|
||||||
|
assert env['HANDLER_ARGS'] == 'foo=bar'
|
||||||
|
# make user spawner.handler doesn't persist after spawn finishes
|
||||||
|
assert app_user.spawner.handler is None
|
||||||
|
|
||||||
|
r = yield api_request(app, 'users', name, 'server', method='delete')
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
@mark.slow
|
@mark.slow
|
||||||
@mark.gen_test
|
@mark.gen_test
|
||||||
def test_slow_spawn(app, no_patience, slow_spawn):
|
def test_slow_spawn(app, no_patience, slow_spawn):
|
||||||
@@ -656,7 +684,8 @@ def test_slow_spawn(app, no_patience, slow_spawn):
|
|||||||
assert not app_user.spawner._stop_pending
|
assert not app_user.spawner._stop_pending
|
||||||
assert app_user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
r = yield api_request(app, 'users', name, 'server', method='delete')
|
r = yield api_request(app, 'users', name, 'server', method='delete')
|
||||||
assert r.status_code == 400
|
# 204 deleted if there's no such server
|
||||||
|
assert r.status_code == 204
|
||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
assert app.users.count_active_users()['active'] == 0
|
assert app.users.count_active_users()['active'] == 0
|
||||||
|
|
||||||
@@ -727,6 +756,8 @@ def test_progress(request, app, no_patience, slow_spawn):
|
|||||||
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
request.addfinalizer(r.close)
|
request.addfinalizer(r.close)
|
||||||
|
assert r.headers['content-type'] == 'text/event-stream'
|
||||||
|
|
||||||
ex = async_requests.executor
|
ex = async_requests.executor
|
||||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||||
evt = yield ex.submit(next_event, line_iter)
|
evt = yield ex.submit(next_event, line_iter)
|
||||||
@@ -788,6 +819,7 @@ def test_progress_ready(request, app):
|
|||||||
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
request.addfinalizer(r.close)
|
request.addfinalizer(r.close)
|
||||||
|
assert r.headers['content-type'] == 'text/event-stream'
|
||||||
ex = async_requests.executor
|
ex = async_requests.executor
|
||||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||||
evt = yield ex.submit(next_event, line_iter)
|
evt = yield ex.submit(next_event, line_iter)
|
||||||
@@ -807,6 +839,7 @@ def test_progress_bad(request, app, no_patience, bad_spawn):
|
|||||||
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
request.addfinalizer(r.close)
|
request.addfinalizer(r.close)
|
||||||
|
assert r.headers['content-type'] == 'text/event-stream'
|
||||||
ex = async_requests.executor
|
ex = async_requests.executor
|
||||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||||
evt = yield ex.submit(next_event, line_iter)
|
evt = yield ex.submit(next_event, line_iter)
|
||||||
@@ -828,6 +861,7 @@ def test_progress_bad_slow(request, app, no_patience, slow_bad_spawn):
|
|||||||
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
r = yield api_request(app, 'users', name, 'server/progress', stream=True)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
request.addfinalizer(r.close)
|
request.addfinalizer(r.close)
|
||||||
|
assert r.headers['content-type'] == 'text/event-stream'
|
||||||
ex = async_requests.executor
|
ex = async_requests.executor
|
||||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||||
evt = yield ex.submit(next_event, line_iter)
|
evt = yield ex.submit(next_event, line_iter)
|
||||||
@@ -1195,14 +1229,19 @@ def test_token_as_user_deprecated(app, as_user, for_user, status):
|
|||||||
|
|
||||||
|
|
||||||
@mark.gen_test
|
@mark.gen_test
|
||||||
@mark.parametrize("headers, status, note", [
|
@mark.parametrize("headers, status, note, expires_in", [
|
||||||
({}, 200, 'test note'),
|
({}, 200, 'test note', None),
|
||||||
({}, 200, ''),
|
({}, 200, '', 100),
|
||||||
({'Authorization': 'token bad'}, 403, ''),
|
({'Authorization': 'token bad'}, 403, '', None),
|
||||||
])
|
])
|
||||||
def test_get_new_token(app, headers, status, note):
|
def test_get_new_token(app, headers, status, note, expires_in):
|
||||||
|
options = {}
|
||||||
if note:
|
if note:
|
||||||
body = json.dumps({'note': note})
|
options['note'] = note
|
||||||
|
if expires_in:
|
||||||
|
options['expires_in'] = expires_in
|
||||||
|
if options:
|
||||||
|
body = json.dumps(options)
|
||||||
else:
|
else:
|
||||||
body = ''
|
body = ''
|
||||||
# request a new token
|
# request a new token
|
||||||
@@ -1220,6 +1259,10 @@ def test_get_new_token(app, headers, status, note):
|
|||||||
assert reply['user'] == 'admin'
|
assert reply['user'] == 'admin'
|
||||||
assert reply['created']
|
assert reply['created']
|
||||||
assert 'last_activity' in reply
|
assert 'last_activity' in reply
|
||||||
|
if expires_in:
|
||||||
|
assert isinstance(reply['expires_at'], str)
|
||||||
|
else:
|
||||||
|
assert reply['expires_at'] is None
|
||||||
if note:
|
if note:
|
||||||
assert reply['note'] == note
|
assert reply['note'] == note
|
||||||
else:
|
else:
|
||||||
|
55
jupyterhub/tests/test_dummyauth.py
Normal file
55
jupyterhub/tests/test_dummyauth.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Tests for dummy authentication"""
|
||||||
|
|
||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from jupyterhub.auth import DummyAuthenticator
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_dummy_auth_without_global_password():
|
||||||
|
authenticator = DummyAuthenticator()
|
||||||
|
authorized = yield authenticator.get_authenticated_user(None, {
|
||||||
|
'username': 'test_user',
|
||||||
|
'password': 'test_pass',
|
||||||
|
})
|
||||||
|
assert authorized['name'] == 'test_user'
|
||||||
|
|
||||||
|
authorized = yield authenticator.get_authenticated_user(None, {
|
||||||
|
'username': 'test_user',
|
||||||
|
'password': '',
|
||||||
|
})
|
||||||
|
assert authorized['name'] == 'test_user'
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_dummy_auth_without_username():
|
||||||
|
authenticator = DummyAuthenticator()
|
||||||
|
authorized = yield authenticator.get_authenticated_user(None, {
|
||||||
|
'username': '',
|
||||||
|
'password': 'test_pass',
|
||||||
|
})
|
||||||
|
assert authorized is None
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_dummy_auth_with_global_password():
|
||||||
|
authenticator = DummyAuthenticator()
|
||||||
|
authenticator.password = "test_password"
|
||||||
|
|
||||||
|
authorized = yield authenticator.get_authenticated_user(None, {
|
||||||
|
'username': 'test_user',
|
||||||
|
'password': 'test_password',
|
||||||
|
})
|
||||||
|
assert authorized['name'] == 'test_user'
|
||||||
|
|
||||||
|
authorized = yield authenticator.get_authenticated_user(None, {
|
||||||
|
'username': 'test_user',
|
||||||
|
'password': 'qwerty',
|
||||||
|
})
|
||||||
|
assert authorized is None
|
||||||
|
|
||||||
|
authorized = yield authenticator.get_authenticated_user(None, {
|
||||||
|
'username': 'some_other_user',
|
||||||
|
'password': 'test_password',
|
||||||
|
})
|
||||||
|
assert authorized['name'] == 'some_other_user'
|
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for named servers"""
|
"""Tests for named servers"""
|
||||||
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -134,6 +135,21 @@ def test_delete_named_server(app, named_servers):
|
|||||||
'auth_state': None,
|
'auth_state': None,
|
||||||
'servers': {},
|
'servers': {},
|
||||||
})
|
})
|
||||||
|
# wrapper Spawner is gone
|
||||||
|
assert servername not in user.spawners
|
||||||
|
# low-level record still exists
|
||||||
|
assert servername in user.orm_spawners
|
||||||
|
|
||||||
|
r = yield api_request(
|
||||||
|
app, 'users', username, 'servers', servername,
|
||||||
|
method='delete',
|
||||||
|
data=json.dumps({'remove': True}),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 204
|
||||||
|
# low-level record is now removes
|
||||||
|
assert servername not in user.orm_spawners
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
def test_named_server_disabled(app):
|
def test_named_server_disabled(app):
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
"""Tests for HTML pages"""
|
"""Tests for HTML pages"""
|
||||||
|
|
||||||
|
import sys
|
||||||
from urllib.parse import urlencode, urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
@@ -168,6 +170,31 @@ def test_spawn_redirect(app):
|
|||||||
assert path == ujoin(app.base_url, '/user/%s/' % name)
|
assert path == ujoin(app.base_url, '/user/%s/' % name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_spawn_handler_access(app):
|
||||||
|
name = 'winston'
|
||||||
|
cookies = yield app.login_user(name)
|
||||||
|
u = app.users[orm.User.find(app.db, name)]
|
||||||
|
|
||||||
|
status = yield u.spawner.poll()
|
||||||
|
assert status is not None
|
||||||
|
|
||||||
|
# spawn server via browser link with ?arg=value
|
||||||
|
r = yield get_page('spawn', app, cookies=cookies, params={'arg': 'value'})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
# verify that request params got passed down
|
||||||
|
# implemented in MockSpawner
|
||||||
|
r = yield async_requests.get(ujoin(public_url(app, u), 'env'))
|
||||||
|
env = r.json()
|
||||||
|
assert 'HANDLER_ARGS' in env
|
||||||
|
assert env['HANDLER_ARGS'] == 'arg=value'
|
||||||
|
|
||||||
|
# stop server
|
||||||
|
r = yield api_request(app, 'users', name, 'server', method='delete')
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
def test_spawn_admin_access(app, admin_access):
|
def test_spawn_admin_access(app, admin_access):
|
||||||
"""GET /user/:name as admin with admin-access spawns user's server"""
|
"""GET /user/:name as admin with admin-access spawns user's server"""
|
||||||
@@ -573,6 +600,66 @@ def test_announcements(app, announcements):
|
|||||||
assert_announcement("logout", r.text)
|
assert_announcement("logout", r.text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"params",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"redirect_uri=/noexist",
|
||||||
|
"redirect_uri=ok&client_id=nosuchthing",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_bad_oauth_get(app, params):
|
||||||
|
cookies = yield app.login_user("authorizer")
|
||||||
|
r = yield get_page("hub/api/oauth2/authorize?" + params, app, hub=False, cookies=cookies)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gen_test
|
||||||
|
def test_token_page(app):
|
||||||
|
name = "cake"
|
||||||
|
cookies = yield app.login_user(name)
|
||||||
|
r = yield get_page("token", app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert urlparse(r.url).path.endswith('/hub/token')
|
||||||
|
def extract_body(r):
|
||||||
|
soup = BeautifulSoup(r.text, "html5lib")
|
||||||
|
import re
|
||||||
|
# trim empty lines
|
||||||
|
return re.sub(r"(\n\s*)+", "\n", soup.body.find(class_="container").text)
|
||||||
|
body = extract_body(r)
|
||||||
|
assert "Request new API token" in body, body
|
||||||
|
# no tokens yet, no lists
|
||||||
|
assert "API Tokens" not in body, body
|
||||||
|
assert "Authorized Applications" not in body, body
|
||||||
|
|
||||||
|
# request an API token
|
||||||
|
user = app.users[name]
|
||||||
|
token = user.new_api_token(expires_in=60, note="my-test-token")
|
||||||
|
app.db.commit()
|
||||||
|
|
||||||
|
r = yield get_page("token", app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
body = extract_body(r)
|
||||||
|
assert "API Tokens" in body, body
|
||||||
|
assert "my-test-token" in body, body
|
||||||
|
# no oauth tokens yet, shouldn't have that section
|
||||||
|
assert "Authorized Applications" not in body, body
|
||||||
|
|
||||||
|
# spawn the user to trigger oauth, etc.
|
||||||
|
# request an oauth token
|
||||||
|
user.spawner.cmd = [sys.executable, '-m', 'jupyterhub.singleuser']
|
||||||
|
r = yield get_page("spawn", app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
r = yield get_page("token", app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
body = extract_body(r)
|
||||||
|
assert "API Tokens" in body, body
|
||||||
|
assert "Server at %s" % user.base_url in body, body
|
||||||
|
assert "Authorized Applications" in body, body
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
def test_server_not_running_api_request(app):
|
def test_server_not_running_api_request(app):
|
||||||
cookies = yield app.login_user("bees")
|
cookies = yield app.login_user("bees")
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
|
"""Tests for service authentication"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
import copy
|
import copy
|
||||||
|
from functools import partial
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
@@ -24,7 +26,7 @@ from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated
|
|||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .mocking import public_url, public_host
|
from .mocking import public_url, public_host
|
||||||
from .test_api import add_user
|
from .test_api import add_user
|
||||||
from .utils import async_requests
|
from .utils import async_requests, AsyncSession
|
||||||
|
|
||||||
# mock for sending monotonic counter way into the future
|
# mock for sending monotonic counter way into the future
|
||||||
monotonic_future = mock.patch('time.monotonic', lambda : sys.maxsize)
|
monotonic_future = mock.patch('time.monotonic', lambda : sys.maxsize)
|
||||||
@@ -322,24 +324,29 @@ def test_hubauth_service_token(app, mockservice_url):
|
|||||||
def test_oauth_service(app, mockservice_url):
|
def test_oauth_service(app, mockservice_url):
|
||||||
service = mockservice_url
|
service = mockservice_url
|
||||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||||
# first request is only going to set login cookie
|
# first request is only going to login and get us to the oauth form page
|
||||||
s = requests.Session()
|
s = AsyncSession()
|
||||||
name = 'link'
|
name = 'link'
|
||||||
s.cookies = yield app.login_user(name)
|
s.cookies = yield app.login_user(name)
|
||||||
# run session.get in async_requests thread
|
|
||||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
r = yield s.get(url)
|
||||||
r = yield s_get(url)
|
r.raise_for_status()
|
||||||
|
# we should be looking at the oauth confirmation page
|
||||||
|
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
|
||||||
|
# verify oauth state cookie was set at some point
|
||||||
|
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||||
|
|
||||||
|
# submit the oauth form to complete authorization
|
||||||
|
r = yield s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url == url
|
assert r.url == url
|
||||||
# verify oauth cookie is set
|
# verify oauth cookie is set
|
||||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||||
# verify oauth state cookie has been consumed
|
# verify oauth state cookie has been consumed
|
||||||
assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys())
|
assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys())
|
||||||
# verify oauth state cookie was set at some point
|
|
||||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
|
||||||
|
|
||||||
# second request should be authenticated
|
# second request should be authenticated, which means no redirects
|
||||||
r = yield s_get(url, allow_redirects=False)
|
r = yield s.get(url, allow_redirects=False)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
@@ -376,25 +383,23 @@ def test_oauth_cookie_collision(app, mockservice_url):
|
|||||||
service = mockservice_url
|
service = mockservice_url
|
||||||
url = url_path_join(public_url(app, mockservice_url), 'owhoami/')
|
url = url_path_join(public_url(app, mockservice_url), 'owhoami/')
|
||||||
print(url)
|
print(url)
|
||||||
s = requests.Session()
|
s = AsyncSession()
|
||||||
name = 'mypha'
|
name = 'mypha'
|
||||||
s.cookies = yield app.login_user(name)
|
s.cookies = yield app.login_user(name)
|
||||||
# run session.get in async_requests thread
|
|
||||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
|
||||||
state_cookie_name = 'service-%s-oauth-state' % service.name
|
state_cookie_name = 'service-%s-oauth-state' % service.name
|
||||||
service_cookie_name = 'service-%s' % service.name
|
service_cookie_name = 'service-%s' % service.name
|
||||||
oauth_1 = yield s_get(url, allow_redirects=False)
|
oauth_1 = yield s.get(url)
|
||||||
print(oauth_1.headers)
|
print(oauth_1.headers)
|
||||||
print(oauth_1.cookies, oauth_1.url, url)
|
print(oauth_1.cookies, oauth_1.url, url)
|
||||||
assert state_cookie_name in s.cookies
|
assert state_cookie_name in s.cookies
|
||||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
state_cookies = [ c for c in s.cookies.keys() if c.startswith(state_cookie_name) ]
|
||||||
# only one state cookie
|
# only one state cookie
|
||||||
assert state_cookies == [state_cookie_name]
|
assert state_cookies == [state_cookie_name]
|
||||||
state_1 = s.cookies[state_cookie_name]
|
state_1 = s.cookies[state_cookie_name]
|
||||||
|
|
||||||
# start second oauth login before finishing the first
|
# start second oauth login before finishing the first
|
||||||
oauth_2 = yield s_get(url, allow_redirects=False)
|
oauth_2 = yield s.get(url)
|
||||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
state_cookies = [ c for c in s.cookies.keys() if c.startswith(state_cookie_name) ]
|
||||||
assert len(state_cookies) == 2
|
assert len(state_cookies) == 2
|
||||||
# get the random-suffix cookie name
|
# get the random-suffix cookie name
|
||||||
state_cookie_2 = sorted(state_cookies)[-1]
|
state_cookie_2 = sorted(state_cookies)[-1]
|
||||||
@@ -402,11 +407,14 @@ def test_oauth_cookie_collision(app, mockservice_url):
|
|||||||
assert s.cookies[state_cookie_name] == state_1
|
assert s.cookies[state_cookie_name] == state_1
|
||||||
|
|
||||||
# finish oauth 2
|
# finish oauth 2
|
||||||
url = oauth_2.headers['Location']
|
# submit the oauth form to complete authorization
|
||||||
if not urlparse(url).netloc:
|
r = yield s.post(
|
||||||
url = public_host(app) + url
|
oauth_2.url,
|
||||||
r = yield s_get(url)
|
data={'scopes': ['identify']},
|
||||||
|
headers={'Referer': oauth_2.url},
|
||||||
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
assert r.url == url
|
||||||
# after finishing, state cookie is cleared
|
# after finishing, state cookie is cleared
|
||||||
assert state_cookie_2 not in s.cookies
|
assert state_cookie_2 not in s.cookies
|
||||||
# service login cookie is set
|
# service login cookie is set
|
||||||
@@ -414,11 +422,14 @@ def test_oauth_cookie_collision(app, mockservice_url):
|
|||||||
service_cookie_2 = s.cookies[service_cookie_name]
|
service_cookie_2 = s.cookies[service_cookie_name]
|
||||||
|
|
||||||
# finish oauth 1
|
# finish oauth 1
|
||||||
url = oauth_1.headers['Location']
|
r = yield s.post(
|
||||||
if not urlparse(url).netloc:
|
oauth_1.url,
|
||||||
url = public_host(app) + url
|
data={'scopes': ['identify']},
|
||||||
r = yield s_get(url)
|
headers={'Referer': oauth_1.url},
|
||||||
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
assert r.url == url
|
||||||
|
|
||||||
# after finishing, state cookie is cleared (again)
|
# after finishing, state cookie is cleared (again)
|
||||||
assert state_cookie_name not in s.cookies
|
assert state_cookie_name not in s.cookies
|
||||||
# service login cookie is set (again, to a different value)
|
# service login cookie is set (again, to a different value)
|
||||||
@@ -443,7 +454,7 @@ def test_oauth_logout(app, mockservice_url):
|
|||||||
service_cookie_name = 'service-%s' % service.name
|
service_cookie_name = 'service-%s' % service.name
|
||||||
url = url_path_join(public_url(app, mockservice_url), 'owhoami/?foo=bar')
|
url = url_path_join(public_url(app, mockservice_url), 'owhoami/?foo=bar')
|
||||||
# first request is only going to set login cookie
|
# first request is only going to set login cookie
|
||||||
s = requests.Session()
|
s = AsyncSession()
|
||||||
name = 'propha'
|
name = 'propha'
|
||||||
app_user = add_user(app.db, app=app, name=name)
|
app_user = add_user(app.db, app=app, name=name)
|
||||||
def auth_tokens():
|
def auth_tokens():
|
||||||
@@ -458,13 +469,16 @@ def test_oauth_logout(app, mockservice_url):
|
|||||||
|
|
||||||
s.cookies = yield app.login_user(name)
|
s.cookies = yield app.login_user(name)
|
||||||
assert 'jupyterhub-session-id' in s.cookies
|
assert 'jupyterhub-session-id' in s.cookies
|
||||||
# run session.get in async_requests thread
|
r = yield s.get(url)
|
||||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
r.raise_for_status()
|
||||||
r = yield s_get(url)
|
assert urlparse(r.url).path.endswith('oauth2/authorize')
|
||||||
|
# submit the oauth form to complete authorization
|
||||||
|
r = yield s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url == url
|
assert r.url == url
|
||||||
|
|
||||||
# second request should be authenticated
|
# second request should be authenticated
|
||||||
r = yield s_get(url, allow_redirects=False)
|
r = yield s.get(url, allow_redirects=False)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
@@ -483,13 +497,13 @@ def test_oauth_logout(app, mockservice_url):
|
|||||||
assert len(auth_tokens()) == 1
|
assert len(auth_tokens()) == 1
|
||||||
|
|
||||||
# hit hub logout URL
|
# hit hub logout URL
|
||||||
r = yield s_get(public_url(app, path='hub/logout'))
|
r = yield s.get(public_url(app, path='hub/logout'))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
# verify that all cookies other than the service cookie are cleared
|
# verify that all cookies other than the service cookie are cleared
|
||||||
assert list(s.cookies.keys()) == [service_cookie_name]
|
assert list(s.cookies.keys()) == [service_cookie_name]
|
||||||
# verify that clearing session id invalidates service cookie
|
# verify that clearing session id invalidates service cookie
|
||||||
# i.e. redirect back to login page
|
# i.e. redirect back to login page
|
||||||
r = yield s_get(url)
|
r = yield s.get(url)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.url.split('?')[0] == public_url(app, path='hub/login')
|
assert r.url.split('?')[0] == public_url(app, path='hub/login')
|
||||||
|
|
||||||
@@ -506,7 +520,7 @@ def test_oauth_logout(app, mockservice_url):
|
|||||||
# check that we got the old session id back
|
# check that we got the old session id back
|
||||||
assert session_id == s.cookies['jupyterhub-session-id']
|
assert session_id == s.cookies['jupyterhub-session-id']
|
||||||
|
|
||||||
r = yield s_get(url, allow_redirects=False)
|
r = yield s.get(url, allow_redirects=False)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
|
@@ -10,7 +10,7 @@ import jupyterhub
|
|||||||
from .mocking import StubSingleUserSpawner, public_url
|
from .mocking import StubSingleUserSpawner, public_url
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
|
|
||||||
from .utils import async_requests
|
from .utils import async_requests, AsyncSession
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gen_test
|
@pytest.mark.gen_test
|
||||||
@@ -41,9 +41,20 @@ def test_singleuser_auth(app):
|
|||||||
r = yield async_requests.get(url_path_join(url, 'logout'), cookies=cookies)
|
r = yield async_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
|
# accessing another user's server hits the oauth confirmation page
|
||||||
cookies = yield app.login_user('burgess')
|
cookies = yield app.login_user('burgess')
|
||||||
r = yield async_requests.get(url, cookies=cookies)
|
s = AsyncSession()
|
||||||
|
s.cookies = cookies
|
||||||
|
r = yield s.get(url)
|
||||||
|
assert urlparse(r.url).path.endswith('/oauth2/authorize')
|
||||||
|
# submit the oauth form to complete authorization
|
||||||
|
r = yield s.post(
|
||||||
|
r.url,
|
||||||
|
data={'scopes': ['identify']},
|
||||||
|
headers={'Referer': r.url},
|
||||||
|
)
|
||||||
|
assert urlparse(r.url).path.rstrip('/').endswith('/user/nandy/tree')
|
||||||
|
# user isn't authorized, should raise 403
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
assert 'burgess' in r.text
|
assert 'burgess' in r.text
|
||||||
|
|
||||||
|
@@ -5,7 +5,7 @@ from certipy import Certipy
|
|||||||
|
|
||||||
class _AsyncRequests:
|
class _AsyncRequests:
|
||||||
"""Wrapper around requests to return a Future from request methods
|
"""Wrapper around requests to return a Future from request methods
|
||||||
|
|
||||||
A single thread is allocated to avoid blocking the IOLoop thread.
|
A single thread is allocated to avoid blocking the IOLoop thread.
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -15,9 +15,17 @@ class _AsyncRequests:
|
|||||||
requests_method = getattr(requests, name)
|
requests_method = getattr(requests, name)
|
||||||
return lambda *args, **kwargs: self.executor.submit(requests_method, *args, **kwargs)
|
return lambda *args, **kwargs: self.executor.submit(requests_method, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
# async_requests.get = requests.get returning a Future, etc.
|
# async_requests.get = requests.get returning a Future, etc.
|
||||||
async_requests = _AsyncRequests()
|
async_requests = _AsyncRequests()
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncSession(requests.Session):
|
||||||
|
"""requests.Session object that runs in the background thread"""
|
||||||
|
def request(self, *args, **kwargs):
|
||||||
|
return async_requests.executor.submit(super().request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def ssl_setup(cert_dir, authority_name):
|
def ssl_setup(cert_dir, authority_name):
|
||||||
# Set up the external certs with the same authority as the internal
|
# Set up the external certs with the same authority as the internal
|
||||||
# one so that certificate trust works regardless of chosen endpoint.
|
# one so that certificate trust works regardless of chosen endpoint.
|
||||||
|
@@ -4,7 +4,8 @@ Traitlets that are used in JupyterHub
|
|||||||
# 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.
|
||||||
|
|
||||||
from traitlets import List, Unicode, Integer, TraitType, TraitError
|
import entrypoints
|
||||||
|
from traitlets import List, Unicode, Integer, Type, TraitType, TraitError
|
||||||
|
|
||||||
|
|
||||||
class URLPrefix(Unicode):
|
class URLPrefix(Unicode):
|
||||||
@@ -91,3 +92,46 @@ class Callable(TraitType):
|
|||||||
return value
|
return value
|
||||||
else:
|
else:
|
||||||
self.error(obj, value)
|
self.error(obj, value)
|
||||||
|
|
||||||
|
|
||||||
|
class EntryPointType(Type):
|
||||||
|
"""Entry point-extended Type
|
||||||
|
|
||||||
|
classes can be registered via entry points
|
||||||
|
in addition to standard 'mypackage.MyClass' strings
|
||||||
|
"""
|
||||||
|
|
||||||
|
_original_help = ''
|
||||||
|
|
||||||
|
def __init__(self, *args, entry_point_group, **kwargs):
|
||||||
|
self.entry_point_group = entry_point_group
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def help(self):
|
||||||
|
"""Extend help by listing currently installed choices"""
|
||||||
|
chunks = [self._original_help]
|
||||||
|
chunks.append("Currently installed: ")
|
||||||
|
for key, entry_point in self.load_entry_points().items():
|
||||||
|
chunks.append(" - {}: {}.{}".format(key, entry_point.module_name, entry_point.object_name))
|
||||||
|
return '\n'.join(chunks)
|
||||||
|
|
||||||
|
@help.setter
|
||||||
|
def help(self, value):
|
||||||
|
self._original_help = value
|
||||||
|
|
||||||
|
def load_entry_points(self):
|
||||||
|
"""Load my entry point group"""
|
||||||
|
# load the group
|
||||||
|
group = entrypoints.get_group_named(self.entry_point_group)
|
||||||
|
# make it case-insensitive
|
||||||
|
return {key.lower(): value for key, value in group.items()}
|
||||||
|
|
||||||
|
def validate(self, obj, value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
# first, look up in entry point registry
|
||||||
|
registry = self.load_entry_points()
|
||||||
|
key = value.lower()
|
||||||
|
if key in registry:
|
||||||
|
value = registry[key].load()
|
||||||
|
return super().validate(obj, value)
|
||||||
|
@@ -6,7 +6,6 @@ from datetime import datetime, timedelta
|
|||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from oauth2.error import ClientNotFoundError
|
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
@@ -183,19 +182,41 @@ class User:
|
|||||||
await self.save_auth_state(auth_state)
|
await self.save_auth_state(auth_state)
|
||||||
return auth_state
|
return auth_state
|
||||||
|
|
||||||
def _new_spawner(self, name, spawner_class=None, **kwargs):
|
|
||||||
|
def all_spawners(self, include_default=True):
|
||||||
|
"""Generator yielding all my spawners
|
||||||
|
|
||||||
|
including those that are not running.
|
||||||
|
|
||||||
|
Spawners that aren't running will be low-level orm.Spawner objects,
|
||||||
|
while those that are will be higher-level Spawner wrapper objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for name, orm_spawner in sorted(self.orm_user.orm_spawners.items()):
|
||||||
|
if name == '' and not include_default:
|
||||||
|
continue
|
||||||
|
if name and not self.allow_named_servers:
|
||||||
|
continue
|
||||||
|
if name in self.spawners:
|
||||||
|
# yield wrapper if it exists (server may be active)
|
||||||
|
yield self.spawners[name]
|
||||||
|
else:
|
||||||
|
# otherwise, yield low-level ORM object (server is not active)
|
||||||
|
yield orm_spawner
|
||||||
|
|
||||||
|
def _new_spawner(self, server_name, spawner_class=None, **kwargs):
|
||||||
"""Create a new spawner"""
|
"""Create a new spawner"""
|
||||||
if spawner_class is None:
|
if spawner_class is None:
|
||||||
spawner_class = self.spawner_class
|
spawner_class = self.spawner_class
|
||||||
self.log.debug("Creating %s for %s:%s", spawner_class, self.name, name)
|
self.log.debug("Creating %s for %s:%s", spawner_class, self.name, server_name)
|
||||||
|
|
||||||
orm_spawner = self.orm_spawners.get(name)
|
orm_spawner = self.orm_spawners.get(server_name)
|
||||||
if orm_spawner is None:
|
if orm_spawner is None:
|
||||||
orm_spawner = orm.Spawner(user=self.orm_user, name=name)
|
orm_spawner = orm.Spawner(user=self.orm_user, name=server_name)
|
||||||
self.db.add(orm_spawner)
|
self.db.add(orm_spawner)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
assert name in self.orm_spawners
|
assert server_name in self.orm_spawners
|
||||||
if name == '' and self.state:
|
if server_name == '' and self.state:
|
||||||
# migrate user.state to spawner.state
|
# migrate user.state to spawner.state
|
||||||
orm_spawner.state = self.state
|
orm_spawner.state = self.state
|
||||||
self.state = None
|
self.state = None
|
||||||
@@ -203,15 +224,15 @@ class User:
|
|||||||
# use fully quoted name for client_id because it will be used in cookie-name
|
# use fully quoted name for client_id because it will be used in cookie-name
|
||||||
# self.escaped_name may contain @ which is legal in URLs but not cookie keys
|
# self.escaped_name may contain @ which is legal in URLs but not cookie keys
|
||||||
client_id = 'jupyterhub-user-%s' % quote(self.name)
|
client_id = 'jupyterhub-user-%s' % quote(self.name)
|
||||||
if name:
|
if server_name:
|
||||||
client_id = '%s-%s' % (client_id, quote(name))
|
client_id = '%s-%s' % (client_id, quote(server_name))
|
||||||
spawn_kwargs = dict(
|
spawn_kwargs = dict(
|
||||||
user=self,
|
user=self,
|
||||||
orm_spawner=orm_spawner,
|
orm_spawner=orm_spawner,
|
||||||
hub=self.settings.get('hub'),
|
hub=self.settings.get('hub'),
|
||||||
authenticator=self.authenticator,
|
authenticator=self.authenticator,
|
||||||
config=self.settings.get('config'),
|
config=self.settings.get('config'),
|
||||||
proxy_spec=url_path_join(self.proxy_spec, name, '/'),
|
proxy_spec=url_path_join(self.proxy_spec, server_name, '/'),
|
||||||
db=self.db,
|
db=self.db,
|
||||||
oauth_client_id=client_id,
|
oauth_client_id=client_id,
|
||||||
cookie_options = self.settings.get('cookie_options', {}),
|
cookie_options = self.settings.get('cookie_options', {}),
|
||||||
@@ -334,6 +355,13 @@ class User:
|
|||||||
else:
|
else:
|
||||||
return self.base_url
|
return self.base_url
|
||||||
|
|
||||||
|
def server_url(self, server_name=''):
|
||||||
|
"""Get the url for a server with a given name"""
|
||||||
|
if not server_name:
|
||||||
|
return self.url
|
||||||
|
else:
|
||||||
|
return url_path_join(self.url, server_name)
|
||||||
|
|
||||||
def progress_url(self, server_name=''):
|
def progress_url(self, server_name=''):
|
||||||
"""API URL for progress endpoint for a server with a given name"""
|
"""API URL for progress endpoint for a server with a given name"""
|
||||||
url_parts = [self.settings['hub'].base_url, 'api/users', self.escaped_name]
|
url_parts = [self.settings['hub'].base_url, 'api/users', self.escaped_name]
|
||||||
@@ -343,7 +371,7 @@ class User:
|
|||||||
url_parts.extend(['server/progress'])
|
url_parts.extend(['server/progress'])
|
||||||
return url_path_join(*url_parts)
|
return url_path_join(*url_parts)
|
||||||
|
|
||||||
async def spawn(self, server_name='', options=None):
|
async def spawn(self, server_name='', options=None, handler=None):
|
||||||
"""Start the user's spawner
|
"""Start the user's spawner
|
||||||
|
|
||||||
depending from the value of JupyterHub.allow_named_servers
|
depending from the value of JupyterHub.allow_named_servers
|
||||||
@@ -373,6 +401,9 @@ class User:
|
|||||||
spawner.server = server = Server(orm_server=orm_server)
|
spawner.server = server = Server(orm_server=orm_server)
|
||||||
assert spawner.orm_spawner.server is orm_server
|
assert spawner.orm_spawner.server is orm_server
|
||||||
|
|
||||||
|
# pass requesting handler to the spawner
|
||||||
|
# e.g. for processing GET params
|
||||||
|
spawner.handler = handler
|
||||||
# Passing user_options to the spawner
|
# Passing user_options to the spawner
|
||||||
spawner.user_options = options or {}
|
spawner.user_options = options or {}
|
||||||
# we are starting a new server, make sure it doesn't restore state
|
# we are starting a new server, make sure it doesn't restore state
|
||||||
@@ -384,17 +415,14 @@ class User:
|
|||||||
client_id = spawner.oauth_client_id
|
client_id = spawner.oauth_client_id
|
||||||
oauth_provider = self.settings.get('oauth_provider')
|
oauth_provider = self.settings.get('oauth_provider')
|
||||||
if oauth_provider:
|
if oauth_provider:
|
||||||
client_store = oauth_provider.client_authenticator.client_store
|
oauth_client = oauth_provider.fetch_by_client_id(client_id)
|
||||||
try:
|
|
||||||
oauth_client = client_store.fetch_by_client_id(client_id)
|
|
||||||
except ClientNotFoundError:
|
|
||||||
oauth_client = None
|
|
||||||
# create a new OAuth client + secret on every launch
|
# create a new OAuth client + secret on every launch
|
||||||
# containers that resume will be updated below
|
# containers that resume will be updated below
|
||||||
client_store.add_client(client_id, api_token,
|
oauth_provider.add_client(
|
||||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
client_id, api_token,
|
||||||
description="Server at %s" % (url_path_join(self.base_url, server_name) + '/'),
|
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||||
)
|
description="Server at %s" % (url_path_join(self.base_url, server_name) + '/'),
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# trigger pre-spawn hook on authenticator
|
# trigger pre-spawn hook on authenticator
|
||||||
@@ -469,10 +497,10 @@ class User:
|
|||||||
)
|
)
|
||||||
# update OAuth client secret with updated API token
|
# update OAuth client secret with updated API token
|
||||||
if oauth_provider:
|
if oauth_provider:
|
||||||
client_store = oauth_provider.client_authenticator.client_store
|
oauth_provider.add_client(
|
||||||
client_store.add_client(client_id, spawner.api_token,
|
client_id, spawner.api_token,
|
||||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -497,6 +525,9 @@ class User:
|
|||||||
# raise original exception
|
# raise original exception
|
||||||
spawner._start_pending = False
|
spawner._start_pending = False
|
||||||
raise e
|
raise e
|
||||||
|
finally:
|
||||||
|
# clear reference to handler after start finishes
|
||||||
|
spawner.handler = None
|
||||||
spawner.start_polling()
|
spawner.start_polling()
|
||||||
|
|
||||||
# store state
|
# store state
|
||||||
@@ -572,11 +603,25 @@ class User:
|
|||||||
# remove server entry from db
|
# remove server entry from db
|
||||||
spawner.server = None
|
spawner.server = None
|
||||||
if not spawner.will_resume:
|
if not spawner.will_resume:
|
||||||
# find and remove the API token if the spawner isn't
|
# find and remove the API token and oauth client if the spawner isn't
|
||||||
# going to re-use it next time
|
# going to re-use it next time
|
||||||
orm_token = orm.APIToken.find(self.db, api_token)
|
orm_token = orm.APIToken.find(self.db, api_token)
|
||||||
if orm_token:
|
if orm_token:
|
||||||
self.db.delete(orm_token)
|
self.db.delete(orm_token)
|
||||||
|
# remove oauth client as well
|
||||||
|
# handle upgrades from 0.8, where client id will be `user-USERNAME`,
|
||||||
|
# not just `jupyterhub-user-USERNAME`
|
||||||
|
client_ids = (
|
||||||
|
spawner.oauth_client_id,
|
||||||
|
spawner.oauth_client_id.split('-', 1)[1],
|
||||||
|
)
|
||||||
|
for oauth_client in (
|
||||||
|
self.db
|
||||||
|
.query(orm.OAuthClient)
|
||||||
|
.filter(orm.OAuthClient.identifier.in_(client_ids))
|
||||||
|
):
|
||||||
|
self.log.debug("Deleting oauth client %s", oauth_client.identifier)
|
||||||
|
self.db.delete(oauth_client)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
finally:
|
finally:
|
||||||
spawner.orm_spawner.started = None
|
spawner.orm_spawner.started = None
|
||||||
|
@@ -258,14 +258,14 @@ def authenticated_403(self):
|
|||||||
Like tornado.web.authenticated, this decorator raises a 403 error
|
Like tornado.web.authenticated, this decorator raises a 403 error
|
||||||
instead of redirecting to login.
|
instead of redirecting to login.
|
||||||
"""
|
"""
|
||||||
if self.get_current_user() is None:
|
if self.current_user is None:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
|
|
||||||
@auth_decorator
|
@auth_decorator
|
||||||
def admin_only(self):
|
def admin_only(self):
|
||||||
"""Decorator for restricting access to admin users"""
|
"""Decorator for restricting access to admin users"""
|
||||||
user = self.get_current_user()
|
user = self.current_user
|
||||||
if user is None or not user.admin:
|
if user is None or not user.admin:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
@@ -471,7 +471,11 @@ def maybe_future(obj):
|
|||||||
elif isinstance(obj, concurrent.futures.Future):
|
elif isinstance(obj, concurrent.futures.Future):
|
||||||
return asyncio.wrap_future(obj)
|
return asyncio.wrap_future(obj)
|
||||||
else:
|
else:
|
||||||
return to_asyncio_future(gen.maybe_future(obj))
|
# could also check for tornado.concurrent.Future
|
||||||
|
# but with tornado >= 5 tornado.Future is asyncio.Future
|
||||||
|
f = asyncio.Future()
|
||||||
|
f.set_result(obj)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
@@ -10,12 +10,14 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "python ./bower-lite",
|
"postinstall": "python ./bower-lite",
|
||||||
|
"fmt": "prettier --write --trailing-comma es5 share/jupyterhub/static/js/*",
|
||||||
"lessc": "lessc"
|
"lessc": "lessc"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"clean-css": "^3.4.13",
|
||||||
"less": "^2.7.1",
|
"less": "^2.7.1",
|
||||||
"less-plugin-clean-css": "^1.5.1",
|
"less-plugin-clean-css": "^1.5.1",
|
||||||
"clean-css": "^3.4.13"
|
"prettier": "^1.14.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^3.3.7",
|
"bootstrap": "^3.3.7",
|
||||||
|
@@ -1,6 +1,11 @@
|
|||||||
name: jupyterhub
|
name: jupyterhub
|
||||||
type: sphinx
|
type: sphinx
|
||||||
conda:
|
conda:
|
||||||
file: docs/environment.yml
|
file: docs/environment.yml
|
||||||
python:
|
python:
|
||||||
version: 3
|
version: 3
|
||||||
|
formats:
|
||||||
|
- htmlzip
|
||||||
|
- epub
|
||||||
|
# pdf disabled due to bug in sphinx 1.8 + recommonmark
|
||||||
|
# - pdf
|
||||||
|
@@ -1,10 +1,11 @@
|
|||||||
alembic
|
alembic
|
||||||
async_generator>=1.8
|
async_generator>=1.8
|
||||||
|
entrypoints
|
||||||
traitlets>=4.3.2
|
traitlets>=4.3.2
|
||||||
tornado>=5.0
|
tornado>=5.0
|
||||||
jinja2
|
jinja2
|
||||||
pamela
|
pamela
|
||||||
python-oauth2>=1.0
|
oauthlib>=2.0
|
||||||
python-dateutil
|
python-dateutil
|
||||||
SQLAlchemy>=1.1
|
SQLAlchemy>=1.1
|
||||||
requests
|
requests
|
||||||
|
11
setup.py
11
setup.py
@@ -106,6 +106,17 @@ setup_args = dict(
|
|||||||
platforms = "Linux, Mac OS X",
|
platforms = "Linux, Mac OS X",
|
||||||
keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'],
|
keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'],
|
||||||
python_requires = ">=3.5",
|
python_requires = ">=3.5",
|
||||||
|
entry_points = {
|
||||||
|
'jupyterhub.authenticators': [
|
||||||
|
'default = jupyterhub.auth:PAMAuthenticator',
|
||||||
|
'pam = jupyterhub.auth:PAMAuthenticator',
|
||||||
|
'dummy = jupyterhub.auth:DummyAuthenticator',
|
||||||
|
],
|
||||||
|
'jupyterhub.spawners': [
|
||||||
|
'default = jupyterhub.spawner:LocalProcessSpawner',
|
||||||
|
'localprocess = jupyterhub.spawner:LocalProcessSpawner',
|
||||||
|
],
|
||||||
|
},
|
||||||
classifiers = [
|
classifiers = [
|
||||||
'Intended Audience :: Developers',
|
'Intended Audience :: Developers',
|
||||||
'Intended Audience :: System Administrators',
|
'Intended Audience :: System Administrators',
|
||||||
|
@@ -1,226 +1,298 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, moment, JHAPI, utils) {
|
require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function(
|
||||||
"use strict";
|
$,
|
||||||
|
bs,
|
||||||
|
moment,
|
||||||
|
JHAPI,
|
||||||
|
utils
|
||||||
|
) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
var base_url = window.jhdata.base_url;
|
var base_url = window.jhdata.base_url;
|
||||||
var prefix = window.jhdata.prefix;
|
var prefix = window.jhdata.prefix;
|
||||||
var admin_access = window.jhdata.admin_access;
|
var admin_access = window.jhdata.admin_access;
|
||||||
var options_form = window.jhdata.options_form;
|
var options_form = window.jhdata.options_form;
|
||||||
|
|
||||||
var api = new JHAPI(base_url);
|
var api = new JHAPI(base_url);
|
||||||
|
|
||||||
function get_row (element) {
|
function getRow(element) {
|
||||||
while (!element.hasClass("user-row")) {
|
var original = element;
|
||||||
element = element.parent();
|
while (!element.hasClass("server-row")) {
|
||||||
}
|
element = element.parent();
|
||||||
return element;
|
if (element[0].tagName === "BODY") {
|
||||||
|
console.error("Couldn't find row for", original);
|
||||||
|
throw new Error("No server-row found");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
function resort (col, order) {
|
function resort(col, order) {
|
||||||
var query = window.location.search.slice(1).split('&');
|
var query = window.location.search.slice(1).split("&");
|
||||||
// if col already present in args, remove it
|
// if col already present in args, remove it
|
||||||
var i = 0;
|
var i = 0;
|
||||||
while (i < query.length) {
|
while (i < query.length) {
|
||||||
if (query[i] === 'sort=' + col) {
|
if (query[i] === "sort=" + col) {
|
||||||
query.splice(i,1);
|
query.splice(i, 1);
|
||||||
if (query[i] && query[i].substr(0, 6) === 'order=') {
|
if (query[i] && query[i].substr(0, 6) === "order=") {
|
||||||
query.splice(i,1);
|
query.splice(i, 1);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// add new order to the front
|
} else {
|
||||||
if (order) {
|
i += 1;
|
||||||
query.unshift('order=' + order);
|
}
|
||||||
}
|
|
||||||
query.unshift('sort=' + col);
|
|
||||||
// reload page with new order
|
|
||||||
window.location = window.location.pathname + '?' + query.join('&');
|
|
||||||
}
|
}
|
||||||
|
// add new order to the front
|
||||||
$("th").map(function (i, th) {
|
if (order) {
|
||||||
th = $(th);
|
query.unshift("order=" + order);
|
||||||
var col = th.data('sort');
|
|
||||||
if (!col || col.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var order = th.find('i').hasClass('fa-sort-desc') ? 'asc':'desc';
|
|
||||||
th.find('a').click(
|
|
||||||
function () {
|
|
||||||
resort(col, order);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
$(".time-col").map(function (i, el) {
|
|
||||||
// convert ISO datestamps to nice momentjs ones
|
|
||||||
el = $(el);
|
|
||||||
let m = moment(new Date(el.text().trim()));
|
|
||||||
el.text(m.isValid() ? m.fromNow() : "Never");
|
|
||||||
});
|
|
||||||
|
|
||||||
$(".stop-server").click(function () {
|
|
||||||
var el = $(this);
|
|
||||||
var row = get_row(el);
|
|
||||||
var user = row.data('user');
|
|
||||||
el.text("stopping...");
|
|
||||||
api.stop_server(user, {
|
|
||||||
success: function () {
|
|
||||||
el.text('stop server').addClass('hidden');
|
|
||||||
row.find('.access-server').addClass('hidden');
|
|
||||||
row.find('.start-server').removeClass('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(".access-server").map(function (i, el) {
|
|
||||||
el = $(el);
|
|
||||||
var user = get_row(el).data('user');
|
|
||||||
el.attr('href', utils.url_path_join(prefix, 'user', user) + '/');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (admin_access && options_form) {
|
|
||||||
// if admin access and options form are enabled
|
|
||||||
// link to spawn page instead of making API requests
|
|
||||||
$('.start-server').map(function (i, el) {
|
|
||||||
el = $(el);
|
|
||||||
var user = get_row(el).data('user');
|
|
||||||
el.attr('href', utils.url_path_join(prefix, 'hub/spawn', user));
|
|
||||||
})
|
|
||||||
// cannot start all servers in this case
|
|
||||||
// since it would mean opening a bunch of tabs
|
|
||||||
$('#start-all-servers').addClass('hidden');
|
|
||||||
} else {
|
|
||||||
$(".start-server").click(function () {
|
|
||||||
var el = $(this);
|
|
||||||
var row = get_row(el);
|
|
||||||
var user = row.data('user');
|
|
||||||
el.text("starting...");
|
|
||||||
api.start_server(user, {
|
|
||||||
success: function () {
|
|
||||||
el.text('start server').addClass('hidden');
|
|
||||||
row.find('.stop-server').removeClass('hidden');
|
|
||||||
row.find('.access-server').removeClass('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
query.unshift("sort=" + col);
|
||||||
|
// reload page with new order
|
||||||
|
window.location = window.location.pathname + "?" + query.join("&");
|
||||||
|
}
|
||||||
|
|
||||||
$(".edit-user").click(function () {
|
$("th").map(function(i, th) {
|
||||||
var el = $(this);
|
th = $(th);
|
||||||
var row = get_row(el);
|
var col = th.data("sort");
|
||||||
var user = row.data('user');
|
if (!col || col.length === 0) {
|
||||||
var admin = row.data('admin');
|
return;
|
||||||
var dialog = $("#edit-user-dialog");
|
|
||||||
dialog.data('user', user);
|
|
||||||
dialog.find(".username-input").val(user);
|
|
||||||
dialog.find(".admin-checkbox").attr("checked", admin==='True');
|
|
||||||
dialog.modal();
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#edit-user-dialog").find(".save-button").click(function () {
|
|
||||||
var dialog = $("#edit-user-dialog");
|
|
||||||
var user = dialog.data('user');
|
|
||||||
var name = dialog.find(".username-input").val();
|
|
||||||
var admin = dialog.find(".admin-checkbox").prop("checked");
|
|
||||||
api.edit_user(user, {
|
|
||||||
admin: admin,
|
|
||||||
name: name
|
|
||||||
}, {
|
|
||||||
success: function () {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$(".delete-user").click(function () {
|
|
||||||
var el = $(this);
|
|
||||||
var row = get_row(el);
|
|
||||||
var user = row.data('user');
|
|
||||||
var dialog = $("#delete-user-dialog");
|
|
||||||
dialog.find(".delete-username").text(user);
|
|
||||||
dialog.modal();
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#delete-user-dialog").find(".delete-button").click(function () {
|
|
||||||
var dialog = $("#delete-user-dialog");
|
|
||||||
var username = dialog.find(".delete-username").text();
|
|
||||||
console.log("deleting", username);
|
|
||||||
api.delete_user(username, {
|
|
||||||
success: function () {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#add-users").click(function () {
|
|
||||||
var dialog = $("#add-users-dialog");
|
|
||||||
dialog.find(".username-input").val('');
|
|
||||||
dialog.find(".admin-checkbox").prop("checked", false);
|
|
||||||
dialog.modal();
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#add-users-dialog").find(".save-button").click(function () {
|
|
||||||
var dialog = $("#add-users-dialog");
|
|
||||||
var lines = dialog.find(".username-input").val().split('\n');
|
|
||||||
var admin = dialog.find(".admin-checkbox").prop("checked");
|
|
||||||
var usernames = [];
|
|
||||||
lines.map(function (line) {
|
|
||||||
var username = line.trim();
|
|
||||||
if (username.length) {
|
|
||||||
usernames.push(username);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
api.add_users(usernames, {admin: admin}, {
|
|
||||||
success: function () {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#stop-all-servers").click(function () {
|
|
||||||
$("#stop-all-servers-dialog").modal();
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#start-all-servers").click(function () {
|
|
||||||
$("#start-all-servers-dialog").modal();
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#stop-all-servers-dialog").find(".stop-all-button").click(function () {
|
|
||||||
// stop all clicks all the active stop buttons
|
|
||||||
$('.stop-server').not('.hidden').click();
|
|
||||||
});
|
|
||||||
|
|
||||||
function start(el) {
|
|
||||||
return function(){
|
|
||||||
$(el).click();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
var order = th.find("i").hasClass("fa-sort-desc") ? "asc" : "desc";
|
||||||
|
th.find("a").click(function() {
|
||||||
|
resort(col, order);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$("#start-all-servers-dialog").find(".start-all-button").click(function () {
|
$(".time-col").map(function(i, el) {
|
||||||
$('.start-server').not('.hidden').each(function(i){
|
// convert ISO datestamps to nice momentjs ones
|
||||||
setTimeout(start(this), i * 500);
|
el = $(el);
|
||||||
|
var m = moment(new Date(el.text().trim()));
|
||||||
|
el.text(m.isValid() ? m.fromNow() : "Never");
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".stop-server").click(function() {
|
||||||
|
var el = $(this);
|
||||||
|
var row = getRow(el);
|
||||||
|
var serverName = row.data("server-name");
|
||||||
|
var user = row.data("user");
|
||||||
|
el.text("stopping...");
|
||||||
|
var stop = function(options) {
|
||||||
|
return api.stop_server(user, options);
|
||||||
|
};
|
||||||
|
if (serverName !== "") {
|
||||||
|
stop = function(options) {
|
||||||
|
return api.stop_named_server(user, serverName, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
stop({
|
||||||
|
success: function() {
|
||||||
|
el.text("stop " + serverName).addClass("hidden");
|
||||||
|
row.find(".access-server").addClass("hidden");
|
||||||
|
row.find(".start-server").removeClass("hidden");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".delete-server").click(function() {
|
||||||
|
var el = $(this);
|
||||||
|
var row = getRow(el);
|
||||||
|
var serverName = row.data("server-name");
|
||||||
|
var user = row.data("user");
|
||||||
|
el.text("deleting...");
|
||||||
|
api.delete_named_server(user, serverName, {
|
||||||
|
success: function() {
|
||||||
|
row.remove();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".access-server").map(function(i, el) {
|
||||||
|
el = $(el);
|
||||||
|
var row = getRow(el);
|
||||||
|
var user = row.data("user");
|
||||||
|
var serverName = row.data("server-name");
|
||||||
|
el.attr(
|
||||||
|
"href",
|
||||||
|
utils.url_path_join(prefix, "user", user, serverName) + "/"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (admin_access && options_form) {
|
||||||
|
// if admin access and options form are enabled
|
||||||
|
// link to spawn page instead of making API requests
|
||||||
|
$(".start-server").map(function(i, el) {
|
||||||
|
el = $(el);
|
||||||
|
var user = getRow(el).data("user");
|
||||||
|
// TODO: include server-name
|
||||||
|
el.attr("href", utils.url_path_join(prefix, "hub/spawn", user));
|
||||||
|
});
|
||||||
|
// cannot start all servers in this case
|
||||||
|
// since it would mean opening a bunch of tabs
|
||||||
|
$("#start-all-servers").addClass("hidden");
|
||||||
|
} else {
|
||||||
|
$(".start-server").click(function() {
|
||||||
|
var el = $(this);
|
||||||
|
var row = getRow(el);
|
||||||
|
var user = row.data("user");
|
||||||
|
var serverName = row.data("server-name");
|
||||||
|
el.text("starting...");
|
||||||
|
var start = function(options) {
|
||||||
|
return api.start_server(user, options);
|
||||||
|
};
|
||||||
|
if (serverName !== "") {
|
||||||
|
start = function(options) {
|
||||||
|
return api.start_named_server(user, serverName, options);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
start({
|
||||||
|
success: function() {
|
||||||
|
el.text("start " + serverName).addClass("hidden");
|
||||||
|
row.find(".stop-server").removeClass("hidden");
|
||||||
|
row.find(".access-server").removeClass("hidden");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(".edit-user").click(function() {
|
||||||
|
var el = $(this);
|
||||||
|
var row = getRow(el);
|
||||||
|
var user = row.data("user");
|
||||||
|
var admin = row.data("admin");
|
||||||
|
var dialog = $("#edit-user-dialog");
|
||||||
|
dialog.data("user", user);
|
||||||
|
dialog.find(".username-input").val(user);
|
||||||
|
dialog.find(".admin-checkbox").attr("checked", admin === "True");
|
||||||
|
dialog.modal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#edit-user-dialog")
|
||||||
|
.find(".save-button")
|
||||||
|
.click(function() {
|
||||||
|
var dialog = $("#edit-user-dialog");
|
||||||
|
var user = dialog.data("user");
|
||||||
|
var name = dialog.find(".username-input").val();
|
||||||
|
var admin = dialog.find(".admin-checkbox").prop("checked");
|
||||||
|
api.edit_user(
|
||||||
|
user,
|
||||||
|
{
|
||||||
|
admin: admin,
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: function() {
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".delete-user").click(function() {
|
||||||
|
var el = $(this);
|
||||||
|
var row = getRow(el);
|
||||||
|
var user = row.data("user");
|
||||||
|
var dialog = $("#delete-user-dialog");
|
||||||
|
dialog.find(".delete-username").text(user);
|
||||||
|
dialog.modal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#delete-user-dialog")
|
||||||
|
.find(".delete-button")
|
||||||
|
.click(function() {
|
||||||
|
var dialog = $("#delete-user-dialog");
|
||||||
|
var username = dialog.find(".delete-username").text();
|
||||||
|
console.log("deleting", username);
|
||||||
|
api.delete_user(username, {
|
||||||
|
success: function() {
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#add-users").click(function() {
|
||||||
|
var dialog = $("#add-users-dialog");
|
||||||
|
dialog.find(".username-input").val("");
|
||||||
|
dialog.find(".admin-checkbox").prop("checked", false);
|
||||||
|
dialog.modal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#add-users-dialog")
|
||||||
|
.find(".save-button")
|
||||||
|
.click(function() {
|
||||||
|
var dialog = $("#add-users-dialog");
|
||||||
|
var lines = dialog
|
||||||
|
.find(".username-input")
|
||||||
|
.val()
|
||||||
|
.split("\n");
|
||||||
|
var admin = dialog.find(".admin-checkbox").prop("checked");
|
||||||
|
var usernames = [];
|
||||||
|
lines.map(function(line) {
|
||||||
|
var username = line.trim();
|
||||||
|
if (username.length) {
|
||||||
|
usernames.push(username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.add_users(
|
||||||
|
usernames,
|
||||||
|
{ admin: admin },
|
||||||
|
{
|
||||||
|
success: function() {
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#stop-all-servers").click(function() {
|
||||||
|
$("#stop-all-servers-dialog").modal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#start-all-servers").click(function() {
|
||||||
|
$("#start-all-servers-dialog").modal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#stop-all-servers-dialog")
|
||||||
|
.find(".stop-all-button")
|
||||||
|
.click(function() {
|
||||||
|
// stop all clicks all the active stop buttons
|
||||||
|
$(".stop-server")
|
||||||
|
.not(".hidden")
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
function start(el) {
|
||||||
|
return function() {
|
||||||
|
$(el).click();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#start-all-servers-dialog")
|
||||||
|
.find(".start-all-button")
|
||||||
|
.click(function() {
|
||||||
|
$(".start-server")
|
||||||
|
.not(".hidden")
|
||||||
|
.each(function(i) {
|
||||||
|
setTimeout(start(this), i * 500);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#shutdown-hub").click(function () {
|
$("#shutdown-hub").click(function() {
|
||||||
var dialog = $("#shutdown-hub-dialog");
|
var dialog = $("#shutdown-hub-dialog");
|
||||||
dialog.find("input[type=checkbox]").prop("checked", true);
|
dialog.find("input[type=checkbox]").prop("checked", true);
|
||||||
dialog.modal();
|
dialog.modal();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#shutdown-hub-dialog").find(".shutdown-button").click(function () {
|
$("#shutdown-hub-dialog")
|
||||||
var dialog = $("#shutdown-hub-dialog");
|
.find(".shutdown-button")
|
||||||
var servers = dialog.find(".shutdown-servers-checkbox").prop("checked");
|
.click(function() {
|
||||||
var proxy = dialog.find(".shutdown-proxy-checkbox").prop("checked");
|
var dialog = $("#shutdown-hub-dialog");
|
||||||
api.shutdown_hub({
|
var servers = dialog.find(".shutdown-servers-checkbox").prop("checked");
|
||||||
proxy: proxy,
|
var proxy = dialog.find(".shutdown-proxy-checkbox").prop("checked");
|
||||||
servers: servers,
|
api.shutdown_hub({
|
||||||
});
|
proxy: proxy,
|
||||||
|
servers: servers,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,13 +1,98 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
require(["jquery", "jhapi"], function($, JHAPI) {
|
require(["jquery", "moment", "jhapi"], function($, moment, JHAPI) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var base_url = window.jhdata.base_url;
|
var base_url = window.jhdata.base_url;
|
||||||
var user = window.jhdata.user;
|
var user = window.jhdata.user;
|
||||||
var api = new JHAPI(base_url);
|
var api = new JHAPI(base_url);
|
||||||
|
|
||||||
|
// Named servers buttons
|
||||||
|
|
||||||
|
function getRow(element) {
|
||||||
|
while (!element.hasClass("home-server-row")) {
|
||||||
|
element = element.parent();
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableRow(row) {
|
||||||
|
row
|
||||||
|
.find(".btn")
|
||||||
|
.attr("disabled", true)
|
||||||
|
.off("click");
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableRow(row, running) {
|
||||||
|
// enable buttons on a server row
|
||||||
|
// once the server is running or not
|
||||||
|
row.find(".btn").attr("disabled", false);
|
||||||
|
row.find(".start-server").click(startServer);
|
||||||
|
row.find(".stop-server").click(stopServer);
|
||||||
|
row.find(".delete-server").click(deleteServer);
|
||||||
|
|
||||||
|
if (running) {
|
||||||
|
row.find(".start-server").addClass("hidden");
|
||||||
|
row.find(".delete-server").addClass("hidden");
|
||||||
|
row.find(".stop-server").removeClass("hidden");
|
||||||
|
} else {
|
||||||
|
row.find(".start-server").removeClass("hidden");
|
||||||
|
row.find(".delete-server").removeClass("hidden");
|
||||||
|
row.find(".stop-server").addClass("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer() {
|
||||||
|
var row = getRow($(this));
|
||||||
|
var serverName = row.data("server-name");
|
||||||
|
|
||||||
|
// before request
|
||||||
|
disableRow(row);
|
||||||
|
|
||||||
|
// request
|
||||||
|
api.stop_named_server(user, serverName, {
|
||||||
|
success: function() {
|
||||||
|
enableRow(row, false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer() {
|
||||||
|
var row = getRow($(this));
|
||||||
|
var serverName = row.data("server-name");
|
||||||
|
|
||||||
|
// before request
|
||||||
|
disableRow(row);
|
||||||
|
|
||||||
|
// request
|
||||||
|
api.start_named_server(user, serverName, {
|
||||||
|
success: function(reply) {
|
||||||
|
enableRow(row, true);
|
||||||
|
// TODO: this may 404 on the wrong server
|
||||||
|
// in case of slow startup
|
||||||
|
// it should really redirect to a `/spawn?server=...` page
|
||||||
|
window.location.href = row.find(".server-link").attr("href");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteServer() {
|
||||||
|
var row = getRow($(this));
|
||||||
|
var serverName = row.data("server-name");
|
||||||
|
|
||||||
|
// before request
|
||||||
|
disableRow(row);
|
||||||
|
|
||||||
|
// request
|
||||||
|
api.delete_named_server(user, serverName, {
|
||||||
|
success: function() {
|
||||||
|
row.remove();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial state: hook up click events
|
||||||
$("#stop").click(function() {
|
$("#stop").click(function() {
|
||||||
$("#start")
|
$("#start")
|
||||||
.attr("disabled", true)
|
.attr("disabled", true)
|
||||||
@@ -19,11 +104,32 @@ require(["jquery", "jhapi"], function($, JHAPI) {
|
|||||||
success: function() {
|
success: function() {
|
||||||
$("#start")
|
$("#start")
|
||||||
.text("Start My Server")
|
.text("Start My Server")
|
||||||
.attr("title", "Start your server")
|
.attr("title", "Start your default server")
|
||||||
.attr("disabled", false)
|
.attr("disabled", false)
|
||||||
.off("click");
|
.off("click");
|
||||||
$("#stop").hide();
|
},
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#new-server-btn").click(function() {
|
||||||
|
var serverName = $("#new-server-name").val();
|
||||||
|
api.start_named_server(user, serverName, {
|
||||||
|
success: function(reply) {
|
||||||
|
// reload after creating the server
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".start-server").click(startServer);
|
||||||
|
$(".stop-server").click(stopServer);
|
||||||
|
$(".delete-server").click(deleteServer);
|
||||||
|
|
||||||
|
// render timestamps
|
||||||
|
$(".time-col").map(function(i, el) {
|
||||||
|
// convert ISO datestamps to nice momentjs ones
|
||||||
|
el = $(el);
|
||||||
|
var m = moment(new Date(el.text().trim()));
|
||||||
|
el.text(m.isValid() ? m.fromNow() : "Never");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,154 +1,160 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
define(['jquery', 'utils'], function ($, utils) {
|
define(["jquery", "utils"], function($, utils) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var JHAPI = function (base_url) {
|
var JHAPI = function(base_url) {
|
||||||
this.base_url = base_url;
|
this.base_url = base_url;
|
||||||
};
|
};
|
||||||
|
|
||||||
var default_options = {
|
var default_options = {
|
||||||
type: 'GET',
|
type: "GET",
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
cache: false,
|
cache: false,
|
||||||
dataType : "json",
|
dataType: "json",
|
||||||
processData: false,
|
processData: false,
|
||||||
success: null,
|
success: null,
|
||||||
error: utils.ajax_error_dialog,
|
error: utils.ajax_error_dialog,
|
||||||
};
|
};
|
||||||
|
|
||||||
var update = function (d1, d2) {
|
var update = function(d1, d2) {
|
||||||
$.map(d2, function (i, key) {
|
$.map(d2, function(i, key) {
|
||||||
d1[key] = d2[key];
|
d1[key] = d2[key];
|
||||||
});
|
});
|
||||||
return d1;
|
return d1;
|
||||||
};
|
};
|
||||||
|
|
||||||
var ajax_defaults = function (options) {
|
var ajax_defaults = function(options) {
|
||||||
var d = {};
|
var d = {};
|
||||||
update(d, default_options);
|
update(d, default_options);
|
||||||
update(d, options);
|
update(d, options);
|
||||||
return d;
|
return d;
|
||||||
};
|
};
|
||||||
|
|
||||||
JHAPI.prototype.api_request = function (path, options) {
|
JHAPI.prototype.api_request = function(path, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
options = ajax_defaults(options || {});
|
options = ajax_defaults(options || {});
|
||||||
var url = utils.url_path_join(
|
var url = utils.url_path_join(
|
||||||
this.base_url,
|
this.base_url,
|
||||||
'api',
|
"api",
|
||||||
utils.encode_uri_components(path)
|
utils.encode_uri_components(path)
|
||||||
);
|
);
|
||||||
$.ajax(url, options);
|
$.ajax(url, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
JHAPI.prototype.start_server = function (user, options) {
|
JHAPI.prototype.start_server = function(user, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
options = update(options, {type: 'POST', dataType: null});
|
options = update(options, { type: "POST", dataType: null });
|
||||||
this.api_request(
|
this.api_request(utils.url_path_join("users", user, "server"), options);
|
||||||
utils.url_path_join('users', user, 'server'),
|
};
|
||||||
options
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
JHAPI.prototype.stop_server = function (user, options) {
|
JHAPI.prototype.start_named_server = function(user, server_name, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
options = update(options, {type: 'DELETE', dataType: null});
|
options = update(options, { type: "POST", dataType: null });
|
||||||
this.api_request(
|
this.api_request(
|
||||||
utils.url_path_join('users', user, 'server'),
|
utils.url_path_join("users", user, "servers", server_name),
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
JHAPI.prototype.list_users = function (options) {
|
JHAPI.prototype.stop_server = function(user, options) {
|
||||||
this.api_request('users', options);
|
options = options || {};
|
||||||
};
|
options = update(options, { type: "DELETE", dataType: null });
|
||||||
|
this.api_request(utils.url_path_join("users", user, "server"), options);
|
||||||
|
};
|
||||||
|
|
||||||
JHAPI.prototype.get_user = function (user, options) {
|
JHAPI.prototype.stop_named_server = function(user, server_name, options) {
|
||||||
this.api_request(
|
options = options || {};
|
||||||
utils.url_path_join('users', user),
|
options = update(options, { type: "DELETE", dataType: null });
|
||||||
options
|
this.api_request(
|
||||||
);
|
utils.url_path_join("users", user, "servers", server_name),
|
||||||
};
|
options
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
JHAPI.prototype.add_users = function (usernames, userinfo, options) {
|
JHAPI.prototype.delete_named_server = function(user, server_name, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
var data = update(userinfo, {usernames: usernames});
|
options.data = JSON.stringify({ remove: true });
|
||||||
options = update(options, {
|
return this.stop_named_server(user, server_name, options);
|
||||||
type: 'POST',
|
};
|
||||||
dataType: null,
|
|
||||||
data: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.api_request('users', options);
|
JHAPI.prototype.list_users = function(options) {
|
||||||
};
|
this.api_request("users", options);
|
||||||
|
};
|
||||||
|
|
||||||
JHAPI.prototype.edit_user = function (user, userinfo, options) {
|
JHAPI.prototype.get_user = function(user, options) {
|
||||||
options = options || {};
|
this.api_request(utils.url_path_join("users", user), options);
|
||||||
options = update(options, {
|
};
|
||||||
type: 'PATCH',
|
|
||||||
dataType: null,
|
|
||||||
data: JSON.stringify(userinfo)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.api_request(
|
JHAPI.prototype.add_users = function(usernames, userinfo, options) {
|
||||||
utils.url_path_join('users', user),
|
options = options || {};
|
||||||
options
|
var data = update(userinfo, { usernames: usernames });
|
||||||
);
|
options = update(options, {
|
||||||
};
|
type: "POST",
|
||||||
|
dataType: null,
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
JHAPI.prototype.admin_access = function (user, options) {
|
this.api_request("users", options);
|
||||||
options = options || {};
|
};
|
||||||
options = update(options, {
|
|
||||||
type: 'POST',
|
|
||||||
dataType: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.api_request(
|
JHAPI.prototype.edit_user = function(user, userinfo, options) {
|
||||||
utils.url_path_join('users', user, 'admin-access'),
|
options = options || {};
|
||||||
options
|
options = update(options, {
|
||||||
);
|
type: "PATCH",
|
||||||
};
|
dataType: null,
|
||||||
|
data: JSON.stringify(userinfo),
|
||||||
|
});
|
||||||
|
|
||||||
JHAPI.prototype.delete_user = function (user, options) {
|
this.api_request(utils.url_path_join("users", user), options);
|
||||||
options = options || {};
|
};
|
||||||
options = update(options, {type: 'DELETE', dataType: null});
|
|
||||||
this.api_request(
|
|
||||||
utils.url_path_join('users', user),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
JHAPI.prototype.request_token = function (user, props, options) {
|
JHAPI.prototype.admin_access = function(user, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
options = update(options, {type: 'POST'});
|
options = update(options, {
|
||||||
if (props) {
|
type: "POST",
|
||||||
options.data = JSON.stringify(props);
|
dataType: null,
|
||||||
}
|
});
|
||||||
this.api_request(
|
|
||||||
utils.url_path_join('users', user, 'tokens'),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
JHAPI.prototype.revoke_token = function (user, token_id, options) {
|
this.api_request(
|
||||||
options = options || {};
|
utils.url_path_join("users", user, "admin-access"),
|
||||||
options = update(options, {type: 'DELETE'});
|
options
|
||||||
this.api_request(
|
);
|
||||||
utils.url_path_join('users', user, 'tokens', token_id),
|
};
|
||||||
options
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
JHAPI.prototype.shutdown_hub = function (data, options) {
|
JHAPI.prototype.delete_user = function(user, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
options = update(options, {type: 'POST'});
|
options = update(options, { type: "DELETE", dataType: null });
|
||||||
if (data) {
|
this.api_request(utils.url_path_join("users", user), options);
|
||||||
options.data = JSON.stringify(data);
|
};
|
||||||
}
|
|
||||||
this.api_request('shutdown', options);
|
|
||||||
};
|
|
||||||
|
|
||||||
return JHAPI;
|
JHAPI.prototype.request_token = function(user, props, options) {
|
||||||
|
options = options || {};
|
||||||
|
options = update(options, { type: "POST" });
|
||||||
|
if (props) {
|
||||||
|
options.data = JSON.stringify(props);
|
||||||
|
}
|
||||||
|
this.api_request(utils.url_path_join("users", user, "tokens"), options);
|
||||||
|
};
|
||||||
|
|
||||||
|
JHAPI.prototype.revoke_token = function(user, token_id, options) {
|
||||||
|
options = options || {};
|
||||||
|
options = update(options, { type: "DELETE" });
|
||||||
|
this.api_request(
|
||||||
|
utils.url_path_join("users", user, "tokens", token_id),
|
||||||
|
options
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
JHAPI.prototype.shutdown_hub = function(data, options) {
|
||||||
|
options = options || {};
|
||||||
|
options = update(options, { type: "POST" });
|
||||||
|
if (data) {
|
||||||
|
options.data = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
this.api_request("shutdown", options);
|
||||||
|
};
|
||||||
|
|
||||||
|
return JHAPI;
|
||||||
});
|
});
|
||||||
|
@@ -11,7 +11,7 @@ require(["jquery", "jhapi", "moment"], function($, JHAPI, moment) {
|
|||||||
$(".time-col").map(function(i, el) {
|
$(".time-col").map(function(i, el) {
|
||||||
// convert ISO datestamps to nice momentjs ones
|
// convert ISO datestamps to nice momentjs ones
|
||||||
el = $(el);
|
el = $(el);
|
||||||
let m = moment(new Date(el.text().trim()));
|
var m = moment(new Date(el.text().trim()));
|
||||||
el.text(m.isValid() ? m.fromNow() : el.text());
|
el.text(m.isValid() ? m.fromNow() : el.text());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ require(["jquery", "jhapi", "moment"], function($, JHAPI, moment) {
|
|||||||
var el = $(this);
|
var el = $(this);
|
||||||
var row = get_token_row(el);
|
var row = get_token_row(el);
|
||||||
el.attr("disabled", true);
|
el.attr("disabled", true);
|
||||||
api.revoke_token(user, row.data('token-id'), {
|
api.revoke_token(user, row.data("token-id"), {
|
||||||
success: function(reply) {
|
success: function(reply) {
|
||||||
row.remove();
|
row.remove();
|
||||||
},
|
},
|
||||||
|
@@ -5,133 +5,140 @@
|
|||||||
// Modifications Copyright (c) Juptyer Development Team.
|
// Modifications Copyright (c) Juptyer Development Team.
|
||||||
// Distributed under the terms of the Modified BSD License.
|
// Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
define(['jquery'], function($){
|
define(["jquery"], function($) {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var url_path_join = function () {
|
var url_path_join = function() {
|
||||||
// join a sequence of url components with '/'
|
// join a sequence of url components with '/'
|
||||||
var url = '';
|
var url = "";
|
||||||
for (var i = 0; i < arguments.length; i++) {
|
for (var i = 0; i < arguments.length; i++) {
|
||||||
if (arguments[i] === '') {
|
if (arguments[i] === "") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (url.length > 0 && url[url.length-1] != '/') {
|
if (url.length > 0 && url[url.length - 1] != "/") {
|
||||||
url = url + '/' + arguments[i];
|
url = url + "/" + arguments[i];
|
||||||
} else {
|
} else {
|
||||||
url = url + arguments[i];
|
url = url + arguments[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
url = url.replace(/\/\/+/, '/');
|
url = url.replace(/\/\/+/, "/");
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
var parse_url = function (url) {
|
var parse_url = function(url) {
|
||||||
// an `a` element with an href allows attr-access to the parsed segments of a URL
|
// an `a` element with an href allows attr-access to the parsed segments of a URL
|
||||||
// a = parse_url("http://localhost:8888/path/name#hash")
|
// a = parse_url("http://localhost:8888/path/name#hash")
|
||||||
// a.protocol = "http:"
|
// a.protocol = "http:"
|
||||||
// a.host = "localhost:8888"
|
// a.host = "localhost:8888"
|
||||||
// a.hostname = "localhost"
|
// a.hostname = "localhost"
|
||||||
// a.port = 8888
|
// a.port = 8888
|
||||||
// a.pathname = "/path/name"
|
// a.pathname = "/path/name"
|
||||||
// a.hash = "#hash"
|
// a.hash = "#hash"
|
||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
return a;
|
return a;
|
||||||
};
|
};
|
||||||
|
|
||||||
var encode_uri_components = function (uri) {
|
var encode_uri_components = function(uri) {
|
||||||
// encode just the components of a multi-segment uri,
|
// encode just the components of a multi-segment uri,
|
||||||
// leaving '/' separators
|
// leaving '/' separators
|
||||||
return uri.split('/').map(encodeURIComponent).join('/');
|
return uri
|
||||||
};
|
.split("/")
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join("/");
|
||||||
|
};
|
||||||
|
|
||||||
var url_join_encode = function () {
|
var url_join_encode = function() {
|
||||||
// join a sequence of url components with '/',
|
// join a sequence of url components with '/',
|
||||||
// encoding each component with encodeURIComponent
|
// encoding each component with encodeURIComponent
|
||||||
return encode_uri_components(url_path_join.apply(null, arguments));
|
return encode_uri_components(url_path_join.apply(null, arguments));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var escape_html = function(text) {
|
||||||
|
// escape text to HTML
|
||||||
|
return $("<div/>")
|
||||||
|
.text(text)
|
||||||
|
.html();
|
||||||
|
};
|
||||||
|
|
||||||
var escape_html = function (text) {
|
var get_body_data = function(key) {
|
||||||
// escape text to HTML
|
// get a url-encoded item from body.data and decode it
|
||||||
return $("<div/>").text(text).html();
|
// we should never have any encoded URLs anywhere else in code
|
||||||
};
|
// until we are building an actual request
|
||||||
|
return decodeURIComponent($("body").data(key));
|
||||||
|
};
|
||||||
|
|
||||||
var get_body_data = function(key) {
|
// http://stackoverflow.com/questions/2400935/browser-detection-in-javascript
|
||||||
// get a url-encoded item from body.data and decode it
|
var browser = (function() {
|
||||||
// we should never have any encoded URLs anywhere else in code
|
if (typeof navigator === "undefined") {
|
||||||
// until we are building an actual request
|
// navigator undefined in node
|
||||||
return decodeURIComponent($('body').data(key));
|
return "None";
|
||||||
};
|
}
|
||||||
|
var N = navigator.appName,
|
||||||
|
ua = navigator.userAgent,
|
||||||
|
tem;
|
||||||
|
var M = ua.match(
|
||||||
|
/(opera|chrome|safari|firefox|msie)\/?\s*(\.?\d+(\.\d+)*)/i
|
||||||
|
);
|
||||||
|
if (M && (tem = ua.match(/version\/([\.\d]+)/i)) !== null) M[2] = tem[1];
|
||||||
|
M = M ? [M[1], M[2]] : [N, navigator.appVersion, "-?"];
|
||||||
|
return M;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// http://stackoverflow.com/questions/11219582/how-to-detect-my-browser-version-and-operating-system-using-javascript
|
||||||
|
var platform = (function() {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
// navigator undefined in node
|
||||||
|
return "None";
|
||||||
|
}
|
||||||
|
var OSName = "None";
|
||||||
|
if (navigator.appVersion.indexOf("Win") != -1) OSName = "Windows";
|
||||||
|
if (navigator.appVersion.indexOf("Mac") != -1) OSName = "MacOS";
|
||||||
|
if (navigator.appVersion.indexOf("X11") != -1) OSName = "UNIX";
|
||||||
|
if (navigator.appVersion.indexOf("Linux") != -1) OSName = "Linux";
|
||||||
|
return OSName;
|
||||||
|
})();
|
||||||
|
|
||||||
// http://stackoverflow.com/questions/2400935/browser-detection-in-javascript
|
var ajax_error_msg = function(jqXHR) {
|
||||||
var browser = (function() {
|
// Return a JSON error message if there is one,
|
||||||
if (typeof navigator === 'undefined') {
|
// otherwise the basic HTTP status text.
|
||||||
// navigator undefined in node
|
if (jqXHR.responseJSON && jqXHR.responseJSON.message) {
|
||||||
return 'None';
|
return jqXHR.responseJSON.message;
|
||||||
}
|
} else {
|
||||||
var N= navigator.appName, ua= navigator.userAgent, tem;
|
return jqXHR.statusText;
|
||||||
var M= ua.match(/(opera|chrome|safari|firefox|msie)\/?\s*(\.?\d+(\.\d+)*)/i);
|
}
|
||||||
if (M && (tem= ua.match(/version\/([\.\d]+)/i)) !== null) M[2]= tem[1];
|
};
|
||||||
M= M? [M[1], M[2]]: [N, navigator.appVersion,'-?'];
|
|
||||||
return M;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// http://stackoverflow.com/questions/11219582/how-to-detect-my-browser-version-and-operating-system-using-javascript
|
var log_ajax_error = function(jqXHR, status, error) {
|
||||||
var platform = (function () {
|
// log ajax failures with informative messages
|
||||||
if (typeof navigator === 'undefined') {
|
var msg = "API request failed (" + jqXHR.status + "): ";
|
||||||
// navigator undefined in node
|
console.log(jqXHR);
|
||||||
return 'None';
|
msg += ajax_error_msg(jqXHR);
|
||||||
}
|
console.log(msg);
|
||||||
var OSName="None";
|
return msg;
|
||||||
if (navigator.appVersion.indexOf("Win")!=-1) OSName="Windows";
|
};
|
||||||
if (navigator.appVersion.indexOf("Mac")!=-1) OSName="MacOS";
|
|
||||||
if (navigator.appVersion.indexOf("X11")!=-1) OSName="UNIX";
|
|
||||||
if (navigator.appVersion.indexOf("Linux")!=-1) OSName="Linux";
|
|
||||||
return OSName;
|
|
||||||
})();
|
|
||||||
|
|
||||||
var ajax_error_msg = function (jqXHR) {
|
var ajax_error_dialog = function(jqXHR, status, error) {
|
||||||
// Return a JSON error message if there is one,
|
console.log("ajax dialog", arguments);
|
||||||
// otherwise the basic HTTP status text.
|
var msg = log_ajax_error(jqXHR, status, error);
|
||||||
if (jqXHR.responseJSON && jqXHR.responseJSON.message) {
|
var dialog = $("#error-dialog");
|
||||||
return jqXHR.responseJSON.message;
|
dialog.find(".ajax-error").text(msg);
|
||||||
} else {
|
dialog.modal();
|
||||||
return jqXHR.statusText;
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var log_ajax_error = function (jqXHR, status, error) {
|
var utils = {
|
||||||
// log ajax failures with informative messages
|
url_path_join: url_path_join,
|
||||||
var msg = "API request failed (" + jqXHR.status + "): ";
|
url_join_encode: url_join_encode,
|
||||||
console.log(jqXHR);
|
encode_uri_components: encode_uri_components,
|
||||||
msg += ajax_error_msg(jqXHR);
|
escape_html: escape_html,
|
||||||
console.log(msg);
|
get_body_data: get_body_data,
|
||||||
return msg;
|
parse_url: parse_url,
|
||||||
};
|
browser: browser,
|
||||||
|
platform: platform,
|
||||||
|
ajax_error_msg: ajax_error_msg,
|
||||||
|
log_ajax_error: log_ajax_error,
|
||||||
|
ajax_error_dialog: ajax_error_dialog,
|
||||||
|
};
|
||||||
|
|
||||||
var ajax_error_dialog = function (jqXHR, status, error) {
|
return utils;
|
||||||
console.log("ajax dialog", arguments);
|
|
||||||
var msg = log_ajax_error(jqXHR, status, error);
|
|
||||||
var dialog = $("#error-dialog");
|
|
||||||
dialog.find(".ajax-error").text(msg);
|
|
||||||
dialog.modal();
|
|
||||||
};
|
|
||||||
|
|
||||||
var utils = {
|
|
||||||
url_path_join : url_path_join,
|
|
||||||
url_join_encode : url_join_encode,
|
|
||||||
encode_uri_components : encode_uri_components,
|
|
||||||
escape_html : escape_html,
|
|
||||||
get_body_data : get_body_data,
|
|
||||||
parse_url : parse_url,
|
|
||||||
browser : browser,
|
|
||||||
platform: platform,
|
|
||||||
ajax_error_msg : ajax_error_msg,
|
|
||||||
log_ajax_error : log_ajax_error,
|
|
||||||
ajax_error_dialog : ajax_error_dialog,
|
|
||||||
};
|
|
||||||
|
|
||||||
return utils;
|
|
||||||
});
|
});
|
||||||
|
@@ -1,3 +1,3 @@
|
|||||||
i.sort-icon {
|
i.sort-icon {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
@@ -24,7 +24,7 @@
|
|||||||
{% block thead %}
|
{% block thead %}
|
||||||
{{ th("User (%i)" % users|length, 'name') }}
|
{{ th("User (%i)" % users|length, 'name') }}
|
||||||
{{ th("Admin", 'admin') }}
|
{{ th("Admin", 'admin') }}
|
||||||
{{ th("Last Seen", 'last_activity') }}
|
{{ th("Last Activity", 'last_activity') }}
|
||||||
{{ th("Running (%i)" % running|length, 'running', colspan=2) }}
|
{{ th("Running (%i)" % running|length, 'running', colspan=2) }}
|
||||||
{% endblock thead %}
|
{% endblock thead %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -40,38 +40,66 @@
|
|||||||
<a id="shutdown-hub" role="button" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a>
|
<a id="shutdown-hub" role="button" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% for u in users %}
|
{% for user in users %}
|
||||||
<tr class="user-row" data-user="{{u.name}}" data-admin="{{u.admin}}">
|
{% for spawner in user.all_spawners() %}
|
||||||
|
<tr class="user-row server-row" id="user-{{user.name}}" data-user="{{ user.name }}" data-server-name="{{spawner.name}}" data-admin="{{user.admin}}">
|
||||||
{% block user_row scoped %}
|
{% block user_row scoped %}
|
||||||
<td class="name-col col-sm-2">{{u.name}}</td>
|
|
||||||
<td class="admin-col col-sm-2">{% if u.admin %}admin{% endif %}</td>
|
<td class="name-col col-sm-2">{{user.name}}
|
||||||
<td class="time-col col-sm-3">
|
{%- if spawner.name -%}
|
||||||
{%- if u.last_activity -%}
|
/{{ spawner.name }}
|
||||||
{{ u.last_activity.isoformat() + 'Z' }}
|
|
||||||
{%- else -%}
|
|
||||||
Never
|
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td class="admin-col col-sm-2">
|
||||||
|
{%- if spawner.name == '' -%}
|
||||||
|
{% if user.admin %}admin{% endif %}
|
||||||
|
{%- endif -%}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="time-col col-sm-3">
|
||||||
|
{%- if spawner.last_activity -%}
|
||||||
|
{{ spawner.last_activity.isoformat() + 'Z' }}
|
||||||
|
{%- else -%}
|
||||||
|
Never
|
||||||
|
{%- endif -%}
|
||||||
|
</td>
|
||||||
|
|
||||||
<td class="server-col col-sm-2 text-center">
|
<td class="server-col col-sm-2 text-center">
|
||||||
<a role="button" class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</a>
|
<a role="button" class="stop-server btn btn-xs btn-danger{% if not spawner.active %} hidden{% endif %}">
|
||||||
<a role="button" class="start-server btn btn-xs btn-primary {% if u.running %}hidden{% endif %}">start server</a>
|
stop server
|
||||||
|
</a>
|
||||||
|
<a role="button" class="start-server btn btn-xs btn-primary{% if spawner.active %} hidden{% endif %}">
|
||||||
|
start server
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="server-col col-sm-1 text-center">
|
<td class="server-col col-sm-1 text-center">
|
||||||
{% if admin_access %}
|
{%- if admin_access %}
|
||||||
<a role="button" class="access-server btn btn-xs btn-primary {% if not u.running %}hidden{% endif %}">access server</a>
|
<a role="button" class="access-server btn btn-xs btn-primary{% if not spawner.active %} hidden{% endif %}">
|
||||||
{% endif %}
|
access server
|
||||||
|
</a>
|
||||||
|
{%- endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="edit-col col-sm-1 text-center">
|
<td class="edit-col col-sm-1 text-center">
|
||||||
<a role="button" class="edit-user btn btn-xs btn-primary">edit</a>
|
{%- if spawner.name == '' -%}
|
||||||
|
<a role="button" class="edit-user btn btn-xs btn-primary">edit user</a>
|
||||||
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
<td class="edit-col col-sm-1 text-center">
|
<td class="edit-col col-sm-1 text-center">
|
||||||
{% if u.name != user.name %}
|
{%- if spawner.name == '' -%}
|
||||||
<a role="button" class="delete-user btn btn-xs btn-danger">delete</a>
|
{#- user row -#}
|
||||||
{% endif %}
|
{%- if user.name != current_user.name -%}
|
||||||
|
<a role="button" class="delete-user btn btn-xs btn-danger">delete user</a>
|
||||||
|
{%- endif -%}
|
||||||
|
{%- else -%}
|
||||||
|
{#- named spawner row -#}
|
||||||
|
<a role="button" class="delete-server btn btn-xs btn-warning">delete server</a>
|
||||||
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
{% endblock user_row %}
|
{% endblock user_row %}
|
||||||
</tr>
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -8,19 +8,78 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
{% if user.running %}
|
{% if default_server.active %}
|
||||||
<a id="stop" role="button" class="btn btn-lg btn-danger">Stop My Server</a>
|
<a id="stop" role="button" class="btn btn-lg btn-danger">
|
||||||
|
Stop My Server
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a id="start" role="button" class="btn btn-lg btn-primary" href="{{ url }}">
|
<a id="start" role="button" class="btn btn-lg btn-primary" href="{{ url }}">
|
||||||
{% if not user.active %}
|
{% if not default_server.active %}Start{% endif %}
|
||||||
Start
|
My Server
|
||||||
{% endif %}
|
|
||||||
My Server
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% if allow_named_servers %}
|
||||||
|
<h2>
|
||||||
|
Named Servers
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
In addition to your default server,
|
||||||
|
you may have additional servers with names.
|
||||||
|
This allows you to have more than one server running at the same time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="server-table table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Server name</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Last activity</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="home-server-row add-server-row">
|
||||||
|
<td colspan="4">
|
||||||
|
<input id="new-server-name" placeholder="Name your server">
|
||||||
|
<a role="button" id="new-server-btn" class="add-server btn btn-xs btn-primary">
|
||||||
|
Add New Server
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for spawner in user.all_spawners(include_default=False) %}
|
||||||
|
<tr class="home-server-row" data-server-name="{{ spawner.name }}">
|
||||||
|
{# name #}
|
||||||
|
<td>{{ spawner.name }}</td>
|
||||||
|
{# url #}
|
||||||
|
<td>
|
||||||
|
<a class="server-link {% if not spawner.ready %}hidden{% endif %}" href="{{ user.server_url(spawner.name) }}">
|
||||||
|
{{ user.server_url(spawner.name) }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{# activity #}
|
||||||
|
<td class='time-col'>
|
||||||
|
{% if spawner.last_activity %}
|
||||||
|
{{ spawner.last_activity.isoformat() + 'Z' }}
|
||||||
|
{% else %}
|
||||||
|
Never
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{# actions #}
|
||||||
|
<td>
|
||||||
|
<a role="button" class="stop-server btn btn-xs btn-danger{% if not spawner.active %} hidden{% endif %}" id="stop-{{ spawner.name }}">stop</a>
|
||||||
|
<a role="button" class="start-server btn btn-xs btn-primary {% if spawner.active %} hidden{% endif %}" id="start-{{ spawner.name }}"
|
||||||
|
>
|
||||||
|
start
|
||||||
|
</a>
|
||||||
|
<a role="button" class="delete-server btn btn-xs btn-danger{% if spawner.active %} hidden{% endif %}" id="delete-{{ spawner.name }}">delete</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
|
51
share/jupyterhub/templates/oauth.html
Normal file
51
share/jupyterhub/templates/oauth.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "page.html" %}
|
||||||
|
|
||||||
|
{% block login_widget %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="container col-md-6 col-md-offset-3">
|
||||||
|
<h1 class="text-center">Authorize access</h1>
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
A service is attempting to authorize with your
|
||||||
|
JupyterHub account
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }})
|
||||||
|
would like permission to identify you.
|
||||||
|
{% if scopes == ["identify"] %}
|
||||||
|
It will not be able to take actions on your behalf.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>The application will be able to:</h3>
|
||||||
|
<div>
|
||||||
|
<form method="POST" action="">
|
||||||
|
{% for scope in scopes %}
|
||||||
|
<div class="checkbox input-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="scopes"
|
||||||
|
checked="true"
|
||||||
|
title="This authorization is required"
|
||||||
|
disabled="disabled" {# disabled because it's required #}
|
||||||
|
value="{{ scope }}"
|
||||||
|
/>
|
||||||
|
{# disabled checkbox isn't included in form, so this is the real one #}
|
||||||
|
<input type="hidden" name="scopes" value="{{ scope }}"/>
|
||||||
|
<span>
|
||||||
|
{# TODO: use scope description when there's more than one #}
|
||||||
|
See your JupyterHub username and group membership (read-only).
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<input type="submit" value="Authorize" class="form-control btn-jupyter"/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
@@ -123,6 +123,7 @@
|
|||||||
{% block login_widget %}
|
{% block login_widget %}
|
||||||
<span id="login_widget">
|
<span id="login_widget">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
|
<p class="navbar-text">{{user.name}}</p>
|
||||||
<a id="logout" role="button" class="navbar-btn btn-sm btn btn-default" href="{{logout_url}}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a>
|
<a id="logout" role="button" class="navbar-btn btn-sm btn btn-default" href="{{logout_url}}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a id="login" role="button" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a>
|
<a id="login" role="button" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a>
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -ex
|
set -ex
|
||||||
|
|
||||||
stable=0.8
|
stable=0.9
|
||||||
|
|
||||||
for V in master $stable; do
|
for V in master $stable; do
|
||||||
docker build --build-arg JUPYTERHUB_VERSION=$V -t $DOCKER_REPO:$V .
|
docker build --build-arg JUPYTERHUB_VERSION=$V -t $DOCKER_REPO:$V .
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -ex
|
||||||
|
|
||||||
stable=0.8
|
stable=0.9
|
||||||
for V in master $stable; do
|
for V in master $stable; do
|
||||||
docker push $DOCKER_REPO:$V
|
docker push $DOCKER_REPO:$V
|
||||||
done
|
done
|
||||||
@@ -12,6 +13,10 @@ function get_hub_version() {
|
|||||||
hub_xyz=$(cat hub_version)
|
hub_xyz=$(cat hub_version)
|
||||||
split=( ${hub_xyz//./ } )
|
split=( ${hub_xyz//./ } )
|
||||||
hub_xy="${split[0]}.${split[1]}"
|
hub_xy="${split[0]}.${split[1]}"
|
||||||
|
# add .dev on hub_xy so it's 1.0.dev
|
||||||
|
if [[ ! -z "${split[3]}" ]]; then
|
||||||
|
hub_xy="${hub_xy}.${split[3]}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
# tag e.g. 0.8.1 with 0.8
|
# tag e.g. 0.8.1 with 0.8
|
||||||
get_hub_version $stable
|
get_hub_version $stable
|
||||||
@@ -22,3 +27,5 @@ docker push $DOCKER_REPO:$hub_xyz
|
|||||||
get_hub_version master
|
get_hub_version master
|
||||||
docker tag $DOCKER_REPO:master $DOCKER_REPO:$hub_xy
|
docker tag $DOCKER_REPO:master $DOCKER_REPO:$hub_xy
|
||||||
docker push $DOCKER_REPO:$hub_xy
|
docker push $DOCKER_REPO:$hub_xy
|
||||||
|
docker tag $DOCKER_REPO:master $DOCKER_REPO:$hub_xyz
|
||||||
|
docker push $DOCKER_REPO:$hub_xyz
|
||||||
|
1
spawners/__init__.py
Normal file
1
spawners/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .simplespawner import SimpleSpawner
|
43
spawners/simplespawner.py
Normal file
43
spawners/simplespawner.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import os
|
||||||
|
from traitlets import Unicode
|
||||||
|
|
||||||
|
from jupyterhub.spawner import LocalProcessSpawner
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleLocalProcessSpawner(LocalProcessSpawner):
|
||||||
|
"""
|
||||||
|
A version of LocalProcessSpawner that doesn't require users to exist on
|
||||||
|
the system beforehand.
|
||||||
|
|
||||||
|
Note: DO NOT USE THIS FOR PRODUCTION USE CASES! It is very insecure, and
|
||||||
|
provides absolutely no isolation between different users!
|
||||||
|
"""
|
||||||
|
|
||||||
|
home_path_template = Unicode(
|
||||||
|
'/tmp/{userid}',
|
||||||
|
config=True,
|
||||||
|
help='Template to expand to set the user home. {userid} and {username} are expanded'
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def home_path(self):
|
||||||
|
return self.home_path_template.format(
|
||||||
|
userid=self.user.id,
|
||||||
|
username=self.user.name
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_preexec_fn(self, name):
|
||||||
|
home = self.home_path
|
||||||
|
def preexec():
|
||||||
|
try:
|
||||||
|
os.makedirs(home, 0o755, exist_ok=True)
|
||||||
|
os.chdir(home)
|
||||||
|
except e:
|
||||||
|
print(e)
|
||||||
|
return preexec
|
||||||
|
|
||||||
|
def user_env(self, env):
|
||||||
|
env['USER'] = self.user.name
|
||||||
|
env['HOME'] = self.home_path
|
||||||
|
env['SHELL'] = '/bin/bash'
|
||||||
|
return env
|
16
testing/jupyterhub_config.py
Normal file
16
testing/jupyterhub_config.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""sample jupyterhub config file for testing
|
||||||
|
|
||||||
|
configures jupyterhub with dummyauthenticator and simplespawner
|
||||||
|
to enable testing without administrative privileges.
|
||||||
|
"""
|
||||||
|
|
||||||
|
c = get_config() # noqa
|
||||||
|
|
||||||
|
from jupyterhub.auth import DummyAuthenticator
|
||||||
|
c.JupyterHub.authenticator_class = DummyAuthenticator
|
||||||
|
|
||||||
|
# Optionally set a global password that all users must use
|
||||||
|
# c.DummyAuthenticator.password = "your_password"
|
||||||
|
|
||||||
|
from jupyterhub.spawners import SimpleSpawner
|
||||||
|
c.JupyterHub.spawner_class = SimpleSpawner
|
Reference in New Issue
Block a user