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
|
||||
# I100: Import statements are in the wrong order
|
||||
# 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 =
|
||||
.cache,
|
||||
|
@@ -17,6 +17,7 @@ services:
|
||||
|
||||
# installing dependencies
|
||||
before_install:
|
||||
- set -e
|
||||
- nvm install 6; nvm use 6
|
||||
- npm install
|
||||
- npm install -g configurable-http-proxy
|
||||
@@ -40,20 +41,25 @@ install:
|
||||
script:
|
||||
- |
|
||||
# run tests
|
||||
set -e
|
||||
if [[ "$TEST" != "docs" ]]; then
|
||||
pytest -v --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||
fi
|
||||
- |
|
||||
# build docs
|
||||
if [[ "$TEST" == "docs" ]]; then
|
||||
pushd docs
|
||||
pip install -r requirements.txt
|
||||
make html
|
||||
popd
|
||||
fi
|
||||
after_success:
|
||||
- codecov
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
include:
|
||||
- python: 3.6
|
||||
env: TEST=docs
|
||||
- python: 3.6
|
||||
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://localhost.jovyan.org:8000
|
||||
- python: 3.6
|
||||
|
@@ -1,98 +1,5 @@
|
||||
# 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).
|
||||
|
||||
|
||||
## Set up your development system
|
||||
|
||||
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
|
||||
```
|
||||
See the [contribution guide](https://jupyterhub.readthedocs.io/en/latest/index.html#contributor) section
|
||||
at the JupyterHub documentation.
|
10
README.md
10
README.md
@@ -11,8 +11,8 @@
|
||||
|
||||
|
||||
[](https://pypi.python.org/pypi/jupyterhub)
|
||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||
[](http://jupyterhub.readthedocs.io/en/0.7.2/?badge=0.7.2)
|
||||
[](https://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||
[](https://jupyterhub.readthedocs.io/en/0.7.2/?badge=0.7.2)
|
||||
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
||||
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
||||
@@ -124,7 +124,7 @@ more configuration of the system.
|
||||
|
||||
## 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.
|
||||
|
||||
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)
|
||||
- [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 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)
|
||||
|
||||
JupyterHub follows the Jupyter [Community Guides](https://jupyter.readthedocs.io/en/latest/community/content-community.html).
|
||||
|
||||
---
|
||||
|
||||
**[Technical Overview](#technical-overview)** |
|
||||
|
@@ -1,5 +1,6 @@
|
||||
-r requirements.txt
|
||||
mock
|
||||
beautifulsoup4
|
||||
codecov
|
||||
cryptography
|
||||
pytest-cov
|
||||
@@ -8,3 +9,6 @@ pytest>=3.3
|
||||
notebook
|
||||
requests-mock
|
||||
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
|
||||
channels:
|
||||
- conda-forge
|
||||
@@ -13,7 +15,9 @@ dependencies:
|
||||
- traitlets>=4.1
|
||||
- sphinx>=1.7
|
||||
- pip:
|
||||
- python-oauth2
|
||||
- oauthlib>=2.0
|
||||
- recommonmark==0.4.0
|
||||
- async_generator
|
||||
- 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
|
||||
sphinx>=1.7
|
||||
recommonmark==0.4.0
|
||||
sphinx-copybutton
|
||||
|
@@ -217,6 +217,13 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
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:
|
||||
'201':
|
||||
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
|
||||
|
||||
:class:`DummyAuthenticator`
|
||||
---------------------------
|
||||
|
||||
.. autoconfigurable:: DummyAuthenticator
|
||||
|
||||
|
@@ -9,6 +9,40 @@ command line for details.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
- Services have an `info` field which is a dictionary.
|
||||
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.
|
||||
Expiry is available in the REST model as `expires_at`,
|
||||
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
|
||||
|
||||
|
||||
[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.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
|
||||
|
@@ -21,6 +21,7 @@ extensions = [
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.napoleon',
|
||||
'autodoc_traits',
|
||||
'sphinx_copybutton'
|
||||
]
|
||||
|
||||
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
|
||||
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
|
||||
[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.
|
||||
|
||||
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
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
@@ -1,3 +1,4 @@
|
||||
==========
|
||||
JupyterHub
|
||||
==========
|
||||
|
||||
@@ -28,75 +29,139 @@ JupyterHub performs the following functions:
|
||||
For convenient administration of the Hub, its users, and services,
|
||||
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
|
||||
--------
|
||||
========
|
||||
|
||||
**Installation Guide**
|
||||
.. _index/distributions:
|
||||
|
||||
* :doc:`installation-guide`
|
||||
* :doc:`quickstart`
|
||||
* :doc:`quickstart-docker`
|
||||
* :doc:`installation-basics`
|
||||
Distributions
|
||||
-------------
|
||||
|
||||
**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`
|
||||
* :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`
|
||||
The two popular ones are:
|
||||
|
||||
**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`
|
||||
* :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`
|
||||
Installation Guide
|
||||
------------------
|
||||
|
||||
**API Reference**
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
* :doc:`api/index`
|
||||
installation-guide
|
||||
quickstart
|
||||
quickstart-docker
|
||||
installation-basics
|
||||
|
||||
**Tutorials**
|
||||
Getting Started
|
||||
---------------
|
||||
|
||||
* :doc:`tutorials/index`
|
||||
* :doc:`tutorials/upgrade-dot-eight`
|
||||
* `Zero to JupyterHub with Kubernetes <https://zero-to-jupyterhub.readthedocs.io/en/latest/>`_
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
**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`
|
||||
* :doc:`gallery-jhub-deployments`
|
||||
reference/index
|
||||
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
|
||||
------------------
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
|
||||
|
||||
Questions? Suggestions?
|
||||
-----------------------
|
||||
=======================
|
||||
|
||||
- `Jupyter mailing list <https://groups.google.com/forum/#!forum/jupyter>`_
|
||||
- `Jupyter website <https://jupyter.org>`_
|
||||
@@ -104,7 +169,7 @@ Questions? Suggestions?
|
||||
.. _contents:
|
||||
|
||||
Full Table of Contents
|
||||
----------------------
|
||||
======================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
@@ -113,7 +178,6 @@ Full Table of Contents
|
||||
getting-started/index
|
||||
reference/index
|
||||
api/index
|
||||
tutorials/index
|
||||
troubleshooting
|
||||
contributor-list
|
||||
gallery-jhub-deployments
|
||||
|
@@ -5,8 +5,8 @@ Hub and single user notebook servers.
|
||||
|
||||
## The default PAM Authenticator
|
||||
|
||||
JupyterHub ships only with the default [PAM][]-based Authenticator,
|
||||
for logging in with local user accounts via a username and password.
|
||||
JupyterHub ships with the default [PAM][]-based Authenticator, for
|
||||
logging in with local user accounts via a username and password.
|
||||
|
||||
## The OAuthenticator
|
||||
|
||||
@@ -34,12 +34,17 @@ popular services:
|
||||
A generic implementation, which you can use for OAuth authentication
|
||||
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
|
||||
|
||||
- ldapauthenticator for LDAP
|
||||
- tmpauthenticator for temporary accounts
|
||||
- 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)
|
||||
A partial list of other authenticators is available on the
|
||||
[JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||
|
||||
## Technical Overview of Authentication
|
||||
|
||||
@@ -70,7 +75,6 @@ Writing an Authenticator that looks up passwords in a dictionary
|
||||
requires only overriding this one method:
|
||||
|
||||
```python
|
||||
from tornado import gen
|
||||
from IPython.utils.traitlets import Dict
|
||||
from jupyterhub.auth import Authenticator
|
||||
|
||||
@@ -80,8 +84,7 @@ class DictionaryAuthenticator(Authenticator):
|
||||
help="""dict of username:password for authentication"""
|
||||
)
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data):
|
||||
async def authenticate(self, handler, data):
|
||||
if self.passwords.get(data['username']) == data['password']:
|
||||
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
|
||||
[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
|
||||
|
||||
@@ -226,5 +264,5 @@ Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||
[OAuth]: https://en.wikipedia.org/wiki/OAuth
|
||||
[GitHub OAuth]: https://developer.github.com/v3/oauth/
|
||||
[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
|
||||
[post_spawn_stop(user, spawner)]: http://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
||||
[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)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
||||
|
@@ -37,7 +37,7 @@ Next, you will need [sudospawner](https://github.com/jupyter/sudospawner)
|
||||
to enable monitoring the single-user servers with sudo:
|
||||
|
||||
```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
|
||||
@@ -70,7 +70,7 @@ Cmnd_Alias JUPYTER_CMD = /usr/local/bin/sudospawner
|
||||
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
|
||||
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
|
||||
Spawner you choose:
|
||||
|
||||
- Multi-user hosts (shared sytem)
|
||||
- Multi-user hosts (shared system)
|
||||
- Container-based
|
||||
|
||||
How you configure user environments for each category can differ a bit
|
||||
|
@@ -12,7 +12,6 @@ Technical Reference
|
||||
proxy
|
||||
rest
|
||||
database
|
||||
upgrading
|
||||
templates
|
||||
config-user-env
|
||||
config-examples
|
||||
|
@@ -205,9 +205,9 @@ To use HubAuth, you must set the `.api_token`, either programmatically when cons
|
||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||
|
||||
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
|
||||
[`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:
|
||||
|
||||
- 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
|
||||
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).
|
||||
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.
|
||||
|
||||
|
||||
[requests]: http://docs.python-requests.org/en/master/
|
||||
[services_auth]: ../api/services.auth.html
|
||||
[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
|
||||
[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
|
||||
|
||||
Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Spawners).
|
||||
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).
|
||||
|
||||
### 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)
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
@@ -75,7 +75,7 @@ the top of all pages. The more specific variables
|
||||
`announcement_login`, `announcement_spawn`, `announcement_home`, and
|
||||
`announcement_logout` are more specific and only show on their
|
||||
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.
|
||||
|
||||
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
|
||||
stdin
|
||||
stdout
|
||||
stoppped
|
||||
stopped
|
||||
subclasses
|
||||
subcommand
|
||||
subdomain
|
||||
|
@@ -204,7 +204,7 @@ from there instead of the internet.
|
||||
For instance, you can install JupyterHub with pip and configurable-http-proxy
|
||||
with npmbox:
|
||||
|
||||
pip wheel jupyterhub
|
||||
python3 -m pip wheel jupyterhub
|
||||
npmbox configurable-http-proxy
|
||||
|
||||
### 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:
|
||||
|
||||
pip install jupyterlab
|
||||
python3 -m pip install jupyterlab
|
||||
jupyter serverextension enable --py jupyterlab --sys-prefix
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
by the bootstrap process.
|
||||
|
@@ -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 shutil
|
||||
from jupyter_client.localinterfaces import public_ips
|
||||
|
||||
def create_dir_hook(spawner):
|
||||
""" Create directory """
|
||||
username = spawner.user.name # get the username
|
||||
volume_path = os.path.join('/volumes/jupyterhub', username)
|
||||
if not os.path.exists(volume_path):
|
||||
@@ -12,23 +17,24 @@ def create_dir_hook(spawner):
|
||||
# ...
|
||||
|
||||
def clean_dir_hook(spawner):
|
||||
""" Delete directory """
|
||||
username = spawner.user.name # get the username
|
||||
temp_path = os.path.join('/volumes/jupyterhub', username, 'temp')
|
||||
if os.path.exists(temp_path) and os.path.isdir(temp_path):
|
||||
shutil.rmtree(temp_path)
|
||||
|
||||
# attach the hook functions to the spawner
|
||||
# pylint: disable=undefined-variable
|
||||
c.Spawner.pre_spawn_hook = create_dir_hook
|
||||
c.Spawner.post_stop_hook = clean_dir_hook
|
||||
|
||||
# Use the DockerSpawner to serve your users' notebooks
|
||||
c.JupyterHub.spawner_class = 'dockerspawner.DockerSpawner'
|
||||
from jupyter_client.localinterfaces import public_ips
|
||||
c.JupyterHub.hub_ip = public_ips()[0]
|
||||
c.DockerSpawner.hub_ip_connect = public_ips()[0]
|
||||
c.DockerSpawner.container_ip = "0.0.0.0"
|
||||
|
||||
# You can now mount the volume to the docker container as we've
|
||||
# made sure the directory exists
|
||||
# pylint: disable=bad-whitespace
|
||||
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))
|
||||
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(
|
||||
url=url + '/users/%s/server' % quote(user['name']),
|
||||
method='DELETE',
|
||||
headers=auth_header,
|
||||
url=delete_url, method='DELETE', headers=auth_header,
|
||||
)
|
||||
resp = yield fetch(req)
|
||||
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)
|
||||
split=( ${hub_xyz//./ } )
|
||||
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
|
||||
|
||||
# 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 push $DOCKER_REPO:$hub_xyz
|
||||
docker tag $ONBUILD:$DOCKER_TAG $ONBUILD:$hub_xyz
|
||||
|
@@ -7,7 +7,7 @@ version_info = (
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
"", # release (b1, rc1, or "" for final)
|
||||
"", # release (b1, rc1, or "" for final or dev)
|
||||
"dev", # dev or nothing
|
||||
)
|
||||
|
||||
|
@@ -5,14 +5,20 @@
|
||||
|
||||
from datetime import datetime
|
||||
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 .. import orm
|
||||
from ..user import User
|
||||
from ..utils import token_authenticated
|
||||
from ..utils import token_authenticated, compare_token
|
||||
from .base import BaseHandler, APIHandler
|
||||
|
||||
|
||||
@@ -46,7 +52,7 @@ class TokenAPIHandler(APIHandler):
|
||||
" Use /hub/api/users/:user/tokens instead."
|
||||
) % self.request.uri
|
||||
self.log.warning(warn_msg)
|
||||
requester = user = self.get_current_user()
|
||||
requester = user = self.current_user
|
||||
if user is None:
|
||||
# allow requesting a token with username and password
|
||||
# for authenticators where that's possible
|
||||
@@ -98,24 +104,190 @@ class CookieAPIHandler(APIHandler):
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
|
||||
|
||||
class OAuthHandler(BaseHandler, OAuth2Handler):
|
||||
"""Implement OAuth provider handlers
|
||||
class OAuthHandler:
|
||||
def extract_oauth_params(self):
|
||||
"""extract oauthlib params from a request
|
||||
|
||||
OAuth2Handler sets `self.provider` in initialize,
|
||||
but we are already passing the Provider object via settings.
|
||||
Returns:
|
||||
|
||||
(uri, http_method, body, headers)
|
||||
"""
|
||||
@property
|
||||
def provider(self):
|
||||
return self.settings['oauth_provider']
|
||||
return (
|
||||
self.request.uri,
|
||||
self.request.method,
|
||||
self.request.body,
|
||||
self.request.headers,
|
||||
)
|
||||
|
||||
def initialize(self):
|
||||
pass
|
||||
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 = [
|
||||
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
||||
(r"/api/authorizations/token", TokenAPIHandler),
|
||||
(r"/api/oauth2/authorize", OAuthHandler),
|
||||
(r"/api/oauth2/token", OAuthHandler),
|
||||
(r"/api/oauth2/authorize", OAuthAuthorizeHandler),
|
||||
(r"/api/oauth2/token", OAuthTokenHandler),
|
||||
]
|
||||
|
@@ -2,6 +2,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from http.client import responses
|
||||
@@ -13,12 +14,25 @@ from .. import orm
|
||||
from ..handlers import BaseHandler
|
||||
from ..utils import isoformat, url_path_join
|
||||
|
||||
|
||||
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
|
||||
def content_security_policy(self):
|
||||
return '; '.join([super().content_security_policy, "default-src 'none'"])
|
||||
|
||||
def get_content_type(self):
|
||||
return 'application/json'
|
||||
|
||||
def check_referer(self):
|
||||
"""Check Origin for cross-site API requests.
|
||||
|
||||
@@ -156,6 +170,7 @@ class APIHandler(BaseHandler):
|
||||
'kind': kind,
|
||||
'created': isoformat(token.created),
|
||||
'last_activity': isoformat(token.last_activity),
|
||||
'expires_at': isoformat(expires_at),
|
||||
}
|
||||
model.update(extra)
|
||||
return model
|
||||
@@ -253,3 +268,13 @@ class APIHandler(BaseHandler):
|
||||
|
||||
def options(self, *args, **kwargs):
|
||||
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):
|
||||
"""Decorator for restricting access to either the target service or admin"""
|
||||
def decorated_method(self, name):
|
||||
current = self.get_current_user()
|
||||
current = self.current_user
|
||||
if current is None:
|
||||
raise web.HTTPError(403)
|
||||
if not current.admin:
|
||||
|
@@ -24,7 +24,7 @@ class SelfAPIHandler(APIHandler):
|
||||
"""
|
||||
|
||||
async def get(self):
|
||||
user = self.get_current_user()
|
||||
user = self.current_user
|
||||
if user is None:
|
||||
# whoami can be accessed via oauth token
|
||||
user = self.get_current_user_oauth_token()
|
||||
@@ -99,7 +99,7 @@ class UserListAPIHandler(APIHandler):
|
||||
def admin_or_self(method):
|
||||
"""Decorator for restricting access to either the target user or admin"""
|
||||
def m(self, name, *args, **kwargs):
|
||||
current = self.get_current_user()
|
||||
current = self.current_user
|
||||
if current is None:
|
||||
raise web.HTTPError(403)
|
||||
if not (current.name == name or current.admin):
|
||||
@@ -117,13 +117,13 @@ class UserAPIHandler(APIHandler):
|
||||
@admin_or_self
|
||||
async def get(self, name):
|
||||
user = self.find_user(name)
|
||||
model = self.user_model(user, include_servers=True, include_state=self.get_current_user().admin)
|
||||
# auth state will only be shown if the requestor is an admin
|
||||
model = self.user_model(user, include_servers=True, include_state=self.current_user.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
|
||||
# are admins, Hub admins often are also marked as admins so they
|
||||
# will see their auth state but normal users won't
|
||||
requestor = self.get_current_user()
|
||||
if requestor.admin:
|
||||
requester = self.current_user
|
||||
if requester.admin:
|
||||
model['auth_state'] = await user.get_auth_state()
|
||||
self.write(json.dumps(model))
|
||||
|
||||
@@ -157,7 +157,7 @@ class UserAPIHandler(APIHandler):
|
||||
user = self.find_user(name)
|
||||
if user is None:
|
||||
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!")
|
||||
if user.spawner._stop_pending:
|
||||
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):
|
||||
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:
|
||||
# defer to Authenticator for identifying the user
|
||||
# can be username+password or an upstream auth token
|
||||
@@ -378,29 +378,52 @@ class UserServerAPIHandler(APIHandler):
|
||||
@admin_or_self
|
||||
async def delete(self, name, server_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 not self.allow_named_servers:
|
||||
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))
|
||||
elif remove:
|
||||
raise web.HTTPError(400, "Cannot delete the default server")
|
||||
|
||||
spawner = user.spawners[server_name]
|
||||
if spawner.pending == 'stop':
|
||||
self.log.debug("%s already stopping", spawner._log_name)
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(202)
|
||||
if remove:
|
||||
spawner._stop_future.add_done_callback(_remove_spawner)
|
||||
return
|
||||
|
||||
if not spawner.ready:
|
||||
if spawner.pending:
|
||||
raise web.HTTPError(
|
||||
400, "%s is not running %s" %
|
||||
(spawner._log_name, '(pending: %s)' % spawner.pending if spawner.pending else '')
|
||||
400, "%s is pending %s, please wait" % (spawner._log_name, spawner.pending)
|
||||
)
|
||||
|
||||
stop_future = None
|
||||
if spawner.ready:
|
||||
# include notify, so that a server that died is noticed immediately
|
||||
status = await spawner.poll_and_notify()
|
||||
if status is not None:
|
||||
raise web.HTTPError(400, "%s is not running" % spawner._log_name)
|
||||
await self.stop_single_user(user, server_name)
|
||||
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
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(status)
|
||||
@@ -415,7 +438,7 @@ class UserAdminAccessAPIHandler(APIHandler):
|
||||
def post(self, name):
|
||||
self.log.warning("Deprecated in JupyterHub 0.8."
|
||||
" 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",
|
||||
current.name, name,
|
||||
)
|
||||
@@ -428,6 +451,9 @@ class UserAdminAccessAPIHandler(APIHandler):
|
||||
|
||||
class SpawnProgressAPIHandler(APIHandler):
|
||||
"""EventStream handler for pending spawns"""
|
||||
|
||||
keepalive_interval = 8
|
||||
|
||||
def get_content_type(self):
|
||||
return 'text/event-stream'
|
||||
|
||||
@@ -440,6 +466,31 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
# raise Finish to halt the handler
|
||||
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
|
||||
async def get(self, username, server_name=''):
|
||||
self.set_header('Cache-Control', 'no-cache')
|
||||
@@ -453,6 +504,9 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
# user has no such server
|
||||
raise web.HTTPError(404)
|
||||
spawner = user.spawners[server_name]
|
||||
|
||||
# start sending keepalive to avoid proxies closing the connection
|
||||
asyncio.ensure_future(self.keepalive())
|
||||
# cases:
|
||||
# - spawner already started and ready
|
||||
# - spawner not running at all
|
||||
|
@@ -41,7 +41,7 @@ from traitlets import (
|
||||
Tuple, Type, Set, Instance, Bytes, Float,
|
||||
observe, default,
|
||||
)
|
||||
from traitlets.config import Application, catch_config_error
|
||||
from traitlets.config import Application, Configurable, catch_config_error
|
||||
|
||||
here = os.path.dirname(__file__)
|
||||
|
||||
@@ -53,11 +53,11 @@ from .services.service import Service
|
||||
from . import crypto
|
||||
from . import dbutil, orm
|
||||
from .user import UserDict
|
||||
from .oauth.store import make_provider
|
||||
from .oauth.provider import make_provider
|
||||
from ._data import DATA_FILES_PATH
|
||||
from .log import CoroutineLogFormatter, log_request
|
||||
from .proxy import Proxy, ConfigurableHTTPProxy
|
||||
from .traitlets import URLPrefix, Command
|
||||
from .traitlets import URLPrefix, Command, EntryPointType
|
||||
from .utils import (
|
||||
maybe_future,
|
||||
url_path_join,
|
||||
@@ -229,13 +229,19 @@ class JupyterHub(Application):
|
||||
'upgrade-db': (UpgradeDB, "Upgrade your JupyterHub state database to the current version."),
|
||||
}
|
||||
|
||||
classes = List([
|
||||
Spawner,
|
||||
LocalProcessSpawner,
|
||||
Authenticator,
|
||||
PAMAuthenticator,
|
||||
CryptKeeper,
|
||||
])
|
||||
classes = List()
|
||||
@default('classes')
|
||||
def _load_classes(self):
|
||||
classes = [Spawner, Authenticator, CryptKeeper]
|
||||
for name, trait in self.traits(config=True).items():
|
||||
# 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()),
|
||||
help="""Dict of 'group': ['usernames'] to load at startup.
|
||||
@@ -750,20 +756,25 @@ class JupyterHub(Application):
|
||||
).tag(config=True)
|
||||
_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.
|
||||
|
||||
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 authenticate method that:
|
||||
with an :meth:`authenticate` method that:
|
||||
|
||||
- is a coroutine (asyncio or tornado)
|
||||
- returns username on success, None on failure
|
||||
- takes two arguments: (handler, data),
|
||||
where `handler` is the calling web.RequestHandler,
|
||||
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)
|
||||
|
||||
@@ -778,10 +789,17 @@ class JupyterHub(Application):
|
||||
).tag(config=True)
|
||||
|
||||
# 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.
|
||||
|
||||
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)
|
||||
|
||||
@@ -1072,6 +1090,8 @@ class JupyterHub(Application):
|
||||
h.extend(self.extra_handlers)
|
||||
|
||||
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)
|
||||
# some extra handlers, outside hub_prefix
|
||||
self.handlers.extend([
|
||||
@@ -1519,7 +1539,7 @@ class JupyterHub(Application):
|
||||
host = '%s://services.%s' % (parsed.scheme, parsed.netloc)
|
||||
else:
|
||||
domain = host = ''
|
||||
client_store = self.oauth_provider.client_authenticator.client_store
|
||||
|
||||
for spec in self.services:
|
||||
if 'name' not in spec:
|
||||
raise ValueError('service spec must have a name: %r' % spec)
|
||||
@@ -1578,7 +1598,7 @@ class JupyterHub(Application):
|
||||
service.orm.server = None
|
||||
|
||||
if service.oauth_available:
|
||||
client_store.add_client(
|
||||
self.oauth_provider.add_client(
|
||||
client_id=service.oauth_client_id,
|
||||
client_secret=service.api_token,
|
||||
redirect_uri=service.oauth_redirect_uri,
|
||||
@@ -1680,7 +1700,7 @@ class JupyterHub(Application):
|
||||
self.oauth_provider = make_provider(
|
||||
lambda: self.db,
|
||||
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):
|
||||
@@ -1695,8 +1715,11 @@ class JupyterHub(Application):
|
||||
for user in self.users.values():
|
||||
for spawner in user.spawners.values():
|
||||
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)):
|
||||
if oauth_client.identifier not in oauth_client_ids:
|
||||
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
|
||||
@@ -2144,8 +2167,7 @@ class JupyterHub(Application):
|
||||
|
||||
def sigterm(self, signum, frame):
|
||||
self.log.critical("Received SIGTERM, shutting down")
|
||||
self.io_loop.stop()
|
||||
self.atexit()
|
||||
raise SystemExit(128 + signum)
|
||||
|
||||
_atexit_ran = False
|
||||
|
||||
@@ -2155,6 +2177,7 @@ class JupyterHub(Application):
|
||||
return
|
||||
self._atexit_ran = True
|
||||
# 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()
|
||||
loop = IOLoop()
|
||||
loop.make_current()
|
||||
|
@@ -287,10 +287,40 @@ class Authenticator(LoggingConfigurable):
|
||||
self.log.warning("User %r not in whitelist.", username)
|
||||
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):
|
||||
"""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,
|
||||
and return None on failed authentication.
|
||||
|
||||
@@ -304,12 +334,14 @@ class Authenticator(LoggingConfigurable):
|
||||
data (dict): The formdata of the login form.
|
||||
The default form has 'username' and 'password' fields.
|
||||
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.
|
||||
|
||||
The Authenticator may return a dict instead, which MUST have a
|
||||
key 'name' holding the username, and may have two optional keys
|
||||
set - 'auth_state', a dictionary of of auth state that will be
|
||||
persisted; and 'admin', the admin setting value for the user.
|
||||
key `name` holding the username, and MAY have two optional keys
|
||||
set: `auth_state`, a dictionary of of auth state that will be
|
||||
persisted; and `admin`, the admin setting value for the user.
|
||||
"""
|
||||
|
||||
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("Disabling PAM sessions from now on.")
|
||||
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.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
from http.client import responses
|
||||
@@ -20,7 +21,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from tornado.log import app_log
|
||||
from tornado.httputil import url_concat, HTTPHeaders
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.web import RequestHandler
|
||||
from tornado.web import RequestHandler, MissingArgumentError
|
||||
from tornado import gen, web
|
||||
|
||||
from .. import __version__
|
||||
@@ -31,6 +32,7 @@ from ..utils import maybe_future, url_path_join
|
||||
from ..metrics import (
|
||||
SERVER_SPAWN_DURATION_SECONDS, ServerSpawnStatus,
|
||||
PROXY_ADD_DURATION_SECONDS, ProxyAddStatus,
|
||||
RUNNING_SERVERS
|
||||
)
|
||||
|
||||
# pattern for the authentication token header
|
||||
@@ -51,6 +53,26 @@ SESSION_COOKIE_NAME = 'jupyterhub-session-id'
|
||||
class BaseHandler(RequestHandler):
|
||||
"""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
|
||||
def log(self):
|
||||
"""I can't seem to avoid typing self.log"""
|
||||
@@ -209,6 +231,55 @@ class BaseHandler(RequestHandler):
|
||||
self.db.commit()
|
||||
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):
|
||||
"""get_current_user from Authorization header token"""
|
||||
token = self.get_auth_token()
|
||||
@@ -217,15 +288,18 @@ class BaseHandler(RequestHandler):
|
||||
orm_token = orm.APIToken.find(self.db, token)
|
||||
if orm_token is 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()
|
||||
return orm_token.service or self._user_from_orm(orm_token.user)
|
||||
|
||||
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):
|
||||
"""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"""
|
||||
return self._user_for_cookie(self.hub.cookie_name)
|
||||
|
||||
def get_current_user(self):
|
||||
async def get_current_user(self):
|
||||
"""get current username"""
|
||||
if not hasattr(self, '_jupyterhub_user'):
|
||||
try:
|
||||
user = self.get_current_user_token()
|
||||
if user is None:
|
||||
user = self.get_current_user_cookie()
|
||||
if user:
|
||||
user = await self.refresh_user_auth(user)
|
||||
self._jupyterhub_user = user
|
||||
except Exception:
|
||||
# don't let errors here raise more than once
|
||||
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
|
||||
|
||||
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.db.commit()
|
||||
|
||||
|
||||
# clear hub cookie
|
||||
self.clear_cookie(self.hub.cookie_name, path=self.hub.base_url, **kwargs)
|
||||
# clear services cookie
|
||||
@@ -467,16 +552,26 @@ class BaseHandler(RequestHandler):
|
||||
next_url = url_path_join(self.hub.base_url, 'home')
|
||||
return next_url
|
||||
|
||||
async def login_user(self, data=None):
|
||||
"""Login a user"""
|
||||
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||
authenticated = await self.authenticate(data)
|
||||
auth_timer.stop(send=False)
|
||||
async def auth_to_user(self, authenticated, user=None):
|
||||
"""Persist data from .authenticate() or .refresh_user() to the User database
|
||||
|
||||
if authenticated:
|
||||
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:
|
||||
@@ -492,11 +587,20 @@ class BaseHandler(RequestHandler):
|
||||
# auth_state is not enabled. Force None.
|
||||
auth_state = None
|
||||
await user.save_auth_state(auth_state)
|
||||
self.db.commit()
|
||||
return user
|
||||
|
||||
async def login_user(self, data=None):
|
||||
"""Login a user"""
|
||||
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||
authenticated = await self.authenticate(data)
|
||||
auth_timer.stop(send=False)
|
||||
|
||||
if authenticated:
|
||||
user = await self.auth_to_user(authenticated)
|
||||
self.set_login_cookie(user)
|
||||
self.statsd.incr('login.success')
|
||||
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
|
||||
else:
|
||||
self.statsd.incr('login.failure')
|
||||
@@ -602,7 +706,7 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
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",
|
||||
spawn_pending_count,
|
||||
@@ -627,6 +731,7 @@ class BaseHandler(RequestHandler):
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s took %.3f seconds to start", user_server_name, toc-tic)
|
||||
self.statsd.timing('spawner.success', (toc - tic) * 1000)
|
||||
RUNNING_SERVERS.inc()
|
||||
SERVER_SPAWN_DURATION_SECONDS.labels(
|
||||
status=ServerSpawnStatus.success
|
||||
).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
|
||||
# this result
|
||||
finish_spawn_future = spawner._spawn_future = maybe_future(finish_user_spawn())
|
||||
|
||||
def _clear_spawn_future(f):
|
||||
# clear spawner._spawn_future when it's done
|
||||
# keep an exception around, though, to prevent repeated implicit spawns
|
||||
@@ -665,10 +771,44 @@ class BaseHandler(RequestHandler):
|
||||
spawner._spawn_future = None
|
||||
# Now we're all done. clear _spawn_pending flag
|
||||
spawner._spawn_pending = False
|
||||
|
||||
finish_spawn_future.add_done_callback(_clear_spawn_future)
|
||||
|
||||
# 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:
|
||||
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:
|
||||
# waiting_for_response indicates server process has started,
|
||||
# but is yet to become responsive.
|
||||
@@ -717,10 +857,10 @@ class BaseHandler(RequestHandler):
|
||||
await self.proxy.delete_user(user, server_name)
|
||||
await user.stop(server_name)
|
||||
|
||||
async def stop_single_user(self, user, name=''):
|
||||
if name not in user.spawners:
|
||||
raise KeyError("User %s has no such spawner %r", user.name, name)
|
||||
spawner = user.spawners[name]
|
||||
async def stop_single_user(self, user, server_name=''):
|
||||
if server_name not in user.spawners:
|
||||
raise KeyError("User %s has no such spawner %r", user.name, server_name)
|
||||
spawner = user.spawners[server_name]
|
||||
if spawner.pending:
|
||||
raise RuntimeError("%s pending %s" % (spawner._log_name, spawner.pending))
|
||||
# set user._stop_pending before doing anything async
|
||||
@@ -736,19 +876,27 @@ class BaseHandler(RequestHandler):
|
||||
"""
|
||||
tic = IOLoop.current().time()
|
||||
try:
|
||||
await self.proxy.delete_user(user, name)
|
||||
await user.stop(name)
|
||||
await self.proxy.delete_user(user, server_name)
|
||||
await user.stop(server_name)
|
||||
finally:
|
||||
spawner._stop_future = None
|
||||
spawner._stop_pending = False
|
||||
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic)
|
||||
self.statsd.timing('spawner.stop', (toc - tic) * 1000)
|
||||
RUNNING_SERVERS.dec()
|
||||
|
||||
future = spawner._stop_future = asyncio.ensure_future(stop())
|
||||
|
||||
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:
|
||||
# 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
|
||||
@@ -780,7 +928,7 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
@property
|
||||
def template_namespace(self):
|
||||
user = self.get_current_user()
|
||||
user = self.current_user
|
||||
ns = dict(
|
||||
base_url=self.hub.base_url,
|
||||
prefix=self.base_url,
|
||||
@@ -855,7 +1003,8 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
class Template404(BaseHandler):
|
||||
"""Render our 404 template"""
|
||||
def prepare(self):
|
||||
async def prepare(self):
|
||||
await super().prepare()
|
||||
raise web.HTTPError(404)
|
||||
|
||||
|
||||
@@ -866,6 +1015,11 @@ class PrefixRedirectHandler(BaseHandler):
|
||||
"""
|
||||
def get(self):
|
||||
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):
|
||||
path = self.request.uri[len(self.base_url):]
|
||||
else:
|
||||
@@ -876,7 +1030,7 @@ class PrefixRedirectHandler(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 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.finish()
|
||||
|
||||
async def get(self, name, user_path):
|
||||
async def get(self, user_name, user_path):
|
||||
if not user_path:
|
||||
user_path = '/'
|
||||
current_user = self.get_current_user()
|
||||
current_user = self.current_user
|
||||
if (
|
||||
current_user
|
||||
and current_user.name != name
|
||||
and current_user.name != user_name
|
||||
and current_user.admin
|
||||
and self.settings.get('admin_access', False)
|
||||
):
|
||||
# allow admins to spawn on behalf of users
|
||||
user = self.find_user(name)
|
||||
user = self.find_user(user_name)
|
||||
if user is None:
|
||||
# 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",
|
||||
current_user.name, user.name)
|
||||
admin_spawn = True
|
||||
@@ -916,7 +1070,7 @@ class UserSpawnHandler(BaseHandler):
|
||||
admin_spawn = False
|
||||
# For non-admins, we should spawn if the user matches
|
||||
# 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:
|
||||
# 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
|
||||
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.
|
||||
# Try to check for that and warn,
|
||||
# though the user-facing behavior is unchanged
|
||||
@@ -944,7 +1098,11 @@ class UserSpawnHandler(BaseHandler):
|
||||
""", self.request.full_url(), self.proxy.public_url)
|
||||
|
||||
# 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.
|
||||
if (
|
||||
@@ -1009,7 +1167,7 @@ class UserSpawnHandler(BaseHandler):
|
||||
{'next': self.request.uri}))
|
||||
return
|
||||
else:
|
||||
await self.spawn_single_user(user)
|
||||
await self.spawn_single_user(user, server_name)
|
||||
|
||||
# spawn didn't finish, show pending page
|
||||
if spawner.pending:
|
||||
@@ -1065,7 +1223,7 @@ class UserSpawnHandler(BaseHandler):
|
||||
url_parts = urlparse(target)
|
||||
query_parts = parse_qs(url_parts.query)
|
||||
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)
|
||||
else:
|
||||
target = url_concat(target, {'redirects': 1})
|
||||
@@ -1103,7 +1261,7 @@ class UserRedirectHandler(BaseHandler):
|
||||
"""
|
||||
@web.authenticated
|
||||
def get(self, path):
|
||||
user = self.get_current_user()
|
||||
user = self.current_user
|
||||
url = url_path_join(user.url, path)
|
||||
if self.request.query:
|
||||
# FIXME: use urlunparse instead?
|
||||
@@ -1133,7 +1291,7 @@ class AddSlashHandler(BaseHandler):
|
||||
|
||||
default_handlers = [
|
||||
(r'', AddSlashHandler), # add trailing / to `/hub`
|
||||
(r'/user/([^/]+)(/.*)?', UserSpawnHandler),
|
||||
(r'/user/(?P<user_name>[^/]+)(?P<user_path>/.*)?', UserSpawnHandler),
|
||||
(r'/user-redirect/(.*)?', UserRedirectHandler),
|
||||
(r'/security/csp-report', CSPReportHandler),
|
||||
]
|
||||
|
@@ -14,7 +14,7 @@ from .base import BaseHandler
|
||||
class LogoutHandler(BaseHandler):
|
||||
"""Log a user out by clearing their login cookie."""
|
||||
def get(self):
|
||||
user = self.get_current_user()
|
||||
user = self.current_user
|
||||
if user:
|
||||
self.log.info("User logged out: %s", user.name)
|
||||
self.clear_login_cookie()
|
||||
@@ -44,11 +44,11 @@ class LoginHandler(BaseHandler):
|
||||
|
||||
async def get(self):
|
||||
self.statsd.incr('login.request')
|
||||
user = self.get_current_user()
|
||||
user = self.current_user
|
||||
if user:
|
||||
# set new login cookie
|
||||
# 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)
|
||||
else:
|
||||
if self.authenticator.auto_login:
|
||||
@@ -83,7 +83,7 @@ class LoginHandler(BaseHandler):
|
||||
|
||||
if user:
|
||||
# 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))
|
||||
else:
|
||||
html = self._render(
|
||||
|
@@ -30,7 +30,7 @@ class RootHandler(BaseHandler):
|
||||
Otherwise, renders login page.
|
||||
"""
|
||||
def get(self):
|
||||
user = self.get_current_user()
|
||||
user = self.current_user
|
||||
if self.default_url:
|
||||
url = self.default_url
|
||||
elif user:
|
||||
@@ -45,18 +45,23 @@ class HomeHandler(BaseHandler):
|
||||
|
||||
@web.authenticated
|
||||
async def get(self):
|
||||
user = self.get_current_user()
|
||||
user = self.current_user
|
||||
if user.running:
|
||||
# trigger poll_and_notify event in case of a server that died
|
||||
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
|
||||
# than an implicit one, which can be caused by any link to `/user/:name`
|
||||
url = user.url if user.spawner.active else url_path_join(self.hub.base_url, 'spawn')
|
||||
# than an implicit one, which can be caused by any link to `/user/:name(/:server_name)`
|
||||
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',
|
||||
user=user,
|
||||
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)
|
||||
|
||||
@@ -87,7 +92,7 @@ class SpawnHandler(BaseHandler):
|
||||
|
||||
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 not user.admin:
|
||||
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():
|
||||
user.spawner._spawn_future = None
|
||||
# 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
|
||||
async def post(self, for_user=None):
|
||||
"""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 not user.admin:
|
||||
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._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',
|
||||
user=self.get_current_user(),
|
||||
current_user=self.current_user,
|
||||
admin_access=self.settings.get('admin_access', False),
|
||||
users=users,
|
||||
running=running,
|
||||
sort={s:o for s,o in zip(sorts, orders)},
|
||||
allow_named_servers=self.allow_named_servers,
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
@@ -226,7 +239,7 @@ class TokenPageHandler(BaseHandler):
|
||||
def get(self):
|
||||
never = datetime(1900, 1, 1)
|
||||
|
||||
user = self.get_current_user()
|
||||
user = self.current_user
|
||||
def sort_key(token):
|
||||
return (
|
||||
token.last_activity or never,
|
||||
@@ -243,9 +256,11 @@ class TokenPageHandler(BaseHandler):
|
||||
api_tokens.append(token)
|
||||
|
||||
# group oauth client tokens by client id
|
||||
# AccessTokens have expires_at as an integer timestamp
|
||||
now_timestamp = now.timestamp()
|
||||
oauth_tokens = defaultdict(list)
|
||||
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.db.delete(token)
|
||||
self.db.commit()
|
||||
|
@@ -123,8 +123,8 @@ def log_request(handler):
|
||||
request_time = 1000.0 * handler.request.request_time()
|
||||
|
||||
try:
|
||||
user = handler.get_current_user()
|
||||
except HTTPError:
|
||||
user = handler.current_user
|
||||
except (HTTPError, RuntimeError):
|
||||
username = ''
|
||||
else:
|
||||
if user is None:
|
||||
|
@@ -18,6 +18,7 @@ them manually here.
|
||||
from enum import Enum
|
||||
|
||||
from prometheus_client import Histogram
|
||||
from prometheus_client import Gauge
|
||||
|
||||
REQUEST_DURATION_SECONDS = Histogram(
|
||||
'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")]
|
||||
)
|
||||
|
||||
RUNNING_SERVERS = Gauge(
|
||||
'running_servers',
|
||||
'the number of user servers currently running',
|
||||
)
|
||||
|
||||
RUNNING_SERVERS.set(0)
|
||||
|
||||
class ServerSpawnStatus(Enum):
|
||||
"""
|
||||
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)
|
||||
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):
|
||||
"""A service run with JupyterHub
|
||||
@@ -469,6 +478,7 @@ class OAuthAccessToken(Hashed, Base):
|
||||
grant_type = Column(Enum(GrantType), nullable=False)
|
||||
expires_at = Column(Integer)
|
||||
refresh_token = Column(Unicode(255))
|
||||
# TODO: drop refresh_expires_at. Refresh tokens shouldn't expire
|
||||
refresh_expires_at = Column(Integer)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
service = None # for API-equivalence with APIToken
|
||||
@@ -513,6 +523,7 @@ class OAuthCode(Base):
|
||||
expires_at = Column(Integer)
|
||||
redirect_uri = Column(Unicode(1023))
|
||||
session_id = Column(Unicode(255))
|
||||
# state = Column(Unicode(1023))
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
|
||||
|
||||
@@ -524,6 +535,10 @@ class OAuthClient(Base):
|
||||
secret = Column(Unicode(255))
|
||||
redirect_uri = Column(Unicode(1023))
|
||||
|
||||
@property
|
||||
def client_id(self):
|
||||
return self.identifier
|
||||
|
||||
access_tokens = relationship(
|
||||
OAuthAccessToken,
|
||||
backref='client',
|
||||
@@ -746,7 +761,7 @@ def new_session_factory(url="sqlite:///:memory:",
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# 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
|
||||
# this off gives us a major performance boost
|
||||
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")
|
||||
|
||||
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):
|
||||
super().__init__(**kwargs)
|
||||
# check for required token if proxy is external
|
||||
@@ -484,7 +492,7 @@ class ConfigurableHTTPProxy(Proxy):
|
||||
return
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
self._check_pid(pid)
|
||||
except ProcessLookupError:
|
||||
self.log.warning("Proxy no longer running at pid=%s", pid)
|
||||
self._remove_pid_file()
|
||||
@@ -492,19 +500,24 @@ class ConfigurableHTTPProxy(Proxy):
|
||||
|
||||
# if we got here, CHP is still running
|
||||
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:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
if os.name == 'nt':
|
||||
self._terminate_win(pid)
|
||||
else:
|
||||
os.kill(pid,sig_list[i])
|
||||
except ProcessLookupError:
|
||||
break
|
||||
time.sleep(1)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
self._check_pid(pid)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
self._check_pid(pid)
|
||||
except ProcessLookupError:
|
||||
self.log.warning("Stopped proxy at pid=%s", pid)
|
||||
self._remove_pid_file()
|
||||
@@ -627,18 +640,21 @@ class ConfigurableHTTPProxy(Proxy):
|
||||
self._check_running_callback = pc
|
||||
pc.start()
|
||||
|
||||
def _terminate(self):
|
||||
"""Terminate our process"""
|
||||
if os.name == 'nt':
|
||||
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(self.proxy_process.pid)
|
||||
parent = psutil.Process(pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
child.kill()
|
||||
psutil.wait_procs(children, timeout=5)
|
||||
|
||||
def _terminate(self):
|
||||
"""Terminate our process"""
|
||||
if os.name == 'nt':
|
||||
self._terminate_win(self.proxy_process.pid)
|
||||
else:
|
||||
self.proxy_process.terminate()
|
||||
|
||||
|
@@ -327,7 +327,15 @@ class HubAuth(SingletonConfigurable):
|
||||
elif r.status_code >= 400:
|
||||
app_log.warning("Failed to check authorization: [%i] %s", r.status_code, r.reason)
|
||||
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:
|
||||
data = r.json()
|
||||
|
||||
@@ -872,6 +880,11 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
|
||||
|
||||
@coroutine
|
||||
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)
|
||||
if not code:
|
||||
raise HTTPError(400, "oauth callback made without a token")
|
||||
|
@@ -310,6 +310,7 @@ class SingleUserNotebookApp(NotebookApp):
|
||||
# disble some single-user configurables
|
||||
token = ''
|
||||
open_browser = False
|
||||
quit_button = False
|
||||
trust_xheaders = True
|
||||
login_handler_class = JupyterHubLoginHandler
|
||||
logout_handler_class = JupyterHubLogoutHandler
|
||||
|
@@ -134,6 +134,10 @@ class Spawner(LoggingConfigurable):
|
||||
|
||||
proxy_spec = Unicode()
|
||||
|
||||
@property
|
||||
def last_activity(self):
|
||||
return self.orm_spawner.last_activity
|
||||
|
||||
@property
|
||||
def server(self):
|
||||
if hasattr(self, '_server'):
|
||||
@@ -167,6 +171,7 @@ class Spawner(LoggingConfigurable):
|
||||
admin_access = Bool(False)
|
||||
api_token = Unicode()
|
||||
oauth_client_id = Unicode()
|
||||
handler = Any()
|
||||
|
||||
will_resume = Bool(False,
|
||||
help="""Whether the Spawner will resume on next start
|
||||
@@ -201,6 +206,19 @@ class Spawner(LoggingConfigurable):
|
||||
"""
|
||||
).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,
|
||||
help="""
|
||||
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.
|
||||
"""
|
||||
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_({
|
||||
"progress": 0,
|
||||
|
@@ -26,6 +26,7 @@ Other components
|
||||
- public_url
|
||||
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
@@ -86,6 +87,8 @@ class MockSpawner(LocalProcessSpawner):
|
||||
pass
|
||||
|
||||
def user_env(self, env):
|
||||
if self.handler:
|
||||
env['HANDLER_ARGS'] = self.handler.request.query
|
||||
return env
|
||||
|
||||
@default('cmd')
|
||||
@@ -150,9 +153,8 @@ class BadSpawner(MockSpawner):
|
||||
class SlowBadSpawner(MockSpawner):
|
||||
"""Spawner that fails after a short delay"""
|
||||
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
yield gen.sleep(0.1)
|
||||
async def start(self):
|
||||
await asyncio.sleep(0.5)
|
||||
raise RuntimeError("I don't work!")
|
||||
|
||||
|
||||
|
@@ -103,6 +103,8 @@ def api_request(app, *api_path, **kwargs):
|
||||
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 '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
|
||||
|
||||
|
||||
@@ -611,6 +613,32 @@ def test_spawn(app):
|
||||
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.gen_test
|
||||
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 app_user.spawner is not None
|
||||
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()['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.raise_for_status()
|
||||
request.addfinalizer(r.close)
|
||||
assert r.headers['content-type'] == 'text/event-stream'
|
||||
|
||||
ex = async_requests.executor
|
||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||
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.raise_for_status()
|
||||
request.addfinalizer(r.close)
|
||||
assert r.headers['content-type'] == 'text/event-stream'
|
||||
ex = async_requests.executor
|
||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||
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.raise_for_status()
|
||||
request.addfinalizer(r.close)
|
||||
assert r.headers['content-type'] == 'text/event-stream'
|
||||
ex = async_requests.executor
|
||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||
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.raise_for_status()
|
||||
request.addfinalizer(r.close)
|
||||
assert r.headers['content-type'] == 'text/event-stream'
|
||||
ex = async_requests.executor
|
||||
line_iter = iter(r.iter_lines(decode_unicode=True))
|
||||
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.parametrize("headers, status, note", [
|
||||
({}, 200, 'test note'),
|
||||
({}, 200, ''),
|
||||
({'Authorization': 'token bad'}, 403, ''),
|
||||
@mark.parametrize("headers, status, note, expires_in", [
|
||||
({}, 200, 'test note', None),
|
||||
({}, 200, '', 100),
|
||||
({'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:
|
||||
body = json.dumps({'note': note})
|
||||
options['note'] = note
|
||||
if expires_in:
|
||||
options['expires_in'] = expires_in
|
||||
if options:
|
||||
body = json.dumps(options)
|
||||
else:
|
||||
body = ''
|
||||
# request a new token
|
||||
@@ -1220,6 +1259,10 @@ def test_get_new_token(app, headers, status, note):
|
||||
assert reply['user'] == 'admin'
|
||||
assert reply['created']
|
||||
assert 'last_activity' in reply
|
||||
if expires_in:
|
||||
assert isinstance(reply['expires_at'], str)
|
||||
else:
|
||||
assert reply['expires_at'] is None
|
||||
if note:
|
||||
assert reply['note'] == note
|
||||
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"""
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -134,6 +135,21 @@ def test_delete_named_server(app, named_servers):
|
||||
'auth_state': None,
|
||||
'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
|
||||
def test_named_server_disabled(app):
|
||||
|
@@ -1,7 +1,9 @@
|
||||
"""Tests for HTML pages"""
|
||||
|
||||
import sys
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from tornado import gen
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
@@ -168,6 +170,31 @@ def test_spawn_redirect(app):
|
||||
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
|
||||
def test_spawn_admin_access(app, admin_access):
|
||||
"""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)
|
||||
|
||||
|
||||
@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
|
||||
def test_server_not_running_api_request(app):
|
||||
cookies = yield app.login_user("bees")
|
||||
|
@@ -1,6 +1,8 @@
|
||||
"""Tests for service authentication"""
|
||||
import asyncio
|
||||
from binascii import hexlify
|
||||
import copy
|
||||
from functools import partial
|
||||
import json
|
||||
import os
|
||||
from queue import Queue
|
||||
@@ -24,7 +26,7 @@ from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_url, public_host
|
||||
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
|
||||
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):
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
# first request is only going to set login cookie
|
||||
s = requests.Session()
|
||||
# first request is only going to login and get us to the oauth form page
|
||||
s = AsyncSession()
|
||||
name = 'link'
|
||||
s.cookies = yield app.login_user(name)
|
||||
# run session.get in async_requests thread
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
r = yield s_get(url)
|
||||
|
||||
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()
|
||||
assert r.url == url
|
||||
# verify oauth cookie is set
|
||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||
# verify oauth state cookie has been consumed
|
||||
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
|
||||
r = yield s_get(url, allow_redirects=False)
|
||||
# second request should be authenticated, which means no redirects
|
||||
r = yield s.get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
@@ -376,25 +383,23 @@ def test_oauth_cookie_collision(app, mockservice_url):
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url), 'owhoami/')
|
||||
print(url)
|
||||
s = requests.Session()
|
||||
s = AsyncSession()
|
||||
name = 'mypha'
|
||||
s.cookies = yield app.login_user(name)
|
||||
# run session.get in async_requests thread
|
||||
s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs)
|
||||
state_cookie_name = 'service-%s-oauth-state' % service.name
|
||||
service_cookie_name = 'service-%s' % service.name
|
||||
oauth_1 = yield s_get(url, allow_redirects=False)
|
||||
oauth_1 = yield s.get(url)
|
||||
print(oauth_1.headers)
|
||||
print(oauth_1.cookies, oauth_1.url, url)
|
||||
assert state_cookie_name in s.cookies
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
state_cookies = [ c for c in s.cookies.keys() if c.startswith(state_cookie_name) ]
|
||||
# only one state cookie
|
||||
assert state_cookies == [state_cookie_name]
|
||||
state_1 = s.cookies[state_cookie_name]
|
||||
|
||||
# start second oauth login before finishing the first
|
||||
oauth_2 = yield s_get(url, allow_redirects=False)
|
||||
state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ]
|
||||
oauth_2 = yield s.get(url)
|
||||
state_cookies = [ c for c in s.cookies.keys() if c.startswith(state_cookie_name) ]
|
||||
assert len(state_cookies) == 2
|
||||
# get the random-suffix cookie name
|
||||
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
|
||||
|
||||
# finish oauth 2
|
||||
url = oauth_2.headers['Location']
|
||||
if not urlparse(url).netloc:
|
||||
url = public_host(app) + url
|
||||
r = yield s_get(url)
|
||||
# submit the oauth form to complete authorization
|
||||
r = yield s.post(
|
||||
oauth_2.url,
|
||||
data={'scopes': ['identify']},
|
||||
headers={'Referer': oauth_2.url},
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
# after finishing, state cookie is cleared
|
||||
assert state_cookie_2 not in s.cookies
|
||||
# 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]
|
||||
|
||||
# finish oauth 1
|
||||
url = oauth_1.headers['Location']
|
||||
if not urlparse(url).netloc:
|
||||
url = public_host(app) + url
|
||||
r = yield s_get(url)
|
||||
r = yield s.post(
|
||||
oauth_1.url,
|
||||
data={'scopes': ['identify']},
|
||||
headers={'Referer': oauth_1.url},
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
|
||||
# after finishing, state cookie is cleared (again)
|
||||
assert state_cookie_name not in s.cookies
|
||||
# service login cookie is set (again, to a different value)
|
||||
@@ -443,7 +454,7 @@ def test_oauth_logout(app, mockservice_url):
|
||||
service_cookie_name = 'service-%s' % service.name
|
||||
url = url_path_join(public_url(app, mockservice_url), 'owhoami/?foo=bar')
|
||||
# first request is only going to set login cookie
|
||||
s = requests.Session()
|
||||
s = AsyncSession()
|
||||
name = 'propha'
|
||||
app_user = add_user(app.db, app=app, name=name)
|
||||
def auth_tokens():
|
||||
@@ -458,13 +469,16 @@ def test_oauth_logout(app, mockservice_url):
|
||||
|
||||
s.cookies = yield app.login_user(name)
|
||||
assert 'jupyterhub-session-id' in s.cookies
|
||||
# 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()
|
||||
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()
|
||||
assert r.url == url
|
||||
|
||||
# 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()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
@@ -483,13 +497,13 @@ def test_oauth_logout(app, mockservice_url):
|
||||
assert len(auth_tokens()) == 1
|
||||
|
||||
# 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()
|
||||
# verify that all cookies other than the service cookie are cleared
|
||||
assert list(s.cookies.keys()) == [service_cookie_name]
|
||||
# verify that clearing session id invalidates service cookie
|
||||
# i.e. redirect back to login page
|
||||
r = yield s_get(url)
|
||||
r = yield s.get(url)
|
||||
r.raise_for_status()
|
||||
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
|
||||
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()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
|
@@ -10,7 +10,7 @@ import jupyterhub
|
||||
from .mocking import StubSingleUserSpawner, public_url
|
||||
from ..utils import url_path_join
|
||||
|
||||
from .utils import async_requests
|
||||
from .utils import async_requests, AsyncSession
|
||||
|
||||
|
||||
@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)
|
||||
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')
|
||||
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 'burgess' in r.text
|
||||
|
||||
|
@@ -15,9 +15,17 @@ class _AsyncRequests:
|
||||
requests_method = getattr(requests, name)
|
||||
return lambda *args, **kwargs: self.executor.submit(requests_method, *args, **kwargs)
|
||||
|
||||
|
||||
# async_requests.get = requests.get returning a Future, etc.
|
||||
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):
|
||||
# Set up the external certs with the same authority as the internal
|
||||
# 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.
|
||||
# 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):
|
||||
@@ -91,3 +92,46 @@ class Callable(TraitType):
|
||||
return value
|
||||
else:
|
||||
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
|
||||
import warnings
|
||||
|
||||
from oauth2.error import ClientNotFoundError
|
||||
from sqlalchemy import inspect
|
||||
from tornado import gen
|
||||
from tornado.log import app_log
|
||||
@@ -183,19 +182,41 @@ class User:
|
||||
await self.save_auth_state(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"""
|
||||
if spawner_class is None:
|
||||
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:
|
||||
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.commit()
|
||||
assert name in self.orm_spawners
|
||||
if name == '' and self.state:
|
||||
assert server_name in self.orm_spawners
|
||||
if server_name == '' and self.state:
|
||||
# migrate user.state to spawner.state
|
||||
orm_spawner.state = self.state
|
||||
self.state = None
|
||||
@@ -203,15 +224,15 @@ class User:
|
||||
# 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
|
||||
client_id = 'jupyterhub-user-%s' % quote(self.name)
|
||||
if name:
|
||||
client_id = '%s-%s' % (client_id, quote(name))
|
||||
if server_name:
|
||||
client_id = '%s-%s' % (client_id, quote(server_name))
|
||||
spawn_kwargs = dict(
|
||||
user=self,
|
||||
orm_spawner=orm_spawner,
|
||||
hub=self.settings.get('hub'),
|
||||
authenticator=self.authenticator,
|
||||
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,
|
||||
oauth_client_id=client_id,
|
||||
cookie_options = self.settings.get('cookie_options', {}),
|
||||
@@ -334,6 +355,13 @@ class User:
|
||||
else:
|
||||
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=''):
|
||||
"""API URL for progress endpoint for a server with a given name"""
|
||||
url_parts = [self.settings['hub'].base_url, 'api/users', self.escaped_name]
|
||||
@@ -343,7 +371,7 @@ class User:
|
||||
url_parts.extend(['server/progress'])
|
||||
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
|
||||
|
||||
depending from the value of JupyterHub.allow_named_servers
|
||||
@@ -373,6 +401,9 @@ class User:
|
||||
spawner.server = server = Server(orm_server=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
|
||||
spawner.user_options = options or {}
|
||||
# we are starting a new server, make sure it doesn't restore state
|
||||
@@ -384,14 +415,11 @@ class User:
|
||||
client_id = spawner.oauth_client_id
|
||||
oauth_provider = self.settings.get('oauth_provider')
|
||||
if oauth_provider:
|
||||
client_store = oauth_provider.client_authenticator.client_store
|
||||
try:
|
||||
oauth_client = client_store.fetch_by_client_id(client_id)
|
||||
except ClientNotFoundError:
|
||||
oauth_client = None
|
||||
oauth_client = oauth_provider.fetch_by_client_id(client_id)
|
||||
# create a new OAuth client + secret on every launch
|
||||
# containers that resume will be updated below
|
||||
client_store.add_client(client_id, api_token,
|
||||
oauth_provider.add_client(
|
||||
client_id, api_token,
|
||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||
description="Server at %s" % (url_path_join(self.base_url, server_name) + '/'),
|
||||
)
|
||||
@@ -469,8 +497,8 @@ class User:
|
||||
)
|
||||
# update OAuth client secret with updated API token
|
||||
if oauth_provider:
|
||||
client_store = oauth_provider.client_authenticator.client_store
|
||||
client_store.add_client(client_id, spawner.api_token,
|
||||
oauth_provider.add_client(
|
||||
client_id, spawner.api_token,
|
||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||
)
|
||||
db.commit()
|
||||
@@ -497,6 +525,9 @@ class User:
|
||||
# raise original exception
|
||||
spawner._start_pending = False
|
||||
raise e
|
||||
finally:
|
||||
# clear reference to handler after start finishes
|
||||
spawner.handler = None
|
||||
spawner.start_polling()
|
||||
|
||||
# store state
|
||||
@@ -572,11 +603,25 @@ class User:
|
||||
# remove server entry from db
|
||||
spawner.server = None
|
||||
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
|
||||
orm_token = orm.APIToken.find(self.db, api_token)
|
||||
if 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()
|
||||
finally:
|
||||
spawner.orm_spawner.started = None
|
||||
|
@@ -258,14 +258,14 @@ def authenticated_403(self):
|
||||
Like tornado.web.authenticated, this decorator raises a 403 error
|
||||
instead of redirecting to login.
|
||||
"""
|
||||
if self.get_current_user() is None:
|
||||
if self.current_user is None:
|
||||
raise web.HTTPError(403)
|
||||
|
||||
|
||||
@auth_decorator
|
||||
def admin_only(self):
|
||||
"""Decorator for restricting access to admin users"""
|
||||
user = self.get_current_user()
|
||||
user = self.current_user
|
||||
if user is None or not user.admin:
|
||||
raise web.HTTPError(403)
|
||||
|
||||
@@ -471,7 +471,11 @@ def maybe_future(obj):
|
||||
elif isinstance(obj, concurrent.futures.Future):
|
||||
return asyncio.wrap_future(obj)
|
||||
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
|
||||
|
@@ -10,12 +10,14 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "python ./bower-lite",
|
||||
"fmt": "prettier --write --trailing-comma es5 share/jupyterhub/static/js/*",
|
||||
"lessc": "lessc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clean-css": "^3.4.13",
|
||||
"less": "^2.7.1",
|
||||
"less-plugin-clean-css": "^1.5.1",
|
||||
"clean-css": "^3.4.13"
|
||||
"prettier": "^1.14.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^3.3.7",
|
||||
|
@@ -4,3 +4,8 @@ conda:
|
||||
file: docs/environment.yml
|
||||
python:
|
||||
version: 3
|
||||
formats:
|
||||
- htmlzip
|
||||
- epub
|
||||
# pdf disabled due to bug in sphinx 1.8 + recommonmark
|
||||
# - pdf
|
||||
|
@@ -1,10 +1,11 @@
|
||||
alembic
|
||||
async_generator>=1.8
|
||||
entrypoints
|
||||
traitlets>=4.3.2
|
||||
tornado>=5.0
|
||||
jinja2
|
||||
pamela
|
||||
python-oauth2>=1.0
|
||||
oauthlib>=2.0
|
||||
python-dateutil
|
||||
SQLAlchemy>=1.1
|
||||
requests
|
||||
|
11
setup.py
11
setup.py
@@ -106,6 +106,17 @@ setup_args = dict(
|
||||
platforms = "Linux, Mac OS X",
|
||||
keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'],
|
||||
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 = [
|
||||
'Intended Audience :: Developers',
|
||||
'Intended Audience :: System Administrators',
|
||||
|
@@ -1,7 +1,13 @@
|
||||
// Copyright (c) Jupyter Development Team.
|
||||
// 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(
|
||||
$,
|
||||
bs,
|
||||
moment,
|
||||
JHAPI,
|
||||
utils
|
||||
) {
|
||||
"use strict";
|
||||
|
||||
var base_url = window.jhdata.base_url;
|
||||
@@ -11,21 +17,26 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
||||
|
||||
var api = new JHAPI(base_url);
|
||||
|
||||
function get_row (element) {
|
||||
while (!element.hasClass("user-row")) {
|
||||
function getRow(element) {
|
||||
var original = element;
|
||||
while (!element.hasClass("server-row")) {
|
||||
element = element.parent();
|
||||
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) {
|
||||
var query = window.location.search.slice(1).split('&');
|
||||
var query = window.location.search.slice(1).split("&");
|
||||
// if col already present in args, remove it
|
||||
var i = 0;
|
||||
while (i < query.length) {
|
||||
if (query[i] === 'sort=' + col) {
|
||||
if (query[i] === "sort=" + col) {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
@@ -34,138 +45,186 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
||||
}
|
||||
// add new order to the front
|
||||
if (order) {
|
||||
query.unshift('order=' + order);
|
||||
query.unshift("order=" + order);
|
||||
}
|
||||
query.unshift('sort=' + col);
|
||||
query.unshift("sort=" + col);
|
||||
// reload page with new order
|
||||
window.location = window.location.pathname + '?' + query.join('&');
|
||||
window.location = window.location.pathname + "?" + query.join("&");
|
||||
}
|
||||
|
||||
$("th").map(function(i, th) {
|
||||
th = $(th);
|
||||
var col = th.data('sort');
|
||||
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 () {
|
||||
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()));
|
||||
var 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');
|
||||
var row = getRow(el);
|
||||
var serverName = row.data("server-name");
|
||||
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');
|
||||
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 user = get_row(el).data('user');
|
||||
el.attr('href', utils.url_path_join(prefix, 'user', user) + '/');
|
||||
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) {
|
||||
$(".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));
|
||||
})
|
||||
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');
|
||||
$("#start-all-servers").addClass("hidden");
|
||||
} else {
|
||||
$(".start-server").click(function() {
|
||||
var el = $(this);
|
||||
var row = get_row(el);
|
||||
var user = row.data('user');
|
||||
var row = getRow(el);
|
||||
var user = row.data("user");
|
||||
var serverName = row.data("server-name");
|
||||
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');
|
||||
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 = get_row(el);
|
||||
var user = row.data('user');
|
||||
var admin = row.data('admin');
|
||||
var row = getRow(el);
|
||||
var user = row.data("user");
|
||||
var admin = row.data("admin");
|
||||
var dialog = $("#edit-user-dialog");
|
||||
dialog.data('user', user);
|
||||
dialog.data("user", user);
|
||||
dialog.find(".username-input").val(user);
|
||||
dialog.find(".admin-checkbox").attr("checked", admin==='True');
|
||||
dialog.find(".admin-checkbox").attr("checked", admin === "True");
|
||||
dialog.modal();
|
||||
});
|
||||
|
||||
$("#edit-user-dialog").find(".save-button").click(function () {
|
||||
$("#edit-user-dialog")
|
||||
.find(".save-button")
|
||||
.click(function() {
|
||||
var dialog = $("#edit-user-dialog");
|
||||
var user = dialog.data('user');
|
||||
var user = dialog.data("user");
|
||||
var name = dialog.find(".username-input").val();
|
||||
var admin = dialog.find(".admin-checkbox").prop("checked");
|
||||
api.edit_user(user, {
|
||||
api.edit_user(
|
||||
user,
|
||||
{
|
||||
admin: admin,
|
||||
name: name
|
||||
}, {
|
||||
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 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 () {
|
||||
$("#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(".username-input").val("");
|
||||
dialog.find(".admin-checkbox").prop("checked", false);
|
||||
dialog.modal();
|
||||
});
|
||||
|
||||
$("#add-users-dialog").find(".save-button").click(function () {
|
||||
$("#add-users-dialog")
|
||||
.find(".save-button")
|
||||
.click(function() {
|
||||
var dialog = $("#add-users-dialog");
|
||||
var lines = dialog.find(".username-input").val().split('\n');
|
||||
var lines = dialog
|
||||
.find(".username-input")
|
||||
.val()
|
||||
.split("\n");
|
||||
var admin = dialog.find(".admin-checkbox").prop("checked");
|
||||
var usernames = [];
|
||||
lines.map(function(line) {
|
||||
@@ -175,11 +234,15 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
||||
}
|
||||
});
|
||||
|
||||
api.add_users(usernames, {admin: admin}, {
|
||||
api.add_users(
|
||||
usernames,
|
||||
{ admin: admin },
|
||||
{
|
||||
success: function() {
|
||||
window.location.reload();
|
||||
},
|
||||
}
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
$("#stop-all-servers").click(function() {
|
||||
@@ -190,19 +253,27 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
||||
$("#start-all-servers-dialog").modal();
|
||||
});
|
||||
|
||||
$("#stop-all-servers-dialog").find(".stop-all-button").click(function () {
|
||||
$("#stop-all-servers-dialog")
|
||||
.find(".stop-all-button")
|
||||
.click(function() {
|
||||
// stop all clicks all the active stop buttons
|
||||
$('.stop-server').not('.hidden').click();
|
||||
$(".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){
|
||||
$("#start-all-servers-dialog")
|
||||
.find(".start-all-button")
|
||||
.click(function() {
|
||||
$(".start-server")
|
||||
.not(".hidden")
|
||||
.each(function(i) {
|
||||
setTimeout(start(this), i * 500);
|
||||
});
|
||||
});
|
||||
@@ -213,7 +284,9 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
||||
dialog.modal();
|
||||
});
|
||||
|
||||
$("#shutdown-hub-dialog").find(".shutdown-button").click(function () {
|
||||
$("#shutdown-hub-dialog")
|
||||
.find(".shutdown-button")
|
||||
.click(function() {
|
||||
var dialog = $("#shutdown-hub-dialog");
|
||||
var servers = dialog.find(".shutdown-servers-checkbox").prop("checked");
|
||||
var proxy = dialog.find(".shutdown-proxy-checkbox").prop("checked");
|
||||
@@ -222,5 +295,4 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
||||
servers: servers,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,13 +1,98 @@
|
||||
// Copyright (c) Jupyter Development Team.
|
||||
// Distributed under the terms of the Modified BSD License.
|
||||
|
||||
require(["jquery", "jhapi"], function($, JHAPI) {
|
||||
require(["jquery", "moment", "jhapi"], function($, moment, JHAPI) {
|
||||
"use strict";
|
||||
|
||||
var base_url = window.jhdata.base_url;
|
||||
var user = window.jhdata.user;
|
||||
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() {
|
||||
$("#start")
|
||||
.attr("disabled", true)
|
||||
@@ -19,11 +104,32 @@ require(["jquery", "jhapi"], function($, JHAPI) {
|
||||
success: function() {
|
||||
$("#start")
|
||||
.text("Start My Server")
|
||||
.attr("title", "Start your server")
|
||||
.attr("title", "Start your default server")
|
||||
.attr("disabled", false)
|
||||
.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,7 +1,7 @@
|
||||
// Copyright (c) Jupyter Development Team.
|
||||
// Distributed under the terms of the Modified BSD License.
|
||||
|
||||
define(['jquery', 'utils'], function ($, utils) {
|
||||
define(["jquery", "utils"], function($, utils) {
|
||||
"use strict";
|
||||
|
||||
var JHAPI = function(base_url) {
|
||||
@@ -9,7 +9,7 @@ define(['jquery', 'utils'], function ($, utils) {
|
||||
};
|
||||
|
||||
var default_options = {
|
||||
type: 'GET',
|
||||
type: "GET",
|
||||
contentType: "application/json",
|
||||
cache: false,
|
||||
dataType: "json",
|
||||
@@ -37,7 +37,7 @@ define(['jquery', 'utils'], function ($, utils) {
|
||||
options = ajax_defaults(options || {});
|
||||
var url = utils.url_path_join(
|
||||
this.base_url,
|
||||
'api',
|
||||
"api",
|
||||
utils.encode_uri_components(path)
|
||||
);
|
||||
$.ajax(url, options);
|
||||
@@ -45,109 +45,115 @@ define(['jquery', 'utils'], function ($, utils) {
|
||||
|
||||
JHAPI.prototype.start_server = function(user, options) {
|
||||
options = options || {};
|
||||
options = update(options, {type: 'POST', dataType: null});
|
||||
options = update(options, { type: "POST", dataType: null });
|
||||
this.api_request(utils.url_path_join("users", user, "server"), options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.start_named_server = function(user, server_name, options) {
|
||||
options = options || {};
|
||||
options = update(options, { type: "POST", dataType: null });
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user, 'server'),
|
||||
utils.url_path_join("users", user, "servers", server_name),
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
JHAPI.prototype.stop_server = function(user, options) {
|
||||
options = options || {};
|
||||
options = update(options, {type: 'DELETE', dataType: null});
|
||||
options = update(options, { type: "DELETE", dataType: null });
|
||||
this.api_request(utils.url_path_join("users", user, "server"), options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.stop_named_server = function(user, server_name, options) {
|
||||
options = options || {};
|
||||
options = update(options, { type: "DELETE", dataType: null });
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user, 'server'),
|
||||
utils.url_path_join("users", user, "servers", server_name),
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
JHAPI.prototype.delete_named_server = function(user, server_name, options) {
|
||||
options = options || {};
|
||||
options.data = JSON.stringify({ remove: true });
|
||||
return this.stop_named_server(user, server_name, options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.list_users = function(options) {
|
||||
this.api_request('users', options);
|
||||
this.api_request("users", options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.get_user = function(user, options) {
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user),
|
||||
options
|
||||
);
|
||||
this.api_request(utils.url_path_join("users", user), options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.add_users = function(usernames, userinfo, options) {
|
||||
options = options || {};
|
||||
var data = update(userinfo, { usernames: usernames });
|
||||
options = update(options, {
|
||||
type: 'POST',
|
||||
type: "POST",
|
||||
dataType: null,
|
||||
data: JSON.stringify(data)
|
||||
data: JSON.stringify(data),
|
||||
});
|
||||
|
||||
this.api_request('users', options);
|
||||
this.api_request("users", options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.edit_user = function(user, userinfo, options) {
|
||||
options = options || {};
|
||||
options = update(options, {
|
||||
type: 'PATCH',
|
||||
type: "PATCH",
|
||||
dataType: null,
|
||||
data: JSON.stringify(userinfo)
|
||||
data: JSON.stringify(userinfo),
|
||||
});
|
||||
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user),
|
||||
options
|
||||
);
|
||||
this.api_request(utils.url_path_join("users", user), options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.admin_access = function(user, options) {
|
||||
options = options || {};
|
||||
options = update(options, {
|
||||
type: 'POST',
|
||||
type: "POST",
|
||||
dataType: null,
|
||||
});
|
||||
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user, 'admin-access'),
|
||||
utils.url_path_join("users", user, "admin-access"),
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
JHAPI.prototype.delete_user = function(user, options) {
|
||||
options = options || {};
|
||||
options = update(options, {type: 'DELETE', dataType: null});
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user),
|
||||
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) {
|
||||
options = options || {};
|
||||
options = update(options, {type: 'POST'});
|
||||
options = update(options, { type: "POST" });
|
||||
if (props) {
|
||||
options.data = JSON.stringify(props);
|
||||
}
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user, 'tokens'),
|
||||
options
|
||||
);
|
||||
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'});
|
||||
options = update(options, { type: "DELETE" });
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user, 'tokens', token_id),
|
||||
utils.url_path_join("users", user, "tokens", token_id),
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
JHAPI.prototype.shutdown_hub = function(data, options) {
|
||||
options = options || {};
|
||||
options = update(options, {type: 'POST'});
|
||||
options = update(options, { type: "POST" });
|
||||
if (data) {
|
||||
options.data = JSON.stringify(data);
|
||||
}
|
||||
this.api_request('shutdown', options);
|
||||
this.api_request("shutdown", options);
|
||||
};
|
||||
|
||||
return JHAPI;
|
||||
|
@@ -11,7 +11,7 @@ require(["jquery", "jhapi", "moment"], function($, JHAPI, moment) {
|
||||
$(".time-col").map(function(i, el) {
|
||||
// convert ISO datestamps to nice momentjs ones
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ require(["jquery", "jhapi", "moment"], function($, JHAPI, moment) {
|
||||
var el = $(this);
|
||||
var row = get_token_row(el);
|
||||
el.attr("disabled", true);
|
||||
api.revoke_token(user, row.data('token-id'), {
|
||||
api.revoke_token(user, row.data("token-id"), {
|
||||
success: function(reply) {
|
||||
row.remove();
|
||||
},
|
||||
|
@@ -5,23 +5,23 @@
|
||||
// Modifications Copyright (c) Juptyer Development Team.
|
||||
// Distributed under the terms of the Modified BSD License.
|
||||
|
||||
define(['jquery'], function($){
|
||||
define(["jquery"], function($) {
|
||||
"use strict";
|
||||
|
||||
var url_path_join = function() {
|
||||
// join a sequence of url components with '/'
|
||||
var url = '';
|
||||
var url = "";
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
if (arguments[i] === '') {
|
||||
if (arguments[i] === "") {
|
||||
continue;
|
||||
}
|
||||
if (url.length > 0 && url[url.length-1] != '/') {
|
||||
url = url + '/' + arguments[i];
|
||||
if (url.length > 0 && url[url.length - 1] != "/") {
|
||||
url = url + "/" + arguments[i];
|
||||
} else {
|
||||
url = url + arguments[i];
|
||||
}
|
||||
}
|
||||
url = url.replace(/\/\/+/, '/');
|
||||
url = url.replace(/\/\/+/, "/");
|
||||
return url;
|
||||
};
|
||||
|
||||
@@ -42,7 +42,10 @@ define(['jquery'], function($){
|
||||
var encode_uri_components = function(uri) {
|
||||
// encode just the components of a multi-segment uri,
|
||||
// leaving '/' separators
|
||||
return uri.split('/').map(encodeURIComponent).join('/');
|
||||
return uri
|
||||
.split("/")
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
};
|
||||
|
||||
var url_join_encode = function() {
|
||||
@@ -51,38 +54,42 @@ define(['jquery'], function($){
|
||||
return encode_uri_components(url_path_join.apply(null, arguments));
|
||||
};
|
||||
|
||||
|
||||
var escape_html = function(text) {
|
||||
// escape text to HTML
|
||||
return $("<div/>").text(text).html();
|
||||
return $("<div/>")
|
||||
.text(text)
|
||||
.html();
|
||||
};
|
||||
|
||||
var get_body_data = function(key) {
|
||||
// get a url-encoded item from body.data and decode it
|
||||
// we should never have any encoded URLs anywhere else in code
|
||||
// until we are building an actual request
|
||||
return decodeURIComponent($('body').data(key));
|
||||
return decodeURIComponent($("body").data(key));
|
||||
};
|
||||
|
||||
|
||||
// http://stackoverflow.com/questions/2400935/browser-detection-in-javascript
|
||||
var browser = (function() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
if (typeof navigator === "undefined") {
|
||||
// navigator undefined in node
|
||||
return 'None';
|
||||
return "None";
|
||||
}
|
||||
var N= navigator.appName, ua= navigator.userAgent, tem;
|
||||
var M= ua.match(/(opera|chrome|safari|firefox|msie)\/?\s*(\.?\d+(\.\d+)*)/i);
|
||||
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,'-?'];
|
||||
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') {
|
||||
if (typeof navigator === "undefined") {
|
||||
// navigator undefined in node
|
||||
return 'None';
|
||||
return "None";
|
||||
}
|
||||
var OSName = "None";
|
||||
if (navigator.appVersion.indexOf("Win") != -1) OSName = "Windows";
|
||||
|
@@ -24,7 +24,7 @@
|
||||
{% block thead %}
|
||||
{{ th("User (%i)" % users|length, 'name') }}
|
||||
{{ th("Admin", 'admin') }}
|
||||
{{ th("Last Seen", 'last_activity') }}
|
||||
{{ th("Last Activity", 'last_activity') }}
|
||||
{{ th("Running (%i)" % running|length, 'running', colspan=2) }}
|
||||
{% endblock thead %}
|
||||
</tr>
|
||||
@@ -40,37 +40,65 @@
|
||||
<a id="shutdown-hub" role="button" class="col-xs-2 col-xs-offset-1 btn btn-danger">Shutdown Hub</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% for u in users %}
|
||||
<tr class="user-row" data-user="{{u.name}}" data-admin="{{u.admin}}">
|
||||
{% for user in users %}
|
||||
{% 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 %}
|
||||
<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}}
|
||||
{%- if spawner.name -%}
|
||||
/{{ spawner.name }}
|
||||
{%- endif -%}
|
||||
</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 u.last_activity -%}
|
||||
{{ u.last_activity.isoformat() + 'Z' }}
|
||||
{%- if spawner.last_activity -%}
|
||||
{{ spawner.last_activity.isoformat() + 'Z' }}
|
||||
{%- else -%}
|
||||
Never
|
||||
{%- endif -%}
|
||||
</td>
|
||||
|
||||
<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="start-server btn btn-xs btn-primary {% if u.running %}hidden{% endif %}">start server</a>
|
||||
<a role="button" class="stop-server btn btn-xs btn-danger{% if not spawner.active %} hidden{% endif %}">
|
||||
stop server
|
||||
</a>
|
||||
<a role="button" class="start-server btn btn-xs btn-primary{% if spawner.active %} hidden{% endif %}">
|
||||
start server
|
||||
</a>
|
||||
</td>
|
||||
<td class="server-col col-sm-1 text-center">
|
||||
{% if admin_access %}
|
||||
<a role="button" class="access-server btn btn-xs btn-primary {% if not u.running %}hidden{% endif %}">access server</a>
|
||||
{% endif %}
|
||||
{%- if admin_access %}
|
||||
<a role="button" class="access-server btn btn-xs btn-primary{% if not spawner.active %} hidden{% endif %}">
|
||||
access server
|
||||
</a>
|
||||
{%- endif %}
|
||||
</td>
|
||||
<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 class="edit-col col-sm-1 text-center">
|
||||
{% if u.name != user.name %}
|
||||
<a role="button" class="delete-user btn btn-xs btn-danger">delete</a>
|
||||
{% endif %}
|
||||
{%- if spawner.name == '' -%}
|
||||
{#- user row -#}
|
||||
{%- 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>
|
||||
{% endblock user_row %}
|
||||
</tr>
|
||||
{% endblock user_row %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@@ -8,19 +8,78 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="text-center">
|
||||
{% if user.running %}
|
||||
<a id="stop" role="button" class="btn btn-lg btn-danger">Stop My Server</a>
|
||||
{% if default_server.active %}
|
||||
<a id="stop" role="button" class="btn btn-lg btn-danger">
|
||||
Stop My Server
|
||||
</a>
|
||||
{% endif %}
|
||||
<a id="start" role="button" class="btn btn-lg btn-primary" href="{{ url }}">
|
||||
{% if not user.active %}
|
||||
Start
|
||||
{% endif %}
|
||||
{% if not default_server.active %}Start{% endif %}
|
||||
My Server
|
||||
</a>
|
||||
</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 %}
|
||||
|
||||
{% 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 %}
|
||||
<span id="login_widget">
|
||||
{% 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>
|
||||
{% else %}
|
||||
<a id="login" role="button" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -ex
|
||||
|
||||
stable=0.8
|
||||
stable=0.9
|
||||
|
||||
for V in master $stable; do
|
||||
docker build --build-arg JUPYTERHUB_VERSION=$V -t $DOCKER_REPO:$V .
|
||||
|
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -ex
|
||||
|
||||
stable=0.8
|
||||
stable=0.9
|
||||
for V in master $stable; do
|
||||
docker push $DOCKER_REPO:$V
|
||||
done
|
||||
@@ -12,6 +13,10 @@ function get_hub_version() {
|
||||
hub_xyz=$(cat hub_version)
|
||||
split=( ${hub_xyz//./ } )
|
||||
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
|
||||
get_hub_version $stable
|
||||
@@ -22,3 +27,5 @@ docker push $DOCKER_REPO:$hub_xyz
|
||||
get_hub_version master
|
||||
docker tag $DOCKER_REPO:master $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