Compare commits

...

86 Commits
2.1.1 ... 2.2.2

Author SHA1 Message Date
Erik Sundell
3590d16e30 Bump to 2.2.2 2022-03-14 12:32:25 +01:00
Erik Sundell
572d258cd2 Merge pull request #3828 from consideRatio/pr/changelog-2.2.2
Add changelog for 2.2.2
2022-03-14 12:30:27 +01:00
Min RK
11d0954551 Merge pull request #3826 from consideRatio/pr/add-dedicated-jsx-tests 2022-03-14 12:27:59 +01:00
Erik Sundell
650d47d5c1 Add changelog for 2.2.2 2022-03-14 12:15:54 +01:00
Erik Sundell
945fc824d8 Remove autoformat added new line for generated file 2022-03-12 01:12:29 +01:00
Erik Sundell
a8aa737b00 Don't autoformat generated admin-react.js 2022-03-12 01:12:29 +01:00
Erik Sundell
cd689a1fab ci: test jsx in a dedicated workflow along with src->dist check 2022-03-12 01:12:29 +01:00
Erik Sundell
fbcf857991 Add inline comments to .pre-commit-config.yaml 2022-03-12 00:00:27 +01:00
Erik Sundell
6c5e5452bc Merge pull request #3825 from NarekA/narek/fix-admin-table-sorting-2
Update admin-react.js
2022-03-11 18:33:33 +01:00
pre-commit-ci[bot]
2f5ba7ba30 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-11 17:15:21 +00:00
Narek Amirbekian
a045eefa64 React file not updated 2022-03-11 09:13:14 -08:00
Min RK
6ea4f2af0d Bump to 2.3.0.dev 2022-03-11 17:00:28 +01:00
Min RK
3d3ad2929c Bump to 2.2.1 2022-03-11 16:59:52 +01:00
Erik Sundell
00287ff5ba Merge pull request #3824 from minrk/221
changelog for 2.2.1
2022-03-11 16:17:11 +01:00
Min RK
805d063d1d changelog for 2.2.1 2022-03-11 15:52:41 +01:00
Min RK
e6bacf7109 Merge pull request #3822 from NarekA/narek/fix-admin-table-sorting
Fix admin dashboard table sorting
2022-03-11 15:48:45 +01:00
Min RK
33ccfa7963 handle null user.server
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-03-11 14:16:30 +01:00
Erik Sundell
593404f558 Merge pull request #3823 from minrk/clear-cookie-kwargs
Fix clearing cookie with custom xsrf cookie options
2022-03-11 09:53:43 +01:00
Min RK
e7bc282c80 clear_cookie only accepts path, domain cookie args 2022-03-11 09:24:31 +01:00
pre-commit-ci[bot]
b939b482a1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-11 06:38:13 +00:00
Narek Amirbekian
8afc2c9ae9 Fix admin table sorting 2022-03-10 22:20:01 -08:00
Chris Holdgraf
d11eda14ed Merge pull request #3820 from yuvipanda/log-docs 2022-03-10 16:38:01 -08:00
Yuvi Panda
ab79251fe2 Reword for clarity
Co-authored-by: Chris Holdgraf <choldgraf@gmail.com>
2022-03-10 15:54:42 -08:00
Yuvi Panda
484dbf48de Merge pull request #3819 from minrk/raise-no-orm-spawner
allow Spawner.server to be mocked without underlying orm_spawner
2022-03-10 13:46:49 -08:00
YuviPanda
6eb526d08a Add a little more structure 2022-03-10 13:45:28 -08:00
YuviPanda
e0a17db5f1 Add some docs on common log messages
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.

I currently added just one log message, but we can add more
over time.

Ref https://github.com/2i2c-org/infrastructure/issues/1081
where this would've been useful troubleshooting
2022-03-10 12:45:09 -08:00
Min RK
45132b7244 allow Spawner.server to be mocked without underlying orm_spawner 2022-03-10 15:40:01 +01:00
Min RK
c23cddeb51 Bump to 2.2.0 2022-03-07 14:35:46 +01:00
Erik Sundell
672e19a22a Merge pull request #3815 from minrk/changelog-2.2
Changelog for 2.2
2022-03-07 14:32:56 +01:00
Min RK
4a6c9c3a01 Prepare changelog for 2.2 2022-03-07 14:27:31 +01:00
Erik Sundell
2b79bc44da Merge pull request #3802 from minrk/fresh-spawner
Replace failed spawners when starting new launch
2022-03-07 14:23:22 +01:00
Min RK
7861662e17 Replace failed spawners when starting new launch
Avoids leaving stale state when re-using a spawner that failed the last time it started

we keep failed spawners around to track their errors,
but we don't want to re-use them when it comes time to start a new launch.

adds User.get_spawner(server_name, replace_failed=True) to always get a non-failed Spawner
2022-03-07 14:03:48 +01:00
Simon Li
4a1842bf8a Merge pull request #3809 from minrk/page_config_hook
Add user token to JupyterLab PageConfig
2022-03-04 21:27:34 +00:00
Min RK
8f18303e50 fix some links revealed by myst
mostly pre-myst markdown links
2022-03-04 10:41:20 +01:00
Min RK
bcad6e287d Merge pull request #3812 from ktaletsk/patch-1
Update example to not reference an undefined scope
2022-03-04 10:03:53 +01:00
Min RK
9de1951952 Merge pull request #3813 from rzo1/apache-sec
Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers)
2022-03-04 10:03:01 +01:00
pre-commit-ci[bot]
99cb1f17f0 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-03 15:41:17 +00:00
Richard Zowalla
10d5157e95 Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers) 2022-03-03 16:40:38 +01:00
Konstantin Taletskiy
2fc4f26832 Update example to not reference an undefined scope
Fixes #3811
2022-03-01 12:25:54 -08:00
Simon Li
f6230001bb Merge pull request #3810 from minrk/server-trait
Keep Spawner.server in sync with underlying orm_spawner.server
2022-03-01 17:40:33 +00:00
Min RK
960f7cbeb9 Keep Spawner.server in sync with underlying orm_spawner.server
Rather than one-time sets of ._server allowing it to become out-of-sync
with underlying orm_spawner.server
2022-03-01 15:59:16 +01:00
Erik Sundell
76f06a6b55 Merge pull request #3808 from manics/apache-x-forwarded-proto
Apache: set X-Forwarded-Proto header
2022-03-01 14:14:34 +01:00
Min RK
9c498aa5d4 Document HubOAuth.get_token for requests on behalf of users 2022-03-01 10:05:17 +01:00
Min RK
a0b60f9118 place JupyterHub token in JupyterLab PageConfig
restores token field useful for javascript-originating API requests,
removed in 1.5 / 2.0 for security reasons because it was the wrong token.

This places the _user's_ token in PageConfig,
so it should have the right permissions.

requires jupyterlab_server 2.9, has no effect on earlier versions.
2022-03-01 09:45:14 +01:00
Min RK
27cb56429b HubAuth.get_token returns oauth token stored in cookie
Useful for backend services that want to use the user's token.

Added `in_cookie` bool argument to exclude cookies (previous behavior),
since notebook servers do some things differently when auth is in query param or header vs cookies
2022-03-01 09:43:01 +01:00
Simon Li
b1ffd4b10b Apache: set X-Forwarded-Proto header 2022-02-28 21:46:53 +00:00
dependabot[bot]
a9ea064202 Merge pull request #3807 from jupyterhub/dependabot/npm_and_yarn/jsx/url-parse-1.5.10 2022-02-28 09:56:10 +00:00
dependabot[bot]
687a41a467 Bump url-parse from 1.5.7 to 1.5.10 in /jsx
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.7 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.7...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-28 04:27:03 +00:00
Erik Sundell
5348451b2e Merge pull request #3803 from tmtabor/patch-1
idle-culler example config missing closing bracket
2022-02-22 23:09:15 +01:00
Thorin Tabor
55f0579dcc idle-culler example config missing closing bracket 2022-02-22 13:16:37 -08:00
Simon Li
a3ea0f0449 Merge pull request #3799 from jupyterhub/dependabot/npm_and_yarn/jsx/url-parse-1.5.7
Bump url-parse from 1.5.3 to 1.5.7 in /jsx
2022-02-19 08:11:52 +00:00
dependabot[bot]
78492a4a8e Bump url-parse from 1.5.3 to 1.5.7 in /jsx
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.3 to 1.5.7.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.3...1.5.7)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-19 07:35:42 +00:00
Min RK
f22203f50e Merge pull request #3793 from satra/patch-1
show insecure-login-warning for all authenticators
2022-02-15 11:24:09 +01:00
Simon Li
500b354a00 Merge pull request #3795 from jupyterhub/dependabot/npm_and_yarn/jsx/follow-redirects-1.14.8
Bump follow-redirects from 1.14.7 to 1.14.8 in /jsx
2022-02-14 09:52:27 +00:00
dependabot[bot]
9d4093782f Bump follow-redirects from 1.14.7 to 1.14.8 in /jsx
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 08:52:00 +00:00
Min RK
43b3cebfff Merge pull request #3791 from rcthomas/async-options
Enable options_from_form(spawner, form_data) from configuration file
2022-02-14 09:45:04 +01:00
Min RK
63c381431d Merge pull request #3787 from minrk/stop_open_session_default
Stop opening PAM sessions by default
2022-02-14 09:43:12 +01:00
Min RK
bf41767b33 Merge pull request #3790 from NarekA/narek/admin-named-servers
Add Missing Features In Admin Console
2022-02-14 09:25:25 +01:00
Satrajit Ghosh
83d6e4e993 fix: insecure-login-warning for all authenticators 2022-02-11 22:19:39 -05:00
pre-commit-ci[bot]
d64a2ddd95 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-02-11 21:11:10 +00:00
Rollin Thomas
392176d873 Add tests for both forms of options_from_form 2022-02-11 12:55:09 -08:00
Narek Amirbekian
58420b3307 Merge remote-tracking branch 'origin' into narek/admin-named-servers 2022-02-11 10:52:21 -08:00
Narek Amirbekian
a5e3b66dee One edit user button per user 2022-02-11 10:50:58 -08:00
Rollin Thomas
a9fbe5c9f6 Enable options_from_form w/spawner from configuration 2022-02-11 10:06:38 -08:00
Erik Sundell
71bbbe4a67 Merge pull request #3792 from minrk/short-circuit
short-circuit token permission check if token and owner share role
2022-02-11 17:36:07 +01:00
Min RK
3843885382 short-circuit token permission check if token and owner share role
No need to compute intersection when we know it's a subset already
2022-02-11 15:20:14 +01:00
Narek Amirbekian
25ea559e0d Pull out button components 2022-02-09 15:21:12 -08:00
Narek Amirbekian
c18815de91 Fix failing tests 2022-02-09 14:04:38 -08:00
Narek Amirbekian
50d53667ce Add start server back 2022-02-09 13:15:27 -08:00
pre-commit-ci[bot]
68e2baf4aa [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-02-09 21:04:22 +00:00
Narek Amirbekian
6fc9d40e51 Merge remote-tracking branch 'origin/narek/admin-named-servers' into narek/admin-named-servers 2022-02-09 13:02:19 -08:00
Narek Amirbekian
0b25694b40 Add "spawn new" button 2022-02-09 12:59:34 -08:00
pre-commit-ci[bot]
bf750e488f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-02-09 19:58:05 +00:00
Narek Amirbekian
359f9055fc Admin missing features 2022-02-09 11:43:06 -08:00
Min RK
b84dd5d735 Stop opening PAM sessions by default
We don't do it correctly, so don't try by default

It does work _sometimes_, but most of the time it does work, it's because it's a no-op.
Turning it off by default makes it more likely folks will see the caveat that it may not work.
2022-02-07 15:45:38 +01:00
Erik Sundell
3ed345f496 Merge pull request #3784 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-01-31 23:57:45 +01:00
pre-commit-ci[bot]
6633f8ef28 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-31 22:17:11 +00:00
pre-commit-ci[bot]
757053a9ec [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/reorder_python_imports: v2.6.0 → v2.7.1](https://github.com/asottile/reorder_python_imports/compare/v2.6.0...v2.7.1)
- [github.com/psf/black: 21.12b0 → 22.1.0](https://github.com/psf/black/compare/21.12b0...22.1.0)
2022-01-31 22:16:31 +00:00
Erik Sundell
36cad38ddf Merge pull request #3781 from cqzlxl/cqzlxl-patch-1
Log proxy's public_url only when started by JupyterHub
2022-01-29 09:16:29 +01:00
pre-commit-ci[bot]
1e9a1cb621 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-29 05:59:52 +00:00
cqzlxl
9f051d3172 Update jupyterhub/app.py
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-01-29 13:59:20 +08:00
cqzlxl
53576c8f82 Update app.py
When we run the proxy separately,  defaults of `hub.bind_url` may be different from proxy's public url. Actually, the hub has no ways to know about which address the proxy is serving at if we do not configure its `bind_url` explicitly.
2022-01-27 21:05:05 +08:00
Min RK
bb5ec39b2f Merge pull request #3548 from C4IROcean/authenticator_user_group_management
Authenticator user group management
2022-01-25 14:36:41 +01:00
Min RK
4c54c6dcc8 Bump to 2.2.0.dev 2022-01-25 14:36:24 +01:00
Min RK
88be7a9967 test coverage for Authenticator.managed_groups
- tests
- docs
- ensure all group APIs are rejected when auth is in control
- use 'groups' field in return value of authenticate/refresh_user, instead of defining new method
- log group changes in sync_groups
2022-01-24 13:45:35 +01:00
Thomas Li Fredriksen
144abcb965 Added authenticator hook for synchronizing user groups
- Added hook function stub to authenticator base class
- Added new config option `manage_groups` to base `Authenticator` class
- Call authenticator hook from `refresh_auth`-function in `Base` handler class
- Added example
2022-01-20 13:30:03 +01:00
38 changed files with 1047 additions and 198 deletions

108
.github/workflows/test-jsx.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
# 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
#
name: Test jsx (admin-react.js)
on:
pull_request:
paths:
- "jsx/**"
- ".github/workflows/test-jsx.yml"
push:
paths:
- "jsx/**"
- ".github/workflows/test-jsx.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
jobs:
# The ./jsx folder contains React based source code files that are to compile
# to share/jupyterhub/static/js/admin-react.js. The ./jsx folder includes
# tests also has tests that this job is meant to run with `yarn test`
# according to the documentation in jsx/README.md.
test-jsx-admin-react:
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install yarn
run: |
npm install -g yarn
- name: yarn
run: |
cd jsx
yarn
- name: yarn test
run: |
cd jsx
yarn test
# The ./jsx folder contains React based source files that are to compile to
# share/jupyterhub/static/js/admin-react.js. This job makes sure that whatever
# we have in jsx/src matches the compiled asset that we package and
# distribute.
#
# This job's purpose is to make sure we don't forget to compile changes and to
# verify nobody sneaks in a change in the hard to review compiled asset.
#
# NOTE: In the future we may want to stop version controlling the compiled
# artifact and instead generate it whenever we package JupyterHub. If we
# do this, we are required to setup node and compile the source code
# more often, at the same time we could avoid having this check be made.
#
compile-jsx-admin-react:
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install yarn
run: |
npm install -g yarn
- name: yarn
run: |
cd jsx
yarn
- name: yarn build
run: |
cd jsx
yarn build
- name: yarn place
run: |
cd jsx
yarn place
- name: Verify compiled jsx/src matches version controlled artifact
run: |
if [[ `git status --porcelain=v1` ]]; then
echo "The source code in ./jsx compiles to something different than found in ./share/jupyterhub/static/js/admin-react.js!"
echo
echo "Please re-compile the source code in ./jsx with the following commands:"
echo
echo "yarn"
echo "yarn build"
echo "yarn place"
echo
echo "See ./jsx/README.md for more details."
exit 1
else
echo "Compilation of jsx/src to share/jupyterhub/static/js/admin-react.js didn't lead to changes."
fi

View File

@@ -31,33 +31,6 @@ env:
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs:
jstest:
# Run javascript tests
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v2
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: "14"
- name: Install Node dependencies
run: |
npm install -g yarn
- name: Run yarn
run: |
cd jsx
yarn
- name: yarn test
run: |
cd jsx
yarn test
# Run "pytest jupyterhub/tests" in various configurations
pytest:
runs-on: ubuntu-20.04

View File

@@ -1,30 +1,53 @@
# pre-commit is a tool to perform a predefined set of tasks manually and/or
# automatically before git commits are made.
#
# Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level
#
# Common tasks
#
# - Run on all files: pre-commit run --all-files
# - Register git hooks: pre-commit install --install-hooks
#
repos:
# Autoformat: Python code, syntax patterns are modernized
- repo: https://github.com/asottile/pyupgrade
rev: v2.31.0
hooks:
- id: pyupgrade
args:
- --py36-plus
# Autoformat: Python code
- repo: https://github.com/asottile/reorder_python_imports
rev: v2.6.0
rev: v2.7.1
hooks:
- id: reorder-python-imports
# Autoformat: Python code
- repo: https://github.com/psf/black
rev: 21.12b0
rev: 22.1.0
hooks:
- id: black
args: [--target-version=py36]
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.5.1
hooks:
- id: prettier
- repo: https://github.com/PyCQA/flake8
rev: "4.0.1"
hooks:
- id: flake8
# Autoformat and linting, misc. details
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
- id: end-of-file-fixer
exclude: share/jupyterhub/static/js/admin-react.js
- id: requirements-txt-fixer
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: requirements-txt-fixer
# Linting: Python code (see the file .flake8)
- repo: https://github.com/PyCQA/flake8
rev: "4.0.1"
hooks:
- id: flake8

View File

@@ -6,7 +6,7 @@ info:
description: The REST API for JupyterHub
license:
name: BSD-3-Clause
version: 2.1.1
version: 2.2.2
servers:
- url: /hub/api
security:

View 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.

View File

@@ -6,9 +6,102 @@ command line for details.
## [Unreleased]
## 2.2
### 2.2.2 2022-03-14
2.2.2 fixes a small regressions in 2.2.1.
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.2.1...6c5e5452bc734dfd5c5a9482e4980b988ddd304e))
#### Bugs fixed
- Fix failure to update admin-react.js by re-compiling from our source [#3825](https://github.com/jupyterhub/jupyterhub/pull/3825) ([@NarekA](https://github.com/NarekA), [@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
#### Continuous integration improvements
- ci: standalone jsx workflow and verify compiled asset matches source code [#3826](https://github.com/jupyterhub/jupyterhub/pull/3826) ([@consideRatio](https://github.com/consideRatio), [@NarekA](https://github.com/NarekA))
#### Contributors to this release
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-03-11&to=2022-03-14&type=c))
[@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-03-11..2022-03-14&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2022-03-11..2022-03-14&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-03-11..2022-03-14&type=Issues) | [@NarekA](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ANarekA+updated%3A2022-03-11..2022-03-14&type=Issues)
### 2.2.1 2022-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 2022-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 2022-01-25
2.1.1 is a tiny bugfix release,
fixing an issue where admins did not receive the new `read:metrics` permission.

View File

@@ -21,6 +21,7 @@ extensions = [
'myst_parser',
]
myst_heading_anchors = 2
myst_enable_extensions = [
'colon_fence',
'deflist',

View File

@@ -10,4 +10,5 @@ well as other information relevant to running your own JupyterHub over time.
troubleshooting
admin/upgrading
admin/log-messages
changelog

View File

@@ -1,6 +1,6 @@
# 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.
## 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,
a custom Authenticator and Spawner are often used together.
For example, the Authenticator methods, [pre_spawn_start(user, spawner)][]
and [post_spawn_stop(user, spawner)][], are hooks that can be used to do
For example, the Authenticator methods, {meth}`.Authenticator.pre_spawn_start`
and {meth}`.Authenticator.post_spawn_stop`, are hooks that can be used to do
auth-related startup (e.g. opening PAM sessions) and cleanup
(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.
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:
```python
@@ -247,10 +247,42 @@ class MyAuthenticator(Authenticator):
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
Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and
[post_spawn_stop(user, spawner)][] to add pass additional state information
Authenticators uses two hooks, {meth}`.Authenticator.pre_spawn_start` and
{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
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
PAM session.
@@ -259,10 +291,7 @@ PAM session.
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
[oauth]: https://en.wikipedia.org/wiki/OAuth
[github oauth]: https://developer.github.com/v3/oauth/
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
[pre_spawn_start(user, spawner)]: 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

View File

@@ -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:
```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:
@@ -188,13 +188,24 @@ Listen 443
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
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
SSLProtocol All -SSLv2 -SSLv3
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
RewriteEngine On
@@ -208,6 +219,7 @@ Listen 443
# proxy to JupyterHub
ProxyPass http://127.0.0.1:8000/
ProxyPassReverse http://127.0.0.1:8000/
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
</Location>
</VirtualHost>
```

View File

@@ -113,7 +113,6 @@ c.JupyterHub.load_roles = [
"scopes": [
# specify the permissions the token should have
"admin:users",
"admin:services",
],
"services": [
# assign the service the above permissions

View File

@@ -83,6 +83,7 @@ c.JupyterHub.load_roles = [
# 'admin:users' # needed if culling idle users as well
]
}
]
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
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.
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.
- [`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.
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
or via the `JUPYTERHUB_API_TOKEN` environment variable.
Most of the logic for authentication implementation is found in the
[`HubAuth.user_for_token`][hubauth.user_for_token]
methods, which makes a request of the Hub, and returns:
{meth}`.HubAuth.user_for_token` methods,
which makes a request of the Hub, and returns:
- None, if no user could be identified, or
- a dict of the following form:
@@ -245,6 +246,19 @@ action.
HubAuth also caches the Hub's response for a number of seconds,
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
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/
[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
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
[fastapi]: https://fastapi.tiangolo.com

View File

@@ -275,7 +275,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key.
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

View 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

View File

@@ -0,0 +1,2 @@
oauthenticator
pyjwt

View File

@@ -10,6 +10,14 @@ import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince";
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) => {
// sort methods
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 [sortMethod, setSortMethod] = useState(null);
var [disabledButtons, setDisabledButtons] = useState({});
var user_data = useSelector((state) => state.user_data),
user_page = useSelector((state) => state.user_page),
@@ -72,6 +81,108 @@ const ServerDashboard = (props) => {
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 (
<div className="container" data-testid="container">
{errorAlert != null ? (
@@ -115,6 +226,14 @@ const ServerDashboard = (props) => {
testid="admin-sort"
/>
</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">
Last Activity{" "}
<SortHandler
@@ -227,88 +346,66 @@ const ServerDashboard = (props) => {
</Button>
</td>
</tr>
{user_data.map((e, i) => (
<tr key={i + "row"} className="user-row">
<td data-testid="user-row-name">{e.name}</td>
<td data-testid="user-row-admin">{e.admin ? "admin" : ""}</td>
<td data-testid="user-row-last-activity">
{e.last_activity ? timeSince(e.last_activity) : "Never"}
</td>
<td data-testid="user-row-server-activity">
{e.server != null ? (
// Stop Single-user server
<button
className="btn btn-danger btn-xs stop-button"
onClick={() =>
stopServer(e.name)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
} else {
setErrorAlert(`Failed to stop server.`);
}
return res;
})
.catch(() => setErrorAlert(`Failed to stop server.`))
}
>
Stop Server
</button>
) : (
// Start Single-user server
<button
className="btn btn-primary btn-xs start-button"
onClick={() =>
startServer(e.name)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
} else {
setErrorAlert(`Failed to start server.`);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to start server.`);
})
}
>
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>
))}
{servers.map(([user, server], i) => {
server.name = server.name || "";
return (
<tr key={i + "row"} className="user-row">
<td data-testid="user-row-name">{user.name}</td>
<td data-testid="user-row-admin">
{user.admin ? "admin" : ""}
</td>
<td data-testid="user-row-server">
{server.name ? (
<p class="text-secondary">{server.name}</p>
) : (
<p style={{ color: "lightgrey" }}>[MAIN]</p>
)}
</td>
<td data-testid="user-row-last-activity">
{server.last_activity
? timeSince(server.last_activity)
: "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.started ? (
// Stop Single-user server
<>
<StopServerButton
serverName={server.name}
userName={user.name}
/>
<AccessServerButton
serverName={server.name}
userName={user.name}
/>
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
/>
<a
href={`/spawn/${user.name}${
server.name && "/" + server.name
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
</td>
<EditUserCell user={user} />
</tr>
);
})}
</tbody>
</table>
<PaginationFooter

View File

@@ -11,8 +11,10 @@ const withAPI = withProps(() => ({
(data) => data.json()
),
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
startServer: (name, serverName = "") =>
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "POST"),
stopServer: (name, serverName = "") =>
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "DELETE"),
startAll: (names) =>
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
stopAll: (names) =>

View File

@@ -3664,9 +3664,9 @@ flatted@^3.1.0:
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
follow-redirects@^1.0.0:
version "1.14.7"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
version "1.14.8"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
for-in@^1.0.2:
version "1.0.2"
@@ -7878,9 +7878,9 @@ urix@^0.1.0:
integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
url-parse@^1.4.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862"
integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==
version "1.5.10"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1"
integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==
dependencies:
querystringify "^2.1.1"
requires-port "^1.0.0"

View File

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

View File

@@ -33,6 +33,11 @@ class _GroupAPIHandler(APIHandler):
raise web.HTTPError(404, "No such group: %s", group_name)
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):
@needs_scope('list:groups')
@@ -68,6 +73,9 @@ class GroupListAPIHandler(_GroupAPIHandler):
@needs_scope('admin:groups')
async def post(self):
"""POST creates Multiple groups"""
self.check_authenticator_managed_groups()
model = self.get_json_body()
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")
@@ -106,6 +114,7 @@ class GroupAPIHandler(_GroupAPIHandler):
@needs_scope('admin:groups')
async def post(self, group_name):
"""POST creates a group by name"""
self.check_authenticator_managed_groups()
model = self.get_json_body()
if model is None:
model = {}
@@ -132,6 +141,7 @@ class GroupAPIHandler(_GroupAPIHandler):
@needs_scope('delete:groups')
def delete(self, group_name):
"""Delete a group by name"""
self.check_authenticator_managed_groups()
group = self.find_group(group_name)
self.log.info("Deleting group %s", group_name)
self.db.delete(group)
@@ -145,6 +155,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
@needs_scope('groups')
def post(self, group_name):
"""POST adds users to a group"""
self.check_authenticator_managed_groups()
group = self.find_group(group_name)
data = self.get_json_body()
self._check_group_model(data)
@@ -163,6 +174,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
@needs_scope('groups')
async def delete(self, group_name):
"""DELETE removes users from a group"""
self.check_authenticator_managed_groups()
group = self.find_group(group_name)
data = self.get_json_body()
self._check_group_model(data)

View File

@@ -515,7 +515,7 @@ class UserServerAPIHandler(APIHandler):
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
if pending == 'spawn':
self.set_header('Content-Type', 'text/plain')

View File

@@ -2001,6 +2001,9 @@ class JupyterHub(Application):
async def init_groups(self):
"""Load predefined groups into the database"""
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():
group = orm.Group.find(db, name)
if group is None:
@@ -3147,7 +3150,12 @@ class JupyterHub(Application):
self.last_activity_callback = pc
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
if _mswindows:
atexit.register(self.atexit)

View File

@@ -582,9 +582,13 @@ class Authenticator(LoggingConfigurable):
or None if Authentication failed.
The Authenticator may return a dict instead, which MUST have a
key `name` holding the username, and MAY have two optional keys
set: `auth_state`, a dictionary of of auth state that will be
persisted; and `admin`, the admin setting value for the user.
key `name` holding the username, and MAY have additional keys:
- `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):
@@ -635,6 +639,19 @@ class Authenticator(LoggingConfigurable):
"""
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(
False,
config=True,
@@ -958,16 +975,24 @@ class PAMAuthenticator(LocalAuthenticator):
).tag(config=True)
open_sessions = Bool(
True,
False,
help="""
Whether to open a new PAM session when spawners are started.
This may trigger things like mounting shared filsystems,
loading credentials, etc. depending on system configuration,
but it does not always work.
This may trigger things like mounting shared filesystems,
loading credentials, etc. depending on system configuration.
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,
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)

View File

@@ -526,10 +526,16 @@ class BaseHandler(RequestHandler):
path=url_path_join(self.base_url, 'services'),
**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(
'_xsrf',
**self.settings.get('xsrf_cookie_kwargs', {}),
**clear_xsrf_cookie_kwargs,
)
# Reset _jupyterhub_user
self._jupyterhub_user = None
@@ -774,13 +780,22 @@ class BaseHandler(RequestHandler):
# always ensure default roles ('user', 'admin' if admin) are assigned
# after a successful login
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,
# because there could be key-rotation or clearing of previous values
# going on.
if not self.authenticator.enable_auth_state:
# auth_state is not enabled. Force None.
auth_state = None
await user.save_auth_state(auth_state)
return user
async def login_user(self, data=None):
@@ -794,6 +809,7 @@ class BaseHandler(RequestHandler):
self.set_login_cookie(user)
self.statsd.incr('login.success')
self.statsd.timing('login.authenticate.success', auth_timer.ms)
self.log.info("User logged in: %s", user.name)
user._auth_refreshed = time.monotonic()
return user
@@ -1510,14 +1526,10 @@ class UserUrlHandler(BaseHandler):
# 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
if (
get_accepted_mimetype(
self.request.headers.get('Accept', ''),
choices=['application/json', 'text/html'],
)
== 'application/json'
or 'api' in user_path.split('/')
):
if get_accepted_mimetype(
self.request.headers.get('Accept', ''),
choices=['application/json', 'text/html'],
) == 'application/json' or 'api' in user_path.split('/'):
self._fail_api_request(user_name, server_name)
return
@@ -1599,7 +1611,7 @@ class UserUrlHandler(BaseHandler):
if redirects:
self.log.warning("Redirect loop detected on %s", self.request.uri)
# 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
url_parts = urlparse(target)
query_parts = parse_qs(url_parts.query)

View File

@@ -151,7 +151,7 @@ class SpawnHandler(BaseHandler):
self.redirect(url)
return
spawner = user.spawners[server_name]
spawner = user.get_spawner(server_name, replace_failed=True)
pending_url = self._get_pending_url(user, server_name)
@@ -237,7 +237,7 @@ class SpawnHandler(BaseHandler):
if user is None:
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:
raise web.HTTPError(400, "%s is already running" % (spawner._log_name))
@@ -255,7 +255,7 @@ class SpawnHandler(BaseHandler):
self.log.debug(
"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)
return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url, options
@@ -369,13 +369,9 @@ class SpawnPendingHandler(BaseHandler):
auth_state = await user.get_auth_state()
# First, check for previous failure.
if (
not spawner.active
and spawner._spawn_future
and spawner._spawn_future.done()
and spawner._spawn_future.exception()
):
# Condition: spawner not active and _spawn_future exists and contains an Exception
if not spawner.active and spawner._failed:
# Condition: spawner not active and last spawn failed
# (failure is available as spawner._spawn_future.exception()).
# 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.
exc = spawner._spawn_future.exception()

View File

@@ -403,6 +403,10 @@ def _token_allowed_role(db, token, role):
if owner is None:
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)
implicit_permissions = {'inherit', 'read:inherit'}

View File

@@ -501,11 +501,17 @@ class HubAuth(SingletonConfigurable):
auth_header_name = 'Authorization'
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
def get_token(self, handler):
"""Get the user token from a request
def get_token(self, handler, in_cookie=True):
"""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 header: Authorization: token <token>
- in cookie (stored after oauth), if in_cookie is True
"""
user_token = handler.get_argument('token', '')
@@ -516,8 +522,14 @@ class HubAuth(SingletonConfigurable):
)
if m:
user_token = m.group(1)
if not user_token and in_cookie:
user_token = self._get_token_cookie(handler)
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):
"""Get the user model from a cookie"""
# overridden in HubOAuth to store the access token after oauth
@@ -553,8 +565,10 @@ class HubAuth(SingletonConfigurable):
handler._cached_hub_user = user_model = None
session_id = self.get_session_id(handler)
# check token first
token = self.get_token(handler)
# check token first, ignoring cookies
# because some checks are different when a request
# is token-authenticated (CORS-related)
token = self.get_token(handler, in_cookie=False)
if token:
user_model = self.user_for_token(token, session_id=session_id)
if user_model:
@@ -614,11 +628,18 @@ class HubOAuth(HubAuth):
"""
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)
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)
if token:
token = token.decode('ascii', 'replace')
user_model = self.user_for_token(token, session_id=session_id)
if user_model is None:
app_log.warning("Token stored in cookie may have expired")

View File

@@ -16,7 +16,6 @@ import random
import secrets
import sys
import warnings
from datetime import datetime
from datetime import timezone
from importlib import import_module
from textwrap import dedent
@@ -493,7 +492,7 @@ class SingleUserNotebookAppMixin(Configurable):
i,
RETRIES,
)
await asyncio.sleep(min(2 ** i, 16))
await asyncio.sleep(min(2**i, 16))
else:
break
else:
@@ -680,6 +679,7 @@ class SingleUserNotebookAppMixin(Configurable):
s['hub_prefix'] = self.hub_prefix
s['hub_host'] = self.hub_host
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(
self.hub_prefix, 'security/csp-report'
)
@@ -707,6 +707,18 @@ class SingleUserNotebookAppMixin(Configurable):
self.patch_default_headers()
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):
if hasattr(RequestHandler, '_orig_set_default_headers'):
return

View File

@@ -11,6 +11,7 @@ import shutil
import signal
import sys
import warnings
from inspect import signature
from subprocess import Popen
from tempfile import mkdtemp
from urllib.parse import urlparse
@@ -183,17 +184,38 @@ class Spawner(LoggingConfigurable):
def last_activity(self):
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
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
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
def server(self, 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:
# delete the old value
db = inspect(self.orm_spawner.server).session
@@ -201,7 +223,13 @@ class Spawner(LoggingConfigurable):
if server is None:
self.orm_spawner.server = None
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
elif server is not None:
self.log.warning(
"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
)
@property
def name(self):
@@ -424,6 +452,13 @@ class Spawner(LoggingConfigurable):
def _default_options_from_form(self, 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):
"""Interpret query arguments passed to /spawn

View File

@@ -1030,7 +1030,7 @@ async def test_never_spawn(app, no_patience, never_spawn):
assert not app_user.spawner._spawn_pending
status = await app_user.spawner.poll()
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
@@ -1039,9 +1039,16 @@ async def test_bad_spawn(app, bad_spawn):
name = 'prim'
user = add_user(db, app=app, name=name)
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 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):
r = await api_request(app, 'users', "nosuchuser", 'server', method='post')
@@ -1806,6 +1813,38 @@ async def test_group_add_delete_users(app):
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
# -----------------

View File

@@ -7,6 +7,7 @@ from urllib.parse import urlparse
import pytest
from requests import HTTPError
from traitlets import Any
from traitlets.config import Config
from .mocking import MockPAMAuthenticator
@@ -14,6 +15,7 @@ from .mocking import MockStructGroup
from .mocking import MockStructPasswd
from .utils import add_user
from .utils import async_requests
from .utils import get_page
from .utils import public_url
from jupyterhub import auth
from jupyterhub import crypto
@@ -527,3 +529,71 @@ async def test_nullauthenticator(app):
r = await async_requests.get(public_url(app))
assert urlparse(r.url).path.endswith("/hub/login")
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

View File

@@ -128,11 +128,20 @@ async def test_admin_sort(app, sort):
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'
cookies = await app.login_user(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()
assert status is not None
@@ -141,6 +150,10 @@ async def test_spawn_redirect(app):
r.raise_for_status()
print(urlparse(r.url))
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
# if spawn was really quick, we might get redirected all the way to the running server,
# so check history instead of r.url
@@ -258,6 +271,25 @@ async def test_spawn_page(app):
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):
with mock.patch.dict(
app.users.settings, {'spawner_class': FalsyCallableFormSpawner}

View File

@@ -459,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
with pytest.raises(ValueError):
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"

View File

@@ -1,5 +1,6 @@
import pytest
from .. import orm
from ..user import UserDict
from .utils import add_user
@@ -20,3 +21,35 @@ async def test_userdict_get(db, attr):
assert userdict.get(key).id == u.id
# `in` should find it now
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

View File

@@ -253,6 +253,58 @@ class User:
def spawner_class(self):
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):
"""Encrypt and store auth_state"""
if auth_state is None:
@@ -592,7 +644,7 @@ class User:
api_token = self.new_api_token(note=note, roles=['server'])
db.commit()
spawner = self.spawners[server_name]
spawner = self.get_spawner(server_name, replace_failed=True)
spawner.server = server = Server(orm_server=orm_server)
assert spawner.orm_spawner.server is orm_server

View File

@@ -11,7 +11,7 @@ target_version = [
github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version]
current = "2.1.1"
current = "2.2.2"
# Example of a semver regexp.
# Make sure this matches current_version before

File diff suppressed because one or more lines are too long

View File

@@ -15,6 +15,11 @@
{{ custom_html | safe }}
{% elif login_service %}
<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}}'>
Sign in with {{login_service}}
</a>