mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-10 11:33:01 +00:00
Compare commits
196 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4931684a2c | ||
![]() |
62d3cc53ef | ||
![]() |
bd002e5340 | ||
![]() |
6f2aefb990 | ||
![]() |
bd3c878c67 | ||
![]() |
c1de376b6a | ||
![]() |
4cc74d287e | ||
![]() |
411a7a0bd8 | ||
![]() |
498c062ee0 | ||
![]() |
d1edbddb77 | ||
![]() |
0c9214ffb7 | ||
![]() |
db0aaf1027 | ||
![]() |
42681f8512 | ||
![]() |
e5c1414b6a | ||
![]() |
d857c20de0 | ||
![]() |
a267174a03 | ||
![]() |
768eeee470 | ||
![]() |
a451f11cd3 | ||
![]() |
63a476f9a6 | ||
![]() |
100b17819d | ||
![]() |
024d8d7378 | ||
![]() |
15e50529ff | ||
![]() |
a1a10be747 | ||
![]() |
a91ee67e74 | ||
![]() |
ea5bfa9999 | ||
![]() |
bea58ee622 | ||
![]() |
b698d4d226 | ||
![]() |
139c7ecacb | ||
![]() |
eefa8fcad7 | ||
![]() |
acaedcd898 | ||
![]() |
a075661bfb | ||
![]() |
f2246df5bb | ||
![]() |
1a3c062512 | ||
![]() |
05e4ab41fe | ||
![]() |
6f3ccb2d3d | ||
![]() |
6e5ce236c1 | ||
![]() |
58437057a1 | ||
![]() |
7d39e6a1a3 | ||
![]() |
0b1aebbbf4 | ||
![]() |
3003c87f02 | ||
![]() |
2c8c88ac3f | ||
![]() |
db994e09d3 | ||
![]() |
357ba23ff3 | ||
![]() |
54c0c276ed | ||
![]() |
baf8bd9e03 | ||
![]() |
e866abe1a0 | ||
![]() |
48fe642c44 | ||
![]() |
c8487c2117 | ||
![]() |
0d6ee3c63c | ||
![]() |
a00abc7a76 | ||
![]() |
02c8855d10 | ||
![]() |
b5877ac546 | ||
![]() |
ea91bed620 | ||
![]() |
3e81e2ebf9 | ||
![]() |
e9e2b17a92 | ||
![]() |
498181d217 | ||
![]() |
9f807a5959 | ||
![]() |
d328015fe8 | ||
![]() |
cbbc0290b9 | ||
![]() |
7477f2f6d1 | ||
![]() |
c03e50b3a2 | ||
![]() |
cfd19c3e61 | ||
![]() |
c289cdfaec | ||
![]() |
7acaf8ce52 | ||
![]() |
e5821e573a | ||
![]() |
0c16fb98f3 | ||
![]() |
b27ef8e4cb | ||
![]() |
6cfd186f06 | ||
![]() |
552859084c | ||
![]() |
6e8a58091e | ||
![]() |
6b0aee2443 | ||
![]() |
8d00ccc506 | ||
![]() |
86e31dffa5 | ||
![]() |
f421d1a6da | ||
![]() |
9112ad0f4a | ||
![]() |
354aeb96af | ||
![]() |
ff1bf7c4c0 | ||
![]() |
1ff659a847 | ||
![]() |
de40310f54 | ||
![]() |
72d9592241 | ||
![]() |
087a93f9ef | ||
![]() |
4d73f4eedb | ||
![]() |
612cc73c3c | ||
![]() |
c9d02382e3 | ||
![]() |
da647397ac | ||
![]() |
546d86e888 | ||
![]() |
36bc07b02e | ||
![]() |
81b13c6660 | ||
![]() |
b0ef2c4c84 | ||
![]() |
38024c65d8 | ||
![]() |
80997c8297 | ||
![]() |
c467c64e01 | ||
![]() |
3fd80f9f3a | ||
![]() |
d4a4d04183 | ||
![]() |
f6a3f371b4 | ||
![]() |
8fb74c8627 | ||
![]() |
fd6e6f1ded | ||
![]() |
74d3740921 | ||
![]() |
1674d2f698 | ||
![]() |
e5d9d136da | ||
![]() |
1d6b16060b | ||
![]() |
cd268af799 | ||
![]() |
bc37c729ff | ||
![]() |
d277951fa7 | ||
![]() |
e4b214536d | ||
![]() |
713f222e19 | ||
![]() |
6b32a5c2d8 | ||
![]() |
5dc38b85eb | ||
![]() |
494e4fe68b | ||
![]() |
778202ada8 | ||
![]() |
6029204383 | ||
![]() |
30eef4d353 | ||
![]() |
b30be43d22 | ||
![]() |
ca1380eb06 | ||
![]() |
491ee38a37 | ||
![]() |
5a9687b02a | ||
![]() |
6b09ff6ef2 | ||
![]() |
bdbb6164d5 | ||
![]() |
2890e27052 | ||
![]() |
43f13086cf | ||
![]() |
e883fccf2b | ||
![]() |
364c648d6f | ||
![]() |
637cc1a7bb | ||
![]() |
6aae4be54d | ||
![]() |
dbc410d6a1 | ||
![]() |
7ed9c9b6c0 | ||
![]() |
ffece0ae79 | ||
![]() |
59fda9632a | ||
![]() |
998fc28c32 | ||
![]() |
34386ba3b7 | ||
![]() |
64c4d00756 | ||
![]() |
04b7056591 | ||
![]() |
d9fc40652d | ||
![]() |
d0b4e5bc2a | ||
![]() |
9372d5f872 | ||
![]() |
ce59815e16 | ||
![]() |
7c5e89faa6 | ||
![]() |
0fe3dab408 | ||
![]() |
789ee44d85 | ||
![]() |
163a4db3ad | ||
![]() |
50d1f78b61 | ||
![]() |
ab0010fa32 | ||
![]() |
1bc8d50261 | ||
![]() |
24fd843c3c | ||
![]() |
cffdf89327 | ||
![]() |
2e53de0459 | ||
![]() |
80531341c0 | ||
![]() |
94a3584620 | ||
![]() |
12a1ec7f57 | ||
![]() |
d13286606a | ||
![]() |
e39e6d2073 | ||
![]() |
904c848bcc | ||
![]() |
038aae7e0a | ||
![]() |
ba81bd4a01 | ||
![]() |
36d62672df | ||
![]() |
ffd334b5ff | ||
![]() |
7701b82f58 | ||
![]() |
7ca96e5c6c | ||
![]() |
3be33f2884 | ||
![]() |
3d6a0c126f | ||
![]() |
39a7feea72 | ||
![]() |
5529774c1d | ||
![]() |
ffb2ba055a | ||
![]() |
2a2f9c0b67 | ||
![]() |
3e89f45954 | ||
![]() |
fad5f5a61d | ||
![]() |
ed94c2d774 | ||
![]() |
77c66d8b27 | ||
![]() |
33a4f31520 | ||
![]() |
3fb2afc2bd | ||
![]() |
8787335b01 | ||
![]() |
376ee29b12 | ||
![]() |
5e16e6f52f | ||
![]() |
ce45fde74a | ||
![]() |
665edb6651 | ||
![]() |
f98c8feaae | ||
![]() |
5100dd29c2 | ||
![]() |
40ae3a5821 | ||
![]() |
da1fe54aee | ||
![]() |
545739472e | ||
![]() |
36bb03dc3f | ||
![]() |
61fa2d9ef2 | ||
![]() |
301560b6f8 | ||
![]() |
5bd649829a | ||
![]() |
eccf2fe5be | ||
![]() |
e82683d14f | ||
![]() |
2455680ab8 | ||
![]() |
827f694589 | ||
![]() |
fa7e230b6e | ||
![]() |
42a8094b20 | ||
![]() |
a898063b83 | ||
![]() |
c0b67770e4 | ||
![]() |
791e527695 | ||
![]() |
a6b79780b3 | ||
![]() |
25bcb6ede4 | ||
![]() |
839bd79bbd |
4
.coveragerc
Normal file
4
.coveragerc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[run]
|
||||||
|
omit =
|
||||||
|
jupyterhub/tests/*
|
||||||
|
jupyterhub/singleuser.py
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,3 +14,6 @@ share/jupyter/hub/static/css/style.min.css
|
|||||||
share/jupyter/hub/static/css/style.min.css.map
|
share/jupyter/hub/static/css/style.min.css.map
|
||||||
*.egg-info
|
*.egg-info
|
||||||
MANIFEST
|
MANIFEST
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
language: python
|
language: python
|
||||||
sudo: false
|
sudo: false
|
||||||
python:
|
python:
|
||||||
|
- 3.5
|
||||||
- 3.4
|
- 3.4
|
||||||
- 3.3
|
- 3.3
|
||||||
before_install:
|
before_install:
|
||||||
@@ -10,6 +11,8 @@ before_install:
|
|||||||
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
||||||
install:
|
install:
|
||||||
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
- pip install -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
||||||
- pip install -f travis-wheels/wheelhouse ipython[notebook]
|
- pip install -f travis-wheels/wheelhouse notebook
|
||||||
script:
|
script:
|
||||||
- py.test jupyterhub
|
- py.test --cov jupyterhub jupyterhub/tests -v
|
||||||
|
after_success:
|
||||||
|
- coveralls
|
||||||
|
@@ -5,7 +5,7 @@
|
|||||||
# FROM jupyter/jupyterhub:latest
|
# FROM jupyter/jupyterhub:latest
|
||||||
#
|
#
|
||||||
|
|
||||||
FROM ipython/ipython
|
FROM jupyter/notebook
|
||||||
|
|
||||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
||||||
|
|
||||||
|
51
README.md
51
README.md
@@ -1,5 +1,9 @@
|
|||||||
# JupyterHub: A multi-user server for Jupyter notebooks
|
# JupyterHub: A multi-user server for Jupyter notebooks
|
||||||
|
|
||||||
|
Questions, comments? Visit our Google Group:
|
||||||
|
|
||||||
|
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||||
|
|
||||||
JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
|
JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user <del>IPython</del> Jupyter notebook server.
|
||||||
|
|
||||||
Three actors:
|
Three actors:
|
||||||
@@ -31,18 +35,30 @@ Then install javascript dependencies:
|
|||||||
|
|
||||||
sudo npm install -g configurable-http-proxy
|
sudo npm install -g configurable-http-proxy
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
|
||||||
|
- Notes on `pip` command used in the below installation sections:
|
||||||
|
- `sudo` may be needed for `pip install`, depending on filesystem permissions.
|
||||||
|
- JupyterHub requires Python >= 3.3, so it may be required on some machines to use `pip3` instead
|
||||||
|
of `pip` (especially when you have both Python 2 and Python 3 installed on your machine).
|
||||||
|
If `pip3` is not found on your machine, you can get it by doing:
|
||||||
|
|
||||||
|
sudo apt-get install python3-pip
|
||||||
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Then you can install the Python package by doing:
|
JupyterHub can be installed with pip:
|
||||||
|
|
||||||
pip install -r requirements.txt
|
pip3 install jupyterhub
|
||||||
pip install .
|
|
||||||
|
If the `pip3 install .` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional javascript dependencies:
|
||||||
|
|
||||||
|
npm install
|
||||||
|
|
||||||
If you plan to run notebook servers locally, you may also need to install the IPython notebook:
|
If you plan to run notebook servers locally, you may also need to install the IPython notebook:
|
||||||
|
|
||||||
pip install "ipython[notebook]"
|
pip3 install "ipython[notebook]"
|
||||||
|
|
||||||
|
|
||||||
This will fetch client-side javascript dependencies and compile CSS,
|
This will fetch client-side javascript dependencies and compile CSS,
|
||||||
and install these files to `sys.prefix`/share/jupyter, as well as
|
and install these files to `sys.prefix`/share/jupyter, as well as
|
||||||
@@ -51,15 +67,16 @@ install any Python dependencies.
|
|||||||
|
|
||||||
### Development install
|
### Development install
|
||||||
|
|
||||||
For a development install:
|
For a development install, clone the repository and then install from source:
|
||||||
|
|
||||||
pip install -r dev-requirements.txt
|
git clone https://github.com/jupyter/jupyterhub
|
||||||
pip install -e .
|
cd jupyterhub
|
||||||
|
pip3 install -r dev-requirements.txt -e .
|
||||||
|
|
||||||
In which case you may need to manually update javascript and css after some updates, with:
|
In which case you may need to manually update javascript and css after some updates, with:
|
||||||
|
|
||||||
python setup.py js # fetch updated client-side js (changes rarely)
|
python3 setup.py js # fetch updated client-side js (changes rarely)
|
||||||
python setup.py css # recompile CSS from LESS sources
|
python3 setup.py css # recompile CSS from LESS sources
|
||||||
|
|
||||||
|
|
||||||
## Running the server
|
## Running the server
|
||||||
@@ -75,6 +92,10 @@ If you want multiple users to be able to sign into the server, you will need to
|
|||||||
The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) describes how to run the server
|
The [wiki](https://github.com/jupyter/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges) describes how to run the server
|
||||||
as a less privileged user, which requires more configuration of the system.
|
as a less privileged user, which requires more configuration of the system.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
see the [getting started doc](docs/getting-started.md) for some of the basics of configuring your JupyterHub deployment.
|
||||||
|
|
||||||
### Some examples
|
### Some examples
|
||||||
|
|
||||||
generate a default config file:
|
generate a default config file:
|
||||||
@@ -91,3 +112,13 @@ Some examples, meant as illustration and testing of this concept:
|
|||||||
|
|
||||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator)
|
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyter/oauthenticator)
|
||||||
- Spawning single-user servers with docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner)
|
- Spawning single-user servers with docker, using the [DockerSpawner](https://github.com/jupyter/dockerspawner)
|
||||||
|
|
||||||
|
# Getting help
|
||||||
|
|
||||||
|
We encourage you to ask questions on the mailing list:
|
||||||
|
|
||||||
|
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||||
|
|
||||||
|
but you can participate in development discussions or get live help on Gitter:
|
||||||
|
|
||||||
|
[](https://gitter.im/jupyter/jupyterhub?utm_source=badge&utm_medium=badge)
|
||||||
|
@@ -1,2 +1,4 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
pytest
|
coveralls
|
||||||
|
pytest-cov
|
||||||
|
pytest>=2.8
|
||||||
|
@@ -11,6 +11,8 @@ One such example is using [GitHub OAuth][].
|
|||||||
Because the username is passed from the Authenticator to the Spawner,
|
Because the username is passed from the Authenticator to the Spawner,
|
||||||
a custom Authenticator and Spawner are often used together.
|
a custom Authenticator and Spawner are often used together.
|
||||||
|
|
||||||
|
See a list of custom Authenticators [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Authenticators).
|
||||||
|
|
||||||
|
|
||||||
## Basics of Authenticators
|
## Basics of Authenticators
|
||||||
|
|
||||||
|
22
docs/changelog.md
Normal file
22
docs/changelog.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Summary of changes in JupyterHub
|
||||||
|
|
||||||
|
See `git log` for a more detailed summary.
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
- No longer make the user starting the Hub an admin
|
||||||
|
- start PAM sessions on login
|
||||||
|
- hooks for Authenticators to fire before spawners start and after they stop,
|
||||||
|
allowing deeper interaction between Spawner/Authenticator pairs.
|
||||||
|
- login redirect fixes
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
- Based on standalone traitlets instead of IPython.utils.traitlets
|
||||||
|
- multiple users in admin panel
|
||||||
|
- Fixes for usernames that require escaping
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
First preview release
|
||||||
|
|
389
docs/getting-started.md
Normal file
389
docs/getting-started.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# Getting started with JupyterHub
|
||||||
|
|
||||||
|
This document describes some of the basics of configuring JupyterHub to do what you want.
|
||||||
|
JupyterHub is highly customizable, so there's a lot to cover.
|
||||||
|
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
See [the readme](../README.md) for help installing JupyterHub.
|
||||||
|
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
JupyterHub is a set of processes that together provide a multiuser Jupyter Notebook server.
|
||||||
|
There are three main categories of processes run by the `jupyterhub` command line program:
|
||||||
|
|
||||||
|
- *Single User Server*: a dedicated, single-user, Jupyter Notebook is started for each user on the system
|
||||||
|
when they log in. The object that starts these processes is called a *Spawner*.
|
||||||
|
- *Proxy*: the public facing part of the server that uses a dynamic proxy to route HTTP requests
|
||||||
|
to the *Hub* and *Single User Servers*.
|
||||||
|
- *Hub*: manages user accounts and authentication and coordinates *Single Users Servers* using a *Spawner*.
|
||||||
|
|
||||||
|
## JupyterHub's default behavior
|
||||||
|
|
||||||
|
|
||||||
|
To start JupyterHub in its default configuration, type the following at the command line:
|
||||||
|
|
||||||
|
sudo jupyterhub
|
||||||
|
|
||||||
|
The default Authenticator that ships with JupyterHub authenticates users
|
||||||
|
with their system name and password (via [PAM][]).
|
||||||
|
Any user on the system with a password will be allowed to start a single-user notebook server.
|
||||||
|
|
||||||
|
The default Spawner starts servers locally as each user, one dedicated server per user.
|
||||||
|
These servers listen on localhost, and start in the given user's home directory.
|
||||||
|
|
||||||
|
By default, the *Proxy* listens on all public interfaces on port 8000.
|
||||||
|
Thus you can reach JupyterHub through:
|
||||||
|
|
||||||
|
http://localhost:8000
|
||||||
|
|
||||||
|
or any other public IP or domain pointing to your system.
|
||||||
|
|
||||||
|
In their default configuration, the other services, the *Hub* and *Single-User Servers*,
|
||||||
|
all communicate with each other on localhost only.
|
||||||
|
|
||||||
|
**NOTE:** In its default configuration, JupyterHub runs without SSL encryption (HTTPS).
|
||||||
|
You should not run JupyterHub without SSL encryption on a public network.
|
||||||
|
See [below](#Security) for how to configure JupyterHub to use SSL.
|
||||||
|
|
||||||
|
By default, starting JupyterHub will write two files to disk in the current working directory:
|
||||||
|
|
||||||
|
- `jupyterhub.sqlite` is the sqlite database containing all of the state of the *Hub*.
|
||||||
|
This file allows the *Hub* to remember what users are running and where,
|
||||||
|
as well as other information enabling you to restart parts of JupyterHub separately.
|
||||||
|
- `jupyterhub_cookie_secret` is the encryption key used for securing cookies.
|
||||||
|
This file needs to persist in order for restarting the Hub server to avoid invalidating cookies.
|
||||||
|
Conversely, deleting this file and restarting the server effectively invalidates all login cookies.
|
||||||
|
The cookie secret file is discussed [below](#Security).
|
||||||
|
|
||||||
|
The location of these files can be specified via configuration, discussed below.
|
||||||
|
|
||||||
|
|
||||||
|
## How to configure JupyterHub
|
||||||
|
|
||||||
|
JupyterHub is configured in two ways:
|
||||||
|
|
||||||
|
1. Command-line arguments
|
||||||
|
2. Configuration files
|
||||||
|
|
||||||
|
Type the following for brief information about the command line arguments:
|
||||||
|
|
||||||
|
jupyterhub -h
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
jupyterhub --help-all
|
||||||
|
|
||||||
|
for the full command line help.
|
||||||
|
|
||||||
|
By default, JupyterHub will look for a configuration file (can be missing)
|
||||||
|
named `jupyterhub_config.py` in the current working directory.
|
||||||
|
You can create an empty configuration file with
|
||||||
|
|
||||||
|
|
||||||
|
jupyterhub --generate-config
|
||||||
|
|
||||||
|
This empty configuration file has descriptions of all configuration variables and their default values.
|
||||||
|
You can load a specific config file with:
|
||||||
|
|
||||||
|
|
||||||
|
jupyterhub -f /path/to/jupyterhub_config.py
|
||||||
|
|
||||||
|
See also: [general docs](http://ipython.org/ipython-doc/dev/development/config.html)
|
||||||
|
on the config system Jupyter uses.
|
||||||
|
|
||||||
|
|
||||||
|
## Networking
|
||||||
|
|
||||||
|
In most situations you will want to change the main IP address and port of the Proxy.
|
||||||
|
This address determines where JupyterHub is available to your users.
|
||||||
|
The default is all network interfaces (`''`) on port 8000.
|
||||||
|
|
||||||
|
This can be done with the following command line arguments:
|
||||||
|
|
||||||
|
jupyterhub --ip=192.168.1.2 --port=443
|
||||||
|
|
||||||
|
Or you can put the following lines in a configuration file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.ip = '192.168.1.2'
|
||||||
|
c.JupyterHub.port = 443
|
||||||
|
```
|
||||||
|
|
||||||
|
Port 443 is used in these examples as it is the default port for SSL/HTTPS.
|
||||||
|
|
||||||
|
Configuring only the main IP and port of JupyterHub should be sufficient for most deployments of JupyterHub.
|
||||||
|
However, for more customized scenarios,
|
||||||
|
you can configure the following additional networking details.
|
||||||
|
|
||||||
|
The Hub service talks to the proxy via a REST API on a secondary port,
|
||||||
|
whose network interface and port can be configured separately.
|
||||||
|
By default, this REST API listens on port 8081 of localhost only.
|
||||||
|
If you want to run the Proxy separate from the Hub,
|
||||||
|
you may need to configure this IP and port with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ideally a private network address
|
||||||
|
c.JupyterHub.proxy_api_ip = '10.0.1.4'
|
||||||
|
c.JupyterHub.proxy_api_port = 5432
|
||||||
|
```
|
||||||
|
|
||||||
|
The Hub service also listens only on localhost (port 8080) by default.
|
||||||
|
The Hub needs needs to be accessible from both the proxy and all Spawners.
|
||||||
|
When spawning local servers localhost is fine,
|
||||||
|
but if *either* the Proxy or (more likely) the Spawners will be remote or isolated in containers,
|
||||||
|
the Hub must listen on an IP that is accessible.
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.hub_ip = '10.0.1.4'
|
||||||
|
c.JupyterHub.hub_port = 54321
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
First of all, since JupyterHub includes authentication and allows arbitrary code execution,
|
||||||
|
you should not run it without SSL (HTTPS).
|
||||||
|
This will require you to obtain an official SSL certificate or create a self-signed certificate.
|
||||||
|
Once you have obtained and installed a key and certificate
|
||||||
|
you need to pass their locations to JupyterHub's configuration as follows:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.ssl_key = '/path/to/my.key'
|
||||||
|
c.JupyterHub.ssl_cert = '/path/to/my.cert'
|
||||||
|
```
|
||||||
|
|
||||||
|
Some cert files also contain the key, in which case only the cert is needed.
|
||||||
|
It is important that these files be put in a secure location on your server.
|
||||||
|
|
||||||
|
There are two other aspects of JupyterHub network security.
|
||||||
|
|
||||||
|
The cookie secret is an encryption key, used to encrypt the cookies used for authentication.
|
||||||
|
If this value changes for the Hub,
|
||||||
|
all single-user servers must also be restarted.
|
||||||
|
Normally, this value is stored in the file `jupyterhub_cookie_secret`,
|
||||||
|
which can be specified with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.cookie_secret_file = '/path/to/jupyterhub_cookie_secret'
|
||||||
|
```
|
||||||
|
|
||||||
|
In most deployments of JupyterHub, you should point this to a secure location on the file system.
|
||||||
|
If the cookie secret file doesn't exist when the Hub starts,
|
||||||
|
a new cookie secret is generated and stored in the file.
|
||||||
|
|
||||||
|
If you would like to avoid the need for files,
|
||||||
|
the value can be loaded in the Hub process from the `JPY_COOKIE_SECRET` env variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
||||||
|
```
|
||||||
|
|
||||||
|
For security reasons, this env variable should only be visible to the Hub.
|
||||||
|
|
||||||
|
The Hub authenticates its requests to the Proxy via an environment variable, `CONFIGPROXY_AUTH_TOKEN`.
|
||||||
|
If you want to be able to start or restart the proxy or Hub independently of each other (not always necessary),
|
||||||
|
you must set this environment variable before starting the server (for both the Hub and Proxy):
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
||||||
|
```
|
||||||
|
|
||||||
|
This env variable needs to be visible to the Hub and Proxy.
|
||||||
|
If you don't set this, the Hub will generate a random key itself,
|
||||||
|
which means that any time you restart the Hub you **must also restart the Proxy**.
|
||||||
|
If the proxy is a subprocess of the Hub,
|
||||||
|
this should happen automatically (this is the default configuration).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Configuring Authentication
|
||||||
|
|
||||||
|
The default Authenticator uses [PAM][] to authenticate system users with their username and password.
|
||||||
|
The default behavior of this Authenticator is to allow any user with an account and password on the system to login.
|
||||||
|
You can restrict which users are allowed to login with `Authenticator.whitelist`:
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin users of JupyterHub have the ability to take actions on users' behalf,
|
||||||
|
such as stopping and restarting their servers,
|
||||||
|
and adding and removing new users from the whitelist.
|
||||||
|
Any users in the admin list are automatically added to the whitelist,
|
||||||
|
if they are not already present.
|
||||||
|
The set of initial Admin users can configured as follows:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Authenticator.admin_users = {'mal', 'zoe'}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `JupyterHub.admin_access` is True (not default),
|
||||||
|
then admin users have permission to log in *as other users* on their respective machines, for debugging.
|
||||||
|
**You should make sure your users know if admin_access is enabled.**
|
||||||
|
|
||||||
|
### Adding and removing users
|
||||||
|
|
||||||
|
Users can be added and removed to the Hub via the admin panel or REST API.
|
||||||
|
These users will be added to the whitelist and database.
|
||||||
|
Restarting the Hub will not require manually updating the whitelist in your config file,
|
||||||
|
as the users will be loaded from the database.
|
||||||
|
This means that after starting the Hub once,
|
||||||
|
it is not sufficient to remove users from the whitelist in your config file.
|
||||||
|
You must also remove them from the database, either by discarding the database file,
|
||||||
|
or via the admin UI.
|
||||||
|
|
||||||
|
The default PAMAuthenticator is one case of a special kind of authenticator,
|
||||||
|
called a LocalAuthenticator,
|
||||||
|
indicating that it manages users on the local system.
|
||||||
|
When you add a user to the Hub, a LocalAuthenticator checks if that user already exists.
|
||||||
|
Normally, there will be an error telling you that the user doesn't exist.
|
||||||
|
If you set the configuration value
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.LocalAuthenticator.create_system_users = True
|
||||||
|
```
|
||||||
|
|
||||||
|
however, adding a user to the Hub that doesn't already exist on the system
|
||||||
|
will result in the Hub creating that user via the system `useradd` mechanism.
|
||||||
|
This option is typically used on hosted deployments of JupyterHub,
|
||||||
|
to avoid the need to manually create all your users before launching the service.
|
||||||
|
It is not recommended when running JupyterHub in situations where JupyterHub users maps directly onto UNIX users.
|
||||||
|
|
||||||
|
|
||||||
|
## Configuring single-user servers
|
||||||
|
|
||||||
|
Since the single-user server is an instance of `ipython notebook`,
|
||||||
|
an entire separate multi-process application,
|
||||||
|
there are many aspect of that server can configure,
|
||||||
|
and a lot of ways to express that configuration.
|
||||||
|
|
||||||
|
At the JupyterHub level, you can set some values on the Spawner.
|
||||||
|
The simplest of these is `Spawner.notebook_dir`,
|
||||||
|
which lets you set the root directory for a user's server.
|
||||||
|
This root notebook directory is the highest level directory users will be able to access in the notebook dashboard.
|
||||||
|
In this example, the root notebook directory is set to `~/notebooks`,
|
||||||
|
where `~` is expanded to the user's home directory.
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Spawner.notebook_dir = '~/notebooks'
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also specify extra command-line arguments to the notebook server with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Spawner.args = ['--debug', '--profile=PHYS131']
|
||||||
|
```
|
||||||
|
|
||||||
|
This could be used to set the users default page for the single user server:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
||||||
|
```
|
||||||
|
|
||||||
|
Since the single-user server extends the notebook server application,
|
||||||
|
it still loads configuration from the `ipython_notebook_config.py` config file.
|
||||||
|
Each user may have one of these files in `$HOME/.ipython/profile_default/`.
|
||||||
|
IPython also supports loading system-wide config files from `/etc/ipython/`,
|
||||||
|
which is the place to put configuration that you want to affect all of your users.
|
||||||
|
|
||||||
|
## External services
|
||||||
|
|
||||||
|
JupyterHub has a REST API that can be used to run external services.
|
||||||
|
More detail on this API will be added in the future.
|
||||||
|
|
||||||
|
## File locations
|
||||||
|
|
||||||
|
It is recommended to put all of the files used by JupyterHub into standard UNIX filesystem locations.
|
||||||
|
|
||||||
|
* `/srv/jupyterhub` for all security and runtime files
|
||||||
|
* `/etc/jupyterhub` for all configuration files
|
||||||
|
* `/var/log` for log files
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
In the following example, we show a configuration files for a fairly standard JupyterHub deployment with the following assumptions:
|
||||||
|
|
||||||
|
* JupyterHub is running on a single cloud server
|
||||||
|
* Using SSL on the standard HTTPS port 443
|
||||||
|
* You want to use [GitHub OAuth][oauthenticator] for login
|
||||||
|
* You need the users to exist locally on the server
|
||||||
|
* You want 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`.
|
||||||
|
|
||||||
|
Let's start out with `jupyterhub_config.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# jupyterhub_config.py
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# put the log file in /var/log
|
||||||
|
c.JupyterHub.log_file = '/var/log/jupyterhub.log'
|
||||||
|
|
||||||
|
# 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'}
|
||||||
|
|
||||||
|
# start single-user notebook servers in ~/assignments,
|
||||||
|
# with ~/assignments/Welcome.ipynb as the default landing page
|
||||||
|
# this config could also be put in
|
||||||
|
# /etc/ipython/ipython_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 env variables][oauth-setup],
|
||||||
|
which we will need to set when we launch the server:
|
||||||
|
|
||||||
|
```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
|
||||||
|
jupyterhub -f /path/to/aboveconfig.py
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# Further reading
|
||||||
|
|
||||||
|
- TODO: troubleshooting
|
||||||
|
- [Custom Authenticators](authenticators.md)
|
||||||
|
- [Custom Spawners](spawners.md)
|
||||||
|
|
||||||
|
|
||||||
|
[oauth-setup]: https://github.com/jupyter/oauthenticator#setup
|
||||||
|
[oauthenticator]: https://github.com/jupyter/oauthenticator
|
||||||
|
[PAM]: http://en.wikipedia.org/wiki/Pluggable_authentication_module
|
@@ -59,6 +59,8 @@ which regular users typically do not have
|
|||||||
|
|
||||||
[More info on custom Authenticators](authenticators.md).
|
[More info on custom Authenticators](authenticators.md).
|
||||||
|
|
||||||
|
See a list of custom Authenticators [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Authenticators).
|
||||||
|
|
||||||
|
|
||||||
### Spawning
|
### Spawning
|
||||||
|
|
||||||
@@ -72,4 +74,4 @@ and needs to be able to take three actions:
|
|||||||
|
|
||||||
[More info on custom Spawners](spawners.md).
|
[More info on custom Spawners](spawners.md).
|
||||||
|
|
||||||
[An example using Docker](https://github.com/jupyter/dockerspawner).
|
See a list of custom Spawners [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners).
|
||||||
|
@@ -8,6 +8,9 @@ and a custom Spawner needs to be able to take three actions:
|
|||||||
2. poll whether the process is still running
|
2. poll whether the process is still running
|
||||||
3. stop the process
|
3. stop the process
|
||||||
|
|
||||||
|
See a list of custom Spawners [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners).
|
||||||
|
|
||||||
|
|
||||||
## Spawner.start
|
## Spawner.start
|
||||||
|
|
||||||
`Spawner.start` should start the single-user server for a single user.
|
`Spawner.start` should start the single-user server for a single user.
|
||||||
|
@@ -1,2 +1,2 @@
|
|||||||
from .version import *
|
from .version import version_info, __version__
|
||||||
|
|
||||||
|
@@ -4,6 +4,7 @@
|
|||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from .. import orm
|
from .. import orm
|
||||||
@@ -11,29 +12,31 @@ from ..utils import token_authenticated
|
|||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TokenAPIHandler(APIHandler):
|
class TokenAPIHandler(APIHandler):
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, token):
|
def get(self, token):
|
||||||
orm_token = orm.APIToken.find(self.db, token)
|
orm_token = orm.APIToken.find(self.db, token)
|
||||||
if orm_token is None:
|
if orm_token is None:
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
self.write(json.dumps({
|
self.write(json.dumps(self.user_model(orm_token.user)))
|
||||||
'user' : orm_token.user.name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
class CookieAPIHandler(APIHandler):
|
class CookieAPIHandler(APIHandler):
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, cookie_name):
|
def get(self, cookie_name, cookie_value=None):
|
||||||
|
cookie_name = quote(cookie_name, safe='')
|
||||||
|
if cookie_value is None:
|
||||||
|
self.log.warn("Cookie values in request body is deprecated, use `/cookie_name/cookie_value`")
|
||||||
cookie_value = self.request.body
|
cookie_value = self.request.body
|
||||||
|
else:
|
||||||
|
cookie_value = cookie_value.encode('utf8')
|
||||||
user = self._user_for_cookie(cookie_name, cookie_value)
|
user = self._user_for_cookie(cookie_name, cookie_value)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
self.write(json.dumps({
|
self.write(json.dumps(self.user_model(user)))
|
||||||
'user' : user.name,
|
|
||||||
}))
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r"/api/authorizations/cookie/([^/]+)", CookieAPIHandler),
|
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
||||||
]
|
]
|
||||||
|
@@ -9,8 +9,43 @@ from http.client import responses
|
|||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
|
from ..utils import url_path_join
|
||||||
|
|
||||||
class APIHandler(BaseHandler):
|
class APIHandler(BaseHandler):
|
||||||
|
|
||||||
|
def check_referer(self):
|
||||||
|
"""Check Origin for cross-site API requests.
|
||||||
|
|
||||||
|
Copied from WebSocket with changes:
|
||||||
|
|
||||||
|
- allow unspecified host/referer (e.g. scripts)
|
||||||
|
"""
|
||||||
|
host = self.request.headers.get("Host")
|
||||||
|
referer = self.request.headers.get("Referer")
|
||||||
|
|
||||||
|
# If no header is provided, assume it comes from a script/curl.
|
||||||
|
# We are only concerned with cross-site browser stuff here.
|
||||||
|
if not host:
|
||||||
|
self.log.warn("Blocking API request with no host")
|
||||||
|
return False
|
||||||
|
if not referer:
|
||||||
|
self.log.warn("Blocking API request with no referer")
|
||||||
|
return False
|
||||||
|
|
||||||
|
host_path = url_path_join(host, self.hub.server.base_url)
|
||||||
|
referer_path = referer.split('://', 1)[-1]
|
||||||
|
if not (referer_path + '/').startswith(host_path):
|
||||||
|
self.log.warn("Blocking Cross Origin API request. Referer: %s, Host: %s",
|
||||||
|
referer, host_path)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_current_user_cookie(self):
|
||||||
|
"""Override get_user_cookie to check Referer header"""
|
||||||
|
if not self.check_referer():
|
||||||
|
return None
|
||||||
|
return super().get_current_user_cookie()
|
||||||
|
|
||||||
def get_json_body(self):
|
def get_json_body(self):
|
||||||
"""Return the body of the request as JSON data."""
|
"""Return the body of the request as JSON data."""
|
||||||
if not self.request.body:
|
if not self.request.body:
|
||||||
@@ -24,7 +59,6 @@ class APIHandler(BaseHandler):
|
|||||||
raise web.HTTPError(400, 'Invalid JSON in body of request')
|
raise web.HTTPError(400, 'Invalid JSON in body of request')
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def write_error(self, status_code, **kwargs):
|
def write_error(self, status_code, **kwargs):
|
||||||
"""Write JSON errors instead of HTML"""
|
"""Write JSON errors instead of HTML"""
|
||||||
exc_info = kwargs.get('exc_info')
|
exc_info = kwargs.get('exc_info')
|
||||||
@@ -47,3 +81,38 @@ class APIHandler(BaseHandler):
|
|||||||
'status': status_code,
|
'status': status_code,
|
||||||
'message': message or status_message,
|
'message': message or status_message,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
def user_model(self, user):
|
||||||
|
model = {
|
||||||
|
'name': user.name,
|
||||||
|
'admin': user.admin,
|
||||||
|
'server': user.server.base_url if user.running else None,
|
||||||
|
'pending': None,
|
||||||
|
'last_activity': user.last_activity.isoformat(),
|
||||||
|
}
|
||||||
|
if user.spawn_pending:
|
||||||
|
model['pending'] = 'spawn'
|
||||||
|
elif user.stop_pending:
|
||||||
|
model['pending'] = 'stop'
|
||||||
|
return model
|
||||||
|
|
||||||
|
_model_types = {
|
||||||
|
'name': str,
|
||||||
|
'admin': bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _check_user_model(self, model):
|
||||||
|
if not isinstance(model, dict):
|
||||||
|
raise web.HTTPError(400, "Invalid JSON data: %r" % model)
|
||||||
|
if not set(model).issubset(set(self._model_types)):
|
||||||
|
raise web.HTTPError(400, "Invalid JSON keys: %r" % model)
|
||||||
|
for key, value in model.items():
|
||||||
|
if not isinstance(value, self._model_types[key]):
|
||||||
|
raise web.HTTPError(400, "user.%s must be %s, not: %r" % (
|
||||||
|
key, self._model_types[key], type(value)
|
||||||
|
))
|
||||||
|
|
||||||
|
def options(self, *args, **kwargs):
|
||||||
|
self.set_header('Access-Control-Allow-Headers', 'accept, content-type')
|
||||||
|
self.finish()
|
||||||
|
|
@@ -58,7 +58,7 @@ class ProxyAPIHandler(APIHandler):
|
|||||||
if 'auth_token' in model:
|
if 'auth_token' in model:
|
||||||
self.proxy.auth_token = model['auth_token']
|
self.proxy.auth_token = model['auth_token']
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.log.info("Updated proxy at %s", server.url)
|
self.log.info("Updated proxy at %s", server.bind_url)
|
||||||
yield self.proxy.check_routes()
|
yield self.proxy.check_routes()
|
||||||
|
|
||||||
|
|
||||||
|
@@ -11,45 +11,57 @@ from .. import orm
|
|||||||
from ..utils import admin_only
|
from ..utils import admin_only
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
class BaseUserHandler(APIHandler):
|
|
||||||
|
|
||||||
def user_model(self, user):
|
class UserListAPIHandler(APIHandler):
|
||||||
model = {
|
|
||||||
'name': user.name,
|
|
||||||
'admin': user.admin,
|
|
||||||
'server': user.server.base_url if user.running else None,
|
|
||||||
'pending': None,
|
|
||||||
'last_activity': user.last_activity.isoformat(),
|
|
||||||
}
|
|
||||||
if user.spawn_pending:
|
|
||||||
model['pending'] = 'spawn'
|
|
||||||
elif user.stop_pending:
|
|
||||||
model['pending'] = 'stop'
|
|
||||||
return model
|
|
||||||
|
|
||||||
_model_types = {
|
|
||||||
'name': str,
|
|
||||||
'admin': bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _check_user_model(self, model):
|
|
||||||
if not isinstance(model, dict):
|
|
||||||
raise web.HTTPError(400, "Invalid JSON data: %r" % model)
|
|
||||||
if not set(model).issubset(set(self._model_types)):
|
|
||||||
raise web.HTTPError(400, "Invalid JSON keys: %r" % model)
|
|
||||||
for key, value in model.items():
|
|
||||||
if not isinstance(value, self._model_types[key]):
|
|
||||||
raise web.HTTPError(400, "user.%s must be %s, not: %r" % (
|
|
||||||
key, self._model_types[key], type(value)
|
|
||||||
))
|
|
||||||
|
|
||||||
class UserListAPIHandler(BaseUserHandler):
|
|
||||||
@admin_only
|
@admin_only
|
||||||
def get(self):
|
def get(self):
|
||||||
users = self.db.query(orm.User)
|
users = self.db.query(orm.User)
|
||||||
data = [ self.user_model(u) for u in users ]
|
data = [ self.user_model(u) for u in users ]
|
||||||
self.write(json.dumps(data))
|
self.write(json.dumps(data))
|
||||||
|
|
||||||
|
@admin_only
|
||||||
|
@gen.coroutine
|
||||||
|
def post(self):
|
||||||
|
data = self.get_json_body()
|
||||||
|
if not data or not isinstance(data, dict) or not data.get('usernames'):
|
||||||
|
raise web.HTTPError(400, "Must specify at least one user to create")
|
||||||
|
|
||||||
|
usernames = data.pop('usernames')
|
||||||
|
self._check_user_model(data)
|
||||||
|
# admin is set for all users
|
||||||
|
# to create admin and non-admin users requires at least two API requests
|
||||||
|
admin = data.get('admin', False)
|
||||||
|
|
||||||
|
to_create = []
|
||||||
|
for name in usernames:
|
||||||
|
user = self.find_user(name)
|
||||||
|
if user is not None:
|
||||||
|
self.log.warn("User %s already exists" % name)
|
||||||
|
else:
|
||||||
|
to_create.append(name)
|
||||||
|
|
||||||
|
if not to_create:
|
||||||
|
raise web.HTTPError(400, "All %i users already exist" % len(usernames))
|
||||||
|
|
||||||
|
created = []
|
||||||
|
for name in to_create:
|
||||||
|
user = self.user_from_username(name)
|
||||||
|
if admin:
|
||||||
|
user.admin = True
|
||||||
|
self.db.commit()
|
||||||
|
try:
|
||||||
|
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||||
|
except Exception:
|
||||||
|
self.log.error("Failed to create user: %s" % name, exc_info=True)
|
||||||
|
self.db.delete(user)
|
||||||
|
self.db.commit()
|
||||||
|
raise web.HTTPError(400, "Failed to create user: %s" % name)
|
||||||
|
else:
|
||||||
|
created.append(user)
|
||||||
|
|
||||||
|
self.write(json.dumps([ self.user_model(u) for u in created ]))
|
||||||
|
self.set_status(201)
|
||||||
|
|
||||||
|
|
||||||
def admin_or_self(method):
|
def admin_or_self(method):
|
||||||
"""Decorator for restricting access to either the target user or admin"""
|
"""Decorator for restricting access to either the target user or admin"""
|
||||||
@@ -66,7 +78,7 @@ def admin_or_self(method):
|
|||||||
return method(self, name)
|
return method(self, name)
|
||||||
return m
|
return m
|
||||||
|
|
||||||
class UserAPIHandler(BaseUserHandler):
|
class UserAPIHandler(APIHandler):
|
||||||
|
|
||||||
@admin_or_self
|
@admin_or_self
|
||||||
def get(self, name):
|
def get(self, name):
|
||||||
@@ -135,7 +147,7 @@ class UserAPIHandler(BaseUserHandler):
|
|||||||
self.write(json.dumps(self.user_model(user)))
|
self.write(json.dumps(self.user_model(user)))
|
||||||
|
|
||||||
|
|
||||||
class UserServerAPIHandler(BaseUserHandler):
|
class UserServerAPIHandler(APIHandler):
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
@admin_or_self
|
@admin_or_self
|
||||||
def post(self, name):
|
def post(self, name):
|
||||||
@@ -165,7 +177,7 @@ class UserServerAPIHandler(BaseUserHandler):
|
|||||||
status = 202 if user.stop_pending else 204
|
status = 202 if user.stop_pending else 204
|
||||||
self.set_status(status)
|
self.set_status(status)
|
||||||
|
|
||||||
class UserAdminAccessAPIHandler(BaseUserHandler):
|
class UserAdminAccessAPIHandler(APIHandler):
|
||||||
"""Grant admins access to single-user servers
|
"""Grant admins access to single-user servers
|
||||||
|
|
||||||
This handler sets the necessary cookie for an admin to login to a single-user server.
|
This handler sets the necessary cookie for an admin to login to a single-user server.
|
||||||
@@ -184,6 +196,7 @@ class UserAdminAccessAPIHandler(BaseUserHandler):
|
|||||||
if not user.running:
|
if not user.running:
|
||||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||||
self.set_server_cookie(user)
|
self.set_server_cookie(user)
|
||||||
|
current.other_user_cookies.add(name)
|
||||||
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
"""The multi-user notebook application"""
|
"""The multi-user notebook application"""
|
||||||
|
|
||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
@@ -11,6 +11,7 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from distutils.version import LooseVersion as V
|
from distutils.version import LooseVersion as V
|
||||||
from getpass import getuser
|
from getpass import getuser
|
||||||
@@ -31,15 +32,11 @@ from tornado.ioloop import IOLoop, PeriodicCallback
|
|||||||
from tornado.log import app_log, access_log, gen_log
|
from tornado.log import app_log, access_log, gen_log
|
||||||
from tornado import gen, web
|
from tornado import gen, web
|
||||||
|
|
||||||
import IPython
|
from traitlets import (
|
||||||
if V(IPython.__version__) < V('3.0'):
|
|
||||||
raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__)
|
|
||||||
|
|
||||||
from IPython.utils.traitlets import (
|
|
||||||
Unicode, Integer, Dict, TraitError, List, Bool, Any,
|
Unicode, Integer, Dict, TraitError, List, Bool, Any,
|
||||||
Type, Set, Instance, Bytes,
|
Type, Set, Instance, Bytes, Float,
|
||||||
)
|
)
|
||||||
from IPython.config import Application, catch_config_error
|
from traitlets.config import Application, catch_config_error
|
||||||
|
|
||||||
here = os.path.dirname(__file__)
|
here = os.path.dirname(__file__)
|
||||||
|
|
||||||
@@ -49,8 +46,8 @@ from .handlers.static import CacheControlStaticFilesHandler
|
|||||||
|
|
||||||
from . import orm
|
from . import orm
|
||||||
from ._data import DATA_FILES_PATH
|
from ._data import DATA_FILES_PATH
|
||||||
from .log import CoroutineLogFormatter
|
from .log import CoroutineLogFormatter, log_request
|
||||||
from .traitlets import URLPrefix
|
from .traitlets import URLPrefix, Command
|
||||||
from .utils import (
|
from .utils import (
|
||||||
url_path_join,
|
url_path_join,
|
||||||
ISO8601_ms, ISO8601_s,
|
ISO8601_ms, ISO8601_s,
|
||||||
@@ -126,6 +123,7 @@ class NewToken(Application):
|
|||||||
hub = JupyterHub(parent=self)
|
hub = JupyterHub(parent=self)
|
||||||
hub.load_config_file(hub.config_file)
|
hub.load_config_file(hub.config_file)
|
||||||
hub.init_db()
|
hub.init_db()
|
||||||
|
hub.hub = hub.db.query(orm.Hub).first()
|
||||||
hub.init_users()
|
hub.init_users()
|
||||||
user = orm.User.find(hub.db, self.name)
|
user = orm.User.find(hub.db, self.name)
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -138,6 +136,7 @@ class NewToken(Application):
|
|||||||
class JupyterHub(Application):
|
class JupyterHub(Application):
|
||||||
"""An Application for starting a Multi-User Jupyter Notebook server."""
|
"""An Application for starting a Multi-User Jupyter Notebook server."""
|
||||||
name = 'jupyterhub'
|
name = 'jupyterhub'
|
||||||
|
version = jupyterhub.__version__
|
||||||
|
|
||||||
description = """Start a multi-user Jupyter Notebook server
|
description = """Start a multi-user Jupyter Notebook server
|
||||||
|
|
||||||
@@ -185,6 +184,11 @@ class JupyterHub(Application):
|
|||||||
Useful for daemonizing jupyterhub.
|
Useful for daemonizing jupyterhub.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
cookie_max_age_days = Float(14, config=True,
|
||||||
|
help="""Number of days for a login cookie to be valid.
|
||||||
|
Default is two weeks.
|
||||||
|
"""
|
||||||
|
)
|
||||||
last_activity_interval = Integer(300, config=True,
|
last_activity_interval = Integer(300, config=True,
|
||||||
help="Interval (in seconds) at which to update last-activity timestamps."
|
help="Interval (in seconds) at which to update last-activity timestamps."
|
||||||
)
|
)
|
||||||
@@ -196,6 +200,14 @@ class JupyterHub(Application):
|
|||||||
help="The location of jupyterhub data files (e.g. /usr/local/share/jupyter/hub)"
|
help="The location of jupyterhub data files (e.g. /usr/local/share/jupyter/hub)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
template_paths = List(
|
||||||
|
config=True,
|
||||||
|
help="Paths to search for jinja templates.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _template_paths_default(self):
|
||||||
|
return [os.path.join(self.data_files_path, 'templates')]
|
||||||
|
|
||||||
ssl_key = Unicode('', config=True,
|
ssl_key = Unicode('', config=True,
|
||||||
help="""Path to SSL key file for the public facing interface of the proxy
|
help="""Path to SSL key file for the public facing interface of the proxy
|
||||||
|
|
||||||
@@ -222,7 +234,7 @@ class JupyterHub(Application):
|
|||||||
help="Supply extra arguments that will be passed to Jinja environment."
|
help="Supply extra arguments that will be passed to Jinja environment."
|
||||||
)
|
)
|
||||||
|
|
||||||
proxy_cmd = Unicode('configurable-http-proxy', config=True,
|
proxy_cmd = Command('configurable-http-proxy', config=True,
|
||||||
help="""The command to start the http proxy.
|
help="""The command to start the http proxy.
|
||||||
|
|
||||||
Only override if configurable-http-proxy is not on your PATH
|
Only override if configurable-http-proxy is not on your PATH
|
||||||
@@ -335,7 +347,6 @@ class JupyterHub(Application):
|
|||||||
debug_db = Bool(False, config=True,
|
debug_db = Bool(False, config=True,
|
||||||
help="log all database transactions. This has A LOT of output"
|
help="log all database transactions. This has A LOT of output"
|
||||||
)
|
)
|
||||||
db = Any()
|
|
||||||
session_factory = Any()
|
session_factory = Any()
|
||||||
|
|
||||||
admin_access = Bool(False, config=True,
|
admin_access = Bool(False, config=True,
|
||||||
@@ -345,11 +356,9 @@ class JupyterHub(Application):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
admin_users = Set(config=True,
|
admin_users = Set(config=True,
|
||||||
help="""set of usernames of admin users
|
help="""DEPRECATED, use Authenticator.admin_users instead."""
|
||||||
|
|
||||||
If unspecified, only the user that launches the server will be admin.
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
tornado_settings = Dict(config=True)
|
tornado_settings = Dict(config=True)
|
||||||
|
|
||||||
cleanup_servers = Bool(True, config=True,
|
cleanup_servers = Bool(True, config=True,
|
||||||
@@ -531,6 +540,40 @@ class JupyterHub(Application):
|
|||||||
# store the loaded trait value
|
# store the loaded trait value
|
||||||
self.cookie_secret = secret
|
self.cookie_secret = secret
|
||||||
|
|
||||||
|
# thread-local storage of db objects
|
||||||
|
_local = Instance(threading.local, ())
|
||||||
|
@property
|
||||||
|
def db(self):
|
||||||
|
if not hasattr(self._local, 'db'):
|
||||||
|
self._local.db = scoped_session(self.session_factory)()
|
||||||
|
return self._local.db
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hub(self):
|
||||||
|
if not getattr(self._local, 'hub', None):
|
||||||
|
q = self.db.query(orm.Hub)
|
||||||
|
assert q.count() <= 1
|
||||||
|
self._local.hub = q.first()
|
||||||
|
return self._local.hub
|
||||||
|
|
||||||
|
@hub.setter
|
||||||
|
def hub(self, hub):
|
||||||
|
self._local.hub = hub
|
||||||
|
|
||||||
|
@property
|
||||||
|
def proxy(self):
|
||||||
|
if not getattr(self._local, 'proxy', None):
|
||||||
|
q = self.db.query(orm.Proxy)
|
||||||
|
assert q.count() <= 1
|
||||||
|
p = self._local.proxy = q.first()
|
||||||
|
if p:
|
||||||
|
p.auth_token = self.proxy_auth_token
|
||||||
|
return self._local.proxy
|
||||||
|
|
||||||
|
@proxy.setter
|
||||||
|
def proxy(self, proxy):
|
||||||
|
self._local.proxy = proxy
|
||||||
|
|
||||||
def init_db(self):
|
def init_db(self):
|
||||||
"""Create the database connection"""
|
"""Create the database connection"""
|
||||||
self.log.debug("Connecting to db: %s", self.db_url)
|
self.log.debug("Connecting to db: %s", self.db_url)
|
||||||
@@ -541,7 +584,8 @@ class JupyterHub(Application):
|
|||||||
echo=self.debug_db,
|
echo=self.debug_db,
|
||||||
**self.db_kwargs
|
**self.db_kwargs
|
||||||
)
|
)
|
||||||
self.db = scoped_session(self.session_factory)()
|
# trigger constructing thread local db property
|
||||||
|
_ = self.db
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
self.log.error("Failed to connect to db: %s", self.db_url)
|
self.log.error("Failed to connect to db: %s", self.db_url)
|
||||||
self.log.debug("Database error was:", exc_info=True)
|
self.log.debug("Database error was:", exc_info=True)
|
||||||
@@ -575,15 +619,21 @@ class JupyterHub(Application):
|
|||||||
"""Load users into and from the database"""
|
"""Load users into and from the database"""
|
||||||
db = self.db
|
db = self.db
|
||||||
|
|
||||||
if not self.admin_users:
|
if self.admin_users and not self.authenticator.admin_users:
|
||||||
# add current user as admin if there aren't any others
|
self.log.warn(
|
||||||
admins = db.query(orm.User).filter(orm.User.admin==True)
|
"\nJupyterHub.admin_users is deprecated."
|
||||||
if admins.first() is None:
|
"\nUse Authenticator.admin_users instead."
|
||||||
self.admin_users.add(getuser())
|
)
|
||||||
|
self.authenticator.admin_users = self.admin_users
|
||||||
|
admin_users = self.authenticator.admin_users
|
||||||
|
|
||||||
|
if not admin_users:
|
||||||
|
self.log.warning("No admin users, admin interface will be unavailable.")
|
||||||
|
self.log.warning("Add any administrative users to `c.Authenticator.admin_users` in config.")
|
||||||
|
|
||||||
new_users = []
|
new_users = []
|
||||||
|
|
||||||
for name in self.admin_users:
|
for name in admin_users:
|
||||||
# ensure anyone specified as admin in config is admin in db
|
# ensure anyone specified as admin in config is admin in db
|
||||||
user = orm.User.find(db, name)
|
user = orm.User.find(db, name)
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -628,6 +678,10 @@ class JupyterHub(Application):
|
|||||||
yield gen.maybe_future(self.authenticator.add_user(user))
|
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
@gen.coroutine
|
||||||
|
def init_spawners(self):
|
||||||
|
db = self.db
|
||||||
|
|
||||||
user_summaries = ['']
|
user_summaries = ['']
|
||||||
def _user_summary(user):
|
def _user_summary(user):
|
||||||
parts = ['{0: >8}'.format(user.name)]
|
parts = ['{0: >8}'.format(user.name)]
|
||||||
@@ -655,6 +709,7 @@ class JupyterHub(Application):
|
|||||||
self.log.debug("Loading state for %s from db", user.name)
|
self.log.debug("Loading state for %s from db", user.name)
|
||||||
user.spawner = spawner = self.spawner_class(
|
user.spawner = spawner = self.spawner_class(
|
||||||
user=user, hub=self.hub, config=self.config, db=self.db,
|
user=user, hub=self.hub, config=self.config, db=self.db,
|
||||||
|
authenticator=self.authenticator,
|
||||||
)
|
)
|
||||||
status = yield spawner.poll()
|
status = yield spawner.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
@@ -705,19 +760,19 @@ class JupyterHub(Application):
|
|||||||
if isinstance(e, HTTPError) and e.code == 403:
|
if isinstance(e, HTTPError) and e.code == 403:
|
||||||
msg = "Did CONFIGPROXY_AUTH_TOKEN change?"
|
msg = "Did CONFIGPROXY_AUTH_TOKEN change?"
|
||||||
else:
|
else:
|
||||||
msg = "Is something else using %s?" % self.proxy.public_server.url
|
msg = "Is something else using %s?" % self.proxy.public_server.bind_url
|
||||||
self.log.error("Proxy appears to be running at %s, but I can't access it (%s)\n%s",
|
self.log.error("Proxy appears to be running at %s, but I can't access it (%s)\n%s",
|
||||||
self.proxy.public_server.url, e, msg)
|
self.proxy.public_server.bind_url, e, msg)
|
||||||
self.exit(1)
|
self.exit(1)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
self.log.info("Proxy already running at: %s", self.proxy.public_server.url)
|
self.log.info("Proxy already running at: %s", self.proxy.public_server.bind_url)
|
||||||
self.proxy_process = None
|
self.proxy_process = None
|
||||||
return
|
return
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token
|
env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token
|
||||||
cmd = [self.proxy_cmd,
|
cmd = self.proxy_cmd + [
|
||||||
'--ip', self.proxy.public_server.ip,
|
'--ip', self.proxy.public_server.ip,
|
||||||
'--port', str(self.proxy.public_server.port),
|
'--port', str(self.proxy.public_server.port),
|
||||||
'--api-ip', self.proxy.api_server.ip,
|
'--api-ip', self.proxy.api_server.ip,
|
||||||
@@ -730,9 +785,17 @@ class JupyterHub(Application):
|
|||||||
cmd.extend(['--ssl-key', self.ssl_key])
|
cmd.extend(['--ssl-key', self.ssl_key])
|
||||||
if self.ssl_cert:
|
if self.ssl_cert:
|
||||||
cmd.extend(['--ssl-cert', self.ssl_cert])
|
cmd.extend(['--ssl-cert', self.ssl_cert])
|
||||||
self.log.info("Starting proxy @ %s", self.proxy.public_server.url)
|
self.log.info("Starting proxy @ %s", self.proxy.public_server.bind_url)
|
||||||
self.log.debug("Proxy cmd: %s", cmd)
|
self.log.debug("Proxy cmd: %s", cmd)
|
||||||
|
try:
|
||||||
self.proxy_process = Popen(cmd, env=env)
|
self.proxy_process = Popen(cmd, env=env)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
self.log.error(
|
||||||
|
"Failed to find proxy %r\n"
|
||||||
|
"The proxy can be installed with `npm install -g configurable-http-proxy`"
|
||||||
|
% self.proxy_cmd
|
||||||
|
)
|
||||||
|
self.exit(1)
|
||||||
def _check():
|
def _check():
|
||||||
status = self.proxy_process.poll()
|
status = self.proxy_process.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -768,9 +831,8 @@ class JupyterHub(Application):
|
|||||||
def init_tornado_settings(self):
|
def init_tornado_settings(self):
|
||||||
"""Set up the tornado settings dict."""
|
"""Set up the tornado settings dict."""
|
||||||
base_url = self.hub.server.base_url
|
base_url = self.hub.server.base_url
|
||||||
template_path = os.path.join(self.data_files_path, 'templates'),
|
|
||||||
jinja_env = Environment(
|
jinja_env = Environment(
|
||||||
loader=FileSystemLoader(template_path),
|
loader=FileSystemLoader(self.template_paths),
|
||||||
**self.jinja_environment_options
|
**self.jinja_environment_options
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -786,23 +848,25 @@ class JupyterHub(Application):
|
|||||||
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
|
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
|
||||||
|
|
||||||
settings = dict(
|
settings = dict(
|
||||||
|
log_function=log_request,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
log=self.log,
|
log=self.log,
|
||||||
db=self.db,
|
db=self.db,
|
||||||
proxy=self.proxy,
|
proxy=self.proxy,
|
||||||
hub=self.hub,
|
hub=self.hub,
|
||||||
admin_users=self.admin_users,
|
admin_users=self.authenticator.admin_users,
|
||||||
admin_access=self.admin_access,
|
admin_access=self.admin_access,
|
||||||
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,
|
||||||
cookie_secret=self.cookie_secret,
|
cookie_secret=self.cookie_secret,
|
||||||
|
cookie_max_age_days=self.cookie_max_age_days,
|
||||||
login_url=login_url,
|
login_url=login_url,
|
||||||
logout_url=logout_url,
|
logout_url=logout_url,
|
||||||
static_path=os.path.join(self.data_files_path, 'static'),
|
static_path=os.path.join(self.data_files_path, 'static'),
|
||||||
static_url_prefix=url_path_join(self.hub.server.base_url, 'static/'),
|
static_url_prefix=url_path_join(self.hub.server.base_url, 'static/'),
|
||||||
static_handler_class=CacheControlStaticFilesHandler,
|
static_handler_class=CacheControlStaticFilesHandler,
|
||||||
template_path=template_path,
|
template_path=self.template_paths,
|
||||||
jinja2_env=jinja_env,
|
jinja2_env=jinja_env,
|
||||||
version_hash=version_hash,
|
version_hash=version_hash,
|
||||||
)
|
)
|
||||||
@@ -845,6 +909,7 @@ class JupyterHub(Application):
|
|||||||
self.init_hub()
|
self.init_hub()
|
||||||
self.init_proxy()
|
self.init_proxy()
|
||||||
yield self.init_users()
|
yield self.init_users()
|
||||||
|
yield self.init_spawners()
|
||||||
self.init_handlers()
|
self.init_handlers()
|
||||||
self.init_tornado_settings()
|
self.init_tornado_settings()
|
||||||
self.init_tornado_application()
|
self.init_tornado_application()
|
||||||
@@ -955,6 +1020,16 @@ class JupyterHub(Application):
|
|||||||
loop.stop()
|
loop.stop()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# start the webserver
|
||||||
|
self.http_server = tornado.httpserver.HTTPServer(self.tornado_application, xheaders=True)
|
||||||
|
try:
|
||||||
|
self.http_server.listen(self.hub_port, address=self.hub_ip)
|
||||||
|
except Exception:
|
||||||
|
self.log.error("Failed to bind hub to %s", self.hub.server.bind_url)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.log.info("Hub API listening on %s", self.hub.server.bind_url)
|
||||||
|
|
||||||
# start the proxy
|
# start the proxy
|
||||||
try:
|
try:
|
||||||
yield self.start_proxy()
|
yield self.start_proxy()
|
||||||
@@ -976,12 +1051,12 @@ class JupyterHub(Application):
|
|||||||
pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval)
|
pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval)
|
||||||
pc.start()
|
pc.start()
|
||||||
|
|
||||||
# start the webserver
|
self.log.info("JupyterHub is now running at %s", self.proxy.public_server.url)
|
||||||
self.http_server = tornado.httpserver.HTTPServer(self.tornado_application, xheaders=True)
|
|
||||||
self.http_server.listen(self.hub_port)
|
|
||||||
|
|
||||||
# register cleanup on both TERM and INT
|
# register cleanup on both TERM and INT
|
||||||
atexit.register(self.atexit)
|
atexit.register(self.atexit)
|
||||||
|
self.init_signal()
|
||||||
|
|
||||||
|
def init_signal(self):
|
||||||
signal.signal(signal.SIGTERM, self.sigterm)
|
signal.signal(signal.SIGTERM, self.sigterm)
|
||||||
|
|
||||||
def sigterm(self, signum, frame):
|
def sigterm(self, signum, frame):
|
||||||
@@ -1006,7 +1081,10 @@ class JupyterHub(Application):
|
|||||||
if not self.io_loop:
|
if not self.io_loop:
|
||||||
return
|
return
|
||||||
if self.http_server:
|
if self.http_server:
|
||||||
|
if self.io_loop._running:
|
||||||
self.io_loop.add_callback(self.http_server.stop)
|
self.io_loop.add_callback(self.http_server.stop)
|
||||||
|
else:
|
||||||
|
self.http_server.stop()
|
||||||
self.io_loop.add_callback(self.io_loop.stop)
|
self.io_loop.add_callback(self.io_loop.stop)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
@@ -1020,7 +1098,7 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def launch_instance(cls, argv=None):
|
def launch_instance(cls, argv=None):
|
||||||
self = cls.instance(argv=argv)
|
self = cls.instance()
|
||||||
loop = IOLoop.current()
|
loop = IOLoop.current()
|
||||||
loop.add_callback(self.launch_instance_async, argv)
|
loop.add_callback(self.launch_instance_async, argv)
|
||||||
try:
|
try:
|
||||||
|
@@ -3,14 +3,15 @@
|
|||||||
# Copyright (c) IPython Development Team.
|
# Copyright (c) IPython Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
from grp import getgrnam
|
||||||
import pwd
|
import pwd
|
||||||
from subprocess import check_call, check_output, CalledProcessError
|
from subprocess import check_call, check_output, CalledProcessError
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
import simplepam
|
import pamela
|
||||||
|
|
||||||
from IPython.config import LoggingConfigurable
|
from traitlets.config import LoggingConfigurable
|
||||||
from IPython.utils.traitlets import Bool, Set, Unicode, Any
|
from traitlets import Bool, Set, Unicode, Any
|
||||||
|
|
||||||
from .handlers.login import LoginHandler
|
from .handlers.login import LoginHandler
|
||||||
from .utils import url_path_join
|
from .utils import url_path_join
|
||||||
@@ -22,6 +23,12 @@ class Authenticator(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
db = Any()
|
db = Any()
|
||||||
|
admin_users = Set(config=True,
|
||||||
|
help="""set of usernames of admin users
|
||||||
|
|
||||||
|
If unspecified, only the user that launches the server will be admin.
|
||||||
|
"""
|
||||||
|
)
|
||||||
whitelist = Set(config=True,
|
whitelist = Set(config=True,
|
||||||
help="""Username whitelist.
|
help="""Username whitelist.
|
||||||
|
|
||||||
@@ -29,7 +36,18 @@ class Authenticator(LoggingConfigurable):
|
|||||||
If empty, allow any user to attempt login.
|
If empty, allow any user to attempt login.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
custom_html = Unicode('')
|
custom_html = Unicode('',
|
||||||
|
help="""HTML login form for custom handlers.
|
||||||
|
Override in form-based custom authenticators
|
||||||
|
that don't use username+password,
|
||||||
|
or need custom branding.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
login_service = Unicode('',
|
||||||
|
help="""Name of the login service for external
|
||||||
|
login services (e.g. 'GitHub').
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def authenticate(self, handler, data):
|
def authenticate(self, handler, data):
|
||||||
@@ -40,6 +58,25 @@ class Authenticator(LoggingConfigurable):
|
|||||||
and return None on failed authentication.
|
and return None on failed authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def pre_spawn_start(self, user, spawner):
|
||||||
|
"""Hook called before spawning a user's server.
|
||||||
|
|
||||||
|
Can be used to do auth-related startup, e.g. opening PAM sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post_spawn_stop(self, user, spawner):
|
||||||
|
"""Hook called after stopping a user container.
|
||||||
|
|
||||||
|
Can be used to do auth-related cleanup, e.g. closing PAM sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def check_whitelist(self, user):
|
||||||
|
"""
|
||||||
|
Return True if the whitelist is empty or user is in the whitelist.
|
||||||
|
"""
|
||||||
|
# Parens aren't necessary here, but they make this easier to parse.
|
||||||
|
return (not self.whitelist) or (user in self.whitelist)
|
||||||
|
|
||||||
def add_user(self, user):
|
def add_user(self, user):
|
||||||
"""Add a new user
|
"""Add a new user
|
||||||
|
|
||||||
@@ -56,8 +93,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
Removes the user from the whitelist.
|
Removes the user from the whitelist.
|
||||||
"""
|
"""
|
||||||
if user.name in self.whitelist:
|
self.whitelist.discard(user.name)
|
||||||
self.whitelist.remove(user.name)
|
|
||||||
|
|
||||||
def login_url(self, base_url):
|
def login_url(self, base_url):
|
||||||
"""Override to register a custom login handler"""
|
"""Override to register a custom login handler"""
|
||||||
@@ -88,6 +124,36 @@ class LocalAuthenticator(Authenticator):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
group_whitelist = Set(
|
||||||
|
config=True,
|
||||||
|
help="Automatically whitelist anyone in this group.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _group_whitelist_changed(self, name, old, new):
|
||||||
|
if self.whitelist:
|
||||||
|
self.log.warn(
|
||||||
|
"Ignoring username whitelist because group whitelist supplied!"
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_whitelist(self, username):
|
||||||
|
if self.group_whitelist:
|
||||||
|
return self.check_group_whitelist(username)
|
||||||
|
else:
|
||||||
|
return super().check_whitelist(username)
|
||||||
|
|
||||||
|
def check_group_whitelist(self, username):
|
||||||
|
if not self.group_whitelist:
|
||||||
|
return False
|
||||||
|
for grnam in self.group_whitelist:
|
||||||
|
try:
|
||||||
|
group = getgrnam(grnam)
|
||||||
|
except KeyError:
|
||||||
|
self.log.error('No such group: [%s]' % grnam)
|
||||||
|
continue
|
||||||
|
if username in group.gr_mem:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def add_user(self, user):
|
def add_user(self, user):
|
||||||
"""Add a new user
|
"""Add a new user
|
||||||
@@ -152,12 +218,26 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
Return None otherwise.
|
Return None otherwise.
|
||||||
"""
|
"""
|
||||||
username = data['username']
|
username = data['username']
|
||||||
if self.whitelist and username not in self.whitelist:
|
if not self.check_whitelist(username):
|
||||||
return
|
return
|
||||||
# simplepam wants bytes, not unicode
|
try:
|
||||||
# see simplepam#3
|
pamela.authenticate(username, data['password'], service=self.service)
|
||||||
busername = username.encode(self.encoding)
|
except pamela.PAMError as e:
|
||||||
bpassword = data['password'].encode(self.encoding)
|
self.log.warn("PAM Authentication failed: %s", e)
|
||||||
if simplepam.authenticate(busername, bpassword, service=self.service):
|
else:
|
||||||
return username
|
return username
|
||||||
|
|
||||||
|
def pre_spawn_start(self, user, spawner):
|
||||||
|
"""Open PAM session for user"""
|
||||||
|
try:
|
||||||
|
pamela.open_session(user.name, service=self.service)
|
||||||
|
except pamela.PAMError as e:
|
||||||
|
self.log.warn("Failed to open PAM session for %s: %s", user.name, e)
|
||||||
|
|
||||||
|
def post_spawn_stop(self, user, spawner):
|
||||||
|
"""Close PAM session for user"""
|
||||||
|
try:
|
||||||
|
pamela.close_session(user.name, service=self.service)
|
||||||
|
except pamela.PAMError as e:
|
||||||
|
self.log.warn("Failed to close PAM session for %s: %s", user.name, e)
|
||||||
|
|
||||||
|
@@ -22,6 +22,13 @@ from ..utils import url_path_join
|
|||||||
# pattern for the authentication token header
|
# pattern for the authentication token header
|
||||||
auth_header_pat = re.compile(r'^token\s+([^\s]+)$')
|
auth_header_pat = re.compile(r'^token\s+([^\s]+)$')
|
||||||
|
|
||||||
|
# mapping of reason: reason_message
|
||||||
|
reasons = {
|
||||||
|
'timeout': "Failed to reach your server."
|
||||||
|
" Please try again later."
|
||||||
|
" Contact admin if the issue persists.",
|
||||||
|
'error': "Failed to start your server. Please contact admin.",
|
||||||
|
}
|
||||||
|
|
||||||
class BaseHandler(RequestHandler):
|
class BaseHandler(RequestHandler):
|
||||||
"""Base Handler class with access to common methods and properties."""
|
"""Base Handler class with access to common methods and properties."""
|
||||||
@@ -62,7 +69,40 @@ class BaseHandler(RequestHandler):
|
|||||||
def finish(self, *args, **kwargs):
|
def finish(self, *args, **kwargs):
|
||||||
"""Roll back any uncommitted transactions from the handler."""
|
"""Roll back any uncommitted transactions from the handler."""
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
super(BaseHandler, self).finish(*args, **kwargs)
|
super().finish(*args, **kwargs)
|
||||||
|
|
||||||
|
#---------------------------------------------------------------
|
||||||
|
# Security policies
|
||||||
|
#---------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def csp_report_uri(self):
|
||||||
|
return self.settings.get('csp_report_uri',
|
||||||
|
url_path_join(self.hub.server.base_url, 'security/csp-report')
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_security_policy(self):
|
||||||
|
"""The default Content-Security-Policy header
|
||||||
|
|
||||||
|
Can be overridden by defining Content-Security-Policy in settings['headers']
|
||||||
|
"""
|
||||||
|
return '; '.join([
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
"report-uri " + self.csp_report_uri,
|
||||||
|
])
|
||||||
|
|
||||||
|
def set_default_headers(self):
|
||||||
|
"""
|
||||||
|
Set any headers passed as tornado_settings['headers'].
|
||||||
|
|
||||||
|
By default sets Content-Security-Policy of frame-ancestors 'self'.
|
||||||
|
"""
|
||||||
|
headers = self.settings.get('headers', {})
|
||||||
|
headers.setdefault("Content-Security-Policy", self.content_security_policy)
|
||||||
|
|
||||||
|
for header_name, header_content in headers.items():
|
||||||
|
self.set_header(header_name, header_content)
|
||||||
|
|
||||||
#---------------------------------------------------------------
|
#---------------------------------------------------------------
|
||||||
# Login and cookie-related
|
# Login and cookie-related
|
||||||
@@ -72,6 +112,10 @@ class BaseHandler(RequestHandler):
|
|||||||
def admin_users(self):
|
def admin_users(self):
|
||||||
return self.settings.setdefault('admin_users', set())
|
return self.settings.setdefault('admin_users', set())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cookie_max_age_days(self):
|
||||||
|
return self.settings.get('cookie_max_age_days', None)
|
||||||
|
|
||||||
def get_current_user_token(self):
|
def get_current_user_token(self):
|
||||||
"""get_current_user from Authorization header token"""
|
"""get_current_user from Authorization header token"""
|
||||||
auth_header = self.request.headers.get('Authorization', '')
|
auth_header = self.request.headers.get('Authorization', '')
|
||||||
@@ -87,16 +131,25 @@ class BaseHandler(RequestHandler):
|
|||||||
|
|
||||||
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
||||||
"""Get the User for a given cookie, if there is one"""
|
"""Get the User for a given cookie, if there is one"""
|
||||||
cookie_id = self.get_secure_cookie(cookie_name, cookie_value)
|
cookie_id = self.get_secure_cookie(
|
||||||
|
cookie_name,
|
||||||
|
cookie_value,
|
||||||
|
max_age_days=self.cookie_max_age_days,
|
||||||
|
)
|
||||||
|
def clear():
|
||||||
|
self.clear_cookie(cookie_name, path=self.hub.server.base_url)
|
||||||
|
|
||||||
if cookie_id is None:
|
if cookie_id is None:
|
||||||
|
if self.get_cookie(cookie_name):
|
||||||
|
self.log.warn("Invalid or expired cookie token")
|
||||||
|
clear()
|
||||||
return
|
return
|
||||||
cookie_id = cookie_id.decode('utf8', 'replace')
|
cookie_id = cookie_id.decode('utf8', 'replace')
|
||||||
user = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
user = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
||||||
if user is None:
|
if user is None:
|
||||||
# don't log the token itself
|
|
||||||
self.log.warn("Invalid cookie token")
|
self.log.warn("Invalid cookie token")
|
||||||
# have cookie, but it's not valid. Clear it and start over.
|
# have cookie, but it's not valid. Clear it and start over.
|
||||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
clear()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def get_current_user_cookie(self):
|
def get_current_user_cookie(self):
|
||||||
@@ -126,26 +179,44 @@ class BaseHandler(RequestHandler):
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def clear_login_cookie(self):
|
def clear_login_cookie(self, name=None):
|
||||||
|
if name is None:
|
||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
|
else:
|
||||||
|
user = self.find_user(name)
|
||||||
if user and user.server:
|
if user and user.server:
|
||||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
|
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
|
||||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
||||||
|
|
||||||
def set_server_cookie(self, user):
|
def set_server_cookie(self, user):
|
||||||
"""set the login cookie for the single-user server"""
|
"""set the login cookie for the single-user server"""
|
||||||
|
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||||
|
# 'secure' kwarg is passed to set_secure_cookie
|
||||||
|
if self.request.protocol == 'https':
|
||||||
|
kwargs = {'secure':True}
|
||||||
|
else:
|
||||||
|
kwargs = {}
|
||||||
self.set_secure_cookie(
|
self.set_secure_cookie(
|
||||||
user.server.cookie_name,
|
user.server.cookie_name,
|
||||||
user.cookie_id,
|
user.cookie_id,
|
||||||
path=user.server.base_url,
|
path=user.server.base_url,
|
||||||
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_hub_cookie(self, user):
|
def set_hub_cookie(self, user):
|
||||||
"""set the login cookie for the Hub"""
|
"""set the login cookie for the Hub"""
|
||||||
|
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||||
|
# 'secure' kwarg is passed to set_secure_cookie
|
||||||
|
if self.request.protocol == 'https':
|
||||||
|
kwargs = {'secure':True}
|
||||||
|
else:
|
||||||
|
kwargs = {}
|
||||||
self.set_secure_cookie(
|
self.set_secure_cookie(
|
||||||
self.hub.server.cookie_name,
|
self.hub.server.cookie_name,
|
||||||
user.cookie_id,
|
user.cookie_id,
|
||||||
path=self.hub.server.base_url)
|
path=self.hub.server.base_url,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
def set_login_cookie(self, user):
|
def set_login_cookie(self, user):
|
||||||
"""Set login cookies for the Hub and single-user server."""
|
"""Set login cookies for the Hub and single-user server."""
|
||||||
@@ -194,6 +265,7 @@ class BaseHandler(RequestHandler):
|
|||||||
base_url=self.base_url,
|
base_url=self.base_url,
|
||||||
hub=self.hub,
|
hub=self.hub,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
|
authenticator=self.authenticator,
|
||||||
)
|
)
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def finish_user_spawn(f=None):
|
def finish_user_spawn(f=None):
|
||||||
@@ -289,6 +361,7 @@ class BaseHandler(RequestHandler):
|
|||||||
prefix=self.base_url,
|
prefix=self.base_url,
|
||||||
user=user,
|
user=user,
|
||||||
login_url=self.settings['login_url'],
|
login_url=self.settings['login_url'],
|
||||||
|
login_service=self.authenticator.login_service,
|
||||||
logout_url=self.settings['logout_url'],
|
logout_url=self.settings['logout_url'],
|
||||||
static_url=self.static_url,
|
static_url=self.static_url,
|
||||||
version_hash=self.version_hash,
|
version_hash=self.version_hash,
|
||||||
@@ -310,7 +383,7 @@ class BaseHandler(RequestHandler):
|
|||||||
# construct the custom reason, if defined
|
# construct the custom reason, if defined
|
||||||
reason = getattr(exception, 'reason', '')
|
reason = getattr(exception, 'reason', '')
|
||||||
if reason:
|
if reason:
|
||||||
status_message = reason
|
message = reasons.get(reason, reason)
|
||||||
|
|
||||||
# build template namespace
|
# build template namespace
|
||||||
ns = dict(
|
ns = dict(
|
||||||
@@ -343,11 +416,12 @@ class PrefixRedirectHandler(BaseHandler):
|
|||||||
Redirects /foo to /prefix/foo, etc.
|
Redirects /foo to /prefix/foo, etc.
|
||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
path = self.request.path[len(self.base_url):]
|
path = self.request.uri[len(self.base_url):]
|
||||||
self.redirect(url_path_join(
|
self.redirect(url_path_join(
|
||||||
self.hub.server.base_url, path,
|
self.hub.server.base_url, path,
|
||||||
), permanent=False)
|
), permanent=False)
|
||||||
|
|
||||||
|
|
||||||
class UserSpawnHandler(BaseHandler):
|
class UserSpawnHandler(BaseHandler):
|
||||||
"""Requests to /user/name handled by the Hub
|
"""Requests to /user/name handled by the Hub
|
||||||
should result in spawning the single-user server and
|
should result in spawning the single-user server and
|
||||||
@@ -373,7 +447,7 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
yield self.spawn_single_user(current_user)
|
yield self.spawn_single_user(current_user)
|
||||||
# set login cookie anew
|
# set login cookie anew
|
||||||
self.set_login_cookie(current_user)
|
self.set_login_cookie(current_user)
|
||||||
without_prefix = self.request.path[len(self.hub.server.base_url):]
|
without_prefix = self.request.uri[len(self.hub.server.base_url):]
|
||||||
target = url_path_join(self.base_url, without_prefix)
|
target = url_path_join(self.base_url, without_prefix)
|
||||||
self.redirect(target)
|
self.redirect(target)
|
||||||
else:
|
else:
|
||||||
@@ -382,9 +456,18 @@ class UserSpawnHandler(BaseHandler):
|
|||||||
self.clear_login_cookie()
|
self.clear_login_cookie()
|
||||||
self.redirect(url_concat(
|
self.redirect(url_concat(
|
||||||
self.settings['login_url'],
|
self.settings['login_url'],
|
||||||
{'next': self.request.path,
|
{'next': self.request.uri,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
class CSPReportHandler(BaseHandler):
|
||||||
|
'''Accepts a content security policy violation report'''
|
||||||
|
@web.authenticated
|
||||||
|
def post(self):
|
||||||
|
'''Log a content security policy violation report'''
|
||||||
|
self.log.warn("Content security violation: %s",
|
||||||
|
self.request.body.decode('utf8', 'replace'))
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
||||||
|
(r'/security/csp-report', CSPReportHandler),
|
||||||
]
|
]
|
||||||
|
@@ -12,31 +12,44 @@ from .base import BaseHandler
|
|||||||
class LogoutHandler(BaseHandler):
|
class LogoutHandler(BaseHandler):
|
||||||
"""Log a user out by clearing their login cookie."""
|
"""Log a user out by clearing their login cookie."""
|
||||||
def get(self):
|
def get(self):
|
||||||
|
user = self.get_current_user()
|
||||||
|
if user:
|
||||||
|
self.log.info("User logged out: %s", user.name)
|
||||||
self.clear_login_cookie()
|
self.clear_login_cookie()
|
||||||
html = self.render_template('logout.html')
|
for name in user.other_user_cookies:
|
||||||
self.finish(html)
|
self.clear_login_cookie(name)
|
||||||
|
user.other_user_cookies = set([])
|
||||||
|
self.redirect(self.hub.server.base_url, permanent=False)
|
||||||
|
|
||||||
|
|
||||||
class LoginHandler(BaseHandler):
|
class LoginHandler(BaseHandler):
|
||||||
"""Render the login page."""
|
"""Render the login page."""
|
||||||
|
|
||||||
def _render(self, message=None, username=None):
|
def _render(self, login_error=None, username=None):
|
||||||
return self.render_template('login.html',
|
return self.render_template('login.html',
|
||||||
next=url_escape(self.get_argument('next', default='')),
|
next=url_escape(self.get_argument('next', default='')),
|
||||||
username=username,
|
username=username,
|
||||||
message=message,
|
login_error=login_error,
|
||||||
custom_html=self.authenticator.custom_html,
|
custom_login_form=self.authenticator.custom_html,
|
||||||
|
login_url=self.settings['login_url'],
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
next_url = self.get_argument('next', False)
|
next_url = self.get_argument('next', '')
|
||||||
if next_url and self.get_current_user():
|
if not next_url.startswith('/'):
|
||||||
|
# disallow non-absolute next URLs (e.g. full URLs)
|
||||||
|
next_url = ''
|
||||||
|
user = self.get_current_user()
|
||||||
|
if user:
|
||||||
|
if not next_url:
|
||||||
|
if user.running:
|
||||||
|
next_url = user.server.base_url
|
||||||
|
else:
|
||||||
|
next_url = self.hub.server.base_url
|
||||||
# set new login cookie
|
# set new login cookie
|
||||||
# because single-user cookie may have been cleared or incorrect
|
# because single-user cookie may have been cleared or incorrect
|
||||||
self.set_login_cookie(self.get_current_user())
|
self.set_login_cookie(self.get_current_user())
|
||||||
self.redirect(next_url, permanent=False)
|
self.redirect(next_url, permanent=False)
|
||||||
elif not next_url and self.get_current_user():
|
|
||||||
self.redirect(self.hub.server.base_url, permanent=False)
|
|
||||||
else:
|
else:
|
||||||
username = self.get_argument('username', default='')
|
username = self.get_argument('username', default='')
|
||||||
self.finish(self._render(username=username))
|
self.finish(self._render(username=username))
|
||||||
@@ -48,9 +61,8 @@ class LoginHandler(BaseHandler):
|
|||||||
for arg in self.request.arguments:
|
for arg in self.request.arguments:
|
||||||
data[arg] = self.get_argument(arg)
|
data[arg] = self.get_argument(arg)
|
||||||
|
|
||||||
username = data['username']
|
username = yield self.authenticate(data)
|
||||||
authorized = yield self.authenticate(data)
|
if username:
|
||||||
if authorized:
|
|
||||||
user = self.user_from_username(username)
|
user = self.user_from_username(username)
|
||||||
already_running = False
|
already_running = False
|
||||||
if user.spawner:
|
if user.spawner:
|
||||||
@@ -59,12 +71,16 @@ class LoginHandler(BaseHandler):
|
|||||||
if not already_running:
|
if not already_running:
|
||||||
yield self.spawn_single_user(user)
|
yield self.spawn_single_user(user)
|
||||||
self.set_login_cookie(user)
|
self.set_login_cookie(user)
|
||||||
next_url = self.get_argument('next', default='') or self.hub.server.base_url
|
next_url = self.get_argument('next', default='')
|
||||||
|
if not next_url.startswith('/'):
|
||||||
|
next_url = ''
|
||||||
|
next_url = next_url or self.hub.server.base_url
|
||||||
self.redirect(next_url)
|
self.redirect(next_url)
|
||||||
|
self.log.info("User logged in: %s", username)
|
||||||
else:
|
else:
|
||||||
self.log.debug("Failed login for %s", username)
|
self.log.debug("Failed login for %s", username)
|
||||||
html = self._render(
|
html = self._render(
|
||||||
message={'error': 'Invalid username or password'},
|
login_error='Invalid username or password',
|
||||||
username=username,
|
username=username,
|
||||||
)
|
)
|
||||||
self.finish(html)
|
self.finish(html)
|
||||||
@@ -72,5 +88,6 @@ class LoginHandler(BaseHandler):
|
|||||||
|
|
||||||
# Only logout is a default handler.
|
# Only logout is a default handler.
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
|
(r"/login", LoginHandler),
|
||||||
(r"/logout", LogoutHandler),
|
(r"/logout", LogoutHandler),
|
||||||
]
|
]
|
||||||
|
@@ -8,26 +8,33 @@ from tornado import web
|
|||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import admin_only, url_path_join
|
from ..utils import admin_only, url_path_join
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
|
from .login import LoginHandler
|
||||||
|
|
||||||
|
|
||||||
class RootHandler(BaseHandler):
|
class RootHandler(BaseHandler):
|
||||||
"""Render the Hub root page.
|
"""Render the Hub root page.
|
||||||
|
|
||||||
Currently redirects to home if logged in,
|
If logged in, redirects to:
|
||||||
shows big fat login button otherwise.
|
|
||||||
|
- single-user server if running
|
||||||
|
- hub home, otherwise
|
||||||
|
|
||||||
|
Otherwise, renders login page.
|
||||||
"""
|
"""
|
||||||
def get(self):
|
def get(self):
|
||||||
if self.get_current_user():
|
user = self.get_current_user()
|
||||||
self.redirect(
|
if user:
|
||||||
url_path_join(self.hub.server.base_url, 'home'),
|
if user.running:
|
||||||
permanent=False,
|
url = user.server.base_url
|
||||||
)
|
self.log.debug("User is running: %s", url)
|
||||||
|
else:
|
||||||
|
url = url_path_join(self.hub.server.base_url, 'home')
|
||||||
|
self.log.debug("User is not running: %s", url)
|
||||||
|
self.redirect(url)
|
||||||
return
|
return
|
||||||
|
url = url_path_join(self.hub.server.base_url, 'login')
|
||||||
|
self.redirect(url)
|
||||||
|
|
||||||
html = self.render_template('index.html',
|
|
||||||
login_url=self.settings['login_url'],
|
|
||||||
)
|
|
||||||
self.finish(html)
|
|
||||||
|
|
||||||
class HomeHandler(BaseHandler):
|
class HomeHandler(BaseHandler):
|
||||||
"""Render the user's home page."""
|
"""Render the user's home page."""
|
||||||
|
@@ -2,9 +2,12 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from tornado.log import LogFormatter
|
from tornado.log import LogFormatter, access_log
|
||||||
|
from tornado.web import StaticFileHandler
|
||||||
|
|
||||||
|
|
||||||
def coroutine_traceback(typ, value, tb):
|
def coroutine_traceback(typ, value, tb):
|
||||||
"""Scrub coroutine frames from a traceback
|
"""Scrub coroutine frames from a traceback
|
||||||
@@ -38,3 +41,61 @@ class CoroutineLogFormatter(LogFormatter):
|
|||||||
def formatException(self, exc_info):
|
def formatException(self, exc_info):
|
||||||
return ''.join(coroutine_traceback(*exc_info))
|
return ''.join(coroutine_traceback(*exc_info))
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_uri(uri):
|
||||||
|
"""scrub auth info from uri"""
|
||||||
|
if '/api/authorizations/cookie/' in uri or '/api/authorizations/token/' in uri:
|
||||||
|
uri = uri.rsplit('/', 1)[0] + '/[secret]'
|
||||||
|
return uri
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_headers(headers):
|
||||||
|
"""scrub auth info from headers"""
|
||||||
|
headers = dict(headers)
|
||||||
|
if 'Authorization' in headers:
|
||||||
|
auth = headers['Authorization']
|
||||||
|
if auth.startswith('token '):
|
||||||
|
headers['Authorization'] = 'token [secret]'
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
# log_request adapted from IPython (BSD)
|
||||||
|
|
||||||
|
def log_request(handler):
|
||||||
|
"""log a bit more information about each request than tornado's default
|
||||||
|
|
||||||
|
- move static file get success to debug-level (reduces noise)
|
||||||
|
- get proxied IP instead of proxy IP
|
||||||
|
- log referer for redirect and failed requests
|
||||||
|
- log user-agent for failed requests
|
||||||
|
"""
|
||||||
|
status = handler.get_status()
|
||||||
|
request = handler.request
|
||||||
|
if status == 304 or (status < 300 and isinstance(handler, StaticFileHandler)):
|
||||||
|
# static-file success and 304 Found are debug-level
|
||||||
|
log_method = access_log.debug
|
||||||
|
elif status < 400:
|
||||||
|
log_method = access_log.info
|
||||||
|
elif status < 500:
|
||||||
|
log_method = access_log.warning
|
||||||
|
else:
|
||||||
|
log_method = access_log.error
|
||||||
|
|
||||||
|
uri = _scrub_uri(request.uri)
|
||||||
|
headers = _scrub_headers(request.headers)
|
||||||
|
|
||||||
|
request_time = 1000.0 * handler.request.request_time()
|
||||||
|
user = handler.get_current_user()
|
||||||
|
ns = dict(
|
||||||
|
status=status,
|
||||||
|
method=request.method,
|
||||||
|
ip=request.remote_ip,
|
||||||
|
uri=uri,
|
||||||
|
request_time=request_time,
|
||||||
|
user=user.name if user else ''
|
||||||
|
)
|
||||||
|
msg = "{status} {method} {uri} ({user}@{ip}) {request_time:.2f}ms"
|
||||||
|
if status >= 500 and status != 502:
|
||||||
|
log_method(json.dumps(headers, indent=2))
|
||||||
|
log_method(msg.format(**ns))
|
||||||
|
|
||||||
|
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta
|
|||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
@@ -75,9 +76,13 @@ class Server(Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def host(self):
|
def host(self):
|
||||||
|
ip = self.ip
|
||||||
|
if ip in {'', '0.0.0.0'}:
|
||||||
|
# when listening on all interfaces, connect to localhost
|
||||||
|
ip = 'localhost'
|
||||||
return "{proto}://{ip}:{port}".format(
|
return "{proto}://{ip}:{port}".format(
|
||||||
proto=self.proto,
|
proto=self.proto,
|
||||||
ip=self.ip or 'localhost',
|
ip=ip,
|
||||||
port=self.port,
|
port=self.port,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,6 +93,17 @@ class Server(Base):
|
|||||||
uri=self.base_url,
|
uri=self.base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bind_url(self):
|
||||||
|
"""representation of URL used for binding
|
||||||
|
|
||||||
|
Never used in APIs, only logging,
|
||||||
|
since it can be non-connectable value, such as '', meaning all interfaces.
|
||||||
|
"""
|
||||||
|
if self.ip in {'', '0.0.0.0'}:
|
||||||
|
return self.url.replace('localhost', self.ip or '*', 1)
|
||||||
|
return self.url
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_up(self, timeout=10, http=False):
|
def wait_up(self, timeout=10, http=False):
|
||||||
"""Wait for this server to come up"""
|
"""Wait for this server to come up"""
|
||||||
@@ -270,6 +286,8 @@ class User(Base):
|
|||||||
spawn_pending = False
|
spawn_pending = False
|
||||||
stop_pending = False
|
stop_pending = False
|
||||||
|
|
||||||
|
other_user_cookies = set([])
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if self.server:
|
if self.server:
|
||||||
return "<{cls}({name}@{ip}:{port})>".format(
|
return "<{cls}({name}@{ip}:{port})>".format(
|
||||||
@@ -284,6 +302,11 @@ class User(Base):
|
|||||||
name=self.name,
|
name=self.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def escaped_name(self):
|
||||||
|
"""My name, escaped for use in URLs, cookies, etc."""
|
||||||
|
return quote(self.name, safe='@')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def running(self):
|
def running(self):
|
||||||
"""property for whether a user has a running server"""
|
"""property for whether a user has a running server"""
|
||||||
@@ -313,14 +336,15 @@ class User(Base):
|
|||||||
return db.query(cls).filter(cls.name==name).first()
|
return db.query(cls).filter(cls.name==name).first()
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def spawn(self, spawner_class, base_url='/', hub=None, config=None):
|
def spawn(self, spawner_class, base_url='/', hub=None, authenticator=None, config=None):
|
||||||
"""Start the user's spawner"""
|
"""Start the user's spawner"""
|
||||||
db = inspect(self).session
|
db = inspect(self).session
|
||||||
if hub is None:
|
if hub is None:
|
||||||
hub = db.query(Hub).first()
|
hub = db.query(Hub).first()
|
||||||
|
|
||||||
self.server = Server(
|
self.server = Server(
|
||||||
cookie_name='%s-%s' % (hub.server.cookie_name, self.name),
|
cookie_name='%s-%s' % (hub.server.cookie_name, quote(self.name, safe='')),
|
||||||
base_url=url_path_join(base_url, 'user', self.name),
|
base_url=url_path_join(base_url, 'user', self.escaped_name),
|
||||||
)
|
)
|
||||||
db.add(self.server)
|
db.add(self.server)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -333,11 +357,15 @@ class User(Base):
|
|||||||
user=self,
|
user=self,
|
||||||
hub=hub,
|
hub=hub,
|
||||||
db=db,
|
db=db,
|
||||||
|
authenticator=authenticator,
|
||||||
)
|
)
|
||||||
# we are starting a new server, make sure it doesn't restore state
|
# we are starting a new server, make sure it doesn't restore state
|
||||||
spawner.clear_state()
|
spawner.clear_state()
|
||||||
spawner.api_token = api_token
|
spawner.api_token = api_token
|
||||||
|
|
||||||
|
# trigger pre-spawn hook on authenticator
|
||||||
|
if (authenticator):
|
||||||
|
yield gen.maybe_future(authenticator.pre_spawn_start(self, spawner))
|
||||||
self.spawn_pending = True
|
self.spawn_pending = True
|
||||||
# wait for spawner.start to return
|
# wait for spawner.start to return
|
||||||
try:
|
try:
|
||||||
@@ -348,10 +376,12 @@ class User(Base):
|
|||||||
self.log.warn("{user}'s server failed to start in {s} seconds, giving up".format(
|
self.log.warn("{user}'s server failed to start in {s} seconds, giving up".format(
|
||||||
user=self.name, s=spawner.start_timeout,
|
user=self.name, s=spawner.start_timeout,
|
||||||
))
|
))
|
||||||
|
e.reason = 'timeout'
|
||||||
else:
|
else:
|
||||||
self.log.error("Unhandled error starting {user}'s server: {error}".format(
|
self.log.error("Unhandled error starting {user}'s server: {error}".format(
|
||||||
user=self.name, error=e,
|
user=self.name, error=e,
|
||||||
))
|
))
|
||||||
|
e.reason = 'error'
|
||||||
try:
|
try:
|
||||||
yield self.stop()
|
yield self.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -378,7 +408,9 @@ class User(Base):
|
|||||||
http_timeout=spawner.http_timeout,
|
http_timeout=spawner.http_timeout,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
e.reason = 'timeout'
|
||||||
else:
|
else:
|
||||||
|
e.reason = 'error'
|
||||||
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
|
self.log.error("Unhandled error waiting for {user}'s server to show up at {url}: {error}".format(
|
||||||
user=self.name, url=self.server.url, error=e,
|
user=self.name, url=self.server.url, error=e,
|
||||||
))
|
))
|
||||||
@@ -400,22 +432,27 @@ class User(Base):
|
|||||||
and cleanup after it.
|
and cleanup after it.
|
||||||
"""
|
"""
|
||||||
self.spawn_pending = False
|
self.spawn_pending = False
|
||||||
if self.spawner is None:
|
spawner = self.spawner
|
||||||
|
if spawner is None:
|
||||||
return
|
return
|
||||||
self.spawner.stop_polling()
|
spawner.stop_polling()
|
||||||
self.stop_pending = True
|
self.stop_pending = True
|
||||||
try:
|
try:
|
||||||
status = yield self.spawner.poll()
|
status = yield spawner.poll()
|
||||||
if status is None:
|
if status is None:
|
||||||
yield self.spawner.stop()
|
yield self.spawner.stop()
|
||||||
self.spawner.clear_state()
|
spawner.clear_state()
|
||||||
self.state = self.spawner.get_state()
|
self.state = spawner.get_state()
|
||||||
self.last_activity = datetime.utcnow()
|
|
||||||
self.server = None
|
self.server = None
|
||||||
inspect(self).session.commit()
|
inspect(self).session.commit()
|
||||||
finally:
|
finally:
|
||||||
self.stop_pending = False
|
self.stop_pending = False
|
||||||
|
# trigger post-spawner hook on authenticator
|
||||||
|
auth = spawner.authenticator
|
||||||
|
if auth:
|
||||||
|
yield gen.maybe_future(
|
||||||
|
auth.post_spawn_stop(self, spawner)
|
||||||
|
)
|
||||||
|
|
||||||
class APIToken(Base):
|
class APIToken(Base):
|
||||||
"""An API token"""
|
"""An API token"""
|
||||||
|
@@ -1,30 +1,51 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
"""Extend regular notebook server to be aware of multiuser things."""
|
"""Extend regular notebook server to be aware of multiuser things."""
|
||||||
|
|
||||||
# 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.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
try:
|
||||||
|
from urllib.parse import quote
|
||||||
|
except ImportError:
|
||||||
|
# PY2 Compat
|
||||||
|
from urllib import quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from jinja2 import ChoiceLoader, FunctionLoader
|
||||||
|
|
||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
from tornado.web import HTTPError
|
from tornado.web import HTTPError
|
||||||
|
|
||||||
from IPython.utils.traitlets import Unicode
|
|
||||||
|
|
||||||
from IPython.html.notebookapp import NotebookApp
|
from IPython.utils.traitlets import (
|
||||||
|
Integer,
|
||||||
|
Unicode,
|
||||||
|
CUnicode,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import notebook
|
||||||
|
# 4.x
|
||||||
|
except ImportError:
|
||||||
|
from IPython.html.notebookapp import NotebookApp, aliases as notebook_aliases
|
||||||
from IPython.html.auth.login import LoginHandler
|
from IPython.html.auth.login import LoginHandler
|
||||||
from IPython.html.auth.logout import LogoutHandler
|
from IPython.html.auth.logout import LogoutHandler
|
||||||
|
|
||||||
from IPython.html.utils import url_path_join
|
from IPython.html.utils import url_path_join
|
||||||
|
|
||||||
|
|
||||||
from distutils.version import LooseVersion as V
|
from distutils.version import LooseVersion as V
|
||||||
|
|
||||||
import IPython
|
import IPython
|
||||||
if V(IPython.__version__) < V('3.0'):
|
if V(IPython.__version__) < V('3.0'):
|
||||||
raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__)
|
raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__)
|
||||||
|
else:
|
||||||
|
from notebook.notebookapp import NotebookApp, aliases as notebook_aliases
|
||||||
|
from notebook.auth.login import LoginHandler
|
||||||
|
from notebook.auth.logout import LogoutHandler
|
||||||
|
|
||||||
|
from notebook.utils import url_path_join
|
||||||
|
|
||||||
|
|
||||||
# Define two methods to attach to AuthenticatedHandler,
|
# Define two methods to attach to AuthenticatedHandler,
|
||||||
# which authenticate via the central auth server.
|
# which authenticate via the central auth server.
|
||||||
@@ -45,14 +66,13 @@ class JupyterHubLoginHandler(LoginHandler):
|
|||||||
hub_api_url = self.settings['hub_api_url']
|
hub_api_url = self.settings['hub_api_url']
|
||||||
hub_api_key = self.settings['hub_api_key']
|
hub_api_key = self.settings['hub_api_key']
|
||||||
r = requests.get(url_path_join(
|
r = requests.get(url_path_join(
|
||||||
hub_api_url, "authorizations/cookie", cookie_name,
|
hub_api_url, "authorizations/cookie", cookie_name, quote(encrypted_cookie, safe=''),
|
||||||
),
|
),
|
||||||
headers = {'Authorization' : 'token %s' % hub_api_key},
|
headers = {'Authorization' : 'token %s' % hub_api_key},
|
||||||
data=encrypted_cookie,
|
|
||||||
)
|
)
|
||||||
if r.status_code == 404:
|
if r.status_code == 404:
|
||||||
data = {'user' : ''}
|
data = None
|
||||||
if r.status_code == 403:
|
elif r.status_code == 403:
|
||||||
self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason)
|
self.log.error("I don't have permission to verify cookies, my auth token may have expired: [%i] %s", r.status_code, r.reason)
|
||||||
raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted")
|
raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted")
|
||||||
elif r.status_code >= 500:
|
elif r.status_code >= 500:
|
||||||
@@ -83,7 +103,7 @@ class JupyterHubLoginHandler(LoginHandler):
|
|||||||
if not auth_data:
|
if not auth_data:
|
||||||
# treat invalid token the same as no token
|
# treat invalid token the same as no token
|
||||||
return None
|
return None
|
||||||
user = auth_data['user']
|
user = auth_data['name']
|
||||||
if user == my_user:
|
if user == my_user:
|
||||||
self._cached_user = user
|
self._cached_user = user
|
||||||
return user
|
return user
|
||||||
@@ -100,7 +120,7 @@ class JupyterHubLogoutHandler(LogoutHandler):
|
|||||||
|
|
||||||
|
|
||||||
# register new hub related command-line aliases
|
# register new hub related command-line aliases
|
||||||
aliases = NotebookApp.aliases.get_default_value()
|
aliases = dict(notebook_aliases)
|
||||||
aliases.update({
|
aliases.update({
|
||||||
'user' : 'SingleUserNotebookApp.user',
|
'user' : 'SingleUserNotebookApp.user',
|
||||||
'cookie-name': 'SingleUserNotebookApp.cookie_name',
|
'cookie-name': 'SingleUserNotebookApp.cookie_name',
|
||||||
@@ -109,9 +129,23 @@ aliases.update({
|
|||||||
'base-url': 'SingleUserNotebookApp.base_url',
|
'base-url': 'SingleUserNotebookApp.base_url',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
page_template = """
|
||||||
|
{% extends "templates/page.html" %}
|
||||||
|
|
||||||
|
{% block header_buttons %}
|
||||||
|
{{super()}}
|
||||||
|
|
||||||
|
<a href='{{hub_control_panel_url}}'
|
||||||
|
class='btn btn-default btn-sm navbar-btn pull-right'
|
||||||
|
style='margin-right: 4px; margin-left: 2px;'
|
||||||
|
>
|
||||||
|
Control Panel</a>
|
||||||
|
{% endblock %}
|
||||||
|
"""
|
||||||
|
|
||||||
class SingleUserNotebookApp(NotebookApp):
|
class SingleUserNotebookApp(NotebookApp):
|
||||||
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
||||||
user = Unicode(config=True)
|
user = CUnicode(config=True)
|
||||||
def _user_changed(self, name, old, new):
|
def _user_changed(self, name, old, new):
|
||||||
self.log.name = new
|
self.log.name = new
|
||||||
cookie_name = Unicode(config=True)
|
cookie_name = Unicode(config=True)
|
||||||
@@ -119,9 +153,20 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
hub_api_url = Unicode(config=True)
|
hub_api_url = Unicode(config=True)
|
||||||
aliases = aliases
|
aliases = aliases
|
||||||
open_browser = False
|
open_browser = False
|
||||||
|
trust_xheaders = True
|
||||||
login_handler_class = JupyterHubLoginHandler
|
login_handler_class = JupyterHubLoginHandler
|
||||||
logout_handler_class = JupyterHubLogoutHandler
|
logout_handler_class = JupyterHubLogoutHandler
|
||||||
|
|
||||||
|
cookie_cache_lifetime = Integer(
|
||||||
|
config=True,
|
||||||
|
default_value=300,
|
||||||
|
allow_none=True,
|
||||||
|
help="""
|
||||||
|
Time, in seconds, that we cache a validated cookie before requiring
|
||||||
|
revalidation with the hub.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
def _log_datefmt_default(self):
|
def _log_datefmt_default(self):
|
||||||
"""Exclude date from default date format"""
|
"""Exclude date from default date format"""
|
||||||
return "%Y-%m-%d %H:%M:%S"
|
return "%Y-%m-%d %H:%M:%S"
|
||||||
@@ -134,6 +179,21 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
# disable the exit confirmation for background notebook processes
|
# disable the exit confirmation for background notebook processes
|
||||||
ioloop.IOLoop.instance().stop()
|
ioloop.IOLoop.instance().stop()
|
||||||
|
|
||||||
|
def _clear_cookie_cache(self):
|
||||||
|
self.log.debug("Clearing cookie cache")
|
||||||
|
self.tornado_settings['cookie_cache'].clear()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# Start a PeriodicCallback to clear cached cookies. This forces us to
|
||||||
|
# revalidate our user with the Hub at least every
|
||||||
|
# `cookie_cache_lifetime` seconds.
|
||||||
|
if self.cookie_cache_lifetime:
|
||||||
|
ioloop.PeriodicCallback(
|
||||||
|
self._clear_cookie_cache,
|
||||||
|
self.cookie_cache_lifetime * 1e3,
|
||||||
|
).start()
|
||||||
|
super(SingleUserNotebookApp, self).start()
|
||||||
|
|
||||||
def init_webapp(self):
|
def init_webapp(self):
|
||||||
# load the hub related settings into the tornado settings dict
|
# load the hub related settings into the tornado settings dict
|
||||||
env = os.environ
|
env = os.environ
|
||||||
@@ -143,9 +203,30 @@ class SingleUserNotebookApp(NotebookApp):
|
|||||||
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
|
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
|
||||||
s['hub_prefix'] = self.hub_prefix
|
s['hub_prefix'] = self.hub_prefix
|
||||||
s['cookie_name'] = self.cookie_name
|
s['cookie_name'] = self.cookie_name
|
||||||
s['login_url'] = url_path_join(self.hub_prefix, 'login')
|
s['login_url'] = self.hub_prefix
|
||||||
s['hub_api_url'] = self.hub_api_url
|
s['hub_api_url'] = self.hub_api_url
|
||||||
|
s['csp_report_uri'] = url_path_join(self.hub_prefix, 'security/csp-report')
|
||||||
|
|
||||||
super(SingleUserNotebookApp, self).init_webapp()
|
super(SingleUserNotebookApp, self).init_webapp()
|
||||||
|
self.patch_templates()
|
||||||
|
|
||||||
|
def patch_templates(self):
|
||||||
|
"""Patch page templates to add Hub-related buttons"""
|
||||||
|
env = self.web_app.settings['jinja2_env']
|
||||||
|
|
||||||
|
env.globals['hub_control_panel_url'] = \
|
||||||
|
url_path_join(self.hub_prefix, 'home')
|
||||||
|
|
||||||
|
# patch jinja env loading to modify page template
|
||||||
|
def get_page(name):
|
||||||
|
if name == 'page.html':
|
||||||
|
return page_template
|
||||||
|
|
||||||
|
orig_loader = env.loader
|
||||||
|
env.loader = ChoiceLoader([
|
||||||
|
FunctionLoader(get_page),
|
||||||
|
orig_loader,
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
@@ -7,24 +7,23 @@ import errno
|
|||||||
import os
|
import os
|
||||||
import pipes
|
import pipes
|
||||||
import pwd
|
import pwd
|
||||||
import re
|
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from subprocess import Popen, check_output, PIPE, CalledProcessError
|
import grp
|
||||||
|
from subprocess import Popen
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
from tornado.ioloop import IOLoop, PeriodicCallback
|
||||||
|
|
||||||
from IPython.config import LoggingConfigurable
|
from traitlets.config import LoggingConfigurable
|
||||||
from IPython.utils.traitlets import (
|
from traitlets import (
|
||||||
Any, Bool, Dict, Enum, Instance, Integer, Float, List, Unicode,
|
Any, Bool, Dict, Instance, Integer, Float, List, Unicode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .traitlets import Command
|
||||||
from .utils import random_port
|
from .utils import random_port
|
||||||
|
|
||||||
NUM_PAT = re.compile(r'\d+')
|
|
||||||
|
|
||||||
class Spawner(LoggingConfigurable):
|
class Spawner(LoggingConfigurable):
|
||||||
"""Base class for spawning single-user notebook servers.
|
"""Base class for spawning single-user notebook servers.
|
||||||
|
|
||||||
@@ -40,6 +39,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
db = Any()
|
db = Any()
|
||||||
user = Any()
|
user = Any()
|
||||||
hub = Any()
|
hub = Any()
|
||||||
|
authenticator = Any()
|
||||||
api_token = Unicode()
|
api_token = Unicode()
|
||||||
ip = Unicode('localhost', config=True,
|
ip = Unicode('localhost', config=True,
|
||||||
help="The IP address (or hostname) the single-user server should listen on"
|
help="The IP address (or hostname) the single-user server should listen on"
|
||||||
@@ -54,7 +54,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
)
|
)
|
||||||
|
|
||||||
http_timeout = Integer(
|
http_timeout = Integer(
|
||||||
10, config=True,
|
30, config=True,
|
||||||
help="""Timeout (in seconds) before giving up on a spawned HTTP server
|
help="""Timeout (in seconds) before giving up on a spawned HTTP server
|
||||||
|
|
||||||
Once a server has successfully been spawned, this is the amount of time
|
Once a server has successfully been spawned, this is the amount of time
|
||||||
@@ -93,7 +93,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
env['JPY_API_TOKEN'] = self.api_token
|
env['JPY_API_TOKEN'] = self.api_token
|
||||||
return env
|
return env
|
||||||
|
|
||||||
cmd = List(Unicode, default_value=['jupyterhub-singleuser'], config=True,
|
cmd = Command(['jupyterhub-singleuser'], config=True,
|
||||||
help="""The command used for starting notebooks."""
|
help="""The command used for starting notebooks."""
|
||||||
)
|
)
|
||||||
args = List(Unicode, config=True,
|
args = List(Unicode, config=True,
|
||||||
@@ -252,7 +252,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
if status is not None:
|
if status is not None:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
yield gen.Task(loop.add_timeout, loop.time() + self.death_interval)
|
yield gen.sleep(self.death_interval)
|
||||||
|
|
||||||
def _try_setcwd(path):
|
def _try_setcwd(path):
|
||||||
"""Try to set CWD, walking up and ultimately falling back to a temp dir"""
|
"""Try to set CWD, walking up and ultimately falling back to a temp dir"""
|
||||||
@@ -275,6 +275,7 @@ def set_user_setuid(username):
|
|||||||
uid = user.pw_uid
|
uid = user.pw_uid
|
||||||
gid = user.pw_gid
|
gid = user.pw_gid
|
||||||
home = user.pw_dir
|
home = user.pw_dir
|
||||||
|
gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ]
|
||||||
|
|
||||||
def preexec():
|
def preexec():
|
||||||
# don't forward signals
|
# don't forward signals
|
||||||
@@ -282,6 +283,10 @@ def set_user_setuid(username):
|
|||||||
|
|
||||||
# set the user and group
|
# set the user and group
|
||||||
os.setgid(gid)
|
os.setgid(gid)
|
||||||
|
try:
|
||||||
|
os.setgroups(gids)
|
||||||
|
except Exception as e:
|
||||||
|
print('Failed to set groups %s' % e, file=sys.stderr)
|
||||||
os.setuid(uid)
|
os.setuid(uid)
|
||||||
|
|
||||||
# start in the user's home dir
|
# start in the user's home dir
|
||||||
@@ -303,7 +308,7 @@ class LocalProcessSpawner(Spawner):
|
|||||||
help="Seconds to wait for process to halt after SIGKILL before giving up"
|
help="Seconds to wait for process to halt after SIGKILL before giving up"
|
||||||
)
|
)
|
||||||
|
|
||||||
proc = Instance(Popen)
|
proc = Instance(Popen, allow_none=True)
|
||||||
pid = Integer(0)
|
pid = Integer(0)
|
||||||
|
|
||||||
def make_preexec_fn(self, name):
|
def make_preexec_fn(self, name):
|
||||||
@@ -329,7 +334,14 @@ class LocalProcessSpawner(Spawner):
|
|||||||
|
|
||||||
def user_env(self, env):
|
def user_env(self, env):
|
||||||
env['USER'] = self.user.name
|
env['USER'] = self.user.name
|
||||||
env['HOME'] = pwd.getpwnam(self.user.name).pw_dir
|
home = pwd.getpwnam(self.user.name).pw_dir
|
||||||
|
shell = pwd.getpwnam(self.user.name).pw_shell
|
||||||
|
# These will be empty if undefined,
|
||||||
|
# in which case don't set the env:
|
||||||
|
if home:
|
||||||
|
env['HOME'] = home
|
||||||
|
if shell:
|
||||||
|
env['SHELL'] = shell
|
||||||
return env
|
return env
|
||||||
|
|
||||||
def _env_default(self):
|
def _env_default(self):
|
||||||
|
@@ -7,6 +7,8 @@ import threading
|
|||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.concurrent import Future
|
from tornado.concurrent import Future
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
@@ -16,16 +18,18 @@ from ..app import JupyterHub
|
|||||||
from ..auth import PAMAuthenticator
|
from ..auth import PAMAuthenticator
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
|
||||||
def mock_authenticate(username, password, service='login'):
|
from pamela import PAMError
|
||||||
# mimic simplepam's failure to handle unicode
|
|
||||||
if isinstance(username, str):
|
|
||||||
return False
|
|
||||||
if isinstance(password, str):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
def mock_authenticate(username, password, service='login'):
|
||||||
# just use equality for testing
|
# just use equality for testing
|
||||||
if password == username:
|
if password == username:
|
||||||
return True
|
return True
|
||||||
|
else:
|
||||||
|
raise PAMError("Fake")
|
||||||
|
|
||||||
|
|
||||||
|
def mock_open_session(username, service):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MockSpawner(LocalProcessSpawner):
|
class MockSpawner(LocalProcessSpawner):
|
||||||
@@ -49,12 +53,12 @@ class SlowSpawner(MockSpawner):
|
|||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def start(self):
|
def start(self):
|
||||||
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
|
yield gen.sleep(2)
|
||||||
yield super().start()
|
yield super().start()
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def stop(self):
|
def stop(self):
|
||||||
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
|
yield gen.sleep(2)
|
||||||
yield super().stop()
|
yield super().stop()
|
||||||
|
|
||||||
|
|
||||||
@@ -70,12 +74,17 @@ class NeverSpawner(MockSpawner):
|
|||||||
|
|
||||||
|
|
||||||
class MockPAMAuthenticator(PAMAuthenticator):
|
class MockPAMAuthenticator(PAMAuthenticator):
|
||||||
|
def _admin_users_default(self):
|
||||||
|
return {'admin'}
|
||||||
|
|
||||||
def system_user_exists(self, user):
|
def system_user_exists(self, user):
|
||||||
# skip the add-system-user bit
|
# skip the add-system-user bit
|
||||||
return not user.name.startswith('dne')
|
return not user.name.startswith('dne')
|
||||||
|
|
||||||
def authenticate(self, *args, **kwargs):
|
def authenticate(self, *args, **kwargs):
|
||||||
with mock.patch('simplepam.authenticate', mock_authenticate):
|
with mock.patch.multiple('pamela',
|
||||||
|
authenticate=mock_authenticate,
|
||||||
|
open_session=mock_open_session):
|
||||||
return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs)
|
return super(MockPAMAuthenticator, self).authenticate(*args, **kwargs)
|
||||||
|
|
||||||
class MockHub(JupyterHub):
|
class MockHub(JupyterHub):
|
||||||
@@ -92,15 +101,18 @@ class MockHub(JupyterHub):
|
|||||||
def _spawner_class_default(self):
|
def _spawner_class_default(self):
|
||||||
return MockSpawner
|
return MockSpawner
|
||||||
|
|
||||||
def _admin_users_default(self):
|
def init_signal(self):
|
||||||
return {'admin'}
|
pass
|
||||||
|
|
||||||
def start(self, argv=None):
|
def start(self, argv=None):
|
||||||
self.db_file = NamedTemporaryFile()
|
self.db_file = NamedTemporaryFile()
|
||||||
self.db_url = 'sqlite:///' + self.db_file.name
|
self.db_url = 'sqlite:///' + self.db_file.name
|
||||||
|
|
||||||
evt = threading.Event()
|
evt = threading.Event()
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def _start_co():
|
def _start_co():
|
||||||
|
assert self.io_loop._running
|
||||||
# put initialize in start for SQLAlchemy threading reasons
|
# put initialize in start for SQLAlchemy threading reasons
|
||||||
yield super(MockHub, self).initialize(argv=argv)
|
yield super(MockHub, self).initialize(argv=argv)
|
||||||
# add an initial user
|
# add an initial user
|
||||||
@@ -108,16 +120,19 @@ class MockHub(JupyterHub):
|
|||||||
self.db.add(user)
|
self.db.add(user)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
yield super(MockHub, self).start()
|
yield super(MockHub, self).start()
|
||||||
|
yield self.hub.server.wait_up(http=True)
|
||||||
self.io_loop.add_callback(evt.set)
|
self.io_loop.add_callback(evt.set)
|
||||||
|
|
||||||
def _start():
|
def _start():
|
||||||
self.io_loop = IOLoop.current()
|
self.io_loop = IOLoop()
|
||||||
|
self.io_loop.make_current()
|
||||||
self.io_loop.add_callback(_start_co)
|
self.io_loop.add_callback(_start_co)
|
||||||
self.io_loop.start()
|
self.io_loop.start()
|
||||||
|
|
||||||
self._thread = threading.Thread(target=_start)
|
self._thread = threading.Thread(target=_start)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
evt.wait(timeout=5)
|
ready = evt.wait(timeout=10)
|
||||||
|
assert ready
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
super().stop()
|
super().stop()
|
||||||
@@ -126,3 +141,15 @@ class MockHub(JupyterHub):
|
|||||||
# ignore the call that will fire in atexit
|
# ignore the call that will fire in atexit
|
||||||
self.cleanup = lambda : None
|
self.cleanup = lambda : None
|
||||||
self.db_file.close()
|
self.db_file.close()
|
||||||
|
|
||||||
|
def login_user(self, name):
|
||||||
|
r = requests.post(self.proxy.public_server.url + 'hub/login',
|
||||||
|
data={
|
||||||
|
'username': name,
|
||||||
|
'password': name,
|
||||||
|
},
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.cookies
|
||||||
|
return r.cookies
|
||||||
|
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
"""Tests for the REST API"""
|
"""Tests for the REST API"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from queue import Queue
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -63,7 +66,11 @@ def api_request(app, *api_path, **kwargs):
|
|||||||
url = ujoin(base_url, 'api', *api_path)
|
url = ujoin(base_url, 'api', *api_path)
|
||||||
method = kwargs.pop('method', 'get')
|
method = kwargs.pop('method', 'get')
|
||||||
f = getattr(requests, method)
|
f = getattr(requests, method)
|
||||||
return f(url, **kwargs)
|
resp = f(url, **kwargs)
|
||||||
|
assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy']
|
||||||
|
assert ujoin(app.hub.server.base_url, "security/csp-report") in resp.headers['Content-Security-Policy']
|
||||||
|
assert 'http' not in resp.headers['Content-Security-Policy']
|
||||||
|
return resp
|
||||||
|
|
||||||
def test_auth_api(app):
|
def test_auth_api(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
@@ -78,7 +85,7 @@ def test_auth_api(app):
|
|||||||
r = api_request(app, 'authorizations/token', api_token)
|
r = api_request(app, 'authorizations/token', api_token)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert reply['user'] == user.name
|
assert reply['name'] == user.name
|
||||||
|
|
||||||
# check fail
|
# check fail
|
||||||
r = api_request(app, 'authorizations/token', api_token,
|
r = api_request(app, 'authorizations/token', api_token,
|
||||||
@@ -91,6 +98,51 @@ def test_auth_api(app):
|
|||||||
)
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_referer_check(app, io_loop):
|
||||||
|
url = app.hub.server.url
|
||||||
|
host = urlparse(url).netloc
|
||||||
|
user = find_user(app.db, 'admin')
|
||||||
|
if user is None:
|
||||||
|
user = add_user(app.db, name='admin', admin=True)
|
||||||
|
cookies = app.login_user('admin')
|
||||||
|
app_user = get_app_user(app, 'admin')
|
||||||
|
# stop the admin's server so we don't mess up future tests
|
||||||
|
io_loop.run_sync(lambda : app.proxy.delete_user(app_user))
|
||||||
|
io_loop.run_sync(app_user.stop)
|
||||||
|
|
||||||
|
r = api_request(app, 'users',
|
||||||
|
headers={
|
||||||
|
'Authorization': '',
|
||||||
|
'Referer': 'null',
|
||||||
|
}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = api_request(app, 'users',
|
||||||
|
headers={
|
||||||
|
'Authorization': '',
|
||||||
|
'Referer': 'http://attack.com/csrf/vulnerability',
|
||||||
|
}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = api_request(app, 'users',
|
||||||
|
headers={
|
||||||
|
'Authorization': '',
|
||||||
|
'Referer': url,
|
||||||
|
'Host': host,
|
||||||
|
}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = api_request(app, 'users',
|
||||||
|
headers={
|
||||||
|
'Authorization': '',
|
||||||
|
'Referer': ujoin(url, 'foo/bar/baz/bat'),
|
||||||
|
'Host': host,
|
||||||
|
}, cookies=cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_get_users(app):
|
def test_get_users(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
r = api_request(app, 'users')
|
r = api_request(app, 'users')
|
||||||
@@ -129,6 +181,82 @@ def test_add_user(app):
|
|||||||
assert user.name == name
|
assert user.name == name
|
||||||
assert not user.admin
|
assert not user.admin
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user(app):
|
||||||
|
name = 'user'
|
||||||
|
r = api_request(app, 'users', name)
|
||||||
|
assert r.status_code == 200
|
||||||
|
user = r.json()
|
||||||
|
user.pop('last_activity')
|
||||||
|
assert user == {
|
||||||
|
'name': name,
|
||||||
|
'admin': False,
|
||||||
|
'server': None,
|
||||||
|
'pending': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_multi_user_bad(app):
|
||||||
|
r = api_request(app, 'users', method='post')
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = api_request(app, 'users', method='post', data='{}')
|
||||||
|
assert r.status_code == 400
|
||||||
|
r = api_request(app, 'users', method='post', data='[]')
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_add_multi_user(app):
|
||||||
|
db = app.db
|
||||||
|
names = ['a', 'b']
|
||||||
|
r = api_request(app, 'users', method='post',
|
||||||
|
data=json.dumps({'usernames': names}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
reply = r.json()
|
||||||
|
r_names = [ user['name'] for user in reply ]
|
||||||
|
assert names == r_names
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
user = find_user(db, name)
|
||||||
|
assert user is not None
|
||||||
|
assert user.name == name
|
||||||
|
assert not user.admin
|
||||||
|
|
||||||
|
# try to create the same users again
|
||||||
|
r = api_request(app, 'users', method='post',
|
||||||
|
data=json.dumps({'usernames': names}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
names = ['a', 'b', 'ab']
|
||||||
|
|
||||||
|
# try to create the same users again
|
||||||
|
r = api_request(app, 'users', method='post',
|
||||||
|
data=json.dumps({'usernames': names}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
reply = r.json()
|
||||||
|
r_names = [ user['name'] for user in reply ]
|
||||||
|
assert r_names == ['ab']
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_multi_user_admin(app):
|
||||||
|
db = app.db
|
||||||
|
names = ['c', 'd']
|
||||||
|
r = api_request(app, 'users', method='post',
|
||||||
|
data=json.dumps({'usernames': names, 'admin': True}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
reply = r.json()
|
||||||
|
r_names = [ user['name'] for user in reply ]
|
||||||
|
assert names == r_names
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
user = find_user(db, name)
|
||||||
|
assert user is not None
|
||||||
|
assert user.name == name
|
||||||
|
assert user.admin
|
||||||
|
|
||||||
|
|
||||||
def test_add_user_bad(app):
|
def test_add_user_bad(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
name = 'dne_newuser'
|
name = 'dne_newuser'
|
||||||
@@ -175,6 +303,18 @@ def test_make_admin(app):
|
|||||||
assert user.name == name
|
assert user.name == name
|
||||||
assert user.admin
|
assert user.admin
|
||||||
|
|
||||||
|
def get_app_user(app, name):
|
||||||
|
"""Get the User object from the main thread
|
||||||
|
|
||||||
|
Needed for access to the Spawner.
|
||||||
|
No ORM methods should be called on the result.
|
||||||
|
"""
|
||||||
|
q = Queue()
|
||||||
|
def get_user():
|
||||||
|
user = find_user(app.db, name)
|
||||||
|
q.put(user)
|
||||||
|
app.io_loop.add_callback(get_user)
|
||||||
|
return q.get(timeout=2)
|
||||||
|
|
||||||
def test_spawn(app, io_loop):
|
def test_spawn(app, io_loop):
|
||||||
db = app.db
|
db = app.db
|
||||||
@@ -183,9 +323,10 @@ def test_spawn(app, io_loop):
|
|||||||
r = api_request(app, 'users', name, 'server', method='post')
|
r = api_request(app, 'users', name, 'server', method='post')
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
assert 'pid' in user.state
|
assert 'pid' in user.state
|
||||||
assert user.spawner is not None
|
app_user = get_app_user(app, name)
|
||||||
assert not user.spawn_pending
|
assert app_user.spawner is not None
|
||||||
status = io_loop.run_sync(user.spawner.poll)
|
assert not app_user.spawn_pending
|
||||||
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status is None
|
assert status is None
|
||||||
|
|
||||||
assert user.server.base_url == '/user/%s' % name
|
assert user.server.base_url == '/user/%s' % name
|
||||||
@@ -203,7 +344,7 @@ def test_spawn(app, io_loop):
|
|||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
|
|
||||||
assert 'pid' not in user.state
|
assert 'pid' not in user.state
|
||||||
status = io_loop.run_sync(user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status == 0
|
assert status == 0
|
||||||
|
|
||||||
def test_slow_spawn(app, io_loop):
|
def test_slow_spawn(app, io_loop):
|
||||||
@@ -217,41 +358,41 @@ def test_slow_spawn(app, io_loop):
|
|||||||
r = api_request(app, 'users', name, 'server', method='post')
|
r = api_request(app, 'users', name, 'server', method='post')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
assert user.spawner is not None
|
app_user = get_app_user(app, name)
|
||||||
assert user.spawn_pending
|
assert app_user.spawner is not None
|
||||||
assert not user.stop_pending
|
assert app_user.spawn_pending
|
||||||
|
assert not app_user.stop_pending
|
||||||
|
|
||||||
dt = timedelta(seconds=0.1)
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_spawn():
|
def wait_spawn():
|
||||||
while user.spawn_pending:
|
while app_user.spawn_pending:
|
||||||
yield gen.Task(io_loop.add_timeout, dt)
|
yield gen.sleep(0.1)
|
||||||
|
|
||||||
io_loop.run_sync(wait_spawn)
|
io_loop.run_sync(wait_spawn)
|
||||||
assert not user.spawn_pending
|
assert not app_user.spawn_pending
|
||||||
status = io_loop.run_sync(user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status is None
|
assert status is None
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_stop():
|
def wait_stop():
|
||||||
while user.stop_pending:
|
while app_user.stop_pending:
|
||||||
yield gen.Task(io_loop.add_timeout, dt)
|
yield gen.sleep(0.1)
|
||||||
|
|
||||||
r = api_request(app, 'users', name, 'server', method='delete')
|
r = api_request(app, 'users', name, 'server', method='delete')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
assert user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
assert user.stop_pending
|
assert app_user.stop_pending
|
||||||
|
|
||||||
r = api_request(app, 'users', name, 'server', method='delete')
|
r = api_request(app, 'users', name, 'server', method='delete')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
assert user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
assert user.stop_pending
|
assert app_user.stop_pending
|
||||||
|
|
||||||
io_loop.run_sync(wait_stop)
|
io_loop.run_sync(wait_stop)
|
||||||
assert not user.stop_pending
|
assert not app_user.stop_pending
|
||||||
assert user.spawner is not None
|
assert app_user.spawner is not None
|
||||||
r = api_request(app, 'users', name, 'server', method='delete')
|
r = api_request(app, 'users', name, 'server', method='delete')
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
@@ -264,18 +405,18 @@ def test_never_spawn(app, io_loop):
|
|||||||
name = 'badger'
|
name = 'badger'
|
||||||
user = add_user(db, name=name)
|
user = add_user(db, name=name)
|
||||||
r = api_request(app, 'users', name, 'server', method='post')
|
r = api_request(app, 'users', name, 'server', method='post')
|
||||||
assert user.spawner is not None
|
app_user = get_app_user(app, name)
|
||||||
assert user.spawn_pending
|
assert app_user.spawner is not None
|
||||||
|
assert app_user.spawn_pending
|
||||||
|
|
||||||
dt = timedelta(seconds=0.1)
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_pending():
|
def wait_pending():
|
||||||
while user.spawn_pending:
|
while app_user.spawn_pending:
|
||||||
yield gen.Task(io_loop.add_timeout, dt)
|
yield gen.sleep(0.1)
|
||||||
|
|
||||||
io_loop.run_sync(wait_pending)
|
io_loop.run_sync(wait_pending)
|
||||||
assert not user.spawn_pending
|
assert not app_user.spawn_pending
|
||||||
status = io_loop.run_sync(user.spawner.poll)
|
status = io_loop.run_sync(app_user.spawner.poll)
|
||||||
assert status is not None
|
assert status is not None
|
||||||
|
|
||||||
|
|
||||||
@@ -284,3 +425,18 @@ def test_get_proxy(app, io_loop):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert list(reply.keys()) == ['/']
|
assert list(reply.keys()) == ['/']
|
||||||
|
|
||||||
|
|
||||||
|
def test_shutdown(app):
|
||||||
|
r = api_request(app, 'shutdown', method='post', data=json.dumps({
|
||||||
|
'servers': True,
|
||||||
|
'proxy': True,
|
||||||
|
}))
|
||||||
|
r.raise_for_status()
|
||||||
|
reply = r.json()
|
||||||
|
for i in range(100):
|
||||||
|
if app.io_loop._running:
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
assert not app.io_loop._running
|
||||||
|
@@ -3,7 +3,6 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from getpass import getuser
|
|
||||||
from subprocess import check_output
|
from subprocess import check_output
|
||||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
|
|
||||||
@@ -16,7 +15,9 @@ 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')
|
||||||
with TemporaryDirectory() as td:
|
with TemporaryDirectory() as td:
|
||||||
out = check_output(cmd + [getuser()], cwd=td).decode('utf8', 'replace').strip()
|
with open(os.path.join(td, 'jupyterhub_config.py'), 'w') as f:
|
||||||
|
f.write("c.Authenticator.admin_users={'user'}")
|
||||||
|
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():
|
||||||
|
@@ -3,8 +3,13 @@
|
|||||||
# 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 subprocess import CalledProcessError
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from .mocking import MockPAMAuthenticator
|
from .mocking import MockPAMAuthenticator
|
||||||
|
|
||||||
|
from jupyterhub import auth, orm
|
||||||
|
|
||||||
def test_pam_auth(io_loop):
|
def test_pam_auth(io_loop):
|
||||||
authenticator = MockPAMAuthenticator()
|
authenticator = MockPAMAuthenticator()
|
||||||
@@ -39,3 +44,106 @@ def test_pam_auth_whitelist(io_loop):
|
|||||||
'password': 'mal',
|
'password': 'mal',
|
||||||
}))
|
}))
|
||||||
assert authorized is None
|
assert authorized is None
|
||||||
|
|
||||||
|
|
||||||
|
class MockGroup:
|
||||||
|
def __init__(self, *names):
|
||||||
|
self.gr_mem = names
|
||||||
|
|
||||||
|
|
||||||
|
def test_pam_auth_group_whitelist(io_loop):
|
||||||
|
g = MockGroup('kaylee')
|
||||||
|
def getgrnam(name):
|
||||||
|
return g
|
||||||
|
|
||||||
|
authenticator = MockPAMAuthenticator(group_whitelist={'group'})
|
||||||
|
|
||||||
|
with mock.patch.object(auth, 'getgrnam', getgrnam):
|
||||||
|
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||||
|
'username': 'kaylee',
|
||||||
|
'password': 'kaylee',
|
||||||
|
}))
|
||||||
|
assert authorized == 'kaylee'
|
||||||
|
|
||||||
|
with mock.patch.object(auth, 'getgrnam', getgrnam):
|
||||||
|
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||||
|
'username': 'mal',
|
||||||
|
'password': 'mal',
|
||||||
|
}))
|
||||||
|
assert authorized is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pam_auth_no_such_group(io_loop):
|
||||||
|
authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'})
|
||||||
|
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
||||||
|
'username': 'kaylee',
|
||||||
|
'password': 'kaylee',
|
||||||
|
}))
|
||||||
|
assert authorized is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wont_add_system_user(io_loop):
|
||||||
|
user = orm.User(name='lioness4321')
|
||||||
|
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
|
||||||
|
authenticator.create_system_users = False
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
io_loop.run_sync(lambda : authenticator.add_user(user))
|
||||||
|
|
||||||
|
|
||||||
|
def test_cant_add_system_user(io_loop):
|
||||||
|
user = orm.User(name='lioness4321')
|
||||||
|
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
|
||||||
|
authenticator.create_system_users = True
|
||||||
|
|
||||||
|
def check_output(cmd, *a, **kw):
|
||||||
|
raise CalledProcessError(1, cmd)
|
||||||
|
|
||||||
|
with mock.patch.object(auth, 'check_output', check_output):
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
io_loop.run_sync(lambda : authenticator.add_user(user))
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_system_user(io_loop):
|
||||||
|
user = orm.User(name='lioness4321')
|
||||||
|
authenticator = auth.PAMAuthenticator(whitelist={'mal'})
|
||||||
|
authenticator.create_system_users = True
|
||||||
|
|
||||||
|
def check_output(*a, **kw):
|
||||||
|
return
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
def check_call(cmd, *a, **kw):
|
||||||
|
record['cmd'] = cmd
|
||||||
|
|
||||||
|
with mock.patch.object(auth, 'check_output', check_output), \
|
||||||
|
mock.patch.object(auth, 'check_call', check_call):
|
||||||
|
io_loop.run_sync(lambda : authenticator.add_user(user))
|
||||||
|
|
||||||
|
assert user.name in record['cmd']
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user(io_loop):
|
||||||
|
user = orm.User(name='zoe')
|
||||||
|
a = MockPAMAuthenticator(whitelist={'mal'})
|
||||||
|
|
||||||
|
assert 'zoe' not in a.whitelist
|
||||||
|
a.add_user(user)
|
||||||
|
assert 'zoe' in a.whitelist
|
||||||
|
a.delete_user(user)
|
||||||
|
assert 'zoe' not in a.whitelist
|
||||||
|
|
||||||
|
|
||||||
|
def test_urls():
|
||||||
|
a = auth.PAMAuthenticator()
|
||||||
|
logout = a.logout_url('/base/url/')
|
||||||
|
login = a.login_url('/base/url')
|
||||||
|
assert logout == '/base/url/logout'
|
||||||
|
assert login == '/base/url/login'
|
||||||
|
|
||||||
|
|
||||||
|
def test_handlers(app):
|
||||||
|
a = auth.PAMAuthenticator()
|
||||||
|
handlers = a.get_handlers(app)
|
||||||
|
assert handlers[0][0] == '/login'
|
||||||
|
|
||||||
|
|
||||||
|
@@ -21,6 +21,7 @@ def test_server(db):
|
|||||||
assert isinstance(server.cookie_name, str)
|
assert isinstance(server.cookie_name, str)
|
||||||
assert server.host == 'http://localhost:%i' % server.port
|
assert server.host == 'http://localhost:%i' % server.port
|
||||||
assert server.url == server.host + '/'
|
assert server.url == server.host + '/'
|
||||||
|
assert server.bind_url == 'http://*:%i/' % server.port
|
||||||
server.ip = '127.0.0.1'
|
server.ip = '127.0.0.1'
|
||||||
assert server.host == 'http://127.0.0.1:%i' % server.port
|
assert server.host == 'http://127.0.0.1:%i' % server.port
|
||||||
assert server.url == server.host + '/'
|
assert server.url == server.host + '/'
|
||||||
|
58
jupyterhub/tests/test_pages.py
Normal file
58
jupyterhub/tests/test_pages.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Tests for HTML pages"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from ..utils import url_path_join as ujoin
|
||||||
|
from .. import orm
|
||||||
|
|
||||||
|
|
||||||
|
def get_page(path, app, **kw):
|
||||||
|
base_url = ujoin(app.proxy.public_server.host, app.hub.server.base_url)
|
||||||
|
print(base_url)
|
||||||
|
return requests.get(ujoin(base_url, path), **kw)
|
||||||
|
|
||||||
|
def test_root_no_auth(app, io_loop):
|
||||||
|
print(app.hub.server.is_up())
|
||||||
|
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||||
|
print(routes)
|
||||||
|
print(app.hub.server)
|
||||||
|
r = requests.get(app.proxy.public_server.host)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.url == ujoin(app.proxy.public_server.host, app.hub.server.base_url, 'login')
|
||||||
|
|
||||||
|
def test_root_auth(app):
|
||||||
|
cookies = app.login_user('river')
|
||||||
|
r = requests.get(app.proxy.public_server.host, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.url == ujoin(app.proxy.public_server.host, '/user/river')
|
||||||
|
|
||||||
|
def test_home_no_auth(app):
|
||||||
|
r = get_page('home', app, allow_redirects=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert '/hub/login' in r.headers['Location']
|
||||||
|
|
||||||
|
def test_home_auth(app):
|
||||||
|
cookies = app.login_user('river')
|
||||||
|
r = get_page('home', app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.url.endswith('home')
|
||||||
|
|
||||||
|
def test_admin_no_auth(app):
|
||||||
|
r = get_page('admin', app)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
def test_admin_not_admin(app):
|
||||||
|
cookies = app.login_user('wash')
|
||||||
|
r = get_page('admin', app, cookies=cookies)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
def test_admin(app):
|
||||||
|
cookies = app.login_user('river')
|
||||||
|
u = orm.User.find(app.db, 'river')
|
||||||
|
u.admin = True
|
||||||
|
app.db.commit()
|
||||||
|
r = get_page('admin', app, cookies=cookies)
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.url.endswith('/admin')
|
||||||
|
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from queue import Queue
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
@@ -26,7 +27,7 @@ def test_external_proxy(request, io_loop):
|
|||||||
request.addfinalizer(fin)
|
request.addfinalizer(fin)
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env['CONFIGPROXY_AUTH_TOKEN'] = auth_token
|
env['CONFIGPROXY_AUTH_TOKEN'] = auth_token
|
||||||
cmd = [app.proxy_cmd,
|
cmd = app.proxy_cmd + [
|
||||||
'--ip', app.ip,
|
'--ip', app.ip,
|
||||||
'--port', str(app.port),
|
'--port', str(app.port),
|
||||||
'--api-ip', proxy_ip,
|
'--api-ip', proxy_ip,
|
||||||
@@ -82,7 +83,7 @@ def test_external_proxy(request, io_loop):
|
|||||||
new_auth_token = 'different!'
|
new_auth_token = 'different!'
|
||||||
env['CONFIGPROXY_AUTH_TOKEN'] = new_auth_token
|
env['CONFIGPROXY_AUTH_TOKEN'] = new_auth_token
|
||||||
proxy_port = 55432
|
proxy_port = 55432
|
||||||
cmd = [app.proxy_cmd,
|
cmd = app.proxy_cmd + [
|
||||||
'--ip', app.ip,
|
'--ip', app.ip,
|
||||||
'--port', str(app.port),
|
'--port', str(app.port),
|
||||||
'--api-ip', app.proxy_api_ip,
|
'--api-ip', app.proxy_api_ip,
|
||||||
@@ -100,7 +101,15 @@ def test_external_proxy(request, io_loop):
|
|||||||
}))
|
}))
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert app.proxy.api_server.port == proxy_port
|
assert app.proxy.api_server.port == proxy_port
|
||||||
assert app.proxy.auth_token == new_auth_token
|
|
||||||
|
# get updated auth token from main thread
|
||||||
|
def get_app_proxy_token():
|
||||||
|
q = Queue()
|
||||||
|
app.io_loop.add_callback(lambda : q.put(app.proxy.auth_token))
|
||||||
|
return q.get(timeout=2)
|
||||||
|
|
||||||
|
assert get_app_proxy_token() == new_auth_token
|
||||||
|
app.proxy.auth_token = new_auth_token
|
||||||
|
|
||||||
# check that the routes are correct
|
# check that the routes are correct
|
||||||
routes = io_loop.run_sync(app.proxy.get_routes)
|
routes = io_loop.run_sync(app.proxy.get_routes)
|
||||||
|
@@ -56,6 +56,30 @@ def test_spawner(db, io_loop):
|
|||||||
status = io_loop.run_sync(spawner.poll)
|
status = io_loop.run_sync(spawner.poll)
|
||||||
assert status == 1
|
assert status == 1
|
||||||
|
|
||||||
|
def test_single_user_spawner(db, io_loop):
|
||||||
|
spawner = new_spawner(db, cmd=[sys.executable, '-m', 'jupyterhub.singleuser'])
|
||||||
|
io_loop.run_sync(spawner.start)
|
||||||
|
assert spawner.user.server.ip == 'localhost'
|
||||||
|
# wait for http server to come up,
|
||||||
|
# checking for early termination every 1s
|
||||||
|
def wait():
|
||||||
|
return spawner.user.server.wait_up(timeout=1, http=True)
|
||||||
|
for i in range(30):
|
||||||
|
status = io_loop.run_sync(spawner.poll)
|
||||||
|
assert status is None
|
||||||
|
try:
|
||||||
|
io_loop.run_sync(wait)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
io_loop.run_sync(wait)
|
||||||
|
status = io_loop.run_sync(spawner.poll)
|
||||||
|
assert status == None
|
||||||
|
io_loop.run_sync(spawner.stop)
|
||||||
|
status = io_loop.run_sync(spawner.poll)
|
||||||
|
assert status == 0
|
||||||
|
|
||||||
|
|
||||||
def test_stop_spawner_sigint_fails(db, io_loop):
|
def test_stop_spawner_sigint_fails(db, io_loop):
|
||||||
spawner = new_spawner(db, cmd=[sys.executable, '-c', _uninterruptible])
|
spawner = new_spawner(db, cmd=[sys.executable, '-c', _uninterruptible])
|
||||||
|
27
jupyterhub/tests/test_traitlets.py
Normal file
27
jupyterhub/tests/test_traitlets.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from traitlets import HasTraits
|
||||||
|
|
||||||
|
from jupyterhub.traitlets import URLPrefix, Command
|
||||||
|
|
||||||
|
def test_url_prefix():
|
||||||
|
class C(HasTraits):
|
||||||
|
url = URLPrefix()
|
||||||
|
|
||||||
|
c = C()
|
||||||
|
c.url = '/a/b/c/'
|
||||||
|
assert c.url == '/a/b/c/'
|
||||||
|
c.url = '/a/b'
|
||||||
|
assert c.url == '/a/b/'
|
||||||
|
c.url = 'a/b/c/d'
|
||||||
|
assert c.url == '/a/b/c/d/'
|
||||||
|
|
||||||
|
def test_command():
|
||||||
|
class C(HasTraits):
|
||||||
|
cmd = Command('default command')
|
||||||
|
cmd2 = Command(['default_cmd'])
|
||||||
|
|
||||||
|
c = C()
|
||||||
|
assert c.cmd == ['default command']
|
||||||
|
assert c.cmd2 == ['default_cmd']
|
||||||
|
c.cmd = 'foo bar'
|
||||||
|
assert c.cmd == ['foo bar']
|
||||||
|
|
@@ -2,7 +2,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from IPython.utils.traitlets import Unicode
|
from traitlets import List, Unicode
|
||||||
|
|
||||||
class URLPrefix(Unicode):
|
class URLPrefix(Unicode):
|
||||||
def validate(self, obj, value):
|
def validate(self, obj, value):
|
||||||
@@ -12,3 +12,18 @@ class URLPrefix(Unicode):
|
|||||||
if not u.endswith('/'):
|
if not u.endswith('/'):
|
||||||
u = u + '/'
|
u = u + '/'
|
||||||
return u
|
return u
|
||||||
|
|
||||||
|
class Command(List):
|
||||||
|
"""Traitlet for a command that should be a list of strings,
|
||||||
|
but allows it to be specified as a single string.
|
||||||
|
"""
|
||||||
|
def __init__(self, default_value=None, **kwargs):
|
||||||
|
kwargs.setdefault('minlen', 1)
|
||||||
|
if isinstance(default_value, str):
|
||||||
|
default_value = [default_value]
|
||||||
|
super().__init__(Unicode, default_value, **kwargs)
|
||||||
|
|
||||||
|
def validate(self, obj, value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = [value]
|
||||||
|
return super().validate(obj, value)
|
||||||
|
@@ -9,13 +9,12 @@ import hashlib
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import uuid
|
import uuid
|
||||||
|
from hmac import compare_digest
|
||||||
|
|
||||||
from tornado import web, gen, ioloop
|
from tornado import web, gen, ioloop
|
||||||
from tornado.httpclient import AsyncHTTPClient, HTTPError
|
from tornado.httpclient import AsyncHTTPClient, HTTPError
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
|
|
||||||
from IPython.html.utils import url_path_join
|
|
||||||
|
|
||||||
|
|
||||||
def random_port():
|
def random_port():
|
||||||
"""get a single random port"""
|
"""get a single random port"""
|
||||||
@@ -42,7 +41,7 @@ def wait_for_server(ip, port, timeout=10):
|
|||||||
app_log.error("Unexpected error waiting for %s:%i %s",
|
app_log.error("Unexpected error waiting for %s:%i %s",
|
||||||
ip, port, e
|
ip, port, e
|
||||||
)
|
)
|
||||||
yield gen.Task(loop.add_timeout, loop.time() + 0.1)
|
yield gen.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format(
|
raise TimeoutError("Server at {ip}:{port} didn't respond in {timeout} seconds".format(
|
||||||
@@ -68,14 +67,14 @@ def wait_for_http_server(url, timeout=10):
|
|||||||
# we expect 599 for no connection,
|
# we expect 599 for no connection,
|
||||||
# but 502 or other proxy error is conceivable
|
# but 502 or other proxy error is conceivable
|
||||||
app_log.warn("Server at %s responded with error: %s", url, e.code)
|
app_log.warn("Server at %s responded with error: %s", url, e.code)
|
||||||
yield gen.Task(loop.add_timeout, loop.time() + 0.25)
|
yield gen.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
app_log.debug("Server at %s responded with %s", url, e.code)
|
app_log.debug("Server at %s responded with %s", url, e.code)
|
||||||
return
|
return
|
||||||
except (OSError, socket.error) as e:
|
except (OSError, socket.error) as e:
|
||||||
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
|
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
|
||||||
app_log.warn("Failed to connect to %s (%s)", url, e)
|
app_log.warn("Failed to connect to %s (%s)", url, e)
|
||||||
yield gen.Task(loop.add_timeout, loop.time() + 0.25)
|
yield gen.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -165,9 +164,31 @@ def compare_token(compare, token):
|
|||||||
uses the same algorithm and salt of the hashed token for comparison
|
uses the same algorithm and salt of the hashed token for comparison
|
||||||
"""
|
"""
|
||||||
algorithm, srounds, salt, _ = compare.split(':')
|
algorithm, srounds, salt, _ = compare.split(':')
|
||||||
hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm)
|
hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm).encode('utf8')
|
||||||
if compare == hashed:
|
compare = compare.encode('utf8')
|
||||||
|
if compare_digest(compare, hashed):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def url_path_join(*pieces):
|
||||||
|
"""Join components of url into a relative url
|
||||||
|
|
||||||
|
Use to prevent double slash when joining subpath. This will leave the
|
||||||
|
initial and final / in place
|
||||||
|
|
||||||
|
Copied from notebook.utils.url_path_join
|
||||||
|
"""
|
||||||
|
initial = pieces[0].startswith('/')
|
||||||
|
final = pieces[-1].endswith('/')
|
||||||
|
stripped = [ s.strip('/') for s in pieces ]
|
||||||
|
result = '/'.join(s for s in stripped if s)
|
||||||
|
|
||||||
|
if initial:
|
||||||
|
result = '/' + result
|
||||||
|
if final:
|
||||||
|
result = result + '/'
|
||||||
|
if result == '//':
|
||||||
|
result = '/'
|
||||||
|
|
||||||
|
return result
|
||||||
|
@@ -5,8 +5,9 @@
|
|||||||
|
|
||||||
version_info = (
|
version_info = (
|
||||||
0,
|
0,
|
||||||
1,
|
3,
|
||||||
0,
|
0,
|
||||||
|
# 'dev',
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = '.'.join(map(str, version_info))
|
__version__ = '.'.join(map(str, version_info))
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
ipython>=3
|
traitlets>=4
|
||||||
tornado>=4
|
tornado>=4.1
|
||||||
jinja2
|
jinja2
|
||||||
simplepam
|
pamela
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
requests
|
requests
|
||||||
|
2
scripts/jupyterhub
Normal file → Executable file
2
scripts/jupyterhub
Normal file → Executable file
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from jupyterhub.app import main
|
from jupyterhub.app import main
|
||||||
main()
|
main()
|
||||||
|
2
scripts/jupyterhub-singleuser
Normal file → Executable file
2
scripts/jupyterhub-singleuser
Normal file → Executable file
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from jupyterhub.singleuser import main
|
from jupyterhub.singleuser import main
|
||||||
main()
|
main()
|
||||||
|
1
setup.py
1
setup.py
@@ -165,6 +165,7 @@ class Bower(BaseCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self.should_run_npm():
|
if self.should_run_npm():
|
||||||
|
print("installing build dependencies with npm")
|
||||||
check_call(['npm', 'install'], cwd=here)
|
check_call(['npm', 'install'], cwd=here)
|
||||||
os.utime(self.node_modules)
|
os.utime(self.node_modules)
|
||||||
|
|
||||||
|
BIN
share/jupyter/hub/static/images/jupyter.png
Normal file
BIN
share/jupyter/hub/static/images/jupyter.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.4 KiB |
@@ -42,7 +42,7 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
|||||||
$("th").map(function (i, th) {
|
$("th").map(function (i, th) {
|
||||||
th = $(th);
|
th = $(th);
|
||||||
var col = th.data('sort');
|
var col = th.data('sort');
|
||||||
if (!col || col.length == 0) {
|
if (!col || col.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var order = th.find('i').hasClass('fa-sort-desc') ? 'asc':'desc';
|
var order = th.find('i').hasClass('fa-sort-desc') ? 'asc':'desc';
|
||||||
@@ -50,7 +50,7 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
|||||||
function () {
|
function () {
|
||||||
resort(col, order);
|
resort(col, order);
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".time-col").map(function (i, el) {
|
$(".time-col").map(function (i, el) {
|
||||||
@@ -161,9 +161,17 @@ require(["jquery", "bootstrap", "moment", "jhapi", "utils"], function ($, bs, mo
|
|||||||
|
|
||||||
$("#add-user-dialog").find(".save-button").click(function () {
|
$("#add-user-dialog").find(".save-button").click(function () {
|
||||||
var dialog = $("#add-user-dialog");
|
var dialog = $("#add-user-dialog");
|
||||||
var username = dialog.find(".username-input").val();
|
var lines = dialog.find(".username-input").val().split('\n');
|
||||||
var admin = dialog.find(".admin-checkbox").prop("checked");
|
var admin = dialog.find(".admin-checkbox").prop("checked");
|
||||||
api.add_user(username, {admin: admin}, {
|
var usernames = [];
|
||||||
|
lines.map(function (line) {
|
||||||
|
var username = line.trim();
|
||||||
|
if (username.length) {
|
||||||
|
usernames.push(username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.add_users(usernames, {admin: admin}, {
|
||||||
success: function () {
|
success: function () {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
@@ -72,18 +72,16 @@ define(['jquery', 'utils'], function ($, utils) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
JHAPI.prototype.add_user = function (user, userinfo, options) {
|
JHAPI.prototype.add_users = function (usernames, userinfo, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
|
var data = update(userinfo, {usernames: usernames});
|
||||||
options = update(options, {
|
options = update(options, {
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
dataType: null,
|
dataType: null,
|
||||||
data: JSON.stringify(userinfo)
|
data: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
|
|
||||||
this.api_request(
|
this.api_request('users', options);
|
||||||
utils.url_path_join('users', user),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
JHAPI.prototype.edit_user = function (user, userinfo, options) {
|
JHAPI.prototype.edit_user = function (user, userinfo, options) {
|
||||||
|
@@ -10,7 +10,7 @@ div.ajax-error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.error > h1 {
|
div.error > h1 {
|
||||||
font-size: 500%;
|
font-size: 300%;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,8 +19,3 @@ div.error > p {
|
|||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.traceback-wrapper {
|
|
||||||
text-align: left;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
@@ -1,33 +1,55 @@
|
|||||||
#login-main {
|
#login-main {
|
||||||
|
display: table;
|
||||||
|
height: 80vh;
|
||||||
|
|
||||||
|
.service-login {
|
||||||
|
text-align: center;
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: auto auto 20% auto;
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
margin: 8px auto;
|
display: table-cell;
|
||||||
width: 400px;
|
vertical-align: middle;
|
||||||
padding: 50px;
|
margin: auto auto 20% auto;
|
||||||
border: 1px solid #ccc;
|
width: 350px;
|
||||||
font-size: large;
|
font-size: large;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
.input-group, input[type=text], button {
|
||||||
border-radius: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group, input, button {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group-addon {
|
input[type=submit] {
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pwd-group {
|
|
||||||
margin-top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button[type=submit] {
|
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control:focus, input[type=submit]:focus {
|
||||||
|
box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @jupyter-orange;
|
||||||
|
border-color: @jupyter-orange;
|
||||||
|
outline-color: @jupyter-orange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login_error {
|
||||||
|
color: orangered;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-header {
|
||||||
|
padding: 10px 20px;
|
||||||
|
color: #fff;
|
||||||
|
background: @jupyter-orange;
|
||||||
|
border-radius: @border-radius-large @border-radius-large 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-body {
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: thin silver solid;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 @border-radius-large @border-radius-large;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
div.logout-main {
|
|
||||||
margin: 2em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.logout-main > h1 {
|
|
||||||
font-size: 400%;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.logout-main > p {
|
|
||||||
font-size: 200%;
|
|
||||||
line-height: normal;
|
|
||||||
}
|
|
@@ -1,12 +1,26 @@
|
|||||||
.jpy-logo {
|
.jpy-logo {
|
||||||
height: 40px;
|
height: 28px;
|
||||||
margin: 8px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div#header {
|
#header {
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #e7e7e7;
|
||||||
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown.navbar-btn{
|
||||||
|
padding:0 5px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login_widget{
|
||||||
|
|
||||||
|
& .navbar-btn.btn-sm {
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@@ -24,5 +24,4 @@
|
|||||||
@import "./page.less";
|
@import "./page.less";
|
||||||
@import "./admin.less";
|
@import "./admin.less";
|
||||||
@import "./error.less";
|
@import "./error.less";
|
||||||
@import "./logout.less";
|
|
||||||
@import "./login.less";
|
@import "./login.less";
|
||||||
|
@@ -0,0 +1,11 @@
|
|||||||
|
@border-radius-small: 2px;
|
||||||
|
@border-radius-base: 2px;
|
||||||
|
@border-radius-large: 3px;
|
||||||
|
@navbar-height: 20px;
|
||||||
|
|
||||||
|
@jupyter-orange: #F37524;
|
||||||
|
@jupyter-red: #E34F21;
|
||||||
|
|
||||||
|
.btn-jupyter {
|
||||||
|
.button-variant(#fff; @jupyter-orange; @jupyter-red);
|
||||||
|
}
|
||||||
|
@@ -31,9 +31,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr class="user-row add-user-row">
|
<tr class="user-row add-user-row">
|
||||||
<td colspan="5">
|
<td colspan="12">
|
||||||
<a id="add-user" class="col-xs-5 btn btn-default">Add User</a>
|
<a id="add-user" class="col-xs-5 btn btn-default">Add User</a>
|
||||||
<a id="shutdown-hub" class="col-xs-4 col-xs-offset-3 btn btn-danger">Shutdown Hub</a>
|
<a id="shutdown-hub" class="col-xs-5 col-xs-offset-2 btn btn-danger">Shutdown Hub</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
@@ -42,15 +42,19 @@
|
|||||||
<td class="name-col col-sm-2">{{u.name}}</td>
|
<td class="name-col col-sm-2">{{u.name}}</td>
|
||||||
<td class="admin-col col-sm-2">{% if u.admin %}admin{% endif %}</td>
|
<td class="admin-col col-sm-2">{% if u.admin %}admin{% endif %}</td>
|
||||||
<td class="time-col col-sm-3">{{u.last_activity.isoformat() + 'Z'}}</td>
|
<td class="time-col col-sm-3">{{u.last_activity.isoformat() + 'Z'}}</td>
|
||||||
<td class="server-col col-sm-3 text-center">
|
<td class="server-col col-sm-2 text-center">
|
||||||
<span class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span>
|
<span class="stop-server btn btn-xs btn-danger {% if not u.running %}hidden{% endif %}">stop server</span>
|
||||||
|
<span class="start-server btn btn-xs btn-success {% if u.running %}hidden{% endif %}">start server</span>
|
||||||
|
</td>
|
||||||
|
<td class="server-col col-sm-1 text-center">
|
||||||
{% if admin_access %}
|
{% if admin_access %}
|
||||||
<span class="access-server btn btn-xs btn-success {% if not u.running %}hidden{% endif %}">access server</span>
|
<span class="access-server btn btn-xs btn-success {% if not u.running %}hidden{% endif %}">access server</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="start-server btn btn-xs btn-success {% if u.running %}hidden{% endif %}">start server</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="edit-col col-sm-2">
|
<td class="edit-col col-sm-1 text-center">
|
||||||
<span class="edit-user btn btn-xs btn-primary">edit</span>
|
<span class="edit-user btn btn-xs btn-primary">edit</span>
|
||||||
|
</td>
|
||||||
|
<td class="edit-col col-sm-1 text-center">
|
||||||
{% if u.name != user.name %}
|
{% if u.name != user.name %}
|
||||||
<span class="delete-user btn btn-xs btn-danger">delete</span>
|
<span class="delete-user btn btn-xs btn-danger">delete</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -82,10 +86,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
{% macro user_modal(name) %}
|
{% macro user_modal(name, multi=False) %}
|
||||||
{% call modal(name, btn_class='btn-primary save-button') %}
|
{% call modal(name, btn_class='btn-primary save-button') %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" class="form-control username-input" placeholder="username">
|
<{%- if multi -%}
|
||||||
|
textarea
|
||||||
|
{%- else -%}
|
||||||
|
input type="text"
|
||||||
|
{%- endif %}
|
||||||
|
class="form-control username-input"
|
||||||
|
placeholder="{%- if multi -%} usernames separated by lines{%- else -%} username {%-endif-%}">
|
||||||
|
{%- if multi -%}</textarea>{%- endif -%}
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox">
|
<div class="checkbox">
|
||||||
<label>
|
<label>
|
||||||
@@ -97,7 +108,7 @@
|
|||||||
|
|
||||||
{{ user_modal('Edit User') }}
|
{{ user_modal('Edit User') }}
|
||||||
|
|
||||||
{{ user_modal('Add User') }}
|
{{ user_modal('Add User', multi=True) }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@@ -7,16 +7,17 @@
|
|||||||
|
|
||||||
<div class="error">
|
<div class="error">
|
||||||
{% block h1_error %}
|
{% block h1_error %}
|
||||||
<h1>{{status_code}} : {{status_message}}</h1>
|
<h1>
|
||||||
|
{{status_code}} : {{status_message}}
|
||||||
|
</h1>
|
||||||
{% endblock h1_error %}
|
{% endblock h1_error %}
|
||||||
{% block error_detail %}
|
{% block error_detail %}
|
||||||
{% if message %}
|
{% if message %}
|
||||||
<p>The error was:</p>
|
<p>
|
||||||
<div class="traceback-wrapper">
|
{{message}}
|
||||||
<pre class="traceback">{{message}}</pre>
|
</p>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock error_detail %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,16 +0,0 @@
|
|||||||
{% extends "page.html" %}
|
|
||||||
|
|
||||||
{% block login_widget %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="text-center">
|
|
||||||
<a id="login" class="btn btn-lg btn-primary" href="{{login_url}}">Log in</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@@ -5,34 +5,63 @@
|
|||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
|
{% block login %}
|
||||||
<div id="login-main" class="container">
|
<div id="login-main" class="container">
|
||||||
{% if custom_html %}
|
{% if custom_html %}
|
||||||
{{ custom_html }}
|
{{ custom_html }}
|
||||||
|
{% elif login_service %}
|
||||||
|
<div class="service-login">
|
||||||
|
<a class='btn btn-jupyter btn-lg' href='{{login_url}}'>
|
||||||
|
Sign in with {{login_service}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form action="{{login_url}}?next={{next}}" method="post" role="form">
|
<form action="{{login_url}}?next={{next}}" method="post" role="form">
|
||||||
<div class="input-group">
|
<div class="auth-form-header">
|
||||||
<span class="input-group-addon">Username:</span>
|
Sign in
|
||||||
<input type="username" class="form-control" name="username" id="username_input" val="{{username}}">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group pwd-group">
|
<div class='auth-form-body'>
|
||||||
<span class="input-group-addon">Password:</span>
|
{% if login_error %}
|
||||||
<input type="password" class="form-control" name="password" id="password_input">
|
<p class="login_error">
|
||||||
|
{{login_error}}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<label for="username_input">Username:</label>
|
||||||
|
<input
|
||||||
|
id="username_input"
|
||||||
|
type="username"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
class="form-control"
|
||||||
|
name="username"
|
||||||
|
val="{{username}}"
|
||||||
|
tabindex="1"
|
||||||
|
autofocus="autofocus"
|
||||||
|
/>
|
||||||
|
<label for='password_input'>Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
name="password"
|
||||||
|
id="password_input"
|
||||||
|
tabindex="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="submit"
|
||||||
|
id="login_submit"
|
||||||
|
class='btn btn-jupyter'
|
||||||
|
value='Sign In'
|
||||||
|
tabindex="3"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" id="login_submit" class="btn btn-default">Log in</button>
|
|
||||||
</form>
|
</form>
|
||||||
{% if message %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="message">
|
|
||||||
{{message}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock login %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
{{super()}}
|
{{super()}}
|
||||||
|
|
||||||
|
@@ -1,13 +0,0 @@
|
|||||||
{% extends "page.html" %}
|
|
||||||
|
|
||||||
{% block login_widget %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
|
|
||||||
<div class="container logout-main">
|
|
||||||
<h1>You have been logged out</h1>
|
|
||||||
<p><a href="{{login_url}}">Log in again...</a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@@ -82,15 +82,15 @@
|
|||||||
|
|
||||||
<div id="header" class="navbar navbar-static-top">
|
<div id="header" class="navbar navbar-static-top">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span id="jupyterhub-logo" class="pull-left"><a href="{{base_url}}"><img src='{{static_url("images/jupyterhub-80.png") }}' alt='JupyterHub' class='jpy-logo' title='Home'/></a></span>
|
<span id="jupyterhub-logo" class="pull-left"><a href="{{base_url}}"><img src='{{static_url("images/jupyter.png") }}' alt='JupyterHub' class='jpy-logo' title='Home'/></a></span>
|
||||||
|
|
||||||
{% block login_widget %}
|
{% block login_widget %}
|
||||||
|
|
||||||
<span id="login_widget">
|
<span id="login_widget">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<a id="logout" class="btn navbar-btn btn-default pull-right" href="{{logout_url}}">Logout</a>
|
<a id="logout" class="navbar-btn btn-sm btn btn-default pull-right" href="{{logout_url}}"> <i class="fa fa-sign-out"></i> Logout</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a id="login" class="btn navbar-btn btn-default pull-right" href="{{login_url}}">Login</a>
|
<a id="login" class="btn-sm btn navbar-btn btn-default pull-right" href="{{login_url}}">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user