mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 07:23:00 +00:00
Merge branch 'rbac' into read_roles
This commit is contained in:
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Should we push this image to a public registry?
|
- name: Should we push this image to a public registry?
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/master') }}" = "true" ]; then
|
if [ "${{ startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/main') }}" = "true" ]; then
|
||||||
# Empty => Docker Hub
|
# Empty => Docker Hub
|
||||||
echo "REGISTRY=" >> $GITHUB_ENV
|
echo "REGISTRY=" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
# https://github.com/jupyterhub/jupyterhub/settings/secrets/actions
|
# https://github.com/jupyterhub/jupyterhub/settings/secrets/actions
|
||||||
if: env.REGISTRY != 'localhost:5000/'
|
if: env.REGISTRY != 'localhost:5000/'
|
||||||
run: |
|
run: |
|
||||||
docker login -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
|
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||||
|
|
||||||
# https://github.com/jupyterhub/action-major-minor-tag-calculator
|
# https://github.com/jupyterhub/action-major-minor-tag-calculator
|
||||||
# If this is a tagged build this will return additional parent tags.
|
# If this is a tagged build this will return additional parent tags.
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ htmlcov
|
|||||||
pip-wheel-metadata
|
pip-wheel-metadata
|
||||||
docs/source/reference/metrics.rst
|
docs/source/reference/metrics.rst
|
||||||
oldest-requirements.txt
|
oldest-requirements.txt
|
||||||
|
jupyterhub-proxy.pid
|
||||||
|
@@ -1 +1 @@
|
|||||||
Please refer to [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md).
|
Please refer to [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/HEAD/conduct/code_of_conduct.md).
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
Welcome! As a [Jupyter](https://jupyter.org) project,
|
Welcome! As a [Jupyter](https://jupyter.org) project,
|
||||||
you can follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html).
|
you can follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en/latest/contributing/content-contributor.html).
|
||||||
|
|
||||||
Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md)
|
Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/HEAD/conduct/code_of_conduct.md)
|
||||||
for a friendly and welcoming collaborative environment.
|
for a friendly and welcoming collaborative environment.
|
||||||
|
|
||||||
## Setting up a development environment
|
## Setting up a development environment
|
||||||
|
@@ -14,7 +14,7 @@
|
|||||||
[](https://github.com/jupyterhub/jupyterhub/actions)
|
[](https://github.com/jupyterhub/jupyterhub/actions)
|
||||||
[](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
|
[](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
|
||||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
|
[](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
|
||||||
[](https://codecov.io/gh/jupyterhub/jupyterhub)
|
[](https://codecov.io/gh/jupyterhub/jupyterhub)
|
||||||
[](https://github.com/jupyterhub/jupyterhub/issues)
|
[](https://github.com/jupyterhub/jupyterhub/issues)
|
||||||
[](https://discourse.jupyter.org/c/jupyterhub)
|
[](https://discourse.jupyter.org/c/jupyterhub)
|
||||||
[](https://gitter.im/jupyterhub/jupyterhub)
|
[](https://gitter.im/jupyterhub/jupyterhub)
|
||||||
@@ -46,7 +46,7 @@ Basic principles for operation are:
|
|||||||
servers.
|
servers.
|
||||||
|
|
||||||
JupyterHub also provides a
|
JupyterHub also provides a
|
||||||
[REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
[REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
|
||||||
for administration of the Hub and its users.
|
for administration of the Hub and its users.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -239,7 +239,7 @@ our JupyterHub [Gitter](https://gitter.im/jupyterhub/jupyterhub) channel.
|
|||||||
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
||||||
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||||
- [Documentation for JupyterHub](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](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
||||||
- [Documentation for JupyterHub's REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
- [Documentation for JupyterHub's REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
|
||||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
||||||
- [Project Jupyter website](https://jupyter.org)
|
- [Project Jupyter website](https://jupyter.org)
|
||||||
- [Project Jupyter community](https://jupyter.org/community)
|
- [Project Jupyter community](https://jupyter.org/community)
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
# see me at: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#/default
|
# see me at: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#/default
|
||||||
swagger: "2.0"
|
swagger: "2.0"
|
||||||
info:
|
info:
|
||||||
title: JupyterHub
|
title: JupyterHub
|
||||||
@@ -143,6 +143,22 @@ paths:
|
|||||||
inactive: all users who have *no* active servers (complement of active)
|
inactive: all users who have *no* active servers (complement of active)
|
||||||
|
|
||||||
Added in JupyterHub 1.3
|
Added in JupyterHub 1.3
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
description: |
|
||||||
|
Return a number users starting at the given offset.
|
||||||
|
Can be used with limit to paginate.
|
||||||
|
If unspecified, return all users.
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
requred: false
|
||||||
|
type: number
|
||||||
|
description: |
|
||||||
|
Return a finite number of users.
|
||||||
|
Can be used with offset to paginate.
|
||||||
|
If unspecified, use api_page_default_limit.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: The Hub's user list
|
description: The Hub's user list
|
||||||
@@ -379,7 +395,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
- name: server_name
|
- name: server_name
|
||||||
description: |
|
description: |
|
||||||
name given to a named-server.
|
name given to a named-server.
|
||||||
|
|
||||||
Note that depending on your JupyterHub infrastructure there are chracterter size limitation to `server_name`. Default spawner with K8s pod will not allow Jupyter Notebooks to be spawned with a name that contains more than 253 characters (keep in mind that the pod will be spawned with extra characters to identify the user and hub).
|
Note that depending on your JupyterHub infrastructure there are chracterter size limitation to `server_name`. Default spawner with K8s pod will not allow Jupyter Notebooks to be spawned with a name that contains more than 253 characters (keep in mind that the pod will be spawned with extra characters to identify the user and hub).
|
||||||
in: path
|
in: path
|
||||||
@@ -540,6 +556,23 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- oauth2:
|
- oauth2:
|
||||||
- read:groups
|
- read:groups
|
||||||
|
parameters:
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
description: |
|
||||||
|
Return a number of groups starting at the specified offset.
|
||||||
|
Can be used with limit to paginate.
|
||||||
|
If unspecified, return all groups.
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
description: |
|
||||||
|
Return a finite number of groups.
|
||||||
|
Can be used with offset to paginate.
|
||||||
|
If unspecified, use api_page_default_limit.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: The list of groups
|
description: The list of groups
|
||||||
@@ -686,6 +719,23 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- oauth2:
|
- oauth2:
|
||||||
- proxy
|
- proxy
|
||||||
|
parameters:
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
type: number
|
||||||
|
description: |
|
||||||
|
Return a number of routes starting at the given offset.
|
||||||
|
Can be used with limit to paginate.
|
||||||
|
If unspecified, return all routes.
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
requred: false
|
||||||
|
type: number
|
||||||
|
description: |
|
||||||
|
Return a finite number of routes.
|
||||||
|
Can be used with offset to paginate.
|
||||||
|
If unspecified, use api_page_default_limit
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Routing table
|
description: Routing table
|
||||||
|
@@ -18,7 +18,7 @@ information on:
|
|||||||
- learning more about JupyterHub's API
|
- learning more about JupyterHub's API
|
||||||
|
|
||||||
The same JupyterHub API spec, as found here, is available in an interactive form
|
The same JupyterHub API spec, as found here, is available in an interactive form
|
||||||
`here (on swagger's petstore) <http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default>`__.
|
`here (on swagger's petstore) <https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default>`__.
|
||||||
The `OpenAPI Initiative`_ (fka Swagger™) is a project used to describe
|
The `OpenAPI Initiative`_ (fka Swagger™) is a project used to describe
|
||||||
and document RESTful APIs.
|
and document RESTful APIs.
|
||||||
|
|
||||||
|
@@ -985,7 +985,7 @@ Bugfixes on 0.6:
|
|||||||
|
|
||||||
### [0.6.0] - 2016-04-25
|
### [0.6.0] - 2016-04-25
|
||||||
|
|
||||||
- JupyterHub has moved to a new `jupyterhub` namespace on GitHub and Docker. What was `juptyer/jupyterhub` is now `jupyterhub/jupyterhub`, etc.
|
- JupyterHub has moved to a new `jupyterhub` namespace on GitHub and Docker. What was `jupyter/jupyterhub` is now `jupyterhub/jupyterhub`, etc.
|
||||||
- `jupyterhub/jupyterhub` image on DockerHub no longer loads the jupyterhub_config.py in an ONBUILD step. A new `jupyterhub/jupyterhub-onbuild` image does this
|
- `jupyterhub/jupyterhub` image on DockerHub no longer loads the jupyterhub_config.py in an ONBUILD step. A new `jupyterhub/jupyterhub-onbuild` image does this
|
||||||
- Add statsd support, via `c.JupyterHub.statsd_{host,port,prefix}`
|
- Add statsd support, via `c.JupyterHub.statsd_{host,port,prefix}`
|
||||||
- Update to traitlets 4.1 `@default`, `@observe` APIs for traits
|
- Update to traitlets 4.1 `@default`, `@observe` APIs for traits
|
||||||
|
@@ -13,7 +13,7 @@ Building documentation locally
|
|||||||
We use `sphinx <http://sphinx-doc.org>`_ to build our documentation. It takes
|
We use `sphinx <http://sphinx-doc.org>`_ to build our documentation. It takes
|
||||||
our documentation source files (written in `markdown
|
our documentation source files (written in `markdown
|
||||||
<https://daringfireball.net/projects/markdown/>`_ or `reStructuredText
|
<https://daringfireball.net/projects/markdown/>`_ or `reStructuredText
|
||||||
<http://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html>`_ &
|
<https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html>`_ &
|
||||||
stored under the ``docs/source`` directory) and converts it into various
|
stored under the ``docs/source`` directory) and converts it into various
|
||||||
formats for people to read. To make sure the documentation you write or
|
formats for people to read. To make sure the documentation you write or
|
||||||
change renders correctly, it is good practice to test it locally.
|
change renders correctly, it is good practice to test it locally.
|
||||||
@@ -39,8 +39,8 @@ change renders correctly, it is good practice to test it locally.
|
|||||||
along with the filename / line number in which they occurred. Fix them,
|
along with the filename / line number in which they occurred. Fix them,
|
||||||
and re-run the ``make html`` command to re-render the documentation.
|
and re-run the ``make html`` command to re-render the documentation.
|
||||||
|
|
||||||
#. View the rendered documentation by opening ``build/html/index.html`` in
|
#. View the rendered documentation by opening ``build/html/index.html`` in
|
||||||
a web browser.
|
a web browser.
|
||||||
|
|
||||||
.. tip::
|
.. tip::
|
||||||
|
|
||||||
|
@@ -6,8 +6,8 @@ We want you to contribute to JupyterHub in ways that are most exciting
|
|||||||
& useful to you. We value documentation, testing, bug reporting & code equally,
|
& useful to you. We value documentation, testing, bug reporting & code equally,
|
||||||
and are glad to have your contributions in whatever form you wish :)
|
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>`_
|
Our `Code of Conduct <https://github.com/jupyter/governance/blob/HEAD/conduct/code_of_conduct.md>`_
|
||||||
(`reporting guidelines <https://github.com/jupyter/governance/blob/master/conduct/reporting_online.md>`_)
|
(`reporting guidelines <https://github.com/jupyter/governance/blob/HEAD/conduct/reporting_online.md>`_)
|
||||||
helps keep our community welcoming to as many people as possible.
|
helps keep our community welcoming to as many people as possible.
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
|
@@ -30,7 +30,7 @@ Please submit pull requests to update information or to add new institutions or
|
|||||||
|
|
||||||
### University of California Davis
|
### University of California Davis
|
||||||
|
|
||||||
- [Spinning up multiple Jupyter Notebooks on AWS for a tutorial](https://github.com/mblmicdiv/course2017/blob/master/exercises/sourmash-setup.md)
|
- [Spinning up multiple Jupyter Notebooks on AWS for a tutorial](https://github.com/mblmicdiv/course2017/blob/HEAD/exercises/sourmash-setup.md)
|
||||||
|
|
||||||
Although not technically a JupyterHub deployment, this tutorial setup
|
Although not technically a JupyterHub deployment, this tutorial setup
|
||||||
may be helpful to others in the Jupyter community.
|
may be helpful to others in the Jupyter community.
|
||||||
|
@@ -22,7 +22,7 @@ Admin users of JupyterHub, `admin_users`, can add and remove users from
|
|||||||
the user `allowed_users` set. `admin_users` can take actions on other users'
|
the user `allowed_users` set. `admin_users` can take actions on other users'
|
||||||
behalf, such as stopping and restarting their servers.
|
behalf, such as stopping and restarting their servers.
|
||||||
|
|
||||||
A set of initial admin users, `admin_users` can configured be as follows:
|
A set of initial admin users, `admin_users` can be configured as follows:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.Authenticator.admin_users = {'mal', 'zoe'}
|
c.Authenticator.admin_users = {'mal', 'zoe'}
|
||||||
@@ -32,9 +32,9 @@ Users in the admin set are automatically added to the user `allowed_users` set,
|
|||||||
if they are not already present.
|
if they are not already present.
|
||||||
|
|
||||||
Each authenticator may have different ways of determining whether a user is an
|
Each authenticator may have different ways of determining whether a user is an
|
||||||
administrator. By default JupyterHub use the PAMAuthenticator which provide the
|
administrator. By default JupyterHub uses the PAMAuthenticator which provides the
|
||||||
`admin_groups` option and can determine administrator status base on a user
|
`admin_groups` option and can set administrator status based on a user
|
||||||
groups. For example we can let any users in the `wheel` group be admin:
|
group. For example we can let any user in the `wheel` group be admin:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.PAMAuthenticator.admin_groups = {'wheel'}
|
c.PAMAuthenticator.admin_groups = {'wheel'}
|
||||||
@@ -42,9 +42,9 @@ c.PAMAuthenticator.admin_groups = {'wheel'}
|
|||||||
|
|
||||||
## Give admin access to other users' notebook servers (`admin_access`)
|
## Give admin access to other users' notebook servers (`admin_access`)
|
||||||
|
|
||||||
Since the default `JupyterHub.admin_access` setting is False, the admins
|
Since the default `JupyterHub.admin_access` setting is `False`, the admins
|
||||||
do not have permission to log in to the single user notebook servers
|
do not have permission to log in to the single user notebook servers
|
||||||
owned by _other users_. If `JupyterHub.admin_access` is set to True,
|
owned by _other users_. If `JupyterHub.admin_access` is set to `True`,
|
||||||
then admins have permission to log in _as other users_ on their
|
then admins have permission to log in _as other users_ on their
|
||||||
respective machines, for debugging. **As a courtesy, you should make
|
respective machines, for debugging. **As a courtesy, you should make
|
||||||
sure your users know if admin_access is enabled.**
|
sure your users know if admin_access is enabled.**
|
||||||
@@ -53,8 +53,8 @@ sure your users know if admin_access is enabled.**
|
|||||||
|
|
||||||
Users can be added to and removed from the Hub via either the admin
|
Users can be added to and removed from the Hub via either the admin
|
||||||
panel or the REST API. When a user is **added**, the user will be
|
panel or the REST API. When a user is **added**, the user will be
|
||||||
automatically added to the allowed users set and database. Restarting the Hub
|
automatically added to the `allowed_users` set and database. Restarting the Hub
|
||||||
will not require manually updating the allowed users set in your config file,
|
will not require manually updating the `allowed_users` set in your config file,
|
||||||
as the users will be loaded from the database.
|
as the users will be loaded from the database.
|
||||||
|
|
||||||
After starting the Hub once, it is not sufficient to **remove** a user
|
After starting the Hub once, it is not sufficient to **remove** a user
|
||||||
@@ -107,8 +107,8 @@ with any provider, is also available.
|
|||||||
|
|
||||||
## Use DummyAuthenticator for testing
|
## Use DummyAuthenticator for testing
|
||||||
|
|
||||||
The :class:`~jupyterhub.auth.DummyAuthenticator` is a simple authenticator that
|
The `DummyAuthenticator` is a simple authenticator that
|
||||||
allows for any username/password unless if a global password has been set. If
|
allows for any username/password unless a global password has been set. If
|
||||||
set, it will allow for any username as long as the correct password is provided.
|
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:
|
To set a global password, add this to the config file:
|
||||||
|
|
||||||
|
@@ -44,7 +44,7 @@ jupyterhub -f /etc/jupyterhub/jupyterhub_config.py
|
|||||||
```
|
```
|
||||||
|
|
||||||
The IPython documentation provides additional information on the
|
The IPython documentation provides additional information on the
|
||||||
[config system](http://ipython.readthedocs.io/en/stable/development/config)
|
[config system](http://ipython.readthedocs.io/en/stable/development/config.html)
|
||||||
that Jupyter uses.
|
that Jupyter uses.
|
||||||
|
|
||||||
## Configure using command line options
|
## Configure using command line options
|
||||||
@@ -63,11 +63,11 @@ would enter:
|
|||||||
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
||||||
```
|
```
|
||||||
|
|
||||||
All configurable options may technically be set on the command-line,
|
All configurable options may technically be set on the command line,
|
||||||
though some are inconvenient to type. To set a particular configuration
|
though some are inconvenient to type. To set a particular configuration
|
||||||
parameter, `c.Class.trait`, you would use the command line option,
|
parameter, `c.Class.trait`, you would use the command line option,
|
||||||
`--Class.trait`, when starting JupyterHub. For example, to configure the
|
`--Class.trait`, when starting JupyterHub. For example, to configure the
|
||||||
`c.Spawner.notebook_dir` trait from the command-line, use the
|
`c.Spawner.notebook_dir` trait from the command line, use the
|
||||||
`--Spawner.notebook_dir` option:
|
`--Spawner.notebook_dir` option:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -89,11 +89,11 @@ meant as illustration, are:
|
|||||||
## Run the proxy separately
|
## Run the proxy separately
|
||||||
|
|
||||||
This is _not_ strictly necessary, but useful in many cases. If you
|
This is _not_ strictly necessary, but useful in many cases. If you
|
||||||
use a custom proxy (e.g. Traefik), this also not needed.
|
use a custom proxy (e.g. Traefik), this is also not needed.
|
||||||
|
|
||||||
Connections to user servers go through the proxy, and _not_ the hub
|
Connections to user servers go through the proxy, and _not_ the hub
|
||||||
itself. If the proxy stays running when the hub restarts (for
|
itself. If the proxy stays running when the hub restarts (for
|
||||||
maintenance, re-configuration, etc.), then use connections are not
|
maintenance, re-configuration, etc.), then user connections are not
|
||||||
interrupted. For simplicity, by default the hub starts the proxy
|
interrupted. For simplicity, by default the hub starts the proxy
|
||||||
automatically, so if the hub restarts, the proxy restarts, and user
|
automatically, so if the hub restarts, the proxy restarts, and user
|
||||||
connections are interrupted. It is easy to run the proxy separately,
|
connections are interrupted. It is easy to run the proxy separately,
|
||||||
|
@@ -26,7 +26,7 @@ so Breq would open `/user/breq/notebooks/foo.ipynb` and
|
|||||||
Seivarden would open `/user/seivarden/notebooks/foo.ipynb`, etc.
|
Seivarden would open `/user/seivarden/notebooks/foo.ipynb`, etc.
|
||||||
|
|
||||||
JupyterHub has a special URL that does exactly this!
|
JupyterHub has a special URL that does exactly this!
|
||||||
It's called `/hub/user-redirect/...` and after the visitor logs in,
|
It's called `/hub/user-redirect/...`.
|
||||||
So if you replace `/user/yourname` in your URL bar
|
So if you replace `/user/yourname` in your URL bar
|
||||||
with `/hub/user-redirect` any visitor should get the same
|
with `/hub/user-redirect` any visitor should get the same
|
||||||
URL on their own server, rather than visiting yours.
|
URL on their own server, rather than visiting yours.
|
||||||
|
@@ -11,7 +11,7 @@ Yes! JupyterHub has been used at-scale for large pools of users, as well
|
|||||||
as complex and high-performance computing. For example, UC Berkeley uses
|
as complex and high-performance computing. For example, UC Berkeley uses
|
||||||
JupyterHub for its Data Science Education Program courses (serving over
|
JupyterHub for its Data Science Education Program courses (serving over
|
||||||
3,000 students). The Pangeo project uses JupyterHub to provide access
|
3,000 students). The Pangeo project uses JupyterHub to provide access
|
||||||
to scalable cloud computing with Dask. JupyterHub is stable customizable
|
to scalable cloud computing with Dask. JupyterHub is stable and customizable
|
||||||
to the use-cases of large organizations.
|
to the use-cases of large organizations.
|
||||||
|
|
||||||
### I keep hearing about Jupyter Notebook, JupyterLab, and now JupyterHub. What’s the difference?
|
### I keep hearing about Jupyter Notebook, JupyterLab, and now JupyterHub. What’s the difference?
|
||||||
@@ -27,14 +27,14 @@ Here is a quick breakdown of these three tools:
|
|||||||
for other parts of the data science stack.
|
for other parts of the data science stack.
|
||||||
- **JupyterHub** is an application that manages interactive computing sessions for **multiple users**.
|
- **JupyterHub** is an application that manages interactive computing sessions for **multiple users**.
|
||||||
It also connects them with infrastructure those users wish to access. It can provide
|
It also connects them with infrastructure those users wish to access. It can provide
|
||||||
remote access to Jupyter Notebooks and Jupyter Lab for many people.
|
remote access to Jupyter Notebooks and JupyterLab for many people.
|
||||||
|
|
||||||
## For management
|
## For management
|
||||||
|
|
||||||
### Briefly, what problem does JupyterHub solve for us?
|
### Briefly, what problem does JupyterHub solve for us?
|
||||||
|
|
||||||
JupyterHub provides a shared platform for data science and collaboration.
|
JupyterHub provides a shared platform for data science and collaboration.
|
||||||
It allows users to utilize familiar data science workflows (such as the scientific python stack,
|
It allows users to utilize familiar data science workflows (such as the scientific Python stack,
|
||||||
the R tidyverse, and Jupyter Notebooks) on institutional infrastructure. It also allows administrators
|
the R tidyverse, and Jupyter Notebooks) on institutional infrastructure. It also allows administrators
|
||||||
some control over access to resources, security, environments, and authentication.
|
some control over access to resources, security, environments, and authentication.
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ industry, and government research labs. It is most-commonly used by two kinds of
|
|||||||
- Large teams (e.g., a department, a large class, or a large group of remote users) to provide
|
- Large teams (e.g., a department, a large class, or a large group of remote users) to provide
|
||||||
access to organizational hardware, data, and analytics environments at scale.
|
access to organizational hardware, data, and analytics environments at scale.
|
||||||
|
|
||||||
Here are a sample of organizations that use JupyterHub:
|
Here is a sample of organizations that use JupyterHub:
|
||||||
|
|
||||||
- **Universities and colleges**: UC Berkeley, UC San Diego, Cal Poly SLO, Harvard University, University of Chicago,
|
- **Universities and colleges**: UC Berkeley, UC San Diego, Cal Poly SLO, Harvard University, University of Chicago,
|
||||||
University of Oslo, University of Sheffield, Université Paris Sud, University of Versailles
|
University of Oslo, University of Sheffield, Université Paris Sud, University of Versailles
|
||||||
@@ -99,7 +99,7 @@ that we currently suggest are:
|
|||||||
guide that runs on Kubernetes. Better for larger or dynamic user groups (50-10,000) or more complex
|
guide that runs on Kubernetes. Better for larger or dynamic user groups (50-10,000) or more complex
|
||||||
compute/data needs.
|
compute/data needs.
|
||||||
- [The Littlest JupyterHub](https://tljh.jupyter.org) is a lightweight JupyterHub that runs on a single
|
- [The Littlest JupyterHub](https://tljh.jupyter.org) is a lightweight JupyterHub that runs on a single
|
||||||
single machine (in the cloud or under your desk). Better for smaller usergroups (4-80) or more
|
single machine (in the cloud or under your desk). Better for smaller user groups (4-80) or more
|
||||||
lightweight computational resources.
|
lightweight computational resources.
|
||||||
|
|
||||||
### Does JupyterHub run well in the cloud?
|
### Does JupyterHub run well in the cloud?
|
||||||
@@ -123,7 +123,7 @@ level for several years, and makes a number of "default" security decisions that
|
|||||||
users.
|
users.
|
||||||
|
|
||||||
- For security considerations in the base JupyterHub application,
|
- For security considerations in the base JupyterHub application,
|
||||||
[see the JupyterHub security page](https://jupyterhub.readthedocs.io/en/stable/reference/websecurity.html)
|
[see the JupyterHub security page](https://jupyterhub.readthedocs.io/en/stable/reference/websecurity.html).
|
||||||
- For security considerations when deploying JupyterHub on Kubernetes, see the
|
- For security considerations when deploying JupyterHub on Kubernetes, see the
|
||||||
[JupyterHub on Kubernetes security page](https://zero-to-jupyterhub.readthedocs.io/en/latest/security.html).
|
[JupyterHub on Kubernetes security page](https://zero-to-jupyterhub.readthedocs.io/en/latest/security.html).
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ how those resources are controlled is taken care of by the non-JupyterHub applic
|
|||||||
|
|
||||||
Yes - JupyterHub can provide access to many kinds of computing infrastructure.
|
Yes - JupyterHub can provide access to many kinds of computing infrastructure.
|
||||||
Especially when combined with other open-source schedulers such as Dask, you can manage fairly
|
Especially when combined with other open-source schedulers such as Dask, you can manage fairly
|
||||||
complex computing infrastructure from the interactive sessions of a JupyterHub. For example
|
complex computing infrastructures from the interactive sessions of a JupyterHub. For example
|
||||||
[see the Dask HPC page](https://docs.dask.org/en/latest/setup/hpc.html).
|
[see the Dask HPC page](https://docs.dask.org/en/latest/setup/hpc.html).
|
||||||
|
|
||||||
### How much resources do user sessions take?
|
### How much resources do user sessions take?
|
||||||
@@ -192,7 +192,7 @@ This is highly configurable by the administrator. If you wish for your users to
|
|||||||
data analytics environments for prototyping and light data exploring, you can restrict their
|
data analytics environments for prototyping and light data exploring, you can restrict their
|
||||||
memory and CPU based on the resources that you have available. If you'd like your JupyterHub
|
memory and CPU based on the resources that you have available. If you'd like your JupyterHub
|
||||||
to serve as a gateway to high-performance compute or data resources, you may increase the
|
to serve as a gateway to high-performance compute or data resources, you may increase the
|
||||||
resources available on user machines, or connect them with computing infrastructure elsewhere.
|
resources available on user machines, or connect them with computing infrastructures elsewhere.
|
||||||
|
|
||||||
### Can I customize the look and feel of a JupyterHub?
|
### Can I customize the look and feel of a JupyterHub?
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ your JupyterHub with the various services and tools that you wish to provide to
|
|||||||
### How well does JupyterHub scale? What are JupyterHub's limitations?
|
### How well does JupyterHub scale? What are JupyterHub's limitations?
|
||||||
|
|
||||||
JupyterHub works well at both a small scale (e.g., a single VM or machine) as well as a
|
JupyterHub works well at both a small scale (e.g., a single VM or machine) as well as a
|
||||||
high scale (e.g., a scalable Kubernetes cluster). It can be used for teams as small a 2, and
|
high scale (e.g., a scalable Kubernetes cluster). It can be used for teams as small as 2, and
|
||||||
for user bases as large as 10,000. The scalability of JupyterHub largely depends on the
|
for user bases as large as 10,000. The scalability of JupyterHub largely depends on the
|
||||||
infrastructure on which it is deployed. JupyterHub has been designed to be lightweight and
|
infrastructure on which it is deployed. JupyterHub has been designed to be lightweight and
|
||||||
flexible, so you can tailor your JupyterHub deployment to your needs.
|
flexible, so you can tailor your JupyterHub deployment to your needs.
|
||||||
@@ -249,7 +249,7 @@ share their results with one another.
|
|||||||
|
|
||||||
JupyterHub also provides a computational framework to share computational narratives between
|
JupyterHub also provides a computational framework to share computational narratives between
|
||||||
different levels of an organization. For example, data scientists can share Jupyter Notebooks
|
different levels of an organization. For example, data scientists can share Jupyter Notebooks
|
||||||
rendered as [voila dashboards](https://voila.readthedocs.io/en/stable/) with those who are not
|
rendered as [Voilà dashboards](https://voila.readthedocs.io/en/stable/) with those who are not
|
||||||
familiar with programming, or create publicly-available interactive analyses to allow others to
|
familiar with programming, or create publicly-available interactive analyses to allow others to
|
||||||
interact with your work.
|
interact with your work.
|
||||||
|
|
||||||
|
@@ -43,7 +43,7 @@ port.
|
|||||||
|
|
||||||
By default, this REST API listens on port 8001 of `localhost` only.
|
By default, this REST API listens on port 8001 of `localhost` only.
|
||||||
The Hub service talks to the proxy via a REST API on a secondary port. The
|
The Hub service talks to the proxy via a REST API on a secondary port. The
|
||||||
API URL can be configured separately and override the default settings.
|
API URL can be configured separately to override the default settings.
|
||||||
|
|
||||||
### Set api_url
|
### Set api_url
|
||||||
|
|
||||||
@@ -82,13 +82,13 @@ c.JupyterHub.hub_ip = '10.0.1.4'
|
|||||||
c.JupyterHub.hub_port = 54321
|
c.JupyterHub.hub_port = 54321
|
||||||
```
|
```
|
||||||
|
|
||||||
**Added in 0.8:** The `c.JupyterHub.hub_connect_ip` setting is the ip address or
|
**Added in 0.8:** The `c.JupyterHub.hub_connect_ip` setting is the IP address or
|
||||||
hostname that other services should use to connect to the Hub. A common
|
hostname that other services should use to connect to the Hub. A common
|
||||||
configuration for, e.g. docker, is:
|
configuration for, e.g. docker, is:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.JupyterHub.hub_ip = '0.0.0.0' # listen on all interfaces
|
c.JupyterHub.hub_ip = '0.0.0.0' # listen on all interfaces
|
||||||
c.JupyterHub.hub_connect_ip = '10.0.1.4' # ip as seen on the docker network. Can also be a hostname.
|
c.JupyterHub.hub_connect_ip = '10.0.1.4' # IP as seen on the docker network. Can also be a hostname.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adjusting the hub's URL
|
## Adjusting the hub's URL
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
When working with JupyterHub, a **Service** is defined as a process
|
When working with JupyterHub, a **Service** is defined as a process
|
||||||
that interacts with the Hub's REST API. A Service may perform a specific
|
that interacts with the Hub's REST API. A Service may perform a specific
|
||||||
or action or task. For example, shutting down individuals' single user
|
action or task. For example, shutting down individuals' single user
|
||||||
notebook servers that have been idle for some time is a good example of
|
notebook servers that have been idle for some time is a good example of
|
||||||
a task that could be automated by a Service. Let's look at how the
|
a task that could be automated by a Service. Let's look at how the
|
||||||
[jupyterhub_idle_culler][] script can be used as a Service.
|
[jupyterhub_idle_culler][] script can be used as a Service.
|
||||||
@@ -114,7 +114,7 @@ interact with it.
|
|||||||
This will run the idle culler service manually. It can be run as a standalone
|
This will run the idle culler service manually. It can be run as a standalone
|
||||||
script anywhere with access to the Hub, and will periodically check for idle
|
script anywhere with access to the Hub, and will periodically check for idle
|
||||||
servers and shut them down via the Hub's REST API. In order to shutdown the
|
servers and shut them down via the Hub's REST API. In order to shutdown the
|
||||||
servers, the token given to cull-idle must have admin privileges.
|
servers, the token given to `cull-idle` must have admin privileges.
|
||||||
|
|
||||||
Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment
|
Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment
|
||||||
variable. Run `jupyterhub_idle_culler` manually.
|
variable. Run `jupyterhub_idle_culler` manually.
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
# Spawners and single-user notebook servers
|
# Spawners and single-user notebook servers
|
||||||
|
|
||||||
Since the single-user server is an instance of `jupyter notebook`, an entire separate
|
Since the single-user server is an instance of `jupyter notebook`, an entire separate
|
||||||
multi-process application, there are many aspect of that server can configure, and a lot of ways
|
multi-process application, there are many aspects of that server that can be configured, and a lot
|
||||||
to express that configuration.
|
of ways to express that configuration.
|
||||||
|
|
||||||
At the JupyterHub level, you can set some values on the Spawner. The simplest of these is
|
At the JupyterHub level, you can set some values on the Spawner. The simplest of these is
|
||||||
`Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root
|
`Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root
|
||||||
@@ -14,7 +14,7 @@ expanded to the user's home directory.
|
|||||||
c.Spawner.notebook_dir = '~/notebooks'
|
c.Spawner.notebook_dir = '~/notebooks'
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also specify extra command-line arguments to the notebook server with:
|
You can also specify extra command line arguments to the notebook server with:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.Spawner.args = ['--debug', '--profile=PHYS131']
|
c.Spawner.args = ['--debug', '--profile=PHYS131']
|
||||||
|
@@ -123,8 +123,8 @@ We want you to contribute to JupyterHub in ways that are most exciting
|
|||||||
& useful to you. We value documentation, testing, bug reporting & code equally,
|
& useful to you. We value documentation, testing, bug reporting & code equally,
|
||||||
and are glad to have your contributions in whatever form you wish :)
|
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>`_
|
Our `Code of Conduct <https://github.com/jupyter/governance/blob/HEAD/conduct/code_of_conduct.md>`_
|
||||||
(`reporting guidelines <https://github.com/jupyter/governance/blob/master/conduct/reporting_online.md>`_)
|
(`reporting guidelines <https://github.com/jupyter/governance/blob/HEAD/conduct/reporting_online.md>`_)
|
||||||
helps keep our community welcoming to as many people as possible.
|
helps keep our community welcoming to as many people as possible.
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
@@ -155,4 +155,4 @@ Questions? Suggestions?
|
|||||||
|
|
||||||
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub
|
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub
|
||||||
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
|
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
|
||||||
.. _REST API: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default
|
.. _REST API: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default
|
||||||
|
@@ -3,4 +3,4 @@
|
|||||||
JupyterHub the hard way
|
JupyterHub the hard way
|
||||||
=======================
|
=======================
|
||||||
|
|
||||||
This guide has moved to https://github.com/jupyterhub/jupyterhub-the-hard-way/blob/master/docs/installation-guide-hard.md
|
This guide has moved to https://github.com/jupyterhub/jupyterhub-the-hard-way/blob/HEAD/docs/installation-guide-hard.md
|
||||||
|
@@ -37,7 +37,7 @@ with any provider, is also available.
|
|||||||
## The Dummy Authenticator
|
## The Dummy Authenticator
|
||||||
|
|
||||||
When testing, it may be helpful to use the
|
When testing, it may be helpful to use the
|
||||||
:class:`~jupyterhub.auth.DummyAuthenticator`. This allows for any username and
|
{class}`jupyterhub.auth.DummyAuthenticator`. This allows for any username and
|
||||||
password unless if a global password has been set. Once set, any username will
|
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.
|
still be accepted but the correct password will need to be provided.
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ PAM session.
|
|||||||
|
|
||||||
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||||
|
|
||||||
[authenticator]: https://github.com/jupyterhub/jupyterhub/blob/master/jupyterhub/auth.py
|
[authenticator]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/auth.py
|
||||||
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||||
[oauth]: https://en.wikipedia.org/wiki/OAuth
|
[oauth]: https://en.wikipedia.org/wiki/OAuth
|
||||||
[github oauth]: https://developer.github.com/v3/oauth/
|
[github oauth]: https://developer.github.com/v3/oauth/
|
||||||
|
@@ -158,6 +158,27 @@ provided by notebook servers managed by JupyterHub if one of the following is tr
|
|||||||
1. The token is for the same user as the owner of the notebook
|
1. The token is for the same user as the owner of the notebook
|
||||||
2. The token is tied to an admin user or service **and** `c.JupyterHub.admin_access` is set to `True`
|
2. The token is tied to an admin user or service **and** `c.JupyterHub.admin_access` is set to `True`
|
||||||
|
|
||||||
|
## Paginating API requests
|
||||||
|
|
||||||
|
Pagination is available through the `offset` and `limit` query parameters on
|
||||||
|
certain endpoints, which can be used to return ideally sized windows of results.
|
||||||
|
Here's example code demonstrating pagination on the `GET /users`
|
||||||
|
endpoint to fetch the first 20 records.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
api_url = 'http://127.0.0.1:8081/hub/api'
|
||||||
|
|
||||||
|
r = requests.get(api_url + '/users?offset=0&limit=20')
|
||||||
|
r.raise_for_status()
|
||||||
|
r.json()
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, pagination limit will be specified by the `JupyterHub.api_page_default_limit` config variable.
|
||||||
|
|
||||||
|
Pagination is enabled on the `GET /users`, `GET /groups`, and `GET /proxy` REST endpoints.
|
||||||
|
|
||||||
## Enabling users to spawn multiple named-servers via the API
|
## Enabling users to spawn multiple named-servers via the API
|
||||||
|
|
||||||
With JupyterHub version 0.8, support for multiple servers per user has landed.
|
With JupyterHub version 0.8, support for multiple servers per user has landed.
|
||||||
@@ -209,7 +230,7 @@ be viewed in a more [interactive style on swagger's petstore][].
|
|||||||
Both resources contain the same information and differ only in its display.
|
Both resources contain the same information and differ only in its display.
|
||||||
Note: The Swagger specification is being renamed the [OpenAPI Initiative][].
|
Note: The Swagger specification is being renamed the [OpenAPI Initiative][].
|
||||||
|
|
||||||
[interactive style on swagger's petstore]: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default
|
[interactive style on swagger's petstore]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default
|
||||||
[openapi initiative]: https://www.openapis.org/
|
[openapi initiative]: https://www.openapis.org/
|
||||||
[jupyterhub rest api]: ./rest-api
|
[jupyterhub rest api]: ./rest-api
|
||||||
[jupyter notebook rest api]: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/master/notebook/services/api/api.yaml
|
[jupyter notebook rest api]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/HEAD/notebook/services/api/api.yaml
|
||||||
|
@@ -203,8 +203,6 @@ To use HubAuth, you must set the `.api_token`, either programmatically when cons
|
|||||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||||
|
|
||||||
Most of the logic for authentication implementation is found in the
|
Most of the logic for authentication implementation is found in the
|
||||||
[`HubAuth.user_for_cookie`][hubauth.user_for_cookie]
|
|
||||||
and in the
|
|
||||||
[`HubAuth.user_for_token`][hubauth.user_for_token]
|
[`HubAuth.user_for_token`][hubauth.user_for_token]
|
||||||
methods, which makes a request of the Hub, and returns:
|
methods, which makes a request of the Hub, and returns:
|
||||||
|
|
||||||
@@ -230,7 +228,7 @@ configurable by the `cookie_cache_max_age` setting (default: five minutes).
|
|||||||
For example, you have a Flask service that returns information about a user.
|
For example, you have a Flask service that returns information about a user.
|
||||||
JupyterHub's HubAuth class can be used to authenticate requests to the Flask
|
JupyterHub's HubAuth class can be used to authenticate requests to the Flask
|
||||||
service. See the `service-whoami-flask` example in the
|
service. See the `service-whoami-flask` example in the
|
||||||
[JupyterHub GitHub repo](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-whoami-flask)
|
[JupyterHub GitHub repo](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-whoami-flask)
|
||||||
for more details.
|
for more details.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -241,11 +239,11 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
from flask import Flask, redirect, request, Response
|
from flask import Flask, redirect, request, Response
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubAuth
|
from jupyterhub.services.auth import HubOAuth
|
||||||
|
|
||||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||||
|
|
||||||
auth = HubAuth(
|
auth = HubOAuth(
|
||||||
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
||||||
cache_max_age=60,
|
cache_max_age=60,
|
||||||
)
|
)
|
||||||
@@ -257,11 +255,8 @@ def authenticated(f):
|
|||||||
"""Decorator for authenticating with the Hub"""
|
"""Decorator for authenticating with the Hub"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
cookie = request.cookies.get(auth.cookie_name)
|
|
||||||
token = request.headers.get(auth.auth_header_name)
|
token = request.headers.get(auth.auth_header_name)
|
||||||
if cookie:
|
if token:
|
||||||
user = auth.user_for_cookie(cookie)
|
|
||||||
elif token:
|
|
||||||
user = auth.user_for_token(token)
|
user = auth.user_for_token(token)
|
||||||
else:
|
else:
|
||||||
user = None
|
user = None
|
||||||
|
@@ -37,14 +37,13 @@ Some examples include:
|
|||||||
Information about the user can be retrieved from `self.user`,
|
Information about the user can be retrieved from `self.user`,
|
||||||
an object encapsulating the user's name, authentication, and server info.
|
an object encapsulating the user's name, authentication, and server info.
|
||||||
|
|
||||||
The return value of `Spawner.start` should be the (ip, port) of the running server.
|
The return value of `Spawner.start` should be the `(ip, port)` of the running server,
|
||||||
|
or a full URL as a string.
|
||||||
**NOTE:** When writing coroutines, _never_ `yield` in between a database change and a commit.
|
|
||||||
|
|
||||||
Most `Spawner.start` functions will look similar to this example:
|
Most `Spawner.start` functions will look similar to this example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def start(self):
|
async def start(self):
|
||||||
self.ip = '127.0.0.1'
|
self.ip = '127.0.0.1'
|
||||||
self.port = random_port()
|
self.port = random_port()
|
||||||
# get environment variables,
|
# get environment variables,
|
||||||
@@ -56,8 +55,10 @@ def start(self):
|
|||||||
cmd.extend(self.cmd)
|
cmd.extend(self.cmd)
|
||||||
cmd.extend(self.get_args())
|
cmd.extend(self.get_args())
|
||||||
|
|
||||||
yield self._actually_start_server_somehow(cmd, env)
|
await self._actually_start_server_somehow(cmd, env)
|
||||||
return (self.ip, self.port)
|
# url may not match self.ip:self.port, but it could!
|
||||||
|
url = self._get_connectable_url()
|
||||||
|
return url
|
||||||
```
|
```
|
||||||
|
|
||||||
When `Spawner.start` returns, the single-user server process should actually be running,
|
When `Spawner.start` returns, the single-user server process should actually be running,
|
||||||
@@ -65,6 +66,48 @@ not just requested. JupyterHub can handle `Spawner.start` being very slow
|
|||||||
(such as PBS-style batch queues, or instantiating whole AWS instances)
|
(such as PBS-style batch queues, or instantiating whole AWS instances)
|
||||||
via relaxing the `Spawner.start_timeout` config value.
|
via relaxing the `Spawner.start_timeout` config value.
|
||||||
|
|
||||||
|
#### Note on IPs and ports
|
||||||
|
|
||||||
|
`Spawner.ip` and `Spawner.port` attributes set the _bind_ url,
|
||||||
|
which the single-user server should listen on
|
||||||
|
(passed to the single-user process via the `JUPYTERHUB_SERVICE_URL` environment variable).
|
||||||
|
The _return_ value is the ip and port (or full url) the Hub should _connect to_.
|
||||||
|
These are not necessarily the same, and usually won't be in any Spawner that works with remote resources or containers.
|
||||||
|
|
||||||
|
The default for Spawner.ip, and Spawner.port is `127.0.0.1:{random}`,
|
||||||
|
which is appropriate for Spawners that launch local processes,
|
||||||
|
where everything is on localhost and each server needs its own port.
|
||||||
|
For remote or container Spawners, it will often make sense to use a different value,
|
||||||
|
such as `ip = '0.0.0.0'` and a fixed port, e.g. `8888`.
|
||||||
|
The defaults can be changed in the class,
|
||||||
|
preserving configuration with traitlets:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from traitlets import default
|
||||||
|
from jupyterhub.spawner import Spawner
|
||||||
|
|
||||||
|
class MySpawner(Spawner):
|
||||||
|
@default("ip")
|
||||||
|
def _default_ip(self):
|
||||||
|
return '0.0.0.0'
|
||||||
|
|
||||||
|
@default("port")
|
||||||
|
def _default_port(self):
|
||||||
|
return 8888
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
env = self.get_env()
|
||||||
|
cmd = []
|
||||||
|
# get jupyterhub command to run,
|
||||||
|
# typically ['jupyterhub-singleuser']
|
||||||
|
cmd.extend(self.cmd)
|
||||||
|
cmd.extend(self.get_args())
|
||||||
|
|
||||||
|
remote_server_info = await self._actually_start_server_somehow(cmd, env)
|
||||||
|
url = self.get_public_url_from(remote_server_info)
|
||||||
|
return url
|
||||||
|
```
|
||||||
|
|
||||||
### Spawner.poll
|
### Spawner.poll
|
||||||
|
|
||||||
`Spawner.poll` should check if the spawner is still running.
|
`Spawner.poll` should check if the spawner is still running.
|
||||||
@@ -125,7 +168,7 @@ If the `Spawner.options_form` is defined, when a user tries to start their serve
|
|||||||
|
|
||||||
If `Spawner.options_form` is undefined, the user's server is spawned directly, and no spawn page is rendered.
|
If `Spawner.options_form` is undefined, the user's server is spawned directly, and no spawn page is rendered.
|
||||||
|
|
||||||
See [this example](https://github.com/jupyterhub/jupyterhub/blob/master/examples/spawn-form/jupyterhub_config.py) for a form that allows custom CLI args for the local spawner.
|
See [this example](https://github.com/jupyterhub/jupyterhub/blob/HEAD/examples/spawn-form/jupyterhub_config.py) for a form that allows custom CLI args for the local spawner.
|
||||||
|
|
||||||
### `Spawner.options_from_form`
|
### `Spawner.options_from_form`
|
||||||
|
|
||||||
@@ -166,7 +209,7 @@ which would return:
|
|||||||
|
|
||||||
When `Spawner.start` is called, this dictionary is accessible as `self.user_options`.
|
When `Spawner.start` is called, this dictionary is accessible as `self.user_options`.
|
||||||
|
|
||||||
[spawner]: https://github.com/jupyterhub/jupyterhub/blob/master/jupyterhub/spawner.py
|
[spawner]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/spawner.py
|
||||||
|
|
||||||
## Writing a custom spawner
|
## Writing a custom spawner
|
||||||
|
|
||||||
@@ -207,6 +250,73 @@ Additionally, configurable attributes for your spawner will
|
|||||||
appear in jupyterhub help output and auto-generated configuration files
|
appear in jupyterhub help output and auto-generated configuration files
|
||||||
via `jupyterhub --generate-config`.
|
via `jupyterhub --generate-config`.
|
||||||
|
|
||||||
|
## Environment variables and command-line arguments
|
||||||
|
|
||||||
|
Spawners mainly do one thing: launch a command in an environment.
|
||||||
|
|
||||||
|
The command-line is constructed from user configuration:
|
||||||
|
|
||||||
|
- Spawner.cmd (default: `['jupterhub-singleuser']`)
|
||||||
|
- Spawner.args (cli args to pass to the cmd, default: empty)
|
||||||
|
|
||||||
|
where the configuration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Spawner.cmd = ["my-singleuser-wrapper"]
|
||||||
|
c.Spawner.args = ["--debug", "--flag"]
|
||||||
|
```
|
||||||
|
|
||||||
|
would result in spawning the command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
my-singleuser-wrapper --debug --flag
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Spawner.get_args()` method is how Spawner.args is accessed,
|
||||||
|
and can be used by Spawners to customize/extend user-provided arguments.
|
||||||
|
|
||||||
|
Prior to 2.0, JupyterHub unconditionally added certain options _if specified_ to the command-line,
|
||||||
|
such as `--ip={Spawner.ip}` and `--port={Spawner.port}`.
|
||||||
|
These have now all been moved to environment variables,
|
||||||
|
and from JupyterHub 2.0,
|
||||||
|
the command-line launched by JupyterHub is fully specified by overridable configuration `Spawner.cmd + Spawner.args`.
|
||||||
|
|
||||||
|
Most process configuration is passed via environment variables.
|
||||||
|
Additional variables can be specified via the `Spawner.environment` configuration.
|
||||||
|
|
||||||
|
The process environment is returned by `Spawner.get_env`, which specifies the following environment variables:
|
||||||
|
|
||||||
|
- JUPYTERHUB*SERVICE_URL - the \_bind* url where the server should launch its http server (`http://127.0.0.1:12345`).
|
||||||
|
This includes Spawner.ip and Spawner.port; _new in 2.0, prior to 2.0 ip,port were on the command-line and only if specified_
|
||||||
|
- JUPYTERHUB_SERVICE_PREFIX - the URL prefix the service will run on (e.g. `/user/name/`)
|
||||||
|
- JUPYTERHUB_USER - the JupyterHub user's username
|
||||||
|
- JUPYTERHUB_SERVER_NAME - the server's name, if using named servers (default server has an empty name)
|
||||||
|
- JUPYTERHUB_API_URL - the full url for the JupyterHub API (http://17.0.0.1:8001/hub/api)
|
||||||
|
- JUPYTERHUB_BASE_URL - the base url of the whole jupyterhub deployment, i.e. the bit before `hub/` or `user/`,
|
||||||
|
as set by c.JupyterHub.base_url (default: `/`)
|
||||||
|
- JUPYTERHUB_API_TOKEN - the API token the server can use to make requests to the Hub.
|
||||||
|
This is also the OAuth client secret.
|
||||||
|
- JUPYTERHUB_CLIENT_ID - the OAuth client ID for authenticating visitors.
|
||||||
|
- JUPYTERHUB_OAUTH_CALLBACK_URL - the callback URL to use in oauth, typically `/user/:name/oauth_callback`
|
||||||
|
|
||||||
|
Optional environment variables, depending on configuration:
|
||||||
|
|
||||||
|
- JUPYTERHUB*SSL*[KEYFILE|CERTFILE|CLIENT_CI] - SSL configuration, when internal_ssl is enabled
|
||||||
|
- JUPYTERHUB_ROOT_DIR - the root directory of the server (notebook directory), when Spawner.notebook_dir is defined (new in 2.0)
|
||||||
|
- JUPYTERHUB_DEFAULT_URL - the default URL for the server (for redirects from /user/:name/),
|
||||||
|
if Spawner.default_url is defined
|
||||||
|
(new in 2.0, previously passed via cli)
|
||||||
|
- JUPYTERHUB_DEBUG=1 - generic debug flag, sets maximum log level when Spawner.debug is True
|
||||||
|
(new in 2.0, previously passed via cli)
|
||||||
|
- JUPYTERHUB_DISABLE_USER_CONFIG=1 - disable loading user config,
|
||||||
|
sets maximum log level when Spawner.debug is True (new in 2.0,
|
||||||
|
previously passed via cli)
|
||||||
|
|
||||||
|
- JUPYTERHUB*[MEM|CPU]*[LIMIT_GUARANTEE] - the values of cpu and memory limits and guarantees.
|
||||||
|
These are not expected to be enforced by the process,
|
||||||
|
but are made available as a hint,
|
||||||
|
e.g. for resource monitoring extensions.
|
||||||
|
|
||||||
## Spawners, resource limits, and guarantees (Optional)
|
## Spawners, resource limits, and guarantees (Optional)
|
||||||
|
|
||||||
Some spawners of the single-user notebook servers allow setting limits or
|
Some spawners of the single-user notebook servers allow setting limits or
|
||||||
|
@@ -10,7 +10,7 @@ appearance.
|
|||||||
|
|
||||||
JupyterHub will look for custom templates in all of the paths in the
|
JupyterHub will look for custom templates in all of the paths in the
|
||||||
`JupyterHub.template_paths` configuration option, falling back on the
|
`JupyterHub.template_paths` configuration option, falling back on the
|
||||||
[default templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates)
|
[default templates](https://github.com/jupyterhub/jupyterhub/tree/HEAD/share/jupyterhub/templates)
|
||||||
if no custom template with that name is found. This fallback
|
if no custom template with that name is found. This fallback
|
||||||
behavior is new in version 0.9; previous versions searched only those paths
|
behavior is new in version 0.9; previous versions searched only those paths
|
||||||
explicitly included in `template_paths`. You may override as many
|
explicitly included in `template_paths`. You may override as many
|
||||||
@@ -21,7 +21,7 @@ or as few templates as you desire.
|
|||||||
Jinja provides a mechanism to [extend templates](http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance).
|
Jinja provides a mechanism to [extend templates](http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance).
|
||||||
A base template can define a `block`, and child templates can replace or
|
A base template can define a `block`, and child templates can replace or
|
||||||
supplement the material in the block. The
|
supplement the material in the block. The
|
||||||
[JupyterHub templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates)
|
[JupyterHub templates](https://github.com/jupyterhub/jupyterhub/tree/HEAD/share/jupyterhub/templates)
|
||||||
make extensive use of blocks, which allows you to customize parts of the
|
make extensive use of blocks, which allows you to customize parts of the
|
||||||
interface easily.
|
interface easily.
|
||||||
|
|
||||||
|
@@ -234,7 +234,7 @@ With a docker container, pass in the environment variable with the run command:
|
|||||||
-e JUPYTERHUB_API_TOKEN=my_secret_token \
|
-e JUPYTERHUB_API_TOKEN=my_secret_token \
|
||||||
jupyter/datascience-notebook:latest
|
jupyter/datascience-notebook:latest
|
||||||
|
|
||||||
[This example](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-notebook/external) demonstrates how to combine the use of the `jupyterhub-singleuser` environment variables when launching a Notebook as an externally managed service.
|
[This example](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-notebook/external) demonstrates how to combine the use of the `jupyterhub-singleuser` environment variables when launching a Notebook as an externally managed service.
|
||||||
|
|
||||||
## How do I...?
|
## How do I...?
|
||||||
|
|
||||||
|
@@ -148,9 +148,9 @@ else
|
|||||||
echo "...initial content loading for user ..."
|
echo "...initial content loading for user ..."
|
||||||
mkdir $USER_DIRECTORY/tutorials
|
mkdir $USER_DIRECTORY/tutorials
|
||||||
cd $USER_DIRECTORY/tutorials
|
cd $USER_DIRECTORY/tutorials
|
||||||
wget https://github.com/jakevdp/PythonDataScienceHandbook/archive/master.zip
|
wget https://github.com/jakevdp/PythonDataScienceHandbook/archive/HEAD.zip
|
||||||
unzip -o master.zip
|
unzip -o HEAD.zip
|
||||||
rm master.zip
|
rm HEAD.zip
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
@@ -40,9 +40,9 @@ else
|
|||||||
echo "...initial content loading for user ..."
|
echo "...initial content loading for user ..."
|
||||||
mkdir $USER_DIRECTORY/tutorials
|
mkdir $USER_DIRECTORY/tutorials
|
||||||
cd $USER_DIRECTORY/tutorials
|
cd $USER_DIRECTORY/tutorials
|
||||||
wget https://github.com/jakevdp/PythonDataScienceHandbook/archive/master.zip
|
wget https://github.com/jakevdp/PythonDataScienceHandbook/archive/HEAD.zip
|
||||||
unzip -o master.zip
|
unzip -o HEAD.zip
|
||||||
rm master.zip
|
rm HEAD.zip
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
@@ -13,7 +13,7 @@ if not api_token:
|
|||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'external-oauth',
|
'name': 'external-oauth',
|
||||||
'oauth_client_id': "whoami-oauth-client-test",
|
'oauth_client_id': "service-oauth-client-test",
|
||||||
'api_token': api_token,
|
'api_token': api_token,
|
||||||
'oauth_redirect_uri': 'http://127.0.0.1:5555/oauth_callback',
|
'oauth_redirect_uri': 'http://127.0.0.1:5555/oauth_callback',
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,7 @@ if [[ -z "${JUPYTERHUB_API_TOKEN}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. oauth client ID
|
# 2. oauth client ID
|
||||||
export JUPYTERHUB_CLIENT_ID='whoami-oauth-client-test'
|
export JUPYTERHUB_CLIENT_ID='service-oauth-client-test'
|
||||||
# 3. where the Hub is
|
# 3. where the Hub is
|
||||||
export JUPYTERHUB_URL='http://127.0.0.1:8000'
|
export JUPYTERHUB_URL='http://127.0.0.1:8000'
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ if [[ -z "${JUPYTERHUB_API_TOKEN}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. oauth client ID
|
# 2. oauth client ID
|
||||||
export JUPYTERHUB_CLIENT_ID="whoami-oauth-client-test"
|
export JUPYTERHUB_CLIENT_ID="service-oauth-client-test"
|
||||||
# 3. what URL to run on
|
# 3. what URL to run on
|
||||||
export JUPYTERHUB_SERVICE_PREFIX='/'
|
export JUPYTERHUB_SERVICE_PREFIX='/'
|
||||||
export JUPYTERHUB_SERVICE_URL='http://127.0.0.1:5555'
|
export JUPYTERHUB_SERVICE_URL='http://127.0.0.1:5555'
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
# Authenticating a flask service with JupyterHub
|
# Authenticating a flask service with JupyterHub
|
||||||
|
|
||||||
Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [flask][] application.
|
Uses `jupyterhub.services.HubOAuth` to authenticate requests with the Hub in a [flask][] application.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [f
|
|||||||
|
|
||||||
jupyterhub --ip=127.0.0.1
|
jupyterhub --ip=127.0.0.1
|
||||||
|
|
||||||
2. Visit http://127.0.0.1:8000/services/whoami/ or http://127.0.0.1:8000/services/whoami-oauth/
|
2. Visit http://127.0.0.1:8000/services/whoami/
|
||||||
|
|
||||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||||
|
|
||||||
|
@@ -5,10 +5,12 @@ c.JupyterHub.services = [
|
|||||||
'command': ['flask', 'run', '--port=10101'],
|
'command': ['flask', 'run', '--port=10101'],
|
||||||
'environment': {'FLASK_APP': 'whoami-flask.py'},
|
'environment': {'FLASK_APP': 'whoami-flask.py'},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'name': 'whoami-oauth',
|
|
||||||
'url': 'http://127.0.0.1:10201',
|
|
||||||
'command': ['flask', 'run', '--port=10201'],
|
|
||||||
'environment': {'FLASK_APP': 'whoami-oauth.py'},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# dummy auth and simple spawner for testing
|
||||||
|
# any username and password will work
|
||||||
|
c.JupyterHub.spawner_class = 'simple'
|
||||||
|
c.JupyterHub.authenticator_class = 'dummy'
|
||||||
|
|
||||||
|
# listen only on localhost while testing with wide-open auth
|
||||||
|
c.JupyterHub.ip = '127.0.0.1'
|
||||||
|
@@ -4,42 +4,48 @@ whoami service authentication with the Hub
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
from flask import make_response
|
||||||
from flask import redirect
|
from flask import redirect
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask import Response
|
from flask import Response
|
||||||
|
from flask import session
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubAuth
|
from jupyterhub.services.auth import HubOAuth
|
||||||
|
|
||||||
|
|
||||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||||
|
|
||||||
auth = HubAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
# encryption key for session cookies
|
||||||
|
app.secret_key = secrets.token_bytes(32)
|
||||||
|
|
||||||
|
|
||||||
def authenticated(f):
|
def authenticated(f):
|
||||||
"""Decorator for authenticating with the Hub"""
|
"""Decorator for authenticating with the Hub via OAuth"""
|
||||||
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
cookie = request.cookies.get(auth.cookie_name)
|
token = session.get("token")
|
||||||
token = request.headers.get(auth.auth_header_name)
|
|
||||||
if cookie:
|
if token:
|
||||||
user = auth.user_for_cookie(cookie)
|
|
||||||
elif token:
|
|
||||||
user = auth.user_for_token(token)
|
user = auth.user_for_token(token)
|
||||||
else:
|
else:
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
return f(user, *args, **kwargs)
|
return f(user, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
# redirect to login url on failed auth
|
# redirect to login url on failed auth
|
||||||
return redirect(auth.login_url + '?next=%s' % quote(request.path))
|
state = auth.generate_state(next_url=request.path)
|
||||||
|
response = make_response(redirect(auth.login_url + '&state=%s' % state))
|
||||||
|
response.set_cookie(auth.state_cookie_name, state)
|
||||||
|
return response
|
||||||
|
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
@@ -50,3 +56,24 @@ def whoami(user):
|
|||||||
return Response(
|
return Response(
|
||||||
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
|
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route(prefix + 'oauth_callback')
|
||||||
|
def oauth_callback():
|
||||||
|
code = request.args.get('code', None)
|
||||||
|
if code is None:
|
||||||
|
return 403
|
||||||
|
|
||||||
|
# validate state field
|
||||||
|
arg_state = request.args.get('state', None)
|
||||||
|
cookie_state = request.cookies.get(auth.state_cookie_name)
|
||||||
|
if arg_state is None or arg_state != cookie_state:
|
||||||
|
# state doesn't match
|
||||||
|
return 403
|
||||||
|
|
||||||
|
token = auth.token_for_code(code)
|
||||||
|
# store token in session cookie
|
||||||
|
session["token"] = token
|
||||||
|
next_url = auth.get_next_url(cookie_state) or prefix
|
||||||
|
response = make_response(redirect(next_url))
|
||||||
|
return response
|
||||||
|
@@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
whoami service authentication with the Hub
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from flask import Flask
|
|
||||||
from flask import make_response
|
|
||||||
from flask import redirect
|
|
||||||
from flask import request
|
|
||||||
from flask import Response
|
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubOAuth
|
|
||||||
|
|
||||||
|
|
||||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
|
||||||
|
|
||||||
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def authenticated(f):
|
|
||||||
"""Decorator for authenticating with the Hub via OAuth"""
|
|
||||||
|
|
||||||
@wraps(f)
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
token = request.cookies.get(auth.cookie_name)
|
|
||||||
if token:
|
|
||||||
user = auth.user_for_token(token)
|
|
||||||
else:
|
|
||||||
user = None
|
|
||||||
if user:
|
|
||||||
return f(user, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
# redirect to login url on failed auth
|
|
||||||
state = auth.generate_state(next_url=request.path)
|
|
||||||
response = make_response(redirect(auth.login_url + '&state=%s' % state))
|
|
||||||
response.set_cookie(auth.state_cookie_name, state)
|
|
||||||
return response
|
|
||||||
|
|
||||||
return decorated
|
|
||||||
|
|
||||||
|
|
||||||
@app.route(prefix)
|
|
||||||
@authenticated
|
|
||||||
def whoami(user):
|
|
||||||
return Response(
|
|
||||||
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route(prefix + 'oauth_callback')
|
|
||||||
def oauth_callback():
|
|
||||||
code = request.args.get('code', None)
|
|
||||||
if code is None:
|
|
||||||
return 403
|
|
||||||
|
|
||||||
# validate state field
|
|
||||||
arg_state = request.args.get('state', None)
|
|
||||||
cookie_state = request.cookies.get(auth.state_cookie_name)
|
|
||||||
if arg_state is None or arg_state != cookie_state:
|
|
||||||
# state doesn't match
|
|
||||||
return 403
|
|
||||||
|
|
||||||
token = auth.token_for_code(code)
|
|
||||||
next_url = auth.get_next_url(cookie_state) or prefix
|
|
||||||
response = make_response(redirect(next_url))
|
|
||||||
response.set_cookie(auth.cookie_name, token)
|
|
||||||
return response
|
|
@@ -14,7 +14,6 @@ from tornado import web
|
|||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import scopes
|
from .. import scopes
|
||||||
from ..user import User
|
|
||||||
from ..utils import token_authenticated
|
from ..utils import token_authenticated
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
@@ -24,7 +23,7 @@ class TokenAPIHandler(APIHandler):
|
|||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, token):
|
def get(self, token):
|
||||||
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
|
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
|
||||||
# TODO: require specific scope for this deprecated API, applied to oauth client secrets only?
|
# TODO: require specific scope for this deprecated API, applied to service tokens only?
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"/authorizations/token/:token endpoint is deprecated in JupyterHub 2.0. Use /api/user"
|
"/authorizations/token/:token endpoint is deprecated in JupyterHub 2.0. Use /api/user"
|
||||||
)
|
)
|
||||||
@@ -55,53 +54,20 @@ class TokenAPIHandler(APIHandler):
|
|||||||
self.write(json.dumps(model))
|
self.write(json.dumps(model))
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
warn_msg = (
|
raise web.HTTPError(
|
||||||
"Using deprecated token creation endpoint %s."
|
404,
|
||||||
" Use /hub/api/users/:user/tokens instead."
|
"Deprecated endpoint /hub/api/authorizations/token is removed in JupyterHub 2.0."
|
||||||
) % self.request.uri
|
" Use /hub/api/users/:user/tokens instead.",
|
||||||
self.log.warning(warn_msg)
|
|
||||||
requester = user = self.current_user
|
|
||||||
if user is None:
|
|
||||||
# allow requesting a token with username and password
|
|
||||||
# for authenticators where that's possible
|
|
||||||
data = self.get_json_body()
|
|
||||||
try:
|
|
||||||
requester = user = await self.login_user(data)
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error("Failure trying to authenticate with form data: %s" % e)
|
|
||||||
user = None
|
|
||||||
if user is None:
|
|
||||||
raise web.HTTPError(403)
|
|
||||||
else:
|
|
||||||
data = self.get_json_body()
|
|
||||||
# admin users can request tokens for other users
|
|
||||||
if data and data.get('username'):
|
|
||||||
user = self.find_user(data['username'])
|
|
||||||
if user is not requester and not requester.admin:
|
|
||||||
raise web.HTTPError(
|
|
||||||
403, "Only admins can request tokens for other users."
|
|
||||||
)
|
|
||||||
if requester.admin and user is None:
|
|
||||||
raise web.HTTPError(400, "No such user '%s'" % data['username'])
|
|
||||||
|
|
||||||
note = (data or {}).get('note')
|
|
||||||
if not note:
|
|
||||||
note = "Requested via deprecated api"
|
|
||||||
if requester is not user:
|
|
||||||
kind = 'user' if isinstance(user, User) else 'service'
|
|
||||||
note += " by %s %s" % (kind, requester.name)
|
|
||||||
|
|
||||||
api_token = user.new_api_token(note=note)
|
|
||||||
self.write(
|
|
||||||
json.dumps(
|
|
||||||
{'token': api_token, 'warning': warn_msg, 'user': self.user_model(user)}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CookieAPIHandler(APIHandler):
|
class CookieAPIHandler(APIHandler):
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, cookie_name, cookie_value=None):
|
def get(self, cookie_name, cookie_value=None):
|
||||||
|
self.log.warning(
|
||||||
|
"/authorizations/cookie endpoint is deprecated in JupyterHub 2.0. Use /api/user with OAuth tokens."
|
||||||
|
)
|
||||||
|
|
||||||
cookie_name = quote(cookie_name, safe='')
|
cookie_name = quote(cookie_name, safe='')
|
||||||
if cookie_value is None:
|
if cookie_value is None:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
|
@@ -368,6 +368,22 @@ class APIHandler(BaseHandler):
|
|||||||
400, ("group names must be str, not %r", type(groupname))
|
400, ("group names must be str, not %r", type(groupname))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_api_pagination(self):
|
||||||
|
default_limit = self.settings["app"].api_page_default_limit
|
||||||
|
max_limit = self.settings["app"].api_page_max_limit
|
||||||
|
offset = self.get_argument("offset", None)
|
||||||
|
limit = self.get_argument("limit", default_limit)
|
||||||
|
try:
|
||||||
|
offset = abs(int(offset)) if offset is not None else 0
|
||||||
|
limit = abs(int(limit))
|
||||||
|
if limit > max_limit:
|
||||||
|
limit = max_limit
|
||||||
|
except Exception as e:
|
||||||
|
raise web.HTTPError(
|
||||||
|
400, "Invalid argument type, offset and limit must be integers"
|
||||||
|
)
|
||||||
|
return offset, limit
|
||||||
|
|
||||||
def options(self, *args, **kwargs):
|
def options(self, *args, **kwargs):
|
||||||
self.finish()
|
self.finish()
|
||||||
|
|
||||||
|
@@ -37,9 +37,11 @@ class GroupListAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('read:groups')
|
@needs_scope('read:groups')
|
||||||
def get(self):
|
def get(self):
|
||||||
"""List groups"""
|
"""List groups"""
|
||||||
groups = self.db.query(orm.Group)
|
query = self.db.query(orm.Group)
|
||||||
|
offset, limit = self.get_api_pagination()
|
||||||
|
query = query.offset(offset).limit(limit)
|
||||||
scope_filter = self.get_scope_filter('read:groups')
|
scope_filter = self.get_scope_filter('read:groups')
|
||||||
data = [self.group_model(g) for g in groups if scope_filter(g, kind='group')]
|
data = [self.group_model(g) for g in query if scope_filter(g, kind='group')]
|
||||||
self.write(json.dumps(data))
|
self.write(json.dumps(data))
|
||||||
|
|
||||||
@needs_scope('admin:groups')
|
@needs_scope('admin:groups')
|
||||||
|
@@ -17,7 +17,16 @@ class ProxyAPIHandler(APIHandler):
|
|||||||
This is the same as fetching the routing table directly from the proxy,
|
This is the same as fetching the routing table directly from the proxy,
|
||||||
but without clients needing to maintain separate
|
but without clients needing to maintain separate
|
||||||
"""
|
"""
|
||||||
|
offset, limit = self.get_api_pagination()
|
||||||
|
|
||||||
routes = await self.proxy.get_all_routes()
|
routes = await self.proxy.get_all_routes()
|
||||||
|
|
||||||
|
routes = {
|
||||||
|
key: routes[key]
|
||||||
|
for key in list(routes.keys())[offset:limit]
|
||||||
|
if key in routes
|
||||||
|
}
|
||||||
|
|
||||||
self.write(json.dumps(routes))
|
self.write(json.dumps(routes))
|
||||||
|
|
||||||
@needs_scope('proxy')
|
@needs_scope('proxy')
|
||||||
|
@@ -44,6 +44,11 @@ class SelfAPIHandler(APIHandler):
|
|||||||
self.raw_scopes.update(scopes.identify_scopes(user.orm_user))
|
self.raw_scopes.update(scopes.identify_scopes(user.orm_user))
|
||||||
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
|
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
|
||||||
model = self.user_model(user)
|
model = self.user_model(user)
|
||||||
|
# validate return, should have at least kind and name,
|
||||||
|
# otherwise our filters did something wrong
|
||||||
|
for key in ("kind", "name"):
|
||||||
|
if key not in model:
|
||||||
|
raise ValueError(f"Missing identify model for {user}: {model}")
|
||||||
self.write(json.dumps(model))
|
self.write(json.dumps(model))
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +71,7 @@ class UserListAPIHandler(APIHandler):
|
|||||||
)
|
)
|
||||||
def get(self):
|
def get(self):
|
||||||
state_filter = self.get_argument("state", None)
|
state_filter = self.get_argument("state", None)
|
||||||
|
offset, limit = self.get_api_pagination()
|
||||||
|
|
||||||
# post_filter
|
# post_filter
|
||||||
post_filter = None
|
post_filter = None
|
||||||
@@ -105,12 +111,16 @@ class UserListAPIHandler(APIHandler):
|
|||||||
else:
|
else:
|
||||||
# no filter, return all users
|
# no filter, return all users
|
||||||
query = self.db.query(orm.User)
|
query = self.db.query(orm.User)
|
||||||
|
|
||||||
|
query = query.offset(offset).limit(limit)
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
for u in query:
|
for u in query:
|
||||||
if post_filter is None or post_filter(u):
|
if post_filter is None or post_filter(u):
|
||||||
user_model = self.user_model(u)
|
user_model = self.user_model(u)
|
||||||
if user_model:
|
if user_model:
|
||||||
data.append(user_model)
|
data.append(user_model)
|
||||||
|
|
||||||
self.write(json.dumps(data))
|
self.write(json.dumps(data))
|
||||||
|
|
||||||
@needs_scope('admin:users')
|
@needs_scope('admin:users')
|
||||||
@@ -239,11 +249,7 @@ class UserAPIHandler(APIHandler):
|
|||||||
|
|
||||||
await maybe_future(self.authenticator.delete_user(user))
|
await maybe_future(self.authenticator.delete_user(user))
|
||||||
|
|
||||||
# allow the spawner to cleanup any persistent resources associated with the user
|
await user.delete_spawners()
|
||||||
try:
|
|
||||||
await user.spawner.delete_forever()
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error("Error cleaning up persistent resources: %s" % e)
|
|
||||||
|
|
||||||
# remove from registry
|
# remove from registry
|
||||||
self.users.delete(user)
|
self.users.delete(user)
|
||||||
@@ -485,10 +491,18 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
options = self.get_json_body()
|
options = self.get_json_body()
|
||||||
remove = (options or {}).get('remove', False)
|
remove = (options or {}).get('remove', False)
|
||||||
|
|
||||||
def _remove_spawner(f=None):
|
async def _remove_spawner(f=None):
|
||||||
if f and f.exception():
|
"""Remove the spawner object
|
||||||
return
|
|
||||||
|
only called after it stops successfully
|
||||||
|
"""
|
||||||
|
if f:
|
||||||
|
# await f, stop on error,
|
||||||
|
# leaving resources in the db in case of failure to stop
|
||||||
|
await f
|
||||||
self.log.info("Deleting spawner %s", spawner._log_name)
|
self.log.info("Deleting spawner %s", spawner._log_name)
|
||||||
|
await maybe_future(user._delete_spawner(spawner))
|
||||||
|
|
||||||
self.db.delete(spawner.orm_spawner)
|
self.db.delete(spawner.orm_spawner)
|
||||||
user.spawners.pop(server_name, None)
|
user.spawners.pop(server_name, None)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
@@ -509,7 +523,8 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
self.set_header('Content-Type', 'text/plain')
|
self.set_header('Content-Type', 'text/plain')
|
||||||
self.set_status(202)
|
self.set_status(202)
|
||||||
if remove:
|
if remove:
|
||||||
spawner._stop_future.add_done_callback(_remove_spawner)
|
# schedule remove when stop completes
|
||||||
|
asyncio.ensure_future(_remove_spawner(spawner._stop_future))
|
||||||
return
|
return
|
||||||
|
|
||||||
if spawner.pending:
|
if spawner.pending:
|
||||||
@@ -527,9 +542,10 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
|
|
||||||
if remove:
|
if remove:
|
||||||
if stop_future:
|
if stop_future:
|
||||||
stop_future.add_done_callback(_remove_spawner)
|
# schedule remove when stop completes
|
||||||
|
asyncio.ensure_future(_remove_spawner(spawner._stop_future))
|
||||||
else:
|
else:
|
||||||
_remove_spawner()
|
await _remove_spawner()
|
||||||
|
|
||||||
status = 202 if spawner._stop_pending else 204
|
status = 202 if spawner._stop_pending else 204
|
||||||
self.set_header('Content-Type', 'text/plain')
|
self.set_header('Content-Type', 'text/plain')
|
||||||
|
@@ -1021,6 +1021,15 @@ class JupyterHub(Application):
|
|||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
api_page_default_limit = Integer(
|
||||||
|
50,
|
||||||
|
help="The default amount of records returned by a paginated endpoint",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
api_page_max_limit = Integer(
|
||||||
|
200, help="The maximum amount of records that can be returned at once"
|
||||||
|
)
|
||||||
|
|
||||||
authenticate_prometheus = Bool(
|
authenticate_prometheus = Bool(
|
||||||
True, help="Authentication for prometheus metrics"
|
True, help="Authentication for prometheus metrics"
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
@@ -2386,10 +2395,6 @@ class JupyterHub(Application):
|
|||||||
for user in self.users.values():
|
for user in self.users.values():
|
||||||
for spawner in user.spawners.values():
|
for spawner in user.spawners.values():
|
||||||
oauth_client_ids.add(spawner.oauth_client_id)
|
oauth_client_ids.add(spawner.oauth_client_id)
|
||||||
# avoid deleting clients created by 0.8
|
|
||||||
# 0.9 uses `jupyterhub-user-...` for the client id, while
|
|
||||||
# 0.8 uses just `user-...`
|
|
||||||
oauth_client_ids.add(spawner.oauth_client_id.split('-', 1)[1])
|
|
||||||
|
|
||||||
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
|
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
|
||||||
if oauth_client.identifier not in oauth_client_ids:
|
if oauth_client.identifier not in oauth_client_ids:
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import enum
|
import enum
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
from base64 import decodebytes
|
from base64 import decodebytes
|
||||||
from base64 import encodebytes
|
from base64 import encodebytes
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -674,18 +675,29 @@ class APIToken(Hashed, Base):
|
|||||||
orm_token.service = service
|
orm_token.service = service
|
||||||
if expires_in is not None:
|
if expires_in is not None:
|
||||||
orm_token.expires_at = cls.now() + timedelta(seconds=expires_in)
|
orm_token.expires_at = cls.now() + timedelta(seconds=expires_in)
|
||||||
|
|
||||||
db.add(orm_token)
|
db.add(orm_token)
|
||||||
# load default roles if they haven't been initiated
|
|
||||||
# correct to have this here? otherwise some tests fail
|
|
||||||
token_role = Role.find(db, 'token')
|
token_role = Role.find(db, 'token')
|
||||||
if not token_role:
|
if not token_role:
|
||||||
|
# FIXME: remove this.
|
||||||
|
# Creating a token before the db has roles defined should raise an error.
|
||||||
|
# PR #3460 should let us fix it by ensuring default roles are defined
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"Token created before default roles!", RuntimeWarning, stacklevel=2
|
||||||
|
)
|
||||||
default_roles = get_default_roles()
|
default_roles = get_default_roles()
|
||||||
for role in default_roles:
|
for role in default_roles:
|
||||||
create_role(db, role)
|
create_role(db, role)
|
||||||
if roles is not None:
|
try:
|
||||||
update_roles(db, entity=orm_token, roles=roles)
|
if roles is not None:
|
||||||
else:
|
update_roles(db, entity=orm_token, roles=roles)
|
||||||
assign_default_roles(db, entity=orm_token)
|
else:
|
||||||
|
assign_default_roles(db, entity=orm_token)
|
||||||
|
except Exception:
|
||||||
|
db.delete(orm_token)
|
||||||
|
db.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return token
|
return token
|
||||||
|
@@ -24,6 +24,7 @@ import time
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
from tornado.httpclient import AsyncHTTPClient
|
from tornado.httpclient import AsyncHTTPClient
|
||||||
from tornado.httpclient import HTTPError
|
from tornado.httpclient import HTTPError
|
||||||
@@ -44,7 +45,6 @@ from .metrics import CHECK_ROUTES_DURATION_SECONDS
|
|||||||
from .metrics import PROXY_POLL_DURATION_SECONDS
|
from .metrics import PROXY_POLL_DURATION_SECONDS
|
||||||
from .objects import Server
|
from .objects import Server
|
||||||
from .utils import exponential_backoff
|
from .utils import exponential_backoff
|
||||||
from .utils import make_ssl_context
|
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
from jupyterhub.traitlets import Command
|
from jupyterhub.traitlets import Command
|
||||||
|
|
||||||
@@ -55,11 +55,18 @@ def _one_at_a_time(method):
|
|||||||
If multiple concurrent calls to this method are made,
|
If multiple concurrent calls to this method are made,
|
||||||
queue them instead of allowing them to be concurrently outstanding.
|
queue them instead of allowing them to be concurrently outstanding.
|
||||||
"""
|
"""
|
||||||
method._lock = asyncio.Lock()
|
# use weak dict for locks
|
||||||
|
# so that the lock is always acquired within the current asyncio loop
|
||||||
|
# should only be relevant in testing, where eventloops are created and destroyed often
|
||||||
|
method._locks = WeakKeyDictionary()
|
||||||
|
|
||||||
@wraps(method)
|
@wraps(method)
|
||||||
async def locked_method(*args, **kwargs):
|
async def locked_method(*args, **kwargs):
|
||||||
async with method._lock:
|
loop = asyncio.get_event_loop()
|
||||||
|
lock = method._locks.get(loop, None)
|
||||||
|
if lock is None:
|
||||||
|
lock = method._locks[loop] = asyncio.Lock()
|
||||||
|
async with lock:
|
||||||
return await method(*args, **kwargs)
|
return await method(*args, **kwargs)
|
||||||
|
|
||||||
return locked_method
|
return locked_method
|
||||||
@@ -123,7 +130,7 @@ class Proxy(LoggingConfigurable):
|
|||||||
a URL as target. The hub will ensure this route is present
|
a URL as target. The hub will ensure this route is present
|
||||||
in the proxy.
|
in the proxy.
|
||||||
|
|
||||||
If the hub is running in host based mode (with
|
If the hub is running in host based mode (with
|
||||||
JupyterHub.subdomain_host set), the routespec *must*
|
JupyterHub.subdomain_host set), the routespec *must*
|
||||||
have a domain component (example.com/my-url/). If the
|
have a domain component (example.com/my-url/). If the
|
||||||
hub is not running in host based mode, the routespec
|
hub is not running in host based mode, the routespec
|
||||||
|
@@ -437,11 +437,11 @@ def assign_default_roles(db, entity):
|
|||||||
"""Assigns the default roles to an entity:
|
"""Assigns the default roles to an entity:
|
||||||
users and services get 'user' role, or admin role if they have admin flag
|
users and services get 'user' role, or admin role if they have admin flag
|
||||||
Tokens get 'token' role"""
|
Tokens get 'token' role"""
|
||||||
default_token_role = orm.Role.find(db, 'token')
|
|
||||||
if isinstance(entity, orm.Group):
|
if isinstance(entity, orm.Group):
|
||||||
pass
|
pass
|
||||||
elif isinstance(entity, orm.APIToken):
|
elif isinstance(entity, orm.APIToken):
|
||||||
app_log.debug('Assigning default roles to tokens')
|
app_log.debug('Assigning default roles to tokens')
|
||||||
|
default_token_role = orm.Role.find(db, 'token')
|
||||||
if not entity.roles and (entity.user or entity.service) is not None:
|
if not entity.roles and (entity.user or entity.service) is not None:
|
||||||
default_token_role.tokens.append(entity)
|
default_token_role.tokens.append(entity)
|
||||||
app_log.info('Added role %s to token %s', default_token_role.name, entity)
|
app_log.info('Added role %s to token %s', default_token_role.name, entity)
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
|
"""General scope definitions and utilities"""
|
||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
|
import warnings
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
@@ -13,60 +15,85 @@ class Scope(Enum):
|
|||||||
ALL = True
|
ALL = True
|
||||||
|
|
||||||
|
|
||||||
def _intersect_scopes(token_scopes, owner_scopes):
|
def _intersect_scopes(scopes_a, scopes_b):
|
||||||
"""Compares the permissions of token and its owner including horizontal filters
|
"""Intersect two sets of scopes
|
||||||
Returns the intersection of the two sets of scopes
|
|
||||||
|
Compares the permissions of two sets of scopes,
|
||||||
|
including horizontal filters,
|
||||||
|
and returns the intersected scopes.
|
||||||
|
|
||||||
Note: Intersects correctly with ALL and exact filter matches
|
Note: Intersects correctly with ALL and exact filter matches
|
||||||
(i.e. users!user=x & read:users:name -> read:users:name!user=x)
|
(i.e. users!user=x & read:users:name -> read:users:name!user=x)
|
||||||
|
|
||||||
Does not currently intersect with containing filters
|
Does not currently intersect with containing filters
|
||||||
(i.e. users!group=x & users!user=y even if user y is in group x,
|
(i.e. users!group=x & users!user=y even if user y is in group x)
|
||||||
same for users:servers!user=x & users:servers!server=y)
|
|
||||||
"""
|
"""
|
||||||
owner_parsed_scopes = parse_scopes(owner_scopes)
|
parsed_scopes_a = parse_scopes(scopes_a)
|
||||||
token_parsed_scopes = parse_scopes(token_scopes)
|
parsed_scopes_b = parse_scopes(scopes_b)
|
||||||
|
|
||||||
common_bases = owner_parsed_scopes.keys() & token_parsed_scopes.keys()
|
common_bases = parsed_scopes_a.keys() & parsed_scopes_b.keys()
|
||||||
|
|
||||||
common_filters = {}
|
common_filters = {}
|
||||||
warn = False
|
warned = False
|
||||||
for base in common_bases:
|
for base in common_bases:
|
||||||
if owner_parsed_scopes[base] == Scope.ALL:
|
filters_a = parsed_scopes_a[base]
|
||||||
common_filters[base] = token_parsed_scopes[base]
|
filters_b = parsed_scopes_b[base]
|
||||||
elif token_parsed_scopes[base] == Scope.ALL:
|
if filters_a == Scope.ALL:
|
||||||
common_filters[base] = owner_parsed_scopes[base]
|
common_filters[base] = filters_b
|
||||||
|
elif filters_b == Scope.ALL:
|
||||||
|
common_filters[base] = filters_a
|
||||||
else:
|
else:
|
||||||
common_entities = (
|
# warn *if* there are non-overlapping user= and group= filters
|
||||||
owner_parsed_scopes[base].keys() & token_parsed_scopes[base].keys()
|
common_entities = filters_a.keys() & filters_b.keys()
|
||||||
)
|
all_entities = filters_a.keys() | filters_b.keys()
|
||||||
all_entities = (
|
if (
|
||||||
owner_parsed_scopes[base].keys() | token_parsed_scopes[base].keys()
|
not warned
|
||||||
)
|
and 'group' in all_entities
|
||||||
if 'user' in all_entities and ('group' or 'server' in all_entities):
|
and ('user' in all_entities or 'server' in all_entities)
|
||||||
warn = True
|
):
|
||||||
|
# this could resolve wrong if there's a user or server only on one side and a group only on the other
|
||||||
|
# check both directions: A has group X not in B group list AND B has user Y not in A user list
|
||||||
|
for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
|
||||||
|
for b_key in ('user', 'server'):
|
||||||
|
if (
|
||||||
|
not warned
|
||||||
|
and "group" in a
|
||||||
|
and b_key in b
|
||||||
|
and set(a["group"]).difference(b.get("group", []))
|
||||||
|
and set(b[b_key]).difference(a.get(b_key, []))
|
||||||
|
):
|
||||||
|
warnings.warn(
|
||||||
|
f"{base}[!{b_key}={b[b_key]}, !group={a['group']}] combinations of filters present,"
|
||||||
|
" intersection between not considered. May result in lower than intended permissions.",
|
||||||
|
UserWarning,
|
||||||
|
)
|
||||||
|
warned = True
|
||||||
|
|
||||||
common_filters[base] = {
|
common_filters[base] = {
|
||||||
entity: set(owner_parsed_scopes[base][entity])
|
entity: set(parsed_scopes_a[base][entity])
|
||||||
& set(token_parsed_scopes[base][entity])
|
& set(parsed_scopes_b[base][entity])
|
||||||
for entity in common_entities
|
for entity in common_entities
|
||||||
}
|
}
|
||||||
|
|
||||||
if warn:
|
if 'server' in all_entities and 'user' in all_entities:
|
||||||
app_log.warning(
|
if filters_a.get('server') == filters_b.get('server'):
|
||||||
"[!user=, !group=] or [!user=, !server=] combinations of filters present, intersection between not considered. May result in lower than intended permissions."
|
continue
|
||||||
)
|
|
||||||
|
|
||||||
scopes = set()
|
additional_servers = set()
|
||||||
for base in common_filters:
|
# resolve user/server hierarchy in both directions
|
||||||
if common_filters[base] == Scope.ALL:
|
for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
|
||||||
scopes.add(base)
|
if 'server' in a and 'user' in b:
|
||||||
else:
|
for server in a['server']:
|
||||||
for entity, names_list in common_filters[base].items():
|
username, _, servername = server.partition("/")
|
||||||
for name in names_list:
|
if username in b['user']:
|
||||||
scopes.add(f'{base}!{entity}={name}')
|
additional_servers.add(server)
|
||||||
|
|
||||||
return scopes
|
if additional_servers:
|
||||||
|
if "server" not in common_filters[base]:
|
||||||
|
common_filters[base]["server"] = set()
|
||||||
|
common_filters[base]["server"].update(additional_servers)
|
||||||
|
|
||||||
|
return unparse_scopes(common_filters)
|
||||||
|
|
||||||
|
|
||||||
def get_scopes_for(orm_object):
|
def get_scopes_for(orm_object):
|
||||||
@@ -176,7 +203,7 @@ def parse_scopes(scope_list):
|
|||||||
"""
|
"""
|
||||||
Parses scopes and filters in something akin to JSON style
|
Parses scopes and filters in something akin to JSON style
|
||||||
|
|
||||||
For instance, scope list ["users", "groups!group=foo", "users:servers!server=bar", "users:servers!server=baz"]
|
For instance, scope list ["users", "groups!group=foo", "users:servers!server=user/bar", "users:servers!server=user/baz"]
|
||||||
would lead to scope model
|
would lead to scope model
|
||||||
{
|
{
|
||||||
"users":scope.ALL,
|
"users":scope.ALL,
|
||||||
@@ -187,8 +214,8 @@ def parse_scopes(scope_list):
|
|||||||
},
|
},
|
||||||
"users:servers":{
|
"users:servers":{
|
||||||
"server":[
|
"server":[
|
||||||
"bar",
|
"user/bar",
|
||||||
"baz"
|
"user/baz"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,6 +235,19 @@ def parse_scopes(scope_list):
|
|||||||
return parsed_scopes
|
return parsed_scopes
|
||||||
|
|
||||||
|
|
||||||
|
def unparse_scopes(parsed_scopes):
|
||||||
|
"""Turn a parsed_scopes dictionary back into a scopes set"""
|
||||||
|
scopes = set()
|
||||||
|
for base, filters in parsed_scopes.items():
|
||||||
|
if filters == Scope.ALL:
|
||||||
|
scopes.add(base)
|
||||||
|
else:
|
||||||
|
for entity, names_list in filters.items():
|
||||||
|
for name in names_list:
|
||||||
|
scopes.add(f'{base}!{entity}={name}')
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
|
||||||
def needs_scope(*scopes):
|
def needs_scope(*scopes):
|
||||||
"""Decorator to restrict access to users or services with the required scope"""
|
"""Decorator to restrict access to users or services with the required scope"""
|
||||||
|
|
||||||
@@ -269,6 +309,9 @@ def identify_scopes(obj):
|
|||||||
for field in {"name", "roles", "groups"}
|
for field in {"name", "roles", "groups"}
|
||||||
}
|
}
|
||||||
elif isinstance(obj, orm.Service):
|
elif isinstance(obj, orm.Service):
|
||||||
|
# FIXME: need sub-scopes for services
|
||||||
|
# until then, we have just one service scope:
|
||||||
|
return {f"read:services!service={obj.name}"}
|
||||||
return {
|
return {
|
||||||
f"read:services:{field}!service={obj.name}" for field in {"name", "roles"}
|
f"read:services:{field}!service={obj.name}" for field in {"name", "roles"}
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
"""Authenticating services with JupyterHub.
|
"""Authenticating services with JupyterHub.
|
||||||
|
|
||||||
Cookies are sent to the Hub for verification. The Hub replies with a JSON
|
Tokens are sent to the Hub for verification.
|
||||||
model describing the authenticated user.
|
The Hub replies with a JSON model describing the authenticated user.
|
||||||
|
|
||||||
``HubAuth`` can be used in any application, even outside tornado.
|
``HubAuth`` can be used in any application, even outside tornado.
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ authenticate with the Hub.
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
@@ -20,7 +21,6 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import quote
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -113,9 +113,15 @@ class HubAuth(SingletonConfigurable):
|
|||||||
|
|
||||||
This can be used by any application.
|
This can be used by any application.
|
||||||
|
|
||||||
|
Use this base class only for direct, token-authenticated applications
|
||||||
|
(web APIs).
|
||||||
|
For applications that support direct visits from browsers,
|
||||||
|
use HubOAuth to enable OAuth redirect-based authentication.
|
||||||
|
|
||||||
|
|
||||||
If using tornado, use via :class:`HubAuthenticated` mixin.
|
If using tornado, use via :class:`HubAuthenticated` mixin.
|
||||||
If using manually, use the ``.user_for_cookie(cookie_value)`` method
|
If using manually, use the ``.user_for_token(token_value)`` method
|
||||||
to identify the user corresponding to a given cookie value.
|
to identify the user owning a given token.
|
||||||
|
|
||||||
The following config must be set:
|
The following config must be set:
|
||||||
|
|
||||||
@@ -129,15 +135,12 @@ class HubAuth(SingletonConfigurable):
|
|||||||
- cookie_cache_max_age: the number of seconds responses
|
- cookie_cache_max_age: the number of seconds responses
|
||||||
from the Hub should be cached.
|
from the Hub should be cached.
|
||||||
- login_url (the *public* ``/hub/login`` URL of the Hub).
|
- login_url (the *public* ``/hub/login`` URL of the Hub).
|
||||||
- cookie_name: the name of the cookie I should be using,
|
|
||||||
if different from the default (unlikely).
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
hub_host = Unicode(
|
hub_host = Unicode(
|
||||||
'',
|
'',
|
||||||
help="""The public host of JupyterHub
|
help="""The public host of JupyterHub
|
||||||
|
|
||||||
Only used if JupyterHub is spreading servers across subdomains.
|
Only used if JupyterHub is spreading servers across subdomains.
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
@@ -239,10 +242,6 @@ class HubAuth(SingletonConfigurable):
|
|||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
cookie_name = Unicode(
|
|
||||||
'jupyterhub-services', help="""The name of the cookie I should be looking for"""
|
|
||||||
).tag(config=True)
|
|
||||||
|
|
||||||
cookie_options = Dict(
|
cookie_options = Dict(
|
||||||
help="""Additional options to pass when setting cookies.
|
help="""Additional options to pass when setting cookies.
|
||||||
|
|
||||||
@@ -286,12 +285,12 @@ class HubAuth(SingletonConfigurable):
|
|||||||
def _default_cache(self):
|
def _default_cache(self):
|
||||||
return _ExpiringDict(self.cache_max_age)
|
return _ExpiringDict(self.cache_max_age)
|
||||||
|
|
||||||
def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
|
def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True):
|
||||||
"""Identify a user with the Hub
|
"""Identify a user with the Hub
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url (str): The API URL to check the Hub for authorization
|
url (str): The API URL to check the Hub for authorization
|
||||||
(e.g. http://127.0.0.1:8081/hub/api/authorizations/token/abc-def)
|
(e.g. http://127.0.0.1:8081/hub/api/user)
|
||||||
cache_key (str): The key for checking the cache
|
cache_key (str): The key for checking the cache
|
||||||
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
||||||
|
|
||||||
@@ -309,7 +308,12 @@ class HubAuth(SingletonConfigurable):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
app_log.debug("HubAuth cache miss: %s", cache_key)
|
app_log.debug("HubAuth cache miss: %s", cache_key)
|
||||||
|
|
||||||
data = self._api_request('GET', url, allow_404=True)
|
data = self._api_request(
|
||||||
|
'GET',
|
||||||
|
url,
|
||||||
|
headers={"Authorization": "token " + api_token},
|
||||||
|
allow_403=True,
|
||||||
|
)
|
||||||
if data is None:
|
if data is None:
|
||||||
app_log.warning("No Hub user identified for request")
|
app_log.warning("No Hub user identified for request")
|
||||||
else:
|
else:
|
||||||
@@ -321,7 +325,7 @@ class HubAuth(SingletonConfigurable):
|
|||||||
|
|
||||||
def _api_request(self, method, url, **kwargs):
|
def _api_request(self, method, url, **kwargs):
|
||||||
"""Make an API request"""
|
"""Make an API request"""
|
||||||
allow_404 = kwargs.pop('allow_404', False)
|
allow_403 = kwargs.pop('allow_403', False)
|
||||||
headers = kwargs.setdefault('headers', {})
|
headers = kwargs.setdefault('headers', {})
|
||||||
headers.setdefault('Authorization', 'token %s' % self.api_token)
|
headers.setdefault('Authorization', 'token %s' % self.api_token)
|
||||||
if "cert" not in kwargs and self.certfile and self.keyfile:
|
if "cert" not in kwargs and self.certfile and self.keyfile:
|
||||||
@@ -345,7 +349,7 @@ class HubAuth(SingletonConfigurable):
|
|||||||
raise HTTPError(500, msg)
|
raise HTTPError(500, msg)
|
||||||
|
|
||||||
data = None
|
data = None
|
||||||
if r.status_code == 404 and allow_404:
|
if r.status_code == 403 and allow_403:
|
||||||
pass
|
pass
|
||||||
elif r.status_code == 403:
|
elif r.status_code == 403:
|
||||||
app_log.error(
|
app_log.error(
|
||||||
@@ -389,26 +393,9 @@ class HubAuth(SingletonConfigurable):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def user_for_cookie(self, encrypted_cookie, use_cache=True, session_id=''):
|
def user_for_cookie(self, encrypted_cookie, use_cache=True, session_id=''):
|
||||||
"""Ask the Hub to identify the user for a given cookie.
|
"""Deprecated and removed. Use HubOAuth to authenticate browsers."""
|
||||||
|
raise RuntimeError(
|
||||||
Args:
|
"Identifying users by shared cookie is removed in JupyterHub 2.0. Use OAuth tokens."
|
||||||
encrypted_cookie (str): the cookie value (not decrypted, the Hub will do that)
|
|
||||||
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
user_model (dict): The user model, if a user is identified, None if authentication fails.
|
|
||||||
|
|
||||||
The 'name' field contains the user's name.
|
|
||||||
"""
|
|
||||||
return self._check_hub_authorization(
|
|
||||||
url=url_path_join(
|
|
||||||
self.api_url,
|
|
||||||
"authorizations/cookie",
|
|
||||||
self.cookie_name,
|
|
||||||
quote(encrypted_cookie, safe=''),
|
|
||||||
),
|
|
||||||
cache_key='cookie:{}:{}'.format(session_id, encrypted_cookie),
|
|
||||||
use_cache=use_cache,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def user_for_token(self, token, use_cache=True, session_id=''):
|
def user_for_token(self, token, use_cache=True, session_id=''):
|
||||||
@@ -425,14 +412,19 @@ class HubAuth(SingletonConfigurable):
|
|||||||
"""
|
"""
|
||||||
return self._check_hub_authorization(
|
return self._check_hub_authorization(
|
||||||
url=url_path_join(
|
url=url_path_join(
|
||||||
self.api_url, "authorizations/token", quote(token, safe='')
|
self.api_url,
|
||||||
|
"user",
|
||||||
|
),
|
||||||
|
api_token=token,
|
||||||
|
cache_key='token:{}:{}'.format(
|
||||||
|
session_id,
|
||||||
|
hashlib.sha256(token.encode("utf8", "replace")).hexdigest(),
|
||||||
),
|
),
|
||||||
cache_key='token:{}:{}'.format(session_id, token),
|
|
||||||
use_cache=use_cache,
|
use_cache=use_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_header_name = 'Authorization'
|
auth_header_name = 'Authorization'
|
||||||
auth_header_pat = re.compile(r'token\s+(.+)', re.IGNORECASE)
|
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
|
||||||
|
|
||||||
def get_token(self, handler):
|
def get_token(self, handler):
|
||||||
"""Get the user token from a request
|
"""Get the user token from a request
|
||||||
@@ -453,10 +445,8 @@ class HubAuth(SingletonConfigurable):
|
|||||||
|
|
||||||
def _get_user_cookie(self, handler):
|
def _get_user_cookie(self, handler):
|
||||||
"""Get the user model from a cookie"""
|
"""Get the user model from a cookie"""
|
||||||
encrypted_cookie = handler.get_cookie(self.cookie_name)
|
# overridden in HubOAuth to store the access token after oauth
|
||||||
session_id = self.get_session_id(handler)
|
return None
|
||||||
if encrypted_cookie:
|
|
||||||
return self.user_for_cookie(encrypted_cookie, session_id=session_id)
|
|
||||||
|
|
||||||
def get_session_id(self, handler):
|
def get_session_id(self, handler):
|
||||||
"""Get the jupyterhub session id
|
"""Get the jupyterhub session id
|
||||||
@@ -509,6 +499,9 @@ class HubAuth(SingletonConfigurable):
|
|||||||
class HubOAuth(HubAuth):
|
class HubOAuth(HubAuth):
|
||||||
"""HubAuth using OAuth for login instead of cookies set by the Hub.
|
"""HubAuth using OAuth for login instead of cookies set by the Hub.
|
||||||
|
|
||||||
|
Use this class if you want users to be able to visit your service with a browser.
|
||||||
|
They will be authenticated via OAuth with the Hub.
|
||||||
|
|
||||||
.. versionadded: 0.8
|
.. versionadded: 0.8
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -557,7 +550,7 @@ class HubOAuth(HubAuth):
|
|||||||
|
|
||||||
oauth_client_id = Unicode(
|
oauth_client_id = Unicode(
|
||||||
help="""The OAuth client ID for this application.
|
help="""The OAuth client ID for this application.
|
||||||
|
|
||||||
Use JUPYTERHUB_CLIENT_ID by default.
|
Use JUPYTERHUB_CLIENT_ID by default.
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
@@ -574,7 +567,7 @@ class HubOAuth(HubAuth):
|
|||||||
|
|
||||||
oauth_redirect_uri = Unicode(
|
oauth_redirect_uri = Unicode(
|
||||||
help="""OAuth redirect URI
|
help="""OAuth redirect URI
|
||||||
|
|
||||||
Should generally be /base_url/oauth_callback
|
Should generally be /base_url/oauth_callback
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
@@ -10,9 +10,11 @@ with JupyterHub authentication mixins enabled.
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
@@ -50,6 +52,17 @@ from ..utils import make_ssl_context
|
|||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_env(key):
|
||||||
|
"""Cast an environment variable to bool
|
||||||
|
|
||||||
|
0, empty, or unset is False; All other values are True.
|
||||||
|
"""
|
||||||
|
if os.environ.get(key, "") in {"", "0"}:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
# Authenticate requests with the Hub
|
# Authenticate requests with the Hub
|
||||||
|
|
||||||
|
|
||||||
@@ -99,19 +112,26 @@ class JupyterHubLoginHandlerMixin:
|
|||||||
Thus shouldn't be called anymore because HubAuthenticatedHandler
|
Thus shouldn't be called anymore because HubAuthenticatedHandler
|
||||||
should have already overridden get_current_user().
|
should have already overridden get_current_user().
|
||||||
|
|
||||||
Keep here to prevent unlikely circumstance from losing auth.
|
Keep here to protect uncommon circumstance of multiple BaseHandlers
|
||||||
|
from missing auth.
|
||||||
|
|
||||||
|
e.g. when multiple BaseHandler classes are used.
|
||||||
"""
|
"""
|
||||||
if HubAuthenticatedHandler not in handler.__class__.mro():
|
if HubAuthenticatedHandler not in handler.__class__.mro():
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
f"Expected to see HubAuthenticatedHandler in {handler.__class__}.mro()",
|
f"Expected to see HubAuthenticatedHandler in {handler.__class__}.mro(),"
|
||||||
|
" patching in at call time. Hub authentication is still applied.",
|
||||||
RuntimeWarning,
|
RuntimeWarning,
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
|
# patch HubAuthenticated into the instance
|
||||||
handler.__class__ = type(
|
handler.__class__ = type(
|
||||||
handler.__class__.__name__,
|
handler.__class__.__name__,
|
||||||
(HubAuthenticatedHandler, handler.__class__),
|
(HubAuthenticatedHandler, handler.__class__),
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
# patch into the class itself so this doesn't happen again for the same class
|
||||||
|
patch_base_handler(handler.__class__)
|
||||||
return handler.get_current_user()
|
return handler.get_current_user()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -141,7 +161,6 @@ class OAuthCallbackHandlerMixin(HubOAuthCallbackHandler):
|
|||||||
aliases = {
|
aliases = {
|
||||||
'user': 'SingleUserNotebookApp.user',
|
'user': 'SingleUserNotebookApp.user',
|
||||||
'group': 'SingleUserNotebookApp.group',
|
'group': 'SingleUserNotebookApp.group',
|
||||||
'cookie-name': 'HubAuth.cookie_name',
|
|
||||||
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
||||||
'hub-host': 'SingleUserNotebookApp.hub_host',
|
'hub-host': 'SingleUserNotebookApp.hub_host',
|
||||||
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
||||||
@@ -270,6 +289,10 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
def _user_changed(self, change):
|
def _user_changed(self, change):
|
||||||
self.log.name = change.new
|
self.log.name = change.new
|
||||||
|
|
||||||
|
@default("default_url")
|
||||||
|
def _default_url(self):
|
||||||
|
return os.environ.get("JUPYTERHUB_DEFAULT_URL", "/tree/")
|
||||||
|
|
||||||
hub_host = Unicode().tag(config=True)
|
hub_host = Unicode().tag(config=True)
|
||||||
|
|
||||||
hub_prefix = Unicode('/hub/').tag(config=True)
|
hub_prefix = Unicode('/hub/').tag(config=True)
|
||||||
@@ -352,7 +375,26 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@validate('notebook_dir')
|
@default("disable_user_config")
|
||||||
|
def _default_disable_user_config(self):
|
||||||
|
return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")
|
||||||
|
|
||||||
|
@default("root_dir")
|
||||||
|
def _default_root_dir(self):
|
||||||
|
if os.environ.get("JUPYTERHUB_ROOT_DIR"):
|
||||||
|
proposal = {"value": os.environ["JUPYTERHUB_ROOT_DIR"]}
|
||||||
|
# explicitly call validator, not called on default values
|
||||||
|
return self._notebook_dir_validate(proposal)
|
||||||
|
else:
|
||||||
|
return os.getcwd()
|
||||||
|
|
||||||
|
# notebook_dir is used by the classic notebook server
|
||||||
|
# root_dir is the future in jupyter server
|
||||||
|
@default("notebook_dir")
|
||||||
|
def _default_notebook_dir(self):
|
||||||
|
return self._default_root_dir()
|
||||||
|
|
||||||
|
@validate("notebook_dir", "root_dir")
|
||||||
def _notebook_dir_validate(self, proposal):
|
def _notebook_dir_validate(self, proposal):
|
||||||
value = os.path.expanduser(proposal['value'])
|
value = os.path.expanduser(proposal['value'])
|
||||||
# Strip any trailing slashes
|
# Strip any trailing slashes
|
||||||
@@ -368,6 +410,13 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
raise TraitError("No such notebook dir: %r" % value)
|
raise TraitError("No such notebook dir: %r" % value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@default('log_level')
|
||||||
|
def _log_level_default(self):
|
||||||
|
if _bool_env("JUPYTERHUB_DEBUG"):
|
||||||
|
return logging.DEBUG
|
||||||
|
else:
|
||||||
|
return logging.INFO
|
||||||
|
|
||||||
@default('log_datefmt')
|
@default('log_datefmt')
|
||||||
def _log_datefmt_default(self):
|
def _log_datefmt_default(self):
|
||||||
"""Exclude date from default date format"""
|
"""Exclude date from default date format"""
|
||||||
@@ -683,6 +732,97 @@ def detect_base_package(App):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _nice_cls_repr(cls):
|
||||||
|
"""Nice repr of classes, e.g. 'module.submod.Class'
|
||||||
|
|
||||||
|
Also accepts tuples of classes
|
||||||
|
"""
|
||||||
|
return f"{cls.__module__}.{cls.__name__}"
|
||||||
|
|
||||||
|
|
||||||
|
def patch_base_handler(BaseHandler, log=None):
|
||||||
|
"""Patch HubAuthenticated into a base handler class
|
||||||
|
|
||||||
|
so anything inheriting from BaseHandler uses Hub authentication.
|
||||||
|
This works *even after* subclasses have imported and inherited from BaseHandler.
|
||||||
|
|
||||||
|
.. versionadded: 1.5
|
||||||
|
Made available as an importable utility
|
||||||
|
"""
|
||||||
|
if log is None:
|
||||||
|
log = logging.getLogger()
|
||||||
|
|
||||||
|
if HubAuthenticatedHandler not in BaseHandler.__bases__:
|
||||||
|
new_bases = (HubAuthenticatedHandler,) + BaseHandler.__bases__
|
||||||
|
log.info(
|
||||||
|
"Patching auth into {mod}.{name}({old_bases}) -> {name}({new_bases})".format(
|
||||||
|
mod=BaseHandler.__module__,
|
||||||
|
name=BaseHandler.__name__,
|
||||||
|
old_bases=', '.join(
|
||||||
|
_nice_cls_repr(cls) for cls in BaseHandler.__bases__
|
||||||
|
),
|
||||||
|
new_bases=', '.join(_nice_cls_repr(cls) for cls in new_bases),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
BaseHandler.__bases__ = new_bases
|
||||||
|
# We've now inserted our class as a parent of BaseHandler,
|
||||||
|
# but we also need to ensure BaseHandler *itself* doesn't
|
||||||
|
# override the public tornado API methods we have inserted.
|
||||||
|
# If they are defined in BaseHandler, explicitly replace them with our methods.
|
||||||
|
for name in ("get_current_user", "get_login_url"):
|
||||||
|
if name in BaseHandler.__dict__:
|
||||||
|
log.debug(
|
||||||
|
f"Overriding {BaseHandler}.{name} with HubAuthenticatedHandler.{name}"
|
||||||
|
)
|
||||||
|
method = getattr(HubAuthenticatedHandler, name)
|
||||||
|
setattr(BaseHandler, name, method)
|
||||||
|
return BaseHandler
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_app_base_handlers(app):
|
||||||
|
"""Patch Hub Authentication into the base handlers of an app
|
||||||
|
|
||||||
|
Patches HubAuthenticatedHandler into:
|
||||||
|
|
||||||
|
- App.base_handler_class (if defined)
|
||||||
|
- jupyter_server's JupyterHandler (if already imported)
|
||||||
|
- notebook's IPythonHandler (if already imported)
|
||||||
|
"""
|
||||||
|
BaseHandler = app_base_handler = getattr(app, "base_handler_class", None)
|
||||||
|
|
||||||
|
base_handlers = []
|
||||||
|
if BaseHandler is not None:
|
||||||
|
base_handlers.append(BaseHandler)
|
||||||
|
|
||||||
|
# patch juptyer_server and notebook handlers if they have been imported
|
||||||
|
for base_handler_name in [
|
||||||
|
"jupyter_server.base.handlers.JupyterHandler",
|
||||||
|
"notebook.base.handlers.IPythonHandler",
|
||||||
|
]:
|
||||||
|
modname, _ = base_handler_name.rsplit(".", 1)
|
||||||
|
if modname in sys.modules:
|
||||||
|
base_handlers.append(import_item(base_handler_name))
|
||||||
|
|
||||||
|
if not base_handlers:
|
||||||
|
pkg = detect_base_package(app.__class__)
|
||||||
|
if pkg == "jupyter_server":
|
||||||
|
BaseHandler = import_item("jupyter_server.base.handlers.JupyterHandler")
|
||||||
|
elif pkg == "notebook":
|
||||||
|
BaseHandler = import_item("notebook.base.handlers.IPythonHandler")
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"{}.base_handler_class must be defined".format(app.__class__.__name__)
|
||||||
|
)
|
||||||
|
base_handlers.append(BaseHandler)
|
||||||
|
|
||||||
|
# patch-in HubAuthenticatedHandler to base handler classes
|
||||||
|
for BaseHandler in base_handlers:
|
||||||
|
patch_base_handler(BaseHandler)
|
||||||
|
|
||||||
|
# return the first entry
|
||||||
|
return base_handlers[0]
|
||||||
|
|
||||||
|
|
||||||
def make_singleuser_app(App):
|
def make_singleuser_app(App):
|
||||||
"""Make and return a singleuser notebook app
|
"""Make and return a singleuser notebook app
|
||||||
|
|
||||||
@@ -706,37 +846,7 @@ def make_singleuser_app(App):
|
|||||||
# detect base classes
|
# detect base classes
|
||||||
LoginHandler = empty_parent_app.login_handler_class
|
LoginHandler = empty_parent_app.login_handler_class
|
||||||
LogoutHandler = empty_parent_app.logout_handler_class
|
LogoutHandler = empty_parent_app.logout_handler_class
|
||||||
BaseHandler = getattr(empty_parent_app, "base_handler_class", None)
|
BaseHandler = _patch_app_base_handlers(empty_parent_app)
|
||||||
if BaseHandler is None:
|
|
||||||
pkg = detect_base_package(App)
|
|
||||||
if pkg == "jupyter_server":
|
|
||||||
BaseHandler = import_item("jupyter_server.base.handlers.JupyterHandler")
|
|
||||||
elif pkg == "notebook":
|
|
||||||
BaseHandler = import_item("notebook.base.handlers.IPythonHandler")
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
"{}.base_handler_class must be defined".format(App.__name__)
|
|
||||||
)
|
|
||||||
|
|
||||||
# patch-in HubAuthenticatedHandler to BaseHandler,
|
|
||||||
# so anything inheriting from BaseHandler uses Hub authentication
|
|
||||||
if HubAuthenticatedHandler not in BaseHandler.__bases__:
|
|
||||||
new_bases = (HubAuthenticatedHandler,) + BaseHandler.__bases__
|
|
||||||
log.debug(
|
|
||||||
f"Patching {BaseHandler}{BaseHandler.__bases__} -> {BaseHandler}{new_bases}"
|
|
||||||
)
|
|
||||||
BaseHandler.__bases__ = new_bases
|
|
||||||
# We've now inserted our class as a parent of BaseHandler,
|
|
||||||
# but we also need to ensure BaseHandler *itself* doesn't
|
|
||||||
# override the public tornado API methods we have inserted.
|
|
||||||
# If they are defined in BaseHandler, explicitly replace them with our methods.
|
|
||||||
for name in ("get_current_user", "get_login_url"):
|
|
||||||
if name in BaseHandler.__dict__:
|
|
||||||
log.debug(
|
|
||||||
f"Overriding {BaseHandler}.{name} with HubAuthenticatedHandler.{name}"
|
|
||||||
)
|
|
||||||
method = getattr(HubAuthenticatedHandler, name)
|
|
||||||
setattr(BaseHandler, name, method)
|
|
||||||
|
|
||||||
# create Handler classes from mixins + bases
|
# create Handler classes from mixins + bases
|
||||||
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
|
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
|
||||||
@@ -766,4 +876,11 @@ def make_singleuser_app(App):
|
|||||||
logout_handler_class = JupyterHubLogoutHandler
|
logout_handler_class = JupyterHubLogoutHandler
|
||||||
oauth_callback_handler_class = OAuthCallbackHandler
|
oauth_callback_handler_class = OAuthCallbackHandler
|
||||||
|
|
||||||
|
def initialize(self, *args, **kwargs):
|
||||||
|
result = super().initialize(*args, **kwargs)
|
||||||
|
# run patch again after initialize, so extensions have already been loaded
|
||||||
|
# probably a no-op most of the time
|
||||||
|
_patch_app_base_handlers(self)
|
||||||
|
return result
|
||||||
|
|
||||||
return SingleUserNotebookApp
|
return SingleUserNotebookApp
|
||||||
|
@@ -39,7 +39,6 @@ from .traitlets import ByteSpecification
|
|||||||
from .traitlets import Callable
|
from .traitlets import Callable
|
||||||
from .traitlets import Command
|
from .traitlets import Command
|
||||||
from .utils import exponential_backoff
|
from .utils import exponential_backoff
|
||||||
from .utils import iterate_until
|
|
||||||
from .utils import maybe_future
|
from .utils import maybe_future
|
||||||
from .utils import random_port
|
from .utils import random_port
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
@@ -246,11 +245,22 @@ class Spawner(LoggingConfigurable):
|
|||||||
)
|
)
|
||||||
|
|
||||||
ip = Unicode(
|
ip = Unicode(
|
||||||
'',
|
'127.0.0.1',
|
||||||
help="""
|
help="""
|
||||||
The IP address (or hostname) the single-user server should listen on.
|
The IP address (or hostname) the single-user server should listen on.
|
||||||
|
|
||||||
|
Usually either '127.0.0.1' (default) or '0.0.0.0'.
|
||||||
|
|
||||||
The JupyterHub proxy implementation should be able to send packets to this interface.
|
The JupyterHub proxy implementation should be able to send packets to this interface.
|
||||||
|
|
||||||
|
Subclasses which launch remotely or in containers
|
||||||
|
should override the default to '0.0.0.0'.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
Default changed to '127.0.0.1', from ''.
|
||||||
|
In most cases, this does not result in a change in behavior,
|
||||||
|
as '' was interpreted as 'unspecified',
|
||||||
|
which used the subprocesses' own default, itself usually '127.0.0.1'.
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@@ -811,8 +821,20 @@ class Spawner(LoggingConfigurable):
|
|||||||
'activity',
|
'activity',
|
||||||
)
|
)
|
||||||
env['JUPYTERHUB_BASE_URL'] = self.hub.base_url[:-4]
|
env['JUPYTERHUB_BASE_URL'] = self.hub.base_url[:-4]
|
||||||
|
|
||||||
if self.server:
|
if self.server:
|
||||||
|
base_url = self.server.base_url
|
||||||
|
if self.ip or self.port:
|
||||||
|
self.server.ip = self.ip
|
||||||
|
self.server.port = self.port
|
||||||
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
env['JUPYTERHUB_SERVICE_PREFIX'] = self.server.base_url
|
||||||
|
else:
|
||||||
|
# this should only occur in mock/testing scenarios
|
||||||
|
base_url = '/'
|
||||||
|
|
||||||
|
proto = 'https' if self.internal_ssl else 'http'
|
||||||
|
bind_url = f"{proto}://{self.ip}:{self.port}{base_url}"
|
||||||
|
env["JUPYTERHUB_SERVICE_URL"] = bind_url
|
||||||
|
|
||||||
# Put in limit and guarantee info if they exist.
|
# Put in limit and guarantee info if they exist.
|
||||||
# Note that this is for use by the humans / notebook extensions in the
|
# Note that this is for use by the humans / notebook extensions in the
|
||||||
@@ -832,6 +854,20 @@ class Spawner(LoggingConfigurable):
|
|||||||
env['JUPYTERHUB_SSL_CERTFILE'] = self.cert_paths['certfile']
|
env['JUPYTERHUB_SSL_CERTFILE'] = self.cert_paths['certfile']
|
||||||
env['JUPYTERHUB_SSL_CLIENT_CA'] = self.cert_paths['cafile']
|
env['JUPYTERHUB_SSL_CLIENT_CA'] = self.cert_paths['cafile']
|
||||||
|
|
||||||
|
if self.notebook_dir:
|
||||||
|
notebook_dir = self.format_string(self.notebook_dir)
|
||||||
|
env["JUPYTERHUB_ROOT_DIR"] = notebook_dir
|
||||||
|
|
||||||
|
if self.default_url:
|
||||||
|
default_url = self.format_string(self.default_url)
|
||||||
|
env["JUPYTERHUB_DEFAULT_URL"] = default_url
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
env["JUPYTERHUB_DEBUG"] = "1"
|
||||||
|
|
||||||
|
if self.disable_user_config:
|
||||||
|
env["JUPYTERHUB_DISABLE_USER_CONFIG"] = "1"
|
||||||
|
|
||||||
# env overrides from config. If the value is a callable, it will be called with
|
# env overrides from config. If the value is a callable, it will be called with
|
||||||
# one parameter - the current spawner instance - and the return value
|
# one parameter - the current spawner instance - and the return value
|
||||||
# will be assigned to the environment variable. This will be called at
|
# will be assigned to the environment variable. This will be called at
|
||||||
@@ -843,7 +879,6 @@ class Spawner(LoggingConfigurable):
|
|||||||
env[key] = value(self)
|
env[key] = value(self)
|
||||||
else:
|
else:
|
||||||
env[key] = value
|
env[key] = value
|
||||||
|
|
||||||
return env
|
return env
|
||||||
|
|
||||||
async def get_url(self):
|
async def get_url(self):
|
||||||
@@ -1010,35 +1045,16 @@ class Spawner(LoggingConfigurable):
|
|||||||
"""Return the arguments to be passed after self.cmd
|
"""Return the arguments to be passed after self.cmd
|
||||||
|
|
||||||
Doesn't expect shell expansion to happen.
|
Doesn't expect shell expansion to happen.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.0
|
||||||
|
Prior to 2.0, JupyterHub passed some options such as
|
||||||
|
ip, port, and default_url to the command-line.
|
||||||
|
JupyterHub 2.0 no longer builds any CLI args
|
||||||
|
other than `Spawner.cmd` and `Spawner.args`.
|
||||||
|
All values that come from jupyterhub itself
|
||||||
|
will be passed via environment variables.
|
||||||
"""
|
"""
|
||||||
args = []
|
return self.args
|
||||||
|
|
||||||
if self.ip:
|
|
||||||
args.append('--ip=%s' % _quote_safe(self.ip))
|
|
||||||
|
|
||||||
if self.port:
|
|
||||||
args.append('--port=%i' % self.port)
|
|
||||||
elif self.server and self.server.port:
|
|
||||||
self.log.warning(
|
|
||||||
"Setting port from user.server is deprecated as of JupyterHub 0.7."
|
|
||||||
)
|
|
||||||
args.append('--port=%i' % self.server.port)
|
|
||||||
|
|
||||||
if self.notebook_dir:
|
|
||||||
notebook_dir = self.format_string(self.notebook_dir)
|
|
||||||
args.append('--notebook-dir=%s' % _quote_safe(notebook_dir))
|
|
||||||
if self.default_url:
|
|
||||||
default_url = self.format_string(self.default_url)
|
|
||||||
args.append(
|
|
||||||
'--SingleUserNotebookApp.default_url=%s' % _quote_safe(default_url)
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.debug:
|
|
||||||
args.append('--debug')
|
|
||||||
if self.disable_user_config:
|
|
||||||
args.append('--disable-user-config')
|
|
||||||
args.extend(self.args)
|
|
||||||
return args
|
|
||||||
|
|
||||||
def run_pre_spawn_hook(self):
|
def run_pre_spawn_hook(self):
|
||||||
"""Run the pre_spawn_hook if defined"""
|
"""Run the pre_spawn_hook if defined"""
|
||||||
@@ -1155,6 +1171,18 @@ class Spawner(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
raise NotImplementedError("Override in subclass. Must be a coroutine.")
|
raise NotImplementedError("Override in subclass. Must be a coroutine.")
|
||||||
|
|
||||||
|
def delete_forever(self):
|
||||||
|
"""Called when a user or server is deleted.
|
||||||
|
|
||||||
|
This can do things like request removal of resources such as persistent storage.
|
||||||
|
Only called on stopped spawners, and is usually the last action ever taken for the user.
|
||||||
|
|
||||||
|
Will only be called once on each Spawner, immediately prior to removal.
|
||||||
|
|
||||||
|
Stopping a server does *not* call this method.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
def add_poll_callback(self, callback, *args, **kwargs):
|
def add_poll_callback(self, callback, *args, **kwargs):
|
||||||
"""Add a callback to fire when the single-user server stops"""
|
"""Add a callback to fire when the single-user server stops"""
|
||||||
if args or kwargs:
|
if args or kwargs:
|
||||||
@@ -1470,7 +1498,8 @@ class LocalProcessSpawner(Spawner):
|
|||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Start the single-user server."""
|
"""Start the single-user server."""
|
||||||
self.port = random_port()
|
if self.port == 0:
|
||||||
|
self.port = random_port()
|
||||||
cmd = []
|
cmd = []
|
||||||
env = self.get_env()
|
env = self.get_env()
|
||||||
|
|
||||||
|
@@ -94,16 +94,6 @@ class MockSpawner(SimpleLocalProcessSpawner):
|
|||||||
def _cmd_default(self):
|
def _cmd_default(self):
|
||||||
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
||||||
|
|
||||||
async def delete_forever(self):
|
|
||||||
"""Called when a user is deleted.
|
|
||||||
|
|
||||||
This can do things like request removal of resources such as persistent storage.
|
|
||||||
Only called on stopped spawners, and is likely the last action ever taken for the user.
|
|
||||||
|
|
||||||
Will only be called once on the user's default Spawner.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
use_this_api_token = None
|
use_this_api_token = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
@@ -11,10 +11,10 @@ Handlers and their purpose include:
|
|||||||
- ArgsHandler: allowing retrieval of `sys.argv`.
|
- ArgsHandler: allowing retrieval of `sys.argv`.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import argparse
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from tornado import httpserver
|
from tornado import httpserver
|
||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
@@ -36,7 +36,8 @@ class ArgsHandler(web.RequestHandler):
|
|||||||
self.write(json.dumps(sys.argv))
|
self.write(json.dumps(sys.argv))
|
||||||
|
|
||||||
|
|
||||||
def main(args):
|
def main():
|
||||||
|
url = urlparse(os.environ["JUPYTERHUB_SERVICE_URL"])
|
||||||
options.logging = 'debug'
|
options.logging = 'debug'
|
||||||
log.enable_pretty_logging()
|
log.enable_pretty_logging()
|
||||||
app = web.Application(
|
app = web.Application(
|
||||||
@@ -50,10 +51,11 @@ def main(args):
|
|||||||
|
|
||||||
if key and cert and ca:
|
if key and cert and ca:
|
||||||
ssl_context = make_ssl_context(key, cert, cafile=ca, check_hostname=False)
|
ssl_context = make_ssl_context(key, cert, cafile=ca, check_hostname=False)
|
||||||
|
assert url.scheme == "https"
|
||||||
|
|
||||||
server = httpserver.HTTPServer(app, ssl_options=ssl_context)
|
server = httpserver.HTTPServer(app, ssl_options=ssl_context)
|
||||||
log.app_log.info("Starting mock singleuser server at 127.0.0.1:%s", args.port)
|
log.app_log.info(f"Starting mock singleuser server at {url.hostname}:{url.port}")
|
||||||
server.listen(args.port, '127.0.0.1')
|
server.listen(url.port, url.hostname)
|
||||||
try:
|
try:
|
||||||
ioloop.IOLoop.instance().start()
|
ioloop.IOLoop.instance().start()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -61,7 +63,4 @@ def main(args):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
parser = argparse.ArgumentParser()
|
main()
|
||||||
parser.add_argument('--port', type=int)
|
|
||||||
args, extra = parser.parse_known_args()
|
|
||||||
main(args)
|
|
||||||
|
@@ -192,6 +192,38 @@ async def test_get_users(app):
|
|||||||
r_user_model = r.json()[0]
|
r_user_model = r.json()[0]
|
||||||
assert r_user_model['name'] == user_model['name']
|
assert r_user_model['name'] == user_model['name']
|
||||||
|
|
||||||
|
# Tests offset for pagination
|
||||||
|
r = await api_request(app, 'users?offset=1')
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
users = sorted(r.json(), key=lambda d: d['name'])
|
||||||
|
users = [normalize_user(u) for u in users]
|
||||||
|
assert users == [
|
||||||
|
fill_user(
|
||||||
|
{'name': 'user', 'admin': False, 'auth_state': None, 'roles': ['user']}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
r = await api_request(app, 'users?offset=20')
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
# Test limit for pagination
|
||||||
|
r = await api_request(app, 'users?limit=1')
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
users = sorted(r.json(), key=lambda d: d['name'])
|
||||||
|
users = [normalize_user(u) for u in users]
|
||||||
|
assert users == [
|
||||||
|
fill_user(
|
||||||
|
{'name': 'admin', 'admin': True, 'auth_state': None, 'roles': ['admin']}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
r = await api_request(app, 'users?limit=0')
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
@mark.user
|
@mark.user
|
||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
@@ -611,10 +643,17 @@ async def test_spawn(app):
|
|||||||
r = await async_requests.get(ujoin(url, 'args'), **kwargs)
|
r = await async_requests.get(ujoin(url, 'args'), **kwargs)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
argv = r.json()
|
argv = r.json()
|
||||||
assert '--port' in ' '.join(argv)
|
assert '--port' not in ' '.join(argv)
|
||||||
|
# we pass no CLI args anymore:
|
||||||
|
assert len(argv) == 1
|
||||||
r = await async_requests.get(ujoin(url, 'env'), **kwargs)
|
r = await async_requests.get(ujoin(url, 'env'), **kwargs)
|
||||||
env = r.json()
|
env = r.json()
|
||||||
for expected in ['JUPYTERHUB_USER', 'JUPYTERHUB_BASE_URL', 'JUPYTERHUB_API_TOKEN']:
|
for expected in [
|
||||||
|
'JUPYTERHUB_USER',
|
||||||
|
'JUPYTERHUB_BASE_URL',
|
||||||
|
'JUPYTERHUB_API_TOKEN',
|
||||||
|
'JUPYTERHUB_SERVICE_URL',
|
||||||
|
]:
|
||||||
assert expected in env
|
assert expected in env
|
||||||
if app.subdomain_host:
|
if app.subdomain_host:
|
||||||
assert env['JUPYTERHUB_HOST'] == app.subdomain_host
|
assert env['JUPYTERHUB_HOST'] == app.subdomain_host
|
||||||
@@ -1176,76 +1215,13 @@ async def test_check_token(app):
|
|||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@mark.parametrize("headers, status", [({}, 200), ({'Authorization': 'token bad'}, 403)])
|
@mark.parametrize("headers, status", [({}, 404), ({'Authorization': 'token bad'}, 404)])
|
||||||
async def test_get_new_token_deprecated(app, headers, status):
|
async def test_get_new_token_deprecated(app, headers, status):
|
||||||
# request a new token
|
# request a new token
|
||||||
r = await api_request(
|
r = await api_request(
|
||||||
app, 'authorizations', 'token', method='post', headers=headers
|
app, 'authorizations', 'token', method='post', headers=headers
|
||||||
)
|
)
|
||||||
assert r.status_code == status
|
assert r.status_code == status
|
||||||
if status != 200:
|
|
||||||
return
|
|
||||||
reply = r.json()
|
|
||||||
assert 'token' in reply
|
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
|
||||||
r.raise_for_status()
|
|
||||||
reply = r.json()
|
|
||||||
assert reply['name'] == 'admin'
|
|
||||||
|
|
||||||
|
|
||||||
async def test_token_formdata_deprecated(app):
|
|
||||||
"""Create a token for a user with formdata and no auth header"""
|
|
||||||
data = {'username': 'fake', 'password': 'fake'}
|
|
||||||
r = await api_request(
|
|
||||||
app,
|
|
||||||
'authorizations',
|
|
||||||
'token',
|
|
||||||
method='post',
|
|
||||||
data=json.dumps(data) if data else None,
|
|
||||||
noauth=True,
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
reply = r.json()
|
|
||||||
assert 'token' in reply
|
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
|
||||||
r.raise_for_status()
|
|
||||||
reply = r.json()
|
|
||||||
assert reply['name'] == data['username']
|
|
||||||
|
|
||||||
|
|
||||||
@mark.parametrize(
|
|
||||||
"as_user, for_user, status",
|
|
||||||
[
|
|
||||||
('admin', 'other', 200),
|
|
||||||
('admin', 'missing', 400),
|
|
||||||
('user', 'other', 403),
|
|
||||||
('user', 'user', 200),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_token_as_user_deprecated(app, as_user, for_user, status):
|
|
||||||
# ensure both users exist
|
|
||||||
u = add_user(app.db, app, name=as_user)
|
|
||||||
if for_user != 'missing':
|
|
||||||
for_user_obj = add_user(app.db, app, name=for_user)
|
|
||||||
data = {'username': for_user}
|
|
||||||
headers = {'Authorization': 'token %s' % u.new_api_token()}
|
|
||||||
r = await api_request(
|
|
||||||
app,
|
|
||||||
'authorizations',
|
|
||||||
'token',
|
|
||||||
method='post',
|
|
||||||
data=json.dumps(data),
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert r.status_code == status
|
|
||||||
reply = r.json()
|
|
||||||
if status != 200:
|
|
||||||
return
|
|
||||||
assert 'token' in reply
|
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
|
||||||
r.raise_for_status()
|
|
||||||
reply = r.json()
|
|
||||||
assert reply['name'] == data['username']
|
|
||||||
|
|
||||||
|
|
||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
@@ -1447,16 +1423,45 @@ async def test_groups_list(app):
|
|||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert reply == []
|
assert reply == []
|
||||||
|
|
||||||
# create a group
|
# create two groups
|
||||||
group = orm.Group(name='alphaflight')
|
group = orm.Group(name='alphaflight')
|
||||||
|
group_2 = orm.Group(name='betaflight')
|
||||||
app.db.add(group)
|
app.db.add(group)
|
||||||
|
app.db.add(group_2)
|
||||||
app.db.commit()
|
app.db.commit()
|
||||||
|
|
||||||
r = await api_request(app, 'groups')
|
r = await api_request(app, 'groups')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
|
assert reply == [
|
||||||
|
{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []},
|
||||||
|
{'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Test offset for pagination
|
||||||
|
r = await api_request(app, "groups?offset=1")
|
||||||
|
r.raise_for_status()
|
||||||
|
reply = r.json()
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert reply == [{'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []}]
|
||||||
|
|
||||||
|
r = await api_request(app, "groups?offset=10")
|
||||||
|
r.raise_for_status()
|
||||||
|
reply = r.json()
|
||||||
|
assert reply == []
|
||||||
|
|
||||||
|
# Test limit for pagination
|
||||||
|
r = await api_request(app, "groups?limit=1")
|
||||||
|
r.raise_for_status()
|
||||||
|
reply = r.json()
|
||||||
|
assert r.status_code == 200
|
||||||
assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []}]
|
assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []}]
|
||||||
|
|
||||||
|
r = await api_request(app, "groups?limit=0")
|
||||||
|
r.raise_for_status()
|
||||||
|
reply = r.json()
|
||||||
|
assert reply == []
|
||||||
|
|
||||||
|
|
||||||
@mark.group
|
@mark.group
|
||||||
async def test_add_multi_group(app):
|
async def test_add_multi_group(app):
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
"""Test scopes for API handlers"""
|
"""Test scopes for API handlers"""
|
||||||
import json
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -11,6 +10,7 @@ from .. import orm
|
|||||||
from .. import roles
|
from .. import roles
|
||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
from ..scopes import _check_scope
|
from ..scopes import _check_scope
|
||||||
|
from ..scopes import _intersect_scopes
|
||||||
from ..scopes import get_scopes_for
|
from ..scopes import get_scopes_for
|
||||||
from ..scopes import needs_scope
|
from ..scopes import needs_scope
|
||||||
from ..scopes import parse_scopes
|
from ..scopes import parse_scopes
|
||||||
@@ -806,3 +806,81 @@ async def test_roles_access(app, create_service_with_scopes, create_user_with_sc
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
model_keys = {'kind', 'name', 'roles'}
|
model_keys = {'kind', 'name', 'roles'}
|
||||||
assert model_keys == r.json().keys()
|
assert model_keys == r.json().keys()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"left, right, expected, should_warn",
|
||||||
|
[
|
||||||
|
(set(), set(), set(), False),
|
||||||
|
(set(), set(["users"]), set(), False),
|
||||||
|
# no warning if users and groups only on the same side
|
||||||
|
(
|
||||||
|
set(["users!user=x", "users!group=y"]),
|
||||||
|
set([]),
|
||||||
|
set([]),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
# no warning if users are on both sizes
|
||||||
|
(
|
||||||
|
set(["users!user=x", "users!user=y", "users!group=y"]),
|
||||||
|
set(["users!user=x"]),
|
||||||
|
set(["users!user=x"]),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
# no warning if users and groups are both defined
|
||||||
|
# on both sides
|
||||||
|
(
|
||||||
|
set(["users!user=x", "users!group=y"]),
|
||||||
|
set(["users!user=x", "users!group=y", "users!user=z"]),
|
||||||
|
set(["users!user=x", "users!group=y"]),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
# warn if there's a user on one side and a group on the other
|
||||||
|
# which *may* intersect
|
||||||
|
(
|
||||||
|
set(["users!group=y", "users!user=z"]),
|
||||||
|
set(["users!user=x"]),
|
||||||
|
set([]),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
# same for group->server
|
||||||
|
(
|
||||||
|
set(["users!group=y", "users!user=z"]),
|
||||||
|
set(["users!server=x/y"]),
|
||||||
|
set([]),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
# this one actually shouldn't warn because server=x/y is under user=x,
|
||||||
|
# but we don't need to overcomplicate things just for a warning
|
||||||
|
(
|
||||||
|
set(["users!group=y", "users!user=x"]),
|
||||||
|
set(["users!server=x/y"]),
|
||||||
|
set(["users!server=x/y"]),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
# resolves server under user, without warning
|
||||||
|
(
|
||||||
|
set(["read:users:servers!user=abc"]),
|
||||||
|
set(["read:users:servers!server=abc/xyz"]),
|
||||||
|
set(["read:users:servers!server=abc/xyz"]),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
# user->server, no match
|
||||||
|
(
|
||||||
|
set(["read:users:servers!user=abc"]),
|
||||||
|
set(["read:users:servers!server=abcd/xyz"]),
|
||||||
|
set([]),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_intersect_scopes(left, right, expected, should_warn, recwarn):
|
||||||
|
# run every test in both directions, to ensure symmetry of the inputs
|
||||||
|
for a, b in [(left, right), (right, left)]:
|
||||||
|
intersection = _intersect_scopes(set(left), set(right))
|
||||||
|
assert intersection == set(expected)
|
||||||
|
|
||||||
|
if should_warn:
|
||||||
|
assert len(recwarn) == 1
|
||||||
|
else:
|
||||||
|
assert len(recwarn) == 0
|
||||||
|
@@ -26,8 +26,8 @@ from tornado.web import RequestHandler
|
|||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..services.auth import _ExpiringDict
|
from ..services.auth import _ExpiringDict
|
||||||
from ..services.auth import HubAuth
|
from ..services.auth import HubOAuth
|
||||||
from ..services.auth import HubAuthenticated
|
from ..services.auth import HubOAuthenticated
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .mocking import public_host
|
from .mocking import public_host
|
||||||
from .mocking import public_url
|
from .mocking import public_url
|
||||||
@@ -76,178 +76,6 @@ def test_expiring_dict():
|
|||||||
assert cache.get('key', 'default') == 'cached value'
|
assert cache.get('key', 'default') == 'cached value'
|
||||||
|
|
||||||
|
|
||||||
def test_hub_auth():
|
|
||||||
auth = HubAuth(cookie_name='foo')
|
|
||||||
mock_model = {'name': 'onyxia'}
|
|
||||||
url = url_path_join(auth.api_url, "authorizations/cookie/foo/bar")
|
|
||||||
with requests_mock.Mocker() as m:
|
|
||||||
m.get(url, text=json.dumps(mock_model))
|
|
||||||
user_model = auth.user_for_cookie('bar')
|
|
||||||
assert user_model == mock_model
|
|
||||||
# check cache
|
|
||||||
user_model = auth.user_for_cookie('bar')
|
|
||||||
assert user_model == mock_model
|
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
|
||||||
m.get(url, status_code=404)
|
|
||||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
|
||||||
assert user_model is None
|
|
||||||
|
|
||||||
# invalidate cache with timer
|
|
||||||
mock_model = {'name': 'willow'}
|
|
||||||
with monotonic_future, requests_mock.Mocker() as m:
|
|
||||||
m.get(url, text=json.dumps(mock_model))
|
|
||||||
user_model = auth.user_for_cookie('bar')
|
|
||||||
assert user_model == mock_model
|
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
|
||||||
m.get(url, status_code=500)
|
|
||||||
with raises(HTTPError) as exc_info:
|
|
||||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
|
||||||
assert exc_info.value.status_code == 502
|
|
||||||
|
|
||||||
with requests_mock.Mocker() as m:
|
|
||||||
m.get(url, status_code=400)
|
|
||||||
with raises(HTTPError) as exc_info:
|
|
||||||
user_model = auth.user_for_cookie('bar', use_cache=False)
|
|
||||||
assert exc_info.value.status_code == 500
|
|
||||||
|
|
||||||
|
|
||||||
def test_hub_authenticated(request):
|
|
||||||
auth = HubAuth(cookie_name='jubal')
|
|
||||||
mock_model = {'name': 'jubalearly', 'groups': ['lions']}
|
|
||||||
cookie_url = url_path_join(auth.api_url, "authorizations/cookie", auth.cookie_name)
|
|
||||||
good_url = url_path_join(cookie_url, "early")
|
|
||||||
bad_url = url_path_join(cookie_url, "late")
|
|
||||||
|
|
||||||
class TestHandler(HubAuthenticated, RequestHandler):
|
|
||||||
hub_auth = auth
|
|
||||||
|
|
||||||
@authenticated
|
|
||||||
def get(self):
|
|
||||||
self.finish(self.get_current_user())
|
|
||||||
|
|
||||||
# start hub-authenticated service in a thread:
|
|
||||||
port = 50505
|
|
||||||
q = Queue()
|
|
||||||
|
|
||||||
def run():
|
|
||||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
|
||||||
app = Application([('/*', TestHandler)], login_url=auth.login_url)
|
|
||||||
|
|
||||||
http_server = HTTPServer(app)
|
|
||||||
http_server.listen(port)
|
|
||||||
loop = IOLoop.current()
|
|
||||||
loop.add_callback(lambda: q.put(loop))
|
|
||||||
loop.start()
|
|
||||||
|
|
||||||
t = Thread(target=run)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
def finish_thread():
|
|
||||||
loop.add_callback(loop.stop)
|
|
||||||
t.join(timeout=30)
|
|
||||||
assert not t.is_alive()
|
|
||||||
|
|
||||||
request.addfinalizer(finish_thread)
|
|
||||||
|
|
||||||
# wait for thread to start
|
|
||||||
loop = q.get(timeout=10)
|
|
||||||
|
|
||||||
with requests_mock.Mocker(real_http=True) as m:
|
|
||||||
# no cookie
|
|
||||||
r = requests.get('http://127.0.0.1:%i' % port, allow_redirects=False)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 302
|
|
||||||
assert auth.login_url in r.headers['Location']
|
|
||||||
|
|
||||||
# wrong cookie
|
|
||||||
m.get(bad_url, status_code=404)
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'late'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 302
|
|
||||||
assert auth.login_url in r.headers['Location']
|
|
||||||
|
|
||||||
# clear the cache because we are going to request
|
|
||||||
# the same url again with a different result
|
|
||||||
auth.cache.clear()
|
|
||||||
|
|
||||||
# upstream 403
|
|
||||||
m.get(bad_url, status_code=403)
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'late'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
assert r.status_code == 500
|
|
||||||
|
|
||||||
m.get(good_url, text=json.dumps(mock_model))
|
|
||||||
|
|
||||||
# no specific allowed user
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
# pass allowed user
|
|
||||||
TestHandler.hub_users = {'jubalearly'}
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
# no pass allowed ser
|
|
||||||
TestHandler.hub_users = {'kaylee'}
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
# pass allowed group
|
|
||||||
TestHandler.hub_groups = {'lions'}
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
# no pass allowed group
|
|
||||||
TestHandler.hub_groups = {'tigers'}
|
|
||||||
r = requests.get(
|
|
||||||
'http://127.0.0.1:%i' % port,
|
|
||||||
cookies={'jubal': 'early'},
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
async def test_hubauth_cookie(app, mockservice_url):
|
|
||||||
"""Test HubAuthenticated service with user cookies"""
|
|
||||||
cookies = await app.login_user('badger')
|
|
||||||
r = await async_requests.get(
|
|
||||||
public_url(app, mockservice_url) + '/whoami/', cookies=cookies
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
print(r.text)
|
|
||||||
reply = r.json()
|
|
||||||
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
|
||||||
assert sub_reply == {'name': 'badger', 'admin': False}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_hubauth_token(app, mockservice_url):
|
async def test_hubauth_token(app, mockservice_url):
|
||||||
"""Test HubAuthenticated service with user API tokens"""
|
"""Test HubAuthenticated service with user API tokens"""
|
||||||
u = add_user(app.db, name='river')
|
u = add_user(app.db, name='river')
|
||||||
@@ -295,8 +123,10 @@ async def test_hubauth_service_token(app, mockservice_url):
|
|||||||
r = await async_requests.get(
|
r = await async_requests.get(
|
||||||
public_url(app, mockservice_url) + '/whoami/',
|
public_url(app, mockservice_url) + '/whoami/',
|
||||||
headers={'Authorization': 'token %s' % token},
|
headers={'Authorization': 'token %s' % token},
|
||||||
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
service_model = {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
|
service_model = {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
|
||||||
assert service_model.items() <= reply.items()
|
assert service_model.items() <= reply.items()
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import os
|
import os
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
@@ -80,14 +81,26 @@ def check_db_locks(func):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def new_func(app, *args, **kwargs):
|
def new_func(app, *args, **kwargs):
|
||||||
retval = func(app, *args, **kwargs)
|
maybe_future = func(app, *args, **kwargs)
|
||||||
|
|
||||||
temp_session = app.session_factory()
|
def _check(_=None):
|
||||||
temp_session.execute('CREATE TABLE dummy (foo INT)')
|
temp_session = app.session_factory()
|
||||||
temp_session.execute('DROP TABLE dummy')
|
try:
|
||||||
temp_session.close()
|
temp_session.execute('CREATE TABLE dummy (foo INT)')
|
||||||
|
temp_session.execute('DROP TABLE dummy')
|
||||||
|
finally:
|
||||||
|
temp_session.close()
|
||||||
|
|
||||||
return retval
|
async def await_then_check():
|
||||||
|
result = await maybe_future
|
||||||
|
_check()
|
||||||
|
return result
|
||||||
|
|
||||||
|
if inspect.isawaitable(maybe_future):
|
||||||
|
return await_then_check()
|
||||||
|
else:
|
||||||
|
_check()
|
||||||
|
return maybe_future
|
||||||
|
|
||||||
return new_func
|
return new_func
|
||||||
|
|
||||||
|
@@ -252,6 +252,35 @@ class User:
|
|||||||
await self.save_auth_state(auth_state)
|
await self.save_auth_state(auth_state)
|
||||||
return auth_state
|
return auth_state
|
||||||
|
|
||||||
|
async def delete_spawners(self):
|
||||||
|
"""Call spawner cleanup methods
|
||||||
|
|
||||||
|
Allows the spawner to cleanup persistent resources
|
||||||
|
"""
|
||||||
|
for name in self.orm_user.orm_spawners.keys():
|
||||||
|
await self._delete_spawner(name)
|
||||||
|
|
||||||
|
async def _delete_spawner(self, name_or_spawner):
|
||||||
|
"""Delete a single spawner"""
|
||||||
|
# always ensure full Spawner
|
||||||
|
# this may instantiate the Spawner if it wasn't already running,
|
||||||
|
# just to delete it
|
||||||
|
if isinstance(name_or_spawner, str):
|
||||||
|
spawner = self.spawners[name_or_spawner]
|
||||||
|
else:
|
||||||
|
spawner = name_or_spawner
|
||||||
|
|
||||||
|
if spawner.active:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Spawner {spawner._log_name} is active and cannot be deleted."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await maybe_future(spawner.delete_forever())
|
||||||
|
except Exception as e:
|
||||||
|
self.log.exception(
|
||||||
|
f"Error cleaning up persistent resources on {spawner._log_name}"
|
||||||
|
)
|
||||||
|
|
||||||
def all_spawners(self, include_default=True):
|
def all_spawners(self, include_default=True):
|
||||||
"""Generator yielding all my spawners
|
"""Generator yielding all my spawners
|
||||||
|
|
||||||
@@ -810,14 +839,8 @@ class User:
|
|||||||
if orm_token:
|
if orm_token:
|
||||||
self.db.delete(orm_token)
|
self.db.delete(orm_token)
|
||||||
# remove oauth client as well
|
# remove oauth client as well
|
||||||
# handle upgrades from 0.8, where client id will be `user-USERNAME`,
|
for oauth_client in self.db.query(orm.OAuthClient).filter_by(
|
||||||
# not just `jupyterhub-user-USERNAME`
|
identifier=spawner.oauth_client_id,
|
||||||
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.log.debug("Deleting oauth client %s", oauth_client.identifier)
|
||||||
self.db.delete(oauth_client)
|
self.db.delete(oauth_client)
|
||||||
|
@@ -6,7 +6,7 @@ FROM $BASE_IMAGE
|
|||||||
MAINTAINER Project Jupyter <jupyter@googlegroups.com>
|
MAINTAINER Project Jupyter <jupyter@googlegroups.com>
|
||||||
|
|
||||||
ADD install_jupyterhub /tmp/install_jupyterhub
|
ADD install_jupyterhub /tmp/install_jupyterhub
|
||||||
ARG JUPYTERHUB_VERSION=master
|
ARG JUPYTERHUB_VERSION=main
|
||||||
# install pinned jupyterhub and ensure notebook is installed
|
# install pinned jupyterhub and ensure notebook is installed
|
||||||
RUN python3 /tmp/install_jupyterhub && \
|
RUN python3 /tmp/install_jupyterhub && \
|
||||||
python3 -m pip install notebook
|
python3 -m pip install notebook
|
||||||
|
@@ -5,7 +5,7 @@ Built from the `jupyter/base-notebook` base image.
|
|||||||
This image contains a single user notebook server for use with
|
This image contains a single user notebook server for use with
|
||||||
[JupyterHub](https://github.com/jupyterhub/jupyterhub). In particular, it is meant
|
[JupyterHub](https://github.com/jupyterhub/jupyterhub). In particular, it is meant
|
||||||
to be used with the
|
to be used with the
|
||||||
[DockerSpawner](https://github.com/jupyterhub/dockerspawner/blob/master/dockerspawner/dockerspawner.py)
|
[DockerSpawner](https://github.com/jupyterhub/dockerspawner/blob/HEAD/dockerspawner/dockerspawner.py)
|
||||||
class to launch user notebook servers within docker containers.
|
class to launch user notebook servers within docker containers.
|
||||||
|
|
||||||
The only thing this image accomplishes is pinning the jupyterhub version on top of base-notebook.
|
The only thing this image accomplishes is pinning the jupyterhub version on top of base-notebook.
|
||||||
|
@@ -13,7 +13,7 @@ function get_hub_version() {
|
|||||||
hub_xy="${hub_xy}.${split[3]}"
|
hub_xy="${hub_xy}.${split[3]}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
# tag e.g. 0.9 with master
|
# tag e.g. 0.9 with main
|
||||||
get_hub_version
|
get_hub_version
|
||||||
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xy
|
docker tag $DOCKER_REPO:$DOCKER_TAG $DOCKER_REPO:$hub_xy
|
||||||
docker push $DOCKER_REPO:$hub_xy
|
docker push $DOCKER_REPO:$hub_xy
|
||||||
|
@@ -9,8 +9,8 @@ pip_install = [
|
|||||||
sys.executable, '-m', 'pip', 'install', '--no-cache', '--upgrade',
|
sys.executable, '-m', 'pip', 'install', '--no-cache', '--upgrade',
|
||||||
'--upgrade-strategy', 'only-if-needed',
|
'--upgrade-strategy', 'only-if-needed',
|
||||||
]
|
]
|
||||||
if V == 'master':
|
if V in {'main', 'HEAD'}:
|
||||||
req = 'https://github.com/jupyterhub/jupyterhub/archive/master.tar.gz'
|
req = 'https://github.com/jupyterhub/jupyterhub/archive/HEAD.tar.gz'
|
||||||
else:
|
else:
|
||||||
version_info = [ int(part) for part in V.split('.') ]
|
version_info = [ int(part) for part in V.split('.') ]
|
||||||
version_info[-1] += 1
|
version_info[-1] += 1
|
||||||
|
Reference in New Issue
Block a user