mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 18:44:10 +00:00
Compare commits
137 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3d3ad2929c | ||
![]() |
00287ff5ba | ||
![]() |
805d063d1d | ||
![]() |
e6bacf7109 | ||
![]() |
33ccfa7963 | ||
![]() |
593404f558 | ||
![]() |
e7bc282c80 | ||
![]() |
b939b482a1 | ||
![]() |
8afc2c9ae9 | ||
![]() |
d11eda14ed | ||
![]() |
ab79251fe2 | ||
![]() |
484dbf48de | ||
![]() |
6eb526d08a | ||
![]() |
e0a17db5f1 | ||
![]() |
45132b7244 | ||
![]() |
c23cddeb51 | ||
![]() |
672e19a22a | ||
![]() |
4a6c9c3a01 | ||
![]() |
2b79bc44da | ||
![]() |
7861662e17 | ||
![]() |
4a1842bf8a | ||
![]() |
8f18303e50 | ||
![]() |
bcad6e287d | ||
![]() |
9de1951952 | ||
![]() |
99cb1f17f0 | ||
![]() |
10d5157e95 | ||
![]() |
2fc4f26832 | ||
![]() |
f6230001bb | ||
![]() |
960f7cbeb9 | ||
![]() |
76f06a6b55 | ||
![]() |
9c498aa5d4 | ||
![]() |
a0b60f9118 | ||
![]() |
27cb56429b | ||
![]() |
b1ffd4b10b | ||
![]() |
a9ea064202 | ||
![]() |
687a41a467 | ||
![]() |
5348451b2e | ||
![]() |
55f0579dcc | ||
![]() |
a3ea0f0449 | ||
![]() |
78492a4a8e | ||
![]() |
f22203f50e | ||
![]() |
500b354a00 | ||
![]() |
9d4093782f | ||
![]() |
43b3cebfff | ||
![]() |
63c381431d | ||
![]() |
bf41767b33 | ||
![]() |
83d6e4e993 | ||
![]() |
d64a2ddd95 | ||
![]() |
392176d873 | ||
![]() |
58420b3307 | ||
![]() |
a5e3b66dee | ||
![]() |
a9fbe5c9f6 | ||
![]() |
71bbbe4a67 | ||
![]() |
3843885382 | ||
![]() |
25ea559e0d | ||
![]() |
c18815de91 | ||
![]() |
50d53667ce | ||
![]() |
68e2baf4aa | ||
![]() |
6fc9d40e51 | ||
![]() |
0b25694b40 | ||
![]() |
bf750e488f | ||
![]() |
359f9055fc | ||
![]() |
b84dd5d735 | ||
![]() |
3ed345f496 | ||
![]() |
6633f8ef28 | ||
![]() |
757053a9ec | ||
![]() |
36cad38ddf | ||
![]() |
1e9a1cb621 | ||
![]() |
9f051d3172 | ||
![]() |
53576c8f82 | ||
![]() |
bb5ec39b2f | ||
![]() |
4c54c6dcc8 | ||
![]() |
39da98f133 | ||
![]() |
29e69aa880 | ||
![]() |
0c315f31b7 | ||
![]() |
508842a68c | ||
![]() |
4b31615a05 | ||
![]() |
17b64280e8 | ||
![]() |
88be7a9967 | ||
![]() |
4ca2344af7 | ||
![]() |
4c050cf165 | ||
![]() |
5e2ccb81fa | ||
![]() |
b8dc3befab | ||
![]() |
2f29848757 | ||
![]() |
4f3d6cdd0c | ||
![]() |
67733ef928 | ||
![]() |
e657754e7f | ||
![]() |
2d6087959c | ||
![]() |
08a913707f | ||
![]() |
9c8a4f287a | ||
![]() |
64d6f0222c | ||
![]() |
538abdf084 | ||
![]() |
144abcb965 | ||
![]() |
6e5c307edb | ||
![]() |
67ebe0b0cf | ||
![]() |
dcf21d53fd | ||
![]() |
f5bb0a2622 | ||
![]() |
704712cc81 | ||
![]() |
f86d53a234 | ||
![]() |
5466224988 | ||
![]() |
f9fa21bfd7 | ||
![]() |
e4855c30f5 | ||
![]() |
f1c4fdd5a2 | ||
![]() |
e58cf06706 | ||
![]() |
91f4918cff | ||
![]() |
b15ccfa4ae | ||
![]() |
5102fde2f0 | ||
![]() |
f5dc005a70 | ||
![]() |
5fd8f0f596 | ||
![]() |
26ceafa8a3 | ||
![]() |
2e2ed8a4ff | ||
![]() |
6cc734f884 | ||
![]() |
4f7f07d3b7 | ||
![]() |
d436c97e3d | ||
![]() |
807c5b8ff9 | ||
![]() |
8da06d1259 | ||
![]() |
1c1be8a24b | ||
![]() |
897606b00c | ||
![]() |
615af5eb33 | ||
![]() |
85f94c12fc | ||
![]() |
ccfee4d235 | ||
![]() |
a2ba55756d | ||
![]() |
1b3e94db6c | ||
![]() |
614d9d89d0 | ||
![]() |
05a3f5aa9a | ||
![]() |
4f47153123 | ||
![]() |
a14d9ecaa1 | ||
![]() |
6815f30d36 | ||
![]() |
13172e6856 | ||
![]() |
ebc9fd7758 | ||
![]() |
0761a5db02 | ||
![]() |
46e7a231fe | ||
![]() |
ffa5a20e2f | ||
![]() |
2088a57ffe | ||
![]() |
345805781f | ||
![]() |
9eb52ea788 | ||
![]() |
fb1405ecd8 |
55
.github/workflows/release.yml
vendored
55
.github/workflows/release.yml
vendored
@@ -1,15 +1,32 @@
|
|||||||
# Build releases and (on tags) publish to PyPI
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
|
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||||
|
#
|
||||||
|
# Test build release artifacts (PyPI package, Docker images) and publish them on
|
||||||
|
# pushed git tags.
|
||||||
|
#
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# always build releases (to make sure wheel-building works)
|
|
||||||
# but only publish to PyPI on tags
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "!dependabot/**"
|
|
||||||
tags:
|
|
||||||
- "*"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- "**.rst"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/release.yml"
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- "**.rst"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/release.yml"
|
||||||
|
branches-ignore:
|
||||||
|
- "dependabot/**"
|
||||||
|
- "pre-commit-ci-update-config"
|
||||||
|
tags:
|
||||||
|
- "**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-release:
|
build-release:
|
||||||
@@ -96,7 +113,6 @@ jobs:
|
|||||||
# Setup docker to build for multiple platforms, see:
|
# Setup docker to build for multiple platforms, see:
|
||||||
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
|
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
|
||||||
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
|
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
|
||||||
|
|
||||||
- name: Set up QEMU (for docker buildx)
|
- name: Set up QEMU (for docker buildx)
|
||||||
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
|
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
|
||||||
|
|
||||||
@@ -120,6 +136,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
|
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||||
|
|
||||||
|
# image: jupyterhub/jupyterhub
|
||||||
|
#
|
||||||
# https://github.com/jupyterhub/action-major-minor-tag-calculator
|
# https://github.com/jupyterhub/action-major-minor-tag-calculator
|
||||||
# If this is a tagged build this will return additional parent tags.
|
# If this is a tagged build this will return additional parent tags.
|
||||||
# E.g. 1.2.3 is expanded to Docker tags
|
# E.g. 1.2.3 is expanded to Docker tags
|
||||||
@@ -137,7 +155,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub
|
- name: Build and push jupyterhub
|
||||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -146,8 +164,8 @@ jobs:
|
|||||||
# array into a comma separated list of tags
|
# array into a comma separated list of tags
|
||||||
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
|
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
|
||||||
|
|
||||||
# jupyterhub-onbuild
|
# image: jupyterhub/jupyterhub-onbuild
|
||||||
|
#
|
||||||
- name: Get list of jupyterhub-onbuild tags
|
- name: Get list of jupyterhub-onbuild tags
|
||||||
id: onbuildtags
|
id: onbuildtags
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||||
@@ -158,7 +176,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub-onbuild
|
- name: Build and push jupyterhub-onbuild
|
||||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
||||||
@@ -167,8 +185,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
|
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
|
||||||
|
|
||||||
# jupyterhub-demo
|
# image: jupyterhub/jupyterhub-demo
|
||||||
|
#
|
||||||
- name: Get list of jupyterhub-demo tags
|
- name: Get list of jupyterhub-demo tags
|
||||||
id: demotags
|
id: demotags
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||||
@@ -179,7 +197,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub-demo
|
- name: Build and push jupyterhub-demo
|
||||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
||||||
@@ -191,7 +209,8 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
|
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
|
||||||
|
|
||||||
# jupyterhub/singleuser
|
# image: jupyterhub/singleuser
|
||||||
|
#
|
||||||
- name: Get list of jupyterhub/singleuser tags
|
- name: Get list of jupyterhub/singleuser tags
|
||||||
id: singleusertags
|
id: singleusertags
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||||
@@ -202,7 +221,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub/singleuser
|
- name: Build and push jupyterhub/singleuser
|
||||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||||
|
64
.github/workflows/test-docs.yml
vendored
Normal file
64
.github/workflows/test-docs.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
|
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||||
|
#
|
||||||
|
# This workflow validates the REST API definition and runs the pytest tests in
|
||||||
|
# the docs/ folder. This workflow does not build the documentation. That is
|
||||||
|
# instead tested via ReadTheDocs (https://readthedocs.org/projects/jupyterhub/).
|
||||||
|
#
|
||||||
|
name: Test docs
|
||||||
|
|
||||||
|
# The tests defined in docs/ are currently influenced by changes to _version.py
|
||||||
|
# and scopes.py.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
- "jupyterhub/_version.py"
|
||||||
|
- "jupyterhub/scopes.py"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/test-docs.yml"
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
- "jupyterhub/_version.py"
|
||||||
|
- "jupyterhub/scopes.py"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/test-docs.yml"
|
||||||
|
branches-ignore:
|
||||||
|
- "dependabot/**"
|
||||||
|
- "pre-commit-ci-update-config"
|
||||||
|
tags:
|
||||||
|
- "**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||||
|
LANG: C.UTF-8
|
||||||
|
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-rest-api-definition:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Validate REST API definition
|
||||||
|
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
|
||||||
|
with:
|
||||||
|
definition-file: docs/source/_static/rest-api.yml
|
||||||
|
|
||||||
|
test-docs:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: "3.9"
|
||||||
|
|
||||||
|
- name: Install requirements
|
||||||
|
run: |
|
||||||
|
pip install -r docs/requirements.txt pytest -e .
|
||||||
|
|
||||||
|
- name: pytest docs/
|
||||||
|
run: |
|
||||||
|
pytest docs/
|
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@@ -1,14 +1,28 @@
|
|||||||
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
|
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||||
#
|
#
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
# Trigger the workflow's on all PRs but only on pushed tags or commits to
|
|
||||||
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
|
|
||||||
# to trigger.
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- "**.rst"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/test.yml"
|
||||||
push:
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- "**.rst"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/test.yml"
|
||||||
|
branches-ignore:
|
||||||
|
- "dependabot/**"
|
||||||
|
- "pre-commit-ci-update-config"
|
||||||
|
tags:
|
||||||
|
- "**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -17,25 +31,6 @@ env:
|
|||||||
PYTEST_ADDOPTS: "--verbose --color=yes"
|
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rest-api:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Validate REST API
|
|
||||||
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
|
|
||||||
with:
|
|
||||||
definition-file: docs/source/_static/rest-api.yml
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: "3.9"
|
|
||||||
# in addition to the doc requirements
|
|
||||||
# the docs *tests* require pre-commit and pytest
|
|
||||||
- run: |
|
|
||||||
pip install -r docs/requirements.txt pytest pre-commit -e .
|
|
||||||
- run: |
|
|
||||||
pytest docs/
|
|
||||||
|
|
||||||
jstest:
|
jstest:
|
||||||
# Run javascript tests
|
# Run javascript tests
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
|
@@ -1,16 +1,16 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v2.29.1
|
rev: v2.31.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args:
|
args:
|
||||||
- --py36-plus
|
- --py36-plus
|
||||||
- repo: https://github.com/asottile/reorder_python_imports
|
- repo: https://github.com/asottile/reorder_python_imports
|
||||||
rev: v2.6.0
|
rev: v2.7.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: reorder-python-imports
|
- id: reorder-python-imports
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 21.12b0
|
rev: 22.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
@@ -22,7 +22,7 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.0.1
|
rev: v4.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
|
@@ -4,10 +4,12 @@ sphinx:
|
|||||||
configuration: docs/source/conf.py
|
configuration: docs/source/conf.py
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: latest
|
os: ubuntu-20.04
|
||||||
|
tools:
|
||||||
|
nodejs: "16"
|
||||||
|
python: "3.9"
|
||||||
|
|
||||||
python:
|
python:
|
||||||
version: 3.7
|
|
||||||
install:
|
install:
|
||||||
- method: pip
|
- method: pip
|
||||||
path: .
|
path: .
|
@@ -117,8 +117,7 @@ To start the Hub server, run the command:
|
|||||||
|
|
||||||
jupyterhub
|
jupyterhub
|
||||||
|
|
||||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
Visit `http://localhost:8000` in your browser, and sign in with your system username and password.
|
||||||
PAM credentials.
|
|
||||||
|
|
||||||
_Note_: To allow multiple users to sign in to the server, you will need to
|
_Note_: To allow multiple users to sign in to the server, you will need to
|
||||||
run the `jupyterhub` command as a _privileged user_, such as root.
|
run the `jupyterhub` command as a _privileged user_, such as root.
|
||||||
|
@@ -3,8 +3,10 @@
|
|||||||
alabaster_jupyterhub
|
alabaster_jupyterhub
|
||||||
autodoc-traits
|
autodoc-traits
|
||||||
myst-parser
|
myst-parser
|
||||||
|
pre-commit
|
||||||
pydata-sphinx-theme
|
pydata-sphinx-theme
|
||||||
pytablewriter>=0.56
|
pytablewriter>=0.56
|
||||||
|
ruamel.yaml
|
||||||
sphinx>=1.7
|
sphinx>=1.7
|
||||||
sphinx-copybutton
|
sphinx-copybutton
|
||||||
sphinx-jsonschema
|
sphinx-jsonschema
|
||||||
|
@@ -6,7 +6,7 @@ info:
|
|||||||
description: The REST API for JupyterHub
|
description: The REST API for JupyterHub
|
||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
version: 2.0.1
|
version: 2.2.1
|
||||||
servers:
|
servers:
|
||||||
- url: /hub/api
|
- url: /hub/api
|
||||||
security:
|
security:
|
||||||
@@ -1419,3 +1419,4 @@ components:
|
|||||||
Read information about the proxy’s routing table, sync the Hub
|
Read information about the proxy’s routing table, sync the Hub
|
||||||
with the proxy and notify the Hub about a new proxy.
|
with the proxy and notify the Hub about a new proxy.
|
||||||
shutdown: Shutdown the hub.
|
shutdown: Shutdown the hub.
|
||||||
|
read:metrics: Read prometheus metrics.
|
||||||
|
37
docs/source/admin/log-messages.md
Normal file
37
docs/source/admin/log-messages.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Common log messages emitted by JupyterHub
|
||||||
|
|
||||||
|
When debugging errors and outages, looking at the logs emitted by
|
||||||
|
JupyterHub is very helpful. This document tries to document some common
|
||||||
|
log messages, and what they mean.
|
||||||
|
|
||||||
|
## Failing suspected API request to not-running server
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
Your logs might be littered with lines that might look slightly scary
|
||||||
|
|
||||||
|
```
|
||||||
|
[W 2022-03-10 17:25:19.774 JupyterHub base:1349] Failing suspected API request to not-running server: /hub/user/<user-name>/api/metrics/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Most likely cause
|
||||||
|
|
||||||
|
This likely means is that the user's server has stopped running but they
|
||||||
|
still have a browser tab open. For example, you might have 3 tabs open, and shut
|
||||||
|
your server down via one. Or you closed your laptop, your server was
|
||||||
|
culled for inactivity, and then you reopen your laptop again! The
|
||||||
|
client side code (JupyterLab, Classic Notebook, etc) does not know
|
||||||
|
yet that the server is dead, and continues to make some API requests.
|
||||||
|
JupyterHub's architecture means that the proxy routes all requests that
|
||||||
|
don't go to a running user server to the hub process itself. The hub
|
||||||
|
process then explicitly returns a failure response, so the client knows
|
||||||
|
that the server is not running anymore. This is used by JupyterLab to
|
||||||
|
tell you your server is not running anymore, and offer you the option
|
||||||
|
to let you restart it.
|
||||||
|
|
||||||
|
Most commonly, you'll see this in reference to the `/api/metrics/v1`
|
||||||
|
URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage).
|
||||||
|
|
||||||
|
### Actions you can take
|
||||||
|
|
||||||
|
This log message is benign, and there is usually no action for you to take.
|
@@ -6,8 +6,173 @@ command line for details.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## 2.2
|
||||||
|
|
||||||
|
### 2.2.1 2021-03-11
|
||||||
|
|
||||||
|
2.2.1 fixes a few small regressions in 2.2.0.
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.2.0...2.2.1))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- Fix clearing cookie with custom xsrf cookie options [#3823](https://github.com/jupyterhub/jupyterhub/pull/3823) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Fix admin dashboard table sorting [#3822](https://github.com/jupyterhub/jupyterhub/pull/3822) ([@NarekA](https://github.com/NarekA), [@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Maintenance and upkeep improvements
|
||||||
|
|
||||||
|
- allow Spawner.server to be mocked without underlying orm_spawner [#3819](https://github.com/jupyterhub/jupyterhub/pull/3819) ([@minrk](https://github.com/minrk), [@yuvipanda](https://github.com/yuvipanda), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
|
||||||
|
- Add some docs on common log messages [#3820](https://github.com/jupyterhub/jupyterhub/pull/3820) ([@yuvipanda](https://github.com/yuvipanda), [@choldgraf](https://github.com/choldgraf), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-03-07&to=2022-03-11&type=c))
|
||||||
|
|
||||||
|
[@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acholdgraf+updated%3A2022-03-07..2022-03-11&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-03-07..2022-03-11&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-03-07..2022-03-11&type=Issues) | [@NarekA](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ANarekA+updated%3A2022-03-07..2022-03-11&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2022-03-07..2022-03-11&type=Issues)
|
||||||
|
|
||||||
|
# 2.2.0 2021-03-07
|
||||||
|
|
||||||
|
JupyterHub 2.2.0 is a small release.
|
||||||
|
The main new feature is the ability of Authenticators to [manage group membership](authenticator-groups),
|
||||||
|
e.g. when the identity provider has its own concept of groups that should be preserved
|
||||||
|
in JupyterHub.
|
||||||
|
|
||||||
|
The links to access user servers from the admin page have been restored.
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.1.1...2.2.0))
|
||||||
|
|
||||||
|
#### New features added
|
||||||
|
|
||||||
|
- Enable `options_from_form(spawner, form_data)` signature from configuration file [#3791](https://github.com/jupyterhub/jupyterhub/pull/3791) ([@rcthomas](https://github.com/rcthomas), [@minrk](https://github.com/minrk))
|
||||||
|
- Authenticator user group management [#3548](https://github.com/jupyterhub/jupyterhub/pull/3548) ([@thomafred](https://github.com/thomafred), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Enhancements made
|
||||||
|
|
||||||
|
- Add user token to JupyterLab PageConfig [#3809](https://github.com/jupyterhub/jupyterhub/pull/3809) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- show insecure-login-warning for all authenticators [#3793](https://github.com/jupyterhub/jupyterhub/pull/3793) ([@satra](https://github.com/satra), [@minrk](https://github.com/minrk))
|
||||||
|
- short-circuit token permission check if token and owner share role [#3792](https://github.com/jupyterhub/jupyterhub/pull/3792) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Named server support, access links in admin page [#3790](https://github.com/jupyterhub/jupyterhub/pull/3790) ([@NarekA](https://github.com/NarekA), [@minrk](https://github.com/minrk), [@ykazakov](https://github.com/ykazakov), [@manics](https://github.com/manics))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- Keep Spawner.server in sync with underlying orm_spawner.server [#3810](https://github.com/jupyterhub/jupyterhub/pull/3810) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics), [@GeorgianaElena](https://github.com/GeorgianaElena), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Replace failed spawners when starting new launch [#3802](https://github.com/jupyterhub/jupyterhub/pull/3802) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Log proxy's public_url only when started by JupyterHub [#3781](https://github.com/jupyterhub/jupyterhub/pull/3781) ([@cqzlxl](https://github.com/cqzlxl), [@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Documentation improvements
|
||||||
|
|
||||||
|
- Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers) [#3813](https://github.com/jupyterhub/jupyterhub/pull/3813) ([@rzo1](https://github.com/rzo1), [@minrk](https://github.com/minrk))
|
||||||
|
- Update example to not reference an undefined scope [#3812](https://github.com/jupyterhub/jupyterhub/pull/3812) ([@ktaletsk](https://github.com/ktaletsk), [@minrk](https://github.com/minrk))
|
||||||
|
- Apache: set X-Forwarded-Proto header [#3808](https://github.com/jupyterhub/jupyterhub/pull/3808) ([@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio), [@rzo1](https://github.com/rzo1), [@tobi45](https://github.com/tobi45))
|
||||||
|
- idle-culler example config missing closing bracket [#3803](https://github.com/jupyterhub/jupyterhub/pull/3803) ([@tmtabor](https://github.com/tmtabor), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Behavior Changes
|
||||||
|
|
||||||
|
- Stop opening PAM sessions by default [#3787](https://github.com/jupyterhub/jupyterhub/pull/3787) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-01-25&to=2022-03-07&type=c))
|
||||||
|
|
||||||
|
[@blink1073](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ablink1073+updated%3A2022-01-25..2022-03-07&type=Issues) | [@clkao](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aclkao+updated%3A2022-01-25..2022-03-07&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-01-25..2022-03-07&type=Issues) | [@cqzlxl](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acqzlxl+updated%3A2022-01-25..2022-03-07&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adependabot+updated%3A2022-01-25..2022-03-07&type=Issues) | [@dtaniwaki](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adtaniwaki+updated%3A2022-01-25..2022-03-07&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Afcollonval+updated%3A2022-01-25..2022-03-07&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AGeorgianaElena+updated%3A2022-01-25..2022-03-07&type=Issues) | [@github-actions](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agithub-actions+updated%3A2022-01-25..2022-03-07&type=Issues) | [@kshitija08](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akshitija08+updated%3A2022-01-25..2022-03-07&type=Issues) | [@ktaletsk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aktaletsk+updated%3A2022-01-25..2022-03-07&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2022-01-25..2022-03-07&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-01-25..2022-03-07&type=Issues) | [@NarekA](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ANarekA+updated%3A2022-01-25..2022-03-07&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Apre-commit-ci+updated%3A2022-01-25..2022-03-07&type=Issues) | [@rajat404](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arajat404+updated%3A2022-01-25..2022-03-07&type=Issues) | [@rcthomas](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arcthomas+updated%3A2022-01-25..2022-03-07&type=Issues) | [@ryogesh](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aryogesh+updated%3A2022-01-25..2022-03-07&type=Issues) | [@rzo1](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arzo1+updated%3A2022-01-25..2022-03-07&type=Issues) | [@satra](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asatra+updated%3A2022-01-25..2022-03-07&type=Issues) | [@thomafred](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Athomafred+updated%3A2022-01-25..2022-03-07&type=Issues) | [@tmtabor](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atmtabor+updated%3A2022-01-25..2022-03-07&type=Issues) | [@tobi45](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atobi45+updated%3A2022-01-25..2022-03-07&type=Issues) | [@ykazakov](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aykazakov+updated%3A2022-01-25..2022-03-07&type=Issues)
|
||||||
|
|
||||||
|
## 2.1
|
||||||
|
|
||||||
|
### 2.1.1 2021-01-25
|
||||||
|
|
||||||
|
2.1.1 is a tiny bugfix release,
|
||||||
|
fixing an issue where admins did not receive the new `read:metrics` permission.
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.1.0...2.1.1))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- add missing read:metrics scope to admin role [#3778](https://github.com/jupyterhub/jupyterhub/pull/3778) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-01-21&to=2022-01-25&type=c))
|
||||||
|
|
||||||
|
[@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-01-21..2022-01-25&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adependabot+updated%3A2022-01-21..2022-01-25&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2022-01-21..2022-01-25&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-01-21..2022-01-25&type=Issues)
|
||||||
|
|
||||||
|
### 2.1.0 2022-01-21
|
||||||
|
|
||||||
|
2.1.0 is a small bugfix release, resolving regressions in 2.0 and further refinements.
|
||||||
|
In particular, the authenticated prometheus metrics endpoint did not work in 2.0 because it lacked a scope.
|
||||||
|
To access the authenticated metrics endpoint with a token,
|
||||||
|
upgrade to 2.1 and make sure the token/owner has the `read:metrics` scope.
|
||||||
|
|
||||||
|
Custom error messages for failed spawns are now handled more consistently on the spawn-progress API and the spawn-failed HTML page.
|
||||||
|
Previously, spawn-progress did not relay the custom message provided by `exception.jupyterhub_message`,
|
||||||
|
and full HTML messages in `exception.jupyterhub_html_message` can now be displayed in both contexts.
|
||||||
|
|
||||||
|
The long-deprecated, inconsistent behavior when users visited a URL for another user's server,
|
||||||
|
where they could sometimes be redirected back to their own server,
|
||||||
|
has been removed in favor of consistent behavior based on the user's permissions.
|
||||||
|
To share a URL that will take any user to their own server, use `https://my.hub/hub/user-redirect/path/...`.
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.2...2.1.0))
|
||||||
|
|
||||||
|
#### Enhancements made
|
||||||
|
|
||||||
|
- relay custom messages in exception.jupyterhub_message in progress API [#3764](https://github.com/jupyterhub/jupyterhub/pull/3764) ([@minrk](https://github.com/minrk))
|
||||||
|
- Add the capability to inform a connection to Alembic Migration Script [#3762](https://github.com/jupyterhub/jupyterhub/pull/3762) ([@DougTrajano](https://github.com/DougTrajano))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- Fix loading Spawner.user_options from db [#3773](https://github.com/jupyterhub/jupyterhub/pull/3773) ([@IgorBerman](https://github.com/IgorBerman))
|
||||||
|
- Add missing `read:metrics` scope for authenticated metrics endpoint [#3770](https://github.com/jupyterhub/jupyterhub/pull/3770) ([@minrk](https://github.com/minrk))
|
||||||
|
- apply scope checks to some admin-or-self situations [#3763](https://github.com/jupyterhub/jupyterhub/pull/3763) ([@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Maintenance and upkeep improvements
|
||||||
|
|
||||||
|
- DOCS: Add github metadata for edit button [#3775](https://github.com/jupyterhub/jupyterhub/pull/3775) ([@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Documentation improvements
|
||||||
|
|
||||||
|
- Improve documentation about spawner exception handling [#3765](https://github.com/jupyterhub/jupyterhub/pull/3765) ([@twalcari](https://github.com/twalcari))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-01-10&to=2022-01-21&type=c))
|
||||||
|
|
||||||
|
[@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-01-10..2022-01-21&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adependabot+updated%3A2022-01-10..2022-01-21&type=Issues) | [@DougTrajano](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ADougTrajano+updated%3A2022-01-10..2022-01-21&type=Issues) | [@IgorBerman](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AIgorBerman+updated%3A2022-01-10..2022-01-21&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-01-10..2022-01-21&type=Issues) | [@twalcari](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atwalcari+updated%3A2022-01-10..2022-01-21&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awelcome+updated%3A2022-01-10..2022-01-21&type=Issues)
|
||||||
|
|
||||||
## 2.0
|
## 2.0
|
||||||
|
|
||||||
|
### [2.0.2] 2022-01-10
|
||||||
|
|
||||||
|
2.0.2 fixes a regression in 2.0.1 causing false positives
|
||||||
|
rejecting valid requests as cross-origin,
|
||||||
|
mostly when JupyterHub is behind additional proxies.
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.1...2.0.2))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- use outermost proxied entry when looking up browser protocol [#3757](https://github.com/jupyterhub/jupyterhub/pull/3757) ([@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Maintenance and upkeep improvements
|
||||||
|
|
||||||
|
- remove unused macro with missing references [#3760](https://github.com/jupyterhub/jupyterhub/pull/3760) ([@minrk](https://github.com/minrk))
|
||||||
|
- ci: refactor to avoid triggering all tests on changes to docs [#3750](https://github.com/jupyterhub/jupyterhub/pull/3750) ([@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Extra test_cors_check tests [#3746](https://github.com/jupyterhub/jupyterhub/pull/3746) ([@manics](https://github.com/manics))
|
||||||
|
|
||||||
|
#### Documentation improvements
|
||||||
|
|
||||||
|
- DOCS: Update theme configuration [#3754](https://github.com/jupyterhub/jupyterhub/pull/3754) ([@choldgraf](https://github.com/choldgraf))
|
||||||
|
- DOC: Add note about allowed_users not being set [#3748](https://github.com/jupyterhub/jupyterhub/pull/3748) ([@choldgraf](https://github.com/choldgraf))
|
||||||
|
- localhost URL is http, not https [#3747](https://github.com/jupyterhub/jupyterhub/pull/3747) ([@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2021-12-22&to=2022-01-10&type=c))
|
||||||
|
|
||||||
|
[@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acholdgraf+updated%3A2021-12-22..2022-01-10&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2021-12-22..2022-01-10&type=Issues) | [@github-actions](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agithub-actions+updated%3A2021-12-22..2022-01-10&type=Issues) | [@jakob-keller](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajakob-keller+updated%3A2021-12-22..2022-01-10&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2021-12-22..2022-01-10&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ameeseeksmachine+updated%3A2021-12-22..2022-01-10&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2021-12-22..2022-01-10&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Apre-commit-ci+updated%3A2021-12-22..2022-01-10&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awelcome+updated%3A2021-12-22..2022-01-10&type=Issues)
|
||||||
|
|
||||||
### [2.0.1]
|
### [2.0.1]
|
||||||
|
|
||||||
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.0...2.0.1))
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.0...2.0.1))
|
||||||
@@ -1359,7 +1524,9 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
|||||||
|
|
||||||
First preview release
|
First preview release
|
||||||
|
|
||||||
[unreleased]: https://github.com/jupyterhub/jupyterhub/compare/2.0.1...HEAD
|
[unreleased]: https://github.com/jupyterhub/jupyterhub/compare/2.1.0...HEAD
|
||||||
|
[2.1.0]: https://github.com/jupyterhub/jupyterhub/compare/2.0.2...2.1.0
|
||||||
|
[2.0.2]: https://github.com/jupyterhub/jupyterhub/compare/2.0.1...2.0.2
|
||||||
[2.0.1]: https://github.com/jupyterhub/jupyterhub/compare/2.0.0...2.0.1
|
[2.0.1]: https://github.com/jupyterhub/jupyterhub/compare/2.0.0...2.0.1
|
||||||
[2.0.0]: https://github.com/jupyterhub/jupyterhub/compare/1.5.0...2.0.0
|
[2.0.0]: https://github.com/jupyterhub/jupyterhub/compare/1.5.0...2.0.0
|
||||||
[1.5.0]: https://github.com/jupyterhub/jupyterhub/compare/1.4.2...1.5.0
|
[1.5.0]: https://github.com/jupyterhub/jupyterhub/compare/1.4.2...1.5.0
|
||||||
|
@@ -21,6 +21,7 @@ extensions = [
|
|||||||
'myst_parser',
|
'myst_parser',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
myst_heading_anchors = 2
|
||||||
myst_enable_extensions = [
|
myst_enable_extensions = [
|
||||||
'colon_fence',
|
'colon_fence',
|
||||||
'deflist',
|
'deflist',
|
||||||
@@ -130,6 +131,30 @@ html_static_path = ['_static']
|
|||||||
|
|
||||||
htmlhelp_basename = 'JupyterHubdoc'
|
htmlhelp_basename = 'JupyterHubdoc'
|
||||||
|
|
||||||
|
html_theme_options = {
|
||||||
|
"icon_links": [
|
||||||
|
{
|
||||||
|
"name": "GitHub",
|
||||||
|
"url": "https://github.com/jupyterhub/jupyterhub",
|
||||||
|
"icon": "fab fa-github-square",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Discourse",
|
||||||
|
"url": "https://discourse.jupyter.org/c/jupyterhub/10",
|
||||||
|
"icon": "fab fa-discourse",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"use_edit_page_button": True,
|
||||||
|
"navbar_align": "left",
|
||||||
|
}
|
||||||
|
|
||||||
|
html_context = {
|
||||||
|
"github_user": "jupyterhub",
|
||||||
|
"github_repo": "jupyterhub",
|
||||||
|
"github_version": "main",
|
||||||
|
"doc_path": "docs",
|
||||||
|
}
|
||||||
|
|
||||||
# -- Options for LaTeX output ---------------------------------------------
|
# -- Options for LaTeX output ---------------------------------------------
|
||||||
|
|
||||||
latex_elements = {
|
latex_elements = {
|
||||||
|
@@ -16,6 +16,10 @@ c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
|
|||||||
Users in the `allowed_users` set are added to the Hub database when the Hub is
|
Users in the `allowed_users` set are added to the Hub database when the Hub is
|
||||||
started.
|
started.
|
||||||
|
|
||||||
|
```{warning}
|
||||||
|
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
|
||||||
|
```
|
||||||
|
|
||||||
## Configure admins (`admin_users`)
|
## Configure admins (`admin_users`)
|
||||||
|
|
||||||
```{note}
|
```{note}
|
||||||
|
@@ -10,4 +10,5 @@ well as other information relevant to running your own JupyterHub over time.
|
|||||||
|
|
||||||
troubleshooting
|
troubleshooting
|
||||||
admin/upgrading
|
admin/upgrading
|
||||||
|
admin/log-messages
|
||||||
changelog
|
changelog
|
||||||
|
@@ -1,16 +1,33 @@
|
|||||||
|
"""
|
||||||
|
This script updates two files with the RBAC scope descriptions found in
|
||||||
|
`scopes.py`.
|
||||||
|
|
||||||
|
The files are:
|
||||||
|
|
||||||
|
1. scope-table.md
|
||||||
|
|
||||||
|
This file is git ignored and referenced by the documentation.
|
||||||
|
|
||||||
|
2. rest-api.yml
|
||||||
|
|
||||||
|
This file is JupyterHub's REST API schema. Both a version and the RBAC
|
||||||
|
scopes descriptions are updated in it.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from subprocess import run
|
||||||
|
|
||||||
from pytablewriter import MarkdownTableWriter
|
from pytablewriter import MarkdownTableWriter
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
import jupyterhub
|
from jupyterhub import __version__
|
||||||
from jupyterhub.scopes import scope_definitions
|
from jupyterhub.scopes import scope_definitions
|
||||||
|
|
||||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||||
DOCS = Path(HERE).parent.parent.absolute()
|
DOCS = Path(HERE).parent.parent.absolute()
|
||||||
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
|
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
|
||||||
|
SCOPE_TABLE_MD = Path(HERE).joinpath("scope-table.md")
|
||||||
|
|
||||||
|
|
||||||
class ScopeTableGenerator:
|
class ScopeTableGenerator:
|
||||||
@@ -82,8 +99,9 @@ class ScopeTableGenerator:
|
|||||||
return table_rows
|
return table_rows
|
||||||
|
|
||||||
def write_table(self):
|
def write_table(self):
|
||||||
"""Generates the scope table in markdown format and writes it into `scope-table.md`"""
|
"""Generates the RBAC scopes reference documentation as a markdown table
|
||||||
filename = f"{HERE}/scope-table.md"
|
and writes it to the .gitignored `scope-table.md`."""
|
||||||
|
filename = SCOPE_TABLE_MD
|
||||||
table_name = ""
|
table_name = ""
|
||||||
headers = ["Scope", "Grants permission to:"]
|
headers = ["Scope", "Grants permission to:"]
|
||||||
values = self._parse_scopes()
|
values = self._parse_scopes()
|
||||||
@@ -99,15 +117,20 @@ class ScopeTableGenerator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def write_api(self):
|
def write_api(self):
|
||||||
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
|
"""Loads `rest-api.yml` and writes it back with a dynamically set
|
||||||
|
JupyterHub version field and list of RBAC scopes descriptions from
|
||||||
|
`scopes.py`."""
|
||||||
filename = REST_API_YAML
|
filename = REST_API_YAML
|
||||||
yaml = YAML(typ='rt')
|
|
||||||
|
yaml = YAML(typ="rt")
|
||||||
yaml.preserve_quotes = True
|
yaml.preserve_quotes = True
|
||||||
|
yaml.indent(mapping=2, offset=2, sequence=4)
|
||||||
|
|
||||||
scope_dict = {}
|
scope_dict = {}
|
||||||
with open(filename) as f:
|
with open(filename) as f:
|
||||||
content = yaml.load(f.read())
|
content = yaml.load(f.read())
|
||||||
|
|
||||||
content["info"]["version"] = jupyterhub.__version__
|
content["info"]["version"] = __version__
|
||||||
for scope in self.scopes:
|
for scope in self.scopes:
|
||||||
description = self.scopes[scope]['description']
|
description = self.scopes[scope]['description']
|
||||||
doc_description = self.scopes[scope].get('doc_description', '')
|
doc_description = self.scopes[scope].get('doc_description', '')
|
||||||
@@ -121,6 +144,12 @@ class ScopeTableGenerator:
|
|||||||
with open(filename, 'w') as f:
|
with open(filename, 'w') as f:
|
||||||
yaml.dump(content, f)
|
yaml.dump(content, f)
|
||||||
|
|
||||||
|
run(
|
||||||
|
['pre-commit', 'run', 'prettier', '--files', filename],
|
||||||
|
cwd=HERE,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
table_generator = ScopeTableGenerator()
|
table_generator = ScopeTableGenerator()
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
# Authenticators
|
# Authenticators
|
||||||
|
|
||||||
The [Authenticator][] is the mechanism for authorizing users to use the
|
The {class}`.Authenticator` is the mechanism for authorizing users to use the
|
||||||
Hub and single user notebook servers.
|
Hub and single user notebook servers.
|
||||||
|
|
||||||
## The default PAM Authenticator
|
## The default PAM Authenticator
|
||||||
@@ -137,8 +137,8 @@ via other mechanisms. One such example is using [GitHub OAuth][].
|
|||||||
|
|
||||||
Because the username is passed from the Authenticator to the Spawner,
|
Because the username is passed from the Authenticator to the Spawner,
|
||||||
a custom Authenticator and Spawner are often used together.
|
a custom Authenticator and Spawner are often used together.
|
||||||
For example, the Authenticator methods, [pre_spawn_start(user, spawner)][]
|
For example, the Authenticator methods, {meth}`.Authenticator.pre_spawn_start`
|
||||||
and [post_spawn_stop(user, spawner)][], are hooks that can be used to do
|
and {meth}`.Authenticator.post_spawn_stop`, are hooks that can be used to do
|
||||||
auth-related startup (e.g. opening PAM sessions) and cleanup
|
auth-related startup (e.g. opening PAM sessions) and cleanup
|
||||||
(e.g. closing PAM sessions).
|
(e.g. closing PAM sessions).
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ If there are multiple keys present, the **first** key is always used to persist
|
|||||||
|
|
||||||
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
|
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
|
||||||
This may mean defining environment variables, placing certificate in the user's home directory, etc.
|
This may mean defining environment variables, placing certificate in the user's home directory, etc.
|
||||||
The `Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
|
The {meth}`Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
|
||||||
to Spawner environment:
|
to Spawner environment:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -247,10 +247,42 @@ class MyAuthenticator(Authenticator):
|
|||||||
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(authenticator-groups)=
|
||||||
|
|
||||||
|
## Authenticator-managed group membership
|
||||||
|
|
||||||
|
:::{versionadded} 2.2
|
||||||
|
:::
|
||||||
|
|
||||||
|
Some identity providers may have their own concept of group membership that you would like to preserve in JupyterHub.
|
||||||
|
This is now possible with `Authenticator.managed_groups`.
|
||||||
|
|
||||||
|
You can set the config:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Authenticator.manage_groups = True
|
||||||
|
```
|
||||||
|
|
||||||
|
to enable this behavior.
|
||||||
|
The default is False for Authenticators that ship with JupyterHub,
|
||||||
|
but may be True for custom Authenticators.
|
||||||
|
Check your Authenticator's documentation for manage_groups support.
|
||||||
|
|
||||||
|
If True, {meth}`.Authenticator.authenticate` and {meth}`.Authenticator.refresh_user` may include a field `groups`
|
||||||
|
which is a list of group names the user should be a member of:
|
||||||
|
|
||||||
|
- Membership will be added for any group in the list
|
||||||
|
- Membership in any groups not in the list will be revoked
|
||||||
|
- Any groups not already present in the database will be created
|
||||||
|
- If `None` is returned, no changes are made to the user's group membership
|
||||||
|
|
||||||
|
If authenticator-managed groups are enabled,
|
||||||
|
all group-management via the API is disabled.
|
||||||
|
|
||||||
## pre_spawn_start and post_spawn_stop hooks
|
## pre_spawn_start and post_spawn_stop hooks
|
||||||
|
|
||||||
Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and
|
Authenticators uses two hooks, {meth}`.Authenticator.pre_spawn_start` and
|
||||||
[post_spawn_stop(user, spawner)][] to add pass additional state information
|
{meth}`.Authenticator.post_spawn_stop(user, spawner)` to add pass additional state information
|
||||||
between the authenticator and a spawner. These hooks are typically used auth-related
|
between the authenticator and a spawner. These hooks are typically used auth-related
|
||||||
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
|
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
|
||||||
PAM session.
|
PAM session.
|
||||||
@@ -259,10 +291,7 @@ PAM session.
|
|||||||
|
|
||||||
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||||
|
|
||||||
[authenticator]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/auth.py
|
|
||||||
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||||
[oauth]: https://en.wikipedia.org/wiki/OAuth
|
[oauth]: https://en.wikipedia.org/wiki/OAuth
|
||||||
[github oauth]: https://developer.github.com/v3/oauth/
|
[github oauth]: https://developer.github.com/v3/oauth/
|
||||||
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
|
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||||
[pre_spawn_start(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
|
|
||||||
[post_spawn_stop(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
|
||||||
|
@@ -165,7 +165,7 @@ As with nginx above, you can use [Apache](https://httpd.apache.org) as the rever
|
|||||||
First, we will need to enable the apache modules that we are going to need:
|
First, we will need to enable the apache modules that we are going to need:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
|
a2enmod ssl rewrite proxy headers proxy_http proxy_wstunnel
|
||||||
```
|
```
|
||||||
|
|
||||||
Our Apache configuration is equivalent to the nginx configuration above:
|
Our Apache configuration is equivalent to the nginx configuration above:
|
||||||
@@ -188,13 +188,24 @@ Listen 443
|
|||||||
|
|
||||||
ServerName HUB.DOMAIN.TLD
|
ServerName HUB.DOMAIN.TLD
|
||||||
|
|
||||||
|
# enable HTTP/2, if available
|
||||||
|
Protocols h2 http/1.1
|
||||||
|
|
||||||
|
# HTTP Strict Transport Security (mod_headers is required) (63072000 seconds)
|
||||||
|
Header always set Strict-Transport-Security "max-age=63072000"
|
||||||
|
|
||||||
# configure SSL
|
# configure SSL
|
||||||
SSLEngine on
|
SSLEngine on
|
||||||
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
||||||
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
||||||
SSLProtocol All -SSLv2 -SSLv3
|
|
||||||
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
|
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
|
||||||
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
|
|
||||||
|
# intermediate configuration from ssl-config.mozilla.org (2022-03-03)
|
||||||
|
# Please note, that this configuration might be out-dated - please update it accordingly using https://ssl-config.mozilla.org/
|
||||||
|
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
|
||||||
|
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
|
||||||
|
SSLHonorCipherOrder off
|
||||||
|
SSLSessionTickets off
|
||||||
|
|
||||||
# Use RewriteEngine to handle websocket connection upgrades
|
# Use RewriteEngine to handle websocket connection upgrades
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
@@ -208,6 +219,7 @@ Listen 443
|
|||||||
# proxy to JupyterHub
|
# proxy to JupyterHub
|
||||||
ProxyPass http://127.0.0.1:8000/
|
ProxyPass http://127.0.0.1:8000/
|
||||||
ProxyPassReverse http://127.0.0.1:8000/
|
ProxyPassReverse http://127.0.0.1:8000/
|
||||||
|
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||||
</Location>
|
</Location>
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
@@ -113,7 +113,6 @@ c.JupyterHub.load_roles = [
|
|||||||
"scopes": [
|
"scopes": [
|
||||||
# specify the permissions the token should have
|
# specify the permissions the token should have
|
||||||
"admin:users",
|
"admin:users",
|
||||||
"admin:services",
|
|
||||||
],
|
],
|
||||||
"services": [
|
"services": [
|
||||||
# assign the service the above permissions
|
# assign the service the above permissions
|
||||||
|
@@ -83,6 +83,7 @@ c.JupyterHub.load_roles = [
|
|||||||
# 'admin:users' # needed if culling idle users as well
|
# 'admin:users' # needed if culling idle users as well
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
@@ -208,23 +209,23 @@ can be used by services. You may go beyond this reference implementation and
|
|||||||
create custom hub-authenticating clients and services. We describe the process
|
create custom hub-authenticating clients and services. We describe the process
|
||||||
below.
|
below.
|
||||||
|
|
||||||
The reference, or base, implementation is the [`HubAuth`][hubauth] class,
|
The reference, or base, implementation is the {class}`.HubAuth` class,
|
||||||
which implements the API requests to the Hub that resolve a token to a User model.
|
which implements the API requests to the Hub that resolve a token to a User model.
|
||||||
|
|
||||||
There are two levels of authentication with the Hub:
|
There are two levels of authentication with the Hub:
|
||||||
|
|
||||||
- [`HubAuth`][hubauth] - the most basic authentication,
|
- {class}`.HubAuth` - the most basic authentication,
|
||||||
for services that should only accept API requests authorized with a token.
|
for services that should only accept API requests authorized with a token.
|
||||||
|
|
||||||
- [`HubOAuth`][huboauth] - For services that should use oauth to authenticate with the Hub.
|
- {class}`.HubOAuth` - For services that should use oauth to authenticate with the Hub.
|
||||||
This should be used for any service that serves pages that should be visited with a browser.
|
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`, either programmatically when constructing the class,
|
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
||||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||||
|
|
||||||
Most of the logic for authentication implementation is found in the
|
Most of the logic for authentication implementation is found in the
|
||||||
[`HubAuth.user_for_token`][hubauth.user_for_token]
|
{meth}`.HubAuth.user_for_token` methods,
|
||||||
methods, which makes a request of the Hub, and returns:
|
which makes a request of the Hub, and returns:
|
||||||
|
|
||||||
- None, if no user could be identified, or
|
- None, if no user could be identified, or
|
||||||
- a dict of the following form:
|
- a dict of the following form:
|
||||||
@@ -245,6 +246,19 @@ action.
|
|||||||
HubAuth also caches the Hub's response for a number of seconds,
|
HubAuth also caches the Hub's response for a number of seconds,
|
||||||
configurable by the `cookie_cache_max_age` setting (default: five minutes).
|
configurable by the `cookie_cache_max_age` setting (default: five minutes).
|
||||||
|
|
||||||
|
If your service would like to make further requests _on behalf of users_,
|
||||||
|
it should use the token issued by this OAuth process.
|
||||||
|
If you are using tornado,
|
||||||
|
you can access the token authenticating the current request with {meth}`.HubAuth.get_token`.
|
||||||
|
|
||||||
|
:::{versionchanged} 2.2
|
||||||
|
|
||||||
|
{meth}`.HubAuth.get_token` adds support for retrieving
|
||||||
|
tokens stored in tornado cookies after completion of OAuth.
|
||||||
|
Previously, it only retrieved tokens from URL parameters or the Authorization header.
|
||||||
|
Passing `get_token(handler, in_cookie=False)` preserves this behavior.
|
||||||
|
:::
|
||||||
|
|
||||||
### Flask Example
|
### Flask Example
|
||||||
|
|
||||||
For example, you have a Flask service that returns information about a user.
|
For example, you have a Flask service that returns information about a user.
|
||||||
@@ -370,11 +384,6 @@ section on securing the notebook viewer.
|
|||||||
|
|
||||||
[requests]: http://docs.python-requests.org/en/master/
|
[requests]: http://docs.python-requests.org/en/master/
|
||||||
[services_auth]: ../api/services.auth.html
|
[services_auth]: ../api/services.auth.html
|
||||||
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
|
||||||
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
|
|
||||||
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
|
||||||
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
|
||||||
[huboauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuthenticated
|
|
||||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||||
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
||||||
[fastapi]: https://fastapi.tiangolo.com
|
[fastapi]: https://fastapi.tiangolo.com
|
||||||
|
@@ -108,6 +108,16 @@ class MySpawner(Spawner):
|
|||||||
return url
|
return url
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Exception handling
|
||||||
|
|
||||||
|
When `Spawner.start` raises an Exception, a message can be passed on to the user via the exception via a `.jupyterhub_html_message` or `.jupyterhub_message` attribute.
|
||||||
|
|
||||||
|
When the Exception has a `.jupyterhub_html_message` attribute, it will be rendered as HTML to the user.
|
||||||
|
|
||||||
|
Alternatively `.jupyterhub_message` is rendered as unformatted text.
|
||||||
|
|
||||||
|
If both attributes are not present, the Exception will be shown to the user as unformatted text.
|
||||||
|
|
||||||
### Spawner.poll
|
### Spawner.poll
|
||||||
|
|
||||||
`Spawner.poll` should check if the spawner is still running.
|
`Spawner.poll` should check if the spawner is still running.
|
||||||
|
@@ -275,7 +275,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key.
|
|||||||
|
|
||||||
Then restart JupyterHub.
|
Then restart JupyterHub.
|
||||||
|
|
||||||
See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption).
|
See also {ref}`ssl-encryption`.
|
||||||
|
|
||||||
### Install JupyterHub without a network connection
|
### Install JupyterHub without a network connection
|
||||||
|
|
||||||
|
@@ -10,7 +10,9 @@ here = Path(__file__).absolute().parent
|
|||||||
root = here.parent
|
root = here.parent
|
||||||
|
|
||||||
|
|
||||||
def test_rest_api_version():
|
def test_rest_api_version_is_updated():
|
||||||
|
"""Checks that the version in JupyterHub's REST API definition file
|
||||||
|
(rest-api.yml) is matching the JupyterHub version."""
|
||||||
version_py = root.joinpath("jupyterhub", "_version.py")
|
version_py = root.joinpath("jupyterhub", "_version.py")
|
||||||
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
|
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
|
||||||
ns = {}
|
ns = {}
|
||||||
@@ -25,18 +27,17 @@ def test_rest_api_version():
|
|||||||
assert jupyterhub_version == rest_api_version
|
assert jupyterhub_version == rest_api_version
|
||||||
|
|
||||||
|
|
||||||
def test_restapi_scopes():
|
def test_rest_api_rbac_scope_descriptions_are_updated():
|
||||||
|
"""Checks that the RBAC scope descriptions in JupyterHub's REST API
|
||||||
|
definition file (rest-api.yml) as can be updated by generate-scope-table.py
|
||||||
|
matches what is committed."""
|
||||||
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
|
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
|
||||||
run(
|
|
||||||
['pre-commit', 'run', 'prettier', '--files', 'source/_static/rest-api.yml'],
|
|
||||||
cwd=here,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
run(
|
run(
|
||||||
[
|
[
|
||||||
"git",
|
"git",
|
||||||
"diff",
|
|
||||||
"--no-pager",
|
"--no-pager",
|
||||||
|
"diff",
|
||||||
|
"--color=always",
|
||||||
"--exit-code",
|
"--exit-code",
|
||||||
str(here.joinpath("source", "_static", "rest-api.yml")),
|
str(here.joinpath("source", "_static", "rest-api.yml")),
|
||||||
],
|
],
|
||||||
|
30
examples/azuread-with-group-management/jupyterhub_config.py
Normal file
30
examples/azuread-with-group-management/jupyterhub_config.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""sample jupyterhub config file for testing
|
||||||
|
|
||||||
|
configures jupyterhub with dummyauthenticator and simplespawner
|
||||||
|
to enable testing without administrative privileges.
|
||||||
|
"""
|
||||||
|
|
||||||
|
c = get_config() # noqa
|
||||||
|
c.Application.log_level = 'DEBUG'
|
||||||
|
|
||||||
|
from oauthenticator.azuread import AzureAdOAuthenticator
|
||||||
|
import os
|
||||||
|
|
||||||
|
c.JupyterHub.authenticator_class = AzureAdOAuthenticator
|
||||||
|
|
||||||
|
c.AzureAdOAuthenticator.client_id = os.getenv("AAD_CLIENT_ID")
|
||||||
|
c.AzureAdOAuthenticator.client_secret = os.getenv("AAD_CLIENT_SECRET")
|
||||||
|
c.AzureAdOAuthenticator.oauth_callback_url = os.getenv("AAD_CALLBACK_URL")
|
||||||
|
c.AzureAdOAuthenticator.tenant_id = os.getenv("AAD_TENANT_ID")
|
||||||
|
c.AzureAdOAuthenticator.username_claim = "email"
|
||||||
|
c.AzureAdOAuthenticator.authorize_url = os.getenv("AAD_AUTHORIZE_URL")
|
||||||
|
c.AzureAdOAuthenticator.token_url = os.getenv("AAD_TOKEN_URL")
|
||||||
|
c.Authenticator.manage_groups = True
|
||||||
|
c.Authenticator.refresh_pre_spawn = True
|
||||||
|
|
||||||
|
# Optionally set a global password that all users must use
|
||||||
|
# c.DummyAuthenticator.password = "your_password"
|
||||||
|
|
||||||
|
from jupyterhub.spawner import SimpleLocalProcessSpawner
|
||||||
|
|
||||||
|
c.JupyterHub.spawner_class = SimpleLocalProcessSpawner
|
2
examples/azuread-with-group-management/requirements.txt
Normal file
2
examples/azuread-with-group-management/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
oauthenticator
|
||||||
|
pyjwt
|
@@ -10,6 +10,14 @@ import "./server-dashboard.css";
|
|||||||
import { timeSince } from "../../util/timeSince";
|
import { timeSince } from "../../util/timeSince";
|
||||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||||
|
|
||||||
|
const AccessServerButton = ({ userName, serverName }) => (
|
||||||
|
<a href={`/user/${userName}/${serverName || ""}`}>
|
||||||
|
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
|
||||||
|
Access Server
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
const ServerDashboard = (props) => {
|
const ServerDashboard = (props) => {
|
||||||
// sort methods
|
// sort methods
|
||||||
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
||||||
@@ -29,6 +37,7 @@ const ServerDashboard = (props) => {
|
|||||||
|
|
||||||
var [errorAlert, setErrorAlert] = useState(null);
|
var [errorAlert, setErrorAlert] = useState(null);
|
||||||
var [sortMethod, setSortMethod] = useState(null);
|
var [sortMethod, setSortMethod] = useState(null);
|
||||||
|
var [disabledButtons, setDisabledButtons] = useState({});
|
||||||
|
|
||||||
var user_data = useSelector((state) => state.user_data),
|
var user_data = useSelector((state) => state.user_data),
|
||||||
user_page = useSelector((state) => state.user_page),
|
user_page = useSelector((state) => state.user_page),
|
||||||
@@ -72,6 +81,108 @@ const ServerDashboard = (props) => {
|
|||||||
user_data = sortMethod(user_data);
|
user_data = sortMethod(user_data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StopServerButton = ({ serverName, userName }) => {
|
||||||
|
var [isDisabled, setIsDisabled] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-xs stop-button"
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
stopServer(userName, serverName)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status < 300) {
|
||||||
|
updateUsers(...slice)
|
||||||
|
.then((data) => {
|
||||||
|
dispatchPageUpdate(data, page);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setIsDisabled(false);
|
||||||
|
setErrorAlert(`Failed to update users list.`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setErrorAlert(`Failed to stop server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrorAlert(`Failed to stop server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop Server
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StartServerButton = ({ serverName, userName }) => {
|
||||||
|
var [isDisabled, setIsDisabled] = useState(false);
|
||||||
|
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, page);
|
||||||
|
})
|
||||||
|
.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditUserCell = ({ user }) => {
|
||||||
|
return (
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-xs"
|
||||||
|
style={{ marginRight: 20 }}
|
||||||
|
onClick={() =>
|
||||||
|
history.push({
|
||||||
|
pathname: "/edit-user",
|
||||||
|
state: {
|
||||||
|
username: user.name,
|
||||||
|
has_admin: user.admin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit User
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let servers = user_data.flatMap((user) => {
|
||||||
|
let userServers = Object.values({
|
||||||
|
"": user.server || {},
|
||||||
|
...(user.servers || {}),
|
||||||
|
});
|
||||||
|
return userServers.map((server) => [user, server]);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" data-testid="container">
|
<div className="container" data-testid="container">
|
||||||
{errorAlert != null ? (
|
{errorAlert != null ? (
|
||||||
@@ -115,6 +226,14 @@ const ServerDashboard = (props) => {
|
|||||||
testid="admin-sort"
|
testid="admin-sort"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
|
<th id="server-header">
|
||||||
|
Server{" "}
|
||||||
|
<SortHandler
|
||||||
|
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
||||||
|
callback={(method) => setSortMethod(() => method)}
|
||||||
|
testid="server-sort"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th id="last-activity-header">
|
<th id="last-activity-header">
|
||||||
Last Activity{" "}
|
Last Activity{" "}
|
||||||
<SortHandler
|
<SortHandler
|
||||||
@@ -227,88 +346,66 @@ const ServerDashboard = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{user_data.map((e, i) => (
|
{servers.map(([user, server], i) => {
|
||||||
<tr key={i + "row"} className="user-row">
|
server.name = server.name || "";
|
||||||
<td data-testid="user-row-name">{e.name}</td>
|
return (
|
||||||
<td data-testid="user-row-admin">{e.admin ? "admin" : ""}</td>
|
<tr key={i + "row"} className="user-row">
|
||||||
<td data-testid="user-row-last-activity">
|
<td data-testid="user-row-name">{user.name}</td>
|
||||||
{e.last_activity ? timeSince(e.last_activity) : "Never"}
|
<td data-testid="user-row-admin">
|
||||||
</td>
|
{user.admin ? "admin" : ""}
|
||||||
<td data-testid="user-row-server-activity">
|
</td>
|
||||||
{e.server != null ? (
|
|
||||||
// Stop Single-user server
|
<td data-testid="user-row-server">
|
||||||
<button
|
{server.name ? (
|
||||||
className="btn btn-danger btn-xs stop-button"
|
<p class="text-secondary">{server.name}</p>
|
||||||
onClick={() =>
|
) : (
|
||||||
stopServer(e.name)
|
<p style={{ color: "lightgrey" }}>[MAIN]</p>
|
||||||
.then((res) => {
|
)}
|
||||||
if (res.status < 300) {
|
</td>
|
||||||
updateUsers(...slice)
|
<td data-testid="user-row-last-activity">
|
||||||
.then((data) => {
|
{server.last_activity
|
||||||
dispatchPageUpdate(data, page);
|
? timeSince(server.last_activity)
|
||||||
})
|
: "Never"}
|
||||||
.catch(() =>
|
</td>
|
||||||
setErrorAlert(`Failed to update users list.`)
|
<td data-testid="user-row-server-activity">
|
||||||
);
|
{server.started ? (
|
||||||
} else {
|
// Stop Single-user server
|
||||||
setErrorAlert(`Failed to stop server.`);
|
<>
|
||||||
}
|
<StopServerButton
|
||||||
return res;
|
serverName={server.name}
|
||||||
})
|
userName={user.name}
|
||||||
.catch(() => setErrorAlert(`Failed to stop server.`))
|
/>
|
||||||
}
|
<AccessServerButton
|
||||||
>
|
serverName={server.name}
|
||||||
Stop Server
|
userName={user.name}
|
||||||
</button>
|
/>
|
||||||
) : (
|
</>
|
||||||
// Start Single-user server
|
) : (
|
||||||
<button
|
// Start Single-user server
|
||||||
className="btn btn-primary btn-xs start-button"
|
<>
|
||||||
onClick={() =>
|
<StartServerButton
|
||||||
startServer(e.name)
|
serverName={server.name}
|
||||||
.then((res) => {
|
userName={user.name}
|
||||||
if (res.status < 300) {
|
/>
|
||||||
updateUsers(...slice)
|
<a
|
||||||
.then((data) => {
|
href={`/spawn/${user.name}${
|
||||||
dispatchPageUpdate(data, page);
|
server.name && "/" + server.name
|
||||||
})
|
}`}
|
||||||
.catch(() =>
|
>
|
||||||
setErrorAlert(`Failed to update users list.`)
|
<button
|
||||||
);
|
className="btn btn-secondary btn-xs"
|
||||||
} else {
|
style={{ marginRight: 20 }}
|
||||||
setErrorAlert(`Failed to start server.`);
|
>
|
||||||
}
|
Spawn Page
|
||||||
return res;
|
</button>
|
||||||
})
|
</a>
|
||||||
.catch(() => {
|
</>
|
||||||
setErrorAlert(`Failed to start server.`);
|
)}
|
||||||
})
|
</td>
|
||||||
}
|
<EditUserCell user={user} />
|
||||||
>
|
</tr>
|
||||||
Start Server
|
);
|
||||||
</button>
|
})}
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{/* Edit User */}
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-xs"
|
|
||||||
style={{ marginRight: 20 }}
|
|
||||||
onClick={() =>
|
|
||||||
history.push({
|
|
||||||
pathname: "/edit-user",
|
|
||||||
state: {
|
|
||||||
username: e.name,
|
|
||||||
has_admin: e.admin,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
edit user
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
|
@@ -11,8 +11,10 @@ const withAPI = withProps(() => ({
|
|||||||
(data) => data.json()
|
(data) => data.json()
|
||||||
),
|
),
|
||||||
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
|
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
|
||||||
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
|
startServer: (name, serverName = "") =>
|
||||||
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
|
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "POST"),
|
||||||
|
stopServer: (name, serverName = "") =>
|
||||||
|
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "DELETE"),
|
||||||
startAll: (names) =>
|
startAll: (names) =>
|
||||||
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
|
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
|
||||||
stopAll: (names) =>
|
stopAll: (names) =>
|
||||||
|
@@ -3664,9 +3664,9 @@ flatted@^3.1.0:
|
|||||||
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
|
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
|
||||||
|
|
||||||
follow-redirects@^1.0.0:
|
follow-redirects@^1.0.0:
|
||||||
version "1.13.0"
|
version "1.14.8"
|
||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
|
||||||
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
|
integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
|
||||||
|
|
||||||
for-in@^1.0.2:
|
for-in@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
@@ -5636,9 +5636,9 @@ nan@^2.12.1:
|
|||||||
integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
|
integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
|
||||||
|
|
||||||
nanoid@^3.1.23:
|
nanoid@^3.1.23:
|
||||||
version "3.1.23"
|
version "3.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c"
|
||||||
integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==
|
integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==
|
||||||
|
|
||||||
nanomatch@^1.2.9:
|
nanomatch@^1.2.9:
|
||||||
version "1.2.13"
|
version "1.2.13"
|
||||||
@@ -7878,9 +7878,9 @@ urix@^0.1.0:
|
|||||||
integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
|
integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
|
||||||
|
|
||||||
url-parse@^1.4.3:
|
url-parse@^1.4.3:
|
||||||
version "1.5.3"
|
version "1.5.10"
|
||||||
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862"
|
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1"
|
||||||
integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==
|
integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
querystringify "^2.1.1"
|
querystringify "^2.1.1"
|
||||||
requires-port "^1.0.0"
|
requires-port "^1.0.0"
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
# version_info updated by running `tbump`
|
# version_info updated by running `tbump`
|
||||||
version_info = (2, 0, 1, "", "")
|
version_info = (2, 2, 1, "", "")
|
||||||
|
|
||||||
# pep 440 version: no dot before beta/rc, but before .dev
|
# pep 440 version: no dot before beta/rc, but before .dev
|
||||||
# 0.1.0rc1
|
# 0.1.0rc1
|
||||||
|
@@ -55,8 +55,15 @@ def run_migrations_offline():
|
|||||||
script output.
|
script output.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
connectable = config.attributes.get('connection', None)
|
||||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
|
||||||
|
if connectable is None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
else:
|
||||||
|
context.configure(
|
||||||
|
connection=connectable, target_metadata=target_metadata, literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
@@ -69,11 +76,14 @@ def run_migrations_online():
|
|||||||
and associate a connection with the context.
|
and associate a connection with the context.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
connectable = engine_from_config(
|
connectable = config.attributes.get('connection', None)
|
||||||
config.get_section(config.config_ini_section),
|
|
||||||
prefix='sqlalchemy.',
|
if connectable is None:
|
||||||
poolclass=pool.NullPool,
|
connectable = engine_from_config(
|
||||||
)
|
config.get_section(config.config_ini_section),
|
||||||
|
prefix='sqlalchemy.',
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
@@ -16,6 +16,7 @@ from tornado import web
|
|||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import roles
|
from .. import roles
|
||||||
from .. import scopes
|
from .. import scopes
|
||||||
|
from ..utils import get_browser_protocol
|
||||||
from ..utils import token_authenticated
|
from ..utils import token_authenticated
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
@@ -115,7 +116,10 @@ class OAuthHandler:
|
|||||||
# make absolute local redirects full URLs
|
# make absolute local redirects full URLs
|
||||||
# to satisfy oauthlib's absolute URI requirement
|
# to satisfy oauthlib's absolute URI requirement
|
||||||
redirect_uri = (
|
redirect_uri = (
|
||||||
self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri
|
get_browser_protocol(self.request)
|
||||||
|
+ "://"
|
||||||
|
+ self.request.host
|
||||||
|
+ redirect_uri
|
||||||
)
|
)
|
||||||
parsed_url = urlparse(uri)
|
parsed_url = urlparse(uri)
|
||||||
query_list = parse_qsl(parsed_url.query, keep_blank_values=True)
|
query_list = parse_qsl(parsed_url.query, keep_blank_values=True)
|
||||||
|
@@ -14,6 +14,7 @@ from tornado import web
|
|||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
|
from ..utils import get_browser_protocol
|
||||||
from ..utils import isoformat
|
from ..utils import isoformat
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
|
|
||||||
@@ -60,6 +61,8 @@ class APIHandler(BaseHandler):
|
|||||||
"""
|
"""
|
||||||
host_header = self.app.forwarded_host_header or "Host"
|
host_header = self.app.forwarded_host_header or "Host"
|
||||||
host = self.request.headers.get(host_header)
|
host = self.request.headers.get(host_header)
|
||||||
|
if host and "," in host:
|
||||||
|
host = host.split(",", 1)[0].strip()
|
||||||
referer = self.request.headers.get("Referer")
|
referer = self.request.headers.get("Referer")
|
||||||
|
|
||||||
# If no header is provided, assume it comes from a script/curl.
|
# If no header is provided, assume it comes from a script/curl.
|
||||||
@@ -71,7 +74,8 @@ class APIHandler(BaseHandler):
|
|||||||
self.log.warning("Blocking API request with no referer")
|
self.log.warning("Blocking API request with no referer")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
proto = self.request.protocol
|
proto = get_browser_protocol(self.request)
|
||||||
|
|
||||||
full_host = f"{proto}://{host}{self.hub.base_url}"
|
full_host = f"{proto}://{host}{self.hub.base_url}"
|
||||||
host_url = urlparse(full_host)
|
host_url = urlparse(full_host)
|
||||||
referer_url = urlparse(referer)
|
referer_url = urlparse(referer)
|
||||||
|
@@ -33,6 +33,11 @@ class _GroupAPIHandler(APIHandler):
|
|||||||
raise web.HTTPError(404, "No such group: %s", group_name)
|
raise web.HTTPError(404, "No such group: %s", group_name)
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
def check_authenticator_managed_groups(self):
|
||||||
|
"""Raise error on group-management APIs if Authenticator is managing groups"""
|
||||||
|
if self.authenticator.manage_groups:
|
||||||
|
raise web.HTTPError(400, "Group management via API is disabled")
|
||||||
|
|
||||||
|
|
||||||
class GroupListAPIHandler(_GroupAPIHandler):
|
class GroupListAPIHandler(_GroupAPIHandler):
|
||||||
@needs_scope('list:groups')
|
@needs_scope('list:groups')
|
||||||
@@ -68,6 +73,9 @@ class GroupListAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('admin:groups')
|
@needs_scope('admin:groups')
|
||||||
async def post(self):
|
async def post(self):
|
||||||
"""POST creates Multiple groups"""
|
"""POST creates Multiple groups"""
|
||||||
|
|
||||||
|
self.check_authenticator_managed_groups()
|
||||||
|
|
||||||
model = self.get_json_body()
|
model = self.get_json_body()
|
||||||
if not model or not isinstance(model, dict) or not model.get('groups'):
|
if not model or not isinstance(model, dict) or not model.get('groups'):
|
||||||
raise web.HTTPError(400, "Must specify at least one group to create")
|
raise web.HTTPError(400, "Must specify at least one group to create")
|
||||||
@@ -106,6 +114,7 @@ class GroupAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('admin:groups')
|
@needs_scope('admin:groups')
|
||||||
async def post(self, group_name):
|
async def post(self, group_name):
|
||||||
"""POST creates a group by name"""
|
"""POST creates a group by name"""
|
||||||
|
self.check_authenticator_managed_groups()
|
||||||
model = self.get_json_body()
|
model = self.get_json_body()
|
||||||
if model is None:
|
if model is None:
|
||||||
model = {}
|
model = {}
|
||||||
@@ -132,6 +141,7 @@ class GroupAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('delete:groups')
|
@needs_scope('delete:groups')
|
||||||
def delete(self, group_name):
|
def delete(self, group_name):
|
||||||
"""Delete a group by name"""
|
"""Delete a group by name"""
|
||||||
|
self.check_authenticator_managed_groups()
|
||||||
group = self.find_group(group_name)
|
group = self.find_group(group_name)
|
||||||
self.log.info("Deleting group %s", group_name)
|
self.log.info("Deleting group %s", group_name)
|
||||||
self.db.delete(group)
|
self.db.delete(group)
|
||||||
@@ -145,6 +155,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('groups')
|
@needs_scope('groups')
|
||||||
def post(self, group_name):
|
def post(self, group_name):
|
||||||
"""POST adds users to a group"""
|
"""POST adds users to a group"""
|
||||||
|
self.check_authenticator_managed_groups()
|
||||||
group = self.find_group(group_name)
|
group = self.find_group(group_name)
|
||||||
data = self.get_json_body()
|
data = self.get_json_body()
|
||||||
self._check_group_model(data)
|
self._check_group_model(data)
|
||||||
@@ -163,6 +174,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('groups')
|
@needs_scope('groups')
|
||||||
async def delete(self, group_name):
|
async def delete(self, group_name):
|
||||||
"""DELETE removes users from a group"""
|
"""DELETE removes users from a group"""
|
||||||
|
self.check_authenticator_managed_groups()
|
||||||
group = self.find_group(group_name)
|
group = self.find_group(group_name)
|
||||||
data = self.get_json_body()
|
data = self.get_json_body()
|
||||||
self._check_group_model(data)
|
self._check_group_model(data)
|
||||||
|
@@ -515,7 +515,7 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
user_name, self.named_server_limit_per_user
|
user_name, self.named_server_limit_per_user
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.get_spawner(server_name, replace_failed=True)
|
||||||
pending = spawner.pending
|
pending = spawner.pending
|
||||||
if pending == 'spawn':
|
if pending == 'spawn':
|
||||||
self.set_header('Content-Type', 'text/plain')
|
self.set_header('Content-Type', 'text/plain')
|
||||||
@@ -714,7 +714,12 @@ class SpawnProgressAPIHandler(APIHandler):
|
|||||||
# check if spawner has just failed
|
# check if spawner has just failed
|
||||||
f = spawn_future
|
f = spawn_future
|
||||||
if f and f.done() and f.exception():
|
if f and f.done() and f.exception():
|
||||||
failed_event['message'] = "Spawn failed: %s" % f.exception()
|
exc = f.exception()
|
||||||
|
message = getattr(exc, "jupyterhub_message", str(exc))
|
||||||
|
failed_event['message'] = f"Spawn failed: {message}"
|
||||||
|
html_message = getattr(exc, "jupyterhub_html_message", "")
|
||||||
|
if html_message:
|
||||||
|
failed_event['html_message'] = html_message
|
||||||
await self.send_event(failed_event)
|
await self.send_event(failed_event)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
@@ -747,7 +752,12 @@ class SpawnProgressAPIHandler(APIHandler):
|
|||||||
# what happened? Maybe spawn failed?
|
# what happened? Maybe spawn failed?
|
||||||
f = spawn_future
|
f = spawn_future
|
||||||
if f and f.done() and f.exception():
|
if f and f.done() and f.exception():
|
||||||
failed_event['message'] = "Spawn failed: %s" % f.exception()
|
exc = f.exception()
|
||||||
|
message = getattr(exc, "jupyterhub_message", str(exc))
|
||||||
|
failed_event['message'] = f"Spawn failed: {message}"
|
||||||
|
html_message = getattr(exc, "jupyterhub_html_message", "")
|
||||||
|
if html_message:
|
||||||
|
failed_event['html_message'] = html_message
|
||||||
else:
|
else:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Server %s didn't start for unknown reason", spawner._log_name
|
"Server %s didn't start for unknown reason", spawner._log_name
|
||||||
|
@@ -2001,6 +2001,9 @@ class JupyterHub(Application):
|
|||||||
async def init_groups(self):
|
async def init_groups(self):
|
||||||
"""Load predefined groups into the database"""
|
"""Load predefined groups into the database"""
|
||||||
db = self.db
|
db = self.db
|
||||||
|
|
||||||
|
if self.authenticator.manage_groups and self.load_groups:
|
||||||
|
raise ValueError("Group management has been offloaded to the authenticator")
|
||||||
for name, usernames in self.load_groups.items():
|
for name, usernames in self.load_groups.items():
|
||||||
group = orm.Group.find(db, name)
|
group = orm.Group.find(db, name)
|
||||||
if group is None:
|
if group is None:
|
||||||
@@ -3147,7 +3150,12 @@ class JupyterHub(Application):
|
|||||||
self.last_activity_callback = pc
|
self.last_activity_callback = pc
|
||||||
pc.start()
|
pc.start()
|
||||||
|
|
||||||
self.log.info("JupyterHub is now running at %s", self.proxy.public_url)
|
if self.proxy.should_start:
|
||||||
|
self.log.info("JupyterHub is now running at %s", self.proxy.public_url)
|
||||||
|
else:
|
||||||
|
self.log.info(
|
||||||
|
"JupyterHub is now running, internal Hub API at %s", self.hub.url
|
||||||
|
)
|
||||||
# Use atexit for Windows, it doesn't have signal handling support
|
# Use atexit for Windows, it doesn't have signal handling support
|
||||||
if _mswindows:
|
if _mswindows:
|
||||||
atexit.register(self.atexit)
|
atexit.register(self.atexit)
|
||||||
|
@@ -582,9 +582,13 @@ class Authenticator(LoggingConfigurable):
|
|||||||
or None if Authentication failed.
|
or None if Authentication failed.
|
||||||
|
|
||||||
The Authenticator may return a dict instead, which MUST have a
|
The Authenticator may return a dict instead, which MUST have a
|
||||||
key `name` holding the username, and MAY have two optional keys
|
key `name` holding the username, and MAY have additional keys:
|
||||||
set: `auth_state`, a dictionary of of auth state that will be
|
|
||||||
persisted; and `admin`, the admin setting value for the user.
|
- `auth_state`, a dictionary of 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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def pre_spawn_start(self, user, spawner):
|
def pre_spawn_start(self, user, spawner):
|
||||||
@@ -635,6 +639,19 @@ class Authenticator(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
self.allowed_users.discard(user.name)
|
self.allowed_users.discard(user.name)
|
||||||
|
|
||||||
|
manage_groups = Bool(
|
||||||
|
False,
|
||||||
|
config=True,
|
||||||
|
help="""Let authenticator manage user groups
|
||||||
|
|
||||||
|
If True, Authenticator.authenticate and/or .refresh_user
|
||||||
|
may return a list of group names in the 'groups' field,
|
||||||
|
which will be assigned to the user.
|
||||||
|
|
||||||
|
All group-assignment APIs are disabled if this is True.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
auto_login = Bool(
|
auto_login = Bool(
|
||||||
False,
|
False,
|
||||||
config=True,
|
config=True,
|
||||||
@@ -958,16 +975,24 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
open_sessions = Bool(
|
open_sessions = Bool(
|
||||||
True,
|
False,
|
||||||
help="""
|
help="""
|
||||||
Whether to open a new PAM session when spawners are started.
|
Whether to open a new PAM session when spawners are started.
|
||||||
|
|
||||||
This may trigger things like mounting shared filsystems,
|
This may trigger things like mounting shared filesystems,
|
||||||
loading credentials, etc. depending on system configuration,
|
loading credentials, etc. depending on system configuration.
|
||||||
but it does not always work.
|
|
||||||
|
The lifecycle of PAM sessions is not correct,
|
||||||
|
so many PAM session configurations will not work.
|
||||||
|
|
||||||
If any errors are encountered when opening/closing PAM sessions,
|
If any errors are encountered when opening/closing PAM sessions,
|
||||||
this is automatically set to False.
|
this is automatically set to False.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.2
|
||||||
|
|
||||||
|
Due to longstanding problems in the session lifecycle,
|
||||||
|
this is now disabled by default.
|
||||||
|
You may opt-in to opening sessions by setting this to True.
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
@@ -45,10 +45,12 @@ from ..metrics import ServerSpawnStatus
|
|||||||
from ..metrics import ServerStopStatus
|
from ..metrics import ServerStopStatus
|
||||||
from ..metrics import TOTAL_USERS
|
from ..metrics import TOTAL_USERS
|
||||||
from ..objects import Server
|
from ..objects import Server
|
||||||
|
from ..scopes import needs_scope
|
||||||
from ..spawner import LocalProcessSpawner
|
from ..spawner import LocalProcessSpawner
|
||||||
from ..user import User
|
from ..user import User
|
||||||
from ..utils import AnyTimeoutError
|
from ..utils import AnyTimeoutError
|
||||||
from ..utils import get_accepted_mimetype
|
from ..utils import get_accepted_mimetype
|
||||||
|
from ..utils import get_browser_protocol
|
||||||
from ..utils import maybe_future
|
from ..utils import maybe_future
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
|
|
||||||
@@ -524,10 +526,16 @@ class BaseHandler(RequestHandler):
|
|||||||
path=url_path_join(self.base_url, 'services'),
|
path=url_path_join(self.base_url, 'services'),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
# clear tornado cookie
|
# clear_cookie only accepts a subset of set_cookie's kwargs
|
||||||
|
clear_xsrf_cookie_kwargs = {
|
||||||
|
key: value
|
||||||
|
for key, value in self.settings.get('xsrf_cookie_kwargs', {})
|
||||||
|
if key in {"path", "domain"}
|
||||||
|
}
|
||||||
|
|
||||||
self.clear_cookie(
|
self.clear_cookie(
|
||||||
'_xsrf',
|
'_xsrf',
|
||||||
**self.settings.get('xsrf_cookie_kwargs', {}),
|
**clear_xsrf_cookie_kwargs,
|
||||||
)
|
)
|
||||||
# Reset _jupyterhub_user
|
# Reset _jupyterhub_user
|
||||||
self._jupyterhub_user = None
|
self._jupyterhub_user = None
|
||||||
@@ -632,12 +640,10 @@ class BaseHandler(RequestHandler):
|
|||||||
next_url = self.get_argument('next', default='')
|
next_url = self.get_argument('next', default='')
|
||||||
# protect against some browsers' buggy handling of backslash as slash
|
# protect against some browsers' buggy handling of backslash as slash
|
||||||
next_url = next_url.replace('\\', '%5C')
|
next_url = next_url.replace('\\', '%5C')
|
||||||
if (next_url + '/').startswith(
|
proto = get_browser_protocol(self.request)
|
||||||
(
|
host = self.request.host
|
||||||
f'{self.request.protocol}://{self.request.host}/',
|
|
||||||
f'//{self.request.host}/',
|
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
|
||||||
)
|
|
||||||
) or (
|
|
||||||
self.subdomain_host
|
self.subdomain_host
|
||||||
and urlparse(next_url).netloc
|
and urlparse(next_url).netloc
|
||||||
and ("." + urlparse(next_url).netloc).endswith(
|
and ("." + urlparse(next_url).netloc).endswith(
|
||||||
@@ -774,13 +780,22 @@ class BaseHandler(RequestHandler):
|
|||||||
# always ensure default roles ('user', 'admin' if admin) are assigned
|
# always ensure default roles ('user', 'admin' if admin) are assigned
|
||||||
# after a successful login
|
# after a successful login
|
||||||
roles.assign_default_roles(self.db, entity=user)
|
roles.assign_default_roles(self.db, entity=user)
|
||||||
|
|
||||||
|
# apply authenticator-managed groups
|
||||||
|
if self.authenticator.manage_groups:
|
||||||
|
group_names = authenticated.get("groups")
|
||||||
|
if group_names is not None:
|
||||||
|
user.sync_groups(group_names)
|
||||||
|
|
||||||
# always set auth_state and commit,
|
# always set auth_state and commit,
|
||||||
# because there could be key-rotation or clearing of previous values
|
# because there could be key-rotation or clearing of previous values
|
||||||
# going on.
|
# going on.
|
||||||
if not self.authenticator.enable_auth_state:
|
if not self.authenticator.enable_auth_state:
|
||||||
# auth_state is not enabled. Force None.
|
# auth_state is not enabled. Force None.
|
||||||
auth_state = None
|
auth_state = None
|
||||||
|
|
||||||
await user.save_auth_state(auth_state)
|
await user.save_auth_state(auth_state)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def login_user(self, data=None):
|
async def login_user(self, data=None):
|
||||||
@@ -794,6 +809,7 @@ class BaseHandler(RequestHandler):
|
|||||||
self.set_login_cookie(user)
|
self.set_login_cookie(user)
|
||||||
self.statsd.incr('login.success')
|
self.statsd.incr('login.success')
|
||||||
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
||||||
|
|
||||||
self.log.info("User logged in: %s", user.name)
|
self.log.info("User logged in: %s", user.name)
|
||||||
user._auth_refreshed = time.monotonic()
|
user._auth_refreshed = time.monotonic()
|
||||||
return user
|
return user
|
||||||
@@ -1449,54 +1465,24 @@ class UserUrlHandler(BaseHandler):
|
|||||||
delete = non_get
|
delete = non_get
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
|
@needs_scope("access:servers")
|
||||||
async def get(self, user_name, user_path):
|
async def get(self, user_name, user_path):
|
||||||
if not user_path:
|
if not user_path:
|
||||||
user_path = '/'
|
user_path = '/'
|
||||||
current_user = self.current_user
|
current_user = self.current_user
|
||||||
|
if user_name != current_user.name:
|
||||||
if (
|
|
||||||
current_user
|
|
||||||
and current_user.name != user_name
|
|
||||||
and current_user.admin
|
|
||||||
and self.settings.get('admin_access', False)
|
|
||||||
):
|
|
||||||
# allow admins to spawn on behalf of users
|
|
||||||
user = self.find_user(user_name)
|
user = self.find_user(user_name)
|
||||||
if user is None:
|
if user is None:
|
||||||
# no such user
|
# no such user
|
||||||
raise web.HTTPError(404, "No such user %s" % user_name)
|
raise web.HTTPError(404, f"No such user {user_name}")
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Admin %s requesting spawn on behalf of %s",
|
f"User {current_user.name} requesting spawn on behalf of {user.name}"
|
||||||
current_user.name,
|
|
||||||
user.name,
|
|
||||||
)
|
)
|
||||||
admin_spawn = True
|
admin_spawn = True
|
||||||
should_spawn = True
|
should_spawn = True
|
||||||
redirect_to_self = False
|
redirect_to_self = False
|
||||||
else:
|
else:
|
||||||
user = current_user
|
user = current_user
|
||||||
admin_spawn = False
|
|
||||||
# For non-admins, spawn if the user requested is the current user
|
|
||||||
# otherwise redirect users to their own server
|
|
||||||
should_spawn = current_user and current_user.name == user_name
|
|
||||||
redirect_to_self = not should_spawn
|
|
||||||
|
|
||||||
if redirect_to_self:
|
|
||||||
# logged in as a different non-admin user, redirect to user's own server
|
|
||||||
# this is only a stop-gap for a common mistake,
|
|
||||||
# because the same request will be a 403
|
|
||||||
# if the requested server is running
|
|
||||||
self.statsd.incr('redirects.user_to_user', 1)
|
|
||||||
self.log.warning(
|
|
||||||
"User %s requested server for %s, which they don't own",
|
|
||||||
current_user.name,
|
|
||||||
user_name,
|
|
||||||
)
|
|
||||||
target = url_path_join(current_user.url, user_path or '')
|
|
||||||
if self.request.query:
|
|
||||||
target = url_concat(target, parse_qsl(self.request.query))
|
|
||||||
self.redirect(target)
|
|
||||||
return
|
|
||||||
|
|
||||||
# If people visit /user/:user_name directly on the Hub,
|
# If people visit /user/:user_name directly on the Hub,
|
||||||
# the redirects will just loop, because the proxy is bypassed.
|
# the redirects will just loop, because the proxy is bypassed.
|
||||||
@@ -1540,14 +1526,10 @@ class UserUrlHandler(BaseHandler):
|
|||||||
|
|
||||||
# if request is expecting JSON, assume it's an API request and fail with 503
|
# if request is expecting JSON, assume it's an API request and fail with 503
|
||||||
# because it won't like the redirect to the pending page
|
# because it won't like the redirect to the pending page
|
||||||
if (
|
if get_accepted_mimetype(
|
||||||
get_accepted_mimetype(
|
self.request.headers.get('Accept', ''),
|
||||||
self.request.headers.get('Accept', ''),
|
choices=['application/json', 'text/html'],
|
||||||
choices=['application/json', 'text/html'],
|
) == 'application/json' or 'api' in user_path.split('/'):
|
||||||
)
|
|
||||||
== 'application/json'
|
|
||||||
or 'api' in user_path.split('/')
|
|
||||||
):
|
|
||||||
self._fail_api_request(user_name, server_name)
|
self._fail_api_request(user_name, server_name)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1629,7 +1611,7 @@ class UserUrlHandler(BaseHandler):
|
|||||||
if redirects:
|
if redirects:
|
||||||
self.log.warning("Redirect loop detected on %s", self.request.uri)
|
self.log.warning("Redirect loop detected on %s", self.request.uri)
|
||||||
# add capped exponential backoff where cap is 10s
|
# add capped exponential backoff where cap is 10s
|
||||||
await asyncio.sleep(min(1 * (2 ** redirects), 10))
|
await asyncio.sleep(min(1 * (2**redirects), 10))
|
||||||
# rewrite target url with new `redirects` query value
|
# rewrite target url with new `redirects` query value
|
||||||
url_parts = urlparse(target)
|
url_parts = urlparse(target)
|
||||||
query_parts = parse_qs(url_parts.query)
|
query_parts = parse_qs(url_parts.query)
|
||||||
|
@@ -12,6 +12,8 @@ class MetricsHandler(BaseHandler):
|
|||||||
Handler to serve Prometheus metrics
|
Handler to serve Prometheus metrics
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_accept_token_auth = True
|
||||||
|
|
||||||
@metrics_authentication
|
@metrics_authentication
|
||||||
async def get(self):
|
async def get(self):
|
||||||
self.set_header('Content-Type', CONTENT_TYPE_LATEST)
|
self.set_header('Content-Type', CONTENT_TYPE_LATEST)
|
||||||
|
@@ -106,22 +106,27 @@ class SpawnHandler(BaseHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
async def get(self, for_user=None, server_name=''):
|
def get(self, user_name=None, server_name=''):
|
||||||
"""GET renders form for spawning with user-specified options
|
"""GET renders form for spawning with user-specified options
|
||||||
|
|
||||||
or triggers spawn via redirect if there is no form.
|
or triggers spawn via redirect if there is no form.
|
||||||
"""
|
"""
|
||||||
|
# two-stage to get the right signature for @require_scopes filter on user_name
|
||||||
|
if user_name is None:
|
||||||
|
user_name = self.current_user.name
|
||||||
|
if server_name is None:
|
||||||
|
server_name = ""
|
||||||
|
return self._get(user_name=user_name, server_name=server_name)
|
||||||
|
|
||||||
|
@needs_scope("servers")
|
||||||
|
async def _get(self, user_name, server_name):
|
||||||
|
for_user = user_name
|
||||||
|
|
||||||
user = current_user = self.current_user
|
user = current_user = self.current_user
|
||||||
if for_user is not None and for_user != user.name:
|
if for_user != user.name:
|
||||||
if not user.admin:
|
|
||||||
raise web.HTTPError(
|
|
||||||
403, "Only admins can spawn on behalf of other users"
|
|
||||||
)
|
|
||||||
|
|
||||||
user = self.find_user(for_user)
|
user = self.find_user(for_user)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
raise web.HTTPError(404, f"No such user: {for_user}")
|
||||||
|
|
||||||
if server_name:
|
if server_name:
|
||||||
if not self.allow_named_servers:
|
if not self.allow_named_servers:
|
||||||
@@ -141,15 +146,12 @@ class SpawnHandler(BaseHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not self.allow_named_servers and user.running:
|
if not self.allow_named_servers and user.running:
|
||||||
url = self.get_next_url(user, default=user.server_url(server_name))
|
url = self.get_next_url(user, default=user.server_url(""))
|
||||||
self.log.info("User is running: %s", user.name)
|
self.log.info("User is running: %s", user.name)
|
||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
return
|
return
|
||||||
|
|
||||||
if server_name is None:
|
spawner = user.get_spawner(server_name, replace_failed=True)
|
||||||
server_name = ''
|
|
||||||
|
|
||||||
spawner = user.spawners[server_name]
|
|
||||||
|
|
||||||
pending_url = self._get_pending_url(user, server_name)
|
pending_url = self._get_pending_url(user, server_name)
|
||||||
|
|
||||||
@@ -189,7 +191,6 @@ class SpawnHandler(BaseHandler):
|
|||||||
spawner._log_name,
|
spawner._log_name,
|
||||||
)
|
)
|
||||||
options = await maybe_future(spawner.options_from_query(query_options))
|
options = await maybe_future(spawner.options_from_query(query_options))
|
||||||
pending_url = self._get_pending_url(user, server_name)
|
|
||||||
return await self._wrap_spawn_single_user(
|
return await self._wrap_spawn_single_user(
|
||||||
user, server_name, spawner, pending_url, options
|
user, server_name, spawner, pending_url, options
|
||||||
)
|
)
|
||||||
@@ -219,19 +220,24 @@ class SpawnHandler(BaseHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
async def post(self, for_user=None, server_name=''):
|
def post(self, user_name=None, server_name=''):
|
||||||
"""POST spawns with user-specified options"""
|
"""POST spawns with user-specified options"""
|
||||||
|
if user_name is None:
|
||||||
|
user_name = self.current_user.name
|
||||||
|
if server_name is None:
|
||||||
|
server_name = ""
|
||||||
|
return self._post(user_name=user_name, server_name=server_name)
|
||||||
|
|
||||||
|
@needs_scope("servers")
|
||||||
|
async def _post(self, user_name, server_name):
|
||||||
|
for_user = user_name
|
||||||
user = current_user = self.current_user
|
user = current_user = self.current_user
|
||||||
if for_user is not None and for_user != user.name:
|
if for_user != user.name:
|
||||||
if not user.admin:
|
|
||||||
raise web.HTTPError(
|
|
||||||
403, "Only admins can spawn on behalf of other users"
|
|
||||||
)
|
|
||||||
user = self.find_user(for_user)
|
user = self.find_user(for_user)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
raise web.HTTPError(404, "No such user: %s" % for_user)
|
||||||
|
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.get_spawner(server_name, replace_failed=True)
|
||||||
|
|
||||||
if spawner.ready:
|
if spawner.ready:
|
||||||
raise web.HTTPError(400, "%s is already running" % (spawner._log_name))
|
raise web.HTTPError(400, "%s is already running" % (spawner._log_name))
|
||||||
@@ -249,7 +255,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Triggering spawn with supplied form options for %s", spawner._log_name
|
"Triggering spawn with supplied form options for %s", spawner._log_name
|
||||||
)
|
)
|
||||||
options = await maybe_future(spawner.options_from_form(form_options))
|
options = await maybe_future(spawner.run_options_from_form(form_options))
|
||||||
pending_url = self._get_pending_url(user, server_name)
|
pending_url = self._get_pending_url(user, server_name)
|
||||||
return await self._wrap_spawn_single_user(
|
return await self._wrap_spawn_single_user(
|
||||||
user, server_name, spawner, pending_url, options
|
user, server_name, spawner, pending_url, options
|
||||||
@@ -337,13 +343,11 @@ class SpawnPendingHandler(BaseHandler):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
async def get(self, for_user, server_name=''):
|
@needs_scope("servers")
|
||||||
|
async def get(self, user_name, server_name=''):
|
||||||
|
for_user = user_name
|
||||||
user = current_user = self.current_user
|
user = current_user = self.current_user
|
||||||
if for_user is not None and for_user != current_user.name:
|
if for_user != current_user.name:
|
||||||
if not current_user.admin:
|
|
||||||
raise web.HTTPError(
|
|
||||||
403, "Only admins can spawn on behalf of other users"
|
|
||||||
)
|
|
||||||
user = self.find_user(for_user)
|
user = self.find_user(for_user)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
raise web.HTTPError(404, "No such user: %s" % for_user)
|
||||||
@@ -365,13 +369,9 @@ class SpawnPendingHandler(BaseHandler):
|
|||||||
auth_state = await user.get_auth_state()
|
auth_state = await user.get_auth_state()
|
||||||
|
|
||||||
# First, check for previous failure.
|
# First, check for previous failure.
|
||||||
if (
|
if not spawner.active and spawner._failed:
|
||||||
not spawner.active
|
# Condition: spawner not active and last spawn failed
|
||||||
and spawner._spawn_future
|
# (failure is available as spawner._spawn_future.exception()).
|
||||||
and spawner._spawn_future.done()
|
|
||||||
and spawner._spawn_future.exception()
|
|
||||||
):
|
|
||||||
# Condition: spawner not active and _spawn_future exists and contains an Exception
|
|
||||||
# Implicit spawn on /user/:name is not allowed if the user's last spawn failed.
|
# Implicit spawn on /user/:name is not allowed if the user's last spawn failed.
|
||||||
# We should point the user to Home if the most recent spawn failed.
|
# We should point the user to Home if the most recent spawn failed.
|
||||||
exc = spawner._spawn_future.exception()
|
exc = spawner._spawn_future.exception()
|
||||||
@@ -387,6 +387,7 @@ class SpawnPendingHandler(BaseHandler):
|
|||||||
server_name=server_name,
|
server_name=server_name,
|
||||||
spawn_url=spawn_url,
|
spawn_url=spawn_url,
|
||||||
failed=True,
|
failed=True,
|
||||||
|
failed_html_message=getattr(exc, 'jupyterhub_html_message', ''),
|
||||||
failed_message=getattr(exc, 'jupyterhub_message', ''),
|
failed_message=getattr(exc, 'jupyterhub_message', ''),
|
||||||
exception=exc,
|
exception=exc,
|
||||||
)
|
)
|
||||||
|
@@ -45,6 +45,7 @@ def get_default_roles():
|
|||||||
'access:services',
|
'access:services',
|
||||||
'access:servers',
|
'access:servers',
|
||||||
'read:roles',
|
'read:roles',
|
||||||
|
'read:metrics',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -402,6 +403,10 @@ def _token_allowed_role(db, token, role):
|
|||||||
if owner is None:
|
if owner is None:
|
||||||
raise ValueError(f"Owner not found for {token}")
|
raise ValueError(f"Owner not found for {token}")
|
||||||
|
|
||||||
|
if role in owner.roles:
|
||||||
|
# shortcut: token is assigned an exact role the owner has
|
||||||
|
return True
|
||||||
|
|
||||||
expanded_scopes = _get_subscopes(role, owner=owner)
|
expanded_scopes = _get_subscopes(role, owner=owner)
|
||||||
|
|
||||||
implicit_permissions = {'inherit', 'read:inherit'}
|
implicit_permissions = {'inherit', 'read:inherit'}
|
||||||
|
@@ -131,6 +131,9 @@ scope_definitions = {
|
|||||||
'description': 'Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
|
'description': 'Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
|
||||||
},
|
},
|
||||||
'shutdown': {'description': 'Shutdown the hub.'},
|
'shutdown': {'description': 'Shutdown the hub.'},
|
||||||
|
'read:metrics': {
|
||||||
|
'description': "Read prometheus metrics.",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -53,6 +53,7 @@ from traitlets import validate
|
|||||||
from traitlets.config import SingletonConfigurable
|
from traitlets.config import SingletonConfigurable
|
||||||
|
|
||||||
from ..scopes import _intersect_expanded_scopes
|
from ..scopes import _intersect_expanded_scopes
|
||||||
|
from ..utils import get_browser_protocol
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
|
|
||||||
|
|
||||||
@@ -500,11 +501,17 @@ class HubAuth(SingletonConfigurable):
|
|||||||
auth_header_name = 'Authorization'
|
auth_header_name = 'Authorization'
|
||||||
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
|
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
|
||||||
|
|
||||||
def get_token(self, handler):
|
def get_token(self, handler, in_cookie=True):
|
||||||
"""Get the user token from a request
|
"""Get the token authenticating a request
|
||||||
|
|
||||||
|
.. versionchanged:: 2.2
|
||||||
|
in_cookie added.
|
||||||
|
Previously, only URL params and header were considered.
|
||||||
|
Pass `in_cookie=False` to preserve that behavior.
|
||||||
|
|
||||||
- in URL parameters: ?token=<token>
|
- in URL parameters: ?token=<token>
|
||||||
- in header: Authorization: token <token>
|
- in header: Authorization: token <token>
|
||||||
|
- in cookie (stored after oauth), if in_cookie is True
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user_token = handler.get_argument('token', '')
|
user_token = handler.get_argument('token', '')
|
||||||
@@ -515,8 +522,14 @@ class HubAuth(SingletonConfigurable):
|
|||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
user_token = m.group(1)
|
user_token = m.group(1)
|
||||||
|
if not user_token and in_cookie:
|
||||||
|
user_token = self._get_token_cookie(handler)
|
||||||
return user_token
|
return user_token
|
||||||
|
|
||||||
|
def _get_token_cookie(self, handler):
|
||||||
|
"""Base class doesn't store tokens in cookies"""
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_user_cookie(self, handler):
|
def _get_user_cookie(self, handler):
|
||||||
"""Get the user model from a cookie"""
|
"""Get the user model from a cookie"""
|
||||||
# overridden in HubOAuth to store the access token after oauth
|
# overridden in HubOAuth to store the access token after oauth
|
||||||
@@ -552,8 +565,10 @@ class HubAuth(SingletonConfigurable):
|
|||||||
handler._cached_hub_user = user_model = None
|
handler._cached_hub_user = user_model = None
|
||||||
session_id = self.get_session_id(handler)
|
session_id = self.get_session_id(handler)
|
||||||
|
|
||||||
# check token first
|
# check token first, ignoring cookies
|
||||||
token = self.get_token(handler)
|
# because some checks are different when a request
|
||||||
|
# is token-authenticated (CORS-related)
|
||||||
|
token = self.get_token(handler, in_cookie=False)
|
||||||
if token:
|
if token:
|
||||||
user_model = self.user_for_token(token, session_id=session_id)
|
user_model = self.user_for_token(token, session_id=session_id)
|
||||||
if user_model:
|
if user_model:
|
||||||
@@ -613,11 +628,18 @@ class HubOAuth(HubAuth):
|
|||||||
"""
|
"""
|
||||||
return self.cookie_name + '-oauth-state'
|
return self.cookie_name + '-oauth-state'
|
||||||
|
|
||||||
def _get_user_cookie(self, handler):
|
def _get_token_cookie(self, handler):
|
||||||
|
"""Base class doesn't store tokens in cookies"""
|
||||||
token = handler.get_secure_cookie(self.cookie_name)
|
token = handler.get_secure_cookie(self.cookie_name)
|
||||||
|
if token:
|
||||||
|
# decode cookie bytes
|
||||||
|
token = token.decode('ascii', 'replace')
|
||||||
|
return token
|
||||||
|
|
||||||
|
def _get_user_cookie(self, handler):
|
||||||
|
token = self._get_token_cookie(handler)
|
||||||
session_id = self.get_session_id(handler)
|
session_id = self.get_session_id(handler)
|
||||||
if token:
|
if token:
|
||||||
token = token.decode('ascii', 'replace')
|
|
||||||
user_model = self.user_for_token(token, session_id=session_id)
|
user_model = self.user_for_token(token, session_id=session_id)
|
||||||
if user_model is None:
|
if user_model is None:
|
||||||
app_log.warning("Token stored in cookie may have expired")
|
app_log.warning("Token stored in cookie may have expired")
|
||||||
@@ -772,7 +794,7 @@ class HubOAuth(HubAuth):
|
|||||||
# OAuth that doesn't complete shouldn't linger too long.
|
# OAuth that doesn't complete shouldn't linger too long.
|
||||||
'max_age': 600,
|
'max_age': 600,
|
||||||
}
|
}
|
||||||
if handler.request.protocol == 'https':
|
if get_browser_protocol(handler.request) == 'https':
|
||||||
kwargs['secure'] = True
|
kwargs['secure'] = True
|
||||||
# load user cookie overrides
|
# load user cookie overrides
|
||||||
kwargs.update(self.cookie_options)
|
kwargs.update(self.cookie_options)
|
||||||
@@ -812,7 +834,7 @@ class HubOAuth(HubAuth):
|
|||||||
def set_cookie(self, handler, access_token):
|
def set_cookie(self, handler, access_token):
|
||||||
"""Set a cookie recording OAuth result"""
|
"""Set a cookie recording OAuth result"""
|
||||||
kwargs = {'path': self.base_url, 'httponly': True}
|
kwargs = {'path': self.base_url, 'httponly': True}
|
||||||
if handler.request.protocol == 'https':
|
if get_browser_protocol(handler.request) == 'https':
|
||||||
kwargs['secure'] = True
|
kwargs['secure'] = True
|
||||||
# load user cookie overrides
|
# load user cookie overrides
|
||||||
kwargs.update(self.cookie_options)
|
kwargs.update(self.cookie_options)
|
||||||
|
@@ -16,7 +16,6 @@ import random
|
|||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime
|
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
@@ -493,7 +492,7 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
i,
|
i,
|
||||||
RETRIES,
|
RETRIES,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(min(2 ** i, 16))
|
await asyncio.sleep(min(2**i, 16))
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -680,6 +679,7 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
s['hub_prefix'] = self.hub_prefix
|
s['hub_prefix'] = self.hub_prefix
|
||||||
s['hub_host'] = self.hub_host
|
s['hub_host'] = self.hub_host
|
||||||
s['hub_auth'] = self.hub_auth
|
s['hub_auth'] = self.hub_auth
|
||||||
|
s['page_config_hook'] = self.page_config_hook
|
||||||
csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
|
csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
|
||||||
self.hub_prefix, 'security/csp-report'
|
self.hub_prefix, 'security/csp-report'
|
||||||
)
|
)
|
||||||
@@ -707,6 +707,18 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
self.patch_default_headers()
|
self.patch_default_headers()
|
||||||
self.patch_templates()
|
self.patch_templates()
|
||||||
|
|
||||||
|
def page_config_hook(self, handler, page_config):
|
||||||
|
"""JupyterLab page config hook
|
||||||
|
|
||||||
|
Adds JupyterHub info to page config.
|
||||||
|
|
||||||
|
Places the JupyterHub API token in PageConfig.token.
|
||||||
|
|
||||||
|
Only has effect on jupyterlab_server >=2.9
|
||||||
|
"""
|
||||||
|
page_config["token"] = self.hub_auth.get_token(handler) or ""
|
||||||
|
return page_config
|
||||||
|
|
||||||
def patch_default_headers(self):
|
def patch_default_headers(self):
|
||||||
if hasattr(RequestHandler, '_orig_set_default_headers'):
|
if hasattr(RequestHandler, '_orig_set_default_headers'):
|
||||||
return
|
return
|
||||||
|
@@ -11,6 +11,7 @@ import shutil
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
from inspect import signature
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -183,17 +184,38 @@ class Spawner(LoggingConfigurable):
|
|||||||
def last_activity(self):
|
def last_activity(self):
|
||||||
return self.orm_spawner.last_activity
|
return self.orm_spawner.last_activity
|
||||||
|
|
||||||
|
# Spawner.server is a wrapper of the ORM orm_spawner.server
|
||||||
|
# make sure it's always in sync with the underlying state
|
||||||
|
# this is harder to do with traitlets,
|
||||||
|
# which do not run on every access, only on set and first-get
|
||||||
|
_server = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def server(self):
|
def server(self):
|
||||||
if hasattr(self, '_server'):
|
# always check that we're in sync with orm_spawner
|
||||||
|
if not self.orm_spawner:
|
||||||
|
# no ORM spawner, nothing to check
|
||||||
return self._server
|
return self._server
|
||||||
if self.orm_spawner and self.orm_spawner.server:
|
|
||||||
return Server(orm_server=self.orm_spawner.server)
|
orm_server = self.orm_spawner.server
|
||||||
|
|
||||||
|
if orm_server is not None and (
|
||||||
|
self._server is None or orm_server is not self._server.orm_server
|
||||||
|
):
|
||||||
|
# self._server is not connected to orm_spawner
|
||||||
|
self._server = Server(orm_server=self.orm_spawner.server)
|
||||||
|
elif orm_server is None:
|
||||||
|
# no ORM server, clear it
|
||||||
|
self._server = None
|
||||||
|
return self._server
|
||||||
|
|
||||||
@server.setter
|
@server.setter
|
||||||
def server(self, server):
|
def server(self, server):
|
||||||
self._server = server
|
self._server = server
|
||||||
if self.orm_spawner:
|
if self.orm_spawner is not None:
|
||||||
|
if server is not None and server.orm_server == self.orm_spawner.server:
|
||||||
|
# no change
|
||||||
|
return
|
||||||
if self.orm_spawner.server is not None:
|
if self.orm_spawner.server is not None:
|
||||||
# delete the old value
|
# delete the old value
|
||||||
db = inspect(self.orm_spawner.server).session
|
db = inspect(self.orm_spawner.server).session
|
||||||
@@ -201,7 +223,13 @@ class Spawner(LoggingConfigurable):
|
|||||||
if server is None:
|
if server is None:
|
||||||
self.orm_spawner.server = None
|
self.orm_spawner.server = None
|
||||||
else:
|
else:
|
||||||
|
if server.orm_server is None:
|
||||||
|
self.log.warning(f"No ORM server for {self._log_name}")
|
||||||
self.orm_spawner.server = server.orm_server
|
self.orm_spawner.server = server.orm_server
|
||||||
|
elif server is not None:
|
||||||
|
self.log.warning(
|
||||||
|
"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
@@ -424,6 +452,13 @@ class Spawner(LoggingConfigurable):
|
|||||||
def _default_options_from_form(self, form_data):
|
def _default_options_from_form(self, form_data):
|
||||||
return form_data
|
return form_data
|
||||||
|
|
||||||
|
def run_options_from_form(self, form_data):
|
||||||
|
sig = signature(self.options_from_form)
|
||||||
|
if 'spawner' in sig.parameters:
|
||||||
|
return self.options_from_form(form_data, spawner=self)
|
||||||
|
else:
|
||||||
|
return self.options_from_form(form_data)
|
||||||
|
|
||||||
def options_from_query(self, query_data):
|
def options_from_query(self, query_data):
|
||||||
"""Interpret query arguments passed to /spawn
|
"""Interpret query arguments passed to /spawn
|
||||||
|
|
||||||
|
@@ -98,27 +98,62 @@ async def test_post_content_type(app, content_type, status):
|
|||||||
|
|
||||||
|
|
||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
"host, referer, status",
|
"host, referer, extraheaders, status",
|
||||||
[
|
[
|
||||||
('$host', '$url', 200),
|
('$host', '$url', {}, 200),
|
||||||
(None, None, 200),
|
(None, None, {}, 200),
|
||||||
(None, 'null', 403),
|
(None, 'null', {}, 403),
|
||||||
(None, 'http://attack.com/csrf/vulnerability', 403),
|
(None, 'http://attack.com/csrf/vulnerability', {}, 403),
|
||||||
('$host', {"path": "/user/someuser"}, 403),
|
('$host', {"path": "/user/someuser"}, {}, 403),
|
||||||
('$host', {"path": "{path}/foo/bar/subpath"}, 200),
|
('$host', {"path": "{path}/foo/bar/subpath"}, {}, 200),
|
||||||
# mismatch host
|
# mismatch host
|
||||||
("mismatch.com", "$url", 403),
|
("mismatch.com", "$url", {}, 403),
|
||||||
# explicit host, matches
|
# explicit host, matches
|
||||||
("fake.example", {"netloc": "fake.example"}, 200),
|
("fake.example", {"netloc": "fake.example"}, {}, 200),
|
||||||
# explicit port, matches implicit port
|
# explicit port, matches implicit port
|
||||||
("fake.example:80", {"netloc": "fake.example"}, 200),
|
("fake.example:80", {"netloc": "fake.example"}, {}, 200),
|
||||||
# explicit port, mismatch
|
# explicit port, mismatch
|
||||||
("fake.example:81", {"netloc": "fake.example"}, 403),
|
("fake.example:81", {"netloc": "fake.example"}, {}, 403),
|
||||||
# implicit ports, mismatch proto
|
# implicit ports, mismatch proto
|
||||||
("fake.example", {"netloc": "fake.example", "scheme": "https"}, 403),
|
("fake.example", {"netloc": "fake.example", "scheme": "https"}, {}, 403),
|
||||||
|
# explicit ports, match
|
||||||
|
("fake.example:81", {"netloc": "fake.example:81"}, {}, 200),
|
||||||
|
# Test proxy protocol defined headers taken into account by utils.get_browser_protocol
|
||||||
|
(
|
||||||
|
"fake.example",
|
||||||
|
{"netloc": "fake.example", "scheme": "https"},
|
||||||
|
{'X-Scheme': 'https'},
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fake.example",
|
||||||
|
{"netloc": "fake.example", "scheme": "https"},
|
||||||
|
{'X-Forwarded-Proto': 'https'},
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fake.example",
|
||||||
|
{"netloc": "fake.example", "scheme": "https"},
|
||||||
|
{
|
||||||
|
'Forwarded': 'host=fake.example;proto=https,for=1.2.34;proto=http',
|
||||||
|
'X-Scheme': 'http',
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fake.example",
|
||||||
|
{"netloc": "fake.example", "scheme": "https"},
|
||||||
|
{
|
||||||
|
'Forwarded': 'host=fake.example;proto=http,for=1.2.34;proto=http',
|
||||||
|
'X-Scheme': 'https',
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https'}, 403),
|
||||||
|
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https, http'}, 403),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_cors_check(request, app, host, referer, status):
|
async def test_cors_check(request, app, host, referer, extraheaders, status):
|
||||||
url = ujoin(public_host(app), app.hub.base_url)
|
url = ujoin(public_host(app), app.hub.base_url)
|
||||||
real_host = urlparse(url).netloc
|
real_host = urlparse(url).netloc
|
||||||
if host == "$host":
|
if host == "$host":
|
||||||
@@ -140,6 +175,7 @@ async def test_cors_check(request, app, host, referer, status):
|
|||||||
headers['X-Forwarded-Host'] = host
|
headers['X-Forwarded-Host'] = host
|
||||||
if referer is not None:
|
if referer is not None:
|
||||||
headers['Referer'] = referer
|
headers['Referer'] = referer
|
||||||
|
headers.update(extraheaders)
|
||||||
|
|
||||||
# add admin user
|
# add admin user
|
||||||
user = find_user(app.db, 'admin')
|
user = find_user(app.db, 'admin')
|
||||||
@@ -994,7 +1030,7 @@ async def test_never_spawn(app, no_patience, never_spawn):
|
|||||||
assert not app_user.spawner._spawn_pending
|
assert not app_user.spawner._spawn_pending
|
||||||
status = await app_user.spawner.poll()
|
status = await app_user.spawner.poll()
|
||||||
assert status is not None
|
assert status is not None
|
||||||
# failed spawn should decrements pending count
|
# failed spawn should decrement pending count
|
||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -1003,9 +1039,16 @@ async def test_bad_spawn(app, bad_spawn):
|
|||||||
name = 'prim'
|
name = 'prim'
|
||||||
user = add_user(db, app=app, name=name)
|
user = add_user(db, app=app, name=name)
|
||||||
r = await api_request(app, 'users', name, 'server', method='post')
|
r = await api_request(app, 'users', name, 'server', method='post')
|
||||||
|
# check that we don't re-use spawners that failed
|
||||||
|
user.spawners[''].reused = True
|
||||||
assert r.status_code == 500
|
assert r.status_code == 500
|
||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
|
|
||||||
|
r = await api_request(app, 'users', name, 'server', method='post')
|
||||||
|
# check that we don't re-use spawners that failed
|
||||||
|
spawner = user.spawners['']
|
||||||
|
assert not getattr(spawner, 'reused', False)
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_nosuch_user(app):
|
async def test_spawn_nosuch_user(app):
|
||||||
r = await api_request(app, 'users', "nosuchuser", 'server', method='post')
|
r = await api_request(app, 'users', "nosuchuser", 'server', method='post')
|
||||||
@@ -1770,6 +1813,38 @@ async def test_group_add_delete_users(app):
|
|||||||
assert sorted(u.name for u in group.users) == sorted(names[2:])
|
assert sorted(u.name for u in group.users) == sorted(names[2:])
|
||||||
|
|
||||||
|
|
||||||
|
@mark.group
|
||||||
|
async def test_auth_managed_groups(request, app, group, user):
|
||||||
|
group.users.append(user)
|
||||||
|
app.db.commit()
|
||||||
|
app.authenticator.manage_groups = True
|
||||||
|
request.addfinalizer(lambda: setattr(app.authenticator, "manage_groups", False))
|
||||||
|
# create groups
|
||||||
|
r = await api_request(app, 'groups', method='post')
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = await api_request(app, 'groups/newgroup', method='post')
|
||||||
|
assert r.status_code == 400
|
||||||
|
# delete groups
|
||||||
|
r = await api_request(app, f'groups/{group.name}', method='delete')
|
||||||
|
assert r.status_code == 400
|
||||||
|
# add users to group
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'groups/{group.name}/users',
|
||||||
|
method='post',
|
||||||
|
data=json.dumps({"users": [user.name]}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
# remove users from group
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'groups/{group.name}/users',
|
||||||
|
method='delete',
|
||||||
|
data=json.dumps({"users": [user.name]}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
# -----------------
|
# -----------------
|
||||||
# Service API tests
|
# Service API tests
|
||||||
# -----------------
|
# -----------------
|
||||||
|
@@ -7,6 +7,7 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from requests import HTTPError
|
from requests import HTTPError
|
||||||
|
from traitlets import Any
|
||||||
from traitlets.config import Config
|
from traitlets.config import Config
|
||||||
|
|
||||||
from .mocking import MockPAMAuthenticator
|
from .mocking import MockPAMAuthenticator
|
||||||
@@ -14,6 +15,7 @@ from .mocking import MockStructGroup
|
|||||||
from .mocking import MockStructPasswd
|
from .mocking import MockStructPasswd
|
||||||
from .utils import add_user
|
from .utils import add_user
|
||||||
from .utils import async_requests
|
from .utils import async_requests
|
||||||
|
from .utils import get_page
|
||||||
from .utils import public_url
|
from .utils import public_url
|
||||||
from jupyterhub import auth
|
from jupyterhub import auth
|
||||||
from jupyterhub import crypto
|
from jupyterhub import crypto
|
||||||
@@ -527,3 +529,71 @@ async def test_nullauthenticator(app):
|
|||||||
r = await async_requests.get(public_url(app))
|
r = await async_requests.get(public_url(app))
|
||||||
assert urlparse(r.url).path.endswith("/hub/login")
|
assert urlparse(r.url).path.endswith("/hub/login")
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class MockGroupsAuthenticator(auth.Authenticator):
|
||||||
|
authenticated_groups = Any()
|
||||||
|
refresh_groups = Any()
|
||||||
|
|
||||||
|
manage_groups = True
|
||||||
|
|
||||||
|
def authenticate(self, handler, data):
|
||||||
|
return {
|
||||||
|
"name": data["username"],
|
||||||
|
"groups": self.authenticated_groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def refresh_user(self, user, handler):
|
||||||
|
return {
|
||||||
|
"name": user.name,
|
||||||
|
"groups": self.refresh_groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"authenticated_groups, refresh_groups",
|
||||||
|
[
|
||||||
|
(None, None),
|
||||||
|
(["auth1"], None),
|
||||||
|
(None, ["auth1"]),
|
||||||
|
(["auth1"], ["auth1", "auth2"]),
|
||||||
|
(["auth1", "auth2"], ["auth1"]),
|
||||||
|
(["auth1", "auth2"], ["auth3"]),
|
||||||
|
(["auth1", "auth2"], ["auth3"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_auth_managed_groups(
|
||||||
|
app, user, group, authenticated_groups, refresh_groups
|
||||||
|
):
|
||||||
|
|
||||||
|
authenticator = MockGroupsAuthenticator(
|
||||||
|
parent=app,
|
||||||
|
authenticated_groups=authenticated_groups,
|
||||||
|
refresh_groups=refresh_groups,
|
||||||
|
)
|
||||||
|
|
||||||
|
user.groups.append(group)
|
||||||
|
app.db.commit()
|
||||||
|
before_groups = [group.name]
|
||||||
|
if authenticated_groups is None:
|
||||||
|
expected_authenticated_groups = before_groups
|
||||||
|
else:
|
||||||
|
expected_authenticated_groups = authenticated_groups
|
||||||
|
if refresh_groups is None:
|
||||||
|
expected_refresh_groups = expected_authenticated_groups
|
||||||
|
else:
|
||||||
|
expected_refresh_groups = refresh_groups
|
||||||
|
|
||||||
|
with mock.patch.dict(app.tornado_settings, {"authenticator": authenticator}):
|
||||||
|
cookies = await app.login_user(user.name)
|
||||||
|
assert not app.db.dirty
|
||||||
|
groups = sorted(g.name for g in user.groups)
|
||||||
|
assert groups == expected_authenticated_groups
|
||||||
|
|
||||||
|
# force refresh_user on next request
|
||||||
|
user._auth_refreshed -= 10 + app.authenticator.auth_refresh_age
|
||||||
|
r = await get_page('home', app, cookies=cookies, allow_redirects=False)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert not app.db.dirty
|
||||||
|
groups = sorted(g.name for g in user.groups)
|
||||||
|
assert groups == expected_refresh_groups
|
||||||
|
@@ -1,9 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from .utils import add_user
|
|
||||||
from .utils import api_request
|
from .utils import api_request
|
||||||
|
from .utils import get_page
|
||||||
from jupyterhub import metrics
|
from jupyterhub import metrics
|
||||||
from jupyterhub import orm
|
from jupyterhub import orm
|
||||||
|
from jupyterhub import roles
|
||||||
|
|
||||||
|
|
||||||
async def test_total_users(app):
|
async def test_total_users(app):
|
||||||
@@ -32,3 +36,42 @@ async def test_total_users(app):
|
|||||||
|
|
||||||
sample = metrics.TOTAL_USERS.collect()[0].samples[0]
|
sample = metrics.TOTAL_USERS.collect()[0].samples[0]
|
||||||
assert sample.value == num_users
|
assert sample.value == num_users
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"authenticate_prometheus, authenticated, authorized, success",
|
||||||
|
[
|
||||||
|
(True, True, True, True),
|
||||||
|
(True, True, False, False),
|
||||||
|
(True, False, False, False),
|
||||||
|
(False, True, True, True),
|
||||||
|
(False, False, False, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_metrics_auth(
|
||||||
|
app,
|
||||||
|
authenticate_prometheus,
|
||||||
|
authenticated,
|
||||||
|
authorized,
|
||||||
|
success,
|
||||||
|
create_temp_role,
|
||||||
|
user,
|
||||||
|
):
|
||||||
|
if authorized:
|
||||||
|
role = create_temp_role(["read:metrics"])
|
||||||
|
roles.grant_role(app.db, user, role)
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if authenticated:
|
||||||
|
token = user.new_api_token()
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
|
||||||
|
with mock.patch.dict(
|
||||||
|
app.tornado_settings, {"authenticate_prometheus": authenticate_prometheus}
|
||||||
|
):
|
||||||
|
r = await get_page("metrics", app, headers=headers)
|
||||||
|
if success:
|
||||||
|
assert r.status_code == 200
|
||||||
|
else:
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert 'read:metrics' in r.text
|
||||||
|
@@ -12,6 +12,7 @@ from tornado.escape import url_escape
|
|||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
from .. import roles
|
||||||
from .. import scopes
|
from .. import scopes
|
||||||
from ..auth import Authenticator
|
from ..auth import Authenticator
|
||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
@@ -20,7 +21,6 @@ from ..utils import url_path_join as ujoin
|
|||||||
from .mocking import FalsyCallableFormSpawner
|
from .mocking import FalsyCallableFormSpawner
|
||||||
from .mocking import FormSpawner
|
from .mocking import FormSpawner
|
||||||
from .test_api import next_event
|
from .test_api import next_event
|
||||||
from .utils import add_user
|
|
||||||
from .utils import api_request
|
from .utils import api_request
|
||||||
from .utils import async_requests
|
from .utils import async_requests
|
||||||
from .utils import AsyncSession
|
from .utils import AsyncSession
|
||||||
@@ -48,16 +48,16 @@ async def test_root_auth(app):
|
|||||||
# if spawning was quick, there will be one more entry that's public_url(user)
|
# if spawning was quick, there will be one more entry that's public_url(user)
|
||||||
|
|
||||||
|
|
||||||
async def test_root_redirect(app):
|
async def test_root_redirect(app, user):
|
||||||
name = 'wash'
|
name = 'wash'
|
||||||
cookies = await app.login_user(name)
|
cookies = await app.login_user(name)
|
||||||
next_url = ujoin(app.base_url, 'user/other/test.ipynb')
|
next_url = ujoin(app.base_url, f'user/{user.name}/test.ipynb')
|
||||||
url = '/?' + urlencode({'next': next_url})
|
url = '/?' + urlencode({'next': next_url})
|
||||||
r = await get_page(url, app, cookies=cookies)
|
r = await get_page(url, app, cookies=cookies)
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
assert path == ujoin(app.base_url, f'hub/user/{user.name}/test.ipynb')
|
||||||
# serve "server not running" page, which has status 424
|
# preserves choice to requested user, which 404s as unavailable without access
|
||||||
assert r.status_code == 424
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_root_default_url_noauth(app):
|
async def test_root_default_url_noauth(app):
|
||||||
@@ -128,11 +128,20 @@ async def test_admin_sort(app, sort):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_redirect(app):
|
@pytest.mark.parametrize("last_failed", [True, False])
|
||||||
|
async def test_spawn_redirect(app, last_failed):
|
||||||
name = 'wash'
|
name = 'wash'
|
||||||
cookies = await app.login_user(name)
|
cookies = await app.login_user(name)
|
||||||
u = app.users[orm.User.find(app.db, name)]
|
u = app.users[orm.User.find(app.db, name)]
|
||||||
|
|
||||||
|
if last_failed:
|
||||||
|
# mock a failed spawn
|
||||||
|
last_spawner = u.spawners['']
|
||||||
|
last_spawner._spawn_future = asyncio.Future()
|
||||||
|
last_spawner._spawn_future.set_exception(RuntimeError("I failed!"))
|
||||||
|
else:
|
||||||
|
last_spawner = None
|
||||||
|
|
||||||
status = await u.spawner.poll()
|
status = await u.spawner.poll()
|
||||||
assert status is not None
|
assert status is not None
|
||||||
|
|
||||||
@@ -141,6 +150,10 @@ async def test_spawn_redirect(app):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
|
|
||||||
|
# ensure we got a new spawner
|
||||||
|
assert u.spawners[''] is not last_spawner
|
||||||
|
|
||||||
# make sure we visited hub/spawn-pending after spawn
|
# make sure we visited hub/spawn-pending after spawn
|
||||||
# if spawn was really quick, we might get redirected all the way to the running server,
|
# if spawn was really quick, we might get redirected all the way to the running server,
|
||||||
# so check history instead of r.url
|
# so check history instead of r.url
|
||||||
@@ -203,13 +216,34 @@ async def test_spawn_handler_access(app):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_admin_access(app, admin_access):
|
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||||
"""GET /user/:name as admin with admin-access spawns user's server"""
|
async def test_spawn_other_user(
|
||||||
cookies = await app.login_user('admin')
|
app, user, username, group, create_temp_role, has_access
|
||||||
name = 'mariel'
|
):
|
||||||
user = add_user(app.db, app=app, name=name)
|
"""GET /user/:name as another user with access to spawns user's server"""
|
||||||
app.db.commit()
|
cookies = await app.login_user(username)
|
||||||
|
requester = app.users[username]
|
||||||
|
name = user.name
|
||||||
|
|
||||||
|
if has_access:
|
||||||
|
if has_access == "group":
|
||||||
|
group.users.append(user)
|
||||||
|
app.db.commit()
|
||||||
|
scopes = [
|
||||||
|
f"access:servers!group={group.name}",
|
||||||
|
f"servers!group={group.name}",
|
||||||
|
]
|
||||||
|
elif has_access == "all":
|
||||||
|
scopes = ["access:servers", "servers"]
|
||||||
|
elif has_access == "user":
|
||||||
|
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
|
||||||
|
role = create_temp_role(scopes)
|
||||||
|
roles.grant_role(app.db, requester, role)
|
||||||
|
|
||||||
r = await get_page('spawn/' + name, app, cookies=cookies)
|
r = await get_page('spawn/' + name, app, cookies=cookies)
|
||||||
|
if not has_access:
|
||||||
|
assert r.status_code == 404
|
||||||
|
return
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
while '/spawn-pending/' in r.url:
|
while '/spawn-pending/' in r.url:
|
||||||
@@ -237,6 +271,25 @@ async def test_spawn_page(app):
|
|||||||
assert FormSpawner.options_form in r.text
|
assert FormSpawner.options_form in r.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_page_after_failed(app, user):
|
||||||
|
cookies = await app.login_user(user.name)
|
||||||
|
|
||||||
|
# mock a failed spawn
|
||||||
|
last_spawner = user.spawners['']
|
||||||
|
last_spawner._spawn_future = asyncio.Future()
|
||||||
|
last_spawner._spawn_future.set_exception(RuntimeError("I failed!"))
|
||||||
|
|
||||||
|
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||||
|
r = await get_page('spawn', app, cookies=cookies)
|
||||||
|
spawner = user.spawners['']
|
||||||
|
# make sure we didn't reuse last spawner
|
||||||
|
assert isinstance(spawner, FormSpawner)
|
||||||
|
assert spawner is not last_spawner
|
||||||
|
assert r.url.endswith('/spawn')
|
||||||
|
spawner = user.spawners['']
|
||||||
|
assert FormSpawner.options_form in r.text
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_page_falsy_callable(app):
|
async def test_spawn_page_falsy_callable(app):
|
||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
app.users.settings, {'spawner_class': FalsyCallableFormSpawner}
|
app.users.settings, {'spawner_class': FalsyCallableFormSpawner}
|
||||||
@@ -248,14 +301,36 @@ async def test_spawn_page_falsy_callable(app):
|
|||||||
assert history[1] == ujoin(public_url(app), "hub/spawn-pending/erik")
|
assert history[1] == ujoin(public_url(app), "hub/spawn-pending/erik")
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_page_admin(app, admin_access):
|
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||||
|
async def test_spawn_page_access(
|
||||||
|
app, has_access, group, username, user, create_temp_role
|
||||||
|
):
|
||||||
|
cookies = await app.login_user(username)
|
||||||
|
requester = app.users[username]
|
||||||
|
if has_access:
|
||||||
|
if has_access == "group":
|
||||||
|
group.users.append(user)
|
||||||
|
app.db.commit()
|
||||||
|
scopes = [
|
||||||
|
f"access:servers!group={group.name}",
|
||||||
|
f"servers!group={group.name}",
|
||||||
|
]
|
||||||
|
elif has_access == "all":
|
||||||
|
scopes = ["access:servers", "servers"]
|
||||||
|
elif has_access == "user":
|
||||||
|
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
|
||||||
|
role = create_temp_role(scopes)
|
||||||
|
roles.grant_role(app.db, requester, role)
|
||||||
|
|
||||||
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||||
cookies = await app.login_user('admin')
|
r = await get_page('spawn/' + user.name, app, cookies=cookies)
|
||||||
u = add_user(app.db, app=app, name='melanie')
|
if not has_access:
|
||||||
r = await get_page('spawn/' + u.name, app, cookies=cookies)
|
assert r.status_code == 404
|
||||||
assert r.url.endswith('/spawn/' + u.name)
|
return
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.url.endswith('/spawn/' + user.name)
|
||||||
assert FormSpawner.options_form in r.text
|
assert FormSpawner.options_form in r.text
|
||||||
assert f"Spawning server for {u.name}" in r.text
|
assert f"Spawning server for {user.name}" in r.text
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_with_query_arguments(app):
|
async def test_spawn_with_query_arguments(app):
|
||||||
@@ -322,18 +397,39 @@ async def test_spawn_form(app):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_form_admin_access(app, admin_access):
|
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||||
|
async def test_spawn_form_other_user(
|
||||||
|
app, username, user, group, create_temp_role, has_access
|
||||||
|
):
|
||||||
|
cookies = await app.login_user(username)
|
||||||
|
requester = app.users[username]
|
||||||
|
if has_access:
|
||||||
|
if has_access == "group":
|
||||||
|
group.users.append(user)
|
||||||
|
app.db.commit()
|
||||||
|
scopes = [
|
||||||
|
f"access:servers!group={group.name}",
|
||||||
|
f"servers!group={group.name}",
|
||||||
|
]
|
||||||
|
elif has_access == "all":
|
||||||
|
scopes = ["access:servers", "servers"]
|
||||||
|
elif has_access == "user":
|
||||||
|
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
|
||||||
|
role = create_temp_role(scopes)
|
||||||
|
roles.grant_role(app.db, requester, role)
|
||||||
|
|
||||||
with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
|
with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
|
||||||
base_url = ujoin(public_host(app), app.hub.base_url)
|
base_url = ujoin(public_host(app), app.hub.base_url)
|
||||||
cookies = await app.login_user('admin')
|
next_url = ujoin(app.base_url, 'user', user.name, 'tree')
|
||||||
u = add_user(app.db, app=app, name='martha')
|
|
||||||
next_url = ujoin(app.base_url, 'user', u.name, 'tree')
|
|
||||||
|
|
||||||
r = await async_requests.post(
|
r = await async_requests.post(
|
||||||
url_concat(ujoin(base_url, 'spawn', u.name), {'next': next_url}),
|
url_concat(ujoin(base_url, 'spawn', user.name), {'next': next_url}),
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
|
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
|
||||||
)
|
)
|
||||||
|
if not has_access:
|
||||||
|
assert r.status_code == 404
|
||||||
|
return
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
while '/spawn-pending/' in r.url:
|
while '/spawn-pending/' in r.url:
|
||||||
@@ -342,8 +438,8 @@ async def test_spawn_form_admin_access(app, admin_access):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
assert r.history
|
assert r.history
|
||||||
assert r.url.startswith(public_url(app, u))
|
assert r.url.startswith(public_url(app, user))
|
||||||
assert u.spawner.user_options == {
|
assert user.spawner.user_options == {
|
||||||
'energy': '938MeV',
|
'energy': '938MeV',
|
||||||
'bounds': [-3, 3],
|
'bounds': [-3, 3],
|
||||||
'notspecified': 5,
|
'notspecified': 5,
|
||||||
@@ -498,31 +594,54 @@ async def test_user_redirect_hook(app, username):
|
|||||||
assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1')
|
assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1')
|
||||||
|
|
||||||
|
|
||||||
async def test_user_redirect_deprecated(app, username):
|
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
|
||||||
"""redirecting from /user/someonelse/ URLs (deprecated)"""
|
async def test_other_user_url(app, username, user, group, create_temp_role, has_access):
|
||||||
|
"""Test accessing /user/someonelse/ URLs when the server is not running
|
||||||
|
|
||||||
|
Used to redirect to your own server,
|
||||||
|
which produced inconsistent behavior depending on whether the server was running.
|
||||||
|
"""
|
||||||
name = username
|
name = username
|
||||||
cookies = await app.login_user(name)
|
cookies = await app.login_user(name)
|
||||||
|
other_user = user
|
||||||
|
requester = app.users[name]
|
||||||
|
other_user_url = f"/user/{other_user.name}"
|
||||||
|
if has_access:
|
||||||
|
if has_access == "group":
|
||||||
|
group.users.append(other_user)
|
||||||
|
app.db.commit()
|
||||||
|
scopes = [f"access:servers!group={group.name}"]
|
||||||
|
elif has_access == "all":
|
||||||
|
scopes = ["access:servers"]
|
||||||
|
elif has_access == "user":
|
||||||
|
scopes = [f"access:servers!user={other_user.name}"]
|
||||||
|
role = create_temp_role(scopes)
|
||||||
|
roles.grant_role(app.db, requester, role)
|
||||||
|
status = 424
|
||||||
|
else:
|
||||||
|
# 404 - access denied without revealing if the user exists
|
||||||
|
status = 404
|
||||||
|
|
||||||
r = await get_page('/user/baduser', app, cookies=cookies, hub=False)
|
r = await get_page(other_user_url, app, cookies=cookies, hub=False)
|
||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/')
|
||||||
assert r.status_code == 424
|
assert r.status_code == status
|
||||||
|
|
||||||
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
|
r = await get_page(f'{other_user_url}/test.ipynb', app, cookies=cookies, hub=False)
|
||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/test.ipynb')
|
||||||
assert r.status_code == 424
|
assert r.status_code == status
|
||||||
|
|
||||||
r = await get_page('/user/baduser/test.ipynb', app, hub=False)
|
r = await get_page(f'{other_user_url}/test.ipynb', app, hub=False)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, '/hub/login')
|
assert path == ujoin(app.base_url, '/hub/login')
|
||||||
query = urlparse(r.url).query
|
query = urlparse(r.url).query
|
||||||
assert query == urlencode(
|
assert query == urlencode(
|
||||||
{'next': ujoin(app.base_url, '/hub/user/baduser/test.ipynb')}
|
{'next': ujoin(app.base_url, f'/hub/user/{other_user.name}/test.ipynb')}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1110,19 +1229,6 @@ async def test_server_not_running_api_request_legacy_status(app):
|
|||||||
assert r.status_code == 503
|
assert r.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
async def test_metrics_no_auth(app):
|
|
||||||
r = await get_page("metrics", app)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
async def test_metrics_auth(app):
|
|
||||||
cookies = await app.login_user('river')
|
|
||||||
metrics_url = ujoin(public_host(app), app.hub.base_url, 'metrics')
|
|
||||||
r = await get_page("metrics", app, cookies=cookies)
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert r.url == metrics_url
|
|
||||||
|
|
||||||
|
|
||||||
async def test_health_check_request(app):
|
async def test_health_check_request(app):
|
||||||
r = await get_page('health', app)
|
r = await get_page('health', app)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
@@ -81,6 +81,18 @@ async def test_spawner(db, request):
|
|||||||
assert isinstance(status, int)
|
assert isinstance(status, int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawner_from_db(app, user):
|
||||||
|
spawner = user.spawners['name']
|
||||||
|
user_options = {"test": "value"}
|
||||||
|
spawner.orm_spawner.user_options = user_options
|
||||||
|
app.db.commit()
|
||||||
|
# delete and recreate the spawner from the db
|
||||||
|
user.spawners.pop('name')
|
||||||
|
new_spawner = user.spawners['name']
|
||||||
|
assert new_spawner.orm_spawner.user_options == user_options
|
||||||
|
assert new_spawner.user_options == user_options
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_spawner(spawner, timeout=10):
|
async def wait_for_spawner(spawner, timeout=10):
|
||||||
"""Wait for an http server to show up
|
"""Wait for an http server to show up
|
||||||
|
|
||||||
@@ -447,3 +459,80 @@ async def test_spawner_oauth_roles_bad(app, user):
|
|||||||
# raises ValueError if we try to assign a role that doesn't exist
|
# raises ValueError if we try to assign a role that doesn't exist
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await spawner.user.spawn()
|
await spawner.user.spawn()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawner_options_from_form(db):
|
||||||
|
def options_from_form(form_data):
|
||||||
|
return form_data
|
||||||
|
|
||||||
|
spawner = new_spawner(db, options_from_form=options_from_form)
|
||||||
|
form_data = {"key": ["value"]}
|
||||||
|
result = spawner.run_options_from_form(form_data)
|
||||||
|
for key, value in form_data.items():
|
||||||
|
assert key in result
|
||||||
|
assert result[key] == value
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawner_options_from_form_with_spawner(db):
|
||||||
|
def options_from_form(form_data, spawner):
|
||||||
|
return form_data
|
||||||
|
|
||||||
|
spawner = new_spawner(db, options_from_form=options_from_form)
|
||||||
|
form_data = {"key": ["value"]}
|
||||||
|
result = spawner.run_options_from_form(form_data)
|
||||||
|
for key, value in form_data.items():
|
||||||
|
assert key in result
|
||||||
|
assert result[key] == value
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawner_server(db):
|
||||||
|
spawner = new_spawner(db)
|
||||||
|
spawner.orm_spawner = None
|
||||||
|
orm_spawner = orm.Spawner()
|
||||||
|
orm_server = orm.Server(base_url="/1/")
|
||||||
|
orm_spawner.server = orm_server
|
||||||
|
db.add(orm_spawner)
|
||||||
|
db.add(orm_server)
|
||||||
|
db.commit()
|
||||||
|
# initial: no orm_spawner
|
||||||
|
assert spawner.server is None
|
||||||
|
# assigning spawner.orm_spawner updates spawner.server
|
||||||
|
spawner.orm_spawner = orm_spawner
|
||||||
|
assert spawner.server is not None
|
||||||
|
assert spawner.server.orm_server is orm_server
|
||||||
|
# update orm_spawner.server without direct access on Spawner
|
||||||
|
orm_spawner.server = new_server = orm.Server(base_url="/2/")
|
||||||
|
db.commit()
|
||||||
|
assert spawner.server is not None
|
||||||
|
assert spawner.server.orm_server is not orm_server
|
||||||
|
assert spawner.server.orm_server is new_server
|
||||||
|
# clear orm_server via orm_spawner clears spawner.server
|
||||||
|
orm_spawner.server = None
|
||||||
|
db.commit()
|
||||||
|
assert spawner.server is None
|
||||||
|
# assigning spawner.server updates orm_spawner.server
|
||||||
|
orm_server = orm.Server(base_url="/3/")
|
||||||
|
db.add(orm_server)
|
||||||
|
db.commit()
|
||||||
|
spawner.server = server = Server(orm_server=orm_server)
|
||||||
|
db.commit()
|
||||||
|
assert spawner.server is server
|
||||||
|
assert spawner.orm_spawner.server is orm_server
|
||||||
|
# change orm spawner.server
|
||||||
|
orm_server = orm.Server(base_url="/4/")
|
||||||
|
db.add(orm_server)
|
||||||
|
db.commit()
|
||||||
|
spawner.server = server2 = Server(orm_server=orm_server)
|
||||||
|
assert spawner.server is server2
|
||||||
|
assert spawner.orm_spawner.server is orm_server
|
||||||
|
# clear server via spawner.server
|
||||||
|
spawner.server = None
|
||||||
|
db.commit()
|
||||||
|
assert spawner.orm_spawner.server is None
|
||||||
|
|
||||||
|
# test with no underlying orm.Spawner
|
||||||
|
# (only relevant for mocking, never true for actual Spawners)
|
||||||
|
spawner = Spawner()
|
||||||
|
spawner.server = Server.from_url("http://1.2.3.4")
|
||||||
|
assert spawner.server is not None
|
||||||
|
assert spawner.server.ip == "1.2.3.4"
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from .. import orm
|
||||||
from ..user import UserDict
|
from ..user import UserDict
|
||||||
from .utils import add_user
|
from .utils import add_user
|
||||||
|
|
||||||
@@ -20,3 +21,35 @@ async def test_userdict_get(db, attr):
|
|||||||
assert userdict.get(key).id == u.id
|
assert userdict.get(key).id == u.id
|
||||||
# `in` should find it now
|
# `in` should find it now
|
||||||
assert key in userdict
|
assert key in userdict
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"group_names",
|
||||||
|
[
|
||||||
|
["isin1", "isin2"],
|
||||||
|
["isin1"],
|
||||||
|
["notin", "isin1"],
|
||||||
|
["new-group", "isin1"],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_sync_groups(app, user, group_names):
|
||||||
|
expected = sorted(group_names)
|
||||||
|
db = app.db
|
||||||
|
db.add(orm.Group(name="notin"))
|
||||||
|
in_groups = [orm.Group(name="isin1"), orm.Group(name="isin2")]
|
||||||
|
for group in in_groups:
|
||||||
|
db.add(group)
|
||||||
|
db.commit()
|
||||||
|
user.groups = in_groups
|
||||||
|
db.commit()
|
||||||
|
user.sync_groups(group_names)
|
||||||
|
assert not app.db.dirty
|
||||||
|
after_groups = sorted(g.name for g in user.groups)
|
||||||
|
assert after_groups == expected
|
||||||
|
# double-check backref
|
||||||
|
for group in db.query(orm.Group):
|
||||||
|
if group.name in expected:
|
||||||
|
assert user.orm_user in group.users
|
||||||
|
else:
|
||||||
|
assert user.orm_user not in group.users
|
||||||
|
@@ -2,12 +2,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from async_generator import aclosing
|
from async_generator import aclosing
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.concurrent import run_on_executor
|
from tornado.concurrent import run_on_executor
|
||||||
|
from tornado.httpserver import HTTPRequest
|
||||||
|
from tornado.httputil import HTTPHeaders
|
||||||
|
|
||||||
|
from .. import utils
|
||||||
from ..utils import iterate_until
|
from ..utils import iterate_until
|
||||||
|
|
||||||
|
|
||||||
@@ -88,3 +92,33 @@ async def test_tornado_coroutines():
|
|||||||
# verify that tornado gen and executor methods return awaitables
|
# verify that tornado gen and executor methods return awaitables
|
||||||
assert (await t.on_executor()) == "executor"
|
assert (await t.on_executor()) == "executor"
|
||||||
assert (await t.tornado_coroutine()) == "gen.coroutine"
|
assert (await t.tornado_coroutine()) == "gen.coroutine"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"forwarded, x_scheme, x_forwarded_proto, expected",
|
||||||
|
[
|
||||||
|
("", "", "", "_attr_"),
|
||||||
|
("for=1.2.3.4", "", "", "_attr_"),
|
||||||
|
("for=1.2.3.4,proto=https", "", "", "_attr_"),
|
||||||
|
("", "https", "http", "https"),
|
||||||
|
("", "https, http", "", "https"),
|
||||||
|
("", "https, http", "http", "https"),
|
||||||
|
("proto=http ; for=1.2.3.4, proto=https", "https, http", "", "http"),
|
||||||
|
("proto=invalid;for=1.2.3.4,proto=http", "https, http", "", "https"),
|
||||||
|
("for=1.2.3.4,proto=http", "https, http", "", "https"),
|
||||||
|
("", "invalid, http", "", "_attr_"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_browser_protocol(x_scheme, x_forwarded_proto, forwarded, expected):
|
||||||
|
request = Mock(spec=HTTPRequest)
|
||||||
|
request.protocol = "_attr_"
|
||||||
|
request.headers = HTTPHeaders()
|
||||||
|
if x_scheme:
|
||||||
|
request.headers["X-Scheme"] = x_scheme
|
||||||
|
if x_forwarded_proto:
|
||||||
|
request.headers["X-Forwarded-Proto"] = x_forwarded_proto
|
||||||
|
if forwarded:
|
||||||
|
request.headers["Forwarded"] = forwarded
|
||||||
|
|
||||||
|
proto = utils.get_browser_protocol(request)
|
||||||
|
assert proto == expected
|
||||||
|
@@ -253,6 +253,58 @@ class User:
|
|||||||
def spawner_class(self):
|
def spawner_class(self):
|
||||||
return self.settings.get('spawner_class', LocalProcessSpawner)
|
return self.settings.get('spawner_class', LocalProcessSpawner)
|
||||||
|
|
||||||
|
def get_spawner(self, server_name="", replace_failed=False):
|
||||||
|
"""Get a spawner by name
|
||||||
|
|
||||||
|
replace_failed governs whether a failed spawner should be replaced
|
||||||
|
or returned (default: returned).
|
||||||
|
|
||||||
|
.. versionadded:: 2.2
|
||||||
|
"""
|
||||||
|
spawner = self.spawners[server_name]
|
||||||
|
if replace_failed and spawner._failed:
|
||||||
|
self.log.debug(f"Discarding failed spawner {spawner._log_name}")
|
||||||
|
# remove failed spawner, create a new one
|
||||||
|
self.spawners.pop(server_name)
|
||||||
|
spawner = self.spawners[server_name]
|
||||||
|
return spawner
|
||||||
|
|
||||||
|
def sync_groups(self, group_names):
|
||||||
|
"""Synchronize groups with database"""
|
||||||
|
|
||||||
|
current_groups = {g.name for g in self.orm_user.groups}
|
||||||
|
new_groups = set(group_names)
|
||||||
|
if current_groups == new_groups:
|
||||||
|
# no change, nothing to do
|
||||||
|
return
|
||||||
|
|
||||||
|
# log group changes
|
||||||
|
new_groups = set(group_names).difference(current_groups)
|
||||||
|
removed_groups = current_groups.difference(group_names)
|
||||||
|
if new_groups:
|
||||||
|
self.log.info("Adding user {self.name} to group(s): {new_groups}")
|
||||||
|
if removed_groups:
|
||||||
|
self.log.info("Removing user {self.name} from group(s): {removed_groups}")
|
||||||
|
|
||||||
|
if group_names:
|
||||||
|
groups = (
|
||||||
|
self.db.query(orm.Group).filter(orm.Group.name.in_(group_names)).all()
|
||||||
|
)
|
||||||
|
existing_groups = {g.name for g in groups}
|
||||||
|
for group_name in group_names:
|
||||||
|
if group_name not in existing_groups:
|
||||||
|
# create groups that don't exist yet
|
||||||
|
self.log.info(
|
||||||
|
f"Creating new group {group_name} for user {self.name}"
|
||||||
|
)
|
||||||
|
group = orm.Group(name=group_name)
|
||||||
|
self.db.add(group)
|
||||||
|
groups.append(group)
|
||||||
|
self.groups = groups
|
||||||
|
else:
|
||||||
|
self.groups = []
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
async def save_auth_state(self, auth_state):
|
async def save_auth_state(self, auth_state):
|
||||||
"""Encrypt and store auth_state"""
|
"""Encrypt and store auth_state"""
|
||||||
if auth_state is None:
|
if auth_state is None:
|
||||||
@@ -376,6 +428,7 @@ class User:
|
|||||||
oauth_client_id=client_id,
|
oauth_client_id=client_id,
|
||||||
cookie_options=self.settings.get('cookie_options', {}),
|
cookie_options=self.settings.get('cookie_options', {}),
|
||||||
trusted_alt_names=trusted_alt_names,
|
trusted_alt_names=trusted_alt_names,
|
||||||
|
user_options=orm_spawner.user_options or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.settings.get('internal_ssl'):
|
if self.settings.get('internal_ssl'):
|
||||||
@@ -591,7 +644,7 @@ class User:
|
|||||||
api_token = self.new_api_token(note=note, roles=['server'])
|
api_token = self.new_api_token(note=note, roles=['server'])
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
spawner = self.spawners[server_name]
|
spawner = self.get_spawner(server_name, replace_failed=True)
|
||||||
spawner.server = server = Server(orm_server=orm_server)
|
spawner.server = server = Server(orm_server=orm_server)
|
||||||
assert spawner.orm_spawner.server is orm_server
|
assert spawner.orm_spawner.server is orm_server
|
||||||
|
|
||||||
|
@@ -320,9 +320,11 @@ def admin_only(f):
|
|||||||
@auth_decorator
|
@auth_decorator
|
||||||
def metrics_authentication(self):
|
def metrics_authentication(self):
|
||||||
"""Decorator for restricting access to metrics"""
|
"""Decorator for restricting access to metrics"""
|
||||||
user = self.current_user
|
if not self.authenticate_prometheus:
|
||||||
if user is None and self.authenticate_prometheus:
|
return
|
||||||
raise web.HTTPError(403)
|
scope = 'read:metrics'
|
||||||
|
if scope not in self.parsed_scopes:
|
||||||
|
raise web.HTTPError(403, f"Access to metrics requires scope '{scope}'")
|
||||||
|
|
||||||
|
|
||||||
# Token utilities
|
# Token utilities
|
||||||
@@ -355,7 +357,7 @@ def hash_token(token, salt=8, rounds=16384, algorithm='sha512'):
|
|||||||
h.update(btoken)
|
h.update(btoken)
|
||||||
digest = h.hexdigest()
|
digest = h.hexdigest()
|
||||||
|
|
||||||
return "{algorithm}:{rounds}:{salt}:{digest}".format(**locals())
|
return f"{algorithm}:{rounds}:{salt}:{digest}"
|
||||||
|
|
||||||
|
|
||||||
def compare_token(compare, token):
|
def compare_token(compare, token):
|
||||||
@@ -683,3 +685,44 @@ def catch_db_error(f):
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
return catching
|
return catching
|
||||||
|
|
||||||
|
|
||||||
|
def get_browser_protocol(request):
|
||||||
|
"""Get the _protocol_ seen by the browser
|
||||||
|
|
||||||
|
Like tornado's _apply_xheaders,
|
||||||
|
but in the case of multiple proxy hops,
|
||||||
|
use the outermost value (what the browser likely sees)
|
||||||
|
instead of the innermost value,
|
||||||
|
which is the most trustworthy.
|
||||||
|
|
||||||
|
We care about what the browser sees,
|
||||||
|
not where the request actually came from,
|
||||||
|
so trusting possible spoofs is the right thing to do.
|
||||||
|
"""
|
||||||
|
headers = request.headers
|
||||||
|
# first choice: Forwarded header
|
||||||
|
forwarded_header = headers.get("Forwarded")
|
||||||
|
if forwarded_header:
|
||||||
|
first_forwarded = forwarded_header.split(",", 1)[0].strip()
|
||||||
|
fields = {}
|
||||||
|
forwarded_dict = {}
|
||||||
|
for field in first_forwarded.split(";"):
|
||||||
|
key, _, value = field.partition("=")
|
||||||
|
fields[key.strip().lower()] = value.strip()
|
||||||
|
if "proto" in fields and fields["proto"].lower() in {"http", "https"}:
|
||||||
|
return fields["proto"].lower()
|
||||||
|
else:
|
||||||
|
app_log.warning(
|
||||||
|
f"Forwarded header present without protocol: {forwarded_header}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# second choice: X-Scheme or X-Forwarded-Proto
|
||||||
|
proto_header = headers.get("X-Scheme", headers.get("X-Forwarded-Proto", None))
|
||||||
|
if proto_header:
|
||||||
|
proto_header = proto_header.split(",")[0].strip().lower()
|
||||||
|
if proto_header in {"http", "https"}:
|
||||||
|
return proto_header
|
||||||
|
|
||||||
|
# no forwarded headers
|
||||||
|
return request.protocol
|
||||||
|
@@ -11,7 +11,7 @@ target_version = [
|
|||||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||||
|
|
||||||
[tool.tbump.version]
|
[tool.tbump.version]
|
||||||
current = "2.0.1"
|
current = "2.2.1"
|
||||||
|
|
||||||
# Example of a semver regexp.
|
# Example of a semver regexp.
|
||||||
# Make sure this matches current_version before
|
# Make sure this matches current_version before
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -1,20 +1,5 @@
|
|||||||
{% extends "page.html" %}
|
{% extends "page.html" %}
|
||||||
|
|
||||||
{% macro th(label, key='', colspan=1) %}
|
|
||||||
<th data-sort="{{key}}" colspan="{{colspan}}">{{label}}
|
|
||||||
{% if key %}
|
|
||||||
<a href="#"><i class="fa {% if sort.get(key) == 'asc' -%}
|
|
||||||
fa-sort-asc
|
|
||||||
{%- elif sort.get(key) == 'desc' -%}
|
|
||||||
fa-sort-desc
|
|
||||||
{%- else -%}
|
|
||||||
fa-sort
|
|
||||||
{%- endif %} sort-icon">
|
|
||||||
</i></a>
|
|
||||||
{% endif %}
|
|
||||||
</th>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<div id="react-admin-hook">
|
<div id="react-admin-hook">
|
||||||
<script id="jupyterhub-admin-config">
|
<script id="jupyterhub-admin-config">
|
||||||
|
@@ -15,6 +15,11 @@
|
|||||||
{{ custom_html | safe }}
|
{{ custom_html | safe }}
|
||||||
{% elif login_service %}
|
{% elif login_service %}
|
||||||
<div class="service-login">
|
<div class="service-login">
|
||||||
|
<p id='insecure-login-warning' class='hidden'>
|
||||||
|
Warning: JupyterHub seems to be served over an unsecured HTTP connection.
|
||||||
|
We strongly recommend enabling HTTPS for JupyterHub.
|
||||||
|
</p>
|
||||||
|
|
||||||
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
|
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
|
||||||
Sign in with {{login_service}}
|
Sign in with {{login_service}}
|
||||||
</a>
|
</a>
|
||||||
|
@@ -18,8 +18,10 @@
|
|||||||
<p>
|
<p>
|
||||||
{% if failed %}
|
{% if failed %}
|
||||||
The latest attempt to start your server {{ server_name }} has failed.
|
The latest attempt to start your server {{ server_name }} has failed.
|
||||||
{% if failed_message %}
|
{% if failed_html_message %}
|
||||||
{{ failed_message }}
|
</p><p>{{ failed_html_message | safe }}</p><p>
|
||||||
|
{% elif failed_message %}
|
||||||
|
</p><p>{{ failed_message }}</p><p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
Would you like to retry starting it?
|
Would you like to retry starting it?
|
||||||
{% else %}
|
{% else %}
|
||||||
|
Reference in New Issue
Block a user