Compare commits

...

196 Commits
0.1.0 ... 0.3.0

Author SHA1 Message Date
Min RK
4931684a2c release 0.3 2015-11-04 17:10:36 +01:00
Min RK
62d3cc53ef changelog for 0.3 2015-11-04 17:09:34 +01:00
Min RK
bd002e5340 Merge pull request #325 from minrk/authenticator-hooks
add pre/post-spawn hooks for Authenticators
2015-11-04 16:07:01 +00:00
Min RK
6f2aefb990 add pre/post-spawn hooks for Authenticators
allows setup/cleanup to be performed by the authenticator

use this to open PAM sessions at spawn
and close them at stop,
rather than open at login and never close.
2015-10-16 12:02:44 +02:00
Min RK
bd3c878c67 Merge pull request #320 from minrk/authenticator-username
get username from authenticator
2015-10-06 15:43:22 +02:00
Min RK
c1de376b6a Merge pull request #310 from minrk/singleuser-notebook
single-user imports notebook package directly
2015-10-06 14:08:35 +02:00
Min RK
4cc74d287e get username from authenticator 2015-10-06 13:36:34 +02:00
Min RK
411a7a0bd8 single-user imports notebook package directly
instead of relying on IPython.html shims

when should we drop support for IPython 3?
2015-09-24 16:13:28 +02:00
Min RK
498c062ee0 Merge pull request #309 from minrk/gen.sleep
use gen.sleep
2015-09-24 16:09:55 +02:00
Min RK
d1edbddb77 use gen.sleep
instead of elaborate `gen.Task(add_timeout...)`

requires tornado 4.1
2015-09-23 17:04:01 +02:00
Min RK
0c9214ffb7 Merge pull request #307 from minrk/test-3.5
test on 3.5
2015-09-22 14:17:30 +02:00
Min RK
db0aaf1027 test on 3.5
requires pytest >= 2.8
2015-09-22 14:09:23 +02:00
Min RK
42681f8512 Merge pull request #306 from minrk/test-token-username
update token app test
2015-09-22 14:08:41 +02:00
Min RK
e5c1414b6a update token app test
now that admin user isn't added by default
2015-09-22 10:14:11 +02:00
Min RK
d857c20de0 Merge pull request #304 from minrk/rm-default-admin
Remove implicit admin of launching user
2015-09-22 08:59:28 +02:00
Min RK
a267174a03 Remove implicit admin of launching user
instead, warn about missing admins and point to config.
2015-09-21 10:52:19 +02:00
Min RK
768eeee470 Merge pull request #298 from minrk/spawner-authenticator
give Spawners a handle on the Authenticator
2015-09-11 14:24:38 +02:00
Min RK
a451f11cd3 give Spawners a handle on the Authenticator
band-aid for spawner-authenticator pairs
2015-09-11 11:57:41 +02:00
Min RK
63a476f9a6 remove some unused cruft from spawner 2015-09-11 11:23:00 +02:00
Min RK
100b17819d Merge pull request #296 from minrk/pamela
use pamela instead of simplepam
2015-09-11 11:02:14 +02:00
Min RK
024d8d7378 update mocking for pamela 2015-09-09 14:24:53 +02:00
Min RK
15e50529ff use pamela instead of simplepam
and open PAM sessions after successful auth
2015-09-09 13:55:02 +02:00
Min RK
a1a10be747 Merge pull request #290 from jhamrick/clear-login-cookies
Unset all login cookies
2015-08-22 18:55:30 -07:00
Jessica B. Hamrick
a91ee67e74 Reset other_user_cookies after clearing them 2015-08-22 13:14:05 -07:00
Jessica B. Hamrick
ea5bfa9999 Unset all login cookies 2015-08-21 19:24:44 -07:00
Min RK
bea58ee622 Merge pull request #288 from minrk/dont-auto-redirect-root
redirect unauthenticated root to *regular* login page
2015-08-19 21:44:00 -07:00
Min RK
b698d4d226 redirect root to *regular* login page
shows "Login with..." button for external services
instead of auto-redirecting to login service
(no good for oauth)
2015-08-19 12:43:32 -07:00
Min RK
139c7ecacb always render login page at /login 2015-08-19 12:30:10 -07:00
Min RK
eefa8fcad7 Merge pull request #284 from minrk/double-base-url
remove double base_url in login redirect
2015-08-06 21:48:49 -07:00
Min RK
acaedcd898 remove double base_url in login redirect
user.server.base_url is already correct,
and shouldn't be joined with the hub url
2015-08-06 21:37:06 -07:00
Min RK
a075661bfb Merge pull request #276 from Crunch-io/redirect-to-login
Redirect unauthenticated root to login
2015-07-23 13:00:16 -07:00
Joseph Tate
f2246df5bb Fix logging and comments 2015-07-23 15:08:53 -04:00
Joseph Tate
1a3c062512 Fix broken test 2015-07-23 15:06:20 -04:00
Joseph Tate
05e4ab41fe Redirect to the loginurl when not logged in, fix the user.running redirect to fix a redirect loop 2015-07-23 15:06:03 -04:00
Min RK
6f3ccb2d3d Merge pull request #275 from jhamrick/installation-instructions
Update installation instructions
2015-07-14 22:06:52 -07:00
Jessica B. Hamrick
6e5ce236c1 Update installation instructions 2015-07-14 15:36:35 -07:00
Min RK
58437057a1 back to dev 2015-07-12 15:30:47 -05:00
Min RK
7d39e6a1a3 release 0.2.0 2015-07-12 15:30:02 -05:00
Min RK
0b1aebbbf4 Merge pull request #274 from minrk/check-referer
port security fixes from IPython
2015-07-12 15:27:26 -05:00
Min RK
3003c87f02 check Referer on API requests
For CSRF
2015-07-12 14:23:02 -05:00
Min RK
2c8c88ac3f add CSP report handler 2015-07-12 11:22:21 -05:00
Min RK
db994e09d3 add OPTIONS on API handlers 2015-07-12 11:22:21 -05:00
Min RK
357ba23ff3 set csp_report_uri in single-user server
so Hub gets CSP reports instead of single-user servers.
2015-07-12 11:22:21 -05:00
Min RK
54c0c276ed use hmac.compare_digest for constant time comparisons on tokens 2015-07-12 11:22:21 -05:00
Min RK
baf8bd9e03 Merge pull request #269 from minrk/travis-segfaulty
Use thread local storage to avoid sharing db sessions in tests
2015-07-09 15:08:55 -05:00
Min RK
e866abe1a0 use thread-local properties for hub, proxy 2015-07-09 11:34:35 -05:00
Min RK
48fe642c44 use thread local db sessions
to avoid segfaults on Travis
2015-07-09 11:34:35 -05:00
Min RK
c8487c2117 Merge pull request #268 from dietmarw/patch-1
ipython/ipython is deprecated.
2015-07-09 09:19:47 -05:00
Dietmar Winkler
0d6ee3c63c ipython/ipython is deprecated.
The correct one is now jupyter/notebook
2015-07-09 09:53:18 +02:00
Min RK
a00abc7a76 Merge pull request #267 from minrk/strict-next
require next_url to be an absolute path
2015-07-07 12:33:19 -05:00
Min RK
02c8855d10 Merge pull request #264
add supplemental groups to single-user servers

closes #264
2015-07-07 12:31:33 -05:00
Min RK
b5877ac546 Catch failure to set gids 2015-07-07 12:28:17 -05:00
Min RK
ea91bed620 require next_url to be an absolute path
- disallow relative path
- disallow full URL (cross-site)
2015-07-07 11:28:44 -05:00
Min RK
3e81e2ebf9 Merge pull request #265 from jhamrick/docs
Add some docs clarifications
2015-06-26 08:13:49 +01:00
Jessica B. Hamrick
e9e2b17a92 Move npm install to a different part of the docs 2015-06-26 01:41:14 -04:00
Jessica B. Hamrick
498181d217 Remove sudo from npm install 2015-06-26 01:37:26 -04:00
Jessica B. Hamrick
9f807a5959 Add some docs clarifications 2015-06-25 14:48:18 -04:00
shreddd
d328015fe8 add supplemental groups to the local user notebook
Set the list of supplemental group ids for the user associated with the spawned notebook process. This allows users to access utilize their full complement of UNIX system groups. Currently the user is restricted to their default group - accessing a file owned by any other group does not work, even if a user is a member of that group. This patch fixes that.
2015-06-24 14:56:44 -07:00
Min RK
cbbc0290b9 Merge pull request #263 from minrk/test-singleuser
single-user should still be using IPython.utils.traitlets
2015-06-24 12:53:25 -07:00
Min RK
7477f2f6d1 make sure to launch single-user server at least once in tests 2015-06-24 08:47:51 -07:00
Min RK
c03e50b3a2 single-user should still be using IPython.utils.traitlets
We haven't moved to using notebook 4.0 in the single-user, yet
2015-06-24 08:46:56 -07:00
Min RK
cfd19c3e61 Merge pull request #261 from minrk/traitlets
remove dependency on IPython
2015-06-22 22:56:08 -07:00
Min RK
c289cdfaec remove dependency on IPython
- Standalone traitlets has been released, use it directly.
- Copy url_path_join from notebook
2015-06-22 16:02:45 -07:00
Min RK
7acaf8ce52 Merge pull request #260 from minrk/notebook-aliases
don't try to get notebook aliases from NotebookApp
2015-06-22 15:59:50 -07:00
Min RK
e5821e573a don't try to get notebook aliases from NotebookApp
Sentinel changes make it ~impossible to fetch values without instantiation,
which probably wasn't the best idea in the first place.
2015-06-22 14:50:37 -07:00
Min RK
0c16fb98f3 Merge pull request #257 from minrk/command-traitlet
add command traitlet
2015-06-19 10:46:38 -07:00
Min RK
b27ef8e4cb we're not in the future, yet. 2015-06-12 14:09:05 -07:00
Min RK
6cfd186f06 proxy_cmd is a list 2015-06-12 14:01:57 -07:00
Min RK
552859084c simplify some installation notes
- use pip3 / python3
2015-06-12 11:22:13 -07:00
Min RK
6e8a58091e prioritize Google Group over Gitter for help 2015-06-12 11:18:44 -07:00
Min RK
6b0aee2443 Merge pull request #259 from nthiery/master
Removed sudo from pip install instructions

closes #258
2015-06-10 16:32:35 -07:00
Nicolas M. Thiéry
8d00ccc506 Removed sudo from pip install instructions, ...
Attempt at implementing @minrk's suggestion

https://github.com/jupyter/jupyterhub/pull/258#issuecomment-110932499
2015-06-11 00:54:32 +02:00
Min RK
86e31dffa5 add command traitlet
allows specifying commands as either strings or list.

This enables adding arguments to JupyterHub.proxy_cmd without breaking backward-compatibility.
2015-06-03 20:05:20 -08:00
Min RK
f421d1a6da Merge pull request #256 from KrishnaPG/master
Added note about pip3 and zmq
2015-05-30 14:35:47 -07:00
Gopalakrishna Palem
9112ad0f4a Added note about pip3 and zmq
Updated Readme.md with optional dependencies
2015-05-30 08:34:16 +05:30
Min RK
354aeb96af Merge pull request #255 from quantopian/query_args
Include query arguments in GET requests to the notebook server (once it spawns)
2015-05-28 13:03:49 -07:00
Tim Shawver
ff1bf7c4c0 Don't strip query string parameters out of GET requests to the notebook server.
Using self.request.uri instead of self.request.path in a few places accomplishes this.
2015-05-28 15:05:39 -04:00
Kyle Kelley
1ff659a847 Merge pull request #251 from minrk/version
fix `jupyterhub --version` output
2015-05-11 15:16:45 -05:00
Min RK
de40310f54 fix jupyterhub --version output 2015-05-11 10:32:46 -07:00
Min RK
72d9592241 avoid import * in __init__ 2015-05-11 10:32:21 -07:00
Fernando Perez
087a93f9ef Merge pull request #250 from minrk/multi-user-add
create multiple users in admin panel
2015-05-06 20:54:23 -07:00
Min RK
4d73f4eedb note that admin is a single value 2015-05-06 15:35:23 -07:00
Min RK
612cc73c3c skip existing users on bulk user creation
rather than aborting if any already exist

if no users are to be created, throw 400
2015-05-06 15:24:34 -07:00
Min RK
c9d02382e3 fixup 2015-05-06 15:14:08 -07:00
Min RK
da647397ac create multiple users in admin panel
usernames separated by lines
2015-05-06 14:03:19 -07:00
Min RK
546d86e888 allow creating multiple users with one API request 2015-05-06 14:01:31 -07:00
Min RK
36bc07b02e add gitter badge 2015-05-05 14:32:24 -07:00
Min RK
81b13c6660 Merge pull request #245 from minrk/user-name
fix auth key in single-user check
2015-05-02 19:01:09 -05:00
Min RK
b0ef2c4c84 fix auth key in single-user check 2015-05-02 15:22:03 -05:00
Min RK
38024c65d8 Merge pull request #209 from minrk/user-model
reply with full user model in auth handlers
2015-05-01 18:32:21 -07:00
Min RK
80997c8297 reply with full user model in auth handlers 2015-05-01 13:43:43 -07:00
Min RK
c467c64e01 move user_model handling to base APIHandler 2015-05-01 13:41:08 -07:00
Min RK
3fd80f9f3a Merge pull request #243 from minrk/url-name
quote usernames in URLs, cookies
2015-04-30 12:07:33 -07:00
Min RK
d4a4d04183 quote usernames
allow @ to be left unescaped in URLs, quote everything in cookie names
2015-04-30 12:04:32 -07:00
Min RK
f6a3f371b4 Merge pull request #241 from toobaz/url_path_join_from_jupyter_notebook
Get url_path_join from jupyter_notebook
2015-04-24 15:51:11 -07:00
Min RK
8fb74c8627 Merge pull request #240 from quantopian/configurable-headers
DEV: Allow configuration of default headers.
2015-04-24 10:14:43 -07:00
Pietro Battiston
fd6e6f1ded Get url_path_join from jupyter_notebook 2015-04-24 12:34:33 +02:00
Scott Sanderson
74d3740921 DEV: Allow configuration of default headers.
Applies Content-Security-Policy: frame-ancestors 'self' by default.
2015-04-24 01:19:25 -04:00
Min RK
1674d2f698 Merge pull request #238 from quantopian/configurable-templates
DEV: Make template search path configurable.
2015-04-23 14:26:40 -07:00
Tim Shawver
e5d9d136da One more place where template_path needed to be changed to template_paths 2015-04-23 12:32:59 -04:00
Scott Sanderson
1d6b16060b DEV: Make template search path configurable. 2015-04-23 11:08:32 -04:00
Min RK
cd268af799 Merge pull request #236 from quantopian/py2-compat
DEV: Python2 compat in singleuser.py
2015-04-20 15:45:40 -07:00
Scott Sanderson
bc37c729ff DEV: Failover for urrlib.parse.quote in PY2. 2015-04-20 16:51:46 -04:00
Min RK
d277951fa7 Merge pull request #232 from minrk/init-order
reorder server init
2015-04-17 12:51:32 -07:00
Min RK
e4b214536d Merge pull request #233 from minrk/single-user-xheaders
trust proxy headers in single-user server
2015-04-17 12:50:56 -07:00
Min RK
713f222e19 trust proxy headers in single-user server
required for request protocol, ip checks to work properly
2015-04-17 10:37:25 -07:00
Min RK
6b32a5c2d8 Merge pull request #231 from Carreau/secure-cookie
Make cookie secure if used over https
2015-04-17 10:33:48 -07:00
Min RK
5dc38b85eb reorder server init
So the Hub private interface isn't the last thing logged,
which caused lots of confusion.
2015-04-17 10:33:03 -07:00
Matthias Bussonnier
494e4fe68b Make cookie secure if used over https 2015-04-17 10:13:28 -07:00
Min RK
778202ada8 Merge pull request #222 from minrk/log-login
log login / logout at info-level
2015-04-12 14:29:58 -07:00
Min RK
6029204383 Merge pull request #191 from minrk/getting-started
add getting started doc
2015-04-12 14:29:44 -07:00
Min RK
30eef4d353 finish up first round of getting-started 2015-04-12 14:12:04 -07:00
Min RK
b30be43d22 move admin_users from JupyterHub to Authenticator 2015-04-12 14:12:02 -07:00
Brian E. Granger
ca1380eb06 Addressing review comments. 2015-04-12 14:10:55 -07:00
Brian E. Granger
491ee38a37 More edits... 2015-04-12 14:10:55 -07:00
Brian E. Granger
5a9687b02a Editing getting started doc. 2015-04-12 14:10:55 -07:00
Min RK
6b09ff6ef2 add getting started doc 2015-04-12 14:10:55 -07:00
Brian E. Granger
bdbb6164d5 Merge pull request #228 from minrk/no-empty-shell
don't set empty values for HOME, SHELL
2015-04-12 11:09:22 -07:00
Min RK
2890e27052 don't set empty values for HOME, SHELL
in weird cases (probably misconfigured systems),
these can be empty strings.
Leave them unset in such cases.
2015-04-12 11:04:17 -07:00
Min RK
43f13086cf Merge pull request #226 from minrk/last-activity-stop
don't update last_activity on shutdown
2015-04-09 09:58:08 -07:00
Min RK
e883fccf2b don't update last_activity on shutdown 2015-04-08 12:48:04 -07:00
Min RK
364c648d6f Merge pull request #223 from minrk/token-init-hub
assign hub in token app
2015-04-08 11:58:10 -07:00
Min RK
637cc1a7bb split user init into two stages
- init_users populates users table
- init_spawners initializes spawner objects

only the first is needed by the token app
2015-04-08 11:47:49 -07:00
Min RK
6aae4be54d assign hub in token app
avoids AttributeError on hub if there are
users with running servers.

Don't call init_hub,
which can modify the Hub's entries in the database,
which shouldn't happen in the token command.
2015-04-08 11:06:09 -07:00
Min RK
dbc410d6a1 log login / logout at info-level 2015-04-08 10:49:13 -07:00
Brian E. Granger
7ed9c9b6c0 Merge pull request #221 from minrk/debug-clear-cookie
Demote cookie clear message to debug-level
2015-04-07 21:58:39 -07:00
Min RK
ffece0ae79 Demote cookie clear message to debug-level 2015-04-07 21:56:25 -07:00
Min RK
59fda9632a Merge pull request #220 from minrk/coverage
add some test coverage
2015-04-07 16:21:45 -07:00
Min RK
998fc28c32 various testing cleanup
- Disable signal register during testing.
  It doesn't work in background threads.
- Fix IOLoop instance management.
  Some instances were being reused across tests.
2015-04-07 16:09:27 -07:00
Min RK
34386ba3b7 more authenticator coverage 2015-04-07 15:49:25 -07:00
Min RK
64c4d00756 test add_system_user 2015-04-07 15:49:25 -07:00
Min RK
04b7056591 fix group-whitelist checks
and test it
2015-04-07 15:49:25 -07:00
Min RK
d9fc40652d test shutdown API handler 2015-04-07 15:49:25 -07:00
Min RK
d0b4e5bc2a add some basic exercise for HTML pages 2015-04-07 15:49:24 -07:00
Min RK
9372d5f872 add coverage 2015-04-07 15:49:24 -07:00
Min RK
ce59815e16 Merge pull request #205 from minrk/page-flow
Update page flow based on dev meeting
2015-04-07 11:12:07 -07:00
Min RK
7c5e89faa6 use jupyter logo 2015-04-06 10:56:36 -07:00
Min RK
0fe3dab408 use jinja FunctionLoader instead of monkey patch to add Control Panel button 2015-03-31 15:04:17 -07:00
Min RK
789ee44d85 Merge pull request #217 from kyper-data/master
switched app and singleuser to run under python3 by default
2015-03-31 14:27:08 -07:00
Min RK
163a4db3ad single-user login url is now the root hub page 2015-03-31 13:58:09 -07:00
Min RK
50d1f78b61 add control panel link to single-user header
This is done by defining the `headingcontainer` block as a function, and inserting it into the template rendering.

Without monkeypatching, we could use a custom template file,
but that would make the single-user server require its own template path,
while it currently functions as a fully encapsulated single script.
2015-03-31 13:58:07 -07:00
Min RK
ab0010fa32 finish removing logout page 2015-03-31 13:56:49 -07:00
Min RK
1bc8d50261 add "Login with..." button
for custom authenticators that use external services (e.g. OAuth)
2015-03-31 13:56:49 -07:00
Min RK
24fd843c3c render login form at root
- redirect to server, if running (hub home, otherwise)
2015-03-31 13:56:49 -07:00
Min RK
cffdf89327 remove logout page
redirect to landing page, instead
2015-03-31 13:56:49 -07:00
Min RK
2e53de0459 Merge pull request #216 from minrk/allow_none
add missing allow_none=True in Spawner
2015-03-31 13:56:19 -07:00
Kyle Solan
80531341c0 switched app and singleuser to run under python3 by default 2015-03-31 19:54:13 +00:00
Min RK
94a3584620 Merge pull request #215 from minrk/cunicode-user
cast user to unicode
2015-03-30 13:19:51 -07:00
Min RK
12a1ec7f57 add missing allow_none=True in Spawner 2015-03-30 13:18:09 -07:00
Min RK
d13286606a cast user to unicode
allows numerical usernames without fighting with the config system.
2015-03-30 11:23:52 -07:00
Min RK
e39e6d2073 Merge pull request #211 from peterruppel/patch-1
Make the hub bind to address specified in hub_ip
2015-03-24 16:31:54 -07:00
Peter Ruppel
904c848bcc Make the hub bind to address specified in hub_ip
Bug: the hub binds to all interfaces and ignores the address given in 'hub_ip'. The 'address' parameter was missing in the call http_server.listen().
2015-03-24 20:15:29 +01:00
Min RK
038aae7e0a Merge pull request #208 from minrk/log-requests
customize request logging
2015-03-24 11:54:04 -07:00
Min RK
ba81bd4a01 Merge pull request #190 from minrk/bind_url
url logging
2015-03-24 11:52:33 -07:00
Min RK
36d62672df Merge pull request #207 from minrk/get-cookie-body
use `cookie_name/cookie_value` URL for cookie checking API
2015-03-24 09:48:17 -07:00
Min RK
ffd334b5ff Merge pull request #189 from jdavidheiser/master
set user shel from pw_shell

closes #189
closes #204
2015-03-23 17:55:44 -07:00
James Davidheiser
7701b82f58 replace using server shell with user shell
Per discussion on PR, replaced the heavy-handed setting of the shell environment variable through the server environment variables.
2015-03-23 17:55:16 -07:00
James Davidheiser
7ca96e5c6c spawner.py update for default shell
Update spawner.py to do a better job keeping the shell variable intact for terminals launched from within the notebook.
2015-03-23 17:55:16 -07:00
Min RK
3be33f2884 escape cookie value in auth uri
requests didn't seem to need it,
but I feel safer doing it explicitly.
2015-03-23 16:17:28 -07:00
Min RK
3d6a0c126f custom request logging
adapted from IPython

- demotes static page success, cache hits to debug-level
- removes auth info from log messages
- logs usernames for authenticated requests
2015-03-23 15:45:57 -07:00
Min RK
39a7feea72 use cookie_name/cookie_value URL for cookie checking API
instead of putting the value in the request body,
which is verboten for GET requests
2015-03-23 15:14:36 -07:00
Min RK
5529774c1d url logging
log the actual bind url (Server.bind_url),
rather than the connect url (Server.url),
which converts all-interfaces IPs to 'localhost'
2015-03-23 12:12:10 -07:00
Min RK
ffb2ba055a remove inappropriate argv in Hub.instance() 2015-03-23 12:06:07 -07:00
Min RK
2a2f9c0b67 Merge pull request #189 from minrk/timeout-spawn-error
better error messages for spawn failure
2015-03-23 11:55:15 -07:00
Min RK
3e89f45954 Merge pull request #197 from minrk/expire-token
expire login cookies
2015-03-23 11:51:08 -07:00
Min RK
fad5f5a61d Merge pull request #200 from quantopian/group_auth
DEV: Allow setting a whitelist_group on LocalAuthenticator.
2015-03-22 21:22:49 -07:00
Scott Sanderson
ed94c2d774 BUG: group_whitelist is a set, not a scalar. 2015-03-22 14:51:10 -04:00
Scott Sanderson
77c66d8b27 DEV: Make group/user whitelist mutually exclusive.
If group whitelist is provided, it takes precedence.
2015-03-19 10:42:55 -04:00
Scott Sanderson
33a4f31520 DEV: Allow setting a whitelist_group on LocalAuthenticator.
Any user in the group is considered in the whitelist.
2015-03-18 19:20:10 -04:00
Min RK
3fb2afc2bd expire login cookies
via tornado's max_age_days mechanism

default expiry is two weeks
2015-03-17 12:56:06 -06:00
Min RK
8787335b01 Merge pull request #196 from quantopian/invalidate-su-cookies
DEV: Periodically clear single-user server cookie cache.
2015-03-17 12:46:54 -06:00
Min RK
376ee29b12 Merge pull request #195 from minrk/chp-missing
better error when proxy command isn't found
2015-03-17 12:44:30 -06:00
Scott Sanderson
5e16e6f52f DEV: Periodically clear single-user server cookie cache.
Default is every 5 minutes.
2015-03-17 13:48:44 -04:00
Min RK
ce45fde74a better error when proxy command isn't found 2015-03-17 11:46:12 -06:00
Min RK
665edb6651 Merge pull request #194 from quantopian/handle-404-auth
BUG: Correctly handle 404 from hub.
2015-03-17 10:54:38 -06:00
Scott Sanderson
f98c8feaae BUG: Correctly handle 404 from hub.
Previously, the `if r.status_code == 404` branch was being trampled by a
later `if r.status_code > 400` branch.
2015-03-17 12:00:26 -04:00
Min RK
5100dd29c2 note when installing with npm 2015-03-14 14:32:31 -06:00
Min RK
40ae3a5821 Merge pull request #188 from blippy/master
Make jupyterhub and jupyterhub-singleuser executable and call python3
2015-03-13 12:57:58 -07:00
Min RK
da1fe54aee better error messages for spawn failure
Server started, but never became accessible:

> Failed to reach your server.
> Please try again later.
> Contact admin if the issue persists.

Server failed to start (errors in Spawner):

> Failed to start your server.
> Please contact admin.
2015-03-13 12:12:36 -07:00
Min RK
545739472e make default http timeout 30 seconds
some computers can be very slow to start
2015-03-13 12:04:07 -07:00
mark carter
36bb03dc3f Make jupyterhub and jupyterhub-singleuser executable and call python3 2015-03-13 09:01:00 +00:00
Min RK
61fa2d9ef2 Merge pull request #182 from Carreau/theme
Better Theme II
2015-03-08 18:26:42 -07:00
Matthias Bussonnier
301560b6f8 use bootstrap variables 2015-03-08 18:06:43 -07:00
Matthias Bussonnier
5bd649829a remove dropdown 2015-03-08 17:44:46 -07:00
Matthias Bussonnier
eccf2fe5be Make theme closer to notebook.
improvement of admin page split
edit/delete in their own column
2015-03-08 17:39:46 -07:00
Min RK
e82683d14f remove 'workaround' that didn't workaround anything 2015-03-08 17:25:53 -07:00
Min RK
2455680ab8 cleanup bizarre login html 2015-03-08 17:21:04 -07:00
Min RK
827f694589 simplify login css
use mixins, remove lots of unnecessary CSS
2015-03-08 17:21:04 -07:00
Min RK
fa7e230b6e Merge pull request #56 from Carreau/master
Implementation of login page design
2015-03-08 17:20:23 -07:00
Min RK
42a8094b20 add links to wiki auth/spawner lists
from the docs
2015-03-07 17:08:13 -08:00
Min RK
a898063b83 back to dev 2015-03-07 16:48:20 -08:00
Matthias Bussonnier
c0b67770e4 make color and gradient more logo-like 2015-03-06 12:25:07 -08:00
Matthias Bussonnier
791e527695 do not change brand primary, plus rework login form 2015-03-06 12:06:45 -08:00
Matthias Bussonnier
a6b79780b3 compute gradient 2015-03-03 17:14:58 -08:00
Matthias Bussonnier
25bcb6ede4 fix bootstrap bug 2015-03-03 17:09:30 -08:00
Bussonnier Matthias
839bd79bbd Implementation of login page design 2015-03-03 16:53:35 -08:00
56 changed files with 1890 additions and 394 deletions

4
.coveragerc Normal file
View File

@@ -0,0 +1,4 @@
[run]
omit =
jupyterhub/tests/*
jupyterhub/singleuser.py

3
.gitignore vendored
View File

@@ -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

View File

@@ -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

View File

@@ -5,7 +5,7 @@
# FROM jupyter/jupyterhub:latest
#
FROM ipython/ipython
FROM jupyter/notebook
MAINTAINER Jupyter Project <jupyter@googlegroups.com>

View File

@@ -1,5 +1,9 @@
# JupyterHub: A multi-user server for Jupyter notebooks
Questions, comments? Visit our Google Group:
[![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](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:
[![Google Group](https://img.shields.io/badge/-Google%20Group-lightgrey.svg)](https://groups.google.com/forum/#!forum/jupyter)
but you can participate in development discussions or get live help on Gitter:
[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jupyter/jupyterhub?utm_source=badge&utm_medium=badge)

View File

@@ -1,2 +1,4 @@
-r requirements.txt
pytest
coveralls
pytest-cov
pytest>=2.8

View File

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

View File

@@ -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).

View File

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

View File

@@ -1,2 +1,2 @@
from .version import *
from .version import version_info, __version__

View File

@@ -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),
]

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 = [

View File

@@ -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:

View File

@@ -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)

View File

@@ -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),
]

View File

@@ -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),
]

View File

@@ -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."""

View File

@@ -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))

View File

@@ -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"""

View File

@@ -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():

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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'

View File

@@ -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 + '/'

View 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')

View File

@@ -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)

View File

@@ -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])

View 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']

View File

@@ -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)

View File

@@ -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

View File

@@ -5,8 +5,9 @@
version_info = (
0,
1,
3,
0,
# 'dev',
)
__version__ = '.'.join(map(str, version_info))

View File

@@ -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
View 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
View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
from jupyterhub.singleuser import main
main()

View File

@@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -24,5 +24,4 @@
@import "./page.less";
@import "./admin.less";
@import "./error.less";
@import "./logout.less";
@import "./login.less";

View File

@@ -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);
}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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()}}

View File

@@ -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 %}

View File

@@ -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>