mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +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
|
||||
*.egg-info
|
||||
MANIFEST
|
||||
.coverage
|
||||
htmlcov
|
||||
|
||||
|
@@ -2,6 +2,7 @@
|
||||
language: python
|
||||
sudo: false
|
||||
python:
|
||||
- 3.5
|
||||
- 3.4
|
||||
- 3.3
|
||||
before_install:
|
||||
@@ -10,6 +11,8 @@ before_install:
|
||||
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
||||
install:
|
||||
- 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:
|
||||
- py.test jupyterhub
|
||||
- py.test --cov jupyterhub jupyterhub/tests -v
|
||||
after_success:
|
||||
- coveralls
|
||||
|
@@ -5,7 +5,7 @@
|
||||
# FROM jupyter/jupyterhub:latest
|
||||
#
|
||||
|
||||
FROM ipython/ipython
|
||||
FROM jupyter/notebook
|
||||
|
||||
MAINTAINER Jupyter Project <jupyter@googlegroups.com>
|
||||
|
||||
|
51
README.md
51
README.md
@@ -1,5 +1,9 @@
|
||||
# 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.
|
||||
|
||||
Three actors:
|
||||
@@ -31,18 +35,30 @@ Then install javascript dependencies:
|
||||
|
||||
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
|
||||
|
||||
Then you can install the Python package by doing:
|
||||
JupyterHub can be installed with pip:
|
||||
|
||||
pip install -r requirements.txt
|
||||
pip install .
|
||||
pip3 install jupyterhub
|
||||
|
||||
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:
|
||||
|
||||
pip install "ipython[notebook]"
|
||||
|
||||
pip3 install "ipython[notebook]"
|
||||
|
||||
This will fetch client-side javascript dependencies and compile CSS,
|
||||
and install these files to `sys.prefix`/share/jupyter, as well as
|
||||
@@ -51,15 +67,16 @@ install any Python dependencies.
|
||||
|
||||
### Development install
|
||||
|
||||
For a development install:
|
||||
For a development install, clone the repository and then install from source:
|
||||
|
||||
pip install -r dev-requirements.txt
|
||||
pip install -e .
|
||||
git clone https://github.com/jupyter/jupyterhub
|
||||
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:
|
||||
|
||||
python setup.py js # fetch updated client-side js (changes rarely)
|
||||
python setup.py css # recompile CSS from LESS sources
|
||||
python3 setup.py js # fetch updated client-side js (changes rarely)
|
||||
python3 setup.py css # recompile CSS from LESS sources
|
||||
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
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)
|
||||
- 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
|
||||
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,
|
||||
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
|
||||
|
||||
|
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).
|
||||
|
||||
See a list of custom Authenticators [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Authenticators).
|
||||
|
||||
|
||||
### Spawning
|
||||
|
||||
@@ -72,4 +74,4 @@ and needs to be able to take three actions:
|
||||
|
||||
[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
|
||||
3. stop the process
|
||||
|
||||
See a list of custom Spawners [on the wiki](https://github.com/jupyter/jupyterhub/wiki/Spawners).
|
||||
|
||||
|
||||
## Spawner.start
|
||||
|
||||
`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.
|
||||
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from tornado import web
|
||||
from .. import orm
|
||||
@@ -11,29 +12,31 @@ from ..utils import token_authenticated
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
|
||||
class TokenAPIHandler(APIHandler):
|
||||
@token_authenticated
|
||||
def get(self, token):
|
||||
orm_token = orm.APIToken.find(self.db, token)
|
||||
if orm_token is None:
|
||||
raise web.HTTPError(404)
|
||||
self.write(json.dumps({
|
||||
'user' : orm_token.user.name,
|
||||
}))
|
||||
self.write(json.dumps(self.user_model(orm_token.user)))
|
||||
|
||||
|
||||
class CookieAPIHandler(APIHandler):
|
||||
@token_authenticated
|
||||
def get(self, cookie_name):
|
||||
cookie_value = self.request.body
|
||||
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
|
||||
else:
|
||||
cookie_value = cookie_value.encode('utf8')
|
||||
user = self._user_for_cookie(cookie_name, cookie_value)
|
||||
if user is None:
|
||||
raise web.HTTPError(404)
|
||||
self.write(json.dumps({
|
||||
'user' : user.name,
|
||||
}))
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/authorizations/cookie/([^/]+)", CookieAPIHandler),
|
||||
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
||||
]
|
||||
|
@@ -9,8 +9,43 @@ from http.client import responses
|
||||
from tornado import web
|
||||
|
||||
from ..handlers import BaseHandler
|
||||
from ..utils import url_path_join
|
||||
|
||||
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):
|
||||
"""Return the body of the request as JSON data."""
|
||||
if not self.request.body:
|
||||
@@ -23,7 +58,6 @@ class APIHandler(BaseHandler):
|
||||
self.log.error("Couldn't parse JSON", exc_info=True)
|
||||
raise web.HTTPError(400, 'Invalid JSON in body of request')
|
||||
return model
|
||||
|
||||
|
||||
def write_error(self, status_code, **kwargs):
|
||||
"""Write JSON errors instead of HTML"""
|
||||
@@ -47,3 +81,38 @@ class APIHandler(BaseHandler):
|
||||
'status': status_code,
|
||||
'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:
|
||||
self.proxy.auth_token = model['auth_token']
|
||||
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()
|
||||
|
||||
|
||||
|
@@ -11,44 +11,56 @@ from .. import orm
|
||||
from ..utils import admin_only
|
||||
from .base import APIHandler
|
||||
|
||||
class BaseUserHandler(APIHandler):
|
||||
|
||||
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)
|
||||
))
|
||||
|
||||
class UserListAPIHandler(BaseUserHandler):
|
||||
class UserListAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
def get(self):
|
||||
users = self.db.query(orm.User)
|
||||
data = [ self.user_model(u) for u in users ]
|
||||
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):
|
||||
@@ -66,7 +78,7 @@ def admin_or_self(method):
|
||||
return method(self, name)
|
||||
return m
|
||||
|
||||
class UserAPIHandler(BaseUserHandler):
|
||||
class UserAPIHandler(APIHandler):
|
||||
|
||||
@admin_or_self
|
||||
def get(self, name):
|
||||
@@ -135,7 +147,7 @@ class UserAPIHandler(BaseUserHandler):
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
|
||||
|
||||
class UserServerAPIHandler(BaseUserHandler):
|
||||
class UserServerAPIHandler(APIHandler):
|
||||
@gen.coroutine
|
||||
@admin_or_self
|
||||
def post(self, name):
|
||||
@@ -165,7 +177,7 @@ class UserServerAPIHandler(BaseUserHandler):
|
||||
status = 202 if user.stop_pending else 204
|
||||
self.set_status(status)
|
||||
|
||||
class UserAdminAccessAPIHandler(BaseUserHandler):
|
||||
class UserAdminAccessAPIHandler(APIHandler):
|
||||
"""Grant admins access to single-user servers
|
||||
|
||||
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:
|
||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||
self.set_server_cookie(user)
|
||||
current.other_user_cookies.add(name)
|
||||
|
||||
|
||||
default_handlers = [
|
||||
|
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
"""The multi-user notebook application"""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
@@ -11,6 +11,7 @@ import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from distutils.version import LooseVersion as V
|
||||
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 import gen, web
|
||||
|
||||
import IPython
|
||||
if V(IPython.__version__) < V('3.0'):
|
||||
raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__)
|
||||
|
||||
from IPython.utils.traitlets import (
|
||||
from traitlets import (
|
||||
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__)
|
||||
|
||||
@@ -49,8 +46,8 @@ from .handlers.static import CacheControlStaticFilesHandler
|
||||
|
||||
from . import orm
|
||||
from ._data import DATA_FILES_PATH
|
||||
from .log import CoroutineLogFormatter
|
||||
from .traitlets import URLPrefix
|
||||
from .log import CoroutineLogFormatter, log_request
|
||||
from .traitlets import URLPrefix, Command
|
||||
from .utils import (
|
||||
url_path_join,
|
||||
ISO8601_ms, ISO8601_s,
|
||||
@@ -126,6 +123,7 @@ class NewToken(Application):
|
||||
hub = JupyterHub(parent=self)
|
||||
hub.load_config_file(hub.config_file)
|
||||
hub.init_db()
|
||||
hub.hub = hub.db.query(orm.Hub).first()
|
||||
hub.init_users()
|
||||
user = orm.User.find(hub.db, self.name)
|
||||
if user is None:
|
||||
@@ -138,6 +136,7 @@ class NewToken(Application):
|
||||
class JupyterHub(Application):
|
||||
"""An Application for starting a Multi-User Jupyter Notebook server."""
|
||||
name = 'jupyterhub'
|
||||
version = jupyterhub.__version__
|
||||
|
||||
description = """Start a multi-user Jupyter Notebook server
|
||||
|
||||
@@ -185,6 +184,11 @@ class JupyterHub(Application):
|
||||
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,
|
||||
help="Interval (in seconds) at which to update last-activity timestamps."
|
||||
)
|
||||
@@ -195,7 +199,15 @@ class JupyterHub(Application):
|
||||
data_files_path = Unicode(DATA_FILES_PATH, config=True,
|
||||
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,
|
||||
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."
|
||||
)
|
||||
|
||||
proxy_cmd = Unicode('configurable-http-proxy', config=True,
|
||||
proxy_cmd = Command('configurable-http-proxy', config=True,
|
||||
help="""The command to start the http proxy.
|
||||
|
||||
Only override if configurable-http-proxy is not on your PATH
|
||||
@@ -335,7 +347,6 @@ class JupyterHub(Application):
|
||||
debug_db = Bool(False, config=True,
|
||||
help="log all database transactions. This has A LOT of output"
|
||||
)
|
||||
db = Any()
|
||||
session_factory = Any()
|
||||
|
||||
admin_access = Bool(False, config=True,
|
||||
@@ -345,11 +356,9 @@ class JupyterHub(Application):
|
||||
"""
|
||||
)
|
||||
admin_users = Set(config=True,
|
||||
help="""set of usernames of admin users
|
||||
|
||||
If unspecified, only the user that launches the server will be admin.
|
||||
"""
|
||||
help="""DEPRECATED, use Authenticator.admin_users instead."""
|
||||
)
|
||||
|
||||
tornado_settings = Dict(config=True)
|
||||
|
||||
cleanup_servers = Bool(True, config=True,
|
||||
@@ -531,6 +540,40 @@ class JupyterHub(Application):
|
||||
# store the loaded trait value
|
||||
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):
|
||||
"""Create the database connection"""
|
||||
self.log.debug("Connecting to db: %s", self.db_url)
|
||||
@@ -541,7 +584,8 @@ class JupyterHub(Application):
|
||||
echo=self.debug_db,
|
||||
**self.db_kwargs
|
||||
)
|
||||
self.db = scoped_session(self.session_factory)()
|
||||
# trigger constructing thread local db property
|
||||
_ = self.db
|
||||
except OperationalError as e:
|
||||
self.log.error("Failed to connect to db: %s", self.db_url)
|
||||
self.log.debug("Database error was:", exc_info=True)
|
||||
@@ -574,16 +618,22 @@ class JupyterHub(Application):
|
||||
def init_users(self):
|
||||
"""Load users into and from the database"""
|
||||
db = self.db
|
||||
|
||||
if not self.admin_users:
|
||||
# add current user as admin if there aren't any others
|
||||
admins = db.query(orm.User).filter(orm.User.admin==True)
|
||||
if admins.first() is None:
|
||||
self.admin_users.add(getuser())
|
||||
|
||||
if self.admin_users and not self.authenticator.admin_users:
|
||||
self.log.warn(
|
||||
"\nJupyterHub.admin_users is deprecated."
|
||||
"\nUse Authenticator.admin_users instead."
|
||||
)
|
||||
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 = []
|
||||
|
||||
for name in self.admin_users:
|
||||
for name in admin_users:
|
||||
# ensure anyone specified as admin in config is admin in db
|
||||
user = orm.User.find(db, name)
|
||||
if user is None:
|
||||
@@ -627,6 +677,10 @@ class JupyterHub(Application):
|
||||
for user in new_users:
|
||||
yield gen.maybe_future(self.authenticator.add_user(user))
|
||||
db.commit()
|
||||
|
||||
@gen.coroutine
|
||||
def init_spawners(self):
|
||||
db = self.db
|
||||
|
||||
user_summaries = ['']
|
||||
def _user_summary(user):
|
||||
@@ -655,6 +709,7 @@ class JupyterHub(Application):
|
||||
self.log.debug("Loading state for %s from db", user.name)
|
||||
user.spawner = spawner = self.spawner_class(
|
||||
user=user, hub=self.hub, config=self.config, db=self.db,
|
||||
authenticator=self.authenticator,
|
||||
)
|
||||
status = yield spawner.poll()
|
||||
if status is None:
|
||||
@@ -705,19 +760,19 @@ class JupyterHub(Application):
|
||||
if isinstance(e, HTTPError) and e.code == 403:
|
||||
msg = "Did CONFIGPROXY_AUTH_TOKEN change?"
|
||||
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.proxy.public_server.url, e, msg)
|
||||
self.proxy.public_server.bind_url, e, msg)
|
||||
self.exit(1)
|
||||
return
|
||||
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
|
||||
return
|
||||
|
||||
env = os.environ.copy()
|
||||
env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token
|
||||
cmd = [self.proxy_cmd,
|
||||
cmd = self.proxy_cmd + [
|
||||
'--ip', self.proxy.public_server.ip,
|
||||
'--port', str(self.proxy.public_server.port),
|
||||
'--api-ip', self.proxy.api_server.ip,
|
||||
@@ -730,9 +785,17 @@ class JupyterHub(Application):
|
||||
cmd.extend(['--ssl-key', self.ssl_key])
|
||||
if 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.proxy_process = Popen(cmd, env=env)
|
||||
try:
|
||||
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():
|
||||
status = self.proxy_process.poll()
|
||||
if status is not None:
|
||||
@@ -768,9 +831,8 @@ class JupyterHub(Application):
|
||||
def init_tornado_settings(self):
|
||||
"""Set up the tornado settings dict."""
|
||||
base_url = self.hub.server.base_url
|
||||
template_path = os.path.join(self.data_files_path, 'templates'),
|
||||
jinja_env = Environment(
|
||||
loader=FileSystemLoader(template_path),
|
||||
loader=FileSystemLoader(self.template_paths),
|
||||
**self.jinja_environment_options
|
||||
)
|
||||
|
||||
@@ -786,23 +848,25 @@ class JupyterHub(Application):
|
||||
version_hash=datetime.now().strftime("%Y%m%d%H%M%S"),
|
||||
|
||||
settings = dict(
|
||||
log_function=log_request,
|
||||
config=self.config,
|
||||
log=self.log,
|
||||
db=self.db,
|
||||
proxy=self.proxy,
|
||||
hub=self.hub,
|
||||
admin_users=self.admin_users,
|
||||
admin_users=self.authenticator.admin_users,
|
||||
admin_access=self.admin_access,
|
||||
authenticator=self.authenticator,
|
||||
spawner_class=self.spawner_class,
|
||||
base_url=self.base_url,
|
||||
cookie_secret=self.cookie_secret,
|
||||
cookie_max_age_days=self.cookie_max_age_days,
|
||||
login_url=login_url,
|
||||
logout_url=logout_url,
|
||||
static_path=os.path.join(self.data_files_path, 'static'),
|
||||
static_url_prefix=url_path_join(self.hub.server.base_url, 'static/'),
|
||||
static_handler_class=CacheControlStaticFilesHandler,
|
||||
template_path=template_path,
|
||||
template_path=self.template_paths,
|
||||
jinja2_env=jinja_env,
|
||||
version_hash=version_hash,
|
||||
)
|
||||
@@ -845,6 +909,7 @@ class JupyterHub(Application):
|
||||
self.init_hub()
|
||||
self.init_proxy()
|
||||
yield self.init_users()
|
||||
yield self.init_spawners()
|
||||
self.init_handlers()
|
||||
self.init_tornado_settings()
|
||||
self.init_tornado_application()
|
||||
@@ -955,6 +1020,16 @@ class JupyterHub(Application):
|
||||
loop.stop()
|
||||
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
|
||||
try:
|
||||
yield self.start_proxy()
|
||||
@@ -976,12 +1051,12 @@ class JupyterHub(Application):
|
||||
pc = PeriodicCallback(self.update_last_activity, 1e3 * self.last_activity_interval)
|
||||
pc.start()
|
||||
|
||||
# start the webserver
|
||||
self.http_server = tornado.httpserver.HTTPServer(self.tornado_application, xheaders=True)
|
||||
self.http_server.listen(self.hub_port)
|
||||
|
||||
self.log.info("JupyterHub is now running at %s", self.proxy.public_server.url)
|
||||
# register cleanup on both TERM and INT
|
||||
atexit.register(self.atexit)
|
||||
self.init_signal()
|
||||
|
||||
def init_signal(self):
|
||||
signal.signal(signal.SIGTERM, self.sigterm)
|
||||
|
||||
def sigterm(self, signum, frame):
|
||||
@@ -1006,7 +1081,10 @@ class JupyterHub(Application):
|
||||
if not self.io_loop:
|
||||
return
|
||||
if self.http_server:
|
||||
self.io_loop.add_callback(self.http_server.stop)
|
||||
if self.io_loop._running:
|
||||
self.io_loop.add_callback(self.http_server.stop)
|
||||
else:
|
||||
self.http_server.stop()
|
||||
self.io_loop.add_callback(self.io_loop.stop)
|
||||
|
||||
@gen.coroutine
|
||||
@@ -1020,7 +1098,7 @@ class JupyterHub(Application):
|
||||
|
||||
@classmethod
|
||||
def launch_instance(cls, argv=None):
|
||||
self = cls.instance(argv=argv)
|
||||
self = cls.instance()
|
||||
loop = IOLoop.current()
|
||||
loop.add_callback(self.launch_instance_async, argv)
|
||||
try:
|
||||
|
@@ -3,14 +3,15 @@
|
||||
# Copyright (c) IPython Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from grp import getgrnam
|
||||
import pwd
|
||||
from subprocess import check_call, check_output, CalledProcessError
|
||||
|
||||
from tornado import gen
|
||||
import simplepam
|
||||
import pamela
|
||||
|
||||
from IPython.config import LoggingConfigurable
|
||||
from IPython.utils.traitlets import Bool, Set, Unicode, Any
|
||||
from traitlets.config import LoggingConfigurable
|
||||
from traitlets import Bool, Set, Unicode, Any
|
||||
|
||||
from .handlers.login import LoginHandler
|
||||
from .utils import url_path_join
|
||||
@@ -22,6 +23,12 @@ class Authenticator(LoggingConfigurable):
|
||||
"""
|
||||
|
||||
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,
|
||||
help="""Username whitelist.
|
||||
|
||||
@@ -29,7 +36,18 @@ class Authenticator(LoggingConfigurable):
|
||||
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
|
||||
def authenticate(self, handler, data):
|
||||
@@ -39,7 +57,26 @@ class Authenticator(LoggingConfigurable):
|
||||
It must return the username on successful 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):
|
||||
"""Add a new user
|
||||
|
||||
@@ -56,8 +93,7 @@ class Authenticator(LoggingConfigurable):
|
||||
|
||||
Removes the user from the whitelist.
|
||||
"""
|
||||
if user.name in self.whitelist:
|
||||
self.whitelist.remove(user.name)
|
||||
self.whitelist.discard(user.name)
|
||||
|
||||
def login_url(self, base_url):
|
||||
"""Override to register a custom login handler"""
|
||||
@@ -87,7 +123,37 @@ class LocalAuthenticator(Authenticator):
|
||||
should I try to create the system user?
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
def add_user(self, user):
|
||||
"""Add a new user
|
||||
@@ -152,12 +218,26 @@ class PAMAuthenticator(LocalAuthenticator):
|
||||
Return None otherwise.
|
||||
"""
|
||||
username = data['username']
|
||||
if self.whitelist and username not in self.whitelist:
|
||||
if not self.check_whitelist(username):
|
||||
return
|
||||
# simplepam wants bytes, not unicode
|
||||
# see simplepam#3
|
||||
busername = username.encode(self.encoding)
|
||||
bpassword = data['password'].encode(self.encoding)
|
||||
if simplepam.authenticate(busername, bpassword, service=self.service):
|
||||
try:
|
||||
pamela.authenticate(username, data['password'], service=self.service)
|
||||
except pamela.PAMError as e:
|
||||
self.log.warn("PAM Authentication failed: %s", e)
|
||||
else:
|
||||
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
|
||||
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):
|
||||
"""Base Handler class with access to common methods and properties."""
|
||||
@@ -62,7 +69,40 @@ class BaseHandler(RequestHandler):
|
||||
def finish(self, *args, **kwargs):
|
||||
"""Roll back any uncommitted transactions from the handler."""
|
||||
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
|
||||
@@ -71,6 +111,10 @@ class BaseHandler(RequestHandler):
|
||||
@property
|
||||
def admin_users(self):
|
||||
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):
|
||||
"""get_current_user from Authorization header token"""
|
||||
@@ -87,16 +131,25 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
||||
"""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 self.get_cookie(cookie_name):
|
||||
self.log.warn("Invalid or expired cookie token")
|
||||
clear()
|
||||
return
|
||||
cookie_id = cookie_id.decode('utf8', 'replace')
|
||||
user = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
||||
if user is None:
|
||||
# don't log the token itself
|
||||
self.log.warn("Invalid cookie token")
|
||||
# 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
|
||||
|
||||
def get_current_user_cookie(self):
|
||||
@@ -126,26 +179,44 @@ class BaseHandler(RequestHandler):
|
||||
self.db.commit()
|
||||
return user
|
||||
|
||||
def clear_login_cookie(self):
|
||||
user = self.get_current_user()
|
||||
def clear_login_cookie(self, name=None):
|
||||
if name is None:
|
||||
user = self.get_current_user()
|
||||
else:
|
||||
user = self.find_user(name)
|
||||
if user and user.server:
|
||||
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)
|
||||
|
||||
def set_server_cookie(self, user):
|
||||
"""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(
|
||||
user.server.cookie_name,
|
||||
user.cookie_id,
|
||||
path=user.server.base_url,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def set_hub_cookie(self, user):
|
||||
"""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.hub.server.cookie_name,
|
||||
user.cookie_id,
|
||||
path=self.hub.server.base_url)
|
||||
path=self.hub.server.base_url,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def set_login_cookie(self, user):
|
||||
"""Set login cookies for the Hub and single-user server."""
|
||||
@@ -194,6 +265,7 @@ class BaseHandler(RequestHandler):
|
||||
base_url=self.base_url,
|
||||
hub=self.hub,
|
||||
config=self.config,
|
||||
authenticator=self.authenticator,
|
||||
)
|
||||
@gen.coroutine
|
||||
def finish_user_spawn(f=None):
|
||||
@@ -289,6 +361,7 @@ class BaseHandler(RequestHandler):
|
||||
prefix=self.base_url,
|
||||
user=user,
|
||||
login_url=self.settings['login_url'],
|
||||
login_service=self.authenticator.login_service,
|
||||
logout_url=self.settings['logout_url'],
|
||||
static_url=self.static_url,
|
||||
version_hash=self.version_hash,
|
||||
@@ -310,7 +383,7 @@ class BaseHandler(RequestHandler):
|
||||
# construct the custom reason, if defined
|
||||
reason = getattr(exception, 'reason', '')
|
||||
if reason:
|
||||
status_message = reason
|
||||
message = reasons.get(reason, reason)
|
||||
|
||||
# build template namespace
|
||||
ns = dict(
|
||||
@@ -343,11 +416,12 @@ class PrefixRedirectHandler(BaseHandler):
|
||||
Redirects /foo to /prefix/foo, etc.
|
||||
"""
|
||||
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.hub.server.base_url, path,
|
||||
), permanent=False)
|
||||
|
||||
|
||||
class UserSpawnHandler(BaseHandler):
|
||||
"""Requests to /user/name handled by the Hub
|
||||
should result in spawning the single-user server and
|
||||
@@ -373,7 +447,7 @@ class UserSpawnHandler(BaseHandler):
|
||||
yield self.spawn_single_user(current_user)
|
||||
# set login cookie anew
|
||||
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)
|
||||
self.redirect(target)
|
||||
else:
|
||||
@@ -382,9 +456,18 @@ class UserSpawnHandler(BaseHandler):
|
||||
self.clear_login_cookie()
|
||||
self.redirect(url_concat(
|
||||
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 = [
|
||||
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
||||
(r'/security/csp-report', CSPReportHandler),
|
||||
]
|
||||
|
@@ -12,31 +12,44 @@ from .base import BaseHandler
|
||||
class LogoutHandler(BaseHandler):
|
||||
"""Log a user out by clearing their login cookie."""
|
||||
def get(self):
|
||||
user = self.get_current_user()
|
||||
if user:
|
||||
self.log.info("User logged out: %s", user.name)
|
||||
self.clear_login_cookie()
|
||||
html = self.render_template('logout.html')
|
||||
self.finish(html)
|
||||
for name in user.other_user_cookies:
|
||||
self.clear_login_cookie(name)
|
||||
user.other_user_cookies = set([])
|
||||
self.redirect(self.hub.server.base_url, permanent=False)
|
||||
|
||||
|
||||
class LoginHandler(BaseHandler):
|
||||
"""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',
|
||||
next=url_escape(self.get_argument('next', default='')),
|
||||
username=username,
|
||||
message=message,
|
||||
custom_html=self.authenticator.custom_html,
|
||||
login_error=login_error,
|
||||
custom_login_form=self.authenticator.custom_html,
|
||||
login_url=self.settings['login_url'],
|
||||
)
|
||||
|
||||
def get(self):
|
||||
next_url = self.get_argument('next', False)
|
||||
if next_url and self.get_current_user():
|
||||
next_url = self.get_argument('next', '')
|
||||
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
|
||||
# because single-user cookie may have been cleared or incorrect
|
||||
self.set_login_cookie(self.get_current_user())
|
||||
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:
|
||||
username = self.get_argument('username', default='')
|
||||
self.finish(self._render(username=username))
|
||||
@@ -48,9 +61,8 @@ class LoginHandler(BaseHandler):
|
||||
for arg in self.request.arguments:
|
||||
data[arg] = self.get_argument(arg)
|
||||
|
||||
username = data['username']
|
||||
authorized = yield self.authenticate(data)
|
||||
if authorized:
|
||||
username = yield self.authenticate(data)
|
||||
if username:
|
||||
user = self.user_from_username(username)
|
||||
already_running = False
|
||||
if user.spawner:
|
||||
@@ -59,12 +71,16 @@ class LoginHandler(BaseHandler):
|
||||
if not already_running:
|
||||
yield self.spawn_single_user(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.log.info("User logged in: %s", username)
|
||||
else:
|
||||
self.log.debug("Failed login for %s", username)
|
||||
html = self._render(
|
||||
message={'error': 'Invalid username or password'},
|
||||
login_error='Invalid username or password',
|
||||
username=username,
|
||||
)
|
||||
self.finish(html)
|
||||
@@ -72,5 +88,6 @@ class LoginHandler(BaseHandler):
|
||||
|
||||
# Only logout is a default handler.
|
||||
default_handlers = [
|
||||
(r"/login", LoginHandler),
|
||||
(r"/logout", LogoutHandler),
|
||||
]
|
||||
|
@@ -8,26 +8,33 @@ from tornado import web
|
||||
from .. import orm
|
||||
from ..utils import admin_only, url_path_join
|
||||
from .base import BaseHandler
|
||||
from .login import LoginHandler
|
||||
|
||||
|
||||
class RootHandler(BaseHandler):
|
||||
"""Render the Hub root page.
|
||||
|
||||
Currently redirects to home if logged in,
|
||||
shows big fat login button otherwise.
|
||||
If logged in, redirects to:
|
||||
|
||||
- single-user server if running
|
||||
- hub home, otherwise
|
||||
|
||||
Otherwise, renders login page.
|
||||
"""
|
||||
def get(self):
|
||||
if self.get_current_user():
|
||||
self.redirect(
|
||||
url_path_join(self.hub.server.base_url, 'home'),
|
||||
permanent=False,
|
||||
)
|
||||
user = self.get_current_user()
|
||||
if user:
|
||||
if user.running:
|
||||
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
|
||||
|
||||
html = self.render_template('index.html',
|
||||
login_url=self.settings['login_url'],
|
||||
)
|
||||
self.finish(html)
|
||||
url = url_path_join(self.hub.server.base_url, 'login')
|
||||
self.redirect(url)
|
||||
|
||||
|
||||
class HomeHandler(BaseHandler):
|
||||
"""Render the user's home page."""
|
||||
|
@@ -2,9 +2,12 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import json
|
||||
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):
|
||||
"""Scrub coroutine frames from a traceback
|
||||
@@ -38,3 +41,61 @@ class CoroutineLogFormatter(LogFormatter):
|
||||
def formatException(self, 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 json
|
||||
import socket
|
||||
from urllib.parse import quote
|
||||
|
||||
from tornado import gen
|
||||
from tornado.log import app_log
|
||||
@@ -75,12 +76,16 @@ class Server(Base):
|
||||
|
||||
@property
|
||||
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(
|
||||
proto=self.proto,
|
||||
ip=self.ip or 'localhost',
|
||||
ip=ip,
|
||||
port=self.port,
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return "{host}{uri}".format(
|
||||
@@ -88,6 +93,17 @@ class Server(Base):
|
||||
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
|
||||
def wait_up(self, timeout=10, http=False):
|
||||
"""Wait for this server to come up"""
|
||||
@@ -130,7 +146,7 @@ class Proxy(Base):
|
||||
)
|
||||
else:
|
||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
||||
|
||||
|
||||
def api_request(self, path, method='GET', body=None, client=None):
|
||||
"""Make an authenticated API request of the proxy"""
|
||||
client = client or AsyncHTTPClient()
|
||||
@@ -269,6 +285,8 @@ class User(Base):
|
||||
spawner = None
|
||||
spawn_pending = False
|
||||
stop_pending = False
|
||||
|
||||
other_user_cookies = set([])
|
||||
|
||||
def __repr__(self):
|
||||
if self.server:
|
||||
@@ -284,6 +302,11 @@ class User(Base):
|
||||
name=self.name,
|
||||
)
|
||||
|
||||
@property
|
||||
def escaped_name(self):
|
||||
"""My name, escaped for use in URLs, cookies, etc."""
|
||||
return quote(self.name, safe='@')
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
"""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()
|
||||
|
||||
@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"""
|
||||
db = inspect(self).session
|
||||
if hub is None:
|
||||
hub = db.query(Hub).first()
|
||||
|
||||
self.server = Server(
|
||||
cookie_name='%s-%s' % (hub.server.cookie_name, self.name),
|
||||
base_url=url_path_join(base_url, 'user', self.name),
|
||||
cookie_name='%s-%s' % (hub.server.cookie_name, quote(self.name, safe='')),
|
||||
base_url=url_path_join(base_url, 'user', self.escaped_name),
|
||||
)
|
||||
db.add(self.server)
|
||||
db.commit()
|
||||
@@ -333,11 +357,15 @@ class User(Base):
|
||||
user=self,
|
||||
hub=hub,
|
||||
db=db,
|
||||
authenticator=authenticator,
|
||||
)
|
||||
# we are starting a new server, make sure it doesn't restore state
|
||||
spawner.clear_state()
|
||||
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
|
||||
# wait for spawner.start to return
|
||||
try:
|
||||
@@ -348,10 +376,12 @@ class User(Base):
|
||||
self.log.warn("{user}'s server failed to start in {s} seconds, giving up".format(
|
||||
user=self.name, s=spawner.start_timeout,
|
||||
))
|
||||
e.reason = 'timeout'
|
||||
else:
|
||||
self.log.error("Unhandled error starting {user}'s server: {error}".format(
|
||||
user=self.name, error=e,
|
||||
))
|
||||
e.reason = 'error'
|
||||
try:
|
||||
yield self.stop()
|
||||
except Exception:
|
||||
@@ -378,7 +408,9 @@ class User(Base):
|
||||
http_timeout=spawner.http_timeout,
|
||||
)
|
||||
)
|
||||
e.reason = 'timeout'
|
||||
else:
|
||||
e.reason = 'error'
|
||||
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,
|
||||
))
|
||||
@@ -400,22 +432,27 @@ class User(Base):
|
||||
and cleanup after it.
|
||||
"""
|
||||
self.spawn_pending = False
|
||||
if self.spawner is None:
|
||||
spawner = self.spawner
|
||||
if spawner is None:
|
||||
return
|
||||
self.spawner.stop_polling()
|
||||
spawner.stop_polling()
|
||||
self.stop_pending = True
|
||||
try:
|
||||
status = yield self.spawner.poll()
|
||||
status = yield spawner.poll()
|
||||
if status is None:
|
||||
yield self.spawner.stop()
|
||||
self.spawner.clear_state()
|
||||
self.state = self.spawner.get_state()
|
||||
self.last_activity = datetime.utcnow()
|
||||
spawner.clear_state()
|
||||
self.state = spawner.get_state()
|
||||
self.server = None
|
||||
inspect(self).session.commit()
|
||||
finally:
|
||||
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):
|
||||
"""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."""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import os
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
except ImportError:
|
||||
# PY2 Compat
|
||||
from urllib import quote
|
||||
|
||||
import requests
|
||||
from jinja2 import ChoiceLoader, FunctionLoader
|
||||
|
||||
from tornado import ioloop
|
||||
from tornado.web import HTTPError
|
||||
|
||||
from IPython.utils.traitlets import Unicode
|
||||
|
||||
from IPython.html.notebookapp import NotebookApp
|
||||
from IPython.html.auth.login import LoginHandler
|
||||
from IPython.html.auth.logout import LogoutHandler
|
||||
from IPython.utils.traitlets import (
|
||||
Integer,
|
||||
Unicode,
|
||||
CUnicode,
|
||||
)
|
||||
|
||||
from IPython.html.utils import url_path_join
|
||||
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.logout import LogoutHandler
|
||||
|
||||
from IPython.html.utils import url_path_join
|
||||
|
||||
from distutils.version import LooseVersion as V
|
||||
from distutils.version import LooseVersion as V
|
||||
|
||||
import IPython
|
||||
if V(IPython.__version__) < V('3.0'):
|
||||
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
|
||||
|
||||
import IPython
|
||||
if V(IPython.__version__) < V('3.0'):
|
||||
raise ImportError("JupyterHub Requires IPython >= 3.0, found %s" % IPython.__version__)
|
||||
|
||||
# Define two methods to attach to AuthenticatedHandler,
|
||||
# which authenticate via the central auth server.
|
||||
@@ -45,14 +66,13 @@ class JupyterHubLoginHandler(LoginHandler):
|
||||
hub_api_url = self.settings['hub_api_url']
|
||||
hub_api_key = self.settings['hub_api_key']
|
||||
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},
|
||||
data=encrypted_cookie,
|
||||
)
|
||||
if r.status_code == 404:
|
||||
data = {'user' : ''}
|
||||
if r.status_code == 403:
|
||||
data = None
|
||||
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)
|
||||
raise HTTPError(500, "Permission failure checking authorization, I may need to be restarted")
|
||||
elif r.status_code >= 500:
|
||||
@@ -83,7 +103,7 @@ class JupyterHubLoginHandler(LoginHandler):
|
||||
if not auth_data:
|
||||
# treat invalid token the same as no token
|
||||
return None
|
||||
user = auth_data['user']
|
||||
user = auth_data['name']
|
||||
if user == my_user:
|
||||
self._cached_user = user
|
||||
return user
|
||||
@@ -100,7 +120,7 @@ class JupyterHubLogoutHandler(LogoutHandler):
|
||||
|
||||
|
||||
# register new hub related command-line aliases
|
||||
aliases = NotebookApp.aliases.get_default_value()
|
||||
aliases = dict(notebook_aliases)
|
||||
aliases.update({
|
||||
'user' : 'SingleUserNotebookApp.user',
|
||||
'cookie-name': 'SingleUserNotebookApp.cookie_name',
|
||||
@@ -109,9 +129,23 @@ aliases.update({
|
||||
'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):
|
||||
"""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):
|
||||
self.log.name = new
|
||||
cookie_name = Unicode(config=True)
|
||||
@@ -119,9 +153,20 @@ class SingleUserNotebookApp(NotebookApp):
|
||||
hub_api_url = Unicode(config=True)
|
||||
aliases = aliases
|
||||
open_browser = False
|
||||
trust_xheaders = True
|
||||
login_handler_class = JupyterHubLoginHandler
|
||||
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):
|
||||
"""Exclude date from default date format"""
|
||||
return "%Y-%m-%d %H:%M:%S"
|
||||
@@ -133,6 +178,21 @@ class SingleUserNotebookApp(NotebookApp):
|
||||
def _confirm_exit(self):
|
||||
# disable the exit confirmation for background notebook processes
|
||||
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):
|
||||
# load the hub related settings into the tornado settings dict
|
||||
@@ -143,9 +203,30 @@ class SingleUserNotebookApp(NotebookApp):
|
||||
s['hub_api_key'] = env.pop('JPY_API_TOKEN')
|
||||
s['hub_prefix'] = self.hub_prefix
|
||||
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['csp_report_uri'] = url_path_join(self.hub_prefix, 'security/csp-report')
|
||||
|
||||
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():
|
||||
|
@@ -7,24 +7,23 @@ import errno
|
||||
import os
|
||||
import pipes
|
||||
import pwd
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
from subprocess import Popen, check_output, PIPE, CalledProcessError
|
||||
import grp
|
||||
from subprocess import Popen
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from tornado import gen
|
||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
||||
|
||||
from IPython.config import LoggingConfigurable
|
||||
from IPython.utils.traitlets import (
|
||||
Any, Bool, Dict, Enum, Instance, Integer, Float, List, Unicode,
|
||||
from traitlets.config import LoggingConfigurable
|
||||
from traitlets import (
|
||||
Any, Bool, Dict, Instance, Integer, Float, List, Unicode,
|
||||
)
|
||||
|
||||
from .traitlets import Command
|
||||
from .utils import random_port
|
||||
|
||||
NUM_PAT = re.compile(r'\d+')
|
||||
|
||||
class Spawner(LoggingConfigurable):
|
||||
"""Base class for spawning single-user notebook servers.
|
||||
|
||||
@@ -40,6 +39,7 @@ class Spawner(LoggingConfigurable):
|
||||
db = Any()
|
||||
user = Any()
|
||||
hub = Any()
|
||||
authenticator = Any()
|
||||
api_token = Unicode()
|
||||
ip = Unicode('localhost', config=True,
|
||||
help="The IP address (or hostname) the single-user server should listen on"
|
||||
@@ -54,7 +54,7 @@ class Spawner(LoggingConfigurable):
|
||||
)
|
||||
|
||||
http_timeout = Integer(
|
||||
10, config=True,
|
||||
30, config=True,
|
||||
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
|
||||
@@ -93,7 +93,7 @@ class Spawner(LoggingConfigurable):
|
||||
env['JPY_API_TOKEN'] = self.api_token
|
||||
return env
|
||||
|
||||
cmd = List(Unicode, default_value=['jupyterhub-singleuser'], config=True,
|
||||
cmd = Command(['jupyterhub-singleuser'], config=True,
|
||||
help="""The command used for starting notebooks."""
|
||||
)
|
||||
args = List(Unicode, config=True,
|
||||
@@ -252,7 +252,7 @@ class Spawner(LoggingConfigurable):
|
||||
if status is not None:
|
||||
break
|
||||
else:
|
||||
yield gen.Task(loop.add_timeout, loop.time() + self.death_interval)
|
||||
yield gen.sleep(self.death_interval)
|
||||
|
||||
def _try_setcwd(path):
|
||||
"""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
|
||||
gid = user.pw_gid
|
||||
home = user.pw_dir
|
||||
gids = [ g.gr_gid for g in grp.getgrall() if username in g.gr_mem ]
|
||||
|
||||
def preexec():
|
||||
# don't forward signals
|
||||
@@ -282,6 +283,10 @@ def set_user_setuid(username):
|
||||
|
||||
# set the user and group
|
||||
os.setgid(gid)
|
||||
try:
|
||||
os.setgroups(gids)
|
||||
except Exception as e:
|
||||
print('Failed to set groups %s' % e, file=sys.stderr)
|
||||
os.setuid(uid)
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
proc = Instance(Popen)
|
||||
proc = Instance(Popen, allow_none=True)
|
||||
pid = Integer(0)
|
||||
|
||||
def make_preexec_fn(self, name):
|
||||
@@ -329,7 +334,14 @@ class LocalProcessSpawner(Spawner):
|
||||
|
||||
def user_env(self, env):
|
||||
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
|
||||
|
||||
def _env_default(self):
|
||||
|
@@ -7,6 +7,8 @@ import threading
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import requests
|
||||
|
||||
from tornado import gen
|
||||
from tornado.concurrent import Future
|
||||
from tornado.ioloop import IOLoop
|
||||
@@ -16,16 +18,18 @@ from ..app import JupyterHub
|
||||
from ..auth import PAMAuthenticator
|
||||
from .. import orm
|
||||
|
||||
from pamela import PAMError
|
||||
|
||||
def mock_authenticate(username, password, service='login'):
|
||||
# mimic simplepam's failure to handle unicode
|
||||
if isinstance(username, str):
|
||||
return False
|
||||
if isinstance(password, str):
|
||||
return False
|
||||
|
||||
# just use equality for testing
|
||||
if password == username:
|
||||
return True
|
||||
else:
|
||||
raise PAMError("Fake")
|
||||
|
||||
|
||||
def mock_open_session(username, service):
|
||||
pass
|
||||
|
||||
|
||||
class MockSpawner(LocalProcessSpawner):
|
||||
@@ -49,12 +53,12 @@ class SlowSpawner(MockSpawner):
|
||||
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
|
||||
yield gen.sleep(2)
|
||||
yield super().start()
|
||||
|
||||
@gen.coroutine
|
||||
def stop(self):
|
||||
yield gen.Task(IOLoop.current().add_timeout, timedelta(seconds=2))
|
||||
yield gen.sleep(2)
|
||||
yield super().stop()
|
||||
|
||||
|
||||
@@ -70,19 +74,24 @@ class NeverSpawner(MockSpawner):
|
||||
|
||||
|
||||
class MockPAMAuthenticator(PAMAuthenticator):
|
||||
def _admin_users_default(self):
|
||||
return {'admin'}
|
||||
|
||||
def system_user_exists(self, user):
|
||||
# skip the add-system-user bit
|
||||
return not user.name.startswith('dne')
|
||||
|
||||
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)
|
||||
|
||||
class MockHub(JupyterHub):
|
||||
"""Hub with various mock bits"""
|
||||
|
||||
db_file = None
|
||||
|
||||
|
||||
def _ip_default(self):
|
||||
return 'localhost'
|
||||
|
||||
@@ -92,15 +101,18 @@ class MockHub(JupyterHub):
|
||||
def _spawner_class_default(self):
|
||||
return MockSpawner
|
||||
|
||||
def _admin_users_default(self):
|
||||
return {'admin'}
|
||||
def init_signal(self):
|
||||
pass
|
||||
|
||||
def start(self, argv=None):
|
||||
self.db_file = NamedTemporaryFile()
|
||||
self.db_url = 'sqlite:///' + self.db_file.name
|
||||
|
||||
evt = threading.Event()
|
||||
|
||||
@gen.coroutine
|
||||
def _start_co():
|
||||
assert self.io_loop._running
|
||||
# put initialize in start for SQLAlchemy threading reasons
|
||||
yield super(MockHub, self).initialize(argv=argv)
|
||||
# add an initial user
|
||||
@@ -108,16 +120,19 @@ class MockHub(JupyterHub):
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
yield super(MockHub, self).start()
|
||||
yield self.hub.server.wait_up(http=True)
|
||||
self.io_loop.add_callback(evt.set)
|
||||
|
||||
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.start()
|
||||
|
||||
self._thread = threading.Thread(target=_start)
|
||||
self._thread.start()
|
||||
evt.wait(timeout=5)
|
||||
ready = evt.wait(timeout=10)
|
||||
assert ready
|
||||
|
||||
def stop(self):
|
||||
super().stop()
|
||||
@@ -126,3 +141,15 @@ class MockHub(JupyterHub):
|
||||
# ignore the call that will fire in atexit
|
||||
self.cleanup = lambda : None
|
||||
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"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from queue import Queue
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
@@ -59,11 +62,15 @@ def api_request(app, *api_path, **kwargs):
|
||||
|
||||
if 'Authorization' not in headers:
|
||||
headers.update(auth_header(app.db, 'admin'))
|
||||
|
||||
|
||||
url = ujoin(base_url, 'api', *api_path)
|
||||
method = kwargs.pop('method', 'get')
|
||||
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):
|
||||
db = app.db
|
||||
@@ -78,7 +85,7 @@ def test_auth_api(app):
|
||||
r = api_request(app, 'authorizations/token', api_token)
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert reply['user'] == user.name
|
||||
assert reply['name'] == user.name
|
||||
|
||||
# check fail
|
||||
r = api_request(app, 'authorizations/token', api_token,
|
||||
@@ -91,6 +98,51 @@ def test_auth_api(app):
|
||||
)
|
||||
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):
|
||||
db = app.db
|
||||
r = api_request(app, 'users')
|
||||
@@ -129,6 +181,82 @@ def test_add_user(app):
|
||||
assert user.name == name
|
||||
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):
|
||||
db = app.db
|
||||
name = 'dne_newuser'
|
||||
@@ -175,6 +303,18 @@ def test_make_admin(app):
|
||||
assert user.name == name
|
||||
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):
|
||||
db = app.db
|
||||
@@ -183,9 +323,10 @@ def test_spawn(app, io_loop):
|
||||
r = api_request(app, 'users', name, 'server', method='post')
|
||||
assert r.status_code == 201
|
||||
assert 'pid' in user.state
|
||||
assert user.spawner is not None
|
||||
assert not user.spawn_pending
|
||||
status = io_loop.run_sync(user.spawner.poll)
|
||||
app_user = get_app_user(app, name)
|
||||
assert app_user.spawner is not None
|
||||
assert not app_user.spawn_pending
|
||||
status = io_loop.run_sync(app_user.spawner.poll)
|
||||
assert status is None
|
||||
|
||||
assert user.server.base_url == '/user/%s' % name
|
||||
@@ -203,7 +344,7 @@ def test_spawn(app, io_loop):
|
||||
assert r.status_code == 204
|
||||
|
||||
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
|
||||
|
||||
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.raise_for_status()
|
||||
assert r.status_code == 202
|
||||
assert user.spawner is not None
|
||||
assert user.spawn_pending
|
||||
assert not user.stop_pending
|
||||
app_user = get_app_user(app, name)
|
||||
assert app_user.spawner is not None
|
||||
assert app_user.spawn_pending
|
||||
assert not app_user.stop_pending
|
||||
|
||||
dt = timedelta(seconds=0.1)
|
||||
@gen.coroutine
|
||||
def wait_spawn():
|
||||
while user.spawn_pending:
|
||||
yield gen.Task(io_loop.add_timeout, dt)
|
||||
while app_user.spawn_pending:
|
||||
yield gen.sleep(0.1)
|
||||
|
||||
io_loop.run_sync(wait_spawn)
|
||||
assert not user.spawn_pending
|
||||
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
|
||||
|
||||
@gen.coroutine
|
||||
def wait_stop():
|
||||
while user.stop_pending:
|
||||
yield gen.Task(io_loop.add_timeout, dt)
|
||||
while app_user.stop_pending:
|
||||
yield gen.sleep(0.1)
|
||||
|
||||
r = api_request(app, 'users', name, 'server', method='delete')
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 202
|
||||
assert user.spawner is not None
|
||||
assert user.stop_pending
|
||||
assert app_user.spawner is not None
|
||||
assert app_user.stop_pending
|
||||
|
||||
r = api_request(app, 'users', name, 'server', method='delete')
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 202
|
||||
assert user.spawner is not None
|
||||
assert user.stop_pending
|
||||
assert app_user.spawner is not None
|
||||
assert app_user.stop_pending
|
||||
|
||||
io_loop.run_sync(wait_stop)
|
||||
assert not user.stop_pending
|
||||
assert user.spawner is not None
|
||||
assert not app_user.stop_pending
|
||||
assert app_user.spawner is not None
|
||||
r = api_request(app, 'users', name, 'server', method='delete')
|
||||
assert r.status_code == 400
|
||||
|
||||
@@ -264,18 +405,18 @@ def test_never_spawn(app, io_loop):
|
||||
name = 'badger'
|
||||
user = add_user(db, name=name)
|
||||
r = api_request(app, 'users', name, 'server', method='post')
|
||||
assert user.spawner is not None
|
||||
assert user.spawn_pending
|
||||
app_user = get_app_user(app, name)
|
||||
assert app_user.spawner is not None
|
||||
assert app_user.spawn_pending
|
||||
|
||||
dt = timedelta(seconds=0.1)
|
||||
@gen.coroutine
|
||||
def wait_pending():
|
||||
while user.spawn_pending:
|
||||
yield gen.Task(io_loop.add_timeout, dt)
|
||||
while app_user.spawn_pending:
|
||||
yield gen.sleep(0.1)
|
||||
|
||||
io_loop.run_sync(wait_pending)
|
||||
assert not user.spawn_pending
|
||||
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 not None
|
||||
|
||||
|
||||
@@ -284,3 +425,18 @@ def test_get_proxy(app, io_loop):
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
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 re
|
||||
import sys
|
||||
from getpass import getuser
|
||||
from subprocess import check_output
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
|
||||
@@ -16,7 +15,9 @@ def test_token_app():
|
||||
cmd = [sys.executable, '-m', 'jupyterhub', 'token']
|
||||
out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
|
||||
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)
|
||||
|
||||
def test_generate_config():
|
||||
|
@@ -3,8 +3,13 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from subprocess import CalledProcessError
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from .mocking import MockPAMAuthenticator
|
||||
|
||||
from jupyterhub import auth, orm
|
||||
|
||||
def test_pam_auth(io_loop):
|
||||
authenticator = MockPAMAuthenticator()
|
||||
@@ -39,3 +44,106 @@ def test_pam_auth_whitelist(io_loop):
|
||||
'password': 'mal',
|
||||
}))
|
||||
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 server.host == 'http://localhost:%i' % server.port
|
||||
assert server.url == server.host + '/'
|
||||
assert server.bind_url == 'http://*:%i/' % server.port
|
||||
server.ip = '127.0.0.1'
|
||||
assert server.host == 'http://127.0.0.1:%i' % server.port
|
||||
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 os
|
||||
from queue import Queue
|
||||
from subprocess import Popen
|
||||
|
||||
from .. import orm
|
||||
@@ -26,7 +27,7 @@ def test_external_proxy(request, io_loop):
|
||||
request.addfinalizer(fin)
|
||||
env = os.environ.copy()
|
||||
env['CONFIGPROXY_AUTH_TOKEN'] = auth_token
|
||||
cmd = [app.proxy_cmd,
|
||||
cmd = app.proxy_cmd + [
|
||||
'--ip', app.ip,
|
||||
'--port', str(app.port),
|
||||
'--api-ip', proxy_ip,
|
||||
@@ -82,7 +83,7 @@ def test_external_proxy(request, io_loop):
|
||||
new_auth_token = 'different!'
|
||||
env['CONFIGPROXY_AUTH_TOKEN'] = new_auth_token
|
||||
proxy_port = 55432
|
||||
cmd = [app.proxy_cmd,
|
||||
cmd = app.proxy_cmd + [
|
||||
'--ip', app.ip,
|
||||
'--port', str(app.port),
|
||||
'--api-ip', app.proxy_api_ip,
|
||||
@@ -100,7 +101,15 @@ def test_external_proxy(request, io_loop):
|
||||
}))
|
||||
r.raise_for_status()
|
||||
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
|
||||
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)
|
||||
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):
|
||||
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.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from IPython.utils.traitlets import Unicode
|
||||
from traitlets import List, Unicode
|
||||
|
||||
class URLPrefix(Unicode):
|
||||
def validate(self, obj, value):
|
||||
@@ -12,3 +12,18 @@ class URLPrefix(Unicode):
|
||||
if not u.endswith('/'):
|
||||
u = 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 socket
|
||||
import uuid
|
||||
from hmac import compare_digest
|
||||
|
||||
from tornado import web, gen, ioloop
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPError
|
||||
from tornado.log import app_log
|
||||
|
||||
from IPython.html.utils import url_path_join
|
||||
|
||||
|
||||
def 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",
|
||||
ip, port, e
|
||||
)
|
||||
yield gen.Task(loop.add_timeout, loop.time() + 0.1)
|
||||
yield gen.sleep(0.1)
|
||||
else:
|
||||
return
|
||||
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,
|
||||
# but 502 or other proxy error is conceivable
|
||||
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:
|
||||
app_log.debug("Server at %s responded with %s", url, e.code)
|
||||
return
|
||||
except (OSError, socket.error) as e:
|
||||
if e.errno not in {errno.ECONNABORTED, errno.ECONNREFUSED, errno.ECONNRESET}:
|
||||
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:
|
||||
return
|
||||
|
||||
@@ -165,9 +164,31 @@ def compare_token(compare, token):
|
||||
uses the same algorithm and salt of the hashed token for comparison
|
||||
"""
|
||||
algorithm, srounds, salt, _ = compare.split(':')
|
||||
hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm)
|
||||
if compare == hashed:
|
||||
hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm).encode('utf8')
|
||||
compare = compare.encode('utf8')
|
||||
if compare_digest(compare, hashed):
|
||||
return True
|
||||
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 = (
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
# 'dev',
|
||||
)
|
||||
|
||||
__version__ = '.'.join(map(str, version_info))
|
||||
|
@@ -1,6 +1,6 @@
|
||||
ipython>=3
|
||||
tornado>=4
|
||||
traitlets>=4
|
||||
tornado>=4.1
|
||||
jinja2
|
||||
simplepam
|
||||
pamela
|
||||
sqlalchemy
|
||||
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
|
||||
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
|
||||
main()
|
||||
|
1
setup.py
1
setup.py
@@ -165,6 +165,7 @@ class Bower(BaseCommand):
|
||||
return
|
||||
|
||||
if self.should_run_npm():
|
||||
print("installing build dependencies with npm")
|
||||
check_call(['npm', 'install'], cwd=here)
|
||||
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 = $(th);
|
||||
var col = th.data('sort');
|
||||
if (!col || col.length == 0) {
|
||||
if (!col || col.length === 0) {
|
||||
return;
|
||||
}
|
||||
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 () {
|
||||
resort(col, order);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
$(".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 () {
|
||||
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");
|
||||
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 () {
|
||||
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 || {};
|
||||
var data = update(userinfo, {usernames: usernames});
|
||||
options = update(options, {
|
||||
type: 'POST',
|
||||
dataType: null,
|
||||
data: JSON.stringify(userinfo)
|
||||
data: JSON.stringify(data)
|
||||
});
|
||||
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user),
|
||||
options
|
||||
);
|
||||
this.api_request('users', options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.edit_user = function (user, userinfo, options) {
|
||||
|
@@ -10,17 +10,12 @@ div.ajax-error {
|
||||
}
|
||||
|
||||
div.error > h1 {
|
||||
font-size: 500%;
|
||||
line-height: normal;
|
||||
font-size: 300%;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
div.error > p {
|
||||
font-size: 200%;
|
||||
line-height: normal;
|
||||
font-size: 200%;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
div.traceback-wrapper {
|
||||
text-align: left;
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
}
|
||||
|
@@ -1,33 +1,55 @@
|
||||
#login-main {
|
||||
display: table;
|
||||
height: 80vh;
|
||||
|
||||
.service-login {
|
||||
text-align: center;
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
margin: auto auto 20% auto;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 8px auto;
|
||||
width: 400px;
|
||||
padding: 50px;
|
||||
border: 1px solid #ccc;
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
margin: auto auto 20% auto;
|
||||
width: 350px;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
* {
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.input-group, input, button {
|
||||
.input-group, input[type=text], button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-group-addon {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.pwd-group {
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
button[type=submit] {
|
||||
input[type=submit] {
|
||||
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 {
|
||||
height: 40px;
|
||||
margin: 8px;
|
||||
height: 28px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
div#header {
|
||||
border-bottom: 1px solid #ccc;
|
||||
#header {
|
||||
border-bottom: 1px solid #e7e7e7;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
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 "./admin.less";
|
||||
@import "./error.less";
|
||||
@import "./logout.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>
|
||||
<tbody>
|
||||
<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="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>
|
||||
</tr>
|
||||
{% for u in users %}
|
||||
@@ -42,15 +42,19 @@
|
||||
<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="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="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 %}
|
||||
<span class="access-server btn btn-xs btn-success {% if not u.running %}hidden{% endif %}">access server</span>
|
||||
{% endif %}
|
||||
<span class="start-server btn btn-xs btn-success {% if u.running %}hidden{% endif %}">start server</span>
|
||||
</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>
|
||||
</td>
|
||||
<td class="edit-col col-sm-1 text-center">
|
||||
{% if u.name != user.name %}
|
||||
<span class="delete-user btn btn-xs btn-danger">delete</span>
|
||||
{% endif %}
|
||||
@@ -82,10 +86,17 @@
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{% macro user_modal(name) %}
|
||||
{% macro user_modal(name, multi=False) %}
|
||||
{% call modal(name, btn_class='btn-primary save-button') %}
|
||||
<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 class="checkbox">
|
||||
<label>
|
||||
@@ -97,7 +108,7 @@
|
||||
|
||||
{{ user_modal('Edit User') }}
|
||||
|
||||
{{ user_modal('Add User') }}
|
||||
{{ user_modal('Add User', multi=True) }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
@@ -6,17 +6,18 @@
|
||||
{% block main %}
|
||||
|
||||
<div class="error">
|
||||
{% block h1_error %}
|
||||
<h1>{{status_code}} : {{status_message}}</h1>
|
||||
{% endblock h1_error %}
|
||||
{% block error_detail %}
|
||||
{% if message %}
|
||||
<p>The error was:</p>
|
||||
<div class="traceback-wrapper">
|
||||
<pre class="traceback">{{message}}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block h1_error %}
|
||||
<h1>
|
||||
{{status_code}} : {{status_message}}
|
||||
</h1>
|
||||
{% endblock h1_error %}
|
||||
{% block error_detail %}
|
||||
{% if message %}
|
||||
<p>
|
||||
{{message}}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock error_detail %}
|
||||
</div>
|
||||
|
||||
{% 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 login %}
|
||||
<div id="login-main" class="container">
|
||||
{% 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 %}
|
||||
<form action="{{login_url}}?next={{next}}" method="post" role="form">
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon">Username:</span>
|
||||
<input type="username" class="form-control" name="username" id="username_input" val="{{username}}">
|
||||
</div>
|
||||
<div class="input-group pwd-group">
|
||||
<span class="input-group-addon">Password:</span>
|
||||
<input type="password" class="form-control" name="password" id="password_input">
|
||||
</div>
|
||||
<button type="submit" id="login_submit" class="btn btn-default">Log in</button>
|
||||
</form>
|
||||
{% if message %}
|
||||
<div class="row">
|
||||
<div class="message">
|
||||
{{message}}
|
||||
</div>
|
||||
<form action="{{login_url}}?next={{next}}" method="post" role="form">
|
||||
<div class="auth-form-header">
|
||||
Sign in
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class='auth-form-body'>
|
||||
{% if login_error %}
|
||||
<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>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock login %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
{{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 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 %}
|
||||
|
||||
<span id="login_widget">
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
</span>
|
||||
|
||||
|
Reference in New Issue
Block a user