Merge branch 'main' into krassowski-manage_roles

This commit is contained in:
Min RK
2024-04-15 10:58:46 +02:00
committed by GitHub
39 changed files with 2207 additions and 319 deletions

View File

@@ -64,7 +64,7 @@ jobs:
- name: Install requirements
run: |
pip install -r docs/requirements.txt pytest
pip install -e . -r docs/requirements.txt pytest
- name: pytest docs/
run: |

View File

@@ -103,6 +103,9 @@ jobs:
subset: singleuser
- python: "3.11"
browser: browser
- python: "3.11"
subdomain: subdomain
browser: browser
- python: "3.12"
main_dependencies: main_dependencies

View File

@@ -16,7 +16,7 @@ ci:
repos:
# autoformat and lint Python code
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.2
rev: v0.3.5
hooks:
- id: ruff
types_or:

View File

@@ -15,6 +15,7 @@ build:
python:
install:
- path: .
- requirements: docs/requirements.txt
formats:

View File

@@ -56,7 +56,7 @@ for administration of the Hub and its users.
### Check prerequisites
- A Linux/Unix based system
- [Python](https://www.python.org/downloads/) 3.6 or greater
- [Python](https://www.python.org/downloads/) 3.8 or greater
- [nodejs/npm](https://www.npmjs.com/)
- If you are using **`conda`**, the nodejs and npm dependencies will be installed for

View File

@@ -1,13 +1,6 @@
# We install the jupyterhub package to help autodoc-traits inspect it and
# generate documentation.
#
# FIXME: If there is a way for this requirements.txt file to pass a flag that
# the build system can intercept to not build the javascript artifacts,
# then do so so. That would mean that installing the documentation can
# avoid needing node/npm installed.
#
--editable .
# docs also require jupyterhub itself to be installed
# don't depend on it here, as that often results in a duplicate
# installation of jupyterhub that's already installed
autodoc-traits
jupyterhub-sphinx-theme
myst-parser>=0.19

View File

@@ -70,6 +70,8 @@ myst_enable_extensions = [
myst_substitutions = {
# date example: Dev 07, 2022
"date": datetime.date.today().strftime("%b %d, %Y").title(),
"node_min": "12",
"python_min": "3.8",
"version": jupyterhub.__version__,
}
@@ -289,6 +291,8 @@ linkcheck_ignore = [
"https://github.com/jupyterhub/jupyterhub/compare/", # too many comparisons in changelog
r"https?://(localhost|127.0.0.1).*", # ignore localhost references in auto-links
r"https://linux.die.net/.*", # linux.die.net seems to block requests from CI with 403 sometimes
# don't check links to unpublished advisories
r"https://github.com/jupyterhub/jupyterhub/security/advisories/.*",
]
linkcheck_anchors_ignore = [
"/#!",

View File

@@ -12,18 +12,18 @@ development.
### Install Python
JupyterHub is written in the [Python](https://python.org) programming language and
requires you have at least version 3.6 installed locally. If you havent
requires you have at least version {{python_min}} installed locally. If you havent
installed Python before, the recommended way to install it is to use
[Miniforge](https://github.com/conda-forge/miniforge#download).
### Install nodejs
[NodeJS 12+](https://nodejs.org/en/) is required for building some JavaScript components.
[NodeJS {{node_min}}+](https://nodejs.org/en/) is required for building some JavaScript components.
`configurable-http-proxy`, the default proxy implementation for JupyterHub, is written in Javascript.
If you have not installed NodeJS before, we recommend installing it in the `miniconda` environment you set up for Python.
You can do so with `conda install nodejs`.
Many in the Jupyter community use \[`nvm`\](<https://github.com/nvm-sh/nvm>) to
Many in the Jupyter community use [`nvm`](https://github.com/nvm-sh/nvm) to
managing node dependencies.
### Install git
@@ -59,7 +59,7 @@ a more detailed discussion.
python -V
```
This should return a version number greater than or equal to 3.6.
This should return a version number greater than or equal to {{python_min}}.
```bash
npm -v

View File

@@ -16,7 +16,8 @@ works.
JupyterHub is designed to be a _simple multi-user server for modestly sized
groups_ of **semi-trusted** users. While the design reflects serving
semi-trusted users, JupyterHub can also be suitable for serving **untrusted** users.
semi-trusted users, JupyterHub can also be suitable for serving **untrusted** users,
but **is not suitable for untrusted users** in its default configuration.
As a result, using JupyterHub with **untrusted** users means more work by the
administrator, since much care is required to secure a Hub, with extra caution on
@@ -56,30 +57,63 @@ ensure that:
If any additional services are run on the same domain as the Hub, the services
**must never** display user-authored HTML that is neither _sanitized_ nor _sandboxed_
(e.g. IFramed) to any user that lacks authentication as the author of a file.
to any user that lacks authentication as the author of a file.
### Sharing access to servers
Because sharing access to servers (via `access:servers` scopes or the sharing feature in JupyterHub 5) by definition means users can serve each other files, enabling sharing is not suitable for untrusted users without also enabling per-user domains.
JupyterHub does not enable any sharing by default.
## Mitigate security issues
The several approaches to mitigating security issues with configuration
options provided by JupyterHub include:
### Enable subdomains
### Enable user subdomains
JupyterHub provides the ability to run single-user servers on their own
subdomains. This means the cross-origin protections between servers has the
desired effect, and user servers and the Hub are protected from each other. A
user's single-user server will be at `username.jupyter.mydomain.com`. This also
requires all user subdomains to point to the same address, which is most easily
accomplished with wildcard DNS. Since this spreads the service across multiple
domains, you will need wildcard SSL as well. Unfortunately, for many
institutional domains, wildcard DNS and SSL are not available. **If you do plan
to serve untrusted users, enabling subdomains is highly encouraged**, as it
resolves the cross-site issues.
domains. This means the cross-origin protections between servers has the
desired effect, and user servers and the Hub are protected from each other.
**Subdomains are the only way to reliably isolate user servers from each other.**
To enable subdomains, set:
```python
c.JupyterHub.subdomain_host = "https://jupyter.example.org"
```
When subdomains are enabled, each user's single-user server will be at e.g. `https://username.jupyter.example.org`.
This also requires all user subdomains to point to the same address,
which is most easily accomplished with wildcard DNS, where a single A record points to your server and a wildcard CNAME record points to your A record:
```
A jupyter.example.org 192.168.1.123
CNAME *.jupyter.example.org jupyter.example.org
```
Since this spreads the service across multiple domains, you will likely need wildcard SSL as well,
matching `*.jupyter.example.org`.
Unfortunately, for many institutional domains, wildcard DNS and SSL may not be available.
We also **strongly encourage** serving JupyterHub and user content on a domain that is _not_ a subdomain of any sensitive content.
For reasoning, see [GitHub's discussion of moving user content to github.io from \*.github.com](https://github.blog/2013-04-09-yummy-cookies-across-domains/).
**If you do plan to serve untrusted users, enabling subdomains is highly encouraged**,
as it resolves many security issues, which are difficult to unavoidable when JupyterHub is on a single-domain.
:::{important}
JupyterHub makes no guarantees about protecting users from each other unless subdomains are enabled.
If you want to protect users from each other, you **_must_** enable per-user domains.
:::
### Disable user config
If subdomains are unavailable or undesirable, JupyterHub provides a
configuration option `Spawner.disable_user_config`, which can be set to prevent
configuration option `Spawner.disable_user_config = True`, which can be set to prevent
the user-owned configuration files from being loaded. After implementing this
option, `PATH`s and package installation are the other things that the
admin must enforce.
@@ -89,23 +123,24 @@ admin must enforce.
For most Spawners, `PATH` is not something users can influence, but it's important that
the Spawner should _not_ evaluate shell configuration files prior to launching the server.
### Isolate packages using virtualenv
### Isolate packages in a read-only environment
Package isolation is most easily handled by running the single-user server in
a virtualenv with disabled system-site-packages. The user should not have
permission to install packages into this environment.
The user must not have permission to install packages into the environment where the singleuser-server runs.
On a shared system, package isolation is most easily handled by running the single-user server in
a root-owned virtualenv with disabled system-site-packages.
The user must not have permission to install packages into this environment.
The same principle extends to the images used by container-based deployments.
If users can select the images in which their servers run, they can disable all security.
If users can select the images in which their servers run, they can disable all security for their own servers.
It is important to note that the control over the environment only affects the
single-user server, and not the environment(s) in which the user's kernel(s)
It is important to note that the control over the environment is only required for the
single-user server, and not the environment(s) in which the users' kernel(s)
may run. Installing additional packages in the kernel environment does not
pose additional risk to the web application's security.
### Encrypt internal connections with SSL/TLS
By default, all communications on the server, between the proxy, hub, and single
-user notebooks are performed unencrypted. Setting the `internal_ssl` flag in
By default, all communications within JupyterHub—between the proxy, hub, and single
-user notebooksare performed unencrypted. Setting the `internal_ssl` flag in
`jupyterhub_config.py` secures the aforementioned routes. Turning this
feature on does require that the enabled `Spawner` can use the certificates
generated by the `Hub` (the default `LocalProcessSpawner` can, for instance).
@@ -119,6 +154,104 @@ Unix permissions to the communication sockets thereby restricting
communication to the socket owner. The `internal_ssl` option will eventually
extend to securing the `tcp` sockets as well.
### Mitigating same-origin deployments
While per-user domains are **required** for robust protection of users from each other,
you can mitigate many (but not all) cross-user issues.
First, it is critical that users cannot modify their server environments, as described above.
Second, it is important that users do not have `access:servers` permission to any server other than their own.
If users can access each others' servers, additional security measures must be enabled, some of which come with distinct user-experience costs.
Without the [Same-Origin Policy] (SOP) protecting user servers from each other,
each user server is considered a trusted origin for requests to each other user server (and the Hub itself).
Servers _cannot_ meaningfully distinguish requests originating from other user servers,
because SOP implies a great deal of trust, losing many restrictions applied to cross-origin requests.
That means pages served from each user server can:
1. arbitrarily modify the path in the Referer
2. make fully authorized requests with cookies
3. access full page contents served from the hub or other servers via popups
JupyterHub uses distinct xsrf tokens stored in cookies on each server path to attempt to limit requests across.
This has limitations because not all requests are protected by these XSRF tokens,
and unless additional measures are taken, the XSRF tokens from other user prefixes may be retrieved.
[Same-Origin Policy]: https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy
For example:
- `Content-Security-Policy` header must prohibit popups and iframes from the same origin.
The following Content-Security-Policy rules are _insecure_ and readily enable users to access each others' servers:
- `frame-ancestors: 'self'`
- `frame-ancestors: '*'`
- `sandbox allow-popups`
- Ideally, pages should use the strictest `Content-Security-Policy: sandbox` available,
but this is not feasible in general for JupyterLab pages, which need at least `sandbox allow-same-origin allow-scripts` to work.
The default Content-Security-Policy for single-user servers is
```
frame-ancestors: 'none'
```
which prohibits iframe embedding, but not pop-ups.
A more secure Content-Security-Policy that has some costs to user experience is:
```
frame-ancestors: 'none'; sandbox allow-same-origin allow-scripts
```
`allow-popups` is not disabled by default because disabling it breaks legitimate functionality, like "Open this in a new tab", and the "JupyterHub Control Panel" menu item.
To reiterate, the right way to avoid these issues is to enable per-user domains, where none of these concerns come up.
Note: even this level of protection requires administrators maintaining full control over the user server environment.
If users can modify their server environment, these methods are ineffective, as users can readily disable them.
### Cookie tossing
Cookie tossing is a technique where another server on a subdomain or peer subdomain can set a cookie
which will be read on another domain.
This is not relevant unless there are other user-controlled servers on a peer domain.
"Domain-locked" cookies avoid this issue, but have their own restrictions:
- JupyterHub must be served over HTTPS
- All secure cookies must be set on `/`, not on sub-paths, which means they are shared by all JupyterHub components in a single-domain deployment.
As a result, this option is only recommended when per-user subdomains are enabled,
to prevent sending all jupyterhub cookies to all user servers.
To enable domain-locked cookies, set:
```python
c.JupyterHub.cookie_host_prefix_enabled = True
```
```{versionadded} 4.1
```
### Forced-login
Jupyter servers can share links with `?token=...`.
JupyterHub prior to 5.0 will accept this request and persist the token for future requests.
This is useful for enabling admins to create 'fully authenticated' links bypassing login.
However, it also means users can share their own links that will log other users into their own servers,
enabling them to serve each other notebooks and other arbitrary HTML, depending on server configuration.
```{versionadded} 4.1
Setting environment variable `JUPYTERHUB_ALLOW_TOKEN_IN_URL=0` in the single-user environment can opt out of accepting token auth in URL parameters.
```
```{versionadded} 5.0
Accepting tokens in URLs is disabled by default, and `JUPYTERHUB_ALLOW_TOKEN_IN_URL=1` environment variable must be set to _allow_ token auth in URL parameters.
```
## Security audits
We recommend that you do periodic reviews of your deployment's security. It's

View File

@@ -37,14 +37,19 @@ A [generic implementation](https://github.com/jupyterhub/oauthenticator/blob/mas
## The Dummy Authenticator
When testing, it may be helpful to use the
{class}`jupyterhub.auth.DummyAuthenticator`. This allows for any username and
password unless if a global password has been set. Once set, any username will
{class}`~.jupyterhub.auth.DummyAuthenticator`. This allows for any username and
password unless a global password has been set. Once set, any username will
still be accepted but the correct password will need to be provided.
:::{versionadded} 5.0
The DummyAuthenticator's default `allow_all` is True,
unlike most other Authenticators.
:::
## Additional Authenticators
A partial list of other authenticators is available on the
[JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
Additional authenticators can be found on GitHub
by searching for [topic:jupyterhub topic:authenticator](https://github.com/search?q=topic%3Ajupyterhub%20topic%3Aauthenticator&type=repositories).
## Technical Overview of Authentication
@@ -54,9 +59,9 @@ The base authenticator uses simple username and password authentication.
The base Authenticator has one central method:
#### Authenticator.authenticate method
#### Authenticator.authenticate
Authenticator.authenticate(handler, data)
{meth}`.Authenticator.authenticate`
This method is passed the Tornado `RequestHandler` and the `POST data`
from JupyterHub's login form. Unless the login form has been customized,
@@ -81,7 +86,8 @@ Writing an Authenticator that looks up passwords in a dictionary
requires only overriding this one method:
```python
from IPython.utils.traitlets import Dict
from secrets import compare_digest
from traitlets import Dict
from jupyterhub.auth import Authenticator
class DictionaryAuthenticator(Authenticator):
@@ -91,8 +97,14 @@ class DictionaryAuthenticator(Authenticator):
)
async def authenticate(self, handler, data):
if self.passwords.get(data['username']) == data['password']:
return data['username']
username = data["username"]
password = data["password"]
check_password = self.passwords.get(username, "")
# always call compare_digest, for timing attacks
if compare_digest(check_password, password) and username in self.passwords:
return username
else:
return None
```
#### Normalize usernames
@@ -136,7 +148,7 @@ To only allow usernames that start with 'w':
c.Authenticator.username_pattern = r'w.*'
```
### How to write a custom authenticator
## How to write a custom authenticator
You can use custom Authenticator subclasses to enable authentication
via other mechanisms. One such example is using [GitHub OAuth][].
@@ -148,11 +160,6 @@ 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).
See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
If you are interested in writing a custom authenticator, you can read
[this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html).
### Registering custom Authenticators via entry points
As of JupyterHub 1.0, custom authenticators can register themselves via
@@ -188,6 +195,166 @@ Additionally, configurable attributes for your authenticator will
appear in jupyterhub help output and auto-generated configuration files
via `jupyterhub --generate-config`.
(authenticator-allow)=
### Allowing access
When dealing with logging in, there are generally two _separate_ steps:
authentication
: identifying who is trying to log in, and
authorization
: deciding whether an authenticated user is allowed to access your JupyterHub
{meth}`Authenticator.authenticate` is responsible for authenticating users.
It is perfectly fine in the simplest cases for `Authenticator.authenticate` to be responsible for authentication _and_ authorization,
in which case `authenticate` may return `None` if the user is not authorized.
However, Authenticators also have two methods, {meth}`~.Authenticator.check_allowed` and {meth}`~.Authenticator.check_blocked_users`, which are called after successful authentication to further check if the user is allowed.
If `check_blocked_users()` returns False, authorization stops and the user is not allowed.
If `Authenticator.allow_all` is True OR `check_allowed()` returns True, authorization proceeds.
:::{versionadded} 5.0
{attr}`.Authenticator.allow_all` and {attr}`.Authenticator.allow_existing_users` are new in JupyterHub 5.0.
By default, `allow_all` is False,
which is a change from pre-5.0, where `allow_all` was implicitly True if `allowed_users` was empty.
:::
### Overriding `check_allowed`
:::{versionchanged} 5.0
`check_allowed()` is **not called** if `allow_all` is True.
:::
:::{versionchanged} 5.0
Starting with 5.0, `check_allowed()` should **NOT** return True if no allow config
is specified (`allow_all` should be used instead).
:::
The base implementation of {meth}`~.Authenticator.check_allowed` checks:
- if username is in the `allowed_users` set, return True
- else return False
:::{versionchanged} 5.0
Prior to 5.0, this would also return True if `allowed_users` was empty.
For clarity, this is no longer the case. A new `allow_all` property (default False) has been added which is checked _before_ calling `check_allowed`.
If `allow_all` is True, this takes priority over `check_allowed`, which will be ignored.
If your Authenticator subclass similarly returns True when no allow config is defined,
this is fully backward compatible for your users, but means `allow_all = False` has no real effect.
You can make your Authenticator forward-compatible with JupyterHub 5 by defining `allow_all` as a boolean config trait on your class:
```python
class MyAuthenticator(Authenticator):
# backport allow_all from JupyterHub 5
allow_all = Bool(False, config=True)
def check_allowed(self, username, authentication):
if self.allow_all:
# replaces previous "if no auth config"
return True
...
```
:::
If an Authenticator defines additional sources of `allow` configuration,
such as membership in a group or other information,
it should override `check_allowed` to account for this.
:::{note}
`allow_` configuration should generally be _additive_,
i.e. if access is granted by _any_ allow configuration,
a user should be authorized.
JupyterHub recommends that Authenticators applying _restrictive_ configuration should use names like `block_` or `require_`,
and check this during `check_blocked_users` or `authenticate`, not `check_allowed`.
:::
In general, an Authenticator's skeleton should look like:
```python
class MyAuthenticator(Authenticator):
# backport allow_all for compatibility with JupyterHub < 5
allow_all = Bool(False, config=True)
require_something = List(config=True)
allowed_something = Set()
def authenticate(self, data, handler):
...
if success:
return {"username": username, "auth_state": {...}}
else:
return None
def check_blocked_users(self, username, authentication=None):
"""Apply _restrictive_ configuration"""
if self.require_something and not has_something(username, self.request_):
return False
# repeat for each restriction
if restriction_defined and restriction_not_met:
return False
return super().check_blocked_users(self, username, authentication)
def check_allowed(self, username, authentication=None):
"""Apply _permissive_ configuration
Only called if check_blocked_users returns True
AND allow_all is False
"""
if self.allow_all:
# check here to backport allow_all behavior
# from JupyterHub 5
# this branch will never be taken with jupyterhub >=5
return True
if self.allowed_something and user_has_something(username):
return True
# repeat for each allow
if allow_config and allow_met:
return True
# should always have this at the end
if self.allowed_users and username in self.allowed_users:
return True
# do not call super!
# super().check_allowed is not safe with JupyterHub < 5.0,
# as it will return True if allowed_users is empty
return False
```
Key points:
- `allow_all` is backported from JupyterHub 5, for consistent behavior in all versions of JupyterHub (optional)
- restrictive configuration is checked in `check_blocked_users`
- if any restriction is not met, `check_blocked_users` returns False
- permissive configuration is checked in `check_allowed`
- if any `allow` condition is met, `check_allowed` returns True
So the logical expression for a user being authorized should look like:
> if ALL restrictions are met AND ANY admissions are met: user is authorized
#### Custom error messages
Any of these authentication and authorization methods may raise a `web.HTTPError` Exception
```python
from tornado import web
raise web.HTTPError(403, "informative message")
```
if you want to show a more informative login failure message rather than the generic one.
(authenticator-auth-state)=
### Authentication state

View File

@@ -6,8 +6,161 @@ For detailed changes from the prior release, click on the version number, and
its link will bring up a GitHub listing of changes. Use `git log` on the
command line for details.
## Versioning
JupyterHub follows Intended Effort Versioning ([EffVer](https://jacobtomlinson.dev/effver/)) for versioning,
where the version number is meant to indicate the amount of effort required to upgrade to the new version.
Contributors to major version bumps in JupyterHub include:
- Database schema changes that require migrations and are hard to roll back
- Increasing the minimum required Python version
- Large new features
- Breaking changes likely to affect users
## [Unreleased]
## 4.1
### 4.1.5 - 2024-04-04
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.4...4.1.5))
#### Bugs fixed
- singleuser mixin: include check_xsrf_cookie in overrides [#4771](https://github.com/jupyterhub/jupyterhub/pull/4771) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
#### Contributors to this release
The following people contributed discussions, new ideas, code and documentation contributions, and review.
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-30&to=2024-04-04&type=c))
@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-30..2024-04-04&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2024-03-30..2024-04-04&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-30..2024-04-04&type=Issues))
### 4.1.4 - 2024-03-30
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.3...4.1.4))
#### Bugs fixed
- avoid xsrf check on navigate GET requests [#4759](https://github.com/jupyterhub/jupyterhub/pull/4759) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
#### Contributors to this release
The following people contributed discussions, new ideas, code and documentation contributions, and review.
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-26&to=2024-03-30&type=c))
@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-26..2024-03-30&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-26..2024-03-30&type=Issues))
### 4.1.3 - 2024-03-26
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.2...4.1.3))
#### Bugs fixed
- respect jupyter-server disable_check_xsrf setting [#4753](https://github.com/jupyterhub/jupyterhub/pull/4753) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
#### Contributors to this release
The following people contributed discussions, new ideas, code and documentation contributions, and review.
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-25&to=2024-03-26&type=c))
@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-25..2024-03-26&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-25..2024-03-26&type=Issues))
### 4.1.2 - 2024-03-25
4.1.2 fixes a regression in 4.1.0 affecting named servers.
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.1...4.1.2))
#### Bugs fixed
- rework handling of multiple xsrf tokens [#4750](https://github.com/jupyterhub/jupyterhub/pull/4750) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
#### Contributors to this release
The following people contributed discussions, new ideas, code and documentation contributions, and review.
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-23&to=2024-03-25&type=c))
@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-23..2024-03-25&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-23..2024-03-25&type=Issues))
### 4.1.1 - 2024-03-23
4.1.1 fixes a compatibility regression in 4.1.0 for some extensions,
particularly jupyter-server-proxy.
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.0...4.1.1))
#### Bugs fixed
- allow subclasses to override xsrf check [#4745](https://github.com/jupyterhub/jupyterhub/pull/4745) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
#### Contributors to this release
The following people contributed discussions, new ideas, code and documentation contributions, and review.
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-20&to=2024-03-23&type=c))
@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-20..2024-03-23&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-20..2024-03-23&type=Issues))
### 4.1.0 - 2024-03-20
JupyterHub 4.1 is a security release, fixing [CVE-2024-28233].
All JupyterHub deployments are encouraged to upgrade,
especially those with other user content on peer domains to JupyterHub.
As always, JupyterHub deployments are especially encouraged to enable per-user domains if protecting users from each other is a concern.
For more information on securely deploying JupyterHub, see the [web security documentation](web-security).
[CVE-2024-28233]: https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-7r3h-4ph8-w38g
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.0.2...4.1.0))
#### Enhancements made
- Backport PR #4628 on branch 4.x (Include LDAP groups in local spawner gids) [#4735](https://github.com/jupyterhub/jupyterhub/pull/4735) ([@minrk](https://github.com/minrk))
- Backport PR #4561 on branch 4.x (Improve debugging when waiting for servers) [#4714](https://github.com/jupyterhub/jupyterhub/pull/4714) ([@minrk](https://github.com/minrk))
- Backport PR #4563 on branch 4.x (only set 'domain' field on session-id cookie) [#4707](https://github.com/jupyterhub/jupyterhub/pull/4707) ([@minrk](https://github.com/minrk))
#### Bugs fixed
- Backport PR #4733 on branch 4.x (Catch ValueError while waiting for server to be reachable) [#4734](https://github.com/jupyterhub/jupyterhub/pull/4734) ([@minrk](https://github.com/minrk))
- Backport PR #4679 on branch 4.x (Unescape jinja username) [#4705](https://github.com/jupyterhub/jupyterhub/pull/4705) ([@minrk](https://github.com/minrk))
- Backport PR #4630: avoid setting unused oauth state cookies on API requests [#4697](https://github.com/jupyterhub/jupyterhub/pull/4697) ([@minrk](https://github.com/minrk))
- Backport PR #4632: simplify, avoid errors in parsing accept headers [#4696](https://github.com/jupyterhub/jupyterhub/pull/4696) ([@minrk](https://github.com/minrk))
- Backport PR #4677 on branch 4.x (Improve validation, docs for token.expires_in) [#4692](https://github.com/jupyterhub/jupyterhub/pull/4692) ([@minrk](https://github.com/minrk))
- Backport PR #4570 on branch 4.x (fix mutation of frozenset in scope intersection) [#4691](https://github.com/jupyterhub/jupyterhub/pull/4691) ([@minrk](https://github.com/minrk))
- Backport PR #4562 on branch 4.x (Use `user.stop` to cleanup spawners that stopped while Hub was down) [#4690](https://github.com/jupyterhub/jupyterhub/pull/4690) ([@minrk](https://github.com/minrk))
- Backport PR #4542 on branch 4.x (Fix include_stopped_servers in paginated next_url) [#4689](https://github.com/jupyterhub/jupyterhub/pull/4689) ([@minrk](https://github.com/minrk))
- Backport PR #4651 on branch 4.x (avoid attempting to patch removed IPythonHandler with notebook v7) [#4688](https://github.com/jupyterhub/jupyterhub/pull/4688) ([@minrk](https://github.com/minrk))
- Backport PR #4560 on branch 4.x (singleuser extension: persist token from ?token=... url in cookie) [#4687](https://github.com/jupyterhub/jupyterhub/pull/4687) ([@minrk](https://github.com/minrk))
#### Maintenance and upkeep improvements
- Backport quay.io publishing [#4698](https://github.com/jupyterhub/jupyterhub/pull/4698) ([@minrk](https://github.com/minrk))
- Backport PR #4617: try to improve reliability of test_external_proxy [#4695](https://github.com/jupyterhub/jupyterhub/pull/4695) ([@minrk](https://github.com/minrk))
- Backport PR #4618 on branch 4.x (browser test: wait for token request to finish before reloading) [#4694](https://github.com/jupyterhub/jupyterhub/pull/4694) ([@minrk](https://github.com/minrk))
- preparing 4.x branch [#4685](https://github.com/jupyterhub/jupyterhub/pull/4685) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
#### Contributors to this release
The following people contributed discussions, new ideas, code and documentation contributions, and review.
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2023-08-10&to=2024-03-19&type=c))
@Achele ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AAchele+updated%3A2023-08-10..2024-03-19&type=Issues)) | @akashthedeveloper ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aakashthedeveloper+updated%3A2023-08-10..2024-03-19&type=Issues)) | @balajialg ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abalajialg+updated%3A2023-08-10..2024-03-19&type=Issues)) | @BhavyaT-135 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ABhavyaT-135+updated%3A2023-08-10..2024-03-19&type=Issues)) | @blink1073 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ablink1073+updated%3A2023-08-10..2024-03-19&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2023-08-10..2024-03-19&type=Issues)) | @fcollonval ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Afcollonval+updated%3A2023-08-10..2024-03-19&type=Issues)) | @I-Am-D-B ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AI-Am-D-B+updated%3A2023-08-10..2024-03-19&type=Issues)) | @jakirkham ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajakirkham+updated%3A2023-08-10..2024-03-19&type=Issues)) | @ktaletsk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aktaletsk+updated%3A2023-08-10..2024-03-19&type=Issues)) | @kzgrzendek ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akzgrzendek+updated%3A2023-08-10..2024-03-19&type=Issues)) | @lumberbot-app ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Alumberbot-app+updated%3A2023-08-10..2024-03-19&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2023-08-10..2024-03-19&type=Issues)) | @mbiette ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ambiette+updated%3A2023-08-10..2024-03-19&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2023-08-10..2024-03-19&type=Issues)) | @rcthomas ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arcthomas+updated%3A2023-08-10..2024-03-19&type=Issues)) | @ryanlovett ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aryanlovett+updated%3A2023-08-10..2024-03-19&type=Issues)) | @sgaist ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asgaist+updated%3A2023-08-10..2024-03-19&type=Issues)) | @shubham0473 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ashubham0473+updated%3A2023-08-10..2024-03-19&type=Issues)) | @Temidayo32 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ATemidayo32+updated%3A2023-08-10..2024-03-19&type=Issues)) | @willingc ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awillingc+updated%3A2023-08-10..2024-03-19&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2023-08-10..2024-03-19&type=Issues))
## 4.0
### 4.0.2 - 2023-08-10

View File

@@ -6,21 +6,58 @@ The default Authenticator uses [PAM][] (Pluggable Authentication Module) to auth
their usernames and passwords. With the default Authenticator, any user
with an account and password on the system will be allowed to login.
## Create a set of allowed users (`allowed_users`)
## Deciding who is allowed
In the base Authenticator, there are 3 configuration options for granting users access to your Hub:
1. `allow_all` grants any user who can successfully authenticate access to the Hub
2. `allowed_users` defines a set of users who can access the Hub
3. `allow_existing_users` enables managing users via the JupyterHub API or admin page
These options should apply to all Authenticators.
Your chosen Authenticator may add additional configuration options to admit users, such as team membership, course enrollment, etc.
:::{important}
You should always specify at least one allow configuration if you want people to be able to access your Hub!
In most cases, this looks like:
```python
c.Authenticator.allow_all = True
# or
c.Authenticator.allowed_users = {"name", ...}
```
:::
:::{versionchanged} 5.0
If no allow config is specified, then by default **nobody will have access to your Hub**.
Prior to 5.0, the opposite was true; effectively `allow_all = True` if no other allow config was specified.
:::
You can restrict which users are allowed to login with a set,
`Authenticator.allowed_users`:
```python
c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
# c.Authenticator.allow_all = False
c.Authenticator.allow_existing_users = False
```
Users in the `allowed_users` set are added to the Hub database when the Hub is
started.
Users in the `allowed_users` set are added to the Hub database when the Hub is started.
```{warning}
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
```
:::{versionchanged} 5.0
{attr}`.Authenticator.allow_all` and {attr}`.Authenticator.allow_existing_users` are new in JupyterHub 5.0
to enable explicit configuration of previously implicit behavior.
Prior to 5.0, `allow_all` was implicitly True if `allowed_users` was empty.
Starting with 5.0, to allow all authenticated users by default,
`allow_all` must be explicitly set to True.
By default, `allow_existing_users` is True when `allowed_users` is not empty,
to ensure backward-compatibility.
To make the `allowed_users` set _restrictive_,
set `allow_existing_users = False`.
:::
## One Time Passwords ( request_otp )
@@ -42,7 +79,7 @@ c.Authenticator.otp_prompt = 'Google Authenticator:'
```{note}
As of JupyterHub 2.0, the full permissions of `admin_users`
should not be required.
Instead, you can assign [roles](define-role-target) to users or groups
Instead, it is best to assign [roles](define-role-target) to users or groups
with only the scopes they require.
```
@@ -68,26 +105,55 @@ group. For example, we can let any user in the `wheel` group be an admin:
c.PAMAuthenticator.admin_groups = {'wheel'}
```
## Give admin access to other users' notebook servers (`admin_access`)
## Give some users access to other users' notebook servers
Since the default `JupyterHub.admin_access` setting is `False`, the admins
do not have permission to log in to the single user notebook servers
owned by _other users_. If `JupyterHub.admin_access` is set to `True`,
then admins have permission to log in _as other users_ on their
respective machines for debugging. **As a courtesy, you should make
sure your users know if admin_access is enabled.**
The `access:servers` scope can be granted to users to give them permission to visit other users' servers.
For example, to give members of the `teachers` group access to the servers of members of the `students` group:
```python
c.JupyterHub.load_roles = [
{
"name": "teachers",
"scopes": [
"admin-ui",
"list:users",
"access:servers!group=students",
],
"groups": ["teachers"],
}
]
```
By default, only the deprecated `admin` role has global `access` permissions.
**As a courtesy, you should make sure your users know if admin access is enabled.**
## Add or remove users from the Hub
:::{versionadded} 5.0
`c.Authenticator.allow_existing_users` is added in 5.0 and True by default _if_ any `allowed_users` are specified.
Prior to 5.0, this behavior was not optional.
:::
Users can be added to and removed from the Hub via the admin
panel or the REST API. When a user is **added**, the user will be
automatically added to the `allowed_users` set and database. Restarting the Hub
will not require manually updating the `allowed_users` set in your config file,
panel or the REST API.
To enable this behavior, set:
```python
c.Authenticator.allow_existing_users = True
```
When a user is **added**, the user will be
automatically added to the `allowed_users` set and database.
If `allow_existing_users` is True, restarting the Hub will not require manually updating the `allowed_users` set in your config file,
as the users will be loaded from the database.
If `allow_existing_users` is False, users not granted access by configuration such as `allowed_users` will not be permitted to login,
even if they are present in the database.
After starting the Hub once, it is not sufficient to **remove** a user
from the allowed users set in your config file. You must also remove the user
from the Hub's database, either by deleting the user from JupyterHub's
from the Hub's database, either by deleting the user via JupyterHub's
admin page, or you can clear the `jupyterhub.sqlite` database and start
fresh.

View File

@@ -5,11 +5,11 @@
Before installing JupyterHub, you will need:
- a Linux/Unix-based system
- [Python](https://www.python.org/downloads/) 3.6 or greater. An understanding
- [Python {{python_min}}](https://www.python.org/downloads/) or greater. An understanding
of using [`pip`](https://pip.pypa.io) or
[`conda`](https://docs.conda.io/projects/conda/en/latest/user-guide/getting-started.html) for
installing Python packages is helpful.
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
- [Node.js {{node_min}}](https://www.npmjs.com/) or greater, along with npm. [Install Node.js/npm](https://docs.npmjs.com/getting-started/installing-node),
using your operating system's package manager.
- If you are using **`conda`**, the nodejs and npm dependencies will be installed for
@@ -24,7 +24,7 @@ Before installing JupyterHub, you will need:
```
[nodesource][] is a great resource to get more recent versions of the nodejs runtime,
if your system package manager only has an old version of Node.js (e.g. 10 or older).
if your system package manager only has an old version of Node.js.
- A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module)
to use the [default Authenticator](authenticators).

62
jsx/package-lock.json generated
View File

@@ -3599,12 +3599,13 @@
}
},
"node_modules/body-parser": {
"version": "1.20.1",
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
"integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
@@ -3612,7 +3613,7 @@
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
"raw-body": "2.5.1",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
@@ -3623,16 +3624,18 @@
},
"node_modules/body-parser/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
@@ -3642,8 +3645,9 @@
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.0.0",
"dev": true,
"license": "MIT"
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true
},
"node_modules/bonjour-service": {
"version": "1.1.1",
@@ -3739,8 +3743,9 @@
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -4051,8 +4056,9 @@
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -4063,9 +4069,10 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.5.0",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -5071,16 +5078,17 @@
}
},
"node_modules/express": {
"version": "4.18.2",
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
"integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.1",
"body-parser": "1.20.2",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.5.0",
"cookie": "0.6.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
@@ -7319,8 +7327,9 @@
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@@ -8120,8 +8129,9 @@
},
"node_modules/qs": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
"integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.4"
},
@@ -8173,9 +8183,10 @@
}
},
"node_modules/raw-body": {
"version": "2.5.1",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
@@ -8188,8 +8199,9 @@
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
@@ -9468,8 +9480,9 @@
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
@@ -9797,9 +9810,10 @@
}
},
"node_modules/webpack-dev-middleware": {
"version": "5.3.3",
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz",
"integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"colorette": "^2.0.10",
"memfs": "^3.4.3",

247
jupyterhub/_xsrf_utils.py Normal file
View File

@@ -0,0 +1,247 @@
"""utilities for XSRF
Extends tornado's xsrf token checks with the following:
- only set xsrf cookie on navigation requests (cannot be fetched)
This utility file enables the consistent reuse of these functions
in both Hub and single-user code
"""
import base64
import hashlib
from http.cookies import SimpleCookie
from tornado import web
from tornado.log import app_log
def _get_signed_value_urlsafe(handler, name, b64_value):
"""Like get_signed_value (used in get_secure_cookie), but for urlsafe values
Decodes urlsafe_base64-encoded signed values
Returns None if any decoding failed
"""
if b64_value is None:
return None
if isinstance(b64_value, str):
try:
b64_value = b64_value.encode("ascii")
except UnicodeEncodeError:
app_log.warning("Invalid value %r", b64_value)
return None
# re-pad, since we stripped padding in _create_signed_value
remainder = len(b64_value) % 4
if remainder:
b64_value = b64_value + (b'=' * (4 - remainder))
try:
value = base64.urlsafe_b64decode(b64_value)
except ValueError:
app_log.warning("Invalid base64 value %r", b64_value)
return None
return web.decode_signed_value(
handler.application.settings["cookie_secret"],
name,
value,
max_age_days=31,
min_version=2,
)
def _create_signed_value_urlsafe(handler, name, value):
"""Like tornado's create_signed_value (used in set_secure_cookie), but returns urlsafe bytes"""
signed_value = handler.create_signed_value(name, value)
return base64.urlsafe_b64encode(signed_value).rstrip(b"=")
def _get_xsrf_token_cookie(handler):
"""
Get the _valid_ XSRF token and id from Cookie
Returns (xsrf_token, xsrf_id) found in Cookies header.
multiple xsrf cookies may be set on multiple paths;
RFC 6265 states that they should be in order of more specific path to less,
but ALSO states that servers should never rely on order.
Tornado (6.4) and stdlib (3.12) SimpleCookie explicitly use the _last_ value,
which means the cookie with the _least_ specific prefix will be used if more than one is present.
Because we sign values, we can get the first valid cookie and not worry about order too much.
This is simplified from tornado's HTTPRequest.cookies property
only looking for a single cookie.
"""
if "Cookie" not in handler.request.headers:
return (None, None)
for chunk in handler.request.headers["Cookie"].split(";"):
key = chunk.partition("=")[0].strip()
if key != "_xsrf":
# we are only looking for the _xsrf cookie
# ignore everything else
continue
# use stdlib parsing to handle quotes, validation, etc.
try:
xsrf_token = SimpleCookie(chunk)[key].value.encode("ascii")
except (ValueError, KeyError):
continue
xsrf_token_id = _get_signed_value_urlsafe(handler, "_xsrf", xsrf_token)
if xsrf_token_id:
# only return if we found a _valid_ xsrf cookie
# otherwise, keep looking
return (xsrf_token, xsrf_token_id)
# no valid token found found
return (None, None)
def _set_xsrf_cookie(handler, xsrf_id, *, cookie_path="", authenticated=None):
"""Set xsrf token cookie"""
xsrf_token = _create_signed_value_urlsafe(handler, "_xsrf", xsrf_id)
xsrf_cookie_kwargs = {}
xsrf_cookie_kwargs.update(handler.settings.get('xsrf_cookie_kwargs', {}))
xsrf_cookie_kwargs.setdefault("path", cookie_path)
if authenticated is None:
try:
current_user = handler.current_user
except Exception:
authenticated = False
else:
authenticated = bool(current_user)
if not authenticated:
# limit anonymous xsrf cookies to one hour
xsrf_cookie_kwargs.pop("expires", None)
xsrf_cookie_kwargs.pop("expires_days", None)
xsrf_cookie_kwargs["max_age"] = 3600
app_log.info(
"Setting new xsrf cookie for %r %r",
xsrf_id,
xsrf_cookie_kwargs,
)
handler.set_cookie("_xsrf", xsrf_token, **xsrf_cookie_kwargs)
def get_xsrf_token(handler, cookie_path=""):
"""Override tornado's xsrf token to add further restrictions
- only set cookie for regular pages (not API requests)
- include login info in xsrf token
- verify signature
"""
# original: https://github.com/tornadoweb/tornado/blob/v6.4.0/tornado/web.py#L1455
if hasattr(handler, "_xsrf_token"):
return handler._xsrf_token
_set_cookie = False
# the raw cookie is the token
xsrf_token, xsrf_id_cookie = _get_xsrf_token_cookie(handler)
cookie_token = xsrf_token
# check the decoded, signed value for validity
xsrf_id = handler._xsrf_token_id
if xsrf_id_cookie != xsrf_id:
# this will usually happen on the first page request after login,
# which changes the inputs to the token id
if xsrf_id_cookie:
app_log.debug("xsrf id mismatch %r != %r", xsrf_id_cookie, xsrf_id)
# generate new value
xsrf_token = _create_signed_value_urlsafe(handler, "_xsrf", xsrf_id)
# only set cookie on regular navigation pages
# i.e. not API requests, etc.
# insecure URLs (public hostname/ip, no https)
# don't set Sec-Fetch-Mode.
# consequence of assuming 'navigate': setting a cookie unnecessarily
# consequence of assuming not 'navigate': xsrf never set, nothing works
_set_cookie = (
handler.request.headers.get("Sec-Fetch-Mode", "navigate") == "navigate"
)
if xsrf_id_cookie and not _set_cookie:
# if we aren't setting a cookie here but we got one,
# this means things probably aren't going to work
app_log.warning(
"Not accepting incorrect xsrf token id in cookie on %s",
handler.request.path,
)
if _set_cookie:
_set_xsrf_cookie(handler, xsrf_id, cookie_path=cookie_path)
handler._xsrf_token = xsrf_token
return xsrf_token
def _needs_check_xsrf(handler):
"""Does the given cookie-authenticated request need to check xsrf?"""
if getattr(handler, "_token_authenticated", False):
return False
fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "unspecified")
if fetch_mode in {"websocket", "no-cors"} or (
fetch_mode in {"navigate", "unspecified"}
and handler.request.method.lower() in {"get", "head", "options"}
):
# no xsrf check needed for regular page views or no-cors
# or websockets after allow_websocket_cookie_auth passes
if fetch_mode == "unspecified":
app_log.warning(
f"Skipping XSRF check for insecure request {handler.request.method} {handler.request.path}"
)
return False
else:
return True
def check_xsrf_cookie(handler):
"""Check that xsrf cookie matches xsrf token in request"""
# overrides tornado's implementation
# because we changed what a correct value should be in xsrf_token
if not _needs_check_xsrf(handler):
# don't require XSRF for regular page views
return
token = (
handler.get_argument("_xsrf", None)
or handler.request.headers.get("X-Xsrftoken")
or handler.request.headers.get("X-Csrftoken")
)
if not token:
raise web.HTTPError(
403, f"'_xsrf' argument missing from {handler.request.method}"
)
try:
token = token.encode("utf8")
except UnicodeEncodeError:
raise web.HTTPError(403, "'_xsrf' argument invalid")
if token != handler.xsrf_token:
raise web.HTTPError(
403, f"XSRF cookie does not match {handler.request.method.upper()} argument"
)
def _anonymous_xsrf_id(handler):
"""Generate an appropriate xsrf token id for an anonymous request
Currently uses hash of request ip and user-agent
These are typically used only for the initial login page,
so only need to be valid for a few seconds to a few minutes
(enough to submit a login form with MFA).
"""
hasher = hashlib.sha256()
hasher.update(handler.request.remote_ip.encode("ascii"))
hasher.update(
handler.request.headers.get("User-Agent", "").encode("utf8", "replace")
)
return base64.urlsafe_b64encode(hasher.digest()).decode("ascii")

View File

@@ -76,15 +76,8 @@ class APIHandler(BaseHandler):
return True
async def prepare(self):
await super().prepare()
# tornado only checks xsrf on non-GET
# we also check xsrf on GETs to API endpoints
# make sure this runs after auth, which happens in super().prepare()
if self.request.method not in {"HEAD", "OPTIONS"} and self.settings.get(
"xsrf_cookies"
):
self.check_xsrf_cookie()
# we also check xsrf on GETs to API endpoints
_xsrf_safe_methods = {"HEAD", "OPTIONS"}
def check_xsrf_cookie(self):
if not hasattr(self, '_jupyterhub_user'):

View File

@@ -402,6 +402,25 @@ class JupyterHub(Application):
Useful for daemonizing JupyterHub.
""",
).tag(config=True)
cookie_host_prefix_enabled = Bool(
False,
help="""Enable `__Host-` prefix on authentication cookies.
The `__Host-` prefix on JupyterHub cookies provides further
protection against cookie tossing when untrusted servers
may control subdomains of your jupyterhub deployment.
_However_, it also requires that cookies be set on the path `/`,
which means they are shared by all JupyterHub components,
so a compromised server component will have access to _all_ JupyterHub-related
cookies of the visiting browser.
It is recommended to only combine `__Host-` cookies with per-user domains.
.. versionadded:: 4.1
""",
).tag(config=True)
cookie_max_age_days = Float(
14,
help="""Number of days for a login cookie to be valid.
@@ -2034,6 +2053,8 @@ class JupyterHub(Application):
hub_args['port'] = self.hub_port
self.hub = Hub(**hub_args)
if self.cookie_host_prefix_enabled:
self.hub.cookie_name = "__Host-" + self.hub.cookie_name
if not self.subdomain_host:
api_prefix = url_path_join(self.hub.base_url, "api/")
@@ -2077,6 +2098,9 @@ class JupyterHub(Application):
"auth_state is enabled, but encryption is not available: %s" % e
)
# give the authenticator a chance to check its own config
self.authenticator.check_allow_config()
if self.admin_users and not self.authenticator.admin_users:
self.log.warning(
"\nJupyterHub.admin_users is deprecated since version 0.7.2."
@@ -2104,9 +2128,9 @@ class JupyterHub(Application):
new_users.append(user)
else:
user.admin = True
# the admin_users config variable will never be used after this point.
# only the database values will be referenced.
allowed_users = [
self.authenticator.normalize_username(name)
for name in self.authenticator.allowed_users
@@ -2116,10 +2140,10 @@ class JupyterHub(Application):
if not self.authenticator.validate_username(username):
raise ValueError("username %r is not valid" % username)
if not allowed_users:
self.log.info(
"Not using allowed_users. Any authenticated user will be allowed."
)
if self.authenticator.allowed_users and self.authenticator.admin_users:
# make sure admin users are in the allowed_users set, if defined,
# otherwise they won't be able to login
self.authenticator.allowed_users |= self.authenticator.admin_users
# add allowed users to the db
for name in allowed_users:
@@ -3161,6 +3185,7 @@ class JupyterHub(Application):
default_url=self.default_url,
public_url=urlparse(self.public_url) if self.public_url else "",
cookie_secret=self.cookie_secret,
cookie_host_prefix_enabled=self.cookie_host_prefix_enabled,
cookie_max_age_days=self.cookie_max_age_days,
redirect_to_server=self.redirect_to_server,
login_url=login_url,

View File

@@ -121,6 +121,55 @@ class Authenticator(LoggingConfigurable):
"""
).tag(config=True)
any_allow_config = Bool(
False,
help="""Is there any allow config?
Used to show a warning if it looks like nobody can access the Hub,
which can happen when upgrading to JupyterHub 5,
now that `allow_all` defaults to False.
Deployments can set this explicitly to True to suppress
the "No allow config found" warning.
Will be True if any config tagged with `.tag(allow_config=True)`
or starts with `allow` is truthy.
.. versionadded:: 5.0
""",
).tag(config=True)
@default("any_allow_config")
def _default_any_allowed(self):
for trait_name, trait in self.traits(config=True).items():
if trait.metadata.get("allow_config", False) or trait_name.startswith(
"allow"
):
# this is only used for a helpful warning, so not the biggest deal if it's imperfect
if getattr(self, trait_name):
return True
return False
def check_allow_config(self):
"""Log a warning if no allow config can be found.
Could get a false positive if _only_ unrecognized allow config is used.
Authenticators can apply `.tag(allow_config=True)` to label this config
to make sure it is found.
Subclasses can override to perform additonal checks and warn about likely
authenticator configuration problems.
.. versionadded:: 5.0
"""
if not self.any_allow_config:
self.log.warning(
"No allow config found, it's possible that nobody can login to your Hub!\n"
"You can set `c.Authenticator.allow_all = True` to allow any user who can login to access the Hub,\n"
"or e.g. `allowed_users` to a set of users who should have access.\n"
"You may suppress this warning by setting c.Authenticator.any_allow_config = True."
)
whitelist = Set(
help="Deprecated, use `Authenticator.allowed_users`",
config=True,
@@ -132,7 +181,7 @@ class Authenticator(LoggingConfigurable):
Use this to limit which authenticated users may login.
Default behavior: only users in this set are allowed.
If empty, does not perform any restriction,
in which case any authenticated user is allowed.
@@ -144,6 +193,83 @@ class Authenticator(LoggingConfigurable):
"""
).tag(config=True)
allow_all = Bool(
False,
config=True,
help="""
Allow every user who can successfully authenticate to access JupyterHub.
False by default, which means for most Authenticators,
_some_ allow-related configuration is required to allow users to log in.
Authenticator subclasses may override the default with e.g.::
@default("allow_all")
def _default_allow_all(self):
# if _any_ auth config (depends on the Authenticator)
if self.allowed_users or self.allowed_groups or self.allow_existing_users:
return False
else:
return True
.. versionadded:: 5.0
.. versionchanged:: 5.0
Prior to 5.0, `allow_all` wasn't defined on its own,
and was instead implicitly True when no allow config was provided,
i.e. `allowed_users` unspecified or empty on the base Authenticator class.
To preserve pre-5.0 behavior,
set `allow_all = True` if you have no other allow configuration.
""",
).tag(allow_config=True)
allow_existing_users = Bool(
# dynamic default computed from allowed_users
config=True,
help="""
Allow existing users to login.
Defaults to True if `allowed_users` is set for historical reasons, and
False otherwise.
With this enabled, all users present in the JupyterHub database are allowed to login.
This has the effect of any user who has _previously_ been allowed to login
via any means will continue to be allowed until the user is deleted via the /hub/admin page
or REST API.
.. warning::
Before enabling this you should review the existing users in the
JupyterHub admin panel at `/hub/admin`. You may find users existing
there because they have previously been declared in config such as
`allowed_users` or allowed to sign in.
.. warning::
When this is enabled and you wish to remove access for one or more
users previously allowed, you must make sure that they
are removed from the jupyterhub database. This can be tricky to do
if you stop allowing an externally managed group of users for example.
With this enabled, JupyterHub admin users can visit `/hub/admin` or use
JupyterHub's REST API to add and remove users to manage who can login.
.. versionadded:: 5.0
""",
).tag(allow_config=True)
@default("allow_existing_users")
def _allow_existing_users_default(self):
"""
Computes the default value of allow_existing_users based on if
allowed_users to align with original behavior not introduce a breaking
change.
"""
if self.allowed_users:
return True
return False
blocked_users = Set(
help="""
Set of usernames that are not allowed to log in.
@@ -472,13 +598,12 @@ class Authenticator(LoggingConfigurable):
web.HTTPError(403):
Raising HTTPErrors directly allows customizing the message shown to the user.
"""
if not self.allowed_users:
# No allowed set means any name is allowed
if self.allow_all:
return True
return username in self.allowed_users
def check_blocked_users(self, username, authentication=None):
"""Check if a username is blocked to authenticate based on Authenticator.blocked configuration
"""Check if a username is blocked to authenticate based on Authenticator.blocked_users configuration
Return True if username is allowed, False otherwise.
No block list means any username is allowed.
@@ -525,8 +650,9 @@ class Authenticator(LoggingConfigurable):
The various stages can be overridden separately:
- `authenticate` turns formdata into a username
- `normalize_username` normalizes the username
- `check_allowed` checks against the allowed usernames
- `check_blocked_users` check against the blocked usernames
- `allow_all` is checked
- `check_allowed` checks against the allowed usernames
- `is_admin` check if a user is an admin
.. versionchanged:: 0.8
@@ -560,7 +686,11 @@ class Authenticator(LoggingConfigurable):
self.log.warning("User %r blocked. Stop authentication", username)
return
allowed_pass = await maybe_future(self.check_allowed(username, authenticated))
allowed_pass = self.allow_all
if not allowed_pass:
allowed_pass = await maybe_future(
self.check_allowed(username, authenticated)
)
if allowed_pass:
if authenticated['admin'] is None:
@@ -697,25 +827,31 @@ class Authenticator(LoggingConfigurable):
"""Hook called when a user is added to JupyterHub
This is called:
- When a user first authenticates
- When the hub restarts, for all users.
- When a user first authenticates, _after_ all allow and block checks have passed
- When the hub restarts, for all users in the database (i.e. users previously allowed)
- When a user is added to the database, either via configuration or REST API
This method may be a coroutine.
By default, this just adds the user to the allowed_users set.
By default, this adds the user to the allowed_users set if
allow_existing_users is true.
Subclasses may do more extensive things, such as adding actual unix users,
Subclasses may do more extensive things, such as creating actual system users,
but they should call super to ensure the allowed_users set is updated.
Note that this should be idempotent, since it is called whenever the hub restarts
for all users.
.. versionchanged:: 5.0
Now adds users to the allowed_users set if allow_all is False and allow_existing_users is True,
instead of if allowed_users is not empty.
Args:
user (User): The User wrapper object
"""
if not self.validate_username(user.name):
raise ValueError("Invalid username: %s" % user.name)
if self.allowed_users:
if self.allow_existing_users and not self.allow_all:
self.allowed_users.add(user.name)
def delete_user(self, user):
@@ -962,23 +1098,16 @@ class LocalAuthenticator(Authenticator):
help="""
Allow login from all users in these UNIX groups.
If set, allowed username set is ignored.
.. versionchanged:: 5.0
`allowed_groups` may be specified together with allowed_users,
to grant access by group OR name.
"""
).tag(config=True)
@observe('allowed_groups')
def _allowed_groups_changed(self, change):
"""Log a warning if mutually exclusive user and group allowed sets are specified."""
if self.allowed_users:
self.log.warning(
"Ignoring Authenticator.allowed_users set because Authenticator.allowed_groups supplied!"
)
).tag(config=True, allow_config=True)
def check_allowed(self, username, authentication=None):
if self.allowed_groups:
return self.check_allowed_groups(username, authentication)
else:
return super().check_allowed(username, authentication)
if self.check_allowed_groups(username, authentication):
return True
return super().check_allowed(username, authentication)
def check_allowed_groups(self, username, authentication=None):
"""
@@ -1308,8 +1437,20 @@ class DummyAuthenticator(Authenticator):
if it logs in with that password.
.. versionadded:: 1.0
.. versionadded:: 5.0
`allow_all` defaults to True,
preserving default behavior.
"""
@default("allow_all")
def _allow_all_default(self):
if self.allowed_users:
return False
else:
# allow all by default
return True
password = Unicode(
config=True,
help="""
@@ -1319,6 +1460,12 @@ class DummyAuthenticator(Authenticator):
""",
)
def check_allow_config(self):
super().check_allow_config()
self.log.warning(
f"Using testing authenticator {self.__class__.__name__}! This is not meant for production!"
)
async def authenticate(self, handler, data):
"""Checks against a global password if it's been set. If not, allow any user/pass combo"""
if self.password:

View File

@@ -24,6 +24,12 @@ from tornado.log import app_log
from tornado.web import RequestHandler, addslash
from .. import __version__, orm, roles, scopes
from .._xsrf_utils import (
_anonymous_xsrf_id,
_set_xsrf_cookie,
check_xsrf_cookie,
get_xsrf_token,
)
from ..metrics import (
PROXY_ADD_DURATION_SECONDS,
PROXY_DELETE_DURATION_SECONDS,
@@ -38,7 +44,6 @@ from ..metrics import (
ServerStopStatus,
)
from ..objects import Server
from ..scopes import needs_scope
from ..spawner import LocalProcessSpawner
from ..user import User
from ..utils import (
@@ -100,7 +105,14 @@ class BaseHandler(RequestHandler):
self.log.error("Rolling back session due to database error")
self.db.rollback()
self._resolve_roles_and_scopes()
return await maybe_future(super().prepare())
await maybe_future(super().prepare())
# run xsrf check after prepare
# because our version takes auth info into account
if (
self.request.method not in self._xsrf_safe_methods
and self.application.settings.get("xsrf_cookies")
):
self.check_xsrf_cookie()
@property
def log(self):
@@ -205,9 +217,13 @@ class BaseHandler(RequestHandler):
"""The default Content-Security-Policy header
Can be overridden by defining Content-Security-Policy in settings['headers']
..versionchanged:: 4.1
Change default frame-ancestors from 'self' to 'none'
"""
return '; '.join(
["frame-ancestors 'self'", "report-uri " + self.csp_report_uri]
["frame-ancestors 'none'", "report-uri " + self.csp_report_uri]
)
def get_content_type(self):
@@ -217,7 +233,6 @@ class BaseHandler(RequestHandler):
"""
Set any headers passed as tornado_settings['headers'].
By default sets Content-Security-Policy of frame-ancestors 'self'.
Also responsible for setting content-type header
"""
# wrap in HTTPHeaders for case-insensitivity
@@ -239,17 +254,63 @@ class BaseHandler(RequestHandler):
# Login and cookie-related
# ---------------------------------------------------------------
_xsrf_safe_methods = {"GET", "HEAD", "OPTIONS"}
@property
def _xsrf_token_id(self):
"""Value to be signed/encrypted for xsrf token
include login info in xsrf token
this means xsrf tokens are tied to logged-in users,
and change after a user logs in.
While the user is not yet logged in,
an anonymous value is used, to prevent portability.
These anonymous values are short-lived.
"""
# cases:
# 1. logged in, session id (session_id:user_id)
# 2. logged in, no session id (anonymous_id:user_id)
# 3. not logged in, session id (session_id:anonymous_id)
# 4. no cookies at all, use single anonymous value (:anonymous_id)
session_id = self.get_session_cookie()
if self.current_user:
if isinstance(self.current_user, User):
user_id = self.current_user.cookie_id
else:
# this shouldn't happen, but may if e.g. a Service attempts to fetch a page,
# which usually won't work, but this method should not be what raises
user_id = ""
if not session_id:
# no session id, use non-portable anonymous id
session_id = _anonymous_xsrf_id(self)
else:
# not logged in yet, use non-portable anonymous id
user_id = _anonymous_xsrf_id(self)
xsrf_id = f"{session_id}:{user_id}".encode("utf8", "replace")
return xsrf_id
@property
def xsrf_token(self):
"""Override tornado's xsrf token with further restrictions
- only set cookie for regular pages
- include login info in xsrf token
- verify signature
"""
return get_xsrf_token(self, cookie_path=self.hub.base_url)
def check_xsrf_cookie(self):
try:
return super().check_xsrf_cookie()
except web.HTTPError as e:
# ensure _jupyterhub_user is defined on rejected requests
if not hasattr(self, "_jupyterhub_user"):
self._jupyterhub_user = None
self._resolve_roles_and_scopes()
# rewrite message because we use this on methods other than POST
e.log_message = e.log_message.replace("POST", self.request.method)
raise
"""Check that xsrf cookie matches xsrf token in request"""
# overrides tornado's implementation
# because we changed what a correct value should be in xsrf_token
if not hasattr(self, "_jupyterhub_user"):
# run too early to check the value
# tornado runs this before 'prepare',
# but we run it again after so auth info is available, which happens in 'prepare'
return None
return check_xsrf_cookie(self)
@property
def admin_users(self):
@@ -526,6 +587,16 @@ class BaseHandler(RequestHandler):
user = self._user_from_orm(u)
return user
def clear_cookie(self, cookie_name, **kwargs):
"""Clear a cookie
overrides RequestHandler to always handle __Host- prefix correctly
"""
if cookie_name.startswith("__Host-"):
kwargs["path"] = "/"
kwargs["secure"] = True
return super().clear_cookie(cookie_name, **kwargs)
def clear_login_cookie(self, name=None):
kwargs = {}
user = self.get_current_user_cookie()
@@ -597,6 +668,11 @@ class BaseHandler(RequestHandler):
kwargs.update(self.settings.get('cookie_options', {}))
kwargs.update(overrides)
if key.startswith("__Host-"):
# __Host- cookies must be secure and on /
kwargs["path"] = "/"
kwargs["secure"] = True
if encrypted:
set_cookie = self.set_secure_cookie
else:
@@ -626,7 +702,9 @@ class BaseHandler(RequestHandler):
Session id cookie is *not* encrypted,
so other services on this domain can read it.
"""
session_id = uuid.uuid4().hex
if not hasattr(self, "_session_id"):
self._session_id = uuid.uuid4().hex
session_id = self._session_id
# if using subdomains, set session cookie on the domain,
# which allows it to be shared by subdomains.
# if domain is unspecified, it is _more_ restricted to only the setting domain
@@ -666,10 +744,20 @@ class BaseHandler(RequestHandler):
if not self.get_session_cookie():
self.set_session_cookie()
# create and set a new cookie token for the hub
if not self.get_current_user_cookie():
# create and set a new cookie for the hub
cookie_user = self.get_current_user_cookie()
if cookie_user is None or cookie_user.id != user.id:
if cookie_user:
self.log.info(f"User {cookie_user.name} is logging in as {user.name}")
self.set_hub_cookie(user)
# make sure xsrf cookie is updated
# this avoids needing a second request to set the right xsrf cookie
self._jupyterhub_user = user
_set_xsrf_cookie(
self, self._xsrf_token_id, cookie_path=self.hub.base_url, authenticated=True
)
def authenticate(self, data):
return maybe_future(self.authenticator.get_authenticated_user(self, data))
@@ -1426,7 +1514,7 @@ class BaseHandler(RequestHandler):
try:
html = self.render_template('%s.html' % status_code, sync=True, **ns)
except TemplateNotFound:
self.log.debug("No template for %d", status_code)
self.log.debug("Using default error template for %d", status_code)
try:
html = self.render_template('error.html', sync=True, **ns)
except Exception:
@@ -1561,10 +1649,28 @@ class UserUrlHandler(BaseHandler):
delete = non_get
@web.authenticated
@needs_scope("access:servers")
async def get(self, user_name, user_path):
if not user_path:
user_path = '/'
path_parts = user_path.split("/", 2)
server_names = [""]
if len(path_parts) >= 3:
# second part _may_ be a server name
server_names.append(path_parts[1])
access_scopes = [
f"access:servers!server={user_name}/{server_name}"
for server_name in server_names
]
if not any(self.has_scope(scope) for scope in access_scopes):
self.log.warning(
"Not authorizing access to %s. Requires any of [%s], not derived from scopes [%s]",
self.request.path,
", ".join(access_scopes),
", ".join(self.expanded_scopes),
)
raise web.HTTPError(404, "No access to resources or resources not found")
current_user = self.current_user
if user_name != current_user.name:
user = self.find_user(user_name)

View File

@@ -657,7 +657,7 @@ class ProxyErrorHandler(BaseHandler):
try:
html = await self.render_template('%s.html' % status_code, **ns)
except TemplateNotFound:
self.log.debug("No template for %d", status_code)
self.log.debug("Using default error template for %d", status_code)
html = await self.render_template('error.html', **ns)
self.write(html)

View File

@@ -35,6 +35,7 @@ import socket
import string
import time
import warnings
from functools import partial
from http import HTTPStatus
from unittest import mock
from urllib.parse import urlencode, urlparse
@@ -43,8 +44,10 @@ from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import url_concat
from tornado.log import app_log
from tornado.web import HTTPError, RequestHandler
from tornado.websocket import WebSocketHandler
from traitlets import (
Any,
Bool,
Dict,
Instance,
Integer,
@@ -56,8 +59,15 @@ from traitlets import (
)
from traitlets.config import SingletonConfigurable
from .._xsrf_utils import (
_anonymous_xsrf_id,
_needs_check_xsrf,
_set_xsrf_cookie,
check_xsrf_cookie,
get_xsrf_token,
)
from ..scopes import _intersect_expanded_scopes
from ..utils import get_browser_protocol, url_path_join
from ..utils import _bool_env, get_browser_protocol, url_path_join
def check_scopes(required_scopes, scopes):
@@ -356,6 +366,46 @@ class HubAuth(SingletonConfigurable):
""",
).tag(config=True)
allow_token_in_url = Bool(
_bool_env("JUPYTERHUB_ALLOW_TOKEN_IN_URL", default=False),
help="""Allow requests to pages with ?token=... in the URL
This allows starting a user session by sharing a URL with credentials,
bypassing authentication with the Hub.
If False, tokens in URLs will be ignored by the server,
except on websocket requests.
Has no effect on websocket requests,
which can only reliably authenticate via token in the URL,
as recommended by browser Websocket implementations.
This will default to False in JupyterHub 5.
.. versionadded:: 4.1
.. versionchanged:: 5.0
default changed to False
""",
).tag(config=True)
allow_websocket_cookie_auth = Bool(
_bool_env("JUPYTERHUB_ALLOW_WEBSOCKET_COOKIE_AUTH", default=True),
help="""Allow websocket requests with only cookie for authentication
Cookie-authenticated websockets cannot be protected from other user servers unless per-user domains are used.
Disabling cookie auth on websockets protects user servers from each other,
but may break some user applications.
Per-user domains eliminate the need to lock this down.
JupyterLab 4.1.2 and Notebook 6.5.6, 7.1.0 will not work
because they rely on cookie authentication without
API or XSRF tokens.
.. versionadded:: 4.1
""",
).tag(config=True)
cookie_options = Dict(
help="""Additional options to pass when setting cookies.
@@ -374,6 +424,40 @@ class HubAuth(SingletonConfigurable):
else:
return {}
cookie_host_prefix_enabled = Bool(
False,
help="""Enable `__Host-` prefix on authentication cookies.
The `__Host-` prefix on JupyterHub cookies provides further
protection against cookie tossing when untrusted servers
may control subdomains of your jupyterhub deployment.
_However_, it also requires that cookies be set on the path `/`,
which means they are shared by all JupyterHub components,
so a compromised server component will have access to _all_ JupyterHub-related
cookies of the visiting browser.
It is recommended to only combine `__Host-` cookies with per-user domains.
Set via $JUPYTERHUB_COOKIE_HOST_PREFIX_ENABLED
""",
).tag(config=True)
@default("cookie_host_prefix_enabled")
def _default_cookie_host_prefix_enabled(self):
return _bool_env("JUPYTERHUB_COOKIE_HOST_PREFIX_ENABLED")
@property
def cookie_path(self):
"""
Path prefix on which to set cookies
self.base_url, but '/' when cookie_host_prefix_enabled is True
"""
if self.cookie_host_prefix_enabled:
return "/"
else:
return self.base_url
cookie_cache_max_age = Integer(help="DEPRECATED. Use cache_max_age")
@observe('cookie_cache_max_age')
@@ -636,6 +720,17 @@ class HubAuth(SingletonConfigurable):
auth_header_name = 'Authorization'
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
def _get_token_url(self, handler):
"""Get the token from the URL
Always run for websockets,
otherwise run only if self.allow_token_in_url
"""
fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "unspecified")
if self.allow_token_in_url or fetch_mode == "websocket":
return handler.get_argument("token", "")
return ""
def get_token(self, handler, in_cookie=True):
"""Get the token authenticating a request
@@ -651,8 +746,7 @@ class HubAuth(SingletonConfigurable):
Args:
handler (tornado.web.RequestHandler): the current request handler
"""
user_token = handler.get_argument('token', '')
user_token = self._get_token_url(handler)
if not user_token:
# get it from Authorization header
m = self.auth_header_pat.match(
@@ -702,6 +796,14 @@ class HubAuth(SingletonConfigurable):
"""
return self._call_coroutine(sync, self._get_user, handler)
def _patch_xsrf(self, handler):
"""Overridden in HubOAuth
HubAuth base class doesn't handle xsrf,
which is only relevant for cookie-based auth
"""
return
async def _get_user(self, handler):
# only allow this to be called once per handler
# avoids issues if an error is raised,
@@ -709,6 +811,9 @@ class HubAuth(SingletonConfigurable):
if hasattr(handler, '_cached_hub_user'):
return handler._cached_hub_user
# patch XSRF checks, which will apply after user check
self._patch_xsrf(handler)
handler._cached_hub_user = user_model = None
session_id = self.get_session_id(handler)
@@ -758,6 +863,10 @@ class HubAuth(SingletonConfigurable):
if not hasattr(self, 'set_cookie'):
# only HubOAuth can persist cookies
return
fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "navigate")
if isinstance(handler, WebSocketHandler) or fetch_mode != "navigate":
# don't do this on websockets or non-navigate requests
return
self.log.info(
"Storing token from url in cookie for %s",
handler.request.remote_ip,
@@ -794,7 +903,10 @@ class HubOAuth(HubAuth):
because we don't want to use the same cookie name
across OAuth clients.
"""
return self.oauth_client_id
cookie_name = self.oauth_client_id
if self.cookie_host_prefix_enabled:
cookie_name = "__Host-" + cookie_name
return cookie_name
@property
def state_cookie_name(self):
@@ -806,22 +918,115 @@ class HubOAuth(HubAuth):
def _get_token_cookie(self, handler):
"""Base class doesn't store tokens in cookies"""
if hasattr(handler, "_hub_auth_token_cookie"):
return handler._hub_auth_token_cookie
fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "unset")
if fetch_mode == "websocket" and not self.allow_websocket_cookie_auth:
# disallow cookie auth on websockets
return None
token = handler.get_secure_cookie(self.cookie_name)
if token:
# decode cookie bytes
token = token.decode('ascii', 'replace')
return token
async def _get_user_cookie(self, handler):
def _get_xsrf_token_id(self, handler):
"""Get contents for xsrf token for a given Handler
This is the value to be encrypted & signed in the xsrf token
"""
token = self._get_token_cookie(handler)
session_id = self.get_session_id(handler)
if token:
token_hash = hashlib.sha256(token.encode("ascii", "replace")).hexdigest()
if not session_id:
session_id = _anonymous_xsrf_id(handler)
else:
token_hash = _anonymous_xsrf_id(handler)
return f"{session_id}:{token_hash}".encode("ascii", "replace")
def _patch_xsrf(self, handler):
"""Patch handler to inject JuptyerHub xsrf token behavior"""
if isinstance(handler, HubAuthenticated):
# doesn't need patch
return
# patch in our xsrf token handling
# overrides tornado and jupyter_server defaults,
# but not others.
# subclasses will still inherit our overridden behavior,
# but their overrides (if any) will take precedence over ours
# such as jupyter-server-proxy
for cls in handler.__class__.__mro__:
# search for the nearest parent class defined
# in one of the 'base' Handler-defining packages.
# In current implementations, this will
# generally be jupyter_server.base.handlers.JupyterHandler
# or tornado.web.RequestHandler,
# but doing it this way ensures consistent results
if (cls.__module__ or '').partition('.')[0] not in {
"jupyter_server",
"notebook",
"tornado",
}:
continue
# override check_xsrf_cookie where it's defined
if "check_xsrf_cookie" in cls.__dict__:
if "_get_xsrf_token_id" in cls.__dict__:
# already patched
return
cls._xsrf_token_id = property(self._get_xsrf_token_id)
cls.xsrf_token = property(
partial(get_xsrf_token, cookie_path=self.base_url)
)
cls.check_xsrf_cookie = lambda handler: self.check_xsrf_cookie(handler)
def check_xsrf_cookie(self, handler):
"""check_xsrf_cookie patch
Applies JupyterHub check_xsrf_cookie if not token authenticated
"""
if getattr(handler, '_token_authenticated', False) or handler.settings.get(
"disable_check_xsrf", False
):
return
check_xsrf_cookie(handler)
def _clear_cookie(self, handler, cookie_name, **kwargs):
"""Clear a cookie, handling __Host- prefix"""
# Set-Cookie is rejected without 'secure',
# this includes clearing cookies!
if cookie_name.startswith("__Host-"):
kwargs["path"] = "/"
kwargs["secure"] = True
return handler.clear_cookie(cookie_name, **kwargs)
async def _get_user_cookie(self, handler):
# check xsrf if needed
token = self._get_token_cookie(handler)
session_id = self.get_session_id(handler)
if token and _needs_check_xsrf(handler):
# call handler.check_xsrf_cookie instead of self.check_xsrf_cookie
# to allow subclass overrides
try:
handler.check_xsrf_cookie()
except HTTPError as e:
self.log.debug(
f"Not accepting cookie auth on {handler.request.method} {handler.request.path}: {e.log_message}"
)
# don't proceed with cookie auth unless xsrf is okay
# don't raise either, because that makes a mess
return None
if token:
user_model = await self.user_for_token(
token, session_id=session_id, sync=False
)
if user_model is None:
app_log.warning("Token stored in cookie may have expired")
handler.clear_cookie(self.cookie_name)
self._clear_cookie(handler, self.cookie_name, path=self.cookie_path)
return user_model
# HubOAuth API
@@ -962,7 +1167,7 @@ class HubOAuth(HubAuth):
cookie_name = self.state_cookie_name
state_id = self.generate_state(next_url, **extra_state)
kwargs = {
'path': self.base_url,
'path': self.cookie_path,
'httponly': True,
# Expire oauth state cookie in ten minutes.
# Usually this will be cleared by completed login
@@ -1020,9 +1225,10 @@ class HubOAuth(HubAuth):
"""Clear persisted oauth state"""
for cookie_name, cookie in handler.request.cookies.items():
if cookie_name.startswith(self.state_cookie_name):
handler.clear_cookie(
self._clear_cookie(
handler,
cookie_name,
path=self.base_url,
path=self.cookie_path,
)
def _decode_state(self, state_id, /):
@@ -1044,8 +1250,11 @@ class HubOAuth(HubAuth):
def set_cookie(self, handler, access_token):
"""Set a cookie recording OAuth result"""
kwargs = {'path': self.base_url, 'httponly': True}
if get_browser_protocol(handler.request) == 'https':
kwargs = {'path': self.cookie_path, 'httponly': True}
if (
get_browser_protocol(handler.request) == 'https'
or self.cookie_host_prefix_enabled
):
kwargs['secure'] = True
# load user cookie overrides
kwargs.update(self.cookie_options)
@@ -1056,6 +1265,15 @@ class HubOAuth(HubAuth):
kwargs,
)
handler.set_secure_cookie(self.cookie_name, access_token, **kwargs)
# set updated xsrf token cookie,
# which changes after login
handler._hub_auth_token_cookie = access_token
_set_xsrf_cookie(
handler,
handler._xsrf_token_id,
cookie_path=self.base_url,
authenticated=True,
)
def clear_cookie(self, handler):
"""Clear the OAuth cookie
@@ -1063,7 +1281,7 @@ class HubOAuth(HubAuth):
Args:
handler (tornado.web.RequestHandler): the current request handler
"""
handler.clear_cookie(self.cookie_name, path=self.base_url)
self._clear_cookie(handler, self.cookie_name, path=self.cookie_path)
class UserNotAllowed(Exception):
@@ -1275,7 +1493,7 @@ class HubAuthenticated:
return
try:
self._hub_auth_user_cache = self.check_hub_user(user_model)
except UserNotAllowed as e:
except UserNotAllowed:
# cache None, in case get_user is called again while processing the error
self._hub_auth_user_cache = None
@@ -1297,6 +1515,25 @@ class HubAuthenticated:
self.hub_auth._persist_url_token_if_set(self)
return self._hub_auth_user_cache
@property
def _xsrf_token_id(self):
if hasattr(self, "__xsrf_token_id"):
return self.__xsrf_token_id
if not isinstance(self.hub_auth, HubOAuth):
return ""
return self.hub_auth._get_xsrf_token_id(self)
@_xsrf_token_id.setter
def _xsrf_token_id(self, value):
self.__xsrf_token_id = value
@property
def xsrf_token(self):
return get_xsrf_token(self, cookie_path=self.hub_auth.base_url)
def check_xsrf_cookie(self):
return self.hub_auth.check_xsrf_cookie(self)
class HubOAuthenticated(HubAuthenticated):
"""Simple subclass of HubAuthenticated using OAuth instead of old shared cookies"""
@@ -1332,7 +1569,7 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
cookie_state = self.get_secure_cookie(cookie_name)
# clear cookie state now that we've consumed it
if cookie_state:
self.clear_cookie(cookie_name, path=self.hub_auth.base_url)
self.hub_auth.clear_oauth_state_cookies(self)
else:
# completing oauth with stale state, but already logged in.
# stop here and redirect to default URL
@@ -1349,8 +1586,13 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler):
# check that state matches
if arg_state != cookie_state:
app_log.warning("oauth state %r != %r", arg_state, cookie_state)
raise HTTPError(403, "OAuth state does not match. Try logging in again.")
app_log.warning(
"oauth state argument %r != cookie %s=%r",
arg_state,
cookie_name,
cookie_state,
)
raise HTTPError(403, "oauth state does not match. Try logging in again.")
next_url = self.hub_auth.get_next_url(cookie_state)
# clear consumed state from _oauth_states cache now that we're done with it
self.hub_auth.clear_oauth_state(cookie_state)

View File

@@ -0,0 +1,14 @@
from typing import Any, Callable, TypeVar
try:
from jupyter_server.auth.decorator import allow_unauthenticated
except ImportError:
FuncT = TypeVar("FuncT", bound=Callable[..., Any])
# if using an older jupyter-server version this can be a no-op,
# as these do not support marking endpoints anyways
def allow_unauthenticated(method: FuncT) -> FuncT:
return method
__all__ = ["allow_unauthenticated"]

View File

@@ -44,28 +44,19 @@ from jupyterhub._version import __version__, _check_version
from jupyterhub.log import log_request
from jupyterhub.services.auth import HubOAuth, HubOAuthCallbackHandler
from jupyterhub.utils import (
_bool_env,
exponential_backoff,
isoformat,
make_ssl_context,
url_path_join,
)
from ._decorator import allow_unauthenticated
from ._disable_user_config import _disable_user_config
SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates"))
def _bool_env(key):
"""Cast an environment variable to bool
0, empty, or unset is False; All other values are True.
"""
if os.environ.get(key, "") in {"", "0"}:
return False
else:
return True
def _exclude_home(path_list):
"""Filter out any entries in a path list that are in my home directory.
@@ -78,6 +69,7 @@ def _exclude_home(path_list):
class JupyterHubLogoutHandler(LogoutHandler):
@allow_unauthenticated
def get(self):
hub_auth = self.identity_provider.hub_auth
# clear token stored in single-user cookie (set by hub_auth)
@@ -105,6 +97,10 @@ class JupyterHubOAuthCallbackHandler(HubOAuthCallbackHandler):
def initialize(self, hub_auth):
self.hub_auth = hub_auth
@allow_unauthenticated
async def get(self):
return await super().get()
class JupyterHubIdentityProvider(IdentityProvider):
"""Identity Provider for JupyterHub OAuth
@@ -127,6 +123,9 @@ class JupyterHubIdentityProvider(IdentityProvider):
# HubAuth gets most of its config from the environment
return HubOAuth(parent=self)
def _patch_xsrf(self, handler):
self.hub_auth._patch_xsrf(handler)
def _patch_get_login_url(self, handler):
original_get_login_url = handler.get_login_url
@@ -161,6 +160,7 @@ class JupyterHubIdentityProvider(IdentityProvider):
if hasattr(handler, "_jupyterhub_user"):
return handler._jupyterhub_user
self._patch_get_login_url(handler)
self._patch_xsrf(handler)
user = await self.hub_auth.get_user(handler, sync=False)
if user is None:
handler._jupyterhub_user = None
@@ -632,6 +632,9 @@ class JupyterHubSingleUser(ExtensionApp):
app.web_app.settings["page_config_hook"] = (
app.identity_provider.page_config_hook
)
# disable xsrf_cookie checks by Tornado, which run too early
# checks in Jupyter Server are unconditional
app.web_app.settings["xsrf_cookies"] = False
# if the user has configured a log function in the tornado settings, do not override it
if not 'log_function' in app.config.ServerApp.get('tornado_settings', {}):
app.web_app.settings["log_function"] = log_request
@@ -642,6 +645,9 @@ class JupyterHubSingleUser(ExtensionApp):
# check jupyterhub version
app.io_loop.run_sync(self.check_hub_version)
# set default CSP to prevent iframe embedding across jupyterhub components
headers.setdefault("Content-Security-Policy", "frame-ancestors 'none'")
async def _start_activity():
self._activity_task = asyncio.ensure_future(self.keep_activity_updated())

View File

@@ -45,21 +45,16 @@ from traitlets.config import Configurable
from .._version import __version__, _check_version
from ..log import log_request
from ..services.auth import HubOAuth, HubOAuthCallbackHandler, HubOAuthenticated
from ..utils import exponential_backoff, isoformat, make_ssl_context, url_path_join
from ..utils import (
_bool_env,
exponential_backoff,
isoformat,
make_ssl_context,
url_path_join,
)
from ._decorator import allow_unauthenticated
from ._disable_user_config import _disable_user_config, _exclude_home
def _bool_env(key):
"""Cast an environment variable to bool
0, empty, or unset is False; All other values are True.
"""
if os.environ.get(key, "") in {"", "0"}:
return False
else:
return True
# Authenticate requests with the Hub
@@ -138,6 +133,7 @@ class JupyterHubLoginHandlerMixin:
class JupyterHubLogoutHandlerMixin:
@allow_unauthenticated
def get(self):
self.settings['hub_auth'].clear_cookie(self)
self.redirect(
@@ -153,6 +149,10 @@ class OAuthCallbackHandlerMixin(HubOAuthCallbackHandler):
def hub_auth(self):
return self.settings['hub_auth']
@allow_unauthenticated
async def get(self):
return await super().get()
# register new hub related command-line aliases
aliases = {
@@ -683,10 +683,10 @@ class SingleUserNotebookAppMixin(Configurable):
)
headers = s.setdefault('headers', {})
headers['X-JupyterHub-Version'] = __version__
# set CSP header directly to workaround bugs in jupyter/notebook 5.0
# set default CSP to prevent iframe embedding across jupyterhub components
headers.setdefault(
'Content-Security-Policy',
';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]),
';'.join(["frame-ancestors 'none'", "report-uri " + csp_report_uri]),
)
super().init_webapp()
@@ -833,7 +833,7 @@ def patch_base_handler(BaseHandler, log=None):
# but we also need to ensure BaseHandler *itself* doesn't
# override the public tornado API methods we have inserted.
# If they are defined in BaseHandler, explicitly replace them with our methods.
for name in ("get_current_user", "get_login_url"):
for name in ("get_current_user", "get_login_url", "check_xsrf_cookie"):
if name in BaseHandler.__dict__:
log.debug(
f"Overriding {BaseHandler}.{name} with HubAuthenticatedHandler.{name}"

View File

@@ -163,6 +163,7 @@ class Spawner(LoggingConfigurable):
hub = Any()
orm_spawner = Any()
cookie_options = Dict()
cookie_host_prefix_enabled = Bool()
public_url = Unicode(help="Public URL of this spawner's server")
public_hub_url = Unicode(help="Public URL of the Hub itself")
@@ -1006,6 +1007,10 @@ class Spawner(LoggingConfigurable):
env['JUPYTERHUB_CLIENT_ID'] = self.oauth_client_id
if self.cookie_options:
env['JUPYTERHUB_COOKIE_OPTIONS'] = json.dumps(self.cookie_options)
env["JUPYTERHUB_COOKIE_HOST_PREFIX_ENABLED"] = str(
int(self.cookie_host_prefix_enabled)
)
env['JUPYTERHUB_HOST'] = self.hub.public_host
env['JUPYTERHUB_OAUTH_CALLBACK_URL'] = url_path_join(
self.user.url, url_escape_path(self.name), 'oauth_callback'

View File

@@ -1,6 +1,8 @@
"""Tests for the Playwright Python"""
import asyncio
import json
import pprint
import re
from unittest import mock
from urllib.parse import parse_qs, urlparse
@@ -11,7 +13,8 @@ from tornado.escape import url_escape
from tornado.httputil import url_concat
from jupyterhub import orm, roles, scopes
from jupyterhub.tests.utils import public_host, public_url, ujoin
from jupyterhub.tests.test_named_servers import named_servers # noqa
from jupyterhub.tests.utils import async_requests, public_host, public_url, ujoin
from jupyterhub.utils import url_escape_path, url_path_join
pytestmark = pytest.mark.browser
@@ -44,7 +47,7 @@ async def test_submit_login_form(app, browser, user_special_chars):
login_url = url_path_join(public_host(app), app.hub.base_url, "login")
await browser.goto(login_url)
await login(browser, user.name, password=user.name)
expected_url = ujoin(public_url(app), f"/user/{user_special_chars.urlname}/")
expected_url = public_url(app, user)
await expect(browser).to_have_url(expected_url)
@@ -56,7 +59,7 @@ async def test_submit_login_form(app, browser, user_special_chars):
# will encode given parameters for an unauthenticated URL in the next url
# the next parameter will contain the app base URL (replaces BASE_URL in tests)
'spawn',
[('param', 'value')],
{'param': 'value'},
'/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue',
'/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue',
),
@@ -64,15 +67,15 @@ async def test_submit_login_form(app, browser, user_special_chars):
# login?param=fromlogin&next=encoded(/hub/spawn?param=value)
# will drop parameters given to the login page, passing only the next url
'login',
[('param', 'fromlogin'), ('next', '/hub/spawn?param=value')],
'/hub/login?param=fromlogin&next=%2Fhub%2Fspawn%3Fparam%3Dvalue',
'/hub/login?next=%2Fhub%2Fspawn%3Fparam%3Dvalue',
{'param': 'fromlogin', 'next': '/hub/spawn?param=value'},
'/hub/login?param=fromlogin&next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue',
'/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue',
),
(
# login?param=value&anotherparam=anothervalue
# will drop parameters given to the login page, and use an empty next url
'login',
[('param', 'value'), ('anotherparam', 'anothervalue')],
{'param': 'value', 'anotherparam': 'anothervalue'},
'/hub/login?param=value&anotherparam=anothervalue',
'/hub/login?next=',
),
@@ -80,7 +83,7 @@ async def test_submit_login_form(app, browser, user_special_chars):
# login
# simplest case, accessing the login URL, gives an empty next url
'login',
[],
{},
'/hub/login',
'/hub/login?next=',
),
@@ -98,6 +101,8 @@ async def test_open_url_login(
user = user_special_chars.user
login_url = url_path_join(public_host(app), app.hub.base_url, url)
await browser.goto(login_url)
if params.get("next"):
params["next"] = url_path_join(app.base_url, params["next"])
url_new = url_path_join(public_host(app), app.hub.base_url, url_concat(url, params))
print(url_new)
await browser.goto(url_new)
@@ -853,12 +858,15 @@ async def test_oauth_page(
oauth_client.allowed_scopes = sorted(roles.roles_to_scopes([service_role]))
app.db.commit()
# open the service url in the browser
service_url = url_path_join(public_url(app, service) + 'owhoami/?arg=x')
service_url = url_path_join(public_url(app, service), 'owhoami/?arg=x')
await browser.goto(service_url)
expected_redirect_url = url_path_join(
app.base_url + f"services/{service.name}/oauth_callback"
)
if app.subdomain_host:
expected_redirect_url = url_path_join(
public_url(app, service), "oauth_callback"
)
else:
expected_redirect_url = url_path_join(service.prefix, "oauth_callback")
expected_client_id = f"service-{service.name}"
# decode the URL
@@ -1236,3 +1244,266 @@ async def test_start_stop_server_on_admin_page(
await expect(browser.get_by_role("button", name="Spawn Page")).to_have_count(
len(users_list)
)
@pytest.mark.parametrize(
"case",
[
"fresh",
"invalid",
"valid-prefix-invalid-root",
"valid-prefix-invalid-other-prefix",
],
)
async def test_login_xsrf_initial_cookies(app, browser, case, username):
"""Test that login works with various initial states for xsrf tokens
Page will be reloaded with correct values
"""
hub_root = public_host(app)
hub_url = url_path_join(public_host(app), app.hub.base_url)
hub_parent = hub_url.rstrip("/").rsplit("/", 1)[0] + "/"
login_url = url_path_join(
hub_url, url_concat("login", {"next": url_path_join(app.base_url, "/hub/home")})
)
# start with all cookies cleared
await browser.context.clear_cookies()
if case == "invalid":
await browser.context.add_cookies(
[{"name": "_xsrf", "value": "invalid-hub-prefix", "url": hub_url}]
)
elif case.startswith("valid-prefix"):
if "invalid-root" in case:
invalid_url = hub_root
else:
invalid_url = hub_parent
await browser.goto(login_url)
# first visit sets valid xsrf cookie
cookies = await browser.context.cookies()
assert len(cookies) == 1
# second visit is also made with invalid xsrf on `/`
# handling of this behavior is undefined in HTTP itself!
# _either_ the invalid cookie on / is ignored
# _or_ both will be cleared
# currently, this test assumes the observed behavior,
# which is that the invalid cookie on `/` has _higher_ priority
await browser.context.add_cookies(
[{"name": "_xsrf", "value": "invalid-root", "url": invalid_url}]
)
cookies = await browser.context.cookies()
assert len(cookies) == 2
# after visiting page, cookies get re-established
await browser.goto(login_url)
cookies = await browser.context.cookies()
print(cookies)
cookie = cookies[0]
assert cookie['name'] == '_xsrf'
assert cookie["path"] == app.hub.base_url
# next page visit, cookies don't change
await browser.goto(login_url)
cookies_2 = await browser.context.cookies()
assert cookies == cookies_2
# login is successful
await login(browser, username, username)
def _cookie_dict(cookie_list):
"""Convert list of cookies to dict of the form
{ 'path': {'key': {cookie} } }
"""
cookie_dict = {}
for cookie in cookie_list:
path_cookies = cookie_dict.setdefault(cookie['path'], {})
path_cookies[cookie['name']] = cookie
return cookie_dict
async def test_singleuser_xsrf(
app,
browser,
user,
create_user_with_scopes,
full_spawn,
named_servers, # noqa: F811
):
# full login process, checking XSRF handling
# start two servers
target_user = user
target_start = asyncio.ensure_future(target_user.spawn())
browser_user = create_user_with_scopes("self", "access:servers")
# login browser_user
login_url = url_path_join(public_host(app), app.hub.base_url, "login")
await browser.goto(login_url)
await login(browser, browser_user.name, browser_user.name)
# end up at single-user
await expect(browser).to_have_url(re.compile(rf".*/user/{browser_user.name}/.*"))
# wait for target user to start, too
await target_start
await app.proxy.add_user(target_user)
# visit target user, sets credentials for second server
await browser.goto(public_url(app, target_user))
await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize"))
auth_button = browser.locator('//input[@type="submit"]')
await expect(auth_button).to_be_enabled()
await auth_button.click()
await expect(browser).to_have_url(re.compile(rf".*/user/{target_user.name}/.*"))
# at this point, we are on a page served by target_user,
# logged in as browser_user
# basic check that xsrf isolation works
cookies = await browser.context.cookies()
cookie_dict = _cookie_dict(cookies)
pprint.pprint(cookie_dict)
# we should have xsrf tokens for both singleuser servers and the hub
target_prefix = target_user.prefix
user_prefix = browser_user.prefix
hub_prefix = app.hub.base_url
assert target_prefix in cookie_dict
assert user_prefix in cookie_dict
assert hub_prefix in cookie_dict
target_xsrf = cookie_dict[target_prefix].get("_xsrf", {}).get("value")
assert target_xsrf
user_xsrf = cookie_dict[user_prefix].get("_xsrf", {}).get("value")
assert user_xsrf
hub_xsrf = cookie_dict[hub_prefix].get("_xsrf", {}).get("value")
assert hub_xsrf
assert hub_xsrf != target_xsrf
assert hub_xsrf != user_xsrf
assert target_xsrf != user_xsrf
# we are on a page served by target_user
# check that we can't access
async def fetch_user_page(path, params=None):
url = url_path_join(public_url(app, browser_user), path)
if params:
url = url_concat(url, params)
status = await browser.evaluate(
"""
async (user_url) => {
try {
response = await fetch(user_url);
} catch (e) {
return 'error';
}
return response.status;
}
""",
url,
)
return status
if app.subdomain_host:
expected_status = 'error'
else:
expected_status = 403
status = await fetch_user_page("/api/contents")
assert status == expected_status
status = await fetch_user_page("/api/contents", params={"_xsrf": target_xsrf})
assert status == expected_status
if not app.subdomain_host:
expected_status = 200
status = await fetch_user_page("/api/contents", params={"_xsrf": user_xsrf})
assert status == expected_status
# check that we can't iframe the other user's page
async def iframe(src):
return await browser.evaluate(
"""
async (src) => {
const frame = document.createElement("iframe");
frame.src = src;
return new Promise((resolve, reject) => {
frame.addEventListener("load", (event) => {
if (frame.contentDocument) {
resolve("got document!");
} else {
resolve("blocked")
}
});
setTimeout(() => {
// some browsers (firefox) never fire load event
// despite spec appasrently stating it must always do so,
// even for rejected frames
resolve("timeout")
}, 3000)
document.body.appendChild(frame);
});
}
""",
src,
)
hub_iframe = await iframe(url_path_join(public_url(app), "hub/admin"))
assert hub_iframe in {"timeout", "blocked"}
user_iframe = await iframe(public_url(app, browser_user))
assert user_iframe in {"timeout", "blocked"}
# check that server page can still connect to its own kernels
token = target_user.new_api_token(scopes=["access:servers!user"])
async def test_kernel(kernels_url):
headers = {"Authorization": f"Bearer {token}"}
r = await async_requests.post(kernels_url, headers=headers)
r.raise_for_status()
kernel = r.json()
kernel_id = kernel["id"]
kernel_url = url_path_join(kernels_url, kernel_id)
kernel_ws_url = "ws" + url_path_join(kernel_url, "channels")[4:]
try:
result = await browser.evaluate(
"""
async (ws_url) => {
ws = new WebSocket(ws_url);
finished = await new Promise((resolve, reject) => {
ws.onerror = (err) => {
reject(err);
};
ws.onopen = () => {
resolve("ok");
};
});
return finished;
}
""",
kernel_ws_url,
)
finally:
r = await async_requests.delete(kernel_url, headers=headers)
r.raise_for_status()
assert result == "ok"
kernels_url = url_path_join(public_url(app, target_user), "/api/kernels")
await test_kernel(kernels_url)
# final check: make sure named servers work.
# first, visit spawn page to launch server,
# will issue cookies, etc.
server_name = "named"
url = url_path_join(
public_host(app),
url_path_join(app.base_url, f"hub/spawn/{browser_user.name}/{server_name}"),
)
await browser.goto(url)
await expect(browser).to_have_url(
re.compile(rf".*/user/{browser_user.name}/{server_name}/.*")
)
# from named server URL, make sure we can talk to a kernel
token = browser_user.new_api_token(scopes=["access:servers!user"])
# named-server URL
kernels_url = url_path_join(
public_url(app, browser_user), server_name, "api/kernels"
)
await test_kernel(kernels_url)
# go back to user's own page, test again
# make sure we didn't break anything
await browser.goto(public_url(app, browser_user))
await test_kernel(url_path_join(public_url(app, browser_user), "api/kernels"))

View File

@@ -502,8 +502,6 @@ def create_user_with_scopes(app, create_temp_role):
return app.users[orm_user.id]
yield temp_user_creator
for user in temp_users:
app.users.delete(user)
@fixture

View File

@@ -44,8 +44,8 @@ from .. import metrics, orm, roles
from ..app import JupyterHub
from ..auth import PAMAuthenticator
from ..spawner import SimpleLocalProcessSpawner
from ..utils import random_port, utcnow
from .utils import async_requests, public_url, ssl_setup
from ..utils import random_port, url_path_join, utcnow
from .utils import AsyncSession, public_url, ssl_setup
def mock_authenticate(username, password, service, encoding):
@@ -243,6 +243,8 @@ class MockHub(JupyterHub):
cert_location = kwargs['internal_certs_location']
kwargs['external_certs'] = ssl_setup(cert_location, 'hub-ca')
super().__init__(*args, **kwargs)
if 'allow_all' not in self.config.Authenticator:
self.config.Authenticator.allow_all = True
@default('subdomain_host')
def _subdomain_host_default(self):
@@ -372,29 +374,32 @@ class MockHub(JupyterHub):
async def login_user(self, name):
"""Login a user by name, returning her cookies."""
base_url = public_url(self)
external_ca = None
s = AsyncSession()
if self.internal_ssl:
external_ca = self.external_certs['files']['ca']
s.verify = self.external_certs['files']['ca']
login_url = base_url + 'hub/login'
r = await async_requests.get(login_url)
r = await s.get(login_url)
r.raise_for_status()
xsrf = r.cookies['_xsrf']
r = await async_requests.post(
r = await s.post(
url_concat(login_url, {"_xsrf": xsrf}),
cookies=r.cookies,
data={'username': name, 'password': name},
allow_redirects=False,
verify=external_ca,
)
r.raise_for_status()
r.cookies["_xsrf"] = xsrf
assert sorted(r.cookies.keys()) == [
# make second request to get updated xsrf cookie
r2 = await s.get(
url_path_join(base_url, "hub/home"),
allow_redirects=False,
)
assert r2.status_code == 200
assert sorted(s.cookies.keys()) == [
'_xsrf',
'jupyterhub-hub-login',
'jupyterhub-session-id',
]
return r.cookies
return s.cookies
class InstrumentedSpawner(MockSpawner):

View File

@@ -99,7 +99,7 @@ async def test_post_content_type(app, content_type, status):
assert r.status_code == status
@mark.parametrize("xsrf_in_url", [True, False])
@mark.parametrize("xsrf_in_url", [True, False, "invalid"])
@mark.parametrize(
"method, path",
[
@@ -110,6 +110,13 @@ async def test_post_content_type(app, content_type, status):
async def test_xsrf_check(app, username, method, path, xsrf_in_url):
cookies = await app.login_user(username)
xsrf = cookies['_xsrf']
if xsrf_in_url == "invalid":
cookies.pop("_xsrf")
# a valid old-style tornado xsrf token is no longer valid
xsrf = cookies['_xsrf'] = (
"2|7329b149|d837ced983e8aac7468bc7a61ce3d51a|1708610065"
)
url = path.format(username=username)
if xsrf_in_url:
url = f"{url}?_xsrf={xsrf}"
@@ -120,7 +127,7 @@ async def test_xsrf_check(app, username, method, path, xsrf_in_url):
noauth=True,
cookies=cookies,
)
if xsrf_in_url:
if xsrf_in_url is True:
assert r.status_code == 200
else:
assert r.status_code == 403
@@ -153,7 +160,7 @@ async def test_permission_error_messages(app, user, auth, expected_message):
params["_xsrf"] = cookies["_xsrf"]
if auth == "cookie_xsrf_mismatch":
params["_xsrf"] = "somethingelse"
headers['Sec-Fetch-Mode'] = 'cors'
r = await async_requests.get(url, **kwargs)
assert r.status_code == 403
response = r.json()

View File

@@ -475,6 +475,7 @@ async def test_user_creation(tmpdir, request):
]
cfg = Config()
cfg.Authenticator.allow_all = False
cfg.Authenticator.allowed_users = allowed_users
cfg.JupyterHub.load_groups = groups
cfg.JupyterHub.load_roles = roles

View File

@@ -3,12 +3,13 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import logging
from itertools import chain
from unittest import mock
from urllib.parse import urlparse
import pytest
from requests import HTTPError
from traitlets import Any
from traitlets import Any, Tuple
from traitlets.config import Config
from jupyterhub import auth, crypto, orm
@@ -18,7 +19,7 @@ from .utils import add_user, async_requests, get_page, public_url
async def test_pam_auth():
authenticator = MockPAMAuthenticator()
authenticator = MockPAMAuthenticator(allow_all=True)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'match', 'password': 'match'}
)
@@ -37,7 +38,7 @@ async def test_pam_auth():
async def test_pam_auth_account_check_disabled():
authenticator = MockPAMAuthenticator(check_account=False)
authenticator = MockPAMAuthenticator(allow_all=True, check_account=False)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'allowedmatch', 'password': 'allowedmatch'}
)
@@ -82,7 +83,9 @@ async def test_pam_auth_admin_groups():
return user_group_map[name]
authenticator = MockPAMAuthenticator(
admin_groups={'jh_admins', 'wheel'}, admin_users={'override_admin'}
admin_groups={'jh_admins', 'wheel'},
admin_users={'override_admin'},
allow_all=True,
)
# Check admin_group applies as expected
@@ -141,7 +144,10 @@ async def test_pam_auth_admin_groups():
async def test_pam_auth_allowed():
authenticator = MockPAMAuthenticator(allowed_users={'wash', 'kaylee'})
authenticator = MockPAMAuthenticator(
allowed_users={'wash', 'kaylee'}, allow_all=False
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
)
@@ -162,7 +168,7 @@ async def test_pam_auth_allowed_groups():
def getgrnam(name):
return MockStructGroup('grp', ['kaylee'])
authenticator = MockPAMAuthenticator(allowed_groups={'group'})
authenticator = MockPAMAuthenticator(allowed_groups={'group'}, allow_all=False)
with mock.patch.object(authenticator, '_getgrnam', getgrnam):
authorized = await authenticator.get_authenticated_user(
@@ -179,14 +185,14 @@ async def test_pam_auth_allowed_groups():
async def test_pam_auth_blocked():
# Null case compared to next case
authenticator = MockPAMAuthenticator()
authenticator = MockPAMAuthenticator(allow_all=True)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'wash', 'password': 'wash'}
)
assert authorized['name'] == 'wash'
# Blacklist basics
authenticator = MockPAMAuthenticator(blocked_users={'wash'})
# Blocklist basics
authenticator = MockPAMAuthenticator(blocked_users={'wash'}, allow_all=True)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'wash', 'password': 'wash'}
)
@@ -194,7 +200,9 @@ async def test_pam_auth_blocked():
# User in both allowed and blocked: default deny. Make error someday?
authenticator = MockPAMAuthenticator(
blocked_users={'wash'}, allowed_users={'wash', 'kaylee'}
blocked_users={'wash'},
allowed_users={'wash', 'kaylee'},
allow_all=True,
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'wash', 'password': 'wash'}
@@ -203,7 +211,8 @@ async def test_pam_auth_blocked():
# User not in blocked set can log in
authenticator = MockPAMAuthenticator(
blocked_users={'wash'}, allowed_users={'wash', 'kaylee'}
blocked_users={'wash'},
allowed_users={'wash', 'kaylee'},
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
@@ -221,7 +230,8 @@ async def test_pam_auth_blocked():
# User in neither list
authenticator = MockPAMAuthenticator(
blocked_users={'mal'}, allowed_users={'wash', 'kaylee'}
blocked_users={'mal'},
allowed_users={'wash', 'kaylee'},
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'simon', 'password': 'simon'}
@@ -257,7 +267,9 @@ async def test_deprecated_signatures():
async def test_pam_auth_no_such_group():
authenticator = MockPAMAuthenticator(allowed_groups={'nosuchcrazygroup'})
authenticator = MockPAMAuthenticator(
allowed_groups={'nosuchcrazygroup'},
)
authorized = await authenticator.get_authenticated_user(
None, {'username': 'kaylee', 'password': 'kaylee'}
)
@@ -405,7 +417,7 @@ async def test_auth_state_disabled(app, auth_state_unavailable):
async def test_normalize_names():
a = MockPAMAuthenticator()
a = MockPAMAuthenticator(allow_all=True)
authorized = await a.get_authenticated_user(
None, {'username': 'ZOE', 'password': 'ZOE'}
)
@@ -428,7 +440,7 @@ async def test_normalize_names():
async def test_username_map():
a = MockPAMAuthenticator(username_map={'wash': 'alpha'})
a = MockPAMAuthenticator(username_map={'wash': 'alpha'}, allow_all=True)
authorized = await a.get_authenticated_user(
None, {'username': 'WASH', 'password': 'WASH'}
)
@@ -458,7 +470,7 @@ async def test_post_auth_hook():
authentication['testkey'] = 'testvalue'
return authentication
a = MockPAMAuthenticator(post_auth_hook=test_auth_hook)
a = MockPAMAuthenticator(allow_all=True, post_auth_hook=test_auth_hook)
authorized = await a.get_authenticated_user(
None, {'username': 'test_user', 'password': 'test_user'}
@@ -566,6 +578,7 @@ async def test_auth_managed_groups(
parent=app,
authenticated_groups=authenticated_groups,
refresh_groups=refresh_groups,
allow_all=True,
)
user.groups.append(group)
@@ -1019,3 +1032,193 @@ async def test_auth_manage_roles_description_handling(app, user, role_spec, expe
assert not app.db.dirty
roles = {role.name: role for role in user.roles}
assert roles[name].description == expected
@pytest.mark.parametrize(
"allowed_users, allow_existing_users",
[
('specified', True),
('', False),
],
)
async def test_allow_defaults(app, user, allowed_users, allow_existing_users):
if allowed_users:
allowed_users = set(allowed_users.split(','))
else:
allowed_users = set()
authenticator = auth.Authenticator(allowed_users=allowed_users)
authenticator.authenticate = lambda handler, data: data["username"]
assert authenticator.allow_all is False
assert authenticator.allow_existing_users == allow_existing_users
# user was already in the database
# this happens during hub startup
authenticator.add_user(user)
if allowed_users:
assert user.name in authenticator.allowed_users
else:
authenticator.allowed_users == set()
specified_allowed = await authenticator.get_authenticated_user(
None, {"username": "specified"}
)
if "specified" in allowed_users:
assert specified_allowed is not None
else:
assert specified_allowed is None
existing_allowed = await authenticator.get_authenticated_user(
None, {"username": user.name}
)
if allow_existing_users:
assert existing_allowed is not None
else:
assert existing_allowed is None
@pytest.mark.parametrize("allow_all", [None, True, False])
@pytest.mark.parametrize("allow_existing_users", [None, True, False])
@pytest.mark.parametrize("allowed_users", ["existing", ""])
def test_allow_existing_users(
app, user, allowed_users, allow_all, allow_existing_users
):
if allowed_users:
allowed_users = set(allowed_users.split(','))
else:
allowed_users = set()
authenticator = auth.Authenticator(
allowed_users=allowed_users,
)
if allow_all is None:
# default allow_all
allow_all = authenticator.allow_all
else:
authenticator.allow_all = allow_all
if allow_existing_users is None:
# default allow_all
allow_existing_users = authenticator.allow_existing_users
else:
authenticator.allow_existing_users = allow_existing_users
# first, nobody in the database
assert authenticator.check_allowed("newuser") == allow_all
# user was already in the database
# this happens during hub startup
authenticator.add_user(user)
if allow_existing_users or allow_all:
assert authenticator.check_allowed(user.name)
else:
assert not authenticator.check_allowed(user.name)
for username in allowed_users:
assert authenticator.check_allowed(username)
assert authenticator.check_allowed("newuser") == allow_all
@pytest.mark.parametrize("allow_all", [True, False])
@pytest.mark.parametrize("allow_existing_users", [True, False])
def test_allow_existing_users_first_time(user, allow_all, allow_existing_users):
# make sure that calling add_user doesn't change results
authenticator = auth.Authenticator(
allow_all=allow_all,
allow_existing_users=allow_existing_users,
)
allowed_before_one = authenticator.check_allowed(user.name)
allowed_before_two = authenticator.check_allowed("newuser")
# add_user is called after successful login
# it shouldn't change results (e.g. by switching .allowed_users from empty to non-empty)
if allowed_before_one:
authenticator.add_user(user)
assert authenticator.check_allowed(user.name) == allowed_before_one
assert authenticator.check_allowed("newuser") == allowed_before_two
class AllowAllIgnoringAuthenticator(auth.Authenticator):
"""Test authenticator with custom check_allowed
not updated for allow_all, allow_existing_users
Make sure new config doesn't break backward-compatibility
or grant unintended access for Authenticators written before JupyterHub 5.
"""
allowed_letters = Tuple(config=True, help="Initial letters to allow")
def authenticate(self, handler, data):
return {"name": data["username"]}
def check_allowed(self, username, auth=None):
if not self.allowed_users and not self.allowed_letters:
# this subclass doesn't know about the JupyterHub 5 allow_all config
# no allow config, allow all!
return True
if self.allowed_users and username in self.allowed_users:
return True
if self.allowed_letters and username.startswith(self.allowed_letters):
return True
return False
# allow_all is not recognized by Authenticator subclass
# make sure it doesn't make anything more permissive, at least
@pytest.mark.parametrize("allow_all", [True, False])
@pytest.mark.parametrize(
"allowed_users, allowed_letters, allow_existing_users, allowed, not_allowed",
[
("", "", None, "anyone,should-be,allowed,existing", ""),
("", "a,b", None, "alice,bebe", "existing,other"),
("", "a,b", False, "alice,bebe", "existing,other"),
("", "a,b", True, "alice,bebe,existing", "other"),
("specified", "a,b", None, "specified,alice,bebe,existing", "other"),
("specified", "a,b", False, "specified,alice,bebe", "existing,other"),
("specified", "a,b", True, "specified,alice,bebe,existing", "other"),
],
)
async def test_authenticator_without_allow_all(
app,
allowed_users,
allowed_letters,
allow_existing_users,
allowed,
not_allowed,
allow_all,
):
kwargs = {}
if allow_all is not None:
kwargs["allow_all"] = allow_all
if allow_existing_users is not None:
kwargs["allow_existing_users"] = allow_existing_users
if allowed_users:
kwargs["allowed_users"] = set(allowed_users.split(','))
if allowed_letters:
kwargs["allowed_letters"] = tuple(allowed_letters.split(','))
authenticator = AllowAllIgnoringAuthenticator(**kwargs)
# load one user from db
existing_user = add_user(app.db, app, name="existing")
authenticator.add_user(existing_user)
if allowed:
allowed = allowed.split(",")
if not_allowed:
not_allowed = not_allowed.split(",")
expected_allowed = sorted(allowed)
expected_not_allowed = sorted(not_allowed)
to_check = list(chain(expected_allowed, expected_not_allowed))
if allow_all:
expected_allowed = to_check
expected_not_allowed = []
are_allowed = []
are_not_allowed = []
for username in to_check:
if await authenticator.get_authenticated_user(None, {"username": username}):
are_allowed.append(username)
else:
are_not_allowed.append(username)
assert are_allowed == expected_allowed
assert are_not_allowed == expected_not_allowed

View File

@@ -213,7 +213,9 @@ async def test_spawn_handler_access(app):
r.raise_for_status()
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
@pytest.mark.parametrize(
"has_access", ["all", "user", (pytest.param("group", id="in-group")), False]
)
async def test_spawn_other_user(
app, user, username, group, create_temp_role, has_access
):
@@ -300,7 +302,9 @@ async def test_spawn_page_falsy_callable(app):
assert history[1] == ujoin(public_url(app), "hub/spawn-pending/erik")
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
@pytest.mark.parametrize(
"has_access", ["all", "user", (pytest.param("group", id="in-group")), False]
)
async def test_spawn_page_access(
app, has_access, group, username, user, create_temp_role
):
@@ -403,7 +407,9 @@ async def test_spawn_form(app):
}
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
@pytest.mark.parametrize(
"has_access", ["all", "user", (pytest.param("group", id="in-group")), False]
)
async def test_spawn_form_other_user(
app, username, user, group, create_temp_role, has_access
):
@@ -624,7 +630,9 @@ async def test_user_redirect_hook(app, username):
assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1')
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
@pytest.mark.parametrize(
"has_access", ["all", "user", (pytest.param("group", id="in-group")), False]
)
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
@@ -685,11 +693,10 @@ async def test_other_user_url(app, username, user, group, create_temp_role, has_
],
)
async def test_page_with_token(app, user, url, token_in):
cookies = await app.login_user(user.name)
token = user.new_api_token()
if token_in == "url":
url = url_concat(url, {"token": token})
headers = None
headers = {}
elif token_in == "header":
headers = {
"Authorization": f"token {token}",
@@ -734,14 +741,13 @@ async def test_login_strip(app, form_user, auth_user, form_password):
"""Test that login form strips space form usernames, but not passwords"""
form_data = {"username": form_user, "password": form_password}
expected_auth = {"username": auth_user, "password": form_password}
base_url = public_url(app)
called_with = []
async def mock_authenticate(handler, data):
called_with.append(data)
with mock.patch.object(app.authenticator, 'authenticate', mock_authenticate):
r = await async_requests.get(base_url + 'hub/login')
r = await get_page('login', app)
r.raise_for_status()
cookies = r.cookies
xsrf = cookies['_xsrf']
@@ -922,17 +928,19 @@ async def test_auto_login(app, request):
async def test_auto_login_logout(app):
name = 'burnham'
cookies = await app.login_user(name)
s = AsyncSession()
s.cookies = cookies
with mock.patch.dict(
app.tornado_settings, {'authenticator': Authenticator(auto_login=True)}
):
r = await async_requests.get(
r = await s.get(
public_host(app) + app.tornado_settings['logout_url'], cookies=cookies
)
r.raise_for_status()
logout_url = public_host(app) + app.tornado_settings['logout_url']
assert r.url == logout_url
assert r.cookies == {}
assert list(s.cookies.keys()) == ["_xsrf"]
# don't include logged-out user in page:
try:
idx = r.text.index(name)
@@ -946,19 +954,23 @@ async def test_auto_login_logout(app):
async def test_logout(app):
name = 'wash'
cookies = await app.login_user(name)
r = await async_requests.get(
public_host(app) + app.tornado_settings['logout_url'], cookies=cookies
s = AsyncSession()
s.cookies = cookies
r = await s.get(
public_host(app) + app.tornado_settings['logout_url'],
)
r.raise_for_status()
login_url = public_host(app) + app.tornado_settings['login_url']
assert r.url == login_url
assert r.cookies == {}
assert list(s.cookies.keys()) == ["_xsrf"]
@pytest.mark.parametrize('shutdown_on_logout', [True, False])
async def test_shutdown_on_logout(app, shutdown_on_logout):
name = 'shutitdown'
cookies = await app.login_user(name)
s = AsyncSession()
s.cookies = cookies
user = app.users[name]
# start the user's server
@@ -978,14 +990,14 @@ async def test_shutdown_on_logout(app, shutdown_on_logout):
with mock.patch.dict(
app.tornado_settings, {'shutdown_on_logout': shutdown_on_logout}
):
r = await async_requests.get(
r = await s.get(
public_host(app) + app.tornado_settings['logout_url'], cookies=cookies
)
r.raise_for_status()
login_url = public_host(app) + app.tornado_settings['login_url']
assert r.url == login_url
assert r.cookies == {}
assert list(s.cookies.keys()) == ["_xsrf"]
# wait for any pending state to resolve
for i in range(50):

View File

@@ -86,17 +86,9 @@ async def test_hubauth_token(app, mockservice_url, create_user_with_scopes):
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
assert sub_reply == {'name': u.name, 'admin': False}
# token in ?token parameter
# token in ?token parameter is not allowed by default
r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/?token=%s' % token
)
r.raise_for_status()
reply = r.json()
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
assert sub_reply == {'name': u.name, 'admin': False}
r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/?token=no-such-token',
public_url(app, mockservice_url) + '/whoami/?token=%s' % token,
allow_redirects=False,
)
assert r.status_code == 302
@@ -180,21 +172,9 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
else:
assert r.status_code == 403
# token in ?token parameter
# token in ?token parameter is not allowed by default
r = await async_requests.get(
public_url(app, mockservice_url) + 'whoami/?token=%s' % token
)
if allowed:
r.raise_for_status()
assert r.status_code == 200
reply = r.json()
assert service_model.items() <= reply.items()
assert not r.cookies
else:
assert r.status_code == 403
r = await async_requests.get(
public_url(app, mockservice_url) + 'whoami/?token=no-such-token',
public_url(app, mockservice_url) + 'whoami/?token=%s' % token,
allow_redirects=False,
)
assert r.status_code == 302
@@ -385,20 +365,14 @@ async def test_oauth_service_roles(
# token-authenticated request to HubOAuth
token = app.users[name].new_api_token()
# token in ?token parameter
r = await async_requests.get(url_concat(url, {'token': token}))
s.headers["Authorization"] = f"Bearer {token}"
r = await async_requests.get(url, headers=s.headers)
r.raise_for_status()
reply = r.json()
assert reply['name'] == name
# verify that ?token= requests set a cookie
assert len(r.cookies) != 0
# ensure cookie works in future requests
r = await async_requests.get(url, cookies=r.cookies, allow_redirects=False)
r.raise_for_status()
assert r.url == url
reply = r.json()
assert reply['name'] == name
# tokens in headers don't set cookies
assert len(r.cookies) == 0
@pytest.mark.parametrize(
@@ -578,9 +552,8 @@ async def test_oauth_cookie_collision(
else:
raise ValueError(f"finish_first should be 1 or 2, not {finish_first!r}")
# submit the oauth form to complete authorization
r = await s.post(
oauth.url, data={'scopes': ['identify'], "_xsrf": s.cookies["_xsrf"]}
)
hub_xsrf = s.cookies.get("_xsrf", path=app.hub.base_url)
r = await s.post(oauth.url, data={'scopes': ['identify'], "_xsrf": hub_xsrf})
r.raise_for_status()
assert r.url == expected_url
# after finishing, state cookies are all cleared
@@ -596,9 +569,7 @@ async def test_oauth_cookie_collision(
assert service_cookie
# finish other oauth
r = await s.post(
second_oauth.url, data={'scopes': ['identify'], "_xsrf": s.cookies["_xsrf"]}
)
r = await s.post(second_oauth.url, data={'scopes': ['identify'], "_xsrf": hub_xsrf})
r.raise_for_status()
# second oauth doesn't complete,
@@ -664,7 +635,7 @@ async def test_oauth_logout(app, mockservice_url, create_user_with_scopes):
r = await s.get(public_url(app, path='hub/logout'))
r.raise_for_status()
# verify that all cookies other than the service cookie are cleared
assert sorted(s.cookies.keys()) == ["_xsrf", service_cookie_name]
assert sorted(set(s.cookies.keys())) == ["_xsrf", service_cookie_name]
# verify that clearing session id invalidates service cookie
# i.e. redirect back to login page
r = await s.get(url)

View File

@@ -159,7 +159,9 @@ def expand_scopes(scope_str, user, group=None, share_with=None):
return scopes
@pytest.mark.parametrize("share_with", ["user", "group"])
@pytest.mark.parametrize(
"share_with", ["user", pytest.param("group", id="share_with=group")]
)
def test_create_share(app, user, share_user, group, share_with):
db = app.db
spawner = user.spawner.orm_spawner
@@ -427,7 +429,7 @@ def test_share_code_expires(app, user, share_user):
"kind",
[
("user"),
("group"),
(pytest.param("group", id="kind=group")),
],
)
async def test_shares_api_user_group_doesnt_exist(

View File

@@ -2,6 +2,7 @@
import os
import sys
import warnings
from contextlib import nullcontext
from pathlib import Path
from pprint import pprint
@@ -75,18 +76,20 @@ async def test_singleuser_auth(
spawner = user.spawners[server_name]
url = url_path_join(public_url(app, user), server_name)
s = AsyncSession()
# no cookies, redirects to login page
r = await async_requests.get(url)
r = await s.get(url)
r.raise_for_status()
assert '/hub/login' in r.url
# unauthenticated /api/ should 403, not redirect
api_url = url_path_join(url, "api/status")
r = await async_requests.get(api_url, allow_redirects=False)
r = await s.get(api_url, allow_redirects=False)
assert r.status_code == 403
# with cookies, login successful
r = await async_requests.get(url, cookies=cookies)
r = await s.get(url, cookies=cookies)
r.raise_for_status()
assert (
urlparse(r.url)
@@ -100,7 +103,7 @@ async def test_singleuser_auth(
assert r.status_code == 200
# logout
r = await async_requests.get(url_path_join(url, 'logout'), cookies=cookies)
r = await s.get(url_path_join(url, 'logout'))
assert len(r.cookies) == 0
# accessing another user's server hits the oauth confirmation page
@@ -149,6 +152,8 @@ async def test_singleuser_auth(
async def test_disable_user_config(request, app, tmp_path, full_spawn):
# login, start the server
cookies = await app.login_user('nandy')
s = AsyncSession()
s.cookies = cookies
user = app.users['nandy']
# stop spawner, if running:
if user.running:
@@ -180,10 +185,11 @@ async def test_disable_user_config(request, app, tmp_path, full_spawn):
url = public_url(app, user)
# with cookies, login successful
r = await async_requests.get(url, cookies=cookies)
r = await s.get(url)
r.raise_for_status()
assert r.url.endswith('/user/nandy/jupyterhub-test-info')
assert r.status_code == 200
info = r.json()
pprint(info)
assert info['disable_user_config']
@@ -286,6 +292,57 @@ async def test_notebook_dir(
raise ValueError(f"No contents check for {notebook_dir=}")
@pytest.mark.parametrize("extension", [True, False])
@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no auth configuration")
async def test_forbid_unauthenticated_access(
request, app, tmp_path, user, full_spawn, extension
):
try:
from jupyter_server.auth.decorator import allow_unauthenticated # noqa
except ImportError:
pytest.skip("needs jupyter-server 2.13")
from jupyter_server.utils import JupyterServerAuthWarning
# login, start the server
cookies = await app.login_user('nandy')
s = AsyncSession()
s.cookies = cookies
user = app.users['nandy']
# stop spawner, if running:
if user.running:
await user.stop()
# start with new config:
user.spawner.default_url = "/jupyterhub-test-info"
if extension:
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "1"
else:
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "0"
# make sure it's resolved to start
tmp_path = tmp_path.resolve()
real_home_dir = tmp_path / "realhome"
real_home_dir.mkdir()
# make symlink to test resolution
home_dir = tmp_path / "home"
home_dir.symlink_to(real_home_dir)
# home_dir is defined on SimpleSpawner
user.spawner.home_dir = str(home_dir)
jupyter_config_dir = home_dir / ".jupyter"
jupyter_config_dir.mkdir()
# verify config paths
with (jupyter_config_dir / "jupyter_server_config.py").open("w") as f:
f.write("c.ServerApp.allow_unauthenticated_access = False")
# If there are core endpoints (added by jupyterhub) without decorators,
# spawn will error out. If there are extension endpoints without decorators
# these will be logged as warnings.
with warnings.catch_warnings():
warnings.simplefilter("error", JupyterServerAuthWarning)
await user.spawn()
@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no --help-all")
def test_help_output():
out = check_output(
@@ -385,20 +442,31 @@ async def test_nbclassic_control_panel(app, user, full_spawn):
@pytest.mark.skipif(
IS_JUPYVERSE, reason="jupyverse doesn't implement token authentication"
)
async def test_token_url_cookie(app, user, full_spawn):
@pytest.mark.parametrize("accept_token_in_url", ["1", "0", ""])
async def test_token_url_cookie(app, user, full_spawn, accept_token_in_url):
if accept_token_in_url:
user.spawner.environment["JUPYTERHUB_ALLOW_TOKEN_IN_URL"] = accept_token_in_url
should_accept = accept_token_in_url == "1"
await user.spawn()
await app.proxy.add_user(user)
token = user.new_api_token(scopes=["access:servers!user"])
url = url_path_join(public_url(app, user), user.spawner.default_url or "/tree/")
# first request: auth with token in URL
r = await async_requests.get(url + f"?token={token}", allow_redirects=False)
s = AsyncSession()
r = await s.get(url + f"?token={token}", allow_redirects=False)
print(r.url, r.status_code)
if not should_accept:
assert r.status_code == 302
return
assert r.status_code == 200
assert r.cookies
assert s.cookies
# second request, use cookies set by first response,
# no token in URL
r = await async_requests.get(url, cookies=r.cookies, allow_redirects=False)
r = await s.get(url, allow_redirects=False)
assert r.status_code == 200
await user.stop()
@@ -409,7 +477,8 @@ async def test_api_403_no_cookie(app, user, full_spawn):
await user.spawn()
await app.proxy.add_user(user)
url = url_path_join(public_url(app, user), "/api/contents/")
r = await async_requests.get(url, allow_redirects=False)
s = AsyncSession()
r = await s.get(url, allow_redirects=False)
# 403, not redirect
assert r.status_code == 403
# no state cookie set

View File

@@ -42,6 +42,13 @@ async_requests = _AsyncRequests()
class AsyncSession(requests.Session):
"""requests.Session object that runs in the background thread"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# session requests are for cookie authentication
# and should look like regular page views,
# so set Sec-Fetch-Mode: navigate
self.headers.setdefault("Sec-Fetch-Mode", "navigate")
def request(self, *args, **kwargs):
return async_requests.executor.submit(super().request, *args, **kwargs)
@@ -157,6 +164,7 @@ async def api_request(
else:
base_url = public_url(app, path='hub')
headers = kwargs.setdefault('headers', {})
headers.setdefault("Sec-Fetch-Mode", "cors")
if 'Authorization' not in headers and not noauth and 'cookies' not in kwargs:
# make a copy to avoid modifying arg in-place
kwargs['headers'] = h = {}
@@ -176,7 +184,7 @@ async def api_request(
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
kwargs["verify"] = app.internal_ssl_ca
resp = await f(url, **kwargs)
assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy']
assert "frame-ancestors 'none'" in resp.headers['Content-Security-Policy']
assert (
ujoin(app.hub.base_url, "security/csp-report")
in resp.headers['Content-Security-Policy']
@@ -197,6 +205,9 @@ def get_page(path, app, hub=True, **kw):
else:
prefix = app.base_url
base_url = ujoin(public_host(app), prefix)
# Sec-Fetch-Mode=navigate to look like a regular page view
headers = kw.setdefault("headers", {})
headers.setdefault("Sec-Fetch-Mode", "navigate")
return async_requests.get(ujoin(base_url, path), **kw)

View File

@@ -526,6 +526,9 @@ class User:
_deprecated_db_session=self.db,
oauth_client_id=client_id,
cookie_options=self.settings.get('cookie_options', {}),
cookie_host_prefix_enabled=self.settings.get(
"cookie_host_prefix_enabled", False
),
trusted_alt_names=trusted_alt_names,
user_options=orm_spawner.user_options or {},
)

View File

@@ -8,6 +8,7 @@ import errno
import functools
import hashlib
import inspect
import os
import random
import re
import secrets
@@ -34,6 +35,21 @@ from tornado.httpclient import AsyncHTTPClient, HTTPError
from tornado.log import app_log
def _bool_env(key, default=False):
"""Cast an environment variable to bool
If unset or empty, return `default`
`0` is False; all other values are True.
"""
value = os.environ.get(key, "")
if value == "":
return default
if value.lower() in {"0", "false"}:
return False
else:
return True
# Deprecated aliases: no longer needed now that we require 3.7
def asyncio_all_tasks(loop=None):
warnings.warn(

View File

@@ -6,11 +6,6 @@
<h1 class="sr-only">Manage JupyterHub Tokens</h1>
<div class="row">
<form id="request-token-form" class="col-md-offset-3 col-md-6">
<div class="text-center">
<button type="submit" class="btn btn-lg btn-jupyter">
Request new API token
</button>
</div>
<div class="form-group">
<label for="token-note">Note</label>
<input
@@ -44,6 +39,11 @@
See the <a href="https://jupyterhub.readthedocs.io/en/stable/rbac/scopes.html#available-scopes">JupyterHub documentation for a list of available scopes</a>.
</small>
</div>
<div class="text-center">
<button type="submit" class="btn btn-lg btn-jupyter">
Request new API token
</button>
</div>
</form>
</div>