Compare commits

...

64 Commits

Author SHA1 Message Date
Min RK
e7eb674a89 0.9.0b3 2018-05-23 16:30:07 +02:00
Min RK
b232633100 Merge pull request #1894 from minrk/db-rollback
Rollback database sessions on SQLAlchemy errors
2018-05-23 16:09:51 +02:00
Carol Willing
6abd19c149 Merge pull request #1911 from minrk/log-classes
log Authenticator and Spawner classes at startup
2018-05-22 11:50:59 -07:00
Min RK
0aa0ff8db7 Merge pull request #1912 from minrk/double-slash
Fix login redirect checking for `//` urls
2018-05-22 15:56:29 +02:00
Min RK
a907429fd4 more test cases for login redirects 2018-05-22 15:40:27 +02:00
Min RK
598b550a67 fix query/hash login redirect handling 2018-05-22 15:40:14 +02:00
Min RK
92bb442494 more robust checking for login redirects outside jupyterhub 2018-05-22 15:40:00 +02:00
Min RK
2d41f6223e log Authenticator and Spawner classes at startup
for better diagnostics
2018-05-22 13:52:41 +02:00
Min RK
791dd5fb9f Merge pull request #1895 from minrk/oauth-commits
avoid creating one huge transaction cleaning up oauth clients
2018-05-22 13:37:56 +02:00
Carol Willing
9a0ccf4c98 Merge pull request #1910 from minrk/ip-typo
default bind url should be on all ips
2018-05-22 01:26:35 -07:00
Min RK
ad2abc5771 default bind url should be on all ips
preserves jupyterhub default behavior

typo introduced in new bind_url config
2018-05-22 09:55:01 +02:00
Min RK
2d99b3943f enable pessimistic connection handling
from the sqlalchemy docs

checks if a connection is valid via `SELECT 1` prior to using it.

Since we have long-running connections, this helps us survive database restarts, disconnects, etc.
2018-05-21 22:14:11 +02:00
Min RK
a358132f95 remove --rm from docker-db.sh
for easier stop/start testing
2018-05-21 22:12:30 +02:00
Tim Head
09cd37feee Merge pull request #1896 from thoralf-gutierrez/fix-typos-in-config
Fix typos in auth config documentation
2018-05-16 22:37:51 +02:00
Thoralf Gutierrez
0f3610e81d Fix typos in auth config documentation 2018-05-16 10:58:02 -07:00
Min RK
3f97c438e2 avoid creating one huge transaction cleaning up oauth clients 2018-05-15 16:33:50 +02:00
Min RK
42351201d2 back to dev 2018-05-15 16:32:24 +02:00
Min RK
907bbb8e9d 0.9.0b2 2018-05-15 14:03:10 +02:00
Min RK
63f3d8b621 catch database errors in update_last_activity 2018-05-15 13:53:05 +02:00
Min RK
47d6e841fd cache get_current_user result
avoids raising an error rendering templates, etc.
2018-05-15 13:49:38 +02:00
Min RK
e3bb09fabe rollback database session on db errors
ensures reconnect will occur when database connection is lost
2018-05-15 13:49:14 +02:00
Carol Willing
d4e0c01189 Merge pull request #1893 from minrk/version
ensure jupyterhub version matches pep440
2018-05-15 07:40:24 -04:00
Min RK
50370d42b0 ensure jupyterhub version matches pep440
avoids mismatch jupyterhub version and tag in docker builds
2018-05-15 13:19:43 +02:00
Min RK
aa190a80b7 Merge pull request #1891 from minrk/base_url
fix and test bind_url / base_url interactions
2018-05-15 12:07:44 +01:00
Min RK
e48bae77aa Merge pull request #1890 from minrk/default-url
test default_url handling
2018-05-15 10:51:17 +01:00
Min RK
96cf0f99ed fix and test bind_url / base_url interactions 2018-05-15 10:51:11 +02:00
Min RK
f380968049 test default_url handling
- default_url is used even if not logged in
- flesh out docstrings
- pass via settings
2018-05-15 10:15:33 +02:00
Min RK
02468f4625 Merge pull request #1854 from summerswallow-whi/extra_handler
Add custom handlers and allow setting of defaults
2018-05-15 08:55:15 +01:00
Haw-minn Lu
24611f94cf Remove base_url from default_url
Add help to new traits
change extra_page_handler to extra_handler
2018-05-14 11:53:22 -07:00
Min RK
dc75a9a4b7 Merge pull request #1881 from paccorsi/check-post-stop-hook
Check the value of post stop hook
2018-05-14 13:31:33 +01:00
Min RK
33f459a23a Merge pull request #1878 from ausecocloud/master
fix listing of OAuth tokens on tokens page
2018-05-14 13:31:06 +01:00
Min RK
bdcc251002 Merge pull request #1882 from dhirschfeld/patch-1
Allow configuring the heading in spawn.html
2018-05-14 13:30:47 +01:00
Pierre Accorsi
86052ba7b4 Check the value of post stop hook 2018-05-11 10:12:45 -04:00
Dave Hirschfeld
62ebcf55c9 Allow configuring the heading in spawn.html 2018-05-11 13:34:17 +10:00
Haw-minn Lu
80ac2475a0 Restore whitespacing to original 2018-05-10 11:25:02 -07:00
Haw-minn Lu
5179d922f5 Clean up extra handler defaults 2018-05-10 11:22:50 -07:00
Gerhard Weis
26f085a8ed add test for oauth tokens on tokens page 2018-05-10 08:46:28 +10:00
Gerhard Weis
b7d302cc72 fix listing of OAuth tokens on tokens page 2018-05-10 08:46:28 +10:00
Carol Willing
f2941e3631 Merge pull request #1873 from minrk/apitoken-expiry
implement API token expiry
2018-05-09 11:45:41 -04:00
Carol Willing
26a6401af4 Merge pull request #1876 from willingc/sudo-section
refactor sudo example config
2018-05-08 09:23:28 -07:00
Carol Willing
5c8ce338a1 edit per @minrk review 2018-05-08 11:54:38 -04:00
Carol Willing
5addc7bbaf correct directive 2018-05-07 21:03:13 -07:00
Carol Willing
da095170bf remove toctree item 2018-05-07 20:38:15 -07:00
Carol Willing
1aab0a69bd fix typo 2018-05-07 20:31:20 -07:00
Carol Willing
fc8e04b62f reflow templates file 2018-05-07 20:29:13 -07:00
Carol Willing
c6c53b4e10 update index 2018-05-07 20:28:55 -07:00
Carol Willing
9b0219a2d8 break up configuration examples 2018-05-07 20:18:02 -07:00
Carol Willing
6e212fa476 reflow proxy doc 2018-05-07 20:17:14 -07:00
Carol Willing
58f9237b12 refactor sudo example config 2018-05-07 15:38:16 -07:00
Carol Willing
74fd925219 Merge pull request #1864 from datalayer-contrib/docs-sudo
Add Docs about sudo (and remove it from the wiki)
2018-05-07 23:29:08 +02:00
Carol Willing
2696bb97d2 Merge pull request #1875 from willingc/api-redux
add packages to environment.yml
2018-05-07 23:16:53 +02:00
Haw-minn Lu
9cefb27704 Move extra_handlers to fall below builtins in priority 2018-05-07 14:06:34 -07:00
Carol Willing
5e75357b06 add packages to environment.yml 2018-05-07 13:54:06 -07:00
Min RK
79bebb4bc9 Merge pull request #1872 from thedataincubator/template-vars
Allow extra variables to be passed into templates
2018-05-07 20:33:44 +02:00
Eric Charles
0ed88f212b add sudo.md 2018-05-07 19:49:26 +02:00
Eric Charles
a8c1cab5fe add sudo doc 2018-05-07 19:49:26 +02:00
Min RK
e1a6b1a70f Merge pull request #1856 from minrk/whoami-users
note about hub_users in whoami example
2018-05-07 19:47:45 +02:00
Robert Schroll
c95ed16786 Allow extra variables to be passed into templates 2018-05-07 10:47:27 -07:00
Min RK
ec784803b4 remove duplicate whoami-oauth.py from external-oauth example 2018-05-07 15:35:05 +02:00
Min RK
302d7a22d3 leave user-whitelist example in a comment
allow all users by default because default whitelist is confusing
2018-05-07 15:34:33 +02:00
Min RK
7c6591aefe add token expiry to token model 2018-05-07 13:02:26 +02:00
Min RK
58c91e3fd4 implement API token expiry 2018-05-07 13:00:37 +02:00
Min RK
db4cf7ae62 note about hub_users in whoami example
explain what hub_users does and the value in the example
2018-05-07 10:55:39 +02:00
Haw-minn Lu
bc86ee1c31 Add custom handlers and allow setting of defaults 2018-04-27 15:58:59 -07:00
32 changed files with 1130 additions and 470 deletions

View File

@@ -8,7 +8,7 @@ export MYSQL_HOST=127.0.0.1
export MYSQL_TCP_PORT=${MYSQL_TCP_PORT:-13306} export MYSQL_TCP_PORT=${MYSQL_TCP_PORT:-13306}
export PGHOST=127.0.0.1 export PGHOST=127.0.0.1
NAME="hub-test-$DB" NAME="hub-test-$DB"
DOCKER_RUN="docker run --rm -d --name $NAME" DOCKER_RUN="docker run -d --name $NAME"
docker rm -f "$NAME" 2>/dev/null || true docker rm -f "$NAME" 2>/dev/null || true

View File

@@ -15,3 +15,5 @@ dependencies:
- pip: - pip:
- python-oauth2 - python-oauth2
- recommonmark==0.4.0 - recommonmark==0.4.0
- async_generator
- prometheus_client

View File

@@ -51,7 +51,7 @@ and tornado < 5.0.
Sets ip, port, base_url all at once. Sets ip, port, base_url all at once.
- Add `JupyterHub.hub_bind_url` for setting the full host+port of the Hub. - Add `JupyterHub.hub_bind_url` for setting the full host+port of the Hub.
`hub_bind_url` supports unix domain sockets, e.g. `hub_bind_url` supports unix domain sockets, e.g.
`unix+http://%2Fsrv%2Fjupytrehub.sock` `unix+http://%2Fsrv%2Fjupyterhub.sock`
- Deprecate `JupyterHub.hub_connect_port` config in favor of `JupyterHub.hub_connect_url`. `hub_connect_ip` is not deprecated - Deprecate `JupyterHub.hub_connect_port` config in favor of `JupyterHub.hub_connect_url`. `hub_connect_ip` is not deprecated
and can still be used in the common case where only the ip address of the hub differs from the bind ip. and can still be used in the common case where only the ip address of the hub differs from the bind ip.

View File

@@ -59,6 +59,9 @@ Contents
* :doc:`reference/rest` * :doc:`reference/rest`
* :doc:`reference/upgrading` * :doc:`reference/upgrading`
* :doc:`reference/config-examples` * :doc:`reference/config-examples`
* :doc:`reference/config-ghoauth`
* :doc:`reference/config-proxy`
* :doc:`reference/config-sudo`
**API Reference** **API Reference**

View File

@@ -1,281 +1,8 @@
# Configuration examples # Configuration examples
This section provides examples, including configuration files and tips, for the The following sections provide examples, including configuration files and tips, for the
following configurations: following:
- Using GitHub OAuth - Configuring GitHub OAuth
- Using nginx reverse proxy - Using reverse proxy (nginx and Apache)
- Run JupyterHub without root privileges using `sudo`
## Using GitHub OAuth
In this example, we show a configuration file for a fairly standard JupyterHub
deployment with the following assumptions:
* Running JupyterHub on a single cloud server
* Using SSL on the standard HTTPS port 443
* Using GitHub OAuth (using oauthenticator) for login
* Using the default spawner (to configure other spawners, uncomment and edit
`spawner_class` as well as follow the instructions for your desired spawner)
* Users exist locally on the server
* Users' notebooks to be served from `~/assignments` to allow users to browse
for notebooks within other users' home directories
* You want the landing page for each user to be a `Welcome.ipynb` notebook in
their assignments directory.
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
The `jupyterhub_config.py` file would have these settings:
```python
# jupyterhub_config.py file
c = get_config()
import os
pjoin = os.path.join
runtime_dir = os.path.join('/srv/jupyterhub')
ssl_dir = pjoin(runtime_dir, 'ssl')
if not os.path.exists(ssl_dir):
os.makedirs(ssl_dir)
# Allows multiple single-server per user
c.JupyterHub.allow_named_servers = True
# https on :443
c.JupyterHub.port = 443
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
# put the JupyterHub cookie secret and state db
# in /var/run/jupyterhub
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
# use GitHub OAuthenticator for local users
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
# create system users that don't exist yet
c.LocalAuthenticator.create_system_users = True
# specify users and admin
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
# uses the default spawner
# To use a different spawner, uncomment `spawner_class` and set to desired
# spawner (e.g. SudoSpawner). Follow instructions for desired spawner
# configuration.
# c.JupyterHub.spawner_class = 'sudospawner.SudoSpawner'
# start single-user notebook servers in ~/assignments,
# with ~/assignments/Welcome.ipynb as the default landing page
# this config could also be put in
# /etc/jupyter/jupyter_notebook_config.py
c.Spawner.notebook_dir = '~/assignments'
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
```
Using the GitHub Authenticator requires a few additional
environment variable to be set prior to launching JupyterHub:
```bash
export GITHUB_CLIENT_ID=github_id
export GITHUB_CLIENT_SECRET=github_secret
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
export CONFIGPROXY_AUTH_TOKEN=super-secret
# append log output to log file /var/log/jupyterhub.log
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
```
## Using a reverse proxy
In the following example, we show configuration files for a JupyterHub server
running locally on port `8000` but accessible from the outside on the standard
SSL port `443`. This could be useful if the JupyterHub server machine is also
hosting other domains or content on `443`. The goal in this example is to
satisfy the following:
* JupyterHub is running on a server, accessed *only* via `HUB.DOMAIN.TLD:443`
* On the same machine, `NO_HUB.DOMAIN.TLD` strictly serves different content,
also on port `443`
* `nginx` or `apache` is used as the public access point (which means that
only nginx/apache will bind to `443`)
* After testing, the server in question should be able to score at least an A on the
Qualys SSL Labs [SSL Server Test](https://www.ssllabs.com/ssltest/)
Let's start out with needed JupyterHub configuration in `jupyterhub_config.py`:
```python
# Force the proxy to only listen to connections to 127.0.0.1
c.JupyterHub.ip = '127.0.0.1'
```
For high-quality SSL configuration, we also generate Diffie-Helman parameters.
This can take a few minutes:
```bash
openssl dhparam -out /etc/ssl/certs/dhparam.pem 4096
```
### nginx
The **`nginx` server config file** is fairly standard fare except for the two
`location` blocks within the `HUB.DOMAIN.TLD` config file:
```bash
# top-level http config for websocket headers
# If Upgrade is defined, Connection = upgrade
# If Upgrade is empty, Connection = close
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP server to redirect all 80 traffic to SSL/HTTPS
server {
listen 80;
server_name HUB.DOMAIN.TLD;
# Tell all requests to port 80 to be 302 redirected to HTTPS
return 302 https://$host$request_uri;
}
# HTTPS server to handle JupyterHub
server {
listen 443;
ssl on;
server_name HUB.DOMAIN.TLD;
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_dhparam /etc/ssl/certs/dhparam.pem;
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security max-age=15768000;
# Managing literal requests to the JupyterHub front end
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# websocket headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# Managing requests to verify letsencrypt host
location ~ /.well-known {
allow all;
}
}
```
If `nginx` is not running on port 443, substitute `$http_host` for `$host` on
the lines setting the `Host` header.
`nginx` will now be the front facing element of JupyterHub on `443` which means
it is also free to bind other servers, like `NO_HUB.DOMAIN.TLD` to the same port
on the same machine and network interface. In fact, one can simply use the same
server blocks as above for `NO_HUB` and simply add line for the root directory
of the site as well as the applicable location call:
```bash
server {
listen 80;
server_name NO_HUB.DOMAIN.TLD;
# Tell all requests to port 80 to be 302 redirected to HTTPS
return 302 https://$host$request_uri;
}
server {
listen 443;
ssl on;
# INSERT OTHER SSL PARAMETERS HERE AS ABOVE
# SSL cert may differ
# Set the appropriate root directory
root /var/www/html
# Set URI handling
location / {
try_files $uri $uri/ =404;
}
# Managing requests to verify letsencrypt host
location ~ /.well-known {
allow all;
}
}
```
Now restart `nginx`, restart the JupyterHub, and enjoy accessing
`https://HUB.DOMAIN.TLD` while serving other content securely on
`https://NO_HUB.DOMAIN.TLD`.
### Apache
As with nginx above, you can use [Apache](https://httpd.apache.org) as the reverse proxy.
First, we will need to enable the apache modules that we are going to need:
```bash
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
```
Our Apache configuration is equivalent to the nginx configuration above:
- Redirect HTTP to HTTPS
- Good SSL Configuration
- Support for websockets on any proxied URL
- JupyterHub is running locally at http://127.0.0.1:8000
```bash
# redirect HTTP to HTTPS
Listen 80
<VirtualHost HUB.DOMAIN.TLD:80>
ServerName HUB.DOMAIN.TLD
Redirect / https://HUB.DOMAIN.TLD/
</VirtualHost>
Listen 443
<VirtualHost HUB.DOMAIN.TLD:443>
ServerName HUB.DOMAIN.TLD
# configure SSL
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
SSLProtocol All -SSLv2 -SSLv3
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
# Use RewriteEngine to handle websocket connection upgrades
RewriteEngine On
RewriteCond %{HTTP:Connection} Upgrade [NC]
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteRule /(.*) ws://127.0.0.1:8000/$1 [P,L]
<Location "/">
# preserve Host header to avoid cross-origin problems
ProxyPreserveHost on
# proxy to JupyterHub
ProxyPass http://127.0.0.1:8000/
ProxyPassReverse http://127.0.0.1:8000/
</Location>
</VirtualHost>
```

View File

@@ -0,0 +1,82 @@
# Configure GitHub OAuth
In this example, we show a configuration file for a fairly standard JupyterHub
deployment with the following assumptions:
* Running JupyterHub on a single cloud server
* Using SSL on the standard HTTPS port 443
* Using GitHub OAuth (using oauthenticator) for login
* Using the default spawner (to configure other spawners, uncomment and edit
`spawner_class` as well as follow the instructions for your desired spawner)
* Users exist locally on the server
* Users' notebooks to be served from `~/assignments` to allow users to browse
for notebooks within other users' home directories
* You want the landing page for each user to be a `Welcome.ipynb` notebook in
their assignments directory.
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
The `jupyterhub_config.py` file would have these settings:
```python
# jupyterhub_config.py file
c = get_config()
import os
pjoin = os.path.join
runtime_dir = os.path.join('/srv/jupyterhub')
ssl_dir = pjoin(runtime_dir, 'ssl')
if not os.path.exists(ssl_dir):
os.makedirs(ssl_dir)
# Allows multiple single-server per user
c.JupyterHub.allow_named_servers = True
# https on :443
c.JupyterHub.port = 443
c.JupyterHub.ssl_key = pjoin(ssl_dir, 'ssl.key')
c.JupyterHub.ssl_cert = pjoin(ssl_dir, 'ssl.cert')
# put the JupyterHub cookie secret and state db
# in /var/run/jupyterhub
c.JupyterHub.cookie_secret_file = pjoin(runtime_dir, 'cookie_secret')
c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
# or `--db=/path/to/jupyterhub.sqlite` on the command-line
# use GitHub OAuthenticator for local users
c.JupyterHub.authenticator_class = 'oauthenticator.LocalGitHubOAuthenticator'
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
# create system users that don't exist yet
c.LocalAuthenticator.create_system_users = True
# specify users and admin
c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'}
c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'}
# uses the default spawner
# To use a different spawner, uncomment `spawner_class` and set to desired
# spawner (e.g. SudoSpawner). Follow instructions for desired spawner
# configuration.
# c.JupyterHub.spawner_class = 'sudospawner.SudoSpawner'
# start single-user notebook servers in ~/assignments,
# with ~/assignments/Welcome.ipynb as the default landing page
# this config could also be put in
# /etc/jupyter/jupyter_notebook_config.py
c.Spawner.notebook_dir = '~/assignments'
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
```
Using the GitHub Authenticator requires a few additional
environment variable to be set prior to launching JupyterHub:
```bash
export GITHUB_CLIENT_ID=github_id
export GITHUB_CLIENT_SECRET=github_secret
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
export CONFIGPROXY_AUTH_TOKEN=super-secret
# append log output to log file /var/log/jupyterhub.log
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py &>> /var/log/jupyterhub.log
```

View File

@@ -0,0 +1,190 @@
# Using a reverse proxy
In the following example, we show configuration files for a JupyterHub server
running locally on port `8000` but accessible from the outside on the standard
SSL port `443`. This could be useful if the JupyterHub server machine is also
hosting other domains or content on `443`. The goal in this example is to
satisfy the following:
* JupyterHub is running on a server, accessed *only* via `HUB.DOMAIN.TLD:443`
* On the same machine, `NO_HUB.DOMAIN.TLD` strictly serves different content,
also on port `443`
* `nginx` or `apache` is used as the public access point (which means that
only nginx/apache will bind to `443`)
* After testing, the server in question should be able to score at least an A on the
Qualys SSL Labs [SSL Server Test](https://www.ssllabs.com/ssltest/)
Let's start out with needed JupyterHub configuration in `jupyterhub_config.py`:
```python
# Force the proxy to only listen to connections to 127.0.0.1
c.JupyterHub.ip = '127.0.0.1'
```
For high-quality SSL configuration, we also generate Diffie-Helman parameters.
This can take a few minutes:
```bash
openssl dhparam -out /etc/ssl/certs/dhparam.pem 4096
```
## nginx
The **`nginx` server config file** is fairly standard fare except for the two
`location` blocks within the `HUB.DOMAIN.TLD` config file:
```bash
# top-level http config for websocket headers
# If Upgrade is defined, Connection = upgrade
# If Upgrade is empty, Connection = close
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# HTTP server to redirect all 80 traffic to SSL/HTTPS
server {
listen 80;
server_name HUB.DOMAIN.TLD;
# Tell all requests to port 80 to be 302 redirected to HTTPS
return 302 https://$host$request_uri;
}
# HTTPS server to handle JupyterHub
server {
listen 443;
ssl on;
server_name HUB.DOMAIN.TLD;
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_dhparam /etc/ssl/certs/dhparam.pem;
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security max-age=15768000;
# Managing literal requests to the JupyterHub front end
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# websocket headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# Managing requests to verify letsencrypt host
location ~ /.well-known {
allow all;
}
}
```
If `nginx` is not running on port 443, substitute `$http_host` for `$host` on
the lines setting the `Host` header.
`nginx` will now be the front facing element of JupyterHub on `443` which means
it is also free to bind other servers, like `NO_HUB.DOMAIN.TLD` to the same port
on the same machine and network interface. In fact, one can simply use the same
server blocks as above for `NO_HUB` and simply add line for the root directory
of the site as well as the applicable location call:
```bash
server {
listen 80;
server_name NO_HUB.DOMAIN.TLD;
# Tell all requests to port 80 to be 302 redirected to HTTPS
return 302 https://$host$request_uri;
}
server {
listen 443;
ssl on;
# INSERT OTHER SSL PARAMETERS HERE AS ABOVE
# SSL cert may differ
# Set the appropriate root directory
root /var/www/html
# Set URI handling
location / {
try_files $uri $uri/ =404;
}
# Managing requests to verify letsencrypt host
location ~ /.well-known {
allow all;
}
}
```
Now restart `nginx`, restart the JupyterHub, and enjoy accessing
`https://HUB.DOMAIN.TLD` while serving other content securely on
`https://NO_HUB.DOMAIN.TLD`.
## Apache
As with nginx above, you can use [Apache](https://httpd.apache.org) as the reverse proxy.
First, we will need to enable the apache modules that we are going to need:
```bash
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
```
Our Apache configuration is equivalent to the nginx configuration above:
- Redirect HTTP to HTTPS
- Good SSL Configuration
- Support for websockets on any proxied URL
- JupyterHub is running locally at http://127.0.0.1:8000
```bash
# redirect HTTP to HTTPS
Listen 80
<VirtualHost HUB.DOMAIN.TLD:80>
ServerName HUB.DOMAIN.TLD
Redirect / https://HUB.DOMAIN.TLD/
</VirtualHost>
Listen 443
<VirtualHost HUB.DOMAIN.TLD:443>
ServerName HUB.DOMAIN.TLD
# configure SSL
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
SSLProtocol All -SSLv2 -SSLv3
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
# Use RewriteEngine to handle websocket connection upgrades
RewriteEngine On
RewriteCond %{HTTP:Connection} Upgrade [NC]
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteRule /(.*) ws://127.0.0.1:8000/$1 [P,L]
<Location "/">
# preserve Host header to avoid cross-origin problems
ProxyPreserveHost on
# proxy to JupyterHub
ProxyPass http://127.0.0.1:8000/
ProxyPassReverse http://127.0.0.1:8000/
</Location>
</VirtualHost>
```

View File

@@ -0,0 +1,254 @@
# Run JupyterHub without root privileges using `sudo`
**Note:** Setting up `sudo` permissions involves many pieces of system
configuration. It is quite easy to get wrong and very difficult to debug.
Only do this if you are very sure you must.
## Overview
There are many Authenticators and Spawners available for JupyterHub. Some, such
as DockerSpawner or OAuthenticator, do not need any elevated permissions. This
document describes how to get the full default behavior of JupyterHub while
running notebook servers as real system users on a shared system without
running the Hub itself as root.
Since JupyterHub needs to spawn processes as other users, the simplest way
is to run it as root, spawning user servers with [setuid](http://linux.die.net/man/2/setuid).
But this isn't especially safe, because you have a process running on the
public web as root.
A **more prudent way** to run the server while preserving functionality is to
create a dedicated user with `sudo` access restricted to launching and
monitoring single-user servers.
## Create a user
To do this, first create a user that will run the Hub:
```bash
sudo useradd rhea
```
This user shouldn't have a login shell or password (possible with -r).
## Set up sudospawner
Next, you will need [sudospawner](https://github.com/jupyter/sudospawner)
to enable monitoring the single-user servers with sudo:
```bash
sudo pip install sudospawner
```
Now we have to configure sudo to allow the Hub user (`rhea`) to launch
the sudospawner script on behalf of our hub users (here `zoe` and `wash`).
We want to confine these permissions to only what we really need.
## Edit `/etc/sudoers`
To do this we add to `/etc/sudoers` (use `visudo` for safe editing of sudoers):
- specify the list of users `JUPYTER_USERS` for whom `rhea` can spawn servers
- set the command `JUPYTER_CMD` that `rhea` can execute on behalf of users
- give `rhea` permission to run `JUPYTER_CMD` on behalf of `JUPYTER_USERS`
without entering a password
For example:
```bash
# comma-separated whitelist of users that can spawn single-user servers
# this should include all of your Hub users
Runas_Alias JUPYTER_USERS = rhea, zoe, wash
# the command(s) the Hub can run on behalf of the above users without needing a password
# the exact path may differ, depending on how sudospawner was installed
Cmnd_Alias JUPYTER_CMD = /usr/local/bin/sudospawner
# actually give the Hub user permission to run the above command on behalf
# of the above users without prompting for a password
rhea ALL=(JUPYTER_USERS) NOPASSWD:JUPYTER_CMD
```
It might be useful to modifiy `secure_path` to add commands in path.
As an alternative to adding every user to the `/etc/sudoers` file, you can
use a group in the last line above, instead of `JUPYTER_USERS`:
```bash
rhea ALL=(%jupyterhub) NOPASSWD:JUPYTER_CMD
```
If the `jupyterhub` group exists, there will be no need to edit `/etc/sudoers`
again. A new user will gain access to the application when added to the group:
```bash
$ adduser -G jupyterhub newuser
```
## Test `sudo` setup
Test that the new user doesn't need to enter a password to run the sudospawner
command.
This should prompt for your password to switch to rhea, but *not* prompt for
any password for the second switch. It should show some help output about
logging options:
```bash
$ sudo -u rhea sudo -n -u $USER /usr/local/bin/sudospawner --help
Usage: /usr/local/bin/sudospawner [OPTIONS]
Options:
--help show this help information
...
```
And this should fail:
```bash
$ sudo -u rhea sudo -n -u $USER echo 'fail'
sudo: a password is required
```
## Enable PAM for non-root
By default, [PAM authentication](http://en.wikipedia.org/wiki/Pluggable_authentication_module)
is used by JupyterHub. To use PAM, the process may need to be able to read
the shadow password database.
### Shadow group (Linux)
```bash
$ ls -l /etc/shadow
-rw-r----- 1 root shadow 2197 Jul 21 13:41 shadow
```
If there's already a shadow group, you are set. If its permissions are more like:
```bash
$ ls -l /etc/shadow
-rw------- 1 root wheel 2197 Jul 21 13:41 shadow
```
Then you may want to add a shadow group, and make the shadow file group-readable:
```bash
$ sudo groupadd shadow
$ sudo chgrp shadow /etc/shadow
$ sudo chmod g+r /etc/shadow
```
We want our new user to be able to read the shadow passwords, so add it to the shadow group:
```bash
$ sudo usermod -a -G shadow rhea
```
If you want jupyterhub to serve pages on a restricted port (such as port 80 for http),
then you will need to give `node` permission to do so:
```bash
sudo setcap 'cap_net_bind_service=+ep' /usr/bin/node
```
However, you may want to further understand the consequences of this.
You may also be interested in limiting the amount of CPU any process can use
on your server. `cpulimit` is a useful tool that is available for many Linux
distributions' packaging system. This can be used to keep any user's process
from using too much CPU cycles. You can configure it accoring to [these
instructions](http://ubuntuforums.org/showthread.php?t=992706).
### Shadow group (FreeBSD)
**NOTE:** This has not been tested and may not work as expected.
```bash
$ ls -l /etc/spwd.db /etc/master.passwd
-rw------- 1 root wheel 2516 Aug 22 13:35 /etc/master.passwd
-rw------- 1 root wheel 40960 Aug 22 13:35 /etc/spwd.db
```
Add a shadow group if there isn't one, and make the shadow file group-readable:
```bash
$ sudo pw group add shadow
$ sudo chgrp shadow /etc/spwd.db
$ sudo chmod g+r /etc/spwd.db
$ sudo chgrp shadow /etc/master.passwd
$ sudo chmod g+r /etc/master.passwd
```
We want our new user to be able to read the shadow passwords, so add it to the
shadow group:
```bash
$ sudo pw user mod rhea -G shadow
```
## Test that PAM works
We can verify that PAM is working, with:
```bash
$ sudo -u rhea python3 -c "import pamela, getpass; print(pamela.authenticate('$USER', getpass.getpass()))"
Password: [enter your unix password]
```
## Make a directory for JupyterHub
JupyterHub stores its state in a database, so it needs write access to a directory.
The simplest way to deal with this is to make a directory owned by your Hub user,
and use that as the CWD when launching the server.
```bash
$ sudo mkdir /etc/jupyterhub
$ sudo chown rhea /etc/jupyterhub
```
## Start jupyterhub
Finally, start the server as our newly configured user, `rhea`:
```bash
$ cd /etc/jupyterhub
$ sudo -u rhea jupyterhub --JupyterHub.spawner_class=sudospawner.SudoSpawner
```
And try logging in.
### Troubleshooting: SELinux
If you still get a generic `Permission denied` `PermissionError`, it's possible SELinux is blocking you.
Here's how you can make a module to allow this.
First, put this in a file sudo_exec_selinux.te:
```bash
module sudo_exec 1.1;
require {
type unconfined_t;
type sudo_exec_t;
class file { read entrypoint };
}
#============= unconfined_t ==============
allow unconfined_t sudo_exec_t:file entrypoint;
```
Then run all of these commands as root:
```bash
$ checkmodule -M -m -o sudo_exec_selinux.mod sudo_exec_selinux.te
$ semodule_package -o sudo_exec_selinux.pp -m sudo_exec_selinux.mod
$ semodule -i sudo_exec_selinux.pp
```
### Troubleshooting: PAM session errors
If the PAM authentication doesn't work and you see errors for
`login:session-auth`, or similar, considering updating to `master`
and/or incorporating this commit https://github.com/jupyter/jupyterhub/commit/40368b8f555f04ffdd662ffe99d32392a088b1d2
and configuration option, `c.PAMAuthenticator.open_sessions = False`.

View File

@@ -14,3 +14,6 @@ Technical Reference
upgrading upgrading
templates templates
config-examples config-examples
config-ghoauth
config-proxy
config-sudo

View File

@@ -1,22 +1,26 @@
# Writing a custom Proxy implementation # Writing a custom Proxy implementation
JupyterHub 0.8 introduced the ability to write a custom implementation of the proxy. JupyterHub 0.8 introduced the ability to write a custom implementation of the
This enables deployments with different needs than the default proxy, proxy. This enables deployments with different needs than the default proxy,
configurable-http-proxy (CHP). configurable-http-proxy (CHP). CHP is a single-process nodejs proxy that they
CHP is a single-process nodejs proxy that they Hub manages by default as a subprocess Hub manages by default as a subprocess (it can be run externally, as well, and
(it can be run externally, as well, and typically is in production deployments). typically is in production deployments).
The upside to CHP, and why we use it by default, is that it's easy to install and run (if you have nodejs, you are set!). The upside to CHP, and why we use it by default, is that it's easy to install
The downsides are that it's a single process and does not support any persistence of the routing table. and run (if you have nodejs, you are set!). The downsides are that it's a
So if the proxy process dies, your whole JupyterHub instance is inaccessible until the Hub notices, restarts the proxy, and restores the routing table. single process and does not support any persistence of the routing table. So
For deployments that want to avoid such a single point of failure, if the proxy process dies, your whole JupyterHub instance is inaccessible
or leverage existing proxy infrastructure in their chosen deployment (such as Kubernetes ingress objects), until the Hub notices, restarts the proxy, and restores the routing table. For
the Proxy API provides a way to do that. deployments that want to avoid such a single point of failure, or leverage
existing proxy infrastructure in their chosen deployment (such as Kubernetes
ingress objects), the Proxy API provides a way to do that.
In general, for a proxy to be usable by JupyterHub, it must: In general, for a proxy to be usable by JupyterHub, it must:
1. support websockets without prior knowledge of the URL where websockets may occur 1. support websockets without prior knowledge of the URL where websockets may
2. support trie-based routing (i.e. allow different routes on `/foo` and `/foo/bar` and route based on specificity) occur
2. support trie-based routing (i.e. allow different routes on `/foo` and
`/foo/bar` and route based on specificity)
3. adding or removing a route should not cause existing connections to drop 3. adding or removing a route should not cause existing connections to drop
Optionally, if the JupyterHub deployment is to use host-based routing, Optionally, if the JupyterHub deployment is to use host-based routing,
@@ -35,10 +39,10 @@ class MyProxy(Proxy):
... ...
``` ```
## Starting and stopping the proxy ## Starting and stopping the proxy
If your proxy should be launched when the Hub starts, you must define how to start and stop your proxy: If your proxy should be launched when the Hub starts, you must define how
to start and stop your proxy:
```python ```python
from tornado import gen from tornado import gen
@@ -55,8 +59,8 @@ class MyProxy(Proxy):
These methods **may** be coroutines. These methods **may** be coroutines.
`c.Proxy.should_start` is a configurable flag that determines whether the Hub should call these methods when the Hub itself starts and stops. `c.Proxy.should_start` is a configurable flag that determines whether the
Hub should call these methods when the Hub itself starts and stops.
### Purely external proxies ### Purely external proxies
@@ -70,31 +74,30 @@ class MyProxy(Proxy):
should_start = False should_start = False
``` ```
## Routes
## Adding and removing routes At its most basic, a Proxy implementation defines a mechanism to add, remove,
and retrieve routes. A proxy that implements these three methods is complete.
At its most basic, a Proxy implementation defines a mechanism to add, remove, and retrieve routes.
A proxy that implements these three methods is complete.
Each of these methods **may** be a coroutine. Each of these methods **may** be a coroutine.
**Definition:** routespec **Definition:** routespec
A routespec, which will appear in these methods, is a string describing a route to be proxied, A routespec, which will appear in these methods, is a string describing a
such as `/user/name/`. A routespec will: route to be proxied, such as `/user/name/`. A routespec will:
1. always end with `/` 1. always end with `/`
2. always start with `/` if it is a path-based route `/proxy/path/` 2. always start with `/` if it is a path-based route `/proxy/path/`
3. precede the leading `/` with a host for host-based routing, e.g. `host.tld/proxy/path/` 3. precede the leading `/` with a host for host-based routing, e.g.
`host.tld/proxy/path/`
### Adding a route ### Adding a route
When adding a route, JupyterHub may pass a JSON-serializable dict as a `data` argument When adding a route, JupyterHub may pass a JSON-serializable dict as a `data`
that should be attacked to the proxy route. argument that should be attacked to the proxy route. When that route is
When that route is retrieved, the `data` argument should be returned as well. retrieved, the `data` argument should be returned as well. If your proxy
If your proxy implementation doesn't support storing data attached to routes, implementation doesn't support storing data attached to routes, then your
then your Python wrapper may have to handle storing the `data` piece itself, Python wrapper may have to handle storing the `data` piece itself, e.g in a
e.g in a simple file or database. simple file or database.
```python ```python
@gen.coroutine @gen.coroutine
@@ -113,12 +116,10 @@ proxy.add_route('/user/pgeorgiou/', 'http://127.0.0.1:1227',
{'user': 'pgeorgiou'}) {'user': 'pgeorgiou'})
``` ```
### Removing routes ### Removing routes
`delete_route()` is given a routespec to delete. `delete_route()` is given a routespec to delete. If there is no such route,
If there is no such route, `delete_route` should still succeed, `delete_route` should still succeed, but a warning may be issued.
but a warning may be issued.
```python ```python
@gen.coroutine @gen.coroutine
@@ -126,18 +127,17 @@ def delete_route(self, routespec):
"""Delete the route""" """Delete the route"""
``` ```
### Retrieving routes ### Retrieving routes
For retrieval, you only *need* to implement a single method that retrieves all routes. For retrieval, you only *need* to implement a single method that retrieves all
The return value for this function should be a dictionary, keyed by `routespect`, routes. The return value for this function should be a dictionary, keyed by
of dicts whose keys are the same three arguments passed to `add_route` `routespect`, of dicts whose keys are the same three arguments passed to
(`routespec`, `target`, `data`) `add_route` (`routespec`, `target`, `data`)
```python ```python
@gen.coroutine @gen.coroutine
def get_all_routes(self): def get_all_routes(self):
"""Return all routes, keyed by routespec"""" """Return all routes, keyed by routespec"""
``` ```
```python ```python
@@ -150,15 +150,15 @@ def get_all_routes(self):
} }
``` ```
## Note on activity tracking
JupyterHub can track activity of users, for use in services such as culling
#### Note on activity tracking idle servers. As of JupyterHub 0.8, this activity tracking is the
responsibility of the proxy. If your proxy implementation can track activity
JupyterHub can track activity of users, for use in services such as culling idle servers. to endpoints, it may add a `last_activity` key to the `data` of routes
As of JupyterHub 0.8, this activity tracking is the responsibility of the proxy. retrieved in `.get_all_routes()`. If present, the value of `last_activity`
If your proxy implementation can track activity to endpoints, should be an [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) UTC date
it may add a `last_activity` key to the `data` of routes retrieved in `.get_all_routes()`. string:
If present, the value of `last_activity` should be an [ISO8601](https://en.wikipedia.org/wiki/ISO_8601) UTC date string:
```python ```python
{ {
@@ -173,11 +173,9 @@ If present, the value of `last_activity` should be an [ISO8601](https://en.wikip
} }
``` ```
If the proxy does not track activity, then only activity to the Hub itself is
tracked, and services such as cull-idle will not work.
If the proxy does not track activity, then only activity to the Hub itself is tracked, Now that `notebook-5.0` tracks activity internally, we can retrieve activity
and services such as cull-idle will not work. information from the single-user servers instead, removing the need to track
activity in the proxy. But this is not yet implemented in JupyterHub 0.8.0.
Now that `notebook-5.0` tracks activity internally,
we can retrieve activity information from the single-user servers instead,
removing the need to track activity in the proxy.
But this is not yet implemented in JupyterHub 0.8.0.

View File

@@ -1,28 +1,55 @@
# Templates # Working with templates and UI
The pages of the JupyterHub application are generated from [Jinja](http://jinja.pocoo.org/) templates. These allow the header, for example, to be defined once and incorporated into all pages. By providing your own templates, you can have complete control over JupyterHub's appearance. The pages of the JupyterHub application are generated from
[Jinja](http://jinja.pocoo.org/) templates. These allow the header, for
example, to be defined once and incorporated into all pages. By providing
your own templates, you can have complete control over JupyterHub's
appearance.
## Custom Templates ## Custom Templates
JupyterHub will look for custom templates in all of the paths in the `JupyterHub.template_paths` configuration option, falling back on the [default templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates) if no custom template with that name is found. (This fallback behavior is new in version 0.9; previous versions searched only those paths explicitly included in `template_paths`.) This means you can override as many or as few templates as you desire. JupyterHub will look for custom templates in all of the paths in the
`JupyterHub.template_paths` configuration option, falling back on the
[default templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates)
if no custom template with that name is found. This fallback
behavior is new in version 0.9; previous versions searched only those paths
explicitly included in `template_paths`. You may override as many
or as few templates as you desire.
## Extending Templates ## Extending Templates
Jinja provides a mechanism to [extend templates](http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance). A base template can define a `block`, and child templates can replace or supplement the material in the block. The [JupyterHub templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates) make extensive use of this feature, which allows you to customize parts of the interface easily. Jinja provides a mechanism to [extend templates](http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance).
A base template can define a `block`, and child templates can replace or
supplement the material in the block. The
[JupyterHub templates](https://github.com/jupyterhub/jupyterhub/tree/master/share/jupyterhub/templates)
make extensive use of blocks, which allows you to customize parts of the
interface easily.
In general, a child template can extend a base template, `base.html`, by beginning with In general, a child template can extend a base template, `base.html`, by beginning with:
```
```html
{% extends "base.html" %} {% extends "base.html" %}
``` ```
This works, unless you are trying to extend the default template for the same file name. Starting in version 0.9, you may refer to the base file with a `templates/` prefix. Thus, if you are writing a custom `base.html`, start it with
``` This works, unless you are trying to extend the default template for the same
file name. Starting in version 0.9, you may refer to the base file with a
`templates/` prefix. Thus, if you are writing a custom `base.html`, start the
file with this block:
```html
{% extends "templates/base.html" %} {% extends "templates/base.html" %}
``` ```
By defining `block`s with same name as in the base template, child templates can replace those sections with custom content. The content from the base template can be included with the `{{ super() }}` directive.
By defining `block`s with same name as in the base template, child templates
can replace those sections with custom content. The content from the base
template can be included with the `{{ super() }}` directive.
### Example ### Example
To add an additional message to the spawn-pending page, below the existing text about the server starting up, place this content in a file named `spawn_pending.html` in a directory included in the `JupyterHub.template_paths` configuration option. To add an additional message to the spawn-pending page, below the existing
text about the server starting up, place this content in a file named
`spawn_pending.html` in a directory included in the
`JupyterHub.template_paths` configuration option.
```html ```html
{% extends "templates/spawn_pending.html" %} {% extends "templates/spawn_pending.html" %}

View File

@@ -1,4 +1,4 @@
.. upgrade-dot-eight: .. _upgrade-dot-eight:
Upgrading to JupyterHub version 0.8 Upgrading to JupyterHub version 0.8
=================================== ===================================

View File

@@ -5,7 +5,7 @@ for external services that may not be otherwise integrated with JupyterHub.
The main feature this enables is using JupyterHub like a 'regular' OAuth 2 The main feature this enables is using JupyterHub like a 'regular' OAuth 2
provider for services running anywhere. provider for services running anywhere.
There are two examples here. `whoami-oauth` uses `jupyterhub.services.HubOAuthenticated` There are two examples here. `whoami-oauth` (in the service-whoami directory) uses `jupyterhub.services.HubOAuthenticated`
to authenticate requests with the Hub for a service run on its own host. to authenticate requests with the Hub for a service run on its own host.
This is an implementation of OAuth 2.0 provided by the jupyterhub package, This is an implementation of OAuth 2.0 provided by the jupyterhub package,
which configures all of the necessary URLs from environment variables. which configures all of the necessary URLs from environment variables.

View File

@@ -18,4 +18,4 @@ export JUPYTERHUB_OAUTH_CALLBACK_URL="$JUPYTERHUB_SERVICE_URL/oauth_callback"
export JUPYTERHUB_HOST='http://127.0.0.1:8000' export JUPYTERHUB_HOST='http://127.0.0.1:8000'
# launch the service # launch the service
exec python3 whoami-oauth.py exec python3 ../service-whoami/whoami-oauth.py

View File

@@ -1,46 +0,0 @@
"""An example service authenticating with the Hub.
This example service serves `/services/whoami/`,
authenticated with the Hub,
showing the user their own info.
"""
from getpass import getuser
import json
import os
from urllib.parse import urlparse
from tornado.ioloop import IOLoop
from tornado import log
from tornado.httpserver import HTTPServer
from tornado.web import RequestHandler, Application, authenticated
from jupyterhub.services.auth import HubOAuthenticated, HubOAuthCallbackHandler
from jupyterhub.utils import url_path_join
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
hub_users = {getuser()} # the users allowed to access this service
@authenticated
def get(self):
user_model = self.get_current_user()
self.set_header('content-type', 'application/json')
self.write(json.dumps(user_model, indent=1, sort_keys=True))
def main():
log.enable_pretty_logging()
app = Application([
(os.environ['JUPYTERHUB_SERVICE_PREFIX'], WhoAmIHandler),
(url_path_join(os.environ['JUPYTERHUB_SERVICE_PREFIX'], 'oauth_callback'), HubOAuthCallbackHandler),
(r'.*', WhoAmIHandler),
], cookie_secret=os.urandom(32))
http_server = HTTPServer(app)
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
log.app_log.info("Running whoami service on %s", os.environ['JUPYTERHUB_SERVICE_URL'])
http_server.listen(url.port, url.hostname)
IOLoop.current().start()
if __name__ == '__main__':
main()

View File

@@ -26,6 +26,10 @@ After logging in with your local-system credentials, you should see a JSON dump
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)). This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
You may set the `hub_users` configuration in the service script
to restrict access to the service to a whitelist of allowed users.
By default, any authenticated user is allowed.
A similar service could be run externally, by setting the JupyterHub service environment variables: A similar service could be run externally, by setting the JupyterHub service environment variables:
JUPYTERHUB_API_TOKEN JUPYTERHUB_API_TOKEN

View File

@@ -17,7 +17,11 @@ from jupyterhub.services.auth import HubOAuthenticated, HubOAuthCallbackHandler
from jupyterhub.utils import url_path_join from jupyterhub.utils import url_path_join
class WhoAmIHandler(HubOAuthenticated, RequestHandler): class WhoAmIHandler(HubOAuthenticated, RequestHandler):
hub_users = {getuser()} # the users allowed to access this service # hub_users can be a set of users who are allowed to access the service
# `getuser()` here would mean only the user who started the service
# can access the service:
# hub_users = {getuser()}
@authenticated @authenticated
def get(self): def get(self):

View File

@@ -15,7 +15,11 @@ from jupyterhub.services.auth import HubAuthenticated
class WhoAmIHandler(HubAuthenticated, RequestHandler): class WhoAmIHandler(HubAuthenticated, RequestHandler):
hub_users = {getuser()} # the users allowed to access me # hub_users can be a set of users who are allowed to access the service
# `getuser()` here would mean only the user who started the service
# can access the service:
# hub_users = {getuser()}
@authenticated @authenticated
def get(self): def get(self):

View File

@@ -7,10 +7,17 @@ version_info = (
0, 0,
9, 9,
0, 0,
'b1', "b3", # release (b1, rc1)
# "dev", # dev
) )
__version__ = '.'.join(map(str, version_info)) # pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1
# 0.1.0a1
# 0.1.0b1.dev
# 0.1.0.dev
__version__ = ".".join(map(str, version_info[:3])) + ".".join(version_info[3:])
def _check_version(hub_version, singleuser_version, log): def _check_version(hub_version, singleuser_version, log):

View File

@@ -0,0 +1,24 @@
"""Add APIToken.expires_at
Revision ID: 896818069c98
Revises: d68c98b66cd4
Create Date: 2018-05-07 11:35:58.050542
"""
# revision identifiers, used by Alembic.
revision = '896818069c98'
down_revision = 'd68c98b66cd4'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('api_tokens', sa.Column('expires_at', sa.DateTime(), nullable=True))
def downgrade():
op.drop_column('api_tokens', 'expires_at')

View File

@@ -6,6 +6,7 @@ import json
from http.client import responses from http.client import responses
from sqlalchemy.exc import SQLAlchemyError
from tornado import web from tornado import web
from .. import orm from .. import orm
@@ -87,6 +88,10 @@ class APIHandler(BaseHandler):
if reason: if reason:
status_message = reason status_message = reason
if exception and isinstance(exception, SQLAlchemyError):
self.log.warning("Rolling back session due to database error %s", exception)
self.db.rollback()
self.set_header('Content-Type', 'application/json') self.set_header('Content-Type', 'application/json')
# allow setting headers from exceptions # allow setting headers from exceptions
# since exception handler clears headers # since exception handler clears headers
@@ -115,16 +120,20 @@ class APIHandler(BaseHandler):
def token_model(self, token): def token_model(self, token):
"""Get the JSON model for an APIToken""" """Get the JSON model for an APIToken"""
expires_at = None
if isinstance(token, orm.APIToken): if isinstance(token, orm.APIToken):
kind = 'api_token' kind = 'api_token'
extra = { extra = {
'note': token.note, 'note': token.note,
} }
expires_at = token.expires_at
elif isinstance(token, orm.OAuthAccessToken): elif isinstance(token, orm.OAuthAccessToken):
kind = 'oauth' kind = 'oauth'
extra = { extra = {
'oauth_client': token.client.description or token.client.client_id, 'oauth_client': token.client.description or token.client.client_id,
} }
if token.expires_at:
expires_at = datetime.fromtimestamp(token.expires_at)
else: else:
raise TypeError( raise TypeError(
"token must be an APIToken or OAuthAccessToken, not %s" "token must be an APIToken or OAuthAccessToken, not %s"

View File

@@ -4,6 +4,7 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import asyncio import asyncio
from datetime import datetime
import json import json
from async_generator import aclosing from async_generator import aclosing
@@ -201,13 +202,30 @@ class UserTokenListAPIHandler(APIHandler):
user = self.find_user(name) user = self.find_user(name)
if not user: if not user:
raise web.HTTPError(404, "No such user: %s" % name) raise web.HTTPError(404, "No such user: %s" % name)
now = datetime.utcnow()
api_tokens = [] api_tokens = []
def sort_key(token): def sort_key(token):
return token.last_activity or token.created return token.last_activity or token.created
for token in sorted(user.api_tokens, key=sort_key): for token in sorted(user.api_tokens, key=sort_key):
if token.expires_at and token.expires_at < now:
# exclude expired tokens
self.db.delete(token)
self.db.commit()
continue
api_tokens.append(self.token_model(token)) api_tokens.append(self.token_model(token))
oauth_tokens = [] oauth_tokens = []
# OAuth tokens use integer timestamps
now_timestamp = now.timestamp()
for token in sorted(user.oauth_tokens, key=sort_key): for token in sorted(user.oauth_tokens, key=sort_key):
if token.expires_at and token.expires_at < now_timestamp:
# exclude expired tokens
self.db.delete(token)
self.db.commit()
continue
oauth_tokens.append(self.token_model(token)) oauth_tokens.append(self.token_model(token))
self.write(json.dumps({ self.write(json.dumps({
'api_tokens': api_tokens, 'api_tokens': api_tokens,
@@ -252,7 +270,7 @@ class UserTokenListAPIHandler(APIHandler):
if requester is not user: if requester is not user:
note += " by %s %s" % (kind, requester.name) note += " by %s %s" % (kind, requester.name)
api_token = user.new_api_token(note=note) api_token = user.new_api_token(note=note, expires_in=body.get('expires_in', None))
if requester is not user: if requester is not user:
self.log.info("%s %s requested API token for %s", kind.title(), requester.name, user.name) self.log.info("%s %s requested API token for %s", kind.title(), requester.name, user.name)
else: else:

View File

@@ -9,6 +9,7 @@ import atexit
import binascii import binascii
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import partial
from getpass import getuser from getpass import getuser
import logging import logging
from operator import itemgetter from operator import itemgetter
@@ -26,7 +27,7 @@ if sys.version_info[:2] < (3, 3):
from dateutil.parser import parse as parse_date from dateutil.parser import parse as parse_date
from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError, SQLAlchemyError
from tornado.httpclient import AsyncHTTPClient from tornado.httpclient import AsyncHTTPClient
import tornado.httpserver import tornado.httpserver
@@ -286,6 +287,10 @@ class JupyterHub(Application):
def _template_paths_default(self): def _template_paths_default(self):
return [os.path.join(self.data_files_path, 'templates')] return [os.path.join(self.data_files_path, 'templates')]
template_vars = Dict(
help="Extra variables to be passed into jinja templates",
).tag(config=True)
confirm_no_ssl = Bool(False, confirm_no_ssl = Bool(False,
help="""DEPRECATED: does nothing""" help="""DEPRECATED: does nothing"""
).tag(config=True) ).tag(config=True)
@@ -310,6 +315,7 @@ class JupyterHub(Application):
should be accessed by users. should be accessed by users.
.. deprecated: 0.9 .. deprecated: 0.9
Use JupyterHub.bind_url
""" """
).tag(config=True) ).tag(config=True)
@@ -325,26 +331,6 @@ class JupyterHub(Application):
""" """
).tag(config=True) ).tag(config=True)
@observe('ip', 'port')
def _ip_port_changed(self, change):
urlinfo = urlparse(self.bind_url)
urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
self.bind_url = urlunparse(urlinfo)
bind_url = Unicode(
"http://127.0.0.1:8000",
help="""The public facing URL of the whole JupyterHub application.
This is the address on which the proxy will bind.
Sets protocol, ip, base_url
"""
).tag(config=True)
@observe('bind_url')
def _bind_url_changed(self, change):
urlinfo = urlparse(change.new)
self.base_url = urlinfo.path
base_url = URLPrefix('/', base_url = URLPrefix('/',
help="""The base URL of the entire application. help="""The base URL of the entire application.
@@ -361,6 +347,25 @@ class JupyterHub(Application):
# call validate to ensure leading/trailing slashes # call validate to ensure leading/trailing slashes
return JupyterHub.base_url.validate(self, urlparse(self.bind_url).path) return JupyterHub.base_url.validate(self, urlparse(self.bind_url).path)
@observe('ip', 'port', 'base_url')
def _url_part_changed(self, change):
"""propagate deprecated ip/port/base_url config to the bind_url"""
urlinfo = urlparse(self.bind_url)
urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
urlinfo = urlinfo._replace(path=self.base_url)
bind_url = urlunparse(urlinfo)
if bind_url != self.bind_url:
self.bind_url = bind_url
bind_url = Unicode(
"http://:8000",
help="""The public facing URL of the whole JupyterHub application.
This is the address on which the proxy will bind.
Sets protocol, ip, base_url
"""
).tag(config=True)
subdomain_host = Unicode('', subdomain_host = Unicode('',
help="""Run single-user servers on subdomains of this host. help="""Run single-user servers on subdomains of this host.
@@ -932,6 +937,24 @@ class JupyterHub(Application):
handlers[i] = tuple(lis) handlers[i] = tuple(lis)
return handlers return handlers
extra_handlers = List(
help="""
Register extra tornado Handlers for jupyterhub.
Should be of the form ``("<regex>", Handler)``
The Hub prefix will be added, so `/my-page` will be served at `/hub/my-page`.
""",
).tag(config=True)
default_url = Unicode(
help="""
The default URL for users when they arrive (e.g. when user directs to "/")
By default, redirects users to their own server.
""",
).tag(config=True)
def init_handlers(self): def init_handlers(self):
h = [] h = []
# load handlers from the authenticator # load handlers from the authenticator
@@ -940,6 +963,9 @@ class JupyterHub(Application):
h.extend(handlers.default_handlers) h.extend(handlers.default_handlers)
h.extend(apihandlers.default_handlers) h.extend(apihandlers.default_handlers)
# add any user configurable handlers.
h.extend(self.extra_handlers)
h.append((r'/logo', LogoHandler, {'path': self.logo_file})) h.append((r'/logo', LogoHandler, {'path': self.logo_file}))
self.handlers = self.add_url_prefix(self.hub_prefix, h) self.handlers = self.add_url_prefix(self.hub_prefix, h)
# some extra handlers, outside hub_prefix # some extra handlers, outside hub_prefix
@@ -1264,10 +1290,23 @@ class JupyterHub(Application):
self.log.debug("Not duplicating token %s", orm_token) self.log.debug("Not duplicating token %s", orm_token)
db.commit() db.commit()
# purge expired tokens hourly
purge_expired_tokens_interval = 3600
async def init_api_tokens(self): async def init_api_tokens(self):
"""Load predefined API tokens (for services) into database""" """Load predefined API tokens (for services) into database"""
await self._add_tokens(self.service_tokens, kind='service') await self._add_tokens(self.service_tokens, kind='service')
await self._add_tokens(self.api_tokens, kind='user') await self._add_tokens(self.api_tokens, kind='user')
purge_expired_tokens = partial(orm.APIToken.purge_expired, self.db)
purge_expired_tokens()
# purge expired tokens hourly
# we don't need to be prompt about this
# because expired tokens cannot be used anyway
pc = PeriodicCallback(
purge_expired_tokens,
1e3 * self.purge_expired_tokens_interval,
)
pc.start()
def init_services(self): def init_services(self):
self._service_map.clear() self._service_map.clear()
@@ -1454,10 +1493,16 @@ class JupyterHub(Application):
oauth_client_ids.add(spawner.oauth_client_id) oauth_client_ids.add(spawner.oauth_client_id)
client_store = self.oauth_provider.client_authenticator.client_store client_store = self.oauth_provider.client_authenticator.client_store
for oauth_client in self.db.query(orm.OAuthClient): for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
if oauth_client.identifier not in oauth_client_ids: if oauth_client.identifier not in oauth_client_ids:
self.log.warning("Deleting OAuth client %s", oauth_client.identifier) self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
self.db.delete(oauth_client) self.db.delete(oauth_client)
# Some deployments that create temporary users may have left *lots*
# of entries here.
# Don't try to delete them all in one transaction,
# commit at most 100 deletions at a time.
if i % 100 == 0:
self.db.commit()
self.db.commit() self.db.commit()
def init_proxy(self): def init_proxy(self):
@@ -1517,6 +1562,7 @@ class JupyterHub(Application):
authenticator=self.authenticator, authenticator=self.authenticator,
spawner_class=self.spawner_class, spawner_class=self.spawner_class,
base_url=self.base_url, base_url=self.base_url,
default_url=self.default_url,
cookie_secret=self.cookie_secret, cookie_secret=self.cookie_secret,
cookie_max_age_days=self.cookie_max_age_days, cookie_max_age_days=self.cookie_max_age_days,
redirect_to_server=self.redirect_to_server, redirect_to_server=self.redirect_to_server,
@@ -1526,6 +1572,7 @@ class JupyterHub(Application):
static_url_prefix=url_path_join(self.hub.base_url, 'static/'), static_url_prefix=url_path_join(self.hub.base_url, 'static/'),
static_handler_class=CacheControlStaticFilesHandler, static_handler_class=CacheControlStaticFilesHandler,
template_path=self.template_paths, template_path=self.template_paths,
template_vars=self.template_vars,
jinja2_env=jinja_env, jinja2_env=jinja_env,
version_hash=version_hash, version_hash=version_hash,
subdomain_host=self.subdomain_host, subdomain_host=self.subdomain_host,
@@ -1580,6 +1627,33 @@ class JupyterHub(Application):
cfg.JupyterHub.merge(cfg.JupyterHubApp) cfg.JupyterHub.merge(cfg.JupyterHubApp)
self.update_config(cfg) self.update_config(cfg)
self.write_pid_file() self.write_pid_file()
def _log_cls(name, cls):
"""Log a configured class
Logs the class and version (if found) of Authenticator
and Spawner
"""
# try to guess the version from the top-level module
# this will work often enough to be useful.
# no need to be perfect.
if cls.__module__:
mod = sys.modules.get(cls.__module__.split('.')[0])
version = getattr(mod, '__version__', '')
if version:
version = '-{}'.format(version)
else:
version = ''
self.log.info(
"Using %s: %s.%s%s",
name,
cls.__module__ or '',
cls.__name__,
version,
)
_log_cls("Authenticator", self.authenticator_class)
_log_cls("Spawner", self.spawner_class)
self.init_pycurl() self.init_pycurl()
self.init_secrets() self.init_secrets()
self.init_db() self.init_db()
@@ -1718,7 +1792,13 @@ class JupyterHub(Application):
self.statsd.gauge('users.running', users_count) self.statsd.gauge('users.running', users_count)
self.statsd.gauge('users.active', active_users_count) self.statsd.gauge('users.active', active_users_count)
self.db.commit() try:
self.db.commit()
except SQLAlchemyError:
self.log.exception("Rolling back session due to database error")
self.db.rollback()
return
await self.proxy.check_routes(self.users, self._service_map, routes) await self.proxy.check_routes(self.users, self._service_map, routes)
async def start(self): async def start(self):

View File

@@ -49,7 +49,7 @@ class Authenticator(LoggingConfigurable):
Encrypting auth_state requires the cryptography package. Encrypting auth_state requires the cryptography package.
Additionally, the JUPYTERHUB_CRYPTO_KEY envirionment variable must Additionally, the JUPYTERHUB_CRYPT_KEY environment variable must
contain one (or more, separated by ;) 32B encryption keys. contain one (or more, separated by ;) 32B encryption keys.
These can be either base64 or hex-encoded. These can be either base64 or hex-encoded.

View File

@@ -15,6 +15,7 @@ import uuid
from jinja2 import TemplateNotFound from jinja2 import TemplateNotFound
from sqlalchemy.exc import SQLAlchemyError
from tornado.log import app_log from tornado.log import app_log
from tornado.httputil import url_concat, HTTPHeaders from tornado.httputil import url_concat, HTTPHeaders
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
@@ -61,6 +62,10 @@ class BaseHandler(RequestHandler):
def base_url(self): def base_url(self):
return self.settings.get('base_url', '/') return self.settings.get('base_url', '/')
@property
def default_url(self):
return self.settings.get('default_url', '')
@property @property
def version_hash(self): def version_hash(self):
return self.settings.get('version_hash', '') return self.settings.get('version_hash', '')
@@ -260,10 +265,17 @@ class BaseHandler(RequestHandler):
def get_current_user(self): def get_current_user(self):
"""get current username""" """get current username"""
user = self.get_current_user_token() if not hasattr(self, '_jupyterhub_user'):
if user is not None: try:
return user user = self.get_current_user_token()
return self.get_current_user_cookie() if user is None:
user = self.get_current_user_cookie()
self._jupyterhub_user = user
except Exception:
# don't let errors here raise more than once
self._jupyterhub_user = None
raise
return self._jupyterhub_user
def find_user(self, name): def find_user(self, name):
"""Get a user by name """Get a user by name
@@ -413,10 +425,20 @@ class BaseHandler(RequestHandler):
- else: /hub/home - else: /hub/home
""" """
next_url = self.get_argument('next', default='') next_url = self.get_argument('next', default='')
if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)): if (next_url + '/').startswith(
(
'%s://%s/' % (self.request.protocol, self.request.host),
'//%s/' % self.request.host,
)
):
# treat absolute URLs for our host as absolute paths: # treat absolute URLs for our host as absolute paths:
next_url = urlparse(next_url).path parsed = urlparse(next_url)
if next_url and not next_url.startswith('/'): next_url = parsed.path
if parsed.query:
next_url = next_url + '?' + parsed.query
if parsed.hash:
next_url = next_url + '#' + parsed.hash
if next_url and (urlparse(next_url).netloc or not next_url.startswith('/')):
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url) self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
next_url = '' next_url = ''
if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')): if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):
@@ -429,9 +451,14 @@ class BaseHandler(RequestHandler):
self.request.uri, next_url, self.request.uri, next_url,
) )
if not next_url:
# custom default URL
next_url = self.default_url
if not next_url: if not next_url:
# default URL after login # default URL after login
# if self.redirect_to_server, default login URL initiates spawn # if self.redirect_to_server, default login URL initiates spawn,
# otherwise send to Hub home page (control panel)
if user and self.redirect_to_server: if user and self.redirect_to_server:
next_url = user.url next_url = user.url
else: else:
@@ -752,7 +779,7 @@ class BaseHandler(RequestHandler):
@property @property
def template_namespace(self): def template_namespace(self):
user = self.get_current_user() user = self.get_current_user()
return dict( ns = dict(
base_url=self.hub.base_url, base_url=self.hub.base_url,
prefix=self.base_url, prefix=self.base_url,
user=user, user=user,
@@ -762,6 +789,9 @@ class BaseHandler(RequestHandler):
static_url=self.static_url, static_url=self.static_url,
version_hash=self.version_hash, version_hash=self.version_hash,
) )
if self.settings['template_vars']:
ns.update(self.settings['template_vars'])
return ns
def write_error(self, status_code, **kwargs): def write_error(self, status_code, **kwargs):
"""render custom error pages""" """render custom error pages"""
@@ -782,6 +812,10 @@ class BaseHandler(RequestHandler):
if reason: if reason:
message = reasons.get(reason, reason) message = reasons.get(reason, reason)
if exception and isinstance(exception, SQLAlchemyError):
self.log.warning("Rolling back session due to database error %s", exception)
self.db.rollback()
# build template namespace # build template namespace
ns = dict( ns = dict(
status_code=status_code, status_code=status_code,

View File

@@ -3,6 +3,7 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
from collections import defaultdict
from datetime import datetime from datetime import datetime
from http.client import responses from http.client import responses
@@ -30,7 +31,9 @@ class RootHandler(BaseHandler):
""" """
def get(self): def get(self):
user = self.get_current_user() user = self.get_current_user()
if user: if self.default_url:
url = self.default_url
elif user:
url = self.get_next_url(user) url = self.get_next_url(user)
else: else:
url = self.settings['login_url'] url = self.settings['login_url']
@@ -229,12 +232,24 @@ class TokenPageHandler(BaseHandler):
token.last_activity or never, token.last_activity or never,
token.created or never, token.created or never,
) )
api_tokens = sorted(user.api_tokens, key=sort_key, reverse=True)
now = datetime.utcnow()
api_tokens = []
for token in sorted(user.api_tokens, key=sort_key, reverse=True):
if token.expires_at and token.expires_at < now:
self.db.delete(token)
self.db.commit()
continue
api_tokens.append(token)
# group oauth client tokens by client id # group oauth client tokens by client id
from collections import defaultdict
oauth_tokens = defaultdict(list) oauth_tokens = defaultdict(list)
for token in user.oauth_tokens: for token in user.oauth_tokens:
if token.expires_at and token.expires_at < now:
self.log.warning("Deleting expired token")
self.db.delete(token)
self.db.commit()
continue
if not token.client_id: if not token.client_id:
# token should have been deleted when client was deleted # token should have been deleted when client was deleted
self.log.warning("Deleting stale oauth token for %s", user.name) self.log.warning("Deleting stale oauth token for %s", user.name)
@@ -260,7 +275,7 @@ class TokenPageHandler(BaseHandler):
token = tokens[0] token = tokens[0]
oauth_clients.append({ oauth_clients.append({
'client': token.client, 'client': token.client,
'description': token.client.description or token.client.client_id, 'description': token.client.description or token.client.identifier,
'created': created, 'created': created,
'last_activity': last_activity, 'last_activity': last_activity,
'tokens': tokens, 'tokens': tokens,

View File

@@ -3,7 +3,7 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
from datetime import datetime from datetime import datetime, timedelta
import enum import enum
import json import json
@@ -14,7 +14,7 @@ from tornado.log import app_log
from sqlalchemy.types import TypeDecorator, TEXT, LargeBinary from sqlalchemy.types import TypeDecorator, TEXT, LargeBinary
from sqlalchemy import ( from sqlalchemy import (
create_engine, event, inspect, create_engine, event, exc, inspect, or_, select,
Column, Integer, ForeignKey, Unicode, Boolean, Column, Integer, ForeignKey, Unicode, Boolean,
DateTime, Enum, Table, DateTime, Enum, Table,
) )
@@ -33,6 +33,9 @@ from .utils import (
new_token, hash_token, compare_token, new_token, hash_token, compare_token,
) )
# top-level variable for easier mocking in tests
utcnow = datetime.utcnow
class JSONDict(TypeDecorator): class JSONDict(TypeDecorator):
"""Represents an immutable structure as a json-encoded string. """Represents an immutable structure as a json-encoded string.
@@ -176,12 +179,12 @@ class User(Base):
running=sum(bool(s.server) for s in self._orm_spawners), running=sum(bool(s.server) for s in self._orm_spawners),
) )
def new_api_token(self, token=None, generated=True, note=''): def new_api_token(self, token=None, **kwargs):
"""Create a new API token """Create a new API token
If `token` is given, load that token. If `token` is given, load that token.
""" """
return APIToken.new(token=token, user=self, note=note, generated=generated) return APIToken.new(token=token, user=self, **kwargs)
@classmethod @classmethod
def find(cls, db, name): def find(cls, db, name):
@@ -242,11 +245,11 @@ class Service(Base):
server = relationship(Server, cascade='all') server = relationship(Server, cascade='all')
pid = Column(Integer) pid = Column(Integer)
def new_api_token(self, token=None, generated=True, note=''): def new_api_token(self, token=None, **kwargs):
"""Create a new API token """Create a new API token
If `token` is given, load that token. If `token` is given, load that token.
""" """
return APIToken.new(token=token, service=self, note=note, generated=generated) return APIToken.new(token=token, service=self, **kwargs)
@classmethod @classmethod
def find(cls, db, name): def find(cls, db, name):
@@ -348,6 +351,7 @@ class APIToken(Hashed, Base):
# token metadata for bookkeeping # token metadata for bookkeeping
created = Column(DateTime, default=datetime.utcnow) created = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, default=None, nullable=True)
last_activity = Column(DateTime) last_activity = Column(DateTime)
note = Column(Unicode(1023)) note = Column(Unicode(1023))
@@ -369,6 +373,22 @@ class APIToken(Hashed, Base):
name=name, name=name,
) )
@classmethod
def purge_expired(cls, db):
"""Purge expired API Tokens from the database"""
now = utcnow()
deleted = False
for token in (
db.query(cls)
.filter(cls.expires_at != None)
.filter(cls.expires_at < now)
):
app_log.debug("Purging expired %s", token)
deleted = True
db.delete(token)
if deleted:
db.commit()
@classmethod @classmethod
def find(cls, db, token, *, kind=None): def find(cls, db, token, *, kind=None):
"""Find a token object by value. """Find a token object by value.
@@ -379,6 +399,9 @@ class APIToken(Hashed, Base):
`kind='service'` only returns API tokens for services `kind='service'` only returns API tokens for services
""" """
prefix_match = cls.find_prefix(db, token) prefix_match = cls.find_prefix(db, token)
prefix_match = prefix_match.filter(
or_(cls.expires_at == None, cls.expires_at >= utcnow())
)
if kind == 'user': if kind == 'user':
prefix_match = prefix_match.filter(cls.user_id != None) prefix_match = prefix_match.filter(cls.user_id != None)
elif kind == 'service': elif kind == 'service':
@@ -390,7 +413,8 @@ class APIToken(Hashed, Base):
return orm_token return orm_token
@classmethod @classmethod
def new(cls, token=None, user=None, service=None, note='', generated=True): def new(cls, token=None, user=None, service=None, note='', generated=True,
expires_in=None):
"""Generate a new API token for a user or service""" """Generate a new API token for a user or service"""
assert user or service assert user or service
assert not (user and service) assert not (user and service)
@@ -412,6 +436,8 @@ class APIToken(Hashed, Base):
else: else:
assert service.id is not None assert service.id is not None
orm_token.service = service orm_token.service = service
if expires_in is not None:
orm_token.expires_at = utcnow() + timedelta(seconds=expires_in)
db.add(orm_token) db.add(orm_token)
db.commit() db.commit()
return token return token
@@ -549,7 +575,7 @@ def _expire_relationship(target, relationship_prop):
def _notify_deleted_relationships(session, obj): def _notify_deleted_relationships(session, obj):
"""Expire relationships when an object becomes deleted """Expire relationships when an object becomes deleted
Needed for Needed to keep relationships up to date.
""" """
mapper = inspect(obj).mapper mapper = inspect(obj).mapper
for prop in mapper.relationships: for prop in mapper.relationships:
@@ -557,6 +583,52 @@ def _notify_deleted_relationships(session, obj):
_expire_relationship(obj, prop) _expire_relationship(obj, prop)
def register_ping_connection(engine):
"""Check connections before using them.
Avoids database errors when using stale connections.
From SQLAlchemy docs on pessimistic disconnect handling:
https://docs.sqlalchemy.org/en/rel_1_1/core/pooling.html#disconnect-handling-pessimistic
"""
@event.listens_for(engine, "engine_connect")
def ping_connection(connection, branch):
if branch:
# "branch" refers to a sub-connection of a connection,
# we don't want to bother pinging on these.
return
# turn off "close with result". This flag is only used with
# "connectionless" execution, otherwise will be False in any case
save_should_close_with_result = connection.should_close_with_result
connection.should_close_with_result = False
try:
# run a SELECT 1. use a core select() so that
# the SELECT of a scalar value without a table is
# appropriately formatted for the backend
connection.scalar(select([1]))
except exc.DBAPIError as err:
# catch SQLAlchemy's DBAPIError, which is a wrapper
# for the DBAPI's exception. It includes a .connection_invalidated
# attribute which specifies if this connection is a "disconnect"
# condition, which is based on inspection of the original exception
# by the dialect in use.
if err.connection_invalidated:
app_log.error("Database connection error, attempting to reconnect: %s", err)
# run the same SELECT again - the connection will re-validate
# itself and establish a new connection. The disconnect detection
# here also causes the whole connection pool to be invalidated
# so that all stale connections are discarded.
connection.scalar(select([1]))
else:
raise
finally:
# restore "close with result"
connection.should_close_with_result = save_should_close_with_result
def check_db_revision(engine): def check_db_revision(engine):
"""Check the JupyterHub database revision """Check the JupyterHub database revision
@@ -635,10 +707,12 @@ def mysql_large_prefix_check(engine):
else: else:
return False return False
def add_row_format(base): def add_row_format(base):
for t in base.metadata.tables.values(): for t in base.metadata.tables.values():
t.dialect_kwargs['mysql_ROW_FORMAT'] = 'DYNAMIC' t.dialect_kwargs['mysql_ROW_FORMAT'] = 'DYNAMIC'
def new_session_factory(url="sqlite:///:memory:", def new_session_factory(url="sqlite:///:memory:",
reset=False, reset=False,
expire_on_commit=False, expire_on_commit=False,
@@ -658,6 +732,9 @@ def new_session_factory(url="sqlite:///:memory:",
kwargs.setdefault('poolclass', StaticPool) kwargs.setdefault('poolclass', StaticPool)
engine = create_engine(url, **kwargs) engine = create_engine(url, **kwargs)
# enable pessimistic disconnect handling
register_ping_connection(engine)
if reset: if reset:
Base.metadata.drop_all(engine) Base.metadata.drop_all(engine)

View File

@@ -703,10 +703,11 @@ class Spawner(LoggingConfigurable):
def run_post_stop_hook(self): def run_post_stop_hook(self):
"""Run the post_stop_hook if defined""" """Run the post_stop_hook if defined"""
try: if self.post_stop_hook is not None:
return self.post_stop_hook(self) try:
except Exception: return self.post_stop_hook(self)
self.log.exception("post_stop_hook failed with exception: %s", self) except Exception:
self.log.exception("post_stop_hook failed with exception: %s", self)
@property @property
def _progress_url(self): def _progress_url(self):

View File

@@ -8,19 +8,22 @@ from subprocess import check_output, Popen, PIPE
from tempfile import NamedTemporaryFile, TemporaryDirectory from tempfile import NamedTemporaryFile, TemporaryDirectory
from unittest.mock import patch from unittest.mock import patch
from tornado import gen
import pytest import pytest
from tornado import gen
from traitlets.config import Config
from .mocking import MockHub from .mocking import MockHub
from .test_api import add_user from .test_api import add_user
from .. import orm from .. import orm
from ..app import COOKIE_SECRET_BYTES from ..app import COOKIE_SECRET_BYTES, JupyterHub
def test_help_all(): def test_help_all():
out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace') out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
assert '--ip' in out assert '--ip' in out
assert '--JupyterHub.ip' in out assert '--JupyterHub.ip' in out
def test_token_app(): def test_token_app():
cmd = [sys.executable, '-m', 'jupyterhub', 'token'] cmd = [sys.executable, '-m', 'jupyterhub', 'token']
out = check_output(cmd + ['--help-all']).decode('utf8', 'replace') out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
@@ -30,6 +33,7 @@ def test_token_app():
out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip() out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip()
assert re.match(r'^[a-z0-9]+$', out) assert re.match(r'^[a-z0-9]+$', out)
def test_generate_config(): def test_generate_config():
with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf: with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
cfg_file = tf.name cfg_file = tf.name
@@ -218,3 +222,51 @@ def test_resume_spawners(tmpdir, request):
assert not user.running assert not user.running
assert user.spawner.server is None assert user.spawner.server is None
assert list(db.query(orm.Server)) == [] assert list(db.query(orm.Server)) == []
@pytest.mark.parametrize(
'hub_config, expected',
[
(
{'ip': '0.0.0.0'},
{'bind_url': 'http://0.0.0.0:8000/'},
),
(
{'port': 123, 'base_url': '/prefix'},
{
'bind_url': 'http://:123/prefix/',
'base_url': '/prefix/',
},
),
(
{'bind_url': 'http://0.0.0.0:12345/sub'},
{'base_url': '/sub/'},
),
(
# no config, test defaults
{},
{
'base_url': '/',
'bind_url': 'http://:8000',
'ip': '',
'port': 8000,
},
),
]
)
def test_url_config(hub_config, expected):
# construct the config object
cfg = Config()
for key, value in hub_config.items():
cfg.JupyterHub[key] = value
# instantiate the Hub and load config
app = JupyterHub(config=cfg)
# validate config
for key, value in hub_config.items():
if key not in expected:
assert getattr(app, key) == value
# validate additional properties
for key, value in expected.items():
assert getattr(app, key) == value

View File

@@ -3,8 +3,10 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
from datetime import datetime, timedelta
import os import os
import socket import socket
from unittest import mock
import pytest import pytest
from tornado import gen from tornado import gen
@@ -99,6 +101,32 @@ def test_tokens(db):
assert len(user.api_tokens) == 3 assert len(user.api_tokens) == 3
def test_token_expiry(db):
user = orm.User(name='parker')
db.add(user)
db.commit()
now = datetime.utcnow()
token = user.new_api_token(expires_in=60)
orm_token = orm.APIToken.find(db, token=token)
assert orm_token
assert orm_token.expires_at is not None
# approximate range
assert orm_token.expires_at > now + timedelta(seconds=50)
assert orm_token.expires_at < now + timedelta(seconds=70)
the_future = mock.patch('jupyterhub.orm.utcnow', lambda : now + timedelta(seconds=70))
with the_future:
found = orm.APIToken.find(db, token=token)
assert found is None
# purging shouldn't delete non-expired tokens
orm.APIToken.purge_expired(db)
assert orm.APIToken.find(db, token=token)
with the_future:
orm.APIToken.purge_expired(db)
assert orm.APIToken.find(db, token=token) is None
# after purging, make sure we aren't in the user token list
assert orm_token not in user.api_tokens
def test_service_tokens(db): def test_service_tokens(db):
service = orm.Service(name='secret') service = orm.Service(name='secret')
db.add(service) db.add(service)

View File

@@ -3,6 +3,7 @@
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
from tornado import gen from tornado import gen
from tornado.httputil import url_concat
from ..handlers import BaseHandler from ..handlers import BaseHandler
from ..utils import url_path_join as ujoin from ..utils import url_path_join as ujoin
@@ -53,6 +54,30 @@ def test_root_redirect(app):
assert path == ujoin(app.base_url, 'user/%s/test.ipynb' % name) assert path == ujoin(app.base_url, 'user/%s/test.ipynb' % name)
@pytest.mark.gen_test
def test_root_default_url_noauth(app):
with mock.patch.dict(app.tornado_settings,
{'default_url': '/foo/bar'}):
r = yield get_page('/', app, allow_redirects=False)
r.raise_for_status()
url = r.headers.get('Location', '')
path = urlparse(url).path
assert path == '/foo/bar'
@pytest.mark.gen_test
def test_root_default_url_auth(app):
name = 'wash'
cookies = yield app.login_user(name)
with mock.patch.dict(app.tornado_settings,
{'default_url': '/foo/bar'}):
r = yield get_page('/', app, cookies=cookies, allow_redirects=False)
r.raise_for_status()
url = r.headers.get('Location', '')
path = urlparse(url).path
assert path == '/foo/bar'
@pytest.mark.gen_test @pytest.mark.gen_test
def test_home_no_auth(app): def test_home_no_auth(app):
r = yield get_page('home', app, allow_redirects=False) r = yield get_page('home', app, allow_redirects=False)
@@ -342,29 +367,50 @@ def test_login_strip(app):
assert called_with == [form_data] assert called_with == [form_data]
@pytest.mark.parametrize(
'running, next_url, location',
[
# default URL if next not specified, for both running and not
(True, '', ''),
(False, '', ''),
# next_url is respected
(False, '/hub/admin', '/hub/admin'),
(False, '/user/other', '/hub/user/other'),
(False, '/absolute', '/absolute'),
(False, '/has?query#andhash', '/has?query#andhash'),
# next_url outside is not allowed
(False, 'https://other.domain', ''),
(False, 'ftp://other.domain', ''),
(False, '//other.domain', ''),
]
)
@pytest.mark.gen_test @pytest.mark.gen_test
def test_login_redirect(app): def test_login_redirect(app, running, next_url, location):
cookies = yield app.login_user('river') cookies = yield app.login_user('river')
user = app.users['river'] user = app.users['river']
# no next_url, server running if location:
yield user.spawn() location = ujoin(app.base_url, location)
r = yield get_page('login', app, cookies=cookies, allow_redirects=False) else:
r.raise_for_status() # use default url
assert r.status_code == 302 location = user.url
assert '/user/river' in r.headers['Location']
# no next_url, server not running url = 'login'
yield user.stop() if next_url:
r = yield get_page('login', app, cookies=cookies, allow_redirects=False) if '//' not in next_url:
r.raise_for_status() next_url = ujoin(app.base_url, next_url, '')
assert r.status_code == 302 url = url_concat(url, dict(next=next_url))
assert '/user/river' in r.headers['Location']
# next URL given, use it if running and not user.active:
r = yield get_page('login?next=/hub/admin', app, cookies=cookies, allow_redirects=False) # ensure running
yield user.spawn()
elif user.active and not running:
# ensure not running
yield user.stop()
r = yield get_page(url, app, cookies=cookies, allow_redirects=False)
r.raise_for_status() r.raise_for_status()
assert r.status_code == 302 assert r.status_code == 302
assert r.headers['Location'].endswith('/hub/admin') assert location == r.headers['Location']
@pytest.mark.gen_test @pytest.mark.gen_test
@@ -447,6 +493,21 @@ def test_token_auth(app):
assert r.status_code == 200 assert r.status_code == 200
@pytest.mark.gen_test
def test_oauth_token_page(app):
name = 'token'
cookies = yield app.login_user(name)
user = app.users[orm.User.find(app.db, name)]
client = orm.OAuthClient(identifier='token')
app.db.add(client)
oauth_token = orm.OAuthAccessToken(client=client, user=user, grant_type=orm.GrantType.authorization_code)
app.db.add(oauth_token)
app.db.commit()
r = yield get_page('token', app, cookies=cookies)
r.raise_for_status()
assert r.status_code == 200
@pytest.mark.parametrize("error_status", [ @pytest.mark.parametrize("error_status", [
503, 503,
404, 404,

View File

@@ -3,9 +3,11 @@
{% block main %} {% block main %}
<div class="container"> <div class="container">
{% block heading %}
<div class="row text-center"> <div class="row text-center">
<h1>Spawner options</h1> <h1>Spawner Options</h1>
</div> </div>
{% endblock %}
<div class="row col-sm-offset-2 col-sm-8"> <div class="row col-sm-offset-2 col-sm-8">
{% if for_user and user.name != for_user.name -%} {% if for_user and user.name != for_user.name -%}
<p>Spawning server for {{ for_user.name }}</p> <p>Spawning server for {{ for_user.name }}</p>