Merge remote-tracking branch 'origin/main' into jupyterhub-public-url

This commit is contained in:
Min RK
2023-09-11 10:32:28 +02:00
68 changed files with 9490 additions and 10024 deletions

View File

@@ -150,7 +150,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -171,7 +171,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
uses: docker/build-push-action@v4
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
@@ -192,7 +192,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-demo
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
uses: docker/build-push-action@v4
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
@@ -216,7 +216,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub/singleuser
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
uses: docker/build-push-action@v4
with:
build-args: |
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}

View File

@@ -65,7 +65,7 @@ jobs:
# unencrypted HTTP
#
# main_dependencies:
# Tests everything when the we use the latest available dependencies
# Tests everything when we use the latest available dependencies
# from: traitlets.
#
# NOTE: Since only the value of these parameters are presented in the
@@ -74,7 +74,7 @@ jobs:
# Python versions available at:
# https://github.com/actions/python-versions/blob/HEAD/versions-manifest.json
include:
- python: "3.7"
- python: "3.8"
oldest_dependencies: oldest_dependencies
legacy_notebook: legacy_notebook
- python: "3.8"
@@ -90,6 +90,9 @@ jobs:
- python: "3.11"
ssl: ssl
serverextension: serverextension
- python: "3.11"
jupyverse: jupyverse
subset: singleuser
- python: "3.11"
subdomain: subdomain
noextension: noextension
@@ -130,6 +133,9 @@ jobs:
elif [ "${{ matrix.noextension }}" != "" ]; then
echo "JUPYTERHUB_SINGLEUSER_EXTENSION=0" >> $GITHUB_ENV
fi
if [ "${{ matrix.jupyverse }}" != "" ]; then
echo "JUPYTERHUB_SINGLEUSER_APP=jupyverse" >> $GITHUB_ENV
fi
- uses: actions/checkout@v3
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
@@ -176,6 +182,10 @@ jobs:
if [ "${{ matrix.jupyter_server }}" != "" ]; then
pip install "jupyter_server==${{ matrix.jupyter_server }}"
fi
if [ "${{ matrix.jupyverse }}" != "" ]; then
pip install "jupyverse[jupyterlab,auth-jupyterhub]"
pip install -e .
fi
if [ "${{ matrix.db }}" == "mysql" ]; then
pip install mysqlclient
fi

View File

@@ -16,7 +16,7 @@ ci:
repos:
# Autoformat: Python code, syntax patterns are modernized
- repo: https://github.com/asottile/pyupgrade
rev: v3.4.0
rev: v3.10.1
hooks:
- id: pyupgrade
args:
@@ -24,7 +24,7 @@ repos:
# Autoformat: Python code
- repo: https://github.com/PyCQA/autoflake
rev: v2.1.1
rev: v2.2.1
hooks:
- id: autoflake
# args ref: https://github.com/PyCQA/autoflake#advanced-usage
@@ -39,13 +39,13 @@ repos:
# Autoformat: Python code
- repo: https://github.com/psf/black
rev: 23.3.0
rev: 23.7.0
hooks:
- id: black
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.0.0-alpha.9-for-vscode
rev: v3.0.3
hooks:
- id: prettier
@@ -61,6 +61,6 @@ repos:
# Linting: Python code (see the file .flake8)
- repo: https://github.com/PyCQA/flake8
rev: "6.0.0"
rev: "6.1.0"
hooks:
- id: flake8

View File

@@ -21,7 +21,7 @@ fi
# Configure a set of databases in the database server for upgrade tests
# this list must be in sync with versions in test_db.py:test_upgrade
set -x
for SUFFIX in '' _upgrade_110 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211; do
for SUFFIX in '' _upgrade_110 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211 _upgrade_311; do
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
done

View File

@@ -6,7 +6,7 @@ info:
description: The REST API for JupyterHub
license:
name: BSD-3-Clause
version: 4.1.0.dev
version: 5.0.0.dev
servers:
- url: /hub/api
security:
@@ -1498,6 +1498,9 @@ components:
read:groups: Read group models.
read:groups:name: Read group names.
delete:groups: Delete groups.
admin:services:
Create, read, update, delete services, not including services
defined from config files.
list:services: List services, including at least their names.
read:services: Read service models.
read:services:name: Read service names.

View File

@@ -173,3 +173,46 @@ python3 setup.py js # fetch updated client-side js
python3 setup.py css # recompile CSS from LESS sources
python3 setup.py jsx # build React admin app
```
### Failed to bind XXX to `http://127.0.0.1:<port>/<path>`
This error can happen when there's already an application or a service using this
port.
Use the following command to find out which service is using this port.
```bash
lsof -P -i TCP:<port> -sTCP:LISTEN
```
If nothing shows up, it likely means there's a system service that uses it but
your current user cannot list it. Reuse the same command with sudo.
```bash
sudo lsof -P -i TCP:<port> -sTCP:LISTEN
```
Depending on the result of the above commands, the most simple solution is to
configure JupyterHub to use a different port for the service that is failing.
As an example, the following is a frequently seen issue:
`Failed to bind hub to http://127.0.0.1:8081/hub/`
Using the procedure described above, start with:
```bash
lsof -P -i TCP:8081 -sTCP:LISTEN
```
and if nothing shows up:
```bash
sudo lsof -P -i TCP:8081 -sTCP:LISTEN
```
Finally, depending on your findings, you can apply the following change and start JupyterHub again:
```python
c.JupyterHub.hub_port = 9081 # Or any other free port
```

View File

@@ -145,7 +145,7 @@ There is additional configuration required for MySQL that is not needed for Post
For example, to connect to a postgres database with psycopg2:
1. install psycopg2: `pip instal psycopg2` (or `psycopg2-binary` to avoid compilation, which is [not recommended for production][psycopg2-binary])
1. install psycopg2: `pip install psycopg2` (or `psycopg2-binary` to avoid compilation, which is [not recommended for production][psycopg2-binary])
2. set authentication via environment variables `PGUSER` and `PGPASSWORD`
3. configure [](JupyterHub.db_url):

View File

@@ -1,3 +1,5 @@
(jupyterhub-oauth)=
# JupyterHub and OAuth
JupyterHub uses [OAuth 2](https://oauth.net/2/) as an internal mechanism for authenticating users.

View File

@@ -45,7 +45,7 @@ additional packages.
## Configuring Jupyter and IPython
[Jupyter](https://jupyter-notebook.readthedocs.io/en/stable/config_overview.html)
[Jupyter](https://jupyter-notebook.readthedocs.io/en/stable/configuring/config_overview.html)
and [IPython](https://ipython.readthedocs.io/en/stable/development/config.html)
have their own configuration systems.
@@ -212,13 +212,31 @@ By default, the single-user server launches JupyterLab,
which is based on [Jupyter Server][].
This is the default server when running JupyterHub ≥ 2.0.
To switch to using the legacy Jupyter Notebook server, you can set the `JUPYTERHUB_SINGLEUSER_APP` environment variable
To switch to using the legacy Jupyter Notebook server (notebook < 7.0), you can set the `JUPYTERHUB_SINGLEUSER_APP` environment variable
(in the single-user environment) to:
```bash
export JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
```
:::{note}
```
JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
```
is only valid for notebook < 7. notebook v7 is based on jupyter-server,
and the default jupyter-server application must be used.
Selecting the new notebook UI is no longer a matter of selecting the server app to launch,
but only the default URL for users to visit.
To use notebook v7 with JupyterHub, leave the default singleuser app config alone (or specify `JUPYTERHUB_SINGLEUSER_APP=jupyter-server`) and set the default _URL_ for user servers:
```python
c.Spawner.default_url = '/tree/'
```
:::
[jupyter server]: https://jupyter-server.readthedocs.io
[jupyter notebook]: https://jupyter-notebook.readthedocs.io

View File

@@ -24,6 +24,7 @@ such as:
- Checking which users are active
- Adding or removing users
- Adding or removing services
- Stopping or starting single user notebook servers
- Authenticating services
- Communicating with an individual Jupyter server's REST API

View File

@@ -10,6 +10,34 @@ command line for details.
## 4.0
### 4.0.2 - 2023-08-10
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.0.1...4.0.2))
#### Enhancements made
- avoid counting failed requests to not-running servers as 'activity' [#4491](https://github.com/jupyterhub/jupyterhub/pull/4491) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
- improve permission-denied errors for various cases [#4489](https://github.com/jupyterhub/jupyterhub/pull/4489) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
#### Bugs fixed
- set root_dir when using singleuser extension [#4503](https://github.com/jupyterhub/jupyterhub/pull/4503) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics))
- Allow setting custom log_function in tornado_settings in SingleUserServer [#4475](https://github.com/jupyterhub/jupyterhub/pull/4475) ([@grios-stratio](https://github.com/grios-stratio), [@minrk](https://github.com/minrk))
#### Documentation improvements
- doc: update notebook config URL [#4523](https://github.com/jupyterhub/jupyterhub/pull/4523) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
- document how to use notebook v7 with jupyterhub [#4522](https://github.com/jupyterhub/jupyterhub/pull/4522) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
#### Contributors to this release
The following people contributed discussions, new ideas, code and documentation contributions, and review.
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2023-06-08&to=2023-08-10&type=c))
@agelosnm ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aagelosnm+updated%3A2023-06-08..2023-08-10&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2023-06-08..2023-08-10&type=Issues)) | @diocas ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adiocas+updated%3A2023-06-08..2023-08-10&type=Issues)) | @grios-stratio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agrios-stratio+updated%3A2023-06-08..2023-08-10&type=Issues)) | @jhgoebbert ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajhgoebbert+updated%3A2023-06-08..2023-08-10&type=Issues)) | @jtpio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajtpio+updated%3A2023-06-08..2023-08-10&type=Issues)) | @kosmonavtus ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akosmonavtus+updated%3A2023-06-08..2023-08-10&type=Issues)) | @kreuzert ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akreuzert+updated%3A2023-06-08..2023-08-10&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2023-06-08..2023-08-10&type=Issues)) | @martinRenou ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AmartinRenou+updated%3A2023-06-08..2023-08-10&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2023-06-08..2023-08-10&type=Issues)) | @opoplawski ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aopoplawski+updated%3A2023-06-08..2023-08-10&type=Issues)) | @Ph0tonic ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3APh0tonic+updated%3A2023-06-08..2023-08-10&type=Issues)) | @sgaist ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asgaist+updated%3A2023-06-08..2023-08-10&type=Issues)) | @trungleduc ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atrungleduc+updated%3A2023-06-08..2023-08-10&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2023-06-08..2023-08-10&type=Issues))
### 4.0.1 - 2023-06-08
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.0.0...4.0.1))

View File

@@ -16,8 +16,6 @@ Please submit pull requests to update information or to add new institutions or
- [BIDS - Berkeley Institute for Data Science](https://bids.berkeley.edu/)
- [Teaching with Jupyter notebooks and JupyterHub](https://bids.berkeley.edu/resources/videos/teaching-ipythonjupyter-notebooks-and-jupyterhub)
- [Data 8](http://data8.org/)
- [GitHub organization](https://github.com/data-8)

View File

@@ -18,3 +18,17 @@ tool like [Grafana](https://grafana.com).
/reference/metrics
```
## Customizing the metrics prefix
JupyterHub metrics all have a `jupyterhub_` prefix.
As of JupyterHub 5.0, this can be overridden with `$JUPYTERHUB_METRICS_PREFIX` environment variable
in the Hub's environment.
For example,
```bash
export JUPYTERHUB_METRICS_PREFIX=jupyterhub_prod
```
would result in the metric `jupyterhub_prod_active_users`, etc.

View File

@@ -4,20 +4,19 @@
## Definition of a Service
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
action or task. For example, the following tasks can each be a unique Service:
When working with JupyterHub, a **Service** is defined as something (usually a process) that can interact with the Hub's REST API.
A Service may perform a specific action or task.
For example, the following tasks can each be a unique Service:
- shutting down individuals' single user notebook servers that have been idle
for some time
- registering additional web servers which should use the Hub's authentication
and be served behind the Hub's proxy.
- shutting down individuals' single user notebook servers that have been idle for some time
- an additional web application which uses the Hub as an OAuth provider to authenticate and authorize user access
- a script run once in a while, which performs any API action
- automating requests to running user servers, such as activity data collection
Two key features help define a Service:
Two key features help differentiate Services:
- Is the Service **managed** by JupyterHub?
- Does the Service have a web server that should be added to the proxy's
table?
- Does the Service have a web server that should be added to the proxy's table?
Currently, these characteristics distinguish two types of Services:
@@ -30,24 +29,32 @@ Currently, these characteristics distinguish two types of Services:
A Service may have the following properties:
- `name: str` - the name of the service
- `admin: bool (default - false)` - whether the service should have
administrative privileges
- `url: str (default - None)` - The URL where the service is/should be. If a
url is specified for where the Service runs its own web server,
the service will be added to the proxy at `/services/:name`
- `api_token: str (default - None)` - For Externally-Managed Services you need to specify
an API token to perform API requests to the Hub
- `url: str (default - None)` - The URL where the service should be running (from the proxy's perspective).
Typically a localhost URL for Hub-managed services.
If a url is specified,
the service will be added to the proxy at `/services/:name`.
- `api_token: str (default - None)` - For Externally-Managed Services,
you need to specify an API token to perform API requests to the Hub.
For Hub-managed services, this token is generated at startup,
and available via `$JUPYTERHUB_API_TOKEN`.
For OAuth services, this is the client secret.
- `display: bool (default - True)` - When set to true, display a link to the
service's URL under the 'Services' dropdown in user's hub home page.
service's URL under the 'Services' dropdown in users' hub home page.
Only has an effect if `url` is also specified.
- `oauth_no_confirm: bool (default - False)` - When set to true,
skip the OAuth confirmation page when users access this service.
By default, when users authenticate with a service using JupyterHub,
they are prompted to confirm that they want to grant that service
access to their credentials.
Skipping the confirmation page is useful for admin-managed services that are considered part of the Hub
and shouldn't need extra prompts for login.
- `oauth_client_id: str (default - 'service-$name')` -
This never needs to be set, but you can specify a service's OAuth client id.
It must start with `service-`.
- `oauth_redirect_uri: str (default: '/services/:name/oauth_redirect')` -
Set the OAuth redirect URI.
Required if the redirect URI differs from the default or the service is not to be added to the proxy at `/services/:name`
(i.e. `url` is not set, but there is still a public web service using OAuth).
If a service is also to be managed by the Hub, it has a few extra options:
@@ -55,19 +62,19 @@ If a service is also to be managed by the Hub, it has a few extra options:
externally. - If a command is specified for launching the Service, the Service will
be started and managed by the Hub.
- `environment: dict` - additional environment variables for the Service.
- `user: str` - the name of a system user to manage the Service. If
unspecified, run as the same user as the Hub.
- `user: str` - the name of a system user to manage the Service.
If unspecified, run as the same user as the Hub.
## Hub-Managed Services
A **Hub-Managed Service** is started by the Hub, and the Hub is responsible
for the Service's actions. A Hub-Managed Service can only be a local
for the Service's operation. A Hub-Managed Service can only be a local
subprocess of the Hub. The Hub will take care of starting the process and
restart the service if the service stops.
While Hub-Managed Services share some similarities with notebook Spawners,
While Hub-Managed Services share some similarities with single-user server Spawners,
there are no plans for Hub-Managed Services to support the same spawning
abstractions as a notebook Spawner.
abstractions as a Spawner.
If you wish to run a Service in a Docker container or other deployment
environments, the Service can be registered as an
@@ -156,8 +163,8 @@ to perform its API requests. Each Externally-Managed Service will need a
unique API token, because the Hub authenticates each API request and the API
token is used to identify the originating Service or user.
A configuration example of an Externally-Managed Service with admin access and
running its own web server is:
A configuration example of an Externally-Managed Service running its own web
server is:
```python
c.JupyterHub.services = [
@@ -174,6 +181,147 @@ c.JupyterHub.services = [
In this case, the `url` field will be passed along to the Service as
`JUPYTERHUB_SERVICE_URL`.
## Service credentials
A service has direct access to the Hub API via its `api_token`.
Exactly what actions the service can take are governed by the service's [role assignments](define-role-target):
```python
c.JupyterHub.services = [
{
"name": "user-lister",
"command": ["python3", "/path/to/user-lister"],
}
]
c.JupyterHub.load_roles = [
{
"name": "list-users",
"scopes": ["list:users", "read:users"],
"services": ["user-lister"]
}
]
```
When a service has a configured URL or explicit `oauth_client_id` or `oauth_redirect_uri`, it can operate as an [OAuth client](jupyterhub-oauth).
When a user visits an oauth-authenticated service,
completion of authentication results in issuing an oauth token.
This token is:
- owned by the authenticated user
- associated with the oauth client of the service
- governed by the service's `oauth_client_allowed_scopes` configuration
This token enables the service to act _on behalf of_ the user.
When an oauthenticated service makes a request to the Hub (or other Hub-authenticated service), it has two credentials available to authenticate the request:
- the service's own `api_token`, which acts _as_ the service,
and is governed by the service's own role assignments.
- the user's oauth token issued to the service during the oauth flow,
which acts _as_ the user.
Choosing which one to use depends on "who" should be considered taking the action represented by the request.
A service's own permissions governs how it can act without any involvement of a user.
The service's `oauth_client_allowed_scopes` configuration allows individual users to _delegate_ permission for the service to act on their behalf.
This allows services to have little to no permissions of their own,
but allow users to take actions _via_ the service,
using their own credentials.
An example of such a service would be a web application for instructors,
presenting a dashboard of actions which can be taken for students in their courses.
The service would need no permission to do anything with the JupyterHub API on its own,
but it could employ the user's oauth credentials to list users,
manage student servers, etc.
This service might look like:
```python
c.JupyterHub.services = [
{
"name": "grader-dashboard",
"command": ["python3", "/path/to/grader-dashboard"],
"url": "http://127.0.0.1:12345",
"oauth_client_allowed_scopes": [
"list:users",
"read:users",
]
}
]
c.JupyterHub.load_roles = [
{
"name": "grader",
"scopes": [
"list:users!group=class-a",
"read:users!group=class-a",
"servers!group=class-a",
"access:servers!group=class-a",
"access:services",
],
"groups": ["graders"]
}
]
```
In this example, the `grader-dashboard` service does not have permission to take any actions with the Hub API on its own because it has not been assigned any role.
But when a grader accesses the service,
the dashboard will have a token with permission to list and read information about any users that the grader can access.
The dashboard will _not_ have permission to do additional things as the grader.
The dashboard will be able to:
- list users in class A (`list:users!group=class-a`)
- read information about users in class A (`read:users!group=class-a`)
The dashboard will _not_ be able to:
- start, stop, or access user servers (`servers`, `access:servers`), even though the grader has this permission (it's not in `oauth_client_allowed_scopes`)
- take any action without the grader granting permission via oauth
## Adding or removing services at runtime
Only externally-managed services can be added at runtime by using JupyterHubs REST API.
### Add a new service
To add a new service, send a POST request to this endpoint
```
POST /hub/api/services/:servicename
```
**Required scope: `admin:services`**
**Payload**: The payload should contain the definition of the service to be created. The endpoint supports the same properties as externally-managed services defined in the config file.
**Possible responses**
- `201 Created`: The service and related objects are created (and started in case of a Hub-managed one) successfully.
- `400 Bad Request`: The payload is invalid or JupyterHub can not create the service.
- `409 Conflict`: The service with the same name already exists.
### Remove an existing service
To remove an existing service, send a DELETE request to this endpoint
```
DELETE /hub/api/services/:servicename
```
**Required scope: `admin:services`**
**Payload**: `None`
**Possible responses**
- `200 OK`: The service and related objects are removed (and stopped in case of a Hub-managed one) successfully.
- `400 Bad Request`: JupyterHub can not remove the service.
- `404 Not Found`: The requested service does not exist.
- `405 Not Allowed`: The requested service is created from the config file, it can not be removed at runtime.
## Writing your own Services
When writing your own services, you have a few decisions to make (in addition
@@ -237,16 +385,14 @@ There are two levels of authentication with the Hub:
This should be used for any service that serves pages that should be visited with a browser.
To use HubAuth, you must set the `.api_token` instance variable. This can be
done either programmatically when constructing the class, or via the
done via the HubAuth constructor, direct assignment to a HubAuth object, or via the
`JUPYTERHUB_API_TOKEN` environment variable. A number of the examples in the
root of the jupyterhub git repository set the `JUPYTERHUB_API_TOKEN` variable
so consider having a look at those for futher reading
so consider having a look at those for further reading
([cull-idle](https://github.com/jupyterhub/jupyterhub/tree/master/examples/cull-idle),
[external-oauth](https://github.com/jupyterhub/jupyterhub/tree/master/examples/external-oauth),
[service-notebook](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-notebook)
and [service-whoiami](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-whoami))
(TODO: Where is this API TOKen set?)
and [service-whoami](https://github.com/jupyterhub/jupyterhub/tree/master/examples/service-whoami))
Most of the logic for authentication implementation is found in the
{meth}`.HubAuth.user_for_token` methods,
@@ -299,7 +445,7 @@ for more details.
### Authenticating tornado services with JupyterHub
Since most Jupyter services are written with tornado,
we include a mixin class, [`HubOAuthenticated`][huboauthenticated],
we include a mixin class, {class}`.HubOAuthenticated`,
for quickly authenticating your own tornado services with JupyterHub.
Tornado's {py:func}`~.tornado.web.authenticated` decorator calls a Handler's {py:meth}`~.tornado.web.RequestHandler.get_current_user`

View File

@@ -31,6 +31,7 @@ Some examples include:
- [SSHSpawner](https://github.com/NERSC/sshspawner) to spawn notebooks
on a remote server using SSH
- [KubeSpawner](https://github.com/jupyterhub/kubespawner) to spawn notebook servers on kubernetes cluster.
- [NomadSpawner](https://github.com/mxab/jupyterhub-nomad-spawner) to spawn a notebook server as a Nomad job inside HashiCorp's Nomad cluster
## Spawner control methods

View File

@@ -22,6 +22,21 @@ started.
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
```
## One Time Passwords ( request_otp )
By setting `request_otp` to true, the login screen will show and additional password input field
to accept an OTP:
```python
c.Authenticator.request_otp = True
```
By default, the prompt label is `OTP:`, but this can be changed by setting `otp_prompt`:
```python
c.Authenticator.otp_prompt = 'Google Authenticator:'
```
## Configure admins (`admin_users`)
```{note}

View File

@@ -60,8 +60,9 @@ The essential pieces for using JupyterHub as an OAuth provider are:
"name": "my-service",
# the oauth client id of your service
# must be unique but isn't private
# can be randomly generated or hand-written
"oauth_client_id": "abc123",
# can be randomly generated or hand-written, but must
# begin with service-
"oauth_client_id": "service-abc123",
# the API token and client secret of the service
# should be generated securely,
# e.g. via `openssl rand -hex 32`
@@ -77,7 +78,7 @@ The essential pieces for using JupyterHub as an OAuth provider are:
The relevant OAuth URLs and keys for using JupyterHub as an OAuth provider are:
1. the client_id, used in oauth requests
1. the client_id, used in oauth requests. This must begin with the characters `service-`
2. the api token registered with jupyterhub is the client_secret for oauth requests
3. oauth url of the Hub, which is "/hub/api/oauth2/authorize", e.g. `https://myhub.horse/hub/api/oauth2/authorize`
4. a redirect handler to receive the authenticated response

View File

@@ -9,7 +9,6 @@ if not api_token:
)
# tell JupyterHub to register the service as an external oauth client
c.JupyterHub.services = [
{
'name': 'external-oauth',
@@ -18,3 +17,26 @@ c.JupyterHub.services = [
'oauth_redirect_uri': 'http://127.0.0.1:5555/oauth_callback',
}
]
# Grant all JupyterHub users ability to access services
c.JupyterHub.load_roles = [
{
'name': 'user',
'description': 'Allow all users to access all services',
'scopes': ['access:services', 'self'],
}
]
# Boilerplate to make sure the example runs - this is not relevant
# to external oauth services.
# Allow authentication with any username and any password
from jupyterhub.auth import DummyAuthenticator
c.JupyterHub.authenticator_class = DummyAuthenticator
# Optionally set a global password that all users must use
# c.DummyAuthenticator.password = "your_password"
# only listen on localhost for testing.
c.JupyterHub.bind_url = 'http://127.0.0.1:8000'

View File

@@ -21,14 +21,6 @@ import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const AccessServerButton = ({ url }) => (
<a href={url || ""}>
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
Access Server
</button>
</a>
);
const RowListItem = ({ text }) => (
<span className="server-dashboard-row-list-item">{text}</span>
);
@@ -56,7 +48,6 @@ const ServerDashboard = (props) => {
var [errorAlert, setErrorAlert] = useState(null);
var [sortMethod, setSortMethod] = useState(null);
var [disabledButtons, setDisabledButtons] = useState({});
var [collapseStates, setCollapseStates] = useState({});
var user_data = useSelector((state) => state.user_data),
@@ -128,15 +119,15 @@ const ServerDashboard = (props) => {
user_data = sortMethod(user_data);
}
const StopServerButton = ({ serverName, userName }) => {
const ServerButton = ({ server, user, action, name, extraClass }) => {
var [isDisabled, setIsDisabled] = useState(false);
return (
<button
className="btn btn-danger btn-xs stop-button"
disabled={isDisabled}
className={`btn btn-xs ${extraClass}`}
disabled={isDisabled || server.pending}
onClick={() => {
setIsDisabled(true);
stopServer(userName, serverName)
action(user.name, server.name)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
@@ -152,103 +143,87 @@ const ServerDashboard = (props) => {
setErrorAlert(`Failed to update users list.`);
});
} else {
setErrorAlert(`Failed to stop server.`);
setErrorAlert(`Failed to ${name.toLowerCase()}.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to stop server.`);
setErrorAlert(`Failed to ${name.toLowerCase()}.`);
setIsDisabled(false);
});
}}
>
Stop Server
{name}
</button>
);
};
const DeleteServerButton = ({ serverName, userName }) => {
if (serverName === "") {
const StopServerButton = ({ server, user }) => {
if (!server.ready) {
return null;
}
return ServerButton({
server,
user,
action: stopServer,
name: "Stop Server",
extraClass: "btn-danger stop-button",
});
};
const DeleteServerButton = ({ server, user }) => {
if (server.name === "") {
// It's not possible to delete unnamed servers
return null;
}
if (server.ready || server.pending) {
return null;
}
return ServerButton({
server,
user,
action: deleteServer,
name: "Delete Server",
extraClass: "btn-danger stop-button",
});
};
const StartServerButton = ({ server, user }) => {
if (server.ready) {
return null;
}
return ServerButton({
server,
user,
action: startServer,
name: server.pending ? "Server is pending" : "Start Server",
extraClass: "btn-success start-button",
});
};
const SpawnPageButton = ({ server, user }) => {
if (server.ready) {
return null;
}
var [isDisabled, setIsDisabled] = useState(false);
return (
<button
className="btn btn-danger btn-xs stop-button"
// It's not possible to delete unnamed servers
disabled={isDisabled}
onClick={() => {
setIsDisabled(true);
deleteServer(userName, serverName)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(
data.items,
data._pagination,
name_filter,
);
})
.catch(() => {
setIsDisabled(false);
setErrorAlert(`Failed to update users list.`);
});
} else {
setErrorAlert(`Failed to delete server.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to delete server.`);
setIsDisabled(false);
});
}}
<a
href={`${base_url}spawn/${user.name}${
server.name ? "/" + server.name : ""
}`}
>
Delete Server
</button>
<button className="btn btn-light btn-xs">Spawn Page</button>
</a>
);
};
const StartServerButton = ({ serverName, userName }) => {
var [isDisabled, setIsDisabled] = useState(false);
const AccessServerButton = ({ server }) => {
if (!server.ready) {
return null;
}
return (
<button
className="btn btn-success btn-xs start-button"
disabled={isDisabled}
onClick={() => {
setIsDisabled(true);
startServer(userName, serverName)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(
data.items,
data._pagination,
name_filter,
);
})
.catch(() => {
setErrorAlert(`Failed to update users list.`);
setIsDisabled(false);
});
} else {
setErrorAlert(`Failed to start server.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to start server.`);
setIsDisabled(false);
});
}}
>
Start Server
</button>
<a href={server.url || ""}>
<button className="btn btn-primary btn-xs">Access Server</button>
</a>
);
};
@@ -358,43 +333,16 @@ const ServerDashboard = (props) => {
<td data-testid="user-row-last-activity">
{server.last_activity ? timeSince(server.last_activity) : "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.ready ? (
// Stop Single-user server
<>
<StopServerButton serverName={server.name} userName={user.name} />
<AccessServerButton url={server.url} />
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
style={{ marginRight: 20 }}
/>
<DeleteServerButton
serverName={server.name}
userName={user.name}
/>
<a
href={`${base_url}spawn/${user.name}${
server.name ? "/" + server.name : ""
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
<td data-testid="user-row-server-activity" className="actions">
<StartServerButton server={server} user={user} />
<StopServerButton server={server} user={user} />
<DeleteServerButton server={server} user={user} />
<AccessServerButton server={server} />
<SpawnPageButton server={server} user={user} />
</td>
<EditUserCell user={user} />
</tr>,
<tr>
<tr key={`${userServerName}-detail`}>
<td
colSpan={6}
style={{ padding: 0 }}
@@ -514,9 +462,11 @@ const ServerDashboard = (props) => {
<tbody>
<tr className="noborder">
<td>
<Button variant="light" className="add-users-button">
<Link to="/add-users">Add Users</Link>
</Button>
<Link to="/add-users">
<Button variant="light" className="add-users-button">
Add Users
</Button>
</Link>
</td>
<td></td>
<td></td>
@@ -611,6 +561,7 @@ const ServerDashboard = (props) => {
Shutdown Hub
</Button>
</td>
<td></td>
</tr>
{servers.flatMap(([user, server]) => serverRow(user, server))}
</tbody>

View File

@@ -2,7 +2,13 @@ import React from "react";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import userEvent from "@testing-library/user-event";
import { render, screen, fireEvent, getByText } from "@testing-library/react";
import {
render,
screen,
fireEvent,
getByText,
getAllByRole,
} from "@testing-library/react";
import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux";
import { createStore } from "redux";
@@ -697,3 +703,59 @@ test("Server delete button exists for named servers", async () => {
expect(delete_button).toBeEnabled();
}
});
test("Start server and confirm pending state", async () => {
let spy = mockAsync();
let mockStartServer = jest.fn(() => {
return new Promise(async (resolve) =>
clock.setTimeout(() => {
resolve({ status: 200 });
}, 100),
);
});
let mockUpdateUsers = jest.fn(() => Promise.resolve(mockAppState()));
await act(async () => {
render(
<Provider store={createStore(mockReducers, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={mockUpdateUsers}
shutdownHub={spy}
startServer={mockStartServer}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>,
);
});
let actions = screen.getAllByTestId("user-row-server-activity")[1];
let buttons = getAllByRole(actions, "button");
expect(buttons.length).toBe(2);
expect(buttons[0].textContent).toBe("Start Server");
expect(buttons[1].textContent).toBe("Spawn Page");
await act(async () => {
fireEvent.click(buttons[0]);
});
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
expect(buttons.length).toBe(2);
expect(buttons[0].textContent).toBe("Start Server");
expect(buttons[0]).toBeDisabled();
expect(buttons[1].textContent).toBe("Spawn Page");
expect(buttons[1]).toBeEnabled();
await act(async () => {
await clock.tick(100);
});
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
});

View File

@@ -7,7 +7,7 @@
margin-left: auto;
}
.server-dashboard-container .add-users-button {
.server-dashboard-container .btn-light {
border: 1px solid #ddd;
}
@@ -38,3 +38,11 @@ tr.noborder > td {
border: 1px solid #ddd;
border-radius: 2px;
}
.table > tbody > tr.user-row > td {
vertical-align: inherit;
}
.user-row .actions > * {
margin-right: 5px;
}

7
jsx/testing/group.json Normal file
View File

@@ -0,0 +1,7 @@
{
"items": [
{ "kind": "group", "name": "testgroup", "users": [] },
{ "kind": "group", "name": "testgroup2", "users": ["foo", "bar"] }
],
"_pagination": { "offset": 0, "limit": 50, "total": 2, "next": null }
}

21
jsx/testing/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>JupyterHub</title>
<meta http-equiv="X-UA-Compatible" content="chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/static/css/style.min.css" type="text/css" />
</head>
<body>
<div id="react-admin-hook">
<script id="jupyterhub-admin-config">
window.api_page_limit = parseInt("50");
window.base_url = "/";
</script>
<script src="admin-react.js"></script>
</div>
</body>
</html>

142
jsx/testing/user.json Normal file
View File

@@ -0,0 +1,142 @@
{
"items": [
{
"last_activity": "2022-08-04T23:01:40.770831Z",
"groups": [],
"created": "2022-08-04T23:01:15.074531Z",
"roles": ["user"],
"auth_state": null,
"pending": null,
"kind": "user",
"server": null,
"name": "userA",
"servers": {
"": {
"name": "",
"last_activity": "2022-08-04T23:01:40.770831Z",
"started": null,
"pending": null,
"ready": false,
"stopped": true,
"url": "/user/usera/",
"user_options": null,
"progress_url": "/hub/api/users/usera/server/progress",
"state": {}
}
}
},
{
"last_activity": "2022-08-05T16:43:44.442068Z",
"groups": [],
"admin": true,
"created": "2022-08-04T23:01:27.819148Z",
"roles": ["user", "admin"],
"auth_state": null,
"pending": null,
"kind": "user",
"server": null,
"name": "userB",
"servers": {
"": {
"name": "",
"last_activity": "2022-08-05T16:43:44.442068Z",
"started": null,
"pending": null,
"ready": true,
"stopped": false,
"url": "/user/userb/",
"user_options": null,
"progress_url": "/hub/api/users/userb/server/progress",
"state": {}
}
}
},
{
"last_activity": "2022-08-05T16:43:44.442068Z",
"groups": [],
"created": "2022-08-04T23:01:27.819148Z",
"roles": ["user"],
"auth_state": null,
"pending": "spawn",
"kind": "user",
"server": null,
"name": "userC",
"servers": {
"": {
"last_activity": "2023-06-11T16:22:02.228468Z",
"name": "",
"pending": "spawn",
"progress_url": "/hub/api/users/userc/server/progress",
"ready": false,
"started": "2023-06-11T16:22:02.228468Z",
"state": { "pid": 68137 },
"stopped": false,
"url": "/user/userc/",
"user_options": {}
}
}
},
{
"last_activity": "2023-06-11T15:19:49.786502Z",
"groups": [],
"created": "2023-05-14T09:05:48.996574Z",
"roles": ["user"],
"auth_state": null,
"pending": null,
"kind": "user",
"server": "/user/userD/",
"name": "userD",
"servers": {
"": {
"name": "",
"last_activity": "2023-06-11T13:39:27.017000Z",
"started": "2023-06-11T13:39:24.679829Z",
"pending": null,
"ready": true,
"stopped": false,
"url": "/user/userd/",
"user_options": {},
"progress_url": "/hub/api/users/userd/server/progress",
"state": { "pid": 41517 }
},
"serverA": {
"name": "serverA",
"last_activity": "2023-05-14T13:59:06.931642Z",
"started": null,
"pending": null,
"ready": false,
"stopped": true,
"url": "/user/userd/servera/",
"user_options": null,
"progress_url": "/hub/api/users/userd/servers/servera/progress",
"state": {}
},
"serverB": {
"last_activity": "2023-06-11T16:22:02.228468Z",
"name": "serverB",
"pending": "spawn",
"progress_url": "/hub/api/users/userb/servers/serverb/progress",
"ready": false,
"started": "2023-06-11T16:22:02.228468Z",
"state": { "pid": 68137 },
"stopped": false,
"url": "/user/userd/serverb/",
"user_options": {}
},
"serverC": {
"name": "serverC",
"last_activity": "2023-05-14T13:59:06.931642Z",
"started": null,
"pending": null,
"ready": true,
"stopped": false,
"url": "/user/userd/serverc/",
"user_options": null,
"progress_url": "/hub/api/users/userd/servers/serverc/progress",
"state": {}
}
}
}
],
"_pagination": { "offset": 0, "limit": 50, "total": 4, "next": null }
}

View File

@@ -1,5 +1,7 @@
const webpack = require("webpack");
const path = require("path");
const user_json = require("./testing/user.json");
const group_json = require("./testing/group.json");
module.exports = {
entry: path.resolve(__dirname, "src", "App.jsx"),
@@ -33,31 +35,21 @@ module.exports = {
},
plugins: [new webpack.HotModuleReplacementPlugin()],
devServer: {
static: {
directory: path.resolve(__dirname, "build"),
client: {
overlay: false,
},
static: ["build", "testing", "../share/jupyterhub"],
port: 9000,
onBeforeSetupMiddleware: (devServer) => {
const app = devServer.app;
var user_data = JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]',
);
var group_data = JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]',
);
// get user_data
app.get("/hub/api/users", (req, res) => {
res
.set("Content-Type", "application/json")
.send(JSON.stringify(user_data));
res.set("Content-Type", "application/json").send(user_json);
});
// get group_data
app.get("/hub/api/groups", (req, res) => {
res
.set("Content-Type", "application/json")
.send(JSON.stringify(group_data));
res.set("Content-Type", "application/json").send(group_json);
});
// add users to group
app.post("/hub/api/groups/*/users", (req, res) => {

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump`
version_info = (4, 1, 0, "", "dev")
version_info = (5, 0, 0, "", "dev")
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1

View File

@@ -0,0 +1,51 @@
"""Add from_config column to the services table
Revision ID: 3c2384c5aae1
Revises: 0eee8c825d24
Create Date: 2023-02-27 16:22:26.196231
"""
# revision identifiers, used by Alembic.
revision = '3c2384c5aae1'
down_revision = '0eee8c825d24'
branch_labels = None
depends_on = None
import sqlalchemy as sa
from alembic import op
from jupyterhub.orm import JSONDict, JSONList
COL_DATA = [
{'name': 'url', 'type': sa.Unicode(length=2047)},
{'name': 'oauth_client_allowed_scopes', 'type': JSONDict()},
{'name': 'info', 'type': JSONDict()},
{'name': 'display', 'type': sa.Boolean},
{'name': 'oauth_no_confirm', 'type': sa.Boolean},
{'name': 'command', 'type': JSONList()},
{'name': 'cwd', 'type': sa.Unicode(length=2047)},
{'name': 'environment', 'type': JSONDict()},
{'name': 'user', 'type': sa.Unicode(255)},
]
def upgrade():
engine = op.get_bind().engine
tables = sa.inspect(engine).get_table_names()
if 'services' in tables:
op.add_column(
'services',
sa.Column('from_config', sa.Boolean, default=True),
)
op.execute('UPDATE services SET from_config = true')
for item in COL_DATA:
op.add_column(
'services',
sa.Column(item['name'], item['type'], nullable=True),
)
def downgrade():
op.drop_column('services', sa.Column('from_config'))
for item in COL_DATA:
op.drop_column('services', sa.Column(item['name']))

View File

@@ -13,16 +13,37 @@ depends_on = None
import sqlalchemy as sa
from alembic import op
from sqlalchemy import Column, ForeignKey, Table
from sqlalchemy.orm import relationship
from sqlalchemy import Column, ForeignKey, Table, text
from sqlalchemy.orm import raiseload, relationship, selectinload
from sqlalchemy.orm.session import Session
from jupyterhub import orm, roles, scopes
from jupyterhub import orm, roles
def access_scopes(oauth_client: orm.OAuthClient, db: Session):
"""Return scope(s) required to access an oauth client
This is a clone of `scopes.access_scopes` without using
the `orm.Service`
"""
scopes = set()
if oauth_client.identifier == "jupyterhub":
return frozenset()
spawner = oauth_client.spawner
if spawner:
scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}")
else:
statement = "SELECT * FROM services WHERE oauth_client_id = :identifier"
service = db.execute(
text(statement), {"identifier": oauth_client.identifier}
).fetchall()
if len(service) > 0:
scopes.add(f"access:services!service={service[0].name}")
return frozenset(scopes)
def upgrade():
c = op.get_bind()
tables = sa.inspect(c.engine).get_table_names()
# oauth codes are short lived, no need to upgrade them
@@ -62,7 +83,10 @@ def upgrade():
# tokens have roles, evaluate to scopes
db = Session(bind=c)
for token in db.query(orm.APIToken):
for token in db.query(orm.APIToken).options(
selectinload(orm.APIToken.roles), raiseload("*")
):
token.scopes = list(roles.roles_to_scopes(token.roles))
db.commit()
# drop token-role relationship
@@ -100,7 +124,7 @@ def upgrade():
db = Session(bind=c)
for oauth_client in db.query(orm.OAuthClient):
allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles))
allowed_scopes.update(scopes.access_scopes(oauth_client))
allowed_scopes.update(access_scopes(oauth_client, db))
oauth_client.allowed_scopes = sorted(allowed_scopes)
db.commit()
# drop token-role relationship

View File

@@ -89,6 +89,11 @@ class APIHandler(BaseHandler):
if not hasattr(self, '_jupyterhub_user'):
# called too early to check if we're token-authenticated
return
if self._jupyterhub_user is None and 'Origin' not in self.request.headers:
# don't raise xsrf if auth failed
# don't apply this shortcut to actual cross-site requests, which have an 'Origin' header,
# which would reveal if there are credentials present
return
if getattr(self, '_token_authenticated', False):
# if token-authenticated, ignore XSRF
return
@@ -263,17 +268,43 @@ class APIHandler(BaseHandler):
return self._include_stopped_servers
def user_model(self, user):
"""Get the JSON model for a User object"""
"""Get the JSON model for a User object
User may be either a high-level User wrapper,
or a low-level orm.User.
"""
is_orm = False
if isinstance(user, orm.User):
user = self.users[user.id]
if user.id in self.users:
# if it's an 'active' user, it's in the users dict,
# get the wrapper so we can get 'pending' state, etc.
user = self.users[user.id]
else:
# don't create wrapper of low-level orm object
is_orm = True
if is_orm:
# if it's not in the users dict,
# we know it has no running servers
running = False
spawners = {}
if not is_orm:
running = user.running
spawners = user.spawners
include_stopped_servers = self.include_stopped_servers
# TODO: we shouldn't fetch fields we can't read and then filter them out,
# which may be wasted database queries
# we should check and then fetch.
# but that's tricky for e.g. server filters
model = {
'kind': 'user',
'name': user.name,
'admin': user.admin,
'roles': [r.name for r in user.roles],
'groups': [g.name for g in user.groups],
'server': user.url if user.running else None,
'server': user.url if running else None,
'pending': None,
'created': isoformat(user.created),
'last_activity': isoformat(user.last_activity),
@@ -303,12 +334,12 @@ class APIHandler(BaseHandler):
model, access_map, user, kind='user', keys=allowed_keys
)
if model:
if '' in user.spawners and 'pending' in allowed_keys:
model['pending'] = user.spawners[''].pending
if '' in spawners and 'pending' in allowed_keys:
model['pending'] = spawners[''].pending
servers = {}
scope_filter = self.get_scope_filter('read:servers')
for name, spawner in user.spawners.items():
for name, spawner in spawners.items():
# include 'active' servers, not just ready
# (this includes pending events)
if (spawner.active or include_stopped_servers) and scope_filter(
@@ -390,6 +421,23 @@ class APIHandler(BaseHandler):
_group_model_types = {'name': str, 'users': list, 'roles': list}
_service_model_types = {
'name': str,
'admin': bool,
'url': str,
'oauth_client_allowed_scopes': list,
'api_token': str,
'info': dict,
'display': bool,
'oauth_no_confirm': bool,
'command': list,
'cwd': str,
'environment': dict,
'user': str,
'oauth_client_id': str,
'oauth_redirect_uri': str,
}
def _check_model(self, model, model_types, name):
"""Check a model provided by a REST API request
@@ -428,6 +476,15 @@ class APIHandler(BaseHandler):
400, ("group names must be str, not %r", type(groupname))
)
def _check_service_model(self, model):
"""Check a request-provided service model from a REST API"""
self._check_model(model, self._service_model_types, 'service')
service_name = model.get('name')
if not isinstance(service_name, str):
raise web.HTTPError(
400, ("Service name must be str, not %r", type(service_name))
)
def get_api_pagination(self):
default_limit = self.settings["api_page_default_limit"]
max_limit = self.settings["api_page_max_limit"]
@@ -475,7 +532,7 @@ class APIHandler(BaseHandler):
if next_offset < total_count:
# if there's a next page
next_url_parsed = urlparse(self.request.full_url())
query = parse_qs(next_url_parsed.query)
query = parse_qs(next_url_parsed.query, keep_blank_values=True)
query['offset'] = [next_offset]
query['limit'] = [limit]
next_url_parsed = next_url_parsed._replace(

View File

@@ -5,8 +5,14 @@ Currently GET-only, no actions can be taken to modify services.
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
from typing import Optional, Tuple
from ..scopes import Scope, needs_scope
from tornado import web
from .. import orm
from ..roles import get_default_roles
from ..scopes import Scope, _check_token_scopes, needs_scope
from ..services.service import Service
from .base import APIHandler
@@ -25,9 +31,171 @@ class ServiceListAPIHandler(APIHandler):
class ServiceAPIHandler(APIHandler):
@needs_scope('read:services', 'read:services:name', 'read:roles:services')
def get(self, service_name):
if service_name not in self.services:
raise web.HTTPError(404, f"No such service: {service_name}")
service = self.services[service_name]
self.write(json.dumps(self.service_model(service)))
def _check_service_scopes(self, spec: dict):
user = self.current_user
requested_scopes = []
if spec.get('admin'):
default_roles = get_default_roles()
admin_scopes = [
role['scopes'] for role in default_roles if role['name'] == 'admin'
]
requested_scopes.extend(admin_scopes[0])
requested_client_scope = spec.get('oauth_client_allowed_scopes')
if requested_client_scope is not None:
requested_scopes.extend(requested_client_scope)
if len(requested_scopes) > 0:
try:
_check_token_scopes(requested_scopes, user, None)
except ValueError as e:
raise web.HTTPError(400, str(e))
async def add_service(self, spec: dict) -> Service:
"""Add a new service and related objects to the database
Args:
spec (dict): The service specification
Raises:
web.HTTPError: Raise if the service is not created
Returns:
Service: Returns the service instance.
"""
self._check_service_model(spec)
self._check_service_scopes(spec)
service_name = spec["name"]
managed = bool(spec.get('command'))
if managed:
msg = f"Can not create managed service {service_name} at runtime"
self.log.error(msg, exc_info=True)
raise web.HTTPError(400, msg)
try:
new_service = self.service_from_spec(spec)
except Exception:
msg = f"Failed to create service {service_name}"
self.log.error(msg, exc_info=True)
raise web.HTTPError(400, msg)
if new_service is None:
raise web.HTTPError(400, f"Failed to create service {service_name}")
if new_service.api_token:
# Add api token to database
await self.app._add_tokens(
{new_service.api_token: new_service.name}, kind='service'
)
if new_service.url:
# Start polling for external service
service_status = await self.app.start_service(service_name, new_service)
if not service_status:
self.log.error(
'Failed to start service %s',
service_name,
exc_info=True,
)
if new_service.oauth_no_confirm:
oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list')
msg = f"Allowing service {new_service.name} to complete OAuth without confirmation on an authorization web page"
self.log.warning(msg)
oauth_no_confirm_list.add(new_service.oauth_client_id)
return new_service
@needs_scope('admin:services')
async def post(self, service_name: str):
data = self.get_json_body()
service, _ = self.find_service(service_name)
if service is not None:
raise web.HTTPError(409, f"Service {service_name} already exists")
if not data or not isinstance(data, dict):
raise web.HTTPError(400, "Invalid service data")
data['name'] = service_name
new_service = await self.add_service(data)
self.write(json.dumps(self.service_model(new_service)))
self.set_status(201)
@needs_scope('admin:services')
async def delete(self, service_name: str):
service, orm_service = self.find_service(service_name)
if service is None:
raise web.HTTPError(404, f"Service {service_name} does not exist")
if service.from_config:
raise web.HTTPError(
405, f"Service {service_name} is not modifiable at runtime"
)
try:
await self.remove_service(service, orm_service)
self.services.pop(service_name)
except Exception:
msg = f"Failed to remove service {service_name}"
self.log.error(msg, exc_info=True)
raise web.HTTPError(400, msg)
self.set_status(200)
async def remove_service(self, service: Service, orm_service: orm.Service) -> None:
"""Remove a service and all related objects from the database.
Args:
service (Service): the service object to be removed
orm_service (orm.Service): The `orm.Service` object linked
with `service`
"""
if service.managed:
await service.stop()
if service.oauth_client:
self.oauth_provider.remove_client(service.oauth_client_id)
if orm_service._server_id is not None:
orm_server = (
self.db.query(orm.Server).filter_by(id=orm_service._server_id).first()
)
if orm_server is not None:
self.db.delete(orm_server)
if service.oauth_no_confirm:
oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list')
oauth_no_confirm_list.discard(service.oauth_client_id)
self.db.delete(orm_service)
self.db.commit()
def service_from_spec(self, spec) -> Optional[Service]:
"""Create service from api request"""
service = self.app.service_from_spec(spec, from_config=False)
self.db.commit()
return service
def find_service(
self, name: str
) -> Tuple[Optional[Service], Optional[orm.Service]]:
"""Get a service by name
return None if no such service
"""
orm_service = orm.Service.find(db=self.db, name=name)
if orm_service is not None:
service = self.services.get(name)
return service, orm_service
return (None, None)
default_handlers = [
(r"/api/services", ServiceListAPIHandler),

View File

@@ -2,12 +2,14 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import asyncio
import inspect
import json
from datetime import datetime, timedelta, timezone
from async_generator import aclosing
from dateutil.parser import parse as parse_date
from sqlalchemy import func, or_
from sqlalchemy.orm import joinedload, selectinload
from tornado import web
from tornado.iostream import StreamClosedError
@@ -89,6 +91,10 @@ class UserListAPIHandler(APIHandler):
# post_filter
post_filter = None
# starting query
# fetch users and groups, which will be used for filters
query = self.db.query(orm.User).outerjoin(orm.Group, orm.User.groups)
if state_filter in {"active", "ready"}:
# only get users with active servers
# an 'active' Spawner has a server record in the database
@@ -96,9 +102,9 @@ class UserListAPIHandler(APIHandler):
# it may still be in a pending start/stop state.
# join filters out users with no Spawners
query = (
self.db.query(orm.User)
query
# join filters out any Users with no Spawners
.join(orm.Spawner)
.join(orm.Spawner, orm.User._orm_spawners)
# this implicitly gets Users with *any* active server
.filter(orm.Spawner.server != None)
)
@@ -113,9 +119,8 @@ class UserListAPIHandler(APIHandler):
# this is the complement to the above query.
# how expensive is this with lots of servers?
query = (
self.db.query(orm.User)
.outerjoin(orm.Spawner)
.outerjoin(orm.Server)
query.outerjoin(orm.Spawner, orm.User._orm_spawners)
.outerjoin(orm.Server, orm.Spawner.server)
.group_by(orm.User.id)
.having(func.count(orm.Server.id) == 0)
)
@@ -123,7 +128,16 @@ class UserListAPIHandler(APIHandler):
raise web.HTTPError(400, "Unrecognized state filter: %r" % state_filter)
else:
# no filter, return all users
query = self.db.query(orm.User)
query = query.outerjoin(orm.Spawner, orm.User._orm_spawners).outerjoin(
orm.Server, orm.Spawner.server
)
# apply eager load options
query = query.options(
selectinload(orm.User.roles),
selectinload(orm.User.groups),
joinedload(orm.User._orm_spawners),
)
sub_scope = self.parsed_scopes['list:users']
if sub_scope != scopes.Scope.ALL:
@@ -710,19 +724,32 @@ class SpawnProgressAPIHandler(APIHandler):
# - spawner not running at all
# - spawner failed
# - spawner pending start (what we expect)
url = url_path_join(user.url, url_escape_path(server_name), '/')
ready_event = {
'progress': 100,
'ready': True,
'message': f"Server ready at {url}",
'html_message': 'Server ready at <a href="{0}">{0}</a>'.format(url),
'url': url,
}
failed_event = {'progress': 100, 'failed': True, 'message': "Spawn failed"}
async def get_ready_event():
url = url_path_join(user.url, url_escape_path(server_name), '/')
ready_event = {
'progress': 100,
'ready': True,
'message': f"Server ready at {url}",
'html_message': 'Server ready at <a href="{0}">{0}</a>'.format(url),
'url': url,
}
original_ready_event = ready_event.copy()
if spawner.progress_ready_hook:
try:
ready_event = spawner.progress_ready_hook(spawner, ready_event)
if inspect.isawaitable(ready_event):
ready_event = await ready_event
except Exception as e:
self.log.exception(f"Error in ready_event hook: {e}")
ready_event = original_ready_event
return ready_event
if spawner.ready:
# spawner already ready. Trigger progress-completion immediately
self.log.info("Server %s is already started", spawner._log_name)
ready_event = await get_ready_event()
await self.send_event(ready_event)
return
@@ -766,6 +793,7 @@ class SpawnProgressAPIHandler(APIHandler):
if spawner.ready:
# spawner is ready, signal completion and redirect
self.log.info("Server %s is ready", spawner._log_name)
ready_event = await get_ready_event()
await self.send_event(ready_event)
else:
# what happened? Maybe spawn failed?

View File

@@ -21,6 +21,7 @@ from functools import partial
from getpass import getuser
from operator import itemgetter
from textwrap import dedent
from typing import Optional
from urllib.parse import unquote, urlparse, urlunparse
if sys.version_info[:2] < (3, 3):
@@ -32,6 +33,7 @@ from dateutil.parser import parse as parse_date
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
from jupyter_telemetry.eventlog import EventLog
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.orm import joinedload
from tornado import gen, web
from tornado.httpclient import AsyncHTTPClient
from tornado.ioloop import IOLoop, PeriodicCallback
@@ -91,6 +93,8 @@ from .utils import (
maybe_future,
print_ps_info,
print_stacks,
subdomain_hook_idna,
subdomain_hook_legacy,
url_path_join,
)
@@ -769,6 +773,72 @@ class JupyterHub(Application):
return ''
return urlparse(self.subdomain_host).hostname
subdomain_hook = Union(
[Callable(), Unicode()],
default_value="idna",
config=True,
help="""
Hook for constructing subdomains for users and services.
Only used when `JupyterHub.subdomain_host` is set.
There are two predefined hooks, which can be selected by name:
- 'legacy' (deprecated)
- 'idna' (default, more robust. No change for _most_ usernames)
Otherwise, should be a function which must not be async.
A custom subdomain_hook should have the signature:
def subdomain_hook(name, domain, kind) -> str:
...
and should return a unique, valid domain name for all usernames.
- `name` is the original name, which may need escaping to be safe as a domain name label
- `domain` is the domain of the Hub itself
- `kind` will be one of 'user' or 'service'
JupyterHub itself puts very little limit on usernames
to accommodate a wide variety of Authenticators,
but your identity provider is likely much more strict,
allowing you to make assumptions about the name.
The default behavior is to have all services
on a single `services.{domain}` subdomain,
and each user on `{username}.{domain}`.
This is the 'legacy' scheme,
and doesn't work for all usernames.
The 'idna' scheme is a new scheme that should produce a valid domain name for any user,
using IDNA encoding for unicode usernames, and a truncate-and-hash approach for
any usernames that can't be easily encoded into a domain component.
.. versionadded:: 5.0
""",
)
@default("subdomain_hook")
def _default_subdomain_hook(self):
return subdomain_hook_idna
@validate("subdomain_hook")
def _subdomain_hook(self, proposal):
# shortcut `subdomain_hook = "idna"` config
hook = proposal.value
if hook == "idna":
return subdomain_hook_idna
if hook == "legacy":
if self.subdomain_host:
self.log.warning(
"Using deprecated 'legacy' subdomain hook. JupyterHub.subdomain_hook = 'idna' is the new default, added in JupyterHub 5."
)
return subdomain_hook_legacy
if not callable(hook):
raise ValueError(
f"subdomain_hook must be 'idna', 'legacy', or a callable, got {hook!r}"
)
return hook
logo_file = Unicode(
'',
help="Specify path to a logo image to override the Jupyter logo in the banner.",
@@ -2073,21 +2143,20 @@ class JupyterHub(Application):
TOTAL_USERS.set(total_users)
async def _get_or_create_user(self, username):
async def _get_or_create_user(self, username, hint):
"""Create user if username is found in config but user does not exist"""
if not (await maybe_future(self.authenticator.check_allowed(username, None))):
raise ValueError(
"Username %r is not in Authenticator.allowed_users" % username
)
user = orm.User.find(self.db, name=username)
if user is None:
if not self.authenticator.validate_username(username):
raise ValueError("Username %r is not valid" % username)
self.log.info(f"Creating user {username}")
self.log.info(f"Creating user {username} found in {hint}")
user = orm.User(name=username)
self.db.add(user)
roles.assign_default_roles(self.db, entity=user)
self.db.commit()
f = self.authenticator.add_user(user)
if f:
await maybe_future(f)
return user
async def init_groups(self):
@@ -2096,7 +2165,9 @@ class JupyterHub(Application):
if self.authenticator.manage_groups and self.load_groups:
raise ValueError("Group management has been offloaded to the authenticator")
for name, contents in self.load_groups.items():
self.log.debug("Loading group %s", name)
group = orm.Group.find(db, name)
if group is None:
@@ -2112,9 +2183,11 @@ class JupyterHub(Application):
if 'users' in contents:
for username in contents['users']:
username = self.authenticator.normalize_username(username)
user = await self._get_or_create_user(username)
user = await self._get_or_create_user(
username, hint=f"group: {name}"
)
if group not in user.groups:
self.log.debug(f"Adding user {username} to group {name}")
self.log.debug(f"Adding user {username} to group {name}")
group.users.append(user)
if 'properties' in contents:
@@ -2142,8 +2215,9 @@ class JupyterHub(Application):
roles_with_new_permissions = []
for role_spec in self.load_roles:
role_name = role_spec['name']
self.log.debug("Loading role %s", role_name)
if role_name in default_roles_dict:
self.log.debug(f"Overriding default role {role_name}")
self.log.debug("Overriding default role %s", role_name)
# merge custom role spec with default role spec when overriding
# so the new role can be partially defined
default_role_spec = default_roles_dict.pop(role_name)
@@ -2230,34 +2304,33 @@ class JupyterHub(Application):
for name in role_spec[kind]:
if kind == 'users':
name = self.authenticator.normalize_username(name)
if not (
await maybe_future(
self.authenticator.check_allowed(name, None)
)
):
raise ValueError(
f"Username {name} is not in Authenticator.allowed_users"
)
Class = orm.get_class(kind)
orm_obj = Class.find(db, name)
if orm_obj is not None:
orm_role_bearers.append(orm_obj)
else:
self.log.info(
f"Found unexisting {kind} {name} in role definition {role_name}"
)
if kind == 'users':
orm_obj = await self._get_or_create_user(name)
orm_obj = await self._get_or_create_user(
name, hint=f"role: {role_name}"
)
orm_role_bearers.append(orm_obj)
elif kind == 'groups':
self.log.info(
f"Creating group {name} found in role: {role_name}"
)
group = orm.Group(name=name)
db.add(group)
db.commit()
orm_role_bearers.append(group)
else:
elif kind == "services":
raise ValueError(
f"{kind} {name} defined in config role definition {role_name} but not present in database"
f"Found undefined service {name} in role {role_name}. Define it first in c.JupyterHub.services."
)
else:
# this can't happen now, but keep the `else` in case we introduce a problem
# in the declaration of `kinds` above
raise ValueError(f"Unhandled role member kind: {kind}")
# Ensure all with admin role have admin flag
if role_name == 'admin':
orm_obj.admin = True
@@ -2279,22 +2352,53 @@ class JupyterHub(Application):
db.commit()
if self.authenticator.allowed_users:
self.log.debug(
f"Assigning {len(self.authenticator.allowed_users)} allowed_users to the user role"
)
allowed_users = db.query(orm.User).filter(
user_role = orm.Role.find(db, "user")
self.log.debug("Assigning allowed_users to the user role")
# query only those that need the user role _and don't have it_
needs_user_role = db.query(orm.User).filter(
orm.User.name.in_(self.authenticator.allowed_users)
& ~orm.User.roles.any(id=user_role.id)
)
for user in allowed_users:
roles.grant_role(db, user, 'user')
if self.log.isEnabledFor(logging.DEBUG):
# filter on isEnabledFor to skip the extra `count()` query if we aren't going to log it
self.log.debug(
f"Assigning {needs_user_role.count()} allowed_users to the user role"
)
for user in needs_user_role:
roles.grant_role(db, user, user_role)
admin_role = orm.Role.find(db, 'admin')
for kind in admin_role_objects:
Class = orm.get_class(kind)
for admin_obj in db.query(Class).filter_by(admin=True):
# sync obj.admin with admin role
# query only those objects that do not match config
# to avoid expensive query for no-op updates
# always: in admin role sets admin = True
for is_admin in db.query(Class).filter(
(Class.admin == False) & Class.roles.any(id=admin_role.id)
):
self.log.info(f"Setting admin=True on {is_admin}")
is_admin.admin = True
# iterate over users with admin=True
# who are not in the admin role.
for not_admin_obj in db.query(Class).filter(
(Class.admin == True) & ~Class.roles.any(id=admin_role.id)
):
if has_admin_role_spec[kind]:
admin_obj.admin = admin_role in admin_obj.roles
# role membership specified exactly in config,
# already populated above.
# make sure user.admin matches admin role
# setting .admin=False for anyone no longer in admin role
self.log.warning(f"Removing admin=True from {not_admin_obj}")
not_admin_obj.admin = False
else:
roles.grant_role(db, admin_obj, 'admin')
# no admin role membership declared,
# populate admin role from admin attribute (the old way, only additive)
roles.grant_role(db, not_admin_obj, admin_role)
db.commit()
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
if getattr(self, '_rbac_upgrade', False):
@@ -2317,20 +2421,12 @@ class JupyterHub(Application):
for token, name in token_dict.items():
if kind == 'user':
name = self.authenticator.normalize_username(name)
if not (
await maybe_future(self.authenticator.check_allowed(name, None))
):
raise ValueError(
"Token user name %r is not in Authenticator.allowed_users"
% name
)
if not self.authenticator.validate_username(name):
raise ValueError("Token user name %r is not valid" % name)
if kind == 'service':
if not any(service["name"] == name for service in self.services):
if not any(service_name == name for service_name in self._service_map):
self.log.warning(
"Warning: service '%s' not in services, creating implicitly. It is recommended to register services using services list."
% name
f"service {name} not in services, creating implicitly. It is recommended to register services using services list."
)
orm_token = orm.APIToken.find(db, token)
if orm_token is None:
@@ -2391,127 +2487,234 @@ class JupyterHub(Application):
)
pc.start()
def init_services(self):
self._service_map.clear()
def service_from_orm(
self,
orm_service: orm.Service,
) -> Service:
"""Create the service instance and related objects from
ORM data.
Args:
orm_service (orm.Service): The `orm.Service` object
Returns:
Service: the created service
"""
name = orm_service.name
if self.domain:
domain = 'services.' + self.domain
parsed = urlparse(self.subdomain_host)
host = f'{parsed.scheme}://services.{parsed.netloc}'
parsed_host = urlparse(self.subdomain_host)
domain = self.subdomain_hook(name, self.domain, kind="service")
host = f"{parsed_host.scheme}://{domain}"
if parsed_host.port:
host = f"{host}:{parsed_host.port}"
else:
domain = host = ''
for spec in self.services:
if 'name' not in spec:
raise ValueError('service spec must have a name: %r' % spec)
name = spec['name']
# get/create orm
orm_service = orm.Service.find(self.db, name=name)
if orm_service is None:
# not found, create a new one
orm_service = orm.Service(name=name)
self.db.add(orm_service)
if spec.get('admin', False):
self.log.warning(
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
" You can assign now assign roles via `JupyterHub.load_roles` configuration."
" If you specify services in the admin role configuration, "
"the Service admin flag will be ignored."
service = Service(
parent=self,
app=self,
base_url=self.base_url,
db=self.db,
orm=orm_service,
roles=orm_service.roles,
domain=domain,
host=host,
hub=self.hub,
)
traits = service.traits(input=True)
for key, trait in traits.items():
if not trait.metadata.get("in_db", True):
continue
orm_value = getattr(orm_service, key)
if orm_value is not None:
setattr(service, key, orm_value)
if orm_service.oauth_client is not None:
service.oauth_client_id = orm_service.oauth_client.identifier
service.oauth_redirect_uri = orm_service.oauth_client.redirect_uri
self._service_map[name] = service
return service
def service_from_spec(
self,
spec: Dict,
from_config=True,
) -> Optional[Service]:
"""Create the service instance and related objects from
config data.
Args:
spec (Dict): The spec of service, defined in the config file.
from_config (bool, optional): `True` if the service will be created
from the config file, `False` if it is created from REST API.
Defaults to `True`.
Returns:
Optional[Service]: The created service
"""
if 'name' not in spec:
raise ValueError(f'service spec must have a name: {spec}')
name = spec['name']
if self.domain:
parsed_host = urlparse(self.subdomain_host)
domain = self.subdomain_hook(name, self.domain, kind="service")
host = f"{parsed_host.scheme}://{domain}"
if parsed_host.port:
host = f"{host}:{parsed_host.port}"
else:
domain = host = ''
# get/create orm
orm_service = orm.Service.find(self.db, name=name)
if orm_service is None:
# not found, create a new one
orm_service = orm.Service(name=name, from_config=from_config)
self.db.add(orm_service)
if spec.get('admin', False):
self.log.warning(
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
" You can assign now assign roles via `JupyterHub.load_roles` configuration."
" If you specify services in the admin role configuration, "
"the Service admin flag will be ignored."
)
roles.update_roles(self.db, entity=orm_service, roles=['admin'])
else:
# Do nothing if the config file tries to modify a API-base service
# or vice versa.
if orm_service.from_config != from_config:
if from_config:
self.log.error(
f"The service {name} from the config file is trying to modify a runtime-created service with the same name"
)
roles.update_roles(self.db, entity=orm_service, roles=['admin'])
orm_service.admin = spec.get('admin', False)
self.db.commit()
service = Service(
parent=self,
app=self,
base_url=self.base_url,
db=self.db,
orm=orm_service,
roles=orm_service.roles,
domain=domain,
host=host,
hub=self.hub,
else:
self.log.error(
f"The runtime-created service {name} is trying to modify a config-based service with the same name"
)
return
orm_service.admin = spec.get('admin', False)
self.db.commit()
service = Service(
parent=self,
app=self,
base_url=self.base_url,
db=self.db,
orm=orm_service,
roles=orm_service.roles,
domain=domain,
host=host,
hub=self.hub,
)
traits = service.traits(input=True)
for key, value in spec.items():
trait = traits.get(key)
if trait is None:
raise AttributeError("No such service field: %s" % key)
setattr(service, key, value)
# also set the value on the orm object
# unless it's marked as not in the db
# (e.g. on the oauth object)
if trait.metadata.get("in_db", True):
setattr(orm_service, key, value)
if service.api_token:
self.service_tokens[service.api_token] = service.name
elif service.managed:
# generate new token
# TODO: revoke old tokens?
service.api_token = service.orm.new_api_token(note="generated at startup")
if service.url:
parsed = urlparse(service.url)
if parsed.scheme not in {"http", "https"}:
raise ValueError(
f"Unsupported scheme in URL for service {name}: {service.url}. Must be http[s]"
)
port = None
if parsed.port is not None:
port = parsed.port
elif parsed.scheme == 'http':
port = 80
elif parsed.scheme == 'https':
port = 443
server = service.orm.server = orm.Server(
proto=parsed.scheme,
ip=parsed.hostname,
port=port,
cookie_name=service.oauth_client_id,
base_url=service.prefix,
)
self.db.add(server)
else:
service.orm.server = None
traits = service.traits(input=True)
for key, value in spec.items():
if key not in traits:
raise AttributeError("No such service field: %s" % key)
setattr(service, key, value)
if service.api_token:
self.service_tokens[service.api_token] = service.name
elif service.managed:
# generate new token
# TODO: revoke old tokens?
service.api_token = service.orm.new_api_token(
note="generated at startup"
)
if service.url:
parsed = urlparse(service.url)
if parsed.port is not None:
port = parsed.port
elif parsed.scheme == 'http':
port = 80
elif parsed.scheme == 'https':
port = 443
server = service.orm.server = orm.Server(
proto=parsed.scheme,
ip=parsed.hostname,
port=port,
cookie_name=service.oauth_client_id,
base_url=service.prefix,
)
self.db.add(server)
else:
service.orm.server = None
if service.oauth_available:
allowed_scopes = set()
if service.oauth_client_allowed_scopes:
allowed_scopes.update(service.oauth_client_allowed_scopes)
if service.oauth_roles:
if not allowed_scopes:
# DEPRECATED? It's still convenient and valid,
# e.g. 'admin'
allowed_roles = list(
self.db.query(orm.Role).filter(
orm.Role.name.in_(service.oauth_roles)
)
if service.oauth_available:
allowed_scopes = set()
if service.oauth_client_allowed_scopes:
allowed_scopes.update(service.oauth_client_allowed_scopes)
if service.oauth_roles:
if not allowed_scopes:
# DEPRECATED? It's still convenient and valid,
# e.g. 'admin'
allowed_roles = list(
self.db.query(orm.Role).filter(
orm.Role.name.in_(service.oauth_roles)
)
allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
else:
self.log.warning(
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
f" using oauth_client_allowed_scopes={allowed_scopes}."
)
oauth_client = self.oauth_provider.add_client(
client_id=service.oauth_client_id,
client_secret=service.api_token,
redirect_uri=service.oauth_redirect_uri,
description="JupyterHub service %s" % service.name,
)
service.orm.oauth_client = oauth_client
# add access-scopes, derived from OAuthClient itself
allowed_scopes.update(scopes.access_scopes(oauth_client))
oauth_client.allowed_scopes = sorted(allowed_scopes)
)
allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
else:
self.log.warning(
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
f" using oauth_client_allowed_scopes={allowed_scopes}."
)
oauth_client = self.oauth_provider.add_client(
client_id=service.oauth_client_id,
client_secret=service.api_token,
redirect_uri=service.oauth_redirect_uri,
description="JupyterHub service %s" % service.name,
)
service.orm.oauth_client = oauth_client
# add access-scopes, derived from OAuthClient itself
allowed_scopes.update(scopes.access_scopes(oauth_client))
oauth_client.allowed_scopes = sorted(allowed_scopes)
else:
if service.oauth_client:
self.db.delete(service.oauth_client)
self._service_map[name] = service
return service
def init_services(self):
self._service_map.clear()
for spec in self.services:
self.service_from_spec(spec, from_config=True)
for service_orm in self.db.query(orm.Service):
if service_orm.from_config:
# delete config-based services from db
# that are not in current config file:
if service_orm.name not in self._service_map:
self.db.delete(service_orm)
else:
if service.oauth_client:
self.db.delete(service.oauth_client)
self.service_from_orm(service_orm)
self._service_map[name] = service
# delete services from db not in service config:
for service in self.db.query(orm.Service):
if service.name not in self._service_map:
self.db.delete(service)
self.db.commit()
async def check_services_health(self):
"""Check connectivity of all services"""
for name, service in self._service_map.items():
if not service.url:
# no URL to check, nothing to do
continue
try:
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
@@ -2628,19 +2831,22 @@ class JupyterHub(Application):
# Server objects can be associated with either a Spawner or a Service,
# we are only interested in the ones associated with a Spawner
check_futures = []
for orm_server in db.query(orm.Server):
orm_spawner = orm_server.spawner
if not orm_spawner:
# check for orphaned Server rows
# this shouldn't happen if we've got our sqlachemy right
if not orm_server.service:
self.log.warning("deleting orphaned server %s", orm_server)
self.db.delete(orm_server)
self.db.commit()
continue
for orm_user, orm_spawner in (
self.db.query(orm.User, orm.Spawner)
# join filters out any Users with no Spawners
.join(orm.Spawner, orm.User._orm_spawners)
# this gets Users with *any* active server
.filter(orm.Spawner.server != None)
# pre-load relationships to avoid O(N active servers) queries
.options(
joinedload(orm.User._orm_spawners),
joinedload(orm.Spawner.server),
)
):
# instantiate Spawner wrapper and check if it's still alive
# spawner should be running
user = self.users[orm_spawner.user]
user = self.users[orm_user]
spawner = user.spawners[orm_spawner.name]
self.log.debug("Loading state for %s from db", spawner._log_name)
# signal that check is pending to avoid race conditions
@@ -2689,7 +2895,6 @@ class JupyterHub(Application):
for user in self.users.values():
for spawner in user.spawners.values():
oauth_client_ids.add(spawner.oauth_client_id)
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
if oauth_client.identifier not in oauth_client_ids:
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
@@ -2796,6 +3001,7 @@ class JupyterHub(Application):
static_path=os.path.join(self.data_files_path, 'static'),
static_url_prefix=url_path_join(self.hub.base_url, 'static/'),
static_handler_class=CacheControlStaticFilesHandler,
subdomain_hook=self.subdomain_hook,
template_path=self.template_paths,
template_vars=self.template_vars,
jinja2_env=jinja_env,
@@ -3132,6 +3338,72 @@ class JupyterHub(Application):
await self.proxy.check_routes(self.users, self._service_map, routes)
async def start_service(
self,
service_name: str,
service: Service,
ssl_context: Optional[ssl.SSLContext] = None,
) -> bool:
"""Start a managed service or poll for external service
Args:
service_name (str): Name of the service.
service (Service): The service object.
Returns:
boolean: Returns `True` if the service is started successfully,
returns `False` otherwise.
"""
if ssl_context is None:
ssl_context = make_ssl_context(
self.internal_ssl_key,
self.internal_ssl_cert,
cafile=self.internal_ssl_ca,
purpose=ssl.Purpose.CLIENT_AUTH,
)
msg = f'{service_name} at {service.url}' if service.url else service_name
if service.managed:
self.log.info("Starting managed service %s", msg)
try:
await service.start()
except Exception as e:
self.log.critical(
"Failed to start service %s", service_name, exc_info=True
)
return False
else:
self.log.info("Adding external service %s", msg)
if service.url:
tries = 10 if service.managed else 1
for i in range(tries):
try:
await Server.from_orm(service.orm.server).wait_up(
http=True, timeout=1, ssl_context=ssl_context
)
except AnyTimeoutError:
if service.managed:
status = await service.spawner.poll()
if status is not None:
self.log.error(
"Service %s exited with status %s",
service_name,
status,
)
return False
else:
return True
else:
self.log.error(
"Cannot connect to %s service %s at %s. Is it running?",
service.kind,
service_name,
service.url,
)
return False
return True
async def start(self):
"""Start the whole thing"""
self.io_loop = loop = IOLoop.current()
@@ -3217,55 +3489,29 @@ class JupyterHub(Application):
# start the service(s)
for service_name, service in self._service_map.items():
msg = f'{service_name} at {service.url}' if service.url else service_name
if service.managed:
self.log.info("Starting managed service %s", msg)
try:
await service.start()
except Exception as e:
self.log.critical(
"Failed to start service %s", service_name, exc_info=True
)
service_ready = await self.start_service(service_name, service, ssl_context)
if not service_ready:
if service.from_config:
# Stop the application if a config-based service failed to start.
self.exit(1)
else:
self.log.info("Adding external service %s", msg)
if service.url:
tries = 10 if service.managed else 1
for i in range(tries):
try:
await Server.from_orm(service.orm.server).wait_up(
http=True, timeout=1, ssl_context=ssl_context
)
except AnyTimeoutError:
if service.managed:
status = await service.spawner.poll()
if status is not None:
self.log.error(
"Service %s exited with status %s",
service_name,
status,
)
break
else:
break
else:
# Only warn for database-based service, so that admin can connect
# to hub to remove the service.
self.log.error(
"Cannot connect to %s service %s at %s. Is it running?",
service.kind,
"Failed to reach externally managed service %s",
service_name,
service.url,
exc_info=True,
)
await self.proxy.check_routes(self.users, self._service_map)
if self.service_check_interval and any(
s.url for s in self._service_map.values()
):
pc = PeriodicCallback(
# Check services health
self._check_services_health_callback = None
if self.service_check_interval:
self._check_services_health_callback = PeriodicCallback(
self.check_services_health, 1e3 * self.service_check_interval
)
pc.start()
self._check_services_health_callback.start()
if self.last_activity_interval:
pc = PeriodicCallback(

View File

@@ -157,6 +157,25 @@ class Authenticator(LoggingConfigurable):
"""
).tag(config=True)
otp_prompt = Any(
"OTP:",
help="""
The prompt string for the extra OTP (One Time Password) field.
.. versionadded:: 5.0
""",
).tag(config=True)
request_otp = Bool(
False,
config=True,
help="""
Prompt for OTP (One Time Password) in the login form.
.. versionadded:: 5.0
""",
)
_deprecated_aliases = {
"whitelist": ("allowed_users", "1.2"),
"blacklist": ("blocked_users", "1.2"),
@@ -485,6 +504,8 @@ class Authenticator(LoggingConfigurable):
- `authenticate` turns formdata into a username
- `normalize_username` normalizes the username
- `check_allowed` checks against the allowed usernames
- `check_blocked_users` check against the blocked usernames
- `is_admin` check if a user is an admin
.. versionchanged:: 0.8
return dict instead of username
@@ -603,8 +624,7 @@ class Authenticator(LoggingConfigurable):
The Authenticator may return a dict instead, which MUST have a
key `name` holding the username, and MAY have additional keys:
- `auth_state`, a dictionary of of auth state that will be
persisted;
- `auth_state`, a dictionary of auth state that will be persisted;
- `admin`, the admin setting value for the user
- `groups`, the list of group names the user should be a member of,
if Authenticator.manage_groups is True.
@@ -1103,9 +1123,16 @@ class PAMAuthenticator(LocalAuthenticator):
Return None otherwise.
"""
username = data['username']
password = data["password"]
if "otp" in data:
# OTP given, pass as tuple (requires pamela 1.1)
password = (data["password"], data["otp"])
try:
pamela.authenticate(
username, data['password'], service=self.service, encoding=self.encoding
username,
password,
service=self.service,
encoding=self.encoding,
)
except pamela.PAMError as e:
if handler is not None:

View File

@@ -236,11 +236,13 @@ class BaseHandler(RequestHandler):
def check_xsrf_cookie(self):
try:
return super().check_xsrf_cookie()
except Exception as e:
# ensure _juptyerhub_user is defined on rejected requests
except web.HTTPError as e:
# ensure _jupyterhub_user is defined on rejected requests
if not hasattr(self, "_jupyterhub_user"):
self._jupyterhub_user = None
self._resolve_roles_and_scopes()
# rewrite message because we use this on methods other than POST
e.log_message = e.log_message.replace("POST", self.request.method)
raise
@property
@@ -1443,6 +1445,12 @@ class UserUrlHandler(BaseHandler):
# accept token auth for API requests that are probably to non-running servers
_accept_token_auth = True
# don't consider these redirects 'activity'
# if the redirect is followed and the subsequent action taken,
# _that_ is activity
def _record_activity(self, obj, timestamp=None):
return False
def _fail_api_request(self, user_name='', server_name=''):
"""Fail an API request to a not-running server"""
self.log.warning(

View File

@@ -105,6 +105,7 @@ class LoginHandler(BaseHandler):
'next': self.get_argument('next', ''),
},
),
"authenticator": self.authenticator,
"xsrf": self.xsrf_token.decode('ascii'),
}
custom_html = Template(

View File

@@ -3,9 +3,11 @@ Prometheus metrics exported by JupyterHub
Read https://prometheus.io/docs/practices/naming/ for naming
conventions for metrics & labels. We generally prefer naming them
`jupyterhub_<noun>_<verb>_<type_suffix>`. So a histogram that's tracking
`<noun>_<verb>_<type_suffix>`. So a histogram that's tracking
the duration (in seconds) of servers spawning would be called
jupyterhub_server_spawn_duration_seconds.
server_spawn_duration_seconds.
A namespace prefix is always added, so this metric is accessed as
`jupyterhub_server_spawn_duration_seconds` by default.
We also create an Enum for each 'status' type label in every metric
we collect. This is to make sure that the metrics exist regardless
@@ -19,6 +21,8 @@ them manually here.
added ``jupyterhub_`` prefix to metric names.
"""
import os
from datetime import timedelta
from enum import Enum
@@ -30,49 +34,66 @@ from traitlets.config import LoggingConfigurable
from . import orm
from .utils import utcnow
metrics_prefix = os.getenv('JUPYTERHUB_METRICS_PREFIX', 'jupyterhub')
REQUEST_DURATION_SECONDS = Histogram(
'jupyterhub_request_duration_seconds',
'request duration for all HTTP requests',
'request_duration_seconds',
'Request duration for all HTTP requests',
['method', 'handler', 'code'],
namespace=metrics_prefix,
)
SERVER_SPAWN_DURATION_SECONDS = Histogram(
'jupyterhub_server_spawn_duration_seconds',
'time taken for server spawning operation',
'server_spawn_duration_seconds',
'Time taken for server spawning operation',
['status'],
# Use custom bucket sizes, since the default bucket ranges
# are meant for quick running processes. Spawns can take a while!
buckets=[0.5, 1, 2.5, 5, 10, 15, 30, 60, 120, 180, 300, 600, float("inf")],
namespace=metrics_prefix,
)
RUNNING_SERVERS = Gauge(
'jupyterhub_running_servers', 'the number of user servers currently running'
'running_servers',
'The number of user servers currently running',
namespace=metrics_prefix,
)
TOTAL_USERS = Gauge('jupyterhub_total_users', 'total number of users')
TOTAL_USERS = Gauge(
'total_users',
'Total number of users',
namespace=metrics_prefix,
)
ACTIVE_USERS = Gauge(
'jupyterhub_active_users',
'number of users who were active in the given time period',
'active_users',
'Number of users who were active in the given time period',
['period'],
namespace=metrics_prefix,
)
CHECK_ROUTES_DURATION_SECONDS = Histogram(
'jupyterhub_check_routes_duration_seconds',
'check_routes_duration_seconds',
'Time taken to validate all routes in proxy',
namespace=metrics_prefix,
)
HUB_STARTUP_DURATION_SECONDS = Histogram(
'jupyterhub_hub_startup_duration_seconds', 'Time taken for Hub to start'
'hub_startup_duration_seconds',
'Time taken for Hub to start',
namespace=metrics_prefix,
)
INIT_SPAWNERS_DURATION_SECONDS = Histogram(
'jupyterhub_init_spawners_duration_seconds', 'Time taken for spawners to initialize'
'init_spawners_duration_seconds',
'Time taken for spawners to initialize',
namespace=metrics_prefix,
)
PROXY_POLL_DURATION_SECONDS = Histogram(
'jupyterhub_proxy_poll_duration_seconds',
'duration for polling all routes from proxy',
'proxy_poll_duration_seconds',
'Duration for polling all routes from proxy',
namespace=metrics_prefix,
)
@@ -97,9 +118,10 @@ for s in ServerSpawnStatus:
PROXY_ADD_DURATION_SECONDS = Histogram(
'jupyterhub_proxy_add_duration_seconds',
'duration for adding user routes to proxy',
'proxy_add_duration_seconds',
'Duration for adding user routes to proxy',
['status'],
namespace=metrics_prefix,
)
@@ -120,9 +142,10 @@ for s in ProxyAddStatus:
SERVER_POLL_DURATION_SECONDS = Histogram(
'jupyterhub_server_poll_duration_seconds',
'time taken to poll if server is running',
'server_poll_duration_seconds',
'Time taken to poll if server is running',
['status'],
namespace=metrics_prefix,
)
@@ -147,9 +170,10 @@ for s in ServerPollStatus:
SERVER_STOP_DURATION_SECONDS = Histogram(
'jupyterhub_server_stop_seconds',
'time taken for server stopping operation',
'server_stop_seconds',
'Time taken for server stopping operation',
['status'],
namespace=metrics_prefix,
)
@@ -170,9 +194,10 @@ for s in ServerStopStatus:
PROXY_DELETE_DURATION_SECONDS = Histogram(
'jupyterhub_proxy_delete_duration_seconds',
'duration for deleting user routes from proxy',
'proxy_delete_duration_seconds',
'Duration for deleting user routes from proxy',
['status'],
namespace=metrics_prefix,
)
@@ -239,7 +264,7 @@ class PeriodicMetricsCollector(LoggingConfigurable):
help="""
Enable active_users prometheus metric.
Populates a `jupyterhub_active_users` prometheus metric, with a label `period` that counts the time period
Populates a `active_users` prometheus metric, with a label `period` that counts the time period
over which these many users were active. Periods are 24h (24 hours), 7d (7 days) and 30d (30 days).
""",
config=True,

View File

@@ -668,6 +668,18 @@ class JupyterHubOAuthServer(WebApplicationServer):
self.db.commit()
return orm_client
def remove_client(self, client_id):
"""Remove a client by its id if it is existed."""
orm_client = (
self.db.query(orm.OAuthClient).filter_by(identifier=client_id).one_or_none()
)
if orm_client is not None:
self.db.delete(orm_client)
self.db.commit()
app_log.info("Removed client %s", client_id)
else:
app_log.warning("No such client %s", client_id)
def fetch_by_client_id(self, client_id):
"""Find a client by its id"""
client = self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()

View File

@@ -29,9 +29,9 @@ from sqlalchemy import (
)
from sqlalchemy.orm import (
Session,
backref,
declarative_base,
interfaces,
joinedload,
object_session,
relationship,
sessionmaker,
@@ -139,6 +139,9 @@ class Server(Base):
base_url = Column(Unicode(255), default='/')
cookie_name = Column(Unicode(255), default='cookie')
service = relationship("Service", back_populates="server", uselist=False)
spawner = relationship("Spawner", back_populates="server", uselist=False)
def __repr__(self):
return f"<Server({self.ip}:{self.port})>"
@@ -178,9 +181,11 @@ class Role(Base):
name = Column(Unicode(255), unique=True)
description = Column(Unicode(1023))
scopes = Column(JSONList, default=[])
users = relationship('User', secondary='user_role_map', backref='roles')
services = relationship('Service', secondary='service_role_map', backref='roles')
groups = relationship('Group', secondary='group_role_map', backref='roles')
users = relationship('User', secondary='user_role_map', back_populates='roles')
services = relationship(
'Service', secondary='service_role_map', back_populates='roles'
)
groups = relationship('Group', secondary='group_role_map', back_populates='roles')
def __repr__(self):
return "<{} {} ({}) - scopes: {}>".format(
@@ -213,15 +218,14 @@ class Group(Base):
__tablename__ = 'groups'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Unicode(255), unique=True)
users = relationship('User', secondary='user_group_map', backref='groups')
users = relationship('User', secondary='user_group_map', back_populates='groups')
properties = Column(JSONDict, default={})
roles = relationship(
'Role', secondary='group_role_map', back_populates='groups', lazy="selectin"
)
def __repr__(self):
return "<%s %s (%i users)>" % (
self.__class__.__name__,
self.name,
len(self.users),
)
return f"<{self.__class__.__name__} {self.name}>"
@classmethod
def find(cls, db, name):
@@ -258,8 +262,15 @@ class User(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Unicode(255), unique=True)
roles = relationship(
'Role',
secondary='user_role_map',
back_populates='users',
lazy="selectin",
)
_orm_spawners = relationship(
"Spawner", backref="user", cascade="all, delete-orphan"
"Spawner", back_populates="user", cascade="all, delete-orphan"
)
@property
@@ -270,9 +281,17 @@ class User(Base):
created = Column(DateTime, default=datetime.utcnow)
last_activity = Column(DateTime, nullable=True)
api_tokens = relationship("APIToken", backref="user", cascade="all, delete-orphan")
api_tokens = relationship(
"APIToken", back_populates="user", cascade="all, delete-orphan"
)
groups = relationship(
"Group",
secondary='user_group_map',
back_populates="users",
lazy="selectin",
)
oauth_codes = relationship(
"OAuthCode", backref="user", cascade="all, delete-orphan"
"OAuthCode", back_populates="user", cascade="all, delete-orphan"
)
cookie_id = Column(Unicode(255), default=new_token, nullable=False, unique=True)
# User.state is actually Spawner state
@@ -312,11 +331,13 @@ class Spawner(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
user = relationship("User", back_populates="_orm_spawners")
server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
server = relationship(
Server,
backref=backref('spawner', uselist=False),
back_populates="spawner",
lazy="joined",
single_parent=True,
cascade="all, delete-orphan",
)
@@ -338,7 +359,7 @@ class Spawner(Base):
)
oauth_client = relationship(
'OAuthClient',
backref=backref("spawner", uselist=False),
back_populates="spawner",
cascade="all, delete-orphan",
single_parent=True,
)
@@ -379,16 +400,39 @@ class Service(Base):
# common user interface:
name = Column(Unicode(255), unique=True)
admin = Column(Boolean(create_constraint=False), default=False)
roles = relationship(
'Role', secondary='service_role_map', back_populates='services', lazy="selectin"
)
url = Column(Unicode(2047), nullable=True)
oauth_client_allowed_scopes = Column(JSONList, nullable=True)
info = Column(JSONDict, nullable=True)
display = Column(Boolean, nullable=True)
oauth_no_confirm = Column(Boolean, nullable=True)
command = Column(JSONList, nullable=True)
cwd = Column(Unicode(4095), nullable=True)
environment = Column(JSONDict, nullable=True)
user = Column(Unicode(255), nullable=True)
from_config = Column(Boolean, default=True)
api_tokens = relationship(
"APIToken", backref="service", cascade="all, delete-orphan"
"APIToken", back_populates="service", cascade="all, delete-orphan"
)
# service-specific interface
_server_id = Column(Integer, ForeignKey('servers.id', ondelete='SET NULL'))
server = relationship(
Server,
backref=backref('service', uselist=False),
back_populates="service",
single_parent=True,
cascade="all, delete-orphan",
)
@@ -402,9 +446,10 @@ class Service(Base):
ondelete='SET NULL',
),
)
oauth_client = relationship(
'OAuthClient',
backref=backref("service", uselist=False),
back_populates="service",
cascade="all, delete-orphan",
single_parent=True,
)
@@ -543,7 +588,10 @@ class Hashed(Expiring):
`kind='user'` only returns API tokens for users
`kind='service'` only returns API tokens for services
"""
prefix_match = cls.find_prefix(db, token)
prefix_match = cls.find_prefix(db, token).options(
joinedload(cls.user), joinedload(cls.service)
)
for orm_token in prefix_match:
if orm_token.match(token):
return orm_token
@@ -579,6 +627,10 @@ class APIToken(Hashed, Base):
nullable=True,
)
user = relationship("User", back_populates="api_tokens")
service = relationship("Service", back_populates="api_tokens")
oauth_client = relationship("OAuthClient", back_populates="access_tokens")
id = Column(Integer, primary_key=True)
hashed = Column(Unicode(255), unique=True)
prefix = Column(Unicode(16), index=True)
@@ -784,12 +836,20 @@ class OAuthCode(Expiring, Base):
client_id = Column(
Unicode(255), ForeignKey('oauth_clients.identifier', ondelete='CASCADE')
)
client = relationship(
"OAuthClient",
back_populates="codes",
)
code = Column(Unicode(36))
expires_at = Column(Integer)
redirect_uri = Column(Unicode(1023))
session_id = Column(Unicode(255))
# state = Column(Unicode(1023))
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
user = relationship(
"User",
back_populates="oauth_codes",
)
scopes = Column(JSONList, default=[])
@@ -803,6 +863,10 @@ class OAuthCode(Expiring, Base):
db.query(cls)
.filter(cls.code == code)
.filter(or_(cls.expires_at == None, cls.expires_at >= cls.now()))
.options(
# load user with the code
joinedload(cls.user, innerjoin=True),
)
.first()
)
@@ -824,10 +888,22 @@ class OAuthClient(Base):
def client_id(self):
return self.identifier
access_tokens = relationship(
APIToken, backref='oauth_client', cascade='all, delete-orphan'
spawner = relationship(
"Spawner",
back_populates="oauth_client",
uselist=False,
)
service = relationship(
"Service",
back_populates="oauth_client",
uselist=False,
)
access_tokens = relationship(
APIToken, back_populates='oauth_client', cascade='all, delete-orphan'
)
codes = relationship(
OAuthCode, back_populates='client', cascade='all, delete-orphan'
)
codes = relationship(OAuthCode, backref='client', cascade='all, delete-orphan')
# these are the scopes an oauth client is allowed to request
# *not* the scopes of the client itself

View File

@@ -34,6 +34,7 @@ def get_default_roles():
'admin-ui',
'admin:users',
'admin:servers',
'admin:services',
'tokens',
'admin:groups',
'list:services',

View File

@@ -123,6 +123,10 @@ scope_definitions = {
'delete:groups': {
'description': "Delete groups.",
},
'admin:services': {
'description': 'Create, read, update, delete services, not including services defined from config files.',
'subscopes': ['list:services', 'read:services', 'read:roles:services'],
},
'list:services': {
'description': 'List services, including at least their names.',
'subscopes': ['read:services:name'],
@@ -435,7 +439,7 @@ def _expand_self_scope(username):
@lru_cache(maxsize=65535)
def _expand_scope(scope):
"""Returns a scope and all all subscopes
"""Returns a scope and all subscopes
Arguments:
scope (str): the scope to expand
@@ -845,6 +849,15 @@ def needs_scope(*scopes):
def scope_decorator(func):
@functools.wraps(func)
def _auth_func(self, *args, **kwargs):
if not self.current_user:
# not authenticated at all, fail with more generic message
# this is the most likely permission error - missing or mis-specified credentials,
# don't indicate that they have insufficient permissions.
raise web.HTTPError(
403,
"Missing or invalid credentials.",
)
sig = inspect.signature(func)
bound_sig = sig.bind(self, *args, **kwargs)
bound_sig.apply_defaults()
@@ -853,6 +866,11 @@ def needs_scope(*scopes):
self.expanded_scopes = {}
self.parsed_scopes = {}
try:
end_point = self.request.path
except AttributeError:
end_point = self.__name__
s_kwargs = {}
for resource in {'user', 'server', 'group', 'service'}:
resource_name = resource + '_name'
@@ -860,14 +878,10 @@ def needs_scope(*scopes):
resource_value = bound_sig.arguments[resource_name]
s_kwargs[resource] = resource_value
for scope in scopes:
app_log.debug("Checking access via scope %s", scope)
app_log.debug("Checking access to %s via scope %s", end_point, scope)
has_access = _check_scope_access(self, scope, **s_kwargs)
if has_access:
return func(self, *args, **kwargs)
try:
end_point = self.request.path
except AttributeError:
end_point = self.__name__
app_log.warning(
"Not authorizing access to {}. Requires any of [{}], not derived from scopes [{}]".format(
end_point, ", ".join(scopes), ", ".join(self.expanded_scopes)

View File

@@ -596,6 +596,9 @@ class HubAuth(SingletonConfigurable):
- in URL parameters: ?token=<token>
- in header: Authorization: token <token>
- in cookie (stored after oauth), if in_cookie is True
Args:
handler (tornado.web.RequestHandler): the current request handler
"""
user_token = handler.get_argument('token', '')
@@ -623,6 +626,9 @@ class HubAuth(SingletonConfigurable):
"""Get the jupyterhub session id
from the jupyterhub-session-id cookie.
Args:
handler (tornado.web.RequestHandler): the current request handler
"""
return handler.get_cookie('jupyterhub-session-id', '')
@@ -949,7 +955,11 @@ class HubOAuth(HubAuth):
handler.set_secure_cookie(self.cookie_name, access_token, **kwargs)
def clear_cookie(self, handler):
"""Clear the OAuth cookie"""
"""Clear the OAuth cookie
Args:
handler (tornado.web.RequestHandler): the current request handler
"""
handler.clear_cookie(self.cookie_name, path=self.base_url)

View File

@@ -180,9 +180,12 @@ class Service(LoggingConfigurable):
- user: str
The name of a system user to become.
If unspecified, run as the same user as the Hub.
"""
# inputs:
# traits tagged with `input=True` are accepted as input from configuration / API
# input traits are also persisted to the db UNLESS they are also tagged with `in_db=False`
name = Unicode(
help="""The name of the service.
@@ -205,7 +208,7 @@ class Service(LoggingConfigurable):
DEPRECATED in 3.0: use oauth_client_allowed_scopes
"""
).tag(input=True)
).tag(input=True, in_db=False)
oauth_client_allowed_scopes = List(
help="""OAuth allowed scopes.
@@ -225,7 +228,7 @@ class Service(LoggingConfigurable):
If unspecified, an API token will be generated for managed services.
"""
).tag(input=True)
).tag(input=True, in_db=False)
info = Dict(
help="""Provide a place to include miscellaneous information about the service,
@@ -310,7 +313,7 @@ class Service(LoggingConfigurable):
You shouldn't generally need to change this.
Default: `service-<name>`
"""
).tag(input=True)
).tag(input=True, in_db=False)
@default('oauth_client_id')
def _default_client_id(self):
@@ -331,7 +334,7 @@ class Service(LoggingConfigurable):
You shouldn't generally need to change this.
Default: `/services/:name/oauth_callback`
"""
).tag(input=True)
).tag(input=True, in_db=False)
@default('oauth_redirect_uri')
def _default_redirect_uri(self):
@@ -362,6 +365,14 @@ class Service(LoggingConfigurable):
def prefix(self):
return url_path_join(self.base_url, 'services', self.name + '/')
@property
def href(self):
"""Convenient 'href' to use for links to this service"""
if self.domain:
return f"//{self.domain}{self.prefix}"
else:
return self.prefix
@property
def proxy_spec(self):
if not self.server:
@@ -371,6 +382,11 @@ class Service(LoggingConfigurable):
else:
return self.server.base_url
@property
def from_config(self):
"""Is the service defined from config file?"""
return self.orm.from_config
def __repr__(self):
return "<{cls}(name={name}{managed})>".format(
cls=self.__class__.__name__,

View File

@@ -67,9 +67,10 @@ else:
from .app import SingleUserNotebookApp, main
# backward-compatibility
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
if SingleUserNotebookApp is not None:
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
__all__ = [

View File

@@ -6,7 +6,7 @@
.. versionchanged:: 2.0
Default app changed to launch `jupyter labhub`.
Use JUPYTERHUB_SINGLEUSER_APP=notebook.notebookapp.NotebookApp for the legacy 'classic' notebook server.
Use JUPYTERHUB_SINGLEUSER_APP='notebook' for the legacy 'classic' notebook server (requires notebook<7).
"""
import os
@@ -26,9 +26,27 @@ _app_shortcuts = {
JUPYTERHUB_SINGLEUSER_APP = _app_shortcuts.get(
JUPYTERHUB_SINGLEUSER_APP.replace("_", "-"), JUPYTERHUB_SINGLEUSER_APP
)
JUPYVERSE = JUPYTERHUB_SINGLEUSER_APP == "jupyverse"
if JUPYTERHUB_SINGLEUSER_APP:
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
if JUPYTERHUB_SINGLEUSER_APP in {"notebook", _app_shortcuts["notebook"]}:
# better error for notebook v7, which uses jupyter-server
# when the legacy notebook server is requested
try:
from notebook import __version__
except ImportError:
# will raise later
pass
else:
# check if this failed because of notebook v7
_notebook_major_version = int(__version__.split(".", 1)[0])
if _notebook_major_version >= 7:
raise ImportError(
f"JUPYTERHUB_SINGLEUSER_APP={JUPYTERHUB_SINGLEUSER_APP} is not valid with notebook>=7 (have notebook=={__version__}).\n"
f"Leave $JUPYTERHUB_SINGLEUSER_APP unspecified (or use the default JUPYTERHUB_SINGLEUSER_APP=jupyter-server), "
'and set `c.Spawner.default_url = "/tree"` to make notebook v7 the default UI.'
)
App = None if JUPYVERSE else import_item(JUPYTERHUB_SINGLEUSER_APP)
else:
App = None
_import_error = None
@@ -48,7 +66,10 @@ else:
raise _import_error
SingleUserNotebookApp = make_singleuser_app(App)
if App is None:
SingleUserNotebookApp = None
else:
SingleUserNotebookApp = make_singleuser_app(App)
def main():
@@ -75,4 +96,9 @@ def main():
if version_tuple >= (3, 1):
return SingleUserLabApp.launch_instance()
if JUPYVERSE:
from fps_auth_jupyterhub import launch
return launch()
return SingleUserNotebookApp.launch_instance()

View File

@@ -483,6 +483,11 @@ class JupyterHubSingleUser(ExtensionApp):
cfg.answer_yes = True
self.config.FileContentsManager.delete_to_trash = False
# load Spawner.notebook_dir configuration, if given
root_dir = os.getenv("JUPYTERHUB_ROOT_DIR", None)
if root_dir:
cfg.root_dir = os.path.expanduser(root_dir)
# load http server config from environment
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
if url.port:
@@ -639,7 +644,7 @@ class JupyterHubSingleUser(ExtensionApp):
disable_user_config = Bool()
@default("disable_user_config")
def _defaut_disable_user_config(self):
def _default_disable_user_config(self):
return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")
@classmethod

View File

@@ -274,8 +274,6 @@ class Spawner(LoggingConfigurable):
api_token = Unicode()
oauth_client_id = Unicode()
oauth_scopes = List(Unicode())
@property
def oauth_scopes(self):
warnings.warn(
@@ -840,6 +838,27 @@ class Spawner(LoggingConfigurable):
""",
).tag(config=True)
progress_ready_hook = Any(
help="""
An optional hook function that you can implement to modify the
ready event, which will be shown to the user on the spawn progress page when their server
is ready.
This can be set independent of any concrete spawner implementation.
This maybe a coroutine.
Example::
async def my_ready_hook(spawner, ready_event):
ready_event["html_message"] = f"Server {spawner.name} is ready for {spawner.user.name}"
return ready_event
c.Spawner.progress_ready_hook = my_ready_hook
"""
).tag(config=True)
pre_spawn_hook = Any(
help="""
An optional hook function that you can implement to do some
@@ -1735,7 +1754,7 @@ class LocalProcessSpawner(Spawner):
self.clear_state()
return 0
# We use pustil.pid_exists on windows
# We use psutil.pid_exists on windows
if os.name == 'nt':
alive = psutil.pid_exists(self.pid)
else:

View File

@@ -2,6 +2,7 @@
import json
import re
from unittest import mock
from urllib.parse import parse_qs, urlparse
import pytest
@@ -143,6 +144,53 @@ async def test_login_with_invalid_credantials(app, browser, username, pass_w):
await expect(browser).to_have_url(re.compile(".*/hub/login"))
@pytest.mark.parametrize("request_otp", [True, False])
async def test_login_otp(request, app, browser, username, request_otp):
def _reset():
app.authenticator.request_otp = False
request.addfinalizer(_reset)
app.authenticator.request_otp = request_otp
login_url = url_concat(
url_path_join(public_host(app), app.hub.base_url, "login"),
{"next": ujoin(public_url(app), "/hub/home/")},
)
await browser.goto(login_url)
# check for otp element
otp_label = browser.locator("label[for=otp_input]")
otp_input = browser.locator("input#otp_input")
if not request_otp:
# request_otp is False, no OTP prompt
assert await otp_label.count() == 0
assert await otp_input.count() == 0
return
await expect(otp_label).to_be_visible()
await expect(otp_input).to_be_visible()
await expect(otp_label).to_have_text(app.authenticator.otp_prompt)
# fill it out
await browser.get_by_label("Username:").fill(username)
await browser.get_by_label("Password:").fill(username)
await browser.get_by_label("OTP:").fill("otp!")
# submit form
with mock.patch(
"jupyterhub.tests.mocking.mock_authenticate",
spec=True,
return_value={"username": username},
) as mock_otp_auth:
await browser.get_by_role("button", name="Sign in").click()
expected_url = ujoin(public_url(app), "/hub/home/")
await expect(browser).to_have_url(expected_url)
# check that OTP was passed
assert mock_otp_auth.called
assert mock_otp_auth.call_args.args[0] == username
assert mock_otp_auth.call_args.args[1] == (username, "otp!")
# SPAWNING
@@ -985,7 +1033,7 @@ async def test_start_stop_server_on_admin_page(
async def click_spawn_page(browser, username):
"""spawn the server for one user via the Spawn page button, index = 0 or 1"""
spawn_btn_xpath = f'//a[contains(@href, "spawn/{username}")]/button[contains(@class, "secondary")]'
spawn_btn_xpath = f'//a[contains(@href, "spawn/{username}")]/button[contains(@class, "btn-light")]'
spawn_btn = browser.locator(spawn_btn_xpath)
await expect(spawn_btn).to_be_enabled()
async with browser.expect_navigation(url=f"**/user/{username}/"):
@@ -993,7 +1041,7 @@ async def test_start_stop_server_on_admin_page(
async def click_access_server(browser, username):
"""access to the server for users via the Access Server button"""
access_btn_xpath = f'//a[contains(@href, "user/{username}")]/button[contains(@class, "primary")]'
access_btn_xpath = f'//a[contains(@href, "user/{username}")]/button[contains(@class, "btn-primary")]'
access_btn = browser.locator(access_btn_xpath)
await expect(access_btn).to_be_enabled()
await access_btn.click()

View File

@@ -34,6 +34,7 @@ from subprocess import TimeoutExpired
from unittest import mock
from pytest import fixture, raises
from sqlalchemy import event
from tornado.httpclient import HTTPError
from tornado.platform.asyncio import AsyncIOMainLoop
@@ -166,7 +167,10 @@ async def cleanup_after(request, io_loop):
app = MockHub.instance()
if app.db_file.closed:
return
for uid, user in list(app.users.items()):
# cleanup users
for orm_user in app.db.query(orm.User):
user = app.users[orm_user]
for name, spawner in list(user.spawners.items()):
if spawner.active:
try:
@@ -176,10 +180,20 @@ async def cleanup_after(request, io_loop):
print(f"Stopping leftover server {spawner._log_name}")
await user.stop(name)
if user.name not in {'admin', 'user'}:
app.users.delete(uid)
app.users.delete(user.id)
# delete groups
for group in app.db.query(orm.Group):
app.db.delete(group)
# clear services
for name, service in app._service_map.items():
if service.managed:
service.stop()
for orm_service in app.db.query(orm.Service):
if orm_service.oauth_client:
app.oauth_provider.remove_client(orm_service.oauth_client_id)
app.db.delete(orm_service)
app._service_map.clear()
app.db.commit()
@@ -261,10 +275,7 @@ class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
poll_interval = 1
_mock_service_counter = 0
async def _mockservice(request, app, external=False, url=False):
async def _mockservice(request, app, name, external=False, url=False):
"""
Add a service to the application
@@ -280,9 +291,6 @@ async def _mockservice(request, app, external=False, url=False):
If True, register the service at a URL
(as opposed to headless, API-only).
"""
global _mock_service_counter
_mock_service_counter += 1
name = 'mock-service-%i' % _mock_service_counter
spec = {'name': name, 'command': mockservice_cmd, 'admin': True}
if url:
if app.internal_ssl:
@@ -329,22 +337,33 @@ async def _mockservice(request, app, external=False, url=False):
return service
_service_name_counter = 0
@fixture
async def mockservice(request, app):
def service_name():
global _service_name_counter
_service_name_counter += 1
name = f'test-service-{_service_name_counter}'
return name
@fixture
async def mockservice(request, app, service_name):
"""Mock a service with no external service url"""
yield await _mockservice(request, app, url=False)
yield await _mockservice(request, app, name=service_name, url=False)
@fixture
async def mockservice_external(request, app):
async def mockservice_external(request, app, service_name):
"""Mock an externally managed service (don't start anything)"""
yield await _mockservice(request, app, external=True, url=False)
yield await _mockservice(request, app, name=service_name, external=True, url=False)
@fixture
async def mockservice_url(request, app):
async def mockservice_url(request, app, service_name):
"""Mock a service with its own url to test external services"""
yield await _mockservice(request, app, url=True)
yield await _mockservice(request, app, name=service_name, url=True)
@fixture
@@ -484,3 +503,66 @@ def preserve_scopes():
scope_definitions = copy.deepcopy(scopes.scope_definitions)
yield scope_definitions
scopes.scope_definitions = scope_definitions
# collect db query counts and report the top N tests by db query count
@fixture(autouse=True)
def count_db_executions(request, record_property):
if 'app' in request.fixturenames:
app = request.getfixturevalue("app")
initial_count = app.db_query_count
yield
# populate property, collected later in pytest_terminal_summary
record_property("db_executions", app.db_query_count - initial_count)
elif 'db' in request.fixturenames:
# some use the 'db' fixture directly for one-off database tests
count = 0
engine = request.getfixturevalue("db").get_bind()
@event.listens_for(engine, "before_execute")
def before_execute(conn, clauseelement, multiparams, params, execution_options):
nonlocal count
count += 1
yield
record_property("db_executions", count)
else:
# nothing to do, still have to yield
yield
def pytest_terminal_summary(terminalreporter, exitstatus, config):
# collect db_executions property
# populated by the count_db_executions fixture
db_counts = {}
for report in terminalreporter.getreports(""):
properties = dict(report.user_properties)
db_executions = properties.get("db_executions", 0)
if db_executions:
db_counts[report.nodeid] = db_executions
total_queries = sum(db_counts.values())
if total_queries == 0:
# nothing to report (e.g. test subset)
return
n = min(10, len(db_counts))
terminalreporter.section(f"top {n} database queries")
terminalreporter.line(f"{total_queries:<6} (total)")
for nodeid in sorted(db_counts, key=db_counts.get, reverse=True)[:n]:
queries = db_counts[nodeid]
if queries:
terminalreporter.line(f"{queries:<6} {nodeid}")
@fixture
def service_data(service_name):
"""Data used to create service at runtime"""
return {
"name": service_name,
"oauth_client_id": f"service-{service_name}",
"api_token": f"api_token-{service_name}",
"oauth_redirect_uri": "http://127.0.0.1:5555/oauth_callback-from-api",
"oauth_no_confirm": True,
"oauth_client_allowed_scopes": ["inherit"],
"info": {'foo': 'bar'},
}

View File

@@ -30,6 +30,7 @@ class JupyterHubTestHandler(JupyterHandler):
info = {
"current_user": self.current_user,
"config": self.app.config,
"root_dir": self.contents_manager.root_dir,
"disable_user_config": getattr(self.app, "disable_user_config", None),
"settings": self.settings,
"config_file_paths": self.app.config_file_paths,

View File

@@ -35,6 +35,7 @@ from unittest import mock
from urllib.parse import urlparse
from pamela import PAMError
from sqlalchemy import event
from tornado.httputil import url_concat
from traitlets import Bool, Dict, default
@@ -308,6 +309,14 @@ class MockHub(JupyterHub):
self.db.delete(role)
self.db.commit()
# count db requests
self.db_query_count = 0
engine = self.db.get_bind()
@event.listens_for(engine, "before_execute")
def before_execute(conn, clauseelement, multiparams, params, execution_options):
self.db_query_count += 1
async def initialize(self, argv=None):
self.pid_file = NamedTemporaryFile(delete=False).name
self.db_file = NamedTemporaryFile()

View File

@@ -4,10 +4,12 @@ import json
import re
import sys
import uuid
from copy import deepcopy
from datetime import datetime, timedelta
from unittest import mock
from urllib.parse import quote, urlparse
from urllib.parse import parse_qs, quote, urlparse
import pytest
from pytest import fixture, mark
from tornado.httputil import url_concat
@@ -122,6 +124,41 @@ async def test_xsrf_check(app, username, method, path, xsrf_in_url):
assert r.status_code == 403
@mark.parametrize(
"auth, expected_message",
[
("", "Missing or invalid credentials"),
("cookie_no_xsrf", "'_xsrf' argument missing from GET"),
("cookie_xsrf_mismatch", "XSRF cookie does not match GET argument"),
("token_no_scope", "requires any of [list:users]"),
("cookie_no_scope", "requires any of [list:users]"),
],
)
async def test_permission_error_messages(app, user, auth, expected_message):
# 1. no credentials, should be 403 and not mention xsrf
url = public_url(app, path="hub/api/users")
kwargs = {}
kwargs["headers"] = headers = {}
kwargs["params"] = params = {}
if auth == "token_no_scope":
token = user.new_api_token()
headers["Authorization"] = f"Bearer {token}"
elif "cookie" in auth:
cookies = kwargs["cookies"] = await app.login_user(user.name)
if auth == "cookie_no_scope":
params["_xsrf"] = cookies["_xsrf"]
if auth == "cookie_xsrf_mismatch":
params["_xsrf"] = "somethingelse"
r = await async_requests.get(url, **kwargs)
assert r.status_code == 403
response = r.json()
message = response["message"]
assert expected_message in message
# --------------
# User API tests
# --------------
@@ -231,20 +268,22 @@ def max_page_limit(app):
@mark.user
@mark.role
@mark.parametrize(
"n, offset, limit, accepts_pagination, expected_count",
"n, offset, limit, accepts_pagination, expected_count, include_stopped_servers",
[
(10, None, None, False, 10),
(10, None, None, True, 10),
(10, 5, None, True, 5),
(10, 5, None, False, 5),
(10, 5, 1, True, 1),
(10, 10, 10, True, 0),
(10, None, None, False, 10, False),
(10, None, None, True, 10, False),
(10, 5, None, True, 5, False),
(10, 5, None, False, 5, False),
(10, None, 5, True, 5, True),
(10, 5, 1, True, 1, True),
(10, 10, 10, True, 0, False),
( # default page limit, pagination expected
30,
None,
None,
True,
'default',
False,
),
(
# default max page limit, pagination not expected
@@ -253,6 +292,7 @@ def max_page_limit(app):
None,
False,
'max',
False,
),
(
# limit exceeded
@@ -261,6 +301,7 @@ def max_page_limit(app):
500,
False,
'max',
False,
),
],
)
@@ -273,6 +314,7 @@ async def test_get_users_pagination(
expected_count,
default_page_limit,
max_page_limit,
include_stopped_servers,
):
db = app.db
@@ -299,6 +341,11 @@ async def test_get_users_pagination(
if limit:
params['limit'] = limit
url = url_concat(url, params)
if include_stopped_servers:
# assumes limit is set. There doesn't seem to be a way to set valueless query
# params using url_cat
url += "&include_stopped_servers"
headers = auth_header(db, 'admin')
if accepts_pagination:
headers['Accept'] = PAGINATION_MEDIA_TYPE
@@ -311,6 +358,11 @@ async def test_get_users_pagination(
"_pagination",
}
pagination = response["_pagination"]
if include_stopped_servers and pagination["next"]:
next_query = parse_qs(
urlparse(pagination["next"]["url"]).query, keep_blank_values=True
)
assert "include_stopped_servers" in next_query
users = response["items"]
else:
users = response
@@ -1110,6 +1162,92 @@ async def test_progress_ready(request, app):
assert evt['url'] == app_user.url
async def test_progress_ready_hook_async_func(request, app):
"""Test progress ready hook in Spawner class with an async function"""
db = app.db
name = 'saga'
app_user = add_user(db, app=app, name=name)
html_message = 'customized html message'
spawner = app_user.spawner
async def custom_progress_ready_hook(spawner, ready_event):
ready_event['html_message'] = html_message
return ready_event
spawner.progress_ready_hook = custom_progress_ready_hook
r = await api_request(app, 'users', name, 'server', method='post')
r.raise_for_status()
r = await api_request(app, 'users', name, 'server/progress', stream=True)
r.raise_for_status()
request.addfinalizer(r.close)
assert r.headers['content-type'] == 'text/event-stream'
ex = async_requests.executor
line_iter = iter(r.iter_lines(decode_unicode=True))
evt = await ex.submit(next_event, line_iter)
assert evt['progress'] == 100
assert evt['ready']
assert evt['url'] == app_user.url
assert evt['html_message'] == html_message
async def test_progress_ready_hook_sync_func(request, app):
"""Test progress ready hook in Spawner class with a sync function"""
db = app.db
name = 'saga'
app_user = add_user(db, app=app, name=name)
html_message = 'customized html message'
spawner = app_user.spawner
def custom_progress_ready_hook(spawner, ready_event):
ready_event['html_message'] = html_message
return ready_event
spawner.progress_ready_hook = custom_progress_ready_hook
r = await api_request(app, 'users', name, 'server', method='post')
r.raise_for_status()
r = await api_request(app, 'users', name, 'server/progress', stream=True)
r.raise_for_status()
request.addfinalizer(r.close)
assert r.headers['content-type'] == 'text/event-stream'
ex = async_requests.executor
line_iter = iter(r.iter_lines(decode_unicode=True))
evt = await ex.submit(next_event, line_iter)
assert evt['progress'] == 100
assert evt['ready']
assert evt['url'] == app_user.url
assert evt['html_message'] == html_message
async def test_progress_ready_hook_async_func_exception(request, app):
"""Test progress ready hook in Spawner class with an exception in
an async function
"""
db = app.db
name = 'saga'
app_user = add_user(db, app=app, name=name)
html_message = 'Server ready at <a href="{0}">{0}</a>'.format(app_user.url)
spawner = app_user.spawner
async def custom_progress_ready_hook(spawner, ready_event):
ready_event["html_message"] = "."
raise Exception()
spawner.progress_ready_hook = custom_progress_ready_hook
r = await api_request(app, 'users', name, 'server', method='post')
r.raise_for_status()
r = await api_request(app, 'users', name, 'server/progress', stream=True)
r.raise_for_status()
request.addfinalizer(r.close)
assert r.headers['content-type'] == 'text/event-stream'
ex = async_requests.executor
line_iter = iter(r.iter_lines(decode_unicode=True))
evt = await ex.submit(next_event, line_iter)
assert evt['progress'] == 100
assert evt['ready']
assert evt['url'] == app_user.url
assert evt['html_message'] == html_message
async def test_progress_bad(request, app, bad_spawn):
"""Test progress API when spawner has already failed"""
db = app.db
@@ -1717,7 +1855,7 @@ async def test_group_get(app):
app.db.commit()
group = orm.Group.find(app.db, name='alphaflight')
user = add_user(app.db, app=app, name='sasquatch')
group.users.append(user)
group.users.append(user.orm_user)
app.db.commit()
r = await api_request(app, 'groups/runaways')
@@ -1875,7 +2013,7 @@ async def test_group_properties(app, group):
@mark.group
async def test_auth_managed_groups(request, app, group, user):
group.users.append(user)
group.users.append(user.orm_user)
app.db.commit()
app.authenticator.manage_groups = True
request.addfinalizer(lambda: setattr(app.authenticator, "manage_groups", False))
@@ -1941,7 +2079,7 @@ async def test_get_services(app, mockservice_url):
async def test_get_service(app, mockservice_url):
mockservice = mockservice_url
db = app.db
r = await api_request(app, 'services/%s' % mockservice.name)
r = await api_request(app, f"services/{mockservice.name}")
r.raise_for_status()
assert r.status_code == 200
@@ -1960,19 +2098,271 @@ async def test_get_service(app, mockservice_url):
}
r = await api_request(
app,
'services/%s' % mockservice.name,
f"services/{mockservice.name}",
headers={'Authorization': 'token %s' % mockservice.api_token},
)
r.raise_for_status()
r = await api_request(
app, 'services/%s' % mockservice.name, headers=auth_header(db, 'user')
app, f"services/{mockservice.name}", headers=auth_header(db, 'user')
)
assert r.status_code == 403
r = await api_request(app, "services/nosuchservice")
assert r.status_code == 404
@pytest.fixture
def service_admin_user(create_user_with_scopes):
return create_user_with_scopes('admin:services')
@mark.services
async def test_create_service(app, service_admin_user, service_name, service_data):
db = app.db
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
r.raise_for_status()
assert r.status_code == 201
assert r.json()['name'] == service_name
orm_service = orm.Service.find(db, service_name)
assert orm_service is not None
oath_client = (
db.query(orm.OAuthClient)
.filter_by(identifier=service_data['oauth_client_id'])
.first()
)
assert oath_client.redirect_uri == service_data['oauth_redirect_uri']
assert service_name in app._service_map
assert (
app._service_map[service_name].oauth_no_confirm
== service_data['oauth_no_confirm']
)
@mark.services
async def test_create_service_no_role(app, service_name, service_data):
db = app.db
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, 'user'),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 403
@mark.services
async def test_create_service_conflict(
app, service_admin_user, mockservice, service_data, service_name
):
db = app.db
app.services = [{'name': service_name}]
app.init_services()
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 409
@mark.services
async def test_create_service_duplication(
app, service_admin_user, service_name, service_data
):
db = app.db
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 201
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 409
@mark.services
async def test_create_managed_service(
app, service_admin_user, service_name, service_data
):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['command'] = ['foo']
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 400
assert 'Can not create managed service' in r.json()['message']
orm_service = orm.Service.find(db, service_name)
assert orm_service is None
@mark.services
async def test_create_admin_service(app, admin_user, service_name, service_data):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['admin'] = True
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, admin_user.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 201
orm_service = orm.Service.find(db, service_name)
assert orm_service is not None
@mark.services
async def test_create_admin_service_without_admin_right(
app, service_admin_user, service_data, service_name
):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['admin'] = True
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 400
assert 'Not assigning requested scopes' in r.json()['message']
orm_service = orm.Service.find(db, service_name)
assert orm_service is None
@mark.services
async def test_create_service_with_scope(
app, create_user_with_scopes, service_name, service_data
):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['oauth_client_allowed_scopes'] = ["admin:users"]
managed_service_data['oauth_client_id'] = "service-client-with-scope"
user_with_scope = create_user_with_scopes('admin:services', 'admin:users')
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, user_with_scope.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 201
orm_service = orm.Service.find(db, service_name)
assert orm_service is not None
@mark.services
async def test_create_service_without_requested_scope(
app,
service_admin_user,
service_data,
service_name,
):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['oauth_client_allowed_scopes'] = ["admin:users"]
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 400
assert 'Not assigning requested scopes' in r.json()['message']
orm_service = orm.Service.find(db, service_name)
assert orm_service is None
@mark.services
async def test_delete_service(app, service_admin_user, service_name, service_data):
db = app.db
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 201
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
method='delete',
)
assert r.status_code == 200
orm_service = orm.Service.find(db, service_name)
assert orm_service is None
oath_client = (
db.query(orm.OAuthClient)
.filter_by(identifier=service_data['oauth_client_id'])
.first()
)
assert oath_client is None
assert service_name not in app._service_map
r = await api_request(app, f"services/{service_name}", method="delete")
assert r.status_code == 404
@mark.services
async def test_delete_service_from_config(app, service_admin_user, mockservice):
db = app.db
service_name = mockservice.name
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
method='delete',
)
assert r.status_code == 405
assert r.json()['message'] == f'Service {service_name} is not modifiable at runtime'
async def test_root_api(app):
base_url = app.hub.url
url = ujoin(base_url, 'api')
kwargs = {}
if app.internal_ssl:
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)

View File

@@ -220,7 +220,7 @@ def test_cookie_secret_env(tmpdir, request):
assert not os.path.exists(hub.cookie_secret_file)
def test_cookie_secret_string_():
def test_cookie_secret_string():
cfg = Config()
cfg.JupyterHub.cookie_secret = "not hex"
@@ -251,7 +251,6 @@ async def test_load_groups(tmpdir, request):
hub.init_db()
db = hub.db
await hub.init_role_creation()
await hub.init_users()
await hub.init_groups()
@@ -271,18 +270,41 @@ async def test_load_groups(tmpdir, request):
)
async def test_resume_spawners(tmpdir, request):
if not os.getenv('JUPYTERHUB_TEST_DB_URL'):
p = patch.dict(
os.environ,
{
'JUPYTERHUB_TEST_DB_URL': 'sqlite:///%s'
% tmpdir.join('jupyterhub.sqlite')
},
)
p.start()
request.addfinalizer(p.stop)
@pytest.fixture
def persist_db(tmpdir):
"""ensure db will persist (overrides default sqlite://:memory:)"""
if os.getenv('JUPYTERHUB_TEST_DB_URL'):
# already using a db, no need
yield
return
with patch.dict(
os.environ,
{'JUPYTERHUB_TEST_DB_URL': f"sqlite:///{tmpdir.join('jupyterhub.sqlite')}"},
):
yield
@pytest.fixture
def new_hub(request, tmpdir, persist_db):
"""Fixture to launch a new hub for testing"""
async def new_hub():
kwargs = {}
ssl_enabled = getattr(request.module, "ssl_enabled", False)
if ssl_enabled:
kwargs['internal_certs_location'] = str(tmpdir)
app = MockHub(test_clean_db=False, **kwargs)
app.config.ConfigurableHTTPProxy.should_start = False
app.config.ConfigurableHTTPProxy.auth_token = 'unused'
request.addfinalizer(app.stop)
await app.initialize([])
return app
return new_hub
async def test_resume_spawners(tmpdir, request, new_hub):
async def new_hub():
kwargs = {}
ssl_enabled = getattr(request.module, "ssl_enabled", False)
@@ -435,3 +457,81 @@ def test_launch_instance(request, argv, sys_argv):
assert hub.argv == sys_argv[1:]
else:
assert hub.argv == argv
async def test_user_creation(tmpdir, request):
allowed_users = {"in-allowed", "in-group-in-allowed", "in-role-in-allowed"}
groups = {
"group": {
"users": ["in-group", "in-group-in-allowed"],
}
}
roles = [
{
"name": "therole",
"users": ["in-role", "in-role-in-allowed"],
}
]
cfg = Config()
cfg.Authenticator.allowed_users = allowed_users
cfg.JupyterHub.load_groups = groups
cfg.JupyterHub.load_roles = roles
ssl_enabled = getattr(request.module, "ssl_enabled", False)
kwargs = dict(config=cfg)
if ssl_enabled:
kwargs['internal_certs_location'] = str(tmpdir)
hub = MockHub(**kwargs)
hub.init_db()
await hub.init_role_creation()
await hub.init_role_assignment()
await hub.init_users()
await hub.init_groups()
assert hub.authenticator.allowed_users == {
"admin", # added by default config
"in-allowed",
"in-group-in-allowed",
"in-role-in-allowed",
"in-group",
"in-role",
}
async def test_recreate_service_from_database(
request, new_hub, service_name, service_data
):
# create a hub and add a service (not from config)
app = await new_hub()
app.service_from_spec(service_data, from_config=False)
app.stop()
# new hub, should load service from db
app = await new_hub()
assert service_name in app._service_map
# verify keys
service = app._service_map[service_name]
for key, value in service_data.items():
if key in {'api_token'}:
# skip some keys
continue
assert getattr(service, key) == value
assert (
service_data['oauth_client_id'] in app.tornado_settings['oauth_no_confirm_list']
)
oauth_client = (
app.db.query(orm.OAuthClient)
.filter_by(identifier=service_data['oauth_client_id'])
.first()
)
assert oauth_client.redirect_uri == service_data['oauth_redirect_uri']
# delete service from db, start one more
app.db.delete(service.orm)
app.db.commit()
# start one more, service should be gone
app = await new_hub()
assert service_name not in app._service_map

View File

@@ -105,7 +105,8 @@ async def test_pam_auth_admin_groups():
_getgrouplist=getgrouplist,
):
authorized = await authenticator.get_authenticated_user(
None, {'username': 'also_group_admin', 'password': 'also_group_admin'}
None,
{'username': 'also_group_admin', 'password': 'also_group_admin'},
)
assert authorized['name'] == 'also_group_admin'
assert authorized['admin'] is True
@@ -118,7 +119,8 @@ async def test_pam_auth_admin_groups():
_getgrouplist=getgrouplist,
):
authorized = await authenticator.get_authenticated_user(
None, {'username': 'override_admin', 'password': 'override_admin'}
None,
{'username': 'override_admin', 'password': 'override_admin'},
)
assert authorized['name'] == 'override_admin'
assert authorized['admin'] is True

View File

@@ -44,7 +44,9 @@ def generate_old_db(env_dir, hub_version, db_url):
# changes to this version list must also be reflected
# in ci/init-db.sh
@pytest.mark.parametrize('hub_version', ["1.1.0", "1.2.2", "1.3.0", "1.5.0", "2.1.1"])
@pytest.mark.parametrize(
'hub_version', ['1.1.0', '1.2.2', '1.3.0', '1.5.0', '2.1.1', '3.1.1']
)
async def test_upgrade(tmpdir, hub_version):
db_url = os.getenv('JUPYTERHUB_TEST_DB_URL')
if db_url:

View File

@@ -10,6 +10,18 @@ from ..utils import utcnow
from .utils import add_user, api_request, get_page
@pytest.mark.parametrize(
"metric_object, expected_names",
[
(metrics.TOTAL_USERS, ['jupyterhub_total_users']),
(metrics.REQUEST_DURATION_SECONDS, ['jupyterhub_request_duration_seconds']),
],
)
def test_metric_names(metric_object, expected_names):
for metric, expected_name in zip(metric_object.describe(), expected_names):
assert metric.name == expected_name
async def test_total_users(app):
num_users = app.db.query(orm.User).count()
sample = metrics.TOTAL_USERS.collect()[0].samples[0]

View File

@@ -549,7 +549,11 @@ def test_expiring_oauth_code(app, user):
db = app.db
code = "abc123"
now = orm.OAuthCode.now
orm_code = orm.OAuthCode(code=code, expires_at=now() + 30)
client = orm.OAuthClient(
identifier="expiring_oauth_code", secret="expiring_oauth_code-yyy"
)
db.add(client)
orm_code = orm.OAuthCode(code=code, expires_at=now() + 30, client=client, user=user)
db.add(orm_code)
db.commit()

View File

@@ -220,15 +220,17 @@ async def test_spawn_other_user(
cookies = await app.login_user(username)
requester = app.users[username]
name = user.name
assert username != user.name
if has_access:
if has_access == "group":
group.users.append(user)
group.users.append(user.orm_user)
app.db.commit()
scopes = [
f"access:servers!group={group.name}",
f"servers!group={group.name}",
]
assert group in user.orm_user.groups
elif has_access == "all":
scopes = ["access:servers", "servers"]
elif has_access == "user":
@@ -305,7 +307,7 @@ async def test_spawn_page_access(
requester = app.users[username]
if has_access:
if has_access == "group":
group.users.append(user)
group.users.append(user.orm_user)
app.db.commit()
scopes = [
f"access:servers!group={group.name}",
@@ -408,7 +410,7 @@ async def test_spawn_form_other_user(
requester = app.users[username]
if has_access:
if has_access == "group":
group.users.append(user)
group.users.append(user.orm_user)
app.db.commit()
scopes = [
f"access:servers!group={group.name}",
@@ -635,7 +637,7 @@ async def test_other_user_url(app, username, user, group, create_temp_role, has_
other_user_url = f"/user/{other_user.name}"
if has_access:
if has_access == "group":
group.users.append(other_user)
group.users.append(other_user.orm_user)
app.db.commit()
scopes = [f"access:servers!group={group.name}"]
elif has_access == "all":

View File

@@ -236,6 +236,16 @@ def test_orm_roles_delete_cascade(db):
['tokens!group=hobbits'],
{'tokens!group=hobbits', 'read:tokens!group=hobbits'},
),
(
['admin:services'],
{
'read:roles:services',
'read:services:name',
'admin:services',
'list:services',
'read:services',
},
),
],
)
def test_get_expanded_scopes(db, scopes, expected_scopes):

View File

@@ -383,7 +383,7 @@ async def test_user_filter_with_group(app, create_user_with_scopes):
group = orm.Group(name=group_name)
app.db.add(group)
for user in {user1, user2}:
group.users.append(user)
group.users.append(user.orm_user)
app.db.commit()
r = await api_request(app, 'users', headers=auth_header(app.db, user1.name))
@@ -548,7 +548,7 @@ async def test_server_state_access(
if not group:
group = orm.Group(name=group_name)
app.db.add(group)
group.users.append(user)
group.users.append(user.orm_user)
app.db.commit()
server_names = ['bianca', 'terry']
for server_name in server_names:
@@ -974,7 +974,7 @@ async def test_list_users_filter(
# create users:
for i in (1, 2):
user = add_user(app.db, app, name=f'in-{i}')
group.users.append(user)
group.users.append(user.orm_user)
add_user(app.db, app, name=f'out-{i}')
app.db.commit()

View File

@@ -2,6 +2,7 @@
import os
import sys
from contextlib import nullcontext
from pprint import pprint
from subprocess import CalledProcessError, check_output
from unittest import mock
from urllib.parse import urlencode, urlparse
@@ -16,6 +17,8 @@ from ..utils import url_path_join
from .mocking import public_url
from .utils import AsyncSession, async_requests, get_page
IS_JUPYVERSE = os.environ.get("JUPYTERHUB_SINGLEUSER_APP") == "jupyverse"
@pytest.mark.parametrize(
"access_scopes, server_name, expect_success",
@@ -62,6 +65,8 @@ async def test_singleuser_auth(
await user.spawn(server_name)
await app.proxy.add_user(user, server_name)
spawner = user.spawners[server_name]
if IS_JUPYVERSE:
spawner.default_url = "/lab"
url = url_path_join(public_url(app, user), server_name)
# no cookies, redirects to login page
@@ -132,6 +137,9 @@ async def test_singleuser_auth(
await user.stop(server_name)
@pytest.mark.skipif(
IS_JUPYVERSE, reason="jupyverse doesn't look up directories for configuration files"
)
async def test_disable_user_config(request, app, tmpdir, full_spawn):
# login, start the server
cookies = await app.login_user('nandy')
@@ -171,9 +179,7 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
)
r.raise_for_status()
info = r.json()
import pprint
pprint.pprint(info)
pprint(info)
assert info['disable_user_config']
server_config = info['config']
settings = info['settings']
@@ -198,6 +204,83 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
assert_not_in_home(path, key)
@pytest.mark.parametrize("extension", [True, False])
@pytest.mark.parametrize("notebook_dir", ["", "~", "~/sub", "ABS"])
@pytest.mark.skipif(
IS_JUPYVERSE, reason="jupyverse has not notebook directory configuration"
)
async def test_notebook_dir(
request, app, tmpdir, user, full_spawn, extension, notebook_dir
):
if extension:
try:
import jupyter_server # noqa
except ImportError:
pytest.skip("needs jupyter-server 2")
else:
if jupyter_server.version_info < (2,):
pytest.skip("needs jupyter-server 2")
token = user.new_api_token(scopes=["access:servers!user"])
headers = {"Authorization": f"Bearer {token}"}
spawner = user.spawner
if extension:
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "1"
else:
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "0"
home_dir = tmpdir.join("home").mkdir()
sub_dir = home_dir.join("sub").mkdir()
with sub_dir.join("subfile.txt").open("w") as f:
f.write("txt\n")
abs_dir = tmpdir.join("abs").mkdir()
with abs_dir.join("absfile.txt").open("w") as f:
f.write("absfile\n")
if notebook_dir:
expected_root_dir = notebook_dir.replace("ABS", str(abs_dir)).replace(
"~", str(home_dir)
)
else:
expected_root_dir = str(home_dir)
spawner.notebook_dir = notebook_dir.replace("ABS", str(abs_dir))
# home_dir is defined on SimpleSpawner
user.spawner.home_dir = home = str(home_dir)
spawner.environment["HOME"] = home
await user.spawn()
await app.proxy.add_user(user)
url = public_url(app, user)
r = await async_requests.get(
url_path_join(public_url(app, user), 'jupyterhub-test-info'), headers=headers
)
r.raise_for_status()
info = r.json()
pprint(info)
assert info["root_dir"] == expected_root_dir
# secondary check: make sure it has the intended effect on root_dir
r = await async_requests.get(
url_path_join(public_url(app, user), 'api/contents/'), headers=headers
)
r.raise_for_status()
root_contents = sorted(item['name'] for item in r.json()['content'])
# check contents
if not notebook_dir or notebook_dir == "~":
# use any to avoid counting possible automatically created files in $HOME
assert 'sub' in root_contents
elif notebook_dir == "ABS":
assert 'absfile.txt' in root_contents
elif notebook_dir == "~/sub":
assert 'subfile.txt' in root_contents
else:
raise ValueError(f"No contents check for {notebook_dir=}")
@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no --help-all")
def test_help_output():
out = check_output(
[sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']
@@ -205,6 +288,7 @@ def test_help_output():
assert 'JupyterHub' in out
@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has not --version")
def test_version():
out = check_output(
[sys.executable, '-m', 'jupyterhub.singleuser', '--version']
@@ -271,6 +355,7 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
assert '--NotebookApp.' not in out
@pytest.mark.skipif(IS_JUPYVERSE, reason="nbclassic specific test")
async def test_nbclassic_control_panel(app, user, full_spawn):
# login, start the server
await user.spawn()

View File

@@ -122,3 +122,46 @@ def test_browser_protocol(x_scheme, x_forwarded_proto, forwarded, expected):
proto = utils.get_browser_protocol(request)
assert proto == expected
@pytest.mark.parametrize(
"name, expected",
[
("safe", "safe"),
("has--doubledash", "u-hasdoubl--cb052ae"),
("uhasdoubl--cb052ae", "u-uhasdoub--3c0d1c9"),
("üni", "xn--ni-wka"),
("xn--ni-wka", "u-xnniwka--ceb4edd"),
("x", "x"),
("-pre", "u-pre--0e46e7b"),
("É", "u-x--a755f65"),
("é", "xn--9ca"),
("a" * 64, "u-aaaaaaaa--ffe054f"),
("a.b", "u-ab--2e7336d"),
],
)
def test_subdomain_hook_idna(name, expected):
expected_domain = expected + ".domain"
resolved = utils.subdomain_hook_idna(name, "domain", "user")
assert resolved == expected_domain
@pytest.mark.parametrize(
"name, expected",
[
("safe", "safe"),
("üni", "_c3_bcni"),
("x", "x"),
("É", "_c3_89"),
("é", "_c3_a9"),
# bad cases:
("a.b", "a.b"),
("has--doubledash", "has--doubledash"),
("-pre", "-pre"),
("a" * 64, "a" * 64),
],
)
def test_subdomain_hook_legacy(name, expected):
expected_domain = expected + ".domain"
resolved = utils.subdomain_hook_legacy(name, "domain", "user")
assert resolved == expected_domain

View File

@@ -1,11 +1,9 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
import string
import warnings
from collections import defaultdict
from datetime import datetime, timedelta
from functools import lru_cache
from urllib.parse import quote, urlparse
from sqlalchemy import inspect
@@ -21,8 +19,10 @@ from .objects import Server
from .spawner import LocalProcessSpawner
from .utils import (
AnyTimeoutError,
_strict_dns_safe,
make_ssl_context,
maybe_future,
subdomain_hook_legacy,
url_escape_path,
url_path_join,
)
@@ -55,42 +55,6 @@ Common causes of this timeout, and debugging tips:
to a number of seconds that is enough for servers to become responsive.
"""
# set of chars that are safe in dns labels
# (allow '.' because we don't mind multiple levels of subdomains)
_dns_safe = set(string.ascii_letters + string.digits + '-.')
# don't escape % because it's the escape char and we handle it separately
_dns_needs_replace = _dns_safe | {"%"}
@lru_cache()
def _dns_quote(name):
"""Escape a name for use in a dns label
this is _NOT_ fully domain-safe, but works often enough for realistic usernames.
Fully safe would be full IDNA encoding,
PLUS escaping non-IDNA-legal ascii,
PLUS some encoding of boundary conditions
"""
# escape name for subdomain label
label = quote(name, safe="").lower()
# some characters are not handled by quote,
# because they are legal in URLs but not domains,
# specifically _ and ~ (starting in 3.7).
# Escape these in the same way (%{hex_codepoint}).
unique_chars = set(label)
for c in unique_chars:
if c not in _dns_needs_replace:
label = label.replace(c, f"%{ord(c):x}")
# underscore is our escape char -
# it's not officially legal in hostnames,
# but is valid in _domain_ names (?),
# and always works in practice.
# FIXME: We should consider switching to proper IDNA encoding
# for 3.0.
label = label.replace("%", "_")
return label
class UserDict(dict):
"""Like defaultdict, but for users
@@ -559,8 +523,19 @@ class User:
@property
def domain(self):
"""Get the domain for my server."""
hook = self.settings.get("subdomain_hook", subdomain_hook_legacy)
return hook(self.name, self.settings['domain'], kind='user')
return _dns_quote(self.name) + '.' + self.settings['domain']
@property
def dns_safe_name(self):
"""Get a dns-safe encoding of my name
- always safe value for a single DNS label
- max 40 characters, leaving room for additional components
.. versionadded:: 5.0
"""
return _strict_dns_safe(self.name, max_length=40)
@property
def host(self):

View File

@@ -8,19 +8,23 @@ import functools
import hashlib
import inspect
import random
import re
import secrets
import socket
import ssl
import string
import sys
import threading
import uuid
import warnings
from binascii import b2a_hex
from datetime import datetime, timezone
from functools import lru_cache
from hmac import compare_digest
from operator import itemgetter
from urllib.parse import quote
import idna
from async_generator import aclosing
from sqlalchemy.exc import SQLAlchemyError
from tornado import gen, ioloop, web
@@ -779,3 +783,153 @@ def get_browser_protocol(request):
# no forwarded headers
return request.protocol
# set of chars that are safe in dns labels
# (allow '.' because we don't mind multiple levels of subdomains)
_dns_safe = set(string.ascii_letters + string.digits + '-.')
# don't escape % because it's the escape char and we handle it separately
_dns_needs_replace = _dns_safe | {"%"}
@lru_cache()
def _dns_quote(name):
"""Escape a name for use in a dns label
this is _NOT_ fully domain-safe, but works often enough for realistic usernames.
Fully safe would be full IDNA encoding,
PLUS escaping non-IDNA-legal ascii,
PLUS some encoding of boundary conditions
"""
# escape name for subdomain label
label = quote(name, safe="").lower()
# some characters are not handled by quote,
# because they are legal in URLs but not domains,
# specifically _ and ~ (starting in 3.7).
# Escape these in the same way (%{hex_codepoint}).
unique_chars = set(label)
for c in unique_chars:
if c not in _dns_needs_replace:
label = label.replace(c, f"%{ord(c):x}")
# underscore is our escape char -
# it's not officially legal in hostnames,
# but is valid in _domain_ names (?),
# and seems to always work in practice.
label = label.replace("%", "_")
return label
def subdomain_hook_legacy(name, domain, kind):
"""Legacy (default) hook for subdomains
Users are at '$user.$host' where $user is _mostly_ DNS-safe.
Services are all simultaneously on 'services.$host`.
"""
if kind == "user":
# backward-compatibility
return f"{_dns_quote(name)}.{domain}"
elif kind == "service":
return f"services.{domain}"
else:
raise ValueError(f"kind must be 'service' or 'user', not {kind!r}")
# strict dns-safe characters (excludes '-')
_strict_dns_safe = set(string.ascii_lowercase) | set(string.digits)
def _trim_and_hash(name):
"""Always-safe fallback for a DNS label
Produces a valid and unique DNS label for any string
- prefix with 'u-' to avoid collisions and first-character rules
- Selects the first N characters that are safe ('x' if none are safe)
- suffix with truncated hash of true name
- length is guaranteed to be < 32 characters
leaving room for additional components to build a DNS label.
Will currently be between 12-19 characters:
4 (prefix, delimiters) + 7 (hash) + 1-8 (name stub)
"""
name_hash = hashlib.sha256(name.encode('utf8')).hexdigest()[:7]
safe_chars = [c for c in name.lower() if c in _strict_dns_safe]
name_stub = ''.join(safe_chars[:8])
# We MUST NOT put the `--` in the 3rd and 4th position (RFC 5891)
# which is reserved for IDNs
# It would be if name_stub were empty, so put 'x' here
# (value doesn't matter, as uniqueness is in the hash - the stub is more of a hint, anyway)
if not name_stub:
name_stub = "x"
return f"u-{name_stub}--{name_hash}"
# A host name (label) can start or end with a letter or a number
# this pattern doesn't need to handle the boundary conditions,
# which are handled more simply with starts/endswith
_dns_re = re.compile(r'^[a-z0-9-]{1,63}$', flags=re.IGNORECASE)
def _is_dns_safe(label, max_length=63):
# A host name (label) MUST NOT consist of all numeric values
if label.isnumeric():
return False
# A host name (label) can be up to 63 characters
if not 0 < len(label) <= max_length:
return False
# A host name (label) MUST NOT start or end with a '-' (dash)
if label.startswith('-') or label.endswith('-'):
return False
return bool(_dns_re.match(label))
def _strict_dns_safe_encode(name, max_length=63):
"""Will encode a username to a guaranteed-safe DNS label
- if it contains '--' at all, jump to the end and take the hash route to avoid collisions with escaped
- if safe, use it
- if not, use IDNA encoding
- if a safe encoding cannot be produced, use stripped safe characters + '--{hash}`
- allow specifying a max_length, to give room for additional components,
if used as only a _part_ of a DNS label.
"""
# short-circuit: avoid accepting already-encoded results
# which all include '--'
if '--' in name:
return _trim_and_hash(name)
# if name is already safe (and can't collide with an escaped result) use it
if _is_dns_safe(name, max_length=max_length):
return name
# next: use IDNA encoding, if applicable
try:
idna_name = idna.encode(name).decode("ascii")
except ValueError:
idna_name = None
if idna_name and idna_name != name and _is_dns_safe(idna_name):
return idna_name
# fallback, always works: trim to safe characters and hash
return _trim_and_hash(name)
def subdomain_hook_idna(name, domain, kind):
"""New, reliable subdomain hook
More reliable than previous, should always produce valid domains
- uses IDNA encoding for simple unicode names
- separate domain for each service
- uses stripped name and hash, where above schemes fail to produce a valid domain
"""
safe_name = _strict_dns_safe_encode(name)
if kind == 'user':
# 'user' namespace is special-cased as the default
# for aesthetics and backward-compatibility for names that don't need escaping
suffix = ""
else:
suffix = f"--{kind}"
return f"{safe_name}{suffix}.{domain}"

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
# ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
[project]
name = "jupyterhub"
version = "4.1.0.dev"
version = "5.0.0.dev"
dynamic = ["readme", "dependencies"]
description = "JupyterHub: A multi-user server for Jupyter notebooks"
authors = [
@@ -15,7 +15,7 @@ authors = [
]
keywords = ["Interactive", "Interpreter", "Shell", "Web", "Jupyter"]
license = { text = "BSD-3-Clause" }
requires-python = ">=3.7"
requires-python = ">=3.8"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
@@ -140,7 +140,7 @@ target_version = [
github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version]
current = "4.1.0.dev"
current = "5.0.0.dev"
# Example of a semver regexp.
# Make sure this matches current_version before

View File

@@ -1,16 +1,17 @@
alembic>=1.4
async_generator>=1.9
certipy>=0.1.2
idna
importlib_metadata>=3.6; python_version < '3.10'
jinja2>=2.11.0
jupyter_telemetry>=0.1.0
oauthlib>=3.0
packaging
pamela; sys_platform != 'win32'
prometheus_client>=0.4.0
pamela>=1.1.0; sys_platform != 'win32'
prometheus_client>=0.5.0
psutil>=5.6.5; sys_platform == 'win32'
python-dateutil
requests
SQLAlchemy>=1.4
SQLAlchemy>=1.4.1
tornado>=5.1
traitlets>=4.3.2

View File

@@ -52,7 +52,6 @@
class="form-control"
name="username"
val="{{username}}"
tabindex="1"
autofocus="autofocus"
/>
<label for='password_input'>Password:</label>
@@ -62,8 +61,16 @@
autocomplete="current-password"
name="password"
id="password_input"
tabindex="2"
/>
{% if authenticator.request_otp %}
<label for='otp_input'>{{ authenticator.otp_prompt }}</label>
<input
class="form-control"
autocomplete="one-time-password"
name="otp"
id="otp_input"
/>
{% endif %}
<div class="feedback-container">
<input

View File

@@ -132,7 +132,7 @@
<ul class="dropdown-menu">
{% for service in services %}
{% block service scoped %}
<li><a class="dropdown-item" href="{{service.prefix}}">{{service.name}}</a></li>
<li><a class="dropdown-item" href="{{service.href}}">{{service.name}}</a></li>
{% endblock %}
{% endfor %}
</ul>