mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
786 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
17851b7586 | ||
![]() |
118e2fa610 | ||
![]() |
8e3553462c | ||
![]() |
37da47d811 | ||
![]() |
a640a468fb | ||
![]() |
92f034766e | ||
![]() |
f7ea451df8 | ||
![]() |
1b7f54b462 | ||
![]() |
b14b12231a | ||
![]() |
2866be9462 | ||
![]() |
f8648644bf | ||
![]() |
69d4d48db0 | ||
![]() |
df309749f2 | ||
![]() |
58751067db | ||
![]() |
4fd70cf79b | ||
![]() |
ff15bad375 | ||
![]() |
90ac4ab6fe | ||
![]() |
cba5bb1676 | ||
![]() |
4b5fa404fc | ||
![]() |
c4ac1240ac | ||
![]() |
d384ad2700 | ||
![]() |
c3da0b8073 | ||
![]() |
9919cba375 | ||
![]() |
1e6b94de92 | ||
![]() |
8451a4cd08 | ||
![]() |
48f1da1b8d | ||
![]() |
e20050b719 | ||
![]() |
a9c0a46a06 | ||
![]() |
03bb094b90 | ||
![]() |
5d0d552c26 | ||
![]() |
2d50cef098 | ||
![]() |
d6d0b83b4e | ||
![]() |
f1dbeda451 | ||
![]() |
512bbae5cb | ||
![]() |
8c575d40af | ||
![]() |
d6b9909bc6 | ||
![]() |
ef7d6dc091 | ||
![]() |
57f707bbfd | ||
![]() |
0ae7213366 | ||
![]() |
22ff7aa672 | ||
![]() |
ca579fbf4a | ||
![]() |
f2eb30d090 | ||
![]() |
63a4b4744b | ||
![]() |
e03b5b3992 | ||
![]() |
fa4a27fb1a | ||
![]() |
d3a6aa2471 | ||
![]() |
8bd64cff59 | ||
![]() |
760db17640 | ||
![]() |
a9cb25f3a2 | ||
![]() |
d9d5ddb77e | ||
![]() |
9b8e5b03b4 | ||
![]() |
02f0c4a5b8 | ||
![]() |
b254716cee | ||
![]() |
4c52ad6f7c | ||
![]() |
0c09bfcafa | ||
![]() |
0b67546481 | ||
![]() |
2698b00fb9 | ||
![]() |
f7ce705999 | ||
![]() |
ee14131827 | ||
![]() |
828c499ac7 | ||
![]() |
a43d594452 | ||
![]() |
406d572a7b | ||
![]() |
71c38fd515 | ||
![]() |
68e02dd62a | ||
![]() |
dd1902b1d9 | ||
![]() |
39041ee08c | ||
![]() |
eb6a2f9e89 | ||
![]() |
4f826d8245 | ||
![]() |
a434a6f144 | ||
![]() |
0fe1020022 | ||
![]() |
8aca08f508 | ||
![]() |
fb0331aa4c | ||
![]() |
184a9bceb9 | ||
![]() |
dfef7c2b52 | ||
![]() |
6b16b51064 | ||
![]() |
85a75b637a | ||
![]() |
fae2d9414a | ||
![]() |
61e263b160 | ||
![]() |
ac13140083 | ||
![]() |
024fd07ec8 | ||
![]() |
95175155d4 | ||
![]() |
e5c088f8d6 | ||
![]() |
42a103c76f | ||
![]() |
b70f2fa20a | ||
![]() |
8e69b158eb | ||
![]() |
6e2c544a19 | ||
![]() |
c62d080e9c | ||
![]() |
bd0e00ed86 | ||
![]() |
264a78e2cc | ||
![]() |
4f95ef437f | ||
![]() |
f0556954ed | ||
![]() |
44bc569868 | ||
![]() |
1e9bbb1d14 | ||
![]() |
f2953f6b09 | ||
![]() |
fa4c5ec9d4 | ||
![]() |
546268809f | ||
![]() |
6af4c0f9e0 | ||
![]() |
7d0fd85d65 | ||
![]() |
15b78307fb | ||
![]() |
6ba3090cd5 | ||
![]() |
74c4c58e37 | ||
![]() |
31f63264b0 | ||
![]() |
9e7dbbbbff | ||
![]() |
c1d120c9bb | ||
![]() |
3955a8c1d0 | ||
![]() |
12f8073e5d | ||
![]() |
ec2b1dd39b | ||
![]() |
e9d603abf1 | ||
![]() |
ac33ba6ff4 | ||
![]() |
3b4888b8ba | ||
![]() |
5c64c88d5a | ||
![]() |
924d095c68 | ||
![]() |
700ccb17cb | ||
![]() |
1d156f8183 | ||
![]() |
c0e2c5cb71 | ||
![]() |
25d19732e0 | ||
![]() |
f0b8d56e9f | ||
![]() |
718a3fe7ef | ||
![]() |
ca6e0ec9b9 | ||
![]() |
a27765f7d5 | ||
![]() |
bf1dd03df3 | ||
![]() |
2726648982 | ||
![]() |
275a4ce18d | ||
![]() |
0b34e13dd4 | ||
![]() |
e666261434 | ||
![]() |
57c8ad6b92 | ||
![]() |
3f032abc25 | ||
![]() |
f86202c07d | ||
![]() |
1b0ff0a5f6 | ||
![]() |
cebb962645 | ||
![]() |
55000f98bc | ||
![]() |
449aff1b1d | ||
![]() |
3c591f744b | ||
![]() |
329781023f | ||
![]() |
8d9731e241 | ||
![]() |
bde37ba9c2 | ||
![]() |
088fdc8f42 | ||
![]() |
886005be2a | ||
![]() |
684afed3f1 | ||
![]() |
210d7e59fd | ||
![]() |
a19a94b2c2 | ||
![]() |
9bf70208c8 | ||
![]() |
fada0d99f0 | ||
![]() |
e6ce468301 | ||
![]() |
875e5d59fe | ||
![]() |
6556135a69 | ||
![]() |
8636b4ebca | ||
![]() |
4a5f914a62 | ||
![]() |
47b6014d13 | ||
![]() |
1995d825df | ||
![]() |
f49606dff6 | ||
![]() |
7520d4b81e | ||
![]() |
083408a685 | ||
![]() |
9c4972239d | ||
![]() |
4458f2e6d4 | ||
![]() |
a24027f188 | ||
![]() |
c749fc05f4 | ||
![]() |
5ad77df04f | ||
![]() |
4b51d67d35 | ||
![]() |
88268bd76f | ||
![]() |
744d96330e | ||
![]() |
55c3164a7d | ||
![]() |
c78e31b136 | ||
![]() |
ecfd0a6796 | ||
![]() |
162ce2a9c5 | ||
![]() |
1f2125a097 | ||
![]() |
feae3eacb1 | ||
![]() |
a1a706cb31 | ||
![]() |
8a1da297d9 | ||
![]() |
1987221026 | ||
![]() |
4b7b34064b | ||
![]() |
5abb4618bd | ||
![]() |
75c1d36237 | ||
![]() |
90e8e1a8aa | ||
![]() |
32a9b38d26 | ||
![]() |
5714f56083 | ||
![]() |
3d635816c9 | ||
![]() |
1aa5ce2f35 | ||
![]() |
f765fde6c1 | ||
![]() |
523cbf641c | ||
![]() |
112834bbaa | ||
![]() |
f0ab1ae907 | ||
![]() |
d6827a2794 | ||
![]() |
a1591185c1 | ||
![]() |
b77c8a8717 | ||
![]() |
831b7d2a86 | ||
![]() |
057a52dd32 | ||
![]() |
8f88fae530 | ||
![]() |
85cc8eb6f3 | ||
![]() |
349f1b115e | ||
![]() |
27de44b0ec | ||
![]() |
9847408d77 | ||
![]() |
cc24f36e80 | ||
![]() |
e7fe6d25b6 | ||
![]() |
afc968146d | ||
![]() |
471decdbb6 | ||
![]() |
638f980281 | ||
![]() |
8f1115a257 | ||
![]() |
9e8b6503a0 | ||
![]() |
91d042f6f3 | ||
![]() |
d559cad042 | ||
![]() |
f05aecf5f9 | ||
![]() |
58f072e5af | ||
![]() |
afc3bcbc75 | ||
![]() |
8ee2fd2cf8 | ||
![]() |
be7faacd07 | ||
![]() |
dc97433d9b | ||
![]() |
da10a8e7dd | ||
![]() |
847ae21ccb | ||
![]() |
128cf115a7 | ||
![]() |
1b9cff6d5f | ||
![]() |
110a8e22ae | ||
![]() |
7f058c0c77 | ||
![]() |
1e3512ac84 | ||
![]() |
8662a4a807 | ||
![]() |
63d1c918e5 | ||
![]() |
0a89090dc2 | ||
![]() |
645575239f | ||
![]() |
8de38b1708 | ||
![]() |
6db987972a | ||
![]() |
0ddf6bf579 | ||
![]() |
9f8033a147 | ||
![]() |
d007b40e15 | ||
![]() |
bbfd36fc92 | ||
![]() |
3faa02b00d | ||
![]() |
eb1895e980 | ||
![]() |
7ee8e96ece | ||
![]() |
3e796b579d | ||
![]() |
74d9e2f421 | ||
![]() |
2603cbb102 | ||
![]() |
194d6c9d4c | ||
![]() |
f364f8e832 | ||
![]() |
ba6af85e9d | ||
![]() |
d2e411dba0 | ||
![]() |
e15a6bb758 | ||
![]() |
96c04f3c60 | ||
![]() |
9c9bc68092 | ||
![]() |
46f003fe14 | ||
![]() |
1404965b07 | ||
![]() |
9fbb1417f2 | ||
![]() |
158a7090a3 | ||
![]() |
9fa9859495 | ||
![]() |
de85fefa7d | ||
![]() |
dee55df94a | ||
![]() |
62b9450ce1 | ||
![]() |
bcdcf4351d | ||
![]() |
0d941e9c96 | ||
![]() |
9d837b2e4b | ||
![]() |
8544010eb6 | ||
![]() |
f37243169a | ||
![]() |
7caa1e1f0e | ||
![]() |
e019a394b0 | ||
![]() |
c0b482e68c | ||
![]() |
2da115f5c4 | ||
![]() |
639ccf5582 | ||
![]() |
2654794968 | ||
![]() |
2cec124b4f | ||
![]() |
e21737399b | ||
![]() |
9a555d8a6e | ||
![]() |
f7bf2b0ba6 | ||
![]() |
710ed0a5c8 | ||
![]() |
7539523ef2 | ||
![]() |
c97444e438 | ||
![]() |
4c86d10037 | ||
![]() |
69a6c79558 | ||
![]() |
a0466dc322 | ||
![]() |
546e35e9a3 | ||
![]() |
ce53b11cf7 | ||
![]() |
1229fd100f | ||
![]() |
e4541591ea | ||
![]() |
be62b1b9df | ||
![]() |
9c21cf4c62 | ||
![]() |
51af6a98cc | ||
![]() |
520d6160f0 | ||
![]() |
e8ebedb2da | ||
![]() |
fd7700d577 | ||
![]() |
6ee88a5424 | ||
![]() |
c89711d0d5 | ||
![]() |
daee0f8df8 | ||
![]() |
e1444f4aca | ||
![]() |
97b9c4899a | ||
![]() |
b8aa6ecd70 | ||
![]() |
e28f3947bd | ||
![]() |
bc9cc98789 | ||
![]() |
72132e7946 | ||
![]() |
b0307dd98e | ||
![]() |
fd1ac55a70 | ||
![]() |
39d8800389 | ||
![]() |
382a7121e1 | ||
![]() |
4c0ac6d502 | ||
![]() |
40dcbedc2a | ||
![]() |
9eda66b3ae | ||
![]() |
d4c48db248 | ||
![]() |
7bd4861689 | ||
![]() |
72550725da | ||
![]() |
5a8011ea66 | ||
![]() |
0fd1a95405 | ||
![]() |
8d0cfa8e7c | ||
![]() |
3d1187283c | ||
![]() |
7416a55083 | ||
![]() |
e8a3c4dac6 | ||
![]() |
33f2026dac | ||
![]() |
d34f6e779d | ||
![]() |
738976a956 | ||
![]() |
fd8cc1df15 | ||
![]() |
61053b063e | ||
![]() |
a27e1e9d40 | ||
![]() |
a7889eb536 | ||
![]() |
0f17709d4e | ||
![]() |
3eca010f66 | ||
![]() |
041ffd6db2 | ||
![]() |
4b5aad41b1 | ||
![]() |
d6565076f5 | ||
![]() |
c943162649 | ||
![]() |
a2e94b8493 | ||
![]() |
94b2bc1261 | ||
![]() |
7d34f83b18 | ||
![]() |
4f27a18616 | ||
![]() |
5a5aa1c2aa | ||
![]() |
1bafdf9130 | ||
![]() |
9eef5d7b1e | ||
![]() |
aee3c74681 | ||
![]() |
653a39c05e | ||
![]() |
efa6a33b0a | ||
![]() |
0c5a9e8347 | ||
![]() |
657f77b7c6 | ||
![]() |
b528572960 | ||
![]() |
e75d24aca2 | ||
![]() |
7607f8d639 | ||
![]() |
9a59c02077 | ||
![]() |
8e3c4b1925 | ||
![]() |
057bf03d3a | ||
![]() |
224faff879 | ||
![]() |
a6c2939bb4 | ||
![]() |
c78d88707c | ||
![]() |
a79071bb33 | ||
![]() |
dca530d2c0 | ||
![]() |
c5b1542af2 | ||
![]() |
a13e7766fc | ||
![]() |
765e391810 | ||
![]() |
6a12e78cee | ||
![]() |
e0effa567a | ||
![]() |
0322ca6d05 | ||
![]() |
13eda34676 | ||
![]() |
874ed0c450 | ||
![]() |
f25ec3c3f0 | ||
![]() |
8373c4619e | ||
![]() |
549dfd99e5 | ||
![]() |
eed88f6366 | ||
![]() |
fcf745b2f4 | ||
![]() |
69a27b7843 | ||
![]() |
a51141810d | ||
![]() |
396f454998 | ||
![]() |
5f21909138 | ||
![]() |
ebb7b4b4ae | ||
![]() |
e691231f64 | ||
![]() |
471110c0f2 | ||
![]() |
73948c016b | ||
![]() |
864e7ac4ee | ||
![]() |
2207220592 | ||
![]() |
a4a5781f7f | ||
![]() |
194d2b9639 | ||
![]() |
530f499ce1 | ||
![]() |
d167e275d1 | ||
![]() |
cdcc7fc3c1 | ||
![]() |
0a30e0ade5 | ||
![]() |
47dc66db5a | ||
![]() |
c192391551 | ||
![]() |
b0c44aa67a | ||
![]() |
29890dcfa9 | ||
![]() |
1742065f77 | ||
![]() |
28480d0359 | ||
![]() |
2f57cfc812 | ||
![]() |
b12a52e266 | ||
![]() |
5d45a44247 | ||
![]() |
8ee520d99b | ||
![]() |
4c0d4ffc47 | ||
![]() |
44c00a2581 | ||
![]() |
1015f3bf53 | ||
![]() |
71378d23d5 | ||
![]() |
f5d0855c2b | ||
![]() |
88040362b0 | ||
![]() |
8f49412438 | ||
![]() |
bb417b98b8 | ||
![]() |
afed81d173 | ||
![]() |
def99c1795 | ||
![]() |
fcdea007ac | ||
![]() |
383b56276e | ||
![]() |
11e6c38702 | ||
![]() |
a2686ac27b | ||
![]() |
49bf4747fd | ||
![]() |
cf257c48b4 | ||
![]() |
05d939beac | ||
![]() |
fa7fed8ea3 | ||
![]() |
fbf5816952 | ||
![]() |
31fc89c944 | ||
![]() |
f7a05713a1 | ||
![]() |
9f532d6b2d | ||
![]() |
5263e4ceae | ||
![]() |
3145011004 | ||
![]() |
5da4348c2d | ||
![]() |
e33e34748f | ||
![]() |
d2e62a90d7 | ||
![]() |
593a3c8ebb | ||
![]() |
6713277f33 | ||
![]() |
178f1ed5e0 | ||
![]() |
f5c703a04f | ||
![]() |
27e83a3260 | ||
![]() |
e7cd5ec019 | ||
![]() |
8704deeb31 | ||
![]() |
9c6056518f | ||
![]() |
5f813a4206 | ||
![]() |
5cb40531d0 | ||
![]() |
fe85a79ae3 | ||
![]() |
97ec0b803d | ||
![]() |
a5fbc0351f | ||
![]() |
38e772dfec | ||
![]() |
dda3762b48 | ||
![]() |
1ddbf97c11 | ||
![]() |
ca4952a85d | ||
![]() |
d76632de91 | ||
![]() |
b96f3485fd | ||
![]() |
a6f1f6ea09 | ||
![]() |
d2533688b6 | ||
![]() |
6810aba5e9 | ||
![]() |
aca5b1ccd4 | ||
![]() |
888aa99ea6 | ||
![]() |
b112b88587 | ||
![]() |
86276541be | ||
![]() |
bdfd81fe83 | ||
![]() |
c24a0a4995 | ||
![]() |
524b9104d0 | ||
![]() |
19e896c38d | ||
![]() |
62517d0c89 | ||
![]() |
49a0f154d0 | ||
![]() |
39248a532d | ||
![]() |
465c81f281 | ||
![]() |
2d8facd022 | ||
![]() |
d548aa1e72 | ||
![]() |
7968912a7c | ||
![]() |
79bd1a50ad | ||
![]() |
7b96950a9c | ||
![]() |
89331d15cc | ||
![]() |
25910b732a | ||
![]() |
bdcb9e7540 | ||
![]() |
130bec4a2f | ||
![]() |
db2d685c40 | ||
![]() |
f9e0f90e08 | ||
![]() |
4f85644c34 | ||
![]() |
73d77ee56b | ||
![]() |
33a37ffa25 | ||
![]() |
2716ba4dc6 | ||
![]() |
65afc65f51 | ||
![]() |
034432bfba | ||
![]() |
f815fe8b59 | ||
![]() |
cc7605d6a9 | ||
![]() |
d809b8717c | ||
![]() |
f878ad54a8 | ||
![]() |
22bc9b0dbf | ||
![]() |
86428aa0f6 | ||
![]() |
8c8b532ffd | ||
![]() |
475c0a3144 | ||
![]() |
3c6e20585c | ||
![]() |
98c2bd9a6a | ||
![]() |
226b6c40a5 | ||
![]() |
4cb18c931d | ||
![]() |
96b75c18b7 | ||
![]() |
87d9d14e5d | ||
![]() |
1069799ea7 | ||
![]() |
5e55753baa | ||
![]() |
be8f847309 | ||
![]() |
acc31b8441 | ||
![]() |
a98bab8b5e | ||
![]() |
7b944a3a3f | ||
![]() |
a0d32c5b33 | ||
![]() |
89f1254396 | ||
![]() |
41c136392f | ||
![]() |
b4d1ee353d | ||
![]() |
a24d7406fc | ||
![]() |
574d3ba1f4 | ||
![]() |
6eb61e2923 | ||
![]() |
9e679e8024 | ||
![]() |
006488fc74 | ||
![]() |
8e66c383e8 | ||
![]() |
e7a0556118 | ||
![]() |
6117c0b573 | ||
![]() |
c3a90e0804 | ||
![]() |
66cb630b86 | ||
![]() |
2b2eefdd1f | ||
![]() |
db77932a95 | ||
![]() |
2aaf82412d | ||
![]() |
4df93cab04 | ||
![]() |
b778232cac | ||
![]() |
f58015dc57 | ||
![]() |
57d3cbccc4 | ||
![]() |
52fdd0bd8c | ||
![]() |
ced8e9f874 | ||
![]() |
76b589bc90 | ||
![]() |
4c79a8cb2d | ||
![]() |
64f7244808 | ||
![]() |
ebaf36d503 | ||
![]() |
e58c1a5f5a | ||
![]() |
c1eb7618d6 | ||
![]() |
0ce0dfbc35 | ||
![]() |
a555af428d | ||
![]() |
b5666a45f6 | ||
![]() |
170f0f918f | ||
![]() |
a59b0af2b4 | ||
![]() |
3cac9a2203 | ||
![]() |
6b22f80ead | ||
![]() |
5f498ffaf3 | ||
![]() |
258fe7b277 | ||
![]() |
703ed7d21e | ||
![]() |
9a1f84329f | ||
![]() |
a20c7eb4de | ||
![]() |
e866651f96 | ||
![]() |
2b5f42a546 | ||
![]() |
b811c63ac5 | ||
![]() |
c7ea106675 | ||
![]() |
1f2218c875 | ||
![]() |
99369aa5a1 | ||
![]() |
ffd3c171fe | ||
![]() |
ce4b9e8e9f | ||
![]() |
ef51eb21e0 | ||
![]() |
b1efe3a5c1 | ||
![]() |
6d647b5387 | ||
![]() |
d11c7ba4db | ||
![]() |
6b33358c56 | ||
![]() |
9030302ff7 | ||
![]() |
1631a6eab0 | ||
![]() |
c6fe145030 | ||
![]() |
5b1435081a | ||
![]() |
39fce0304d | ||
![]() |
5a5fdc2565 | ||
![]() |
bef121dbe3 | ||
![]() |
0b7a43f6fa | ||
![]() |
2d1a45f019 | ||
![]() |
5494172706 | ||
![]() |
198bb875df | ||
![]() |
d1822ee939 | ||
![]() |
5e1516189b | ||
![]() |
5819b442aa | ||
![]() |
4bb8e47f3b | ||
![]() |
ff6a68112e | ||
![]() |
52b9060415 | ||
![]() |
74728e5f42 | ||
![]() |
3e482d08d7 | ||
![]() |
7e55220c3f | ||
![]() |
453d1daf8b | ||
![]() |
d0eb4e0946 | ||
![]() |
9a40196678 | ||
![]() |
4f7552ea1d | ||
![]() |
7412e357cf | ||
![]() |
bac96c679f | ||
![]() |
4f1d201286 | ||
![]() |
bcf6559514 | ||
![]() |
0af9f2b875 | ||
![]() |
d9393c6663 | ||
![]() |
00274c991f | ||
![]() |
e6848b68aa | ||
![]() |
853a460bd7 | ||
![]() |
ff5b708707 | ||
![]() |
0d62ba2f80 | ||
![]() |
f257716d1b | ||
![]() |
43a6cd0bf9 | ||
![]() |
af8965664e | ||
![]() |
168ad315c7 | ||
![]() |
66510de4e9 | ||
![]() |
942e05888b | ||
![]() |
1970273c58 | ||
![]() |
06d081a73b | ||
![]() |
352efa6d47 | ||
![]() |
586dc3868d | ||
![]() |
0fe149dd57 | ||
![]() |
688845b907 | ||
![]() |
21af37a7a3 | ||
![]() |
6078b8d9e5 | ||
![]() |
bfe1457897 | ||
![]() |
f873b77a5f | ||
![]() |
be1af58147 | ||
![]() |
2b8268f1d4 | ||
![]() |
6cc3cd325c | ||
![]() |
efab0dbc47 | ||
![]() |
a5b4ed83f7 | ||
![]() |
8eed5c7709 | ||
![]() |
883d3ad29b | ||
![]() |
044d5d2a84 | ||
![]() |
68f23b2cdf | ||
![]() |
91553ebe34 | ||
![]() |
a45bc9b31e | ||
![]() |
849f52de67 | ||
![]() |
46f9841dce | ||
![]() |
d8213b5fa5 | ||
![]() |
ee276adcf8 | ||
![]() |
07ae847d08 | ||
![]() |
95dc4713f4 | ||
![]() |
2294dc0ad9 | ||
![]() |
87c0d7e54f | ||
![]() |
18238241ef | ||
![]() |
192cb193a1 | ||
![]() |
2fb503df17 | ||
![]() |
810566729d | ||
![]() |
746912cece | ||
![]() |
7a38a57397 | ||
![]() |
4fdf405d77 | ||
![]() |
488706293f | ||
![]() |
9373325f1b | ||
![]() |
e151248ac2 | ||
![]() |
b09ccc4373 | ||
![]() |
f4a7e28aa5 | ||
![]() |
5b85d1e248 | ||
![]() |
a85bc5cad4 | ||
![]() |
d682edd44f | ||
![]() |
3524399984 | ||
![]() |
b127788100 | ||
![]() |
a823a6b371 | ||
![]() |
b47f76c037 | ||
![]() |
1d19684b2c | ||
![]() |
08e8c93b16 | ||
![]() |
a0103ebd6c | ||
![]() |
b5a600d488 | ||
![]() |
27410a6c51 | ||
![]() |
67d6de9f68 | ||
![]() |
3996fa00ef | ||
![]() |
42f8509287 | ||
![]() |
11b738b837 | ||
![]() |
576858b6ca | ||
![]() |
645c2bdd4a | ||
![]() |
9ae708b367 | ||
![]() |
abf554f9cf | ||
![]() |
9df6e76cc3 | ||
![]() |
7afbe952e6 | ||
![]() |
00aa92f7b6 | ||
![]() |
4ae264de5e | ||
![]() |
4b987dd334 | ||
![]() |
f75c4c0ba3 | ||
![]() |
8b4d089376 | ||
![]() |
bd2e758b04 | ||
![]() |
54e5910e45 | ||
![]() |
5460d5748f | ||
![]() |
25d5d95a5b | ||
![]() |
8db26af57a | ||
![]() |
4f29cbe81f | ||
![]() |
0dced91495 | ||
![]() |
c02a463348 | ||
![]() |
a2f717fba2 | ||
![]() |
8973571dc0 | ||
![]() |
0fe3aacb4d | ||
![]() |
7313b4fd26 | ||
![]() |
5c0b3f8b34 | ||
![]() |
a4eb795d32 | ||
![]() |
8e1efc2851 | ||
![]() |
8c999907c2 | ||
![]() |
cd7a31dd3c | ||
![]() |
b21b0427d1 | ||
![]() |
3a2299f7f2 | ||
![]() |
7d5287000f | ||
![]() |
bc37c56742 | ||
![]() |
97b04d8b43 | ||
![]() |
5de1c078d2 | ||
![]() |
021ef6e6c4 | ||
![]() |
69d20eb297 | ||
![]() |
4688348020 | ||
![]() |
cf02f3133a | ||
![]() |
e0748540d7 | ||
![]() |
ab3c28e46a | ||
![]() |
13ae12b57d | ||
![]() |
222cdc7f79 | ||
![]() |
e8a1d2f1bd | ||
![]() |
5245670af1 | ||
![]() |
6b83d516a7 | ||
![]() |
b72562e805 | ||
![]() |
0b964c8358 | ||
![]() |
d61f9547fe | ||
![]() |
78360608b1 | ||
![]() |
2a25e3cb89 | ||
![]() |
f3b7fda4a8 | ||
![]() |
6c6d070b16 | ||
![]() |
eec0a11ef0 | ||
![]() |
2b262f453d | ||
![]() |
c2b494f702 | ||
![]() |
958ee00efd | ||
![]() |
363354d941 | ||
![]() |
074ea61514 | ||
![]() |
abc59d3d30 | ||
![]() |
fea683f992 | ||
![]() |
3402f4f514 | ||
![]() |
3bb82ea330 | ||
![]() |
bced09e5b3 | ||
![]() |
9e84402f42 | ||
![]() |
18c65453fd | ||
![]() |
57ed99020f | ||
![]() |
caa3b0c438 | ||
![]() |
5133cf0275 | ||
![]() |
7f6c080b46 | ||
![]() |
142907395f | ||
![]() |
43d069438e | ||
![]() |
e7b73c4f53 | ||
![]() |
f2ca0a2372 | ||
![]() |
021cfe446f | ||
![]() |
1a71c906d5 | ||
![]() |
10d2eb6449 | ||
![]() |
0f283e088e | ||
![]() |
025977f19a | ||
![]() |
2a9ba788d0 | ||
![]() |
aa65266726 | ||
![]() |
4b6c58292b | ||
![]() |
d0813cc736 | ||
![]() |
f1d7e5e779 | ||
![]() |
66f01fc880 | ||
![]() |
d93384536f | ||
![]() |
69250db70e | ||
![]() |
ad52398087 | ||
![]() |
4f1eec31a1 | ||
![]() |
43c02740ab | ||
![]() |
4605f74cf9 | ||
![]() |
9ab4b35f22 | ||
![]() |
e9784f0e69 | ||
![]() |
3e37d0a39b | ||
![]() |
44ae162f09 | ||
![]() |
2821b9a832 | ||
![]() |
cf97247f75 | ||
![]() |
1bb40e2be1 | ||
![]() |
869db9e31c | ||
![]() |
39ee52ad3c | ||
![]() |
7e699af2b5 | ||
![]() |
2b344cc717 | ||
![]() |
246f0bc442 | ||
![]() |
4afb659f44 | ||
![]() |
a43069fc35 | ||
![]() |
5b43266278 | ||
![]() |
5df16371e1 | ||
![]() |
c086f05c7c | ||
![]() |
7a38857bcd | ||
![]() |
e860925f57 | ||
![]() |
3808067dd7 | ||
![]() |
c7d7dec40d | ||
![]() |
e96e0acc9f | ||
![]() |
3efd2398ca | ||
![]() |
7284ef6e06 | ||
![]() |
f5dc3ad753 | ||
![]() |
fc0d0031bf | ||
![]() |
d44ee4b8fa | ||
![]() |
eb5e755aa6 | ||
![]() |
6fc9e90f28 | ||
![]() |
2effd3da16 | ||
![]() |
69230b1147 | ||
![]() |
5435bf3ec4 | ||
![]() |
b0b13bfcb9 | ||
![]() |
c3b0b2ecf0 | ||
![]() |
a276421d25 | ||
![]() |
dbb6303bdc | ||
![]() |
d28036e173 | ||
![]() |
bc3f1cae16 | ||
![]() |
5e84d0c2b3 | ||
![]() |
086f88852d | ||
![]() |
aa132cade7 | ||
![]() |
dd35ffbe86 | ||
![]() |
8edcf8be81 | ||
![]() |
11196443ac | ||
![]() |
29b02b7bcb | ||
![]() |
0383bc27b2 | ||
![]() |
65d5102b49 | ||
![]() |
8a226e6f46 | ||
![]() |
0bd34e0a10 | ||
![]() |
186107d959 | ||
![]() |
91b07b7ea4 | ||
![]() |
f5b30fd2b4 | ||
![]() |
0234396c2c | ||
![]() |
a43d677ae4 | ||
![]() |
dcfe71e7f0 | ||
![]() |
5d41376c2e | ||
![]() |
dd083359ec | ||
![]() |
e6d54960ba | ||
![]() |
a9295bc5c2 | ||
![]() |
2015c701fa | ||
![]() |
3e9c18f50a | ||
![]() |
7cac874afc | ||
![]() |
a7b6bd8d32 | ||
![]() |
1649a98656 | ||
![]() |
6694cb42c8 | ||
![]() |
b6e293c38e | ||
![]() |
02090c953b | ||
![]() |
ecbe51f60f | ||
![]() |
fed14abed3 | ||
![]() |
dbe8bf5428 |
13
.coveragerc
13
.coveragerc
@@ -1,4 +1,17 @@
|
||||
[run]
|
||||
branch = False
|
||||
omit =
|
||||
jupyterhub/tests/*
|
||||
jupyterhub/alembic/*
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
if self.debug:
|
||||
pragma: no cover
|
||||
raise NotImplementedError
|
||||
if __name__ == .__main__.:
|
||||
ignore_errors = True
|
||||
omit =
|
||||
jupyterhub/tests/*
|
||||
jupyterhub/alembic/*
|
||||
*/site-packages/*
|
||||
|
@@ -4,3 +4,7 @@ jupyterhub_cookie_secret
|
||||
jupyterhub.sqlite
|
||||
jupyterhub_config.py
|
||||
node_modules
|
||||
docs
|
||||
.git
|
||||
dist
|
||||
build
|
||||
|
25
.flake8
Normal file
25
.flake8
Normal file
@@ -0,0 +1,25 @@
|
||||
[flake8]
|
||||
# Ignore style and complexity
|
||||
# E: style errors
|
||||
# W: style warnings
|
||||
# C: complexity
|
||||
# F401: module imported but unused
|
||||
# F403: import *
|
||||
# F811: redefinition of unused `name` from line `N`
|
||||
# F841: local variable assigned but never used
|
||||
# E402: module level import not at top of file
|
||||
# I100: Import statements are in the wrong order
|
||||
# I101: Imported names are in the wrong order. Should be
|
||||
ignore = E, C, W, F401, F403, F811, F841, E402, I100, I101
|
||||
|
||||
exclude =
|
||||
.cache,
|
||||
.github,
|
||||
docs,
|
||||
examples,
|
||||
jupyterhub/alembic*,
|
||||
onbuild,
|
||||
scripts,
|
||||
share,
|
||||
tools,
|
||||
setup.py
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,7 +3,7 @@ node_modules
|
||||
*~
|
||||
.cache
|
||||
.DS_Store
|
||||
build
|
||||
/build
|
||||
dist
|
||||
docs/_build
|
||||
docs/source/_static/rest-api
|
||||
|
53
.travis.yml
53
.travis.yml
@@ -1,22 +1,49 @@
|
||||
# http://travis-ci.org/#!/jupyter/jupyterhub
|
||||
language: python
|
||||
sudo: false
|
||||
python:
|
||||
- 3.6-dev
|
||||
- 3.5
|
||||
- 3.4
|
||||
- 3.3
|
||||
- nightly
|
||||
- 3.6
|
||||
- 3.5
|
||||
- 3.4
|
||||
env:
|
||||
global:
|
||||
- ASYNC_TEST_TIMEOUT=15
|
||||
services:
|
||||
- mysql
|
||||
- postgresql
|
||||
|
||||
# installing dependencies
|
||||
before_install:
|
||||
- npm install
|
||||
- npm install -g configurable-http-proxy
|
||||
- git clone --quiet --depth 1 https://github.com/minrk/travis-wheels travis-wheels
|
||||
- nvm install 6; nvm use 6
|
||||
- npm install
|
||||
- npm install -g configurable-http-proxy
|
||||
- |
|
||||
if [[ $JUPYTERHUB_TEST_DB_URL == mysql* ]]; then
|
||||
mysql -e 'CREATE DATABASE jupyterhub CHARACTER SET utf8 COLLATE utf8_general_ci;'
|
||||
pip install 'mysql-connector<2.2'
|
||||
elif [[ $JUPYTERHUB_TEST_DB_URL == postgresql* ]]; then
|
||||
psql -c 'create database jupyterhub;' -U postgres
|
||||
pip install psycopg2
|
||||
fi
|
||||
install:
|
||||
- pip install --pre -f travis-wheels/wheelhouse -r dev-requirements.txt .
|
||||
- pip install -U pip
|
||||
- pip install --pre -r dev-requirements.txt .
|
||||
- pip freeze
|
||||
|
||||
# running tests
|
||||
script:
|
||||
- travis_retry py.test --cov jupyterhub jupyterhub/tests -v
|
||||
- pytest -v --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||
after_success:
|
||||
- codecov
|
||||
- codecov
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
include:
|
||||
- python: 3.5
|
||||
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://127.0.0.1.xip.io:8000
|
||||
- python: 3.6
|
||||
env: JUPYTERHUB_TEST_SUBDOMAIN_HOST=http://localhost.jovyan.org:8000
|
||||
- python: 3.6
|
||||
env: JUPYTERHUB_TEST_DB_URL=mysql+mysqlconnector://root@127.0.0.1/jupyterhub
|
||||
- python: 3.6
|
||||
env: JUPYTERHUB_TEST_DB_URL=postgresql://postgres@127.0.0.1/jupyterhub
|
||||
allow_failures:
|
||||
- python: nightly
|
||||
|
@@ -1,3 +1,3 @@
|
||||
# Contributing
|
||||
|
||||
We mainly follow the [IPython Contributing Guide](https://github.com/ipython/ipython/blob/master/CONTRIBUTING.md).
|
||||
Welcome! As a [Jupyter](https://jupyter.org) project, we follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html).
|
||||
|
@@ -42,7 +42,9 @@ ENV LANG C.UTF-8
|
||||
RUN wget -q https://repo.continuum.io/miniconda/Miniconda3-4.2.12-Linux-x86_64.sh -O /tmp/miniconda.sh && \
|
||||
echo 'd0c7c71cc5659e54ab51f2005a8d96f3 */tmp/miniconda.sh' | md5sum -c - && \
|
||||
bash /tmp/miniconda.sh -f -b -p /opt/conda && \
|
||||
/opt/conda/bin/conda install --yes -c conda-forge python=3.5 sqlalchemy tornado jinja2 traitlets requests pip nodejs configurable-http-proxy && \
|
||||
/opt/conda/bin/conda install --yes -c conda-forge \
|
||||
python=3.5 sqlalchemy tornado jinja2 traitlets requests pip pycurl \
|
||||
nodejs configurable-http-proxy && \
|
||||
/opt/conda/bin/pip install --upgrade pip && \
|
||||
rm /tmp/miniconda.sh
|
||||
ENV PATH=/opt/conda/bin:$PATH
|
||||
|
@@ -10,6 +10,7 @@ graft onbuild
|
||||
graft jupyterhub
|
||||
graft scripts
|
||||
graft share
|
||||
graft singleuser
|
||||
|
||||
# Documentation
|
||||
graft docs
|
||||
|
217
README.md
217
README.md
@@ -1,82 +1,74 @@
|
||||
**[Technical overview](#technical-overview)** |
|
||||
**[Prerequisites](#prerequisites)** |
|
||||
**[Technical Overview](#technical-overview)** |
|
||||
**[Installation](#installation)** |
|
||||
**[Running the Hub Server](#running-the-hub-server)** |
|
||||
**[Configuration](#configuration)** |
|
||||
**[Docker](#docker)** |
|
||||
**[Contributing](#contributing)** |
|
||||
**[License](#license)** |
|
||||
**[Getting help](#getting-help)**
|
||||
**[Help and Resources](#help-and-resources)**
|
||||
|
||||
|
||||
# [JupyterHub](https://github.com/jupyterhub/jupyterhub)
|
||||
|
||||
|
||||
[](https://pypi.python.org/pypi/jupyterhub)
|
||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||
[](http://jupyterhub.readthedocs.io/en/0.7.2/?badge=0.7.2)
|
||||
[](https://travis-ci.org/jupyterhub/jupyterhub)
|
||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)
|
||||
[](https://codecov.io/github/jupyterhub/jupyterhub?branch=master)
|
||||
"
|
||||
[](http://jupyterhub.readthedocs.org/en/latest/?badge=latest)
|
||||
"
|
||||
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||
[](https://groups.google.com/forum/#!forum/jupyter)
|
||||
|
||||
With [JupyterHub](https://jupyterhub.readthedocs.io) you can create a
|
||||
**multi-user Hub** which spawns, manages, and proxies multiple instances of the
|
||||
single-user [Jupyter notebook *(IPython notebook)* ](https://jupyter-notebook.readthedocs.io) server.
|
||||
single-user [Jupyter notebook (IPython notebook)](https://jupyter-notebook.readthedocs.io)
|
||||
server.
|
||||
|
||||
JupyterHub provides **single-user notebook servers to many users**. For example,
|
||||
JupyterHub could serve notebooks to a class of students, a corporate
|
||||
workgroup, or a science research group.
|
||||
|
||||
by [Project Jupyter](https://jupyter.org)
|
||||
|
||||
----
|
||||
[Project Jupyter](https://jupyter.org) created JupyterHub to support many
|
||||
users. The Hub can offer notebook servers to a class of students, a corporate
|
||||
data science workgroup, a scientific research project, or a high performance
|
||||
computing group.
|
||||
|
||||
## Technical overview
|
||||
|
||||
Three main actors make up JupyterHub:
|
||||
|
||||
- multi-user **Hub** (tornado process)
|
||||
- configurable http **proxy** (node-http-proxy)
|
||||
- multiple **single-user Jupyter notebook servers** (Python/IPython/tornado)
|
||||
|
||||
JupyterHub's basic principles for operation are:
|
||||
Basic principles for operation are:
|
||||
|
||||
- Hub spawns a proxy
|
||||
- Proxy forwards all requests to Hub by default
|
||||
- Hub handles login, and spawns single-user servers on demand
|
||||
- Hub configures proxy to forward url prefixes to the single-user servers
|
||||
- Hub spawns a proxy.
|
||||
- Proxy forwards all requests to Hub by default.
|
||||
- Hub handles login, and spawns single-user servers on demand.
|
||||
- Hub configures proxy to forward url prefixes to the single-user notebook
|
||||
servers.
|
||||
|
||||
JupyterHub also provides a
|
||||
[REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
||||
for administration of the Hub and users.
|
||||
for administration of the Hub and its users.
|
||||
|
||||
----
|
||||
## Installation
|
||||
|
||||
## Prerequisites
|
||||
Before installing JupyterHub, you need:
|
||||
### Check prerequisites
|
||||
|
||||
- [Python](https://www.python.org/downloads/) 3.3 or greater
|
||||
A Linux/Unix based system with the following:
|
||||
|
||||
An understanding of using [`pip`](https://pip.pypa.io/en/stable/) for installing
|
||||
Python packages is recommended.
|
||||
|
||||
- [nodejs/npm](https://www.npmjs.com/)
|
||||
|
||||
[Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node), which is available from your
|
||||
package manager. For example, install on Linux (Debian/Ubuntu) using:
|
||||
- [Python](https://www.python.org/downloads/) 3.4 or greater
|
||||
- [nodejs/npm](https://www.npmjs.com/) Install a recent version of
|
||||
[nodejs/npm](https://docs.npmjs.com/getting-started/installing-node)
|
||||
For example, install it on Linux (Debian/Ubuntu) using:
|
||||
|
||||
sudo apt-get install npm nodejs-legacy
|
||||
|
||||
(The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for npm to work on Debian/Ubuntu.)
|
||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for npm to work on Debian/Ubuntu.
|
||||
|
||||
- TLS certificate and key for HTTPS communication
|
||||
|
||||
- Domain name
|
||||
|
||||
Before running the single-user notebook servers (which may be on the same system as the Hub or not):
|
||||
### Install packages
|
||||
|
||||
- [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html) version 4 or greater
|
||||
|
||||
## Installation
|
||||
JupyterHub can be installed with `pip`, and the proxy with `npm`:
|
||||
|
||||
```bash
|
||||
@@ -85,84 +77,108 @@ pip3 install jupyterhub
|
||||
```
|
||||
|
||||
If you plan to run notebook servers locally, you will need to install the
|
||||
Jupyter notebook:
|
||||
[Jupyter notebook](https://jupyter.readthedocs.io/en/latest/install.html)
|
||||
package:
|
||||
|
||||
pip3 install --upgrade notebook
|
||||
|
||||
## Running the Hub server
|
||||
### Run the Hub server
|
||||
|
||||
To start the Hub server, run the command:
|
||||
|
||||
jupyterhub
|
||||
|
||||
Visit `https://localhost:8000` in your browser, and sign in with your unix credentials.
|
||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
||||
PAM credentials.
|
||||
|
||||
To allow multiple users to sign into the server, you will need to
|
||||
*Note*: To allow multiple users to sign into the server, you will need to
|
||||
run the `jupyterhub` command as a *privileged user*, such as root.
|
||||
The [wiki](https://github.com/jupyterhub/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.
|
||||
|
||||
----
|
||||
describes how to run the server as a *less privileged user*, which requires
|
||||
more configuration of the system.
|
||||
|
||||
## Configuration
|
||||
The [getting started document](docs/source/getting-started.md) contains the
|
||||
basics of configuring a JupyterHub deployment.
|
||||
|
||||
The JupyterHub **tutorial** provides a video and documentation that explains and illustrates the fundamental steps for installation and configuration. [Repo](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||
| [Tutorial documentation](http://jupyterhub-tutorial.readthedocs.io/en/latest/)
|
||||
The [Getting Started](http://jupyterhub.readthedocs.io/en/latest/getting-started/index.html) section of the
|
||||
documentation explains the common steps in setting up JupyterHub.
|
||||
|
||||
#### Generate a default configuration file
|
||||
Generate a default config file:
|
||||
The [**JupyterHub tutorial**](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||
provides an in-depth video and sample configurations of JupyterHub.
|
||||
|
||||
### Create a configuration file
|
||||
|
||||
To generate a default config file with settings and descriptions:
|
||||
|
||||
jupyterhub --generate-config
|
||||
|
||||
#### Customize the configuration, authentication, and process spawning
|
||||
Spawn the server on ``10.0.1.2:443`` with **https**:
|
||||
### Start the Hub
|
||||
|
||||
To start the Hub on a specific url and port ``10.0.1.2:443`` with **https**:
|
||||
|
||||
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
||||
|
||||
The authentication and process spawning mechanisms can be replaced,
|
||||
which should allow plugging into a variety of authentication or process control environments.
|
||||
Some examples, meant as illustration and testing of this concept:
|
||||
### Authenticators
|
||||
|
||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyterhub/oauthenticator)
|
||||
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyterhub/dockerspawner)
|
||||
| Authenticator | Description |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| PAMAuthenticator | Default, built-in authenticator |
|
||||
| [OAuthenticator](https://github.com/jupyterhub/oauthenticator) | OAuth + JupyterHub Authenticator = OAuthenticator |
|
||||
| [ldapauthenticator](https://github.com/jupyterhub/ldapauthenticator) | Simple LDAP Authenticator Plugin for JupyterHub |
|
||||
| [kdcAuthenticator](https://github.com/bloomberg/jupyterhub-kdcauthenticator)| Kerberos Authenticator Plugin for JupyterHub |
|
||||
|
||||
----
|
||||
### Spawners
|
||||
|
||||
| Spawner | Description |
|
||||
| -------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| LocalProcessSpawner | Default, built-in spawner starts single-user servers as local processes |
|
||||
| [dockerspawner](https://github.com/jupyterhub/dockerspawner) | Spawn single-user servers in Docker containers |
|
||||
| [kubespawner](https://github.com/jupyterhub/kubespawner) | Kubernetes spawner for JupyterHub |
|
||||
| [sudospawner](https://github.com/jupyterhub/sudospawner) | Spawn single-user servers without being root |
|
||||
| [systemdspawner](https://github.com/jupyterhub/systemdspawner) | Spawn single-user notebook servers using systemd |
|
||||
| [batchspawner](https://github.com/jupyterhub/batchspawner) | Designed for clusters using batch scheduling software |
|
||||
| [wrapspawner](https://github.com/jupyterhub/wrapspawner) | WrapSpawner and ProfilesSpawner enabling runtime configuration of spawners |
|
||||
|
||||
## Docker
|
||||
A starter [docker image for JupyterHub](https://hub.docker.com/r/jupyterhub/jupyterhub/) gives a baseline deployment of JupyterHub.
|
||||
|
||||
**Important:** This `jupyterhub/jupyterhub` image contains only the Hub itself, with no configuration. In general, one needs
|
||||
to make a derivative image, with at least a `jupyterhub_config.py` setting up an Authenticator and/or a Spawner. To run the
|
||||
single-user servers, which may be on the same system as the Hub or not, Jupyter Notebook version 4 or greater must be installed.
|
||||
A starter [**docker image for JupyterHub**](https://hub.docker.com/r/jupyterhub/jupyterhub/)
|
||||
gives a baseline deployment of JupyterHub using Docker.
|
||||
|
||||
**Important:** This `jupyterhub/jupyterhub` image contains only the Hub itself,
|
||||
with no configuration. In general, one needs to make a derivative image, with
|
||||
at least a `jupyterhub_config.py` setting up an Authenticator and/or a Spawner.
|
||||
To run the single-user servers, which may be on the same system as the Hub or
|
||||
not, Jupyter Notebook version 4 or greater must be installed.
|
||||
|
||||
#### Starting JupyterHub with docker
|
||||
The JupyterHub docker image can be started with the following command:
|
||||
|
||||
docker run -d --name jupyterhub jupyterhub/jupyterhub jupyterhub
|
||||
|
||||
This command will create a container named `jupyterhub` that you can **stop and resume** with `docker stop/start`.
|
||||
This command will create a container named `jupyterhub` that you can
|
||||
**stop and resume** with `docker stop/start`.
|
||||
|
||||
The Hub service will be listening on all interfaces at port 8000, which makes this a good choice for **testing JupyterHub on your desktop or laptop**.
|
||||
The Hub service will be listening on all interfaces at port 8000, which makes
|
||||
this a good choice for **testing JupyterHub on your desktop or laptop**.
|
||||
|
||||
If you want to run docker on a computer that has a public IP then you should (as in MUST) **secure it with ssl** by
|
||||
adding ssl options to your docker configuration or using a ssl enabled proxy.
|
||||
If you want to run docker on a computer that has a public IP then you should
|
||||
(as in MUST) **secure it with ssl** by adding ssl options to your docker
|
||||
configuration or by using a ssl enabled proxy.
|
||||
|
||||
[Mounting volumes](https://docs.docker.com/engine/userguide/containers/dockervolumes/) will
|
||||
allow you to **store data outside the docker image (host system) so it will be persistent**, even when you start
|
||||
a new image.
|
||||
|
||||
The command `docker exec -it jupyterhub bash` will spawn a root shell in your docker
|
||||
container. You can **use the root shell to create system users in the container**. These accounts will be used for authentication
|
||||
in JupyterHub's default configuration.
|
||||
|
||||
----
|
||||
container. You can **use the root shell to create system users in the container**.
|
||||
These accounts will be used for authentication in JupyterHub's default configuration.
|
||||
|
||||
## Contributing
|
||||
If you would like to contribute to the project, please read our [contributor documentation](http://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html) and the [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
|
||||
For a **development install**, clone the [repository](https://github.com/jupyterhub/jupyterhub) and then install from source:
|
||||
If you would like to contribute to the project, please read our
|
||||
[contributor documentation](http://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html)
|
||||
and the [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
|
||||
For a **development install**, clone the [repository](https://github.com/jupyterhub/jupyterhub)
|
||||
and then install from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jupyterhub/jupyterhub
|
||||
@@ -170,41 +186,68 @@ cd jupyterhub
|
||||
pip3 install -r dev-requirements.txt -e .
|
||||
```
|
||||
|
||||
If the `pip3 install` command fails and complains about `lessc` being unavailable, you may need to explicitly install some additional JavaScript dependencies:
|
||||
If the `pip3 install` command fails and complains about `lessc` being
|
||||
unavailable, you may need to explicitly install some additional JavaScript
|
||||
dependencies:
|
||||
|
||||
npm install
|
||||
|
||||
This will fetch client-side JavaScript dependencies necessary to compile CSS.
|
||||
|
||||
You may also need to manually update JavaScript and CSS after some development updates, with:
|
||||
You may also need to manually update JavaScript and CSS after some development
|
||||
updates, with:
|
||||
|
||||
```bash
|
||||
python3 setup.py js # fetch updated client-side js
|
||||
python3 setup.py css # recompile CSS from LESS sources
|
||||
```
|
||||
|
||||
We use [pytest](http://doc.pytest.org/en/latest/) for testing. To run tests:
|
||||
We use [pytest](http://doc.pytest.org/en/latest/) for **running tests**:
|
||||
|
||||
```bash
|
||||
pytest jupyterhub/tests
|
||||
```
|
||||
|
||||
----
|
||||
### A note about platform support
|
||||
|
||||
JupyterHub is supported on Linux/Unix based systems.
|
||||
|
||||
JupyterHub officially **does not** support Windows. You may be able to use
|
||||
JupyterHub on Windows if you use a Spawner and Authenticator that work on
|
||||
Windows, but the JupyterHub defaults will not. Bugs reported on Windows will not
|
||||
be accepted, and the test suite will not run on Windows. Small patches that fix
|
||||
minor Windows compatibility issues (such as basic installation) **may** be accepted,
|
||||
however. For Windows-based systems, we would recommend running JupyterHub in a
|
||||
docker container or Linux VM.
|
||||
|
||||
[Additional Reference:](http://www.tornadoweb.org/en/stable/#installation) Tornado's documentation on Windows platform support
|
||||
|
||||
## License
|
||||
|
||||
We use a shared copyright model that enables all contributors to maintain the
|
||||
copyright on their contributions.
|
||||
|
||||
All code is licensed under the terms of the revised BSD license.
|
||||
|
||||
## Getting help
|
||||
We encourage you to ask questions on the [mailing list](https://groups.google.com/forum/#!forum/jupyter),
|
||||
and you may participate in development discussions or get live help on [Gitter](https://gitter.im/jupyterhub/jupyterhub).
|
||||
## Help and resources
|
||||
|
||||
We encourage you to ask questions on the [Jupyter mailing list](https://groups.google.com/forum/#!forum/jupyter).
|
||||
To participate in development discussions or get help, talk with us on
|
||||
our JupyterHub [Gitter](https://gitter.im/jupyterhub/jupyterhub) channel.
|
||||
|
||||
## Resources
|
||||
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
||||
- JupyterHub tutorial | [Repo](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||
| [Tutorial documentation](http://jupyterhub-tutorial.readthedocs.io/en/latest/)
|
||||
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||
- [Documentation for JupyterHub](http://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
||||
- [Documentation for JupyterHub's REST API](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/master/docs/rest-api.yml#/default)
|
||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
||||
- [Project Jupyter website](https://jupyter.org)
|
||||
|
||||
---
|
||||
|
||||
**[Technical Overview](#technical-overview)** |
|
||||
**[Installation](#installation)** |
|
||||
**[Configuration](#configuration)** |
|
||||
**[Docker](#docker)** |
|
||||
**[Contributing](#contributing)** |
|
||||
**[License](#license)** |
|
||||
**[Help and Resources](#help-and-resources)**
|
||||
|
10
bower.json
10
bower.json
@@ -2,10 +2,10 @@
|
||||
"name": "jupyterhub-deps",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"bootstrap": "components/bootstrap#~3.1",
|
||||
"font-awesome": "components/font-awesome#~4.1",
|
||||
"jquery": "components/jquery#~2.0",
|
||||
"moment": "~2.7",
|
||||
"requirejs": "~2.1"
|
||||
"bootstrap": "components/bootstrap#~3.3",
|
||||
"font-awesome": "components/font-awesome#~4.7",
|
||||
"jquery": "components/jquery#~3.2",
|
||||
"moment": "~2.18",
|
||||
"requirejs": "~2.3"
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,9 @@
|
||||
-r requirements.txt
|
||||
mock
|
||||
codecov
|
||||
cryptography
|
||||
pytest-cov
|
||||
pytest-tornado
|
||||
pytest>=2.8
|
||||
notebook
|
||||
requests-mock
|
||||
|
@@ -3,14 +3,17 @@ channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- nodejs
|
||||
- python=3
|
||||
- python=3.5
|
||||
- alembic
|
||||
- jinja2
|
||||
- pamela
|
||||
- requests
|
||||
- sqlalchemy>=1
|
||||
- tornado>=4.1
|
||||
- traitlets>=4.1
|
||||
- sphinx>=1.3.6
|
||||
- sphinx>=1.4, !=1.5.4
|
||||
- sphinx_rtd_theme
|
||||
- pip:
|
||||
- recommonmark==0.4.0
|
||||
- jupyter_alabaster_theme
|
||||
- python-oauth2
|
||||
- recommonmark==0.4.0
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jupyterhub-docs-build",
|
||||
"version": "0.0.0",
|
||||
"version": "0.8.0",
|
||||
"description": "build JupyterHub swagger docs",
|
||||
"scripts": {
|
||||
"rest-api": "bootprint openapi ./rest-api.yml source/_static/rest-api"
|
||||
@@ -8,7 +8,7 @@
|
||||
"author": "",
|
||||
"license": "BSD-3-Clause",
|
||||
"devDependencies": {
|
||||
"bootprint": "^0.10.0",
|
||||
"bootprint-openapi": "^0.17.0"
|
||||
"bootprint": "^1.0.0",
|
||||
"bootprint-openapi": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
@@ -1,3 +1,3 @@
|
||||
-r ../requirements.txt
|
||||
sphinx>=1.3.6
|
||||
recommonmark==0.4.0
|
||||
sphinx>=1.4
|
||||
recommonmark==0.4.0
|
||||
|
@@ -3,7 +3,7 @@ swagger: '2.0'
|
||||
info:
|
||||
title: JupyterHub
|
||||
description: The REST API for JupyterHub
|
||||
version: 0.7.0
|
||||
version: 0.8.0dev
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
schemes:
|
||||
@@ -215,6 +215,13 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Sets a cookie granting the requesting administrator access to the user's notebook server
|
||||
/user:
|
||||
summary: Return authenticated user's model
|
||||
description:
|
||||
parameters:
|
||||
responses:
|
||||
'200':
|
||||
description: The authenticated user's model is returned.
|
||||
/groups:
|
||||
get:
|
||||
summary: List groups
|
||||
@@ -377,9 +384,38 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
/authorizations/token:
|
||||
post:
|
||||
summary: Request a new API token
|
||||
description: |
|
||||
Request a new API token to use with the JupyterHub REST API.
|
||||
If not already authenticated, username and password can be sent
|
||||
in the JSON request body.
|
||||
Logging in via this method is only available when the active Authenticator
|
||||
accepts passwords (e.g. not OAuth).
|
||||
parameters:
|
||||
- name: username
|
||||
in: body
|
||||
required: false
|
||||
type: string
|
||||
- name: password
|
||||
in: body
|
||||
required: false
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The new API token
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: The new API token.
|
||||
'403':
|
||||
description: The user can not be authenticated.
|
||||
/authorizations/token/{token}:
|
||||
get:
|
||||
summary: Identify a user from an API token
|
||||
summary: Identify a user or service from an API token
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
@@ -387,9 +423,9 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: The user identified by the API token
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
description: The user or service identified by the API token
|
||||
'404':
|
||||
description: A user or service is not found.
|
||||
/authorizations/cookie/{cookie_name}/{cookie_value}:
|
||||
get:
|
||||
summary: Identify a user from a cookie
|
||||
@@ -408,6 +444,81 @@ paths:
|
||||
description: The user identified by the cookie
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
'404':
|
||||
description: A user is not found.
|
||||
/oauth2/authorize:
|
||||
get:
|
||||
summary: 'OAuth 2.0 authorize endpoint'
|
||||
description: |
|
||||
Redirect users to this URL to begin the OAuth process.
|
||||
It is not an API endpoint.
|
||||
parameters:
|
||||
- name: client_id
|
||||
description: The client id
|
||||
in: query
|
||||
required: true
|
||||
type: string
|
||||
- name: response_type
|
||||
description: The response type (always 'code')
|
||||
in: query
|
||||
required: true
|
||||
type: string
|
||||
- name: state
|
||||
description: A state string
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
- name: redirect_uri
|
||||
description: The redirect url
|
||||
in: query
|
||||
required: true
|
||||
type: string
|
||||
/oauth2/token:
|
||||
post:
|
||||
summary: Request an OAuth2 token
|
||||
description: |
|
||||
Request an OAuth2 token from an authorization code.
|
||||
This request completes the OAuth process.
|
||||
consumes:
|
||||
- application/x-www-form-urlencoded
|
||||
parameters:
|
||||
- name: client_id
|
||||
description: The client id
|
||||
in: form
|
||||
required: true
|
||||
type: string
|
||||
- name: client_secret
|
||||
description: The client secret
|
||||
in: form
|
||||
required: true
|
||||
type: string
|
||||
- name: grant_type
|
||||
description: The grant type (always 'authorization_code')
|
||||
in: form
|
||||
required: true
|
||||
type: string
|
||||
- name: code
|
||||
description: The code provided by the authorization redirect
|
||||
in: form
|
||||
required: true
|
||||
type: string
|
||||
- name: redirect_uri
|
||||
description: The redirect url
|
||||
in: form
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: JSON response including the token
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
description: The new API token for the user
|
||||
token_type:
|
||||
type: string
|
||||
description: Will always be 'Bearer'
|
||||
/shutdown:
|
||||
post:
|
||||
summary: Shutdown the Hub
|
||||
@@ -419,10 +530,7 @@ paths:
|
||||
- name: servers
|
||||
in: body
|
||||
type: boolean
|
||||
description: Whether users's notebook servers should be shutdown as well (default from Hub config)
|
||||
responses:
|
||||
'200':
|
||||
description: Hub has shutdown
|
||||
description: Whether users' notebook servers should be shutdown as well (default from Hub config)
|
||||
definitions:
|
||||
User:
|
||||
type: object
|
||||
|
16
docs/source/api/app.rst
Normal file
16
docs/source/api/app.rst
Normal file
@@ -0,0 +1,16 @@
|
||||
=========================
|
||||
Application configuration
|
||||
=========================
|
||||
|
||||
Module: :mod:`jupyterhub.app`
|
||||
=============================
|
||||
|
||||
.. automodule:: jupyterhub.app
|
||||
|
||||
.. currentmodule:: jupyterhub.app
|
||||
|
||||
:class:`JupyterHub`
|
||||
-------------------
|
||||
|
||||
.. autoconfigurable:: JupyterHub
|
||||
|
@@ -9,13 +9,20 @@ Module: :mod:`jupyterhub.auth`
|
||||
|
||||
.. currentmodule:: jupyterhub.auth
|
||||
|
||||
:class:`Authenticator`
|
||||
----------------------
|
||||
|
||||
|
||||
.. autoclass:: Authenticator
|
||||
.. autoconfigurable:: Authenticator
|
||||
:members:
|
||||
|
||||
.. autoclass:: LocalAuthenticator
|
||||
:class:`LocalAuthenticator`
|
||||
---------------------------
|
||||
|
||||
.. autoconfigurable:: LocalAuthenticator
|
||||
:members:
|
||||
|
||||
.. autoclass:: PAMAuthenticator
|
||||
:class:`PAMAuthenticator`
|
||||
-------------------------
|
||||
|
||||
.. autoconfigurable:: PAMAuthenticator
|
||||
|
||||
|
@@ -1,19 +1,21 @@
|
||||
.. _api-index:
|
||||
|
||||
####################
|
||||
The JupyterHub API
|
||||
####################
|
||||
##################
|
||||
The JupyterHub API
|
||||
##################
|
||||
|
||||
:Release: |release|
|
||||
:Date: |today|
|
||||
|
||||
JupyterHub also provides a REST API for administration of the Hub and users.
|
||||
The documentation on `Using JupyterHub's REST API <../rest.html>`_ provides
|
||||
The documentation on `Using JupyterHub's REST API <../reference/rest.html>`_ provides
|
||||
information on:
|
||||
|
||||
- Creating an API token
|
||||
- Adding tokens to the configuration file (optional)
|
||||
- Making an API request
|
||||
- what you can do with the API
|
||||
- creating an API token
|
||||
- adding API tokens to the config files
|
||||
- making an API request programmatically using the requests library
|
||||
- learning more about JupyterHub's API
|
||||
|
||||
The same JupyterHub API spec, as found here, is available in an interactive form
|
||||
`here (on swagger's petstore) <http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default>`__.
|
||||
@@ -24,9 +26,12 @@ JupyterHub API Reference:
|
||||
|
||||
.. toctree::
|
||||
|
||||
app
|
||||
auth
|
||||
spawner
|
||||
proxy
|
||||
user
|
||||
service
|
||||
services.auth
|
||||
|
||||
|
||||
|
23
docs/source/api/proxy.rst
Normal file
23
docs/source/api/proxy.rst
Normal file
@@ -0,0 +1,23 @@
|
||||
=======
|
||||
Proxies
|
||||
=======
|
||||
|
||||
Module: :mod:`jupyterhub.proxy`
|
||||
===============================
|
||||
|
||||
.. automodule:: jupyterhub.proxy
|
||||
|
||||
.. currentmodule:: jupyterhub.proxy
|
||||
|
||||
:class:`Proxy`
|
||||
--------------
|
||||
|
||||
.. autoconfigurable:: Proxy
|
||||
:members:
|
||||
|
||||
:class:`ConfigurableHTTPProxy`
|
||||
------------------------------
|
||||
|
||||
.. autoconfigurable:: ConfigurableHTTPProxy
|
||||
:members: debug, auth_token, check_running_interval, api_url, command
|
||||
|
17
docs/source/api/service.rst
Normal file
17
docs/source/api/service.rst
Normal file
@@ -0,0 +1,17 @@
|
||||
========
|
||||
Services
|
||||
========
|
||||
|
||||
Module: :mod:`jupyterhub.services.service`
|
||||
==========================================
|
||||
|
||||
.. automodule:: jupyterhub.services.service
|
||||
|
||||
.. currentmodule:: jupyterhub.services.service
|
||||
|
||||
:class:`Service`
|
||||
----------------
|
||||
|
||||
.. autoconfigurable:: Service
|
||||
:members: name, admin, url, api_token, managed, kind, command, cwd, environment, user, oauth_client_id, server, prefix, proxy_spec
|
||||
|
@@ -1,5 +1,5 @@
|
||||
=======================
|
||||
Authenticating Services
|
||||
Services Authentication
|
||||
=======================
|
||||
|
||||
Module: :mod:`jupyterhub.services.auth`
|
||||
@@ -10,9 +10,32 @@ Module: :mod:`jupyterhub.services.auth`
|
||||
.. currentmodule:: jupyterhub.services.auth
|
||||
|
||||
|
||||
.. autoclass:: HubAuth
|
||||
:class:`HubAuth`
|
||||
----------------
|
||||
|
||||
.. autoconfigurable:: HubAuth
|
||||
:members:
|
||||
|
||||
:class:`HubOAuth`
|
||||
----------------
|
||||
|
||||
.. autoconfigurable:: HubOAuth
|
||||
:members:
|
||||
|
||||
|
||||
:class:`HubAuthenticated`
|
||||
-------------------------
|
||||
|
||||
.. autoclass:: HubAuthenticated
|
||||
:members:
|
||||
|
||||
:class:`HubOAuthenticated`
|
||||
-------------------------
|
||||
|
||||
.. autoclass:: HubOAuthenticated
|
||||
|
||||
:class:`HubOAuthCallbackHandler`
|
||||
--------------------------------
|
||||
|
||||
.. autoclass:: HubOAuthCallbackHandler
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
==============
|
||||
Spawners
|
||||
==============
|
||||
========
|
||||
Spawners
|
||||
========
|
||||
|
||||
Module: :mod:`jupyterhub.spawner`
|
||||
=================================
|
||||
@@ -12,7 +12,11 @@ Module: :mod:`jupyterhub.spawner`
|
||||
:class:`Spawner`
|
||||
----------------
|
||||
|
||||
.. autoclass:: Spawner
|
||||
.. autoconfigurable:: Spawner
|
||||
:members: options_from_form, poll, start, stop, get_args, get_env, get_state, template_namespace, format_string
|
||||
|
||||
.. autoclass:: LocalProcessSpawner
|
||||
:class:`LocalProcessSpawner`
|
||||
----------------------------
|
||||
|
||||
.. autoconfigurable:: LocalProcessSpawner
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
=============
|
||||
Users
|
||||
=============
|
||||
=====
|
||||
Users
|
||||
=====
|
||||
|
||||
Module: :mod:`jupyterhub.user`
|
||||
==============================
|
||||
@@ -9,11 +9,16 @@ Module: :mod:`jupyterhub.user`
|
||||
|
||||
.. currentmodule:: jupyterhub.user
|
||||
|
||||
:class:`UserDict`
|
||||
-----------------
|
||||
|
||||
.. autoclass:: UserDict
|
||||
:members:
|
||||
|
||||
|
||||
:class:`User`
|
||||
-------------
|
||||
|
||||
.. class:: Server
|
||||
|
||||
.. autoclass:: User
|
||||
:members: escaped_name
|
||||
|
||||
@@ -29,3 +34,4 @@ Module: :mod:`jupyterhub.user`
|
||||
.. attribute:: spawner
|
||||
|
||||
The user's :class:`~.Spawner` instance.
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# Change log summary
|
||||
# Changelog
|
||||
|
||||
For detailed changes from the prior release, click on the version number, and
|
||||
its link will bring up a GitHub listing of changes. Use `git log` on the
|
||||
@@ -7,13 +7,114 @@ command line for details.
|
||||
|
||||
## [Unreleased] 0.8
|
||||
|
||||
## 0.7
|
||||
JupyterHub 0.8 is a big release!
|
||||
|
||||
### [0.7.1] - 2016-01-02
|
||||
Perhaps the biggest change is the use of OAuth to negotiate authentication
|
||||
between the Hub and single-user services.
|
||||
Due to this change, it is important that the single-user server
|
||||
and Hub are both running the same version of JupyterHub.
|
||||
If you are using containers (e.g. via DockerSpawner or KubeSpawner),
|
||||
this means upgrading jupyterhub in your user images at the same time as the Hub.
|
||||
In most cases, a
|
||||
|
||||
pip install jupyterhub==version
|
||||
|
||||
in your Dockerfile is sufficient.
|
||||
|
||||
#### Added
|
||||
|
||||
- `Spawner.will_resume` for signalling that a single-user server is paused instead of stopped.
|
||||
- JupyterHub now defined a `.Proxy` API for custom
|
||||
proxy implementations other than the default.
|
||||
The defaults are unchanged,
|
||||
but configuration of the proxy is now done on the `ConfigurableHTTPProxy` class instead of the top-level JupyterHub.
|
||||
TODO: docs for writing a custom proxy.
|
||||
- Single-user servers and services
|
||||
(anything that uses HubAuth)
|
||||
can now accept token-authenticated requests via the Authentication header.
|
||||
- Authenticators can now store state in the Hub's database.
|
||||
To do so, the `.authenticate` method should return a dict of the form
|
||||
|
||||
```python
|
||||
{
|
||||
'username': 'name'
|
||||
'state': {}
|
||||
}
|
||||
```
|
||||
|
||||
This data will be encrypted and requires `JUPYTERHUB_CRYPT_KEY` environment variable to be set
|
||||
and the `Authenticator.enable_auth_state` flag to be True.
|
||||
If these are not set, auth_state returned by the Authenticator will not be stored.
|
||||
- There is preliminary support for multiple (named) servers per user in the REST API.
|
||||
Named servers can be created via API requests, but there is currently no UI for managing them.
|
||||
- Add `LocalProcessSpawner.popen_kwargs` and `LocalProcessSpawner.shell_cmd`
|
||||
for customizing how user server processes are launched.
|
||||
- Add `Authenticator.auto_login` flag for skipping the "Login with..." page explicitly.
|
||||
- Add `JupyterHub.hub_connect_ip` configuration
|
||||
for the ip that should be used when connecting to the Hub.
|
||||
This is promoting (and deprecating) `DockerSpawner.hub_ip_connect`
|
||||
for use by all Spawners.
|
||||
- Add `Spawner.pre_spawn_hook(spawner)` hook for customizing
|
||||
pre-spawn events.
|
||||
- Add `JupyterHub.active_server_limit` and `JupyterHub.concurrent_spawn_limit`
|
||||
for limiting the total number of running user servers and the number of pending spawns, respectively.
|
||||
|
||||
|
||||
#### Changed
|
||||
|
||||
- more arguments to spawners are now passed via environment variables (`.get_env()`)
|
||||
rather than CLI arguments (`.get_args()`)
|
||||
- internally generated tokens no longer get extra hash rounds,
|
||||
significantly speeding up authentication.
|
||||
The hash rounds were deemed unnecessary because the tokens were already
|
||||
generated with high entropy.
|
||||
- `JUPYTERHUB_API_TOKEN` env is available at all times,
|
||||
rather than being removed during single-user start.
|
||||
The token is now accessible to kernel processes,
|
||||
enabling user kernels to make authenticated API requests to Hub-authenticated services.
|
||||
- Cookie secrets should be 32B hex instead of large base64 secrets.
|
||||
- pycurl is used by default, if available.
|
||||
|
||||
#### Fixed
|
||||
|
||||
So many things fixed!
|
||||
|
||||
- Collisions are checked when users are renamed
|
||||
- Fix bug where OAuth authenticators could not logout users
|
||||
due to being redirected right back through the login process.
|
||||
- If there are errors loading your config files,
|
||||
JupyterHub will refuse to start with an informative error.
|
||||
Previously, the bad config would be ignored and JupyterHub would launch with default configuration.
|
||||
- Raise 403 error on unauthorized user rather than redirect to login,
|
||||
which could cause redirect loop.
|
||||
- Set `httponly` on cookies because it's prudent.
|
||||
- Improve support for MySQL as the database backend
|
||||
- Many race conditions and performance problems under heavy load have been fixed.
|
||||
- Fix alembic tagging of database schema versions.
|
||||
|
||||
#### Removed
|
||||
|
||||
- End support for Python 3.3
|
||||
|
||||
## 0.7
|
||||
|
||||
### [0.7.2] - 2017-01-09
|
||||
|
||||
#### Added
|
||||
|
||||
- Support service environment variables and defaults in `jupyterhub-singleuser`
|
||||
for easier deployment of notebook servers as a Service.
|
||||
- Add `--group` parameter for deploying `jupyterhub-singleuser` as a Service with group authentication.
|
||||
- Include URL parameters when redirecting through `/user-redirect/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix group authentication for HubAuthenticated services
|
||||
|
||||
### [0.7.1] - 2017-01-02
|
||||
|
||||
#### Added
|
||||
|
||||
- `Spawner.will_resume` for signaling that a single-user server is paused instead of stopped.
|
||||
This is needed for cases like `DockerSpawner.remove_containers = False`,
|
||||
where the first API token is re-used for subsequent spawns.
|
||||
- Warning on startup about single-character usernames,
|
||||
@@ -132,7 +233,8 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
||||
First preview release
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.7.1...HEAD
|
||||
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/0.7.2...HEAD
|
||||
[0.7.2]: https://github.com/jupyterhub/jupyterhub/compare/0.7.1...0.7.2
|
||||
[0.7.1]: https://github.com/jupyterhub/jupyterhub/compare/0.7.0...0.7.1
|
||||
[0.7.0]: https://github.com/jupyterhub/jupyterhub/compare/0.6.1...0.7.0
|
||||
[0.6.1]: https://github.com/jupyterhub/jupyterhub/compare/0.6.0...0.6.1
|
||||
|
@@ -8,7 +8,7 @@ import shlex
|
||||
import recommonmark.parser
|
||||
|
||||
# Set paths
|
||||
#sys.path.insert(0, os.path.abspath('.'))
|
||||
sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
@@ -20,6 +20,8 @@ extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.napoleon',
|
||||
'autodoc_traits',
|
||||
'jupyter_alabaster_theme',
|
||||
]
|
||||
|
||||
templates_path = ['_templates']
|
||||
@@ -37,6 +39,7 @@ from os.path import dirname
|
||||
docs = dirname(dirname(__file__))
|
||||
root = dirname(docs)
|
||||
sys.path.insert(0, root)
|
||||
sys.path.insert(0, os.path.join(docs, 'sphinxext'))
|
||||
|
||||
import jupyterhub
|
||||
# The short X.Y version.
|
||||
@@ -49,6 +52,9 @@ exclude_patterns = []
|
||||
pygments_style = 'sphinx'
|
||||
todo_include_todos = False
|
||||
|
||||
# Set the default role so we can use `foo` instead of ``foo``
|
||||
default_role = 'literal'
|
||||
|
||||
# -- Source -------------------------------------------------------------
|
||||
|
||||
source_parsers = {
|
||||
@@ -61,7 +67,7 @@ source_suffix = ['.rst', '.md']
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages.
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme = 'jupyter_alabaster_theme'
|
||||
|
||||
#html_theme_options = {}
|
||||
#html_theme_path = []
|
||||
@@ -158,17 +164,15 @@ epub_exclude_files = ['search.html']
|
||||
|
||||
# -- Intersphinx ----------------------------------------------------------
|
||||
|
||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
||||
intersphinx_mapping = {'https://docs.python.org/3/': None}
|
||||
|
||||
# -- Read The Docs --------------------------------------------------------
|
||||
|
||||
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
|
||||
|
||||
if not on_rtd:
|
||||
# only import and set the theme if we're building docs locally
|
||||
import sphinx_rtd_theme
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
import jupyter_alabaster_theme
|
||||
html_theme = 'jupyter_alabaster_theme'
|
||||
html_theme_path = [jupyter_alabaster_theme.get_path()]
|
||||
else:
|
||||
# readthedocs.org uses their theme by default, so no need to specify it
|
||||
# build rest-api, since RTD doesn't run make
|
||||
|
@@ -4,29 +4,41 @@ Project Jupyter thanks the following people for their help and
|
||||
contribution on JupyterHub:
|
||||
|
||||
- anderbubble
|
||||
- apetresc
|
||||
- barrachri
|
||||
- betatim
|
||||
- Carreau
|
||||
- charnpreetsingh
|
||||
- ckald
|
||||
- CRegenschein
|
||||
- cwaldbieser
|
||||
- danielballen
|
||||
- danoventa
|
||||
- daradib
|
||||
- datapolitan
|
||||
- dblockow-d2dcrc
|
||||
- DeepHorizons
|
||||
- dhirschfeld
|
||||
- dietmarw
|
||||
- dmartzol
|
||||
- DominicFollettSmith
|
||||
- dsblank
|
||||
- ellisonbg
|
||||
- evanlinde
|
||||
- Fokko
|
||||
- fperez
|
||||
- iamed18
|
||||
- JamiesHQ
|
||||
- jbweston
|
||||
- jdavidheiser
|
||||
- jhamrick
|
||||
- josephtate
|
||||
- kinuax
|
||||
- KrishnaPG
|
||||
- kroq-gar78
|
||||
- ksolan
|
||||
- mbmilligan
|
||||
- mgeplf
|
||||
- minrk
|
||||
- mistercrunch
|
||||
- Mistobaan
|
||||
@@ -37,6 +49,8 @@ contribution on JupyterHub:
|
||||
- parente
|
||||
- PeterDaveHello
|
||||
- peterruppel
|
||||
- pjamason
|
||||
- prasadkatti
|
||||
- rafael-ladislau
|
||||
- rgbkrk
|
||||
- robnagler
|
||||
@@ -47,12 +61,16 @@ contribution on JupyterHub:
|
||||
- ssanderson
|
||||
- takluyver
|
||||
- temogen
|
||||
- ThomasMChen
|
||||
- TimShawver
|
||||
- Todd-Z-Li
|
||||
- toobaz
|
||||
- tsaeger
|
||||
- tschaume
|
||||
- vilhelmen
|
||||
- whitead
|
||||
- willingc
|
||||
- YannBrrd
|
||||
- yuvipanda
|
||||
- zoltan-fedor
|
||||
- zonca
|
||||
|
169
docs/source/gallery-jhub-deployments.md
Normal file
169
docs/source/gallery-jhub-deployments.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# A Gallery of JupyterHub Deployments
|
||||
|
||||
**A JupyterHub Community Resource**
|
||||
|
||||
We've compiled this list of JupyterHub deployments to help the community
|
||||
see the breadth and growth of JupyterHub's use in education, research, and
|
||||
high performance computing.
|
||||
|
||||
Please submit pull requests to update information or to add new institutions or uses.
|
||||
|
||||
|
||||
## Academic Institutions, Research Labs, and Supercomputer Centers
|
||||
|
||||
### University of California Berkeley
|
||||
|
||||
- [BIDS - Berkeley Institute for Data Science](https://bids.berkeley.edu/)
|
||||
- [Teaching with Jupyter notebooks and JupyterHub](https://bids.berkeley.edu/resources/videos/teaching-ipythonjupyter-notebooks-and-jupyterhub)
|
||||
|
||||
- [Data 8](http://data8.org/)
|
||||
- [GitHub organization](https://github.com/data-8)
|
||||
|
||||
- [NERSC](http://www.nersc.gov/)
|
||||
- [Press release on Jupyter and Cori](http://www.nersc.gov/news-publications/nersc-news/nersc-center-news/2016/jupyter-notebooks-will-open-up-new-possibilities-on-nerscs-cori-supercomputer/)
|
||||
- [Moving and sharing data](https://www.nersc.gov/assets/Uploads/03-MovingAndSharingData-Cholia.pdf)
|
||||
|
||||
- [Research IT](http://research-it.berkeley.edu)
|
||||
- [JupyterHub server supports campus research computation](http://research-it.berkeley.edu/blog/17/01/24/free-fully-loaded-jupyterhub-server-supports-campus-research-computation)
|
||||
|
||||
### University of California Davis
|
||||
|
||||
- [Spinning up multiple Jupyter Notebooks on AWS for a tutorial](https://github.com/mblmicdiv/course2017/blob/master/exercises/sourmash-setup.md)
|
||||
|
||||
Although not technically a JupyterHub deployment, this tutorial setup
|
||||
may be helpful to others in the Jupyter community.
|
||||
|
||||
Thank you C. Titus Brown for sharing this with the Software Carpentry
|
||||
mailing list.
|
||||
|
||||
```
|
||||
* I started a big Amazon machine;
|
||||
* I installed Docker and built a custom image containing my software of
|
||||
interest;
|
||||
* I ran multiple containers, one connected to port 8000, one on 8001,
|
||||
etc. and gave each student a different port;
|
||||
* students could connect in and use the Terminal program in Jupyter to
|
||||
execute commands, and could upload/download files via the Jupyter
|
||||
console interface;
|
||||
* in theory I could have used notebooks too, but for this I didn’t have
|
||||
need.
|
||||
|
||||
I am aware that JupyterHub can probably do all of this including manage
|
||||
the containers, but I’m still a bit shy of diving into that; this was
|
||||
fairly straightforward, gave me disposable containers that were isolated
|
||||
for each individual student, and worked almost flawlessly. Should be
|
||||
easy to do with RStudio too.
|
||||
```
|
||||
|
||||
### Cal Poly San Luis Obispo
|
||||
|
||||
- [jupyterhub-deploy-teaching](https://github.com/jupyterhub/jupyterhub-deploy-teaching) based on work by Brian Granger for Cal Poly's Data Science 301 Course
|
||||
|
||||
### Clemson University
|
||||
|
||||
- Advanced Computing
|
||||
- [Palmetto cluster and JupyterHub](http://citi.sites.clemson.edu/2016/08/18/JupyterHub-for-Palmetto-Cluster.html)
|
||||
|
||||
### University of Colorado Boulder
|
||||
|
||||
- (CU Research Computing) CURC
|
||||
- [JupyterHub User Guide](https://www.rc.colorado.edu/support/user-guide/jupyterhub.html)
|
||||
- Slurm job dispatched on Crestone compute cluster
|
||||
- log troubleshooting
|
||||
- Profiles in IPython Clusters tab
|
||||
- [Parallel Processing with JupyterHub tutorial](https://www.rc.colorado.edu/support/examples-and-tutorials/parallel-processing-with-jupyterhub.html)
|
||||
- [Parallel Programming with JupyterHub document](https://www.rc.colorado.edu/book/export/html/833)
|
||||
|
||||
- Earth Lab at CU
|
||||
- [Tutorial on Parallel R on JupyterHub](https://earthdatascience.org/tutorials/parallel-r-on-jupyterhub/)
|
||||
|
||||
### HTCondor
|
||||
|
||||
- [HTCondor Python Bindings Tutorial from HTCondor Week 2017 includes information on their JupyterHub tutorials](https://research.cs.wisc.edu/htcondor/HTCondorWeek2017/presentations/TueBockelman_Python.pdf)
|
||||
|
||||
### University of Illinois
|
||||
|
||||
- https://datascience.business.illinois.edu
|
||||
|
||||
### MIT and Lincoln Labs
|
||||
|
||||
|
||||
### Michigan State University
|
||||
|
||||
- [Setting up JupyterHub](https://mediaspace.msu.edu/media/Setting+Up+Your+JupyterHub+Password/1_hgv13aag/11980471)
|
||||
|
||||
### University of Minnesota
|
||||
|
||||
- [JupyterHub Inside HPC](https://insidehpc.com/tag/jupyterhub/)
|
||||
|
||||
### University of Missouri
|
||||
|
||||
- https://dsa.missouri.edu/faq/
|
||||
|
||||
### University of Rochester CIRC
|
||||
|
||||
- [JupyterHub Userguide](https://info.circ.rochester.edu/Web_Applications/JupyterHub.html) - Slurm, beehive
|
||||
|
||||
### University of California San Diego
|
||||
|
||||
- San Diego Supercomputer Center - Andrea Zonca
|
||||
- [Deploy JupyterHub on a Supercomputer with SSH](https://zonca.github.io/2017/05/jupyterhub-hpc-batchspawner-ssh.html)
|
||||
- [Run Jupyterhub on a Supercomputer](https://zonca.github.io/2015/04/jupyterhub-hpc.html)
|
||||
- [Deploy JupyterHub on a VM for a Workshop](https://zonca.github.io/2016/04/jupyterhub-sdsc-cloud.html)
|
||||
- [Customize your Python environment in Jupyterhub](https://zonca.github.io/2017/02/customize-python-environment-jupyterhub.html)
|
||||
- [Jupyterhub deployment on multiple nodes with Docker Swarm](https://zonca.github.io/2016/05/jupyterhub-docker-swarm.html)
|
||||
- [Sample deployment of Jupyterhub in HPC on SDSC Comet](https://zonca.github.io/2017/02/sample-deployment-jupyterhub-hpc.html)
|
||||
|
||||
- Educational Technology Services - Paul Jamason
|
||||
- [jupyterhub.ucsd.edu](https://jupyterhub.ucsd.edu)
|
||||
|
||||
### TACC University of Texas
|
||||
|
||||
### Texas A&M
|
||||
|
||||
- Kristen Thyng - Oceanography
|
||||
- [Teaching with JupyterHub and nbgrader](http://kristenthyng.com/blog/2016/09/07/jupyterhub+nbgrader/)
|
||||
|
||||
|
||||
|
||||
## Service Providers
|
||||
|
||||
### AWS
|
||||
|
||||
- [running-jupyter-notebook-and-jupyterhub-on-amazon-emr](https://aws.amazon.com/blogs/big-data/running-jupyter-notebook-and-jupyterhub-on-amazon-emr/)
|
||||
|
||||
### Google Cloud Platform
|
||||
|
||||
- [Using Tensorflow and JupyterHub in Classrooms](https://cloud.google.com/solutions/using-tensorflow-jupyterhub-classrooms)
|
||||
- [using-tensorflow-and-jupyterhub blog post](https://opensource.googleblog.com/2016/10/using-tensorflow-and-jupyterhub.html)
|
||||
|
||||
### Everware
|
||||
|
||||
[Everware](https://github.com/everware) Reproducible and reusable science powered by jupyterhub and docker. Like nbviewer, but executable. CERN, Geneva [website](http://everware.xyz/)
|
||||
|
||||
|
||||
### Microsoft Azure
|
||||
|
||||
- https://docs.microsoft.com/en-us/azure/machine-learning/machine-learning-data-science-linux-dsvm-intro
|
||||
|
||||
### Rackspace Carina
|
||||
|
||||
- https://getcarina.com/blog/learning-how-to-whale/
|
||||
- http://carolynvanslyck.com/talk/carina/jupyterhub/#/
|
||||
|
||||
### Red Hat
|
||||
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
- https://medium.com/@ybarraud/setting-up-jupyterhub-with-sudospawner-and-anaconda-844628c0dbee#.rm3yt87e1
|
||||
- https://groups.google.com/forum/#!topic/jupyter/nkPSEeMr8c0 Mailing list UT deployment
|
||||
- JupyterHub setup on Centos https://gist.github.com/johnrc/604971f7d41ebf12370bf5729bf3e0a4
|
||||
- Deploy JupyterHub to Docker Swarm https://jupyterhub.surge.sh/#/welcome
|
||||
- http://www.laketide.com/building-your-lab-part-3/
|
||||
- http://estrellita.hatenablog.com/entry/2015/07/31/083202
|
||||
- http://www.walkingrandomly.com/?p=5734
|
||||
- https://wrdrd.com/docs/consulting/education-technology
|
||||
- https://bitbucket.org/jackhale/fenics-jupyter
|
||||
- [LinuxCluster blog](https://linuxcluster.wordpress.com/category/application/jupyterhub/)
|
||||
- [Network Technology](https://arnesund.com/tag/jupyterhub/) [Spark Cluster on OpenStack with Multi-User Jupyter Notebook](https://arnesund.com/2015/09/21/spark-cluster-on-openstack-with-multi-user-jupyter-notebook/)
|
@@ -1,526 +0,0 @@
|
||||
# Getting started with JupyterHub
|
||||
|
||||
This section contains getting started information on the following topics:
|
||||
|
||||
- [Technical Overview](getting-started.html#technical-overview)
|
||||
- [Installation](getting-started.html#installation)
|
||||
- [Configuration](getting-started.html#configuration)
|
||||
- [Networking](getting-started.html#networking)
|
||||
- [Security](getting-started.html#security)
|
||||
- [Authentication and users](getting-started.html#authentication-and-users)
|
||||
- [Spawners and single-user notebook servers](getting-started.html#spawners-and-single-user-notebook-servers)
|
||||
- [External Services](getting-started.html#external-services)
|
||||
|
||||
|
||||
## Technical Overview
|
||||
|
||||
JupyterHub is a set of processes that together provide a single user Jupyter
|
||||
Notebook server for each person in a group.
|
||||
|
||||
### Three subsystems
|
||||
Three major subsystems run by the `jupyterhub` command line program:
|
||||
|
||||
- **Single-User Notebook Server**: a dedicated, single-user, Jupyter Notebook server is
|
||||
started for each user on the system when the user logs in. The object that
|
||||
starts these servers is called a **Spawner**.
|
||||
- **Proxy**: the public facing part of JupyterHub that uses a dynamic proxy
|
||||
to route HTTP requests to the Hub and Single User Notebook Servers.
|
||||
- **Hub**: manages user accounts, authentication, and coordinates Single User
|
||||
Notebook Servers using a Spawner.
|
||||
|
||||

|
||||
|
||||
|
||||
### Deployment server
|
||||
To use JupyterHub, you need a Unix server (typically Linux) running somewhere
|
||||
that is accessible to your team on the network. The JupyterHub server can be
|
||||
on an internal network at your organization, or it can run on the public
|
||||
internet (in which case, take care with the Hub's
|
||||
[security](getting-started.html#security)).
|
||||
|
||||
### Basic operation
|
||||
Users access JupyterHub through a web browser, by going to the IP address or
|
||||
the domain name of the server.
|
||||
|
||||
Basic principles of operation:
|
||||
|
||||
* Hub spawns proxy
|
||||
* Proxy forwards all requests to hub by default
|
||||
* Hub handles login, and spawns single-user servers on demand
|
||||
* Hub configures proxy to forward url prefixes to single-user servers
|
||||
|
||||
Different **[authenticators](authenticators.html)** control access
|
||||
to JupyterHub. The default one (PAM) uses the user accounts on the server where
|
||||
JupyterHub is running. If you use this, you will need to create a user account
|
||||
on the system for each user on your team. Using other authenticators, you can
|
||||
allow users to sign in with e.g. a GitHub account, or with any single-sign-on
|
||||
system your organization has.
|
||||
|
||||
Next, **[spawners](spawners.html)** control how JupyterHub starts
|
||||
the individual notebook server for each user. The default spawner will
|
||||
start a notebook server on the same machine running under their system username.
|
||||
The other main option is to start each server in a separate container, often
|
||||
using Docker.
|
||||
|
||||
### Default behavior
|
||||
|
||||
**IMPORTANT: You should not run JupyterHub without SSL encryption on a public network.**
|
||||
|
||||
See [Security documentation](#security) for how to configure JupyterHub to use SSL,
|
||||
or put it behind SSL termination in another proxy server, such as nginx.
|
||||
|
||||
---
|
||||
|
||||
**Deprecation note:** Removed `--no-ssl` in version 0.7.
|
||||
|
||||
JupyterHub versions 0.5 and 0.6 require extra confirmation via `--no-ssl` to
|
||||
allow running without SSL using the command `jupyterhub --no-ssl`. The
|
||||
`--no-ssl` command line option is not needed anymore in version 0.7.
|
||||
|
||||
---
|
||||
|
||||
To start JupyterHub in its default configuration, type the following at the command line:
|
||||
|
||||
```bash
|
||||
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 either:
|
||||
|
||||
- `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.
|
||||
|
||||
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. It is
|
||||
important to note that this database contains *no* sensitive information other than **Hub**
|
||||
usernames.
|
||||
- `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 in the [Cookie Secret documentation](#cookie-secret).
|
||||
|
||||
The location of these files can be specified via configuration, discussed below.
|
||||
|
||||
## Installation
|
||||
|
||||
See the project's [README](https://github.com/jupyterhub/jupyterhub/blob/master/README.md)
|
||||
for help installing JupyterHub.
|
||||
|
||||
### Planning your installation
|
||||
|
||||
Prior to beginning installation, it's helpful to consider some of the following:
|
||||
- deployment system (bare metal, Docker)
|
||||
- Authentication (PAM, OAuth, etc.)
|
||||
- Spawner of singleuser notebook servers (Docker, Batch, etc.)
|
||||
- Services (nbgrader, etc.)
|
||||
- JupyterHub database (default SQLite; traditional RDBMS such as PostgreSQL,)
|
||||
MySQL, or other databases supported by [SQLAlchemy](http://www.sqlalchemy.org))
|
||||
|
||||
### Folders and 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
|
||||
|
||||
## Configuration
|
||||
|
||||
JupyterHub is configured in two ways:
|
||||
|
||||
1. Configuration file
|
||||
2. Command-line arguments
|
||||
|
||||
### Configuration file
|
||||
By default, JupyterHub will look for a configuration file (which may not be created yet)
|
||||
named `jupyterhub_config.py` in the current working directory.
|
||||
You can create an empty configuration file with:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
### Command-line arguments
|
||||
Type the following for brief information about the command-line arguments:
|
||||
|
||||
```bash
|
||||
jupyterhub -h
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```bash
|
||||
jupyterhub --help-all
|
||||
```
|
||||
|
||||
for the full command line help.
|
||||
|
||||
All configurable options are technically configurable on the command-line,
|
||||
even if some are really inconvenient to type. Just replace the desired option,
|
||||
`c.Class.trait`, with `--Class.trait`. For example, to configure the
|
||||
`c.Spawner.notebook_dir` trait from the command-line:
|
||||
|
||||
```bash
|
||||
jupyterhub --Spawner.notebook_dir='~/assignments'
|
||||
```
|
||||
|
||||
## Networking
|
||||
|
||||
### Configuring the Proxy's IP address and port
|
||||
The Proxy's main IP address setting determines where JupyterHub is available to users.
|
||||
By default, JupyterHub is configured to be available on all network interfaces
|
||||
(`''`) on port 8000. **Note**: Use of `'*'` is discouraged for IP configuration;
|
||||
instead, use of `'0.0.0.0'` is preferred.
|
||||
|
||||
Changing the IP address and port can be done with the following command line
|
||||
arguments:
|
||||
|
||||
```bash
|
||||
jupyterhub --ip=192.168.1.2 --port=443
|
||||
```
|
||||
|
||||
Or by placing the following lines in a configuration file:
|
||||
|
||||
```python
|
||||
c.JupyterHub.ip = '192.168.1.2'
|
||||
c.JupyterHub.port = 443
|
||||
```
|
||||
|
||||
Port 443 is used as an example since 443 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, more customized scenarios may need additional networking details to
|
||||
be configured.
|
||||
|
||||
|
||||
### Configuring the Proxy's REST API communication IP address and port (optional)
|
||||
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 running the Proxy separate from the Hub,
|
||||
configure the REST API communication IP address and port with:
|
||||
|
||||
```python
|
||||
# ideally a private network address
|
||||
c.JupyterHub.proxy_api_ip = '10.0.1.4'
|
||||
c.JupyterHub.proxy_api_port = 5432
|
||||
```
|
||||
|
||||
### Configuring the Hub if Spawners or Proxy are remote or isolated in containers
|
||||
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, an IP address setting of localhost is fine.
|
||||
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
|
||||
|
||||
**IMPORTANT: You should not run JupyterHub without SSL encryption on a public network.**
|
||||
|
||||
---
|
||||
|
||||
**Deprecation note:** Removed `--no-ssl` in version 0.7.
|
||||
|
||||
JupyterHub versions 0.5 and 0.6 require extra confirmation via `--no-ssl` to
|
||||
allow running without SSL using the command `jupyterhub --no-ssl`. The
|
||||
`--no-ssl` command line option is not needed anymore in version 0.7.
|
||||
|
||||
---
|
||||
|
||||
Security is the most important aspect of configuring Jupyter. There are four main aspects of the
|
||||
security configuration:
|
||||
|
||||
1. SSL encryption (to enable HTTPS)
|
||||
2. Cookie secret (a key for encrypting browser cookies)
|
||||
3. Proxy authentication token (used for the Hub and other services to authenticate to the Proxy)
|
||||
4. Periodic security audits
|
||||
|
||||
*Note* that the **Hub** hashes all secrets (e.g., auth tokens) before storing them in its
|
||||
database. A loss of control over read-access to the database should have no security impact
|
||||
on your deployment.
|
||||
|
||||
### SSL encryption
|
||||
|
||||
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, trusted SSL certificate or
|
||||
create a self-signed certificate. Once you have obtained and installed a key and certificate you
|
||||
need to specify their locations in the configuration file as follows:
|
||||
|
||||
```python
|
||||
c.JupyterHub.ssl_key = '/path/to/my.key'
|
||||
c.JupyterHub.ssl_cert = '/path/to/my.cert'
|
||||
```
|
||||
|
||||
It is also possible to use letsencrypt (https://letsencrypt.org/) to obtain
|
||||
a free, trusted SSL certificate. If you run letsencrypt using the default
|
||||
options, the needed configuration is (replace `mydomain.tld` by your fully
|
||||
qualified domain name):
|
||||
|
||||
```python
|
||||
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/{mydomain.tld}/privkey.pem'
|
||||
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/{mydomain.tld}/fullchain.pem'
|
||||
```
|
||||
|
||||
If the fully qualified domain name (FQDN) is `example.com`, the following
|
||||
would be the needed configuration:
|
||||
|
||||
```python
|
||||
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/example.com/privkey.pem'
|
||||
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/example.com/fullchain.pem'
|
||||
```
|
||||
|
||||
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, where they are not readable by regular
|
||||
users.
|
||||
|
||||
Note on **chain certificates**: If you are using a chain certificate, see also
|
||||
[chained certificate for SSL](troubleshooting.md#chained-certificates-for-ssl) in the JupyterHub troubleshooting FAQ).
|
||||
|
||||
Note: In certain cases, e.g. **behind SSL termination in nginx**, allowing no SSL
|
||||
running on the hub may be desired.
|
||||
|
||||
### Cookie secret
|
||||
|
||||
The cookie secret is an encryption key, used to encrypt the browser 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 a file, the location of which can be specified in a config file
|
||||
as follows:
|
||||
|
||||
```python
|
||||
c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/cookie_secret'
|
||||
```
|
||||
|
||||
The content of this file should be a long random string encoded in MIME Base64. An example would be to generate this file as:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 2048 > /srv/jupyterhub/cookie_secret
|
||||
```
|
||||
|
||||
In most deployments of JupyterHub, you should point this to a secure location on the file
|
||||
system, such as `/srv/jupyterhub/cookie_secret`. If the cookie secret file doesn't exist when
|
||||
the Hub starts, a new cookie secret is generated and stored in the file. The
|
||||
file must not be readable by group or other or the server won't start.
|
||||
The recommended permissions for the cookie secret file are 600 (owner-only rw).
|
||||
|
||||
|
||||
If you would like to avoid the need for files, the value can be loaded in the Hub process from
|
||||
the `JPY_COOKIE_SECRET` environment variable, which is a hex-encoded string. You
|
||||
can set it this way:
|
||||
|
||||
```bash
|
||||
export JPY_COOKIE_SECRET=`openssl rand -hex 1024`
|
||||
```
|
||||
|
||||
For security reasons, this environment variable should only be visible to the Hub.
|
||||
If you set it dynamically as above, all users will be logged out each time the
|
||||
Hub starts.
|
||||
|
||||
You can also set the cookie secret in the configuration file itself,`jupyterhub_config.py`,
|
||||
as a binary string:
|
||||
|
||||
```python
|
||||
c.JupyterHub.cookie_secret = bytes.fromhex('VERY LONG SECRET HEX STRING')
|
||||
```
|
||||
|
||||
### Proxy authentication token
|
||||
|
||||
The Hub authenticates its requests to the Proxy using a secret token that
|
||||
the Hub and Proxy agree upon. The value of this string should be a random
|
||||
string (for example, generated by `openssl rand -hex 32`). You can pass
|
||||
this value to the Hub and Proxy using either the `CONFIGPROXY_AUTH_TOKEN`
|
||||
environment variable:
|
||||
|
||||
```bash
|
||||
export CONFIGPROXY_AUTH_TOKEN=`openssl rand -hex 32`
|
||||
```
|
||||
|
||||
This environment variable needs to be visible to the Hub and Proxy.
|
||||
|
||||
Or you can set the value in the configuration file, `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5'
|
||||
```
|
||||
|
||||
If you don't set the Proxy authentication token, 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).
|
||||
|
||||
Another time you must set the Proxy authentication token yourself is if
|
||||
you want other services, such as [nbgrader](https://github.com/jupyter/nbgrader)
|
||||
to also be able to connect to the Proxy.
|
||||
|
||||
### Security audits
|
||||
|
||||
We recommend that you do periodic reviews of your deployment's security. It's
|
||||
good practice to keep JupyterHub, configurable-http-proxy, and nodejs
|
||||
versions up to date.
|
||||
|
||||
A handy website for testing your deployment is
|
||||
[Qualsys' SSL analyzer tool](https://www.ssllabs.com/ssltest/analyze.html).
|
||||
|
||||
## Authentication and users
|
||||
|
||||
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.
|
||||
|
||||
### Creating a whitelist of users
|
||||
|
||||
You can restrict which users are allowed to login with `Authenticator.whitelist`:
|
||||
|
||||
|
||||
```python
|
||||
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||
```
|
||||
|
||||
### Managing Hub administrators
|
||||
|
||||
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.**
|
||||
|
||||
Note: additional configuration examples are provided in this guide's
|
||||
[Configuration Examples section](./config-examples.html).
|
||||
|
||||
### Add or remove users from the Hub
|
||||
|
||||
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 `adduser` command line tool. 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.
|
||||
|
||||
## Spawners and single-user notebook servers
|
||||
|
||||
Since the single-user server is an instance of `jupyter 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 by external services like the
|
||||
[cull_idle_servers](https://github.com/jupyterhub/jupyterhub/blob/master/examples/cull-idle/cull_idle_servers.py)
|
||||
script which monitors and kills idle single-user servers periodically. In order to run such an
|
||||
external service, you need to provide it an API token. In the case of `cull_idle_servers`, it is passed
|
||||
as the environment variable called `JPY_API_TOKEN`.
|
||||
|
||||
Currently there are two ways of registering that token with JupyterHub. The first one is to use
|
||||
the `jupyterhub` command to generate a token for a specific hub user:
|
||||
|
||||
```bash
|
||||
jupyterhub token <username>
|
||||
```
|
||||
|
||||
As of [version 0.6.0](./changelog.html), the preferred way of doing this is to first generate an API token:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
|
||||
and then write it to your JupyterHub configuration file (note that the **key** is the token while the **value** is the username):
|
||||
|
||||
```python
|
||||
c.JupyterHub.api_tokens = {'token' : 'username'}
|
||||
```
|
||||
|
||||
Upon restarting JupyterHub, you should see a message like below in the logs:
|
||||
|
||||
```
|
||||
Adding API token for <username>
|
||||
```
|
||||
|
||||
Now you can run your script, i.e. `cull_idle_servers`, by providing it the API token and it will authenticate through
|
||||
the REST API to interact with it.
|
||||
|
||||
|
||||
[oauth-setup]: https://github.com/jupyterhub/oauthenticator#setup
|
||||
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
99
docs/source/getting-started/authenticators-users-basics.md
Normal file
99
docs/source/getting-started/authenticators-users-basics.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Authentication and User Basics
|
||||
|
||||
The default Authenticator uses [PAM][] to authenticate system users with
|
||||
their username and password. With the default Authenticator, any user
|
||||
with an account and password on the system will be allowed to login.
|
||||
|
||||
## Create a whitelist of users
|
||||
|
||||
You can restrict which users are allowed to login with a whitelist,
|
||||
`Authenticator.whitelist`:
|
||||
|
||||
|
||||
```python
|
||||
c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'}
|
||||
```
|
||||
|
||||
Users in the whitelist are added to the Hub database when the Hub is
|
||||
started.
|
||||
|
||||
## Configure admins (`admin_users`)
|
||||
|
||||
Admin users of JupyterHub, `admin_users`, can add and remove users from
|
||||
the user `whitelist`. `admin_users` can take actions on other users'
|
||||
behalf, such as stopping and restarting their servers.
|
||||
|
||||
A set of initial admin users, `admin_users` can configured be as follows:
|
||||
|
||||
```python
|
||||
c.Authenticator.admin_users = {'mal', 'zoe'}
|
||||
```
|
||||
Users in the admin list are automatically added to the user `whitelist`,
|
||||
if they are not already present.
|
||||
|
||||
## Give admin access to other users' notebook servers (`admin_access`)
|
||||
|
||||
Since the default `JupyterHub.admin_access` setting is False, the admins
|
||||
do not have permission to log in to the single user notebook servers
|
||||
owned by *other users*. If `JupyterHub.admin_access` is set to True,
|
||||
then admins have permission to log in *as other users* on their
|
||||
respective machines, for debugging. **As a courtesy, you should make
|
||||
sure your users know if admin_access is enabled.**
|
||||
|
||||
## Add or remove users from the Hub
|
||||
|
||||
Users can be added to and removed from the Hub via either the admin
|
||||
panel or the REST API. When a user is **added**, the user will be
|
||||
automatically 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.
|
||||
|
||||
After starting the Hub once, it is not sufficient to **remove** a user
|
||||
from the whitelist in your config file. You must also remove the user
|
||||
from the Hub's database, either by deleting the user from JupyterHub's
|
||||
admin page, or you can clear the `jupyterhub.sqlite` database and start
|
||||
fresh.
|
||||
|
||||
## Use LocalAuthenticator to create system users
|
||||
|
||||
The `LocalAuthenticator` is a special kind of authenticator that has
|
||||
the ability to manage users on the local system. When you try to add a
|
||||
new user to the Hub, a `LocalAuthenticator` will check if the user
|
||||
already exists. If you set the configuration value, `create_system_users`,
|
||||
to `True` in the configuration file, the `LocalAuthenticator` has
|
||||
the privileges to add users to the system. The setting in the config
|
||||
file is:
|
||||
|
||||
```python
|
||||
c.LocalAuthenticator.create_system_users = True
|
||||
```
|
||||
|
||||
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 `adduser` command
|
||||
line tool. This option is typically used on hosted deployments of
|
||||
JupyterHub, to avoid the need to manually create all your users before
|
||||
launching the service. This approach is not recommended when running
|
||||
JupyterHub in situations where JupyterHub users map directly onto the
|
||||
system's UNIX users.
|
||||
|
||||
## Use OAuthenticator to support OAuth with popular service providers
|
||||
|
||||
JupyterHub's [OAuthenticator][] currently supports the following
|
||||
popular services:
|
||||
|
||||
- Auth0
|
||||
- Bitbucket
|
||||
- CILogon
|
||||
- GitHub
|
||||
- GitLab
|
||||
- Globus
|
||||
- Google
|
||||
- MediaWiki
|
||||
- Okpy
|
||||
- OpenShift
|
||||
|
||||
A generic implementation, which you can use for OAuth authentication
|
||||
with any provider, is also available.
|
||||
|
||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||
[OAuthenticator]: https://github.com/jupyterhub/oauthenticator
|
87
docs/source/getting-started/config-basics.md
Normal file
87
docs/source/getting-started/config-basics.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Configuration Basics
|
||||
|
||||
The section contains basic information about configuring settings for a JupyterHub
|
||||
deployment. The [Technical Reference](../reference/index.html)
|
||||
documentation provides additional details.
|
||||
|
||||
This section will help you learn how to:
|
||||
|
||||
- generate a default configuration file, `jupyterhub_config.py`
|
||||
- start with a specific configuration file
|
||||
- configure JupyterHub using command line options
|
||||
- find information and examples for some common deployments
|
||||
|
||||
## Generate a default config file
|
||||
|
||||
On startup, JupyterHub will look by default for a configuration file,
|
||||
`jupyterhub_config.py`, in the current working directory.
|
||||
|
||||
To generate a default config file, `jupyterhub_config.py`:
|
||||
|
||||
```bash
|
||||
jupyterhub --generate-config
|
||||
```
|
||||
|
||||
This default `jupyterhub_config.py` file contains comments and guidance for all
|
||||
configuration variables and their default values. We recommend storing
|
||||
configuration files in the standard UNIX filesystem location, i.e.
|
||||
`/etc/jupyterhub`.
|
||||
|
||||
## Start with a specific config file
|
||||
|
||||
You can load a specific config file and start JupyterHub using:
|
||||
|
||||
```bash
|
||||
jupyterhub -f /path/to/jupyterhub_config.py
|
||||
```
|
||||
|
||||
If you have stored your configuration file in the recommended UNIX filesystem
|
||||
location, `/etc/jupyterhub`, the following command will start JupyterHub using
|
||||
the configuration file:
|
||||
|
||||
```bash
|
||||
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py
|
||||
```
|
||||
|
||||
The IPython documentation provides additional information on the
|
||||
[config system](http://ipython.readthedocs.io/en/stable/development/config.html)
|
||||
that Jupyter uses.
|
||||
|
||||
## Configure using command line options
|
||||
|
||||
To display all command line options that are available for configuration:
|
||||
|
||||
```bash
|
||||
jupyterhub --help-all
|
||||
```
|
||||
|
||||
Configuration using the command line options is done when launching JupyterHub.
|
||||
For example, to start JupyterHub on ``10.0.1.2:443`` with https, you
|
||||
would enter:
|
||||
|
||||
```bash
|
||||
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
||||
```
|
||||
|
||||
All configurable options may technically be set on the command-line,
|
||||
though some are inconvenient to type. To set a particular configuration
|
||||
parameter, `c.Class.trait`, you would use the command line option,
|
||||
`--Class.trait`, when starting JupyterHub. For example, to configure the
|
||||
`c.Spawner.notebook_dir` trait from the command-line, use the
|
||||
`--Spawner.notebook_dir` option:
|
||||
|
||||
```bash
|
||||
jupyterhub --Spawner.notebook_dir='~/assignments'
|
||||
```
|
||||
|
||||
## Configure for various deployment environments
|
||||
|
||||
The default authentication and process spawning mechanisms can be replaced, and
|
||||
specific [authenticators](./authenticators-users-basics.html) and
|
||||
[spawners](./spawners-basics.html) can be set in the configuration file.
|
||||
This enables JupyterHub to be used with a variety of authentication methods or
|
||||
process control and deployment environments. [Some examples](../reference/config-examples.html),
|
||||
meant as illustration, are:
|
||||
|
||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyterhub/oauthenticator)
|
||||
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyterhub/dockerspawner)
|
12
docs/source/getting-started/index.rst
Normal file
12
docs/source/getting-started/index.rst
Normal file
@@ -0,0 +1,12 @@
|
||||
Getting Started
|
||||
===============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
config-basics
|
||||
networking-basics
|
||||
security-basics
|
||||
authenticators-users-basics
|
||||
spawners-basics
|
||||
services-basics
|
88
docs/source/getting-started/networking-basics.md
Normal file
88
docs/source/getting-started/networking-basics.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Networking basics
|
||||
|
||||
This section will help you with basic proxy and network configuration to:
|
||||
|
||||
- set the proxy's IP address and port
|
||||
- set the proxy's REST API URL
|
||||
- configure the Hub if the Proxy or Spawners are remote or isolated
|
||||
- set the `hub_connect_ip` which services will use to communicate with the hub
|
||||
|
||||
## Set the Proxy's IP address and port
|
||||
|
||||
The Proxy's main IP address setting determines where JupyterHub is available to users.
|
||||
By default, JupyterHub is configured to be available on all network interfaces
|
||||
(`''`) on port 8000. *Note*: Use of `'*'` is discouraged for IP configuration;
|
||||
instead, use of `'0.0.0.0'` is preferred.
|
||||
|
||||
Changing the Proxy's main IP address and port can be done with the following
|
||||
JupyterHub **command line options**:
|
||||
|
||||
```bash
|
||||
jupyterhub --ip=192.168.1.2 --port=443
|
||||
```
|
||||
|
||||
Or by placing the following lines in a **configuration file**,
|
||||
`jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
c.JupyterHub.ip = '192.168.1.2'
|
||||
c.JupyterHub.port = 443
|
||||
```
|
||||
|
||||
Port 443 is used in the examples since 443 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, more customized scenarios may need
|
||||
additional networking details to be configured.
|
||||
|
||||
## Set the Proxy's REST API communication URL (optional)
|
||||
|
||||
By default, this REST API listens on port 8081 of `localhost` only.
|
||||
The Hub service talks to the proxy via a REST API on a secondary port. The
|
||||
API URL can be configured separately and override the default settings.
|
||||
|
||||
### Set api_url
|
||||
|
||||
The URL to access the API, `c.configurableHTTPProxy.api_url`, is configurable.
|
||||
An example entry to set the proxy's API URL in `jupyterhub_config.py` is:
|
||||
|
||||
```python
|
||||
c.ConfigurableHTTPProxy.api_url = 'http://10.0.1.4:5432'
|
||||
```
|
||||
|
||||
### proxy_api_ip and proxy_api_port (Deprecated in 0.8)
|
||||
|
||||
If running the Proxy separate from the Hub, configure the REST API communication
|
||||
IP address and port by adding this to the `jupyterhub_config.py` file:
|
||||
|
||||
```python
|
||||
# ideally a private network address
|
||||
c.JupyterHub.proxy_api_ip = '10.0.1.4'
|
||||
c.JupyterHub.proxy_api_port = 5432
|
||||
```
|
||||
|
||||
We recommend using the proxy's `api_url` setting instead of the deprecated
|
||||
settings, `proxy_api_ip` and `proxy_api_port`.
|
||||
|
||||
## Configure the Hub if the Proxy or Spawners are remote or isolated
|
||||
|
||||
The Hub service listens only on `localhost` (port 8081) by default.
|
||||
The Hub needs to be accessible from both the proxy and all Spawners.
|
||||
When spawning local servers, an IP address setting of `localhost` is fine.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
**Added in 0.8:** The `c.JupyterHub.hub_connect_ip` setting is the ip address or
|
||||
hostname that other services should use to connect to the Hub. A common
|
||||
configuration for, e.g. docker, is:
|
||||
|
||||
```python
|
||||
c.JupyterHub.hub_ip = '0.0.0.0' # listen on all interfaces
|
||||
c.JupyterHub.hub_connect_ip = '10.0.1.4' # ip as seen on the docker network. Can also be a hostname.
|
||||
```
|
181
docs/source/getting-started/security-basics.rst
Normal file
181
docs/source/getting-started/security-basics.rst
Normal file
@@ -0,0 +1,181 @@
|
||||
Security settings
|
||||
=================
|
||||
|
||||
.. important::
|
||||
|
||||
You should not run JupyterHub without SSL encryption on a public network.
|
||||
|
||||
Security is the most important aspect of configuring Jupyter. Three
|
||||
configuration settings are the main aspects of security configuration:
|
||||
|
||||
1. :ref:`SSL encryption <ssl-encryption>` (to enable HTTPS)
|
||||
2. :ref:`Cookie secret <cookie-secret>` (a key for encrypting browser cookies)
|
||||
3. Proxy :ref:`authentication token <authentication-token>` (used for the Hub and
|
||||
other services to authenticate to the Proxy)
|
||||
|
||||
The Hub hashes all secrets (e.g., auth tokens) before storing them in its
|
||||
database. A loss of control over read-access to the database should have
|
||||
minimal impact on your deployment; if your database has been compromised, it
|
||||
is still a good idea to revoke existing tokens.
|
||||
|
||||
.. _ssl-encryption:
|
||||
|
||||
Enabling SSL encryption
|
||||
-----------------------
|
||||
|
||||
Since JupyterHub includes authentication and allows arbitrary code execution,
|
||||
you should not run it without SSL (HTTPS).
|
||||
|
||||
Using an SSL certificate
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This will require you to obtain an official, trusted SSL certificate or create a
|
||||
self-signed certificate. Once you have obtained and installed a key and
|
||||
certificate you need to specify their locations in the ``jupyterhub_config.py``
|
||||
configuration file as follows:
|
||||
|
||||
.. code-block:: 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, where
|
||||
they are not readable by regular users.
|
||||
|
||||
If you are using a **chain certificate**, see also chained certificate for SSL
|
||||
in the JupyterHub `troubleshooting FAQ <troubleshooting>`_.
|
||||
|
||||
Using letsencrypt
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
It is also possible to use `letsencrypt <https://letsencrypt.org/>`_ to obtain
|
||||
a free, trusted SSL certificate. If you run letsencrypt using the default
|
||||
options, the needed configuration is (replace ``mydomain.tld`` by your fully
|
||||
qualified domain name):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/{mydomain.tld}/privkey.pem'
|
||||
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/{mydomain.tld}/fullchain.pem'
|
||||
|
||||
If the fully qualified domain name (FQDN) is ``example.com``, the following
|
||||
would be the needed configuration:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
c.JupyterHub.ssl_key = '/etc/letsencrypt/live/example.com/privkey.pem'
|
||||
c.JupyterHub.ssl_cert = '/etc/letsencrypt/live/example.com/fullchain.pem'
|
||||
|
||||
|
||||
If SSL termination happens outside of the Hub
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
In certain cases, e.g. behind `SSL termination in NGINX <https://www.nginx.com/resources/admin-guide/nginx-ssl-termination/>`_,
|
||||
allowing no SSL running on the hub may be the desired configuration option.
|
||||
|
||||
.. _cookie-secret:
|
||||
|
||||
Cookie secret
|
||||
-------------
|
||||
|
||||
The cookie secret is an encryption key, used to encrypt the browser cookies
|
||||
which are used for authentication. Three common methods are described for
|
||||
generating and configuring the cookie secret.
|
||||
|
||||
Generating and storing as a cookie secret file
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The cookie secret should be 32 random bytes, encoded as hex, and is typically
|
||||
stored in a ``jupyterhub_cookie_secret`` file. An example command to generate the
|
||||
``jupyterhub_cookie_secret`` file is:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
openssl rand -hex 32 > /srv/jupyterhub/jupyterhub_cookie_secret
|
||||
|
||||
In most deployments of JupyterHub, you should point this to a secure location on
|
||||
the file system, such as ``/srv/jupyterhub/jupyterhub_cookie_secret``.
|
||||
|
||||
The location of the ``jupyterhub_cookie_secret`` file can be specified in the
|
||||
``jupyterhub_config.py`` file as follows:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
c.JupyterHub.cookie_secret_file = '/srv/jupyterhub/jupyterhub_cookie_secret'
|
||||
|
||||
If the cookie secret file doesn't exist when the Hub starts, a new cookie
|
||||
secret is generated and stored in the file. The file must not be readable by
|
||||
``group`` or ``other`` or the server won't start. The recommended permissions
|
||||
for the cookie secret file are ``600`` (owner-only rw).
|
||||
|
||||
Generating and storing as an environment variable
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
If you would like to avoid the need for files, the value can be loaded in the
|
||||
Hub process from the ``JPY_COOKIE_SECRET`` environment variable, which is a
|
||||
hex-encoded string. You can set it this way:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
export JPY_COOKIE_SECRET=`openssl rand -hex 32`
|
||||
|
||||
For security reasons, this environment variable should only be visible to the
|
||||
Hub. If you set it dynamically as above, all users will be logged out each time
|
||||
the Hub starts.
|
||||
|
||||
Generating and storing as a binary string
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
You can also set the cookie secret in the configuration file
|
||||
itself, ``jupyterhub_config.py``, as a binary string:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
c.JupyterHub.cookie_secret = bytes.fromhex('64 CHAR HEX STRING')
|
||||
|
||||
|
||||
.. important::
|
||||
|
||||
If the cookie secret value changes for the Hub, all single-user notebook
|
||||
servers must also be restarted.
|
||||
|
||||
|
||||
.. _authentication-token:
|
||||
|
||||
Proxy authentication token
|
||||
--------------------------
|
||||
|
||||
The Hub authenticates its requests to the Proxy using a secret token that
|
||||
the Hub and Proxy agree upon. The value of this string should be a random
|
||||
string (for example, generated by ``openssl rand -hex 32``).
|
||||
|
||||
Generating and storing token in the configuration file
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Or you can set the value in the configuration file, ``jupyterhub_config.py``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
c.JupyterHub.proxy_auth_token = '0bc02bede919e99a26de1e2a7a5aadfaf6228de836ec39a05a6c6942831d8fe5'
|
||||
|
||||
Generating and storing as an environment variable
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
You can pass this value of the proxy authentication token to the Hub and Proxy
|
||||
using the ``CONFIGPROXY_AUTH_TOKEN`` environment variable:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
export CONFIGPROXY_AUTH_TOKEN='openssl rand -hex 32'
|
||||
|
||||
This environment variable needs to be visible to the Hub and Proxy.
|
||||
|
||||
Default if token is not set
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
If you don't set the Proxy authentication token, 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).
|
121
docs/source/getting-started/services-basics.md
Normal file
121
docs/source/getting-started/services-basics.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# External services
|
||||
|
||||
When working with JupyterHub, a **Service** is defined as a process
|
||||
that interacts with the Hub's REST API. A Service may perform a specific
|
||||
or action or task. For example, shutting down individuals' single user
|
||||
notebook servers that have been is a good example of a task that could
|
||||
be automated by a Service. Let's look at how the [cull_idle_servers][]
|
||||
script can be used as a Service.
|
||||
|
||||
## Real-world example to cull idle servers
|
||||
|
||||
JupyterHub has a REST API that can be used by external services. This
|
||||
document will:
|
||||
|
||||
- explain some basic information about API tokens
|
||||
- clarify that API tokens can be used to authenticate to
|
||||
single-user servers as of [version 0.8.0](../changelog.html)
|
||||
- show how the [cull_idle_servers][] script can be:
|
||||
- used in a Hub-managed service
|
||||
- run as a standalone script
|
||||
|
||||
Both examples for `cull_idle_servers` will communicate tasks to the
|
||||
Hub via the REST API.
|
||||
|
||||
## API Token basics
|
||||
|
||||
### Create an API token
|
||||
|
||||
To run such an external service, an API token must be created and
|
||||
provided to the service.
|
||||
|
||||
As of [version 0.6.0](../changelog.html), the preferred way of doing
|
||||
this is to first generate an API token:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
In [version 0.8.0](../changelog.html), a TOKEN request page for
|
||||
generating an API token is available from the JupyterHub user interface:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Pass environment variable with token to the Hub
|
||||
|
||||
In the case of `cull_idle_servers`, it is passed as the environment
|
||||
variable called `JUPYTERHUB_API_TOKEN`.
|
||||
|
||||
### Use API tokens for services and tasks that require external access
|
||||
|
||||
While API tokens are often associated with a specific user, API tokens
|
||||
can be used by services that require external access for activities
|
||||
that may not correspond to a specific human, e.g. adding users during
|
||||
setup for a tutorial or workshop. Add a service and its API token to the
|
||||
JupyterHub configuration file, `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
c.JupyterHub.services = [
|
||||
{'name': 'adding-users', 'api_token': 'super-secret-token'},
|
||||
]
|
||||
```
|
||||
|
||||
### Restart JupyterHub
|
||||
|
||||
Upon restarting JupyterHub, you should see a message like below in the
|
||||
logs:
|
||||
|
||||
```
|
||||
Adding API token for <username>
|
||||
```
|
||||
|
||||
## Authenticating to single-user servers using API token
|
||||
|
||||
In JupyterHub 0.7, there is no mechanism for token authentication to
|
||||
single-user servers, and only cookies can be used for authentication.
|
||||
0.8 supports using JupyterHub API tokens to authenticate to single-user
|
||||
servers.
|
||||
|
||||
## Configure `cull-idle` to run as a Hub-Managed Service
|
||||
|
||||
In `jupyterhub_config.py`, add the following dictionary for the
|
||||
`cull-idle` Service to the `c.JupyterHub.services` list:
|
||||
|
||||
```python
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'cull-idle',
|
||||
'admin': True,
|
||||
'command': 'python cull_idle_servers.py --timeout=3600'.split(),
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
where:
|
||||
|
||||
- `'admin': True` indicates that the Service has 'admin' permissions, and
|
||||
- `'command'` indicates that the Service will be launched as a
|
||||
subprocess, managed by the Hub.
|
||||
|
||||
## Run `cull-idle` manually as a standalone script
|
||||
|
||||
Now you can run your script, i.e. `cull_idle_servers`, by providing it
|
||||
the API token and it will authenticate through the REST API to
|
||||
interact with it.
|
||||
|
||||
This will run `cull-idle` manually. `cull-idle` can be run as a standalone
|
||||
script anywhere with access to the Hub, and will periodically check for idle
|
||||
servers and shut them down via the Hub's REST API. In order to shutdown the
|
||||
servers, the token given to cull-idle must have admin privileges.
|
||||
|
||||
Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment
|
||||
variable. Run `cull_idle_servers.py` manually.
|
||||
|
||||
```bash
|
||||
export JUPYTERHUB_API_TOKEN='token'
|
||||
python cull_idle_servers.py [--timeout=900] [--url=http://127.0.0.1:8081/hub/api]
|
||||
```
|
||||
|
||||
[cull_idle_servers]: https://github.com/jupyterhub/jupyterhub/blob/master/examples/cull-idle/cull_idle_servers.py
|
33
docs/source/getting-started/spawners-basics.md
Normal file
33
docs/source/getting-started/spawners-basics.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Spawners and single-user notebook servers
|
||||
|
||||
Since the single-user server is an instance of `jupyter 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 `jupyter_notebook_config.py` config file.
|
||||
Each user may have one of these files in `$HOME/.jupyter/`.
|
||||
Jupyter also supports loading system-wide config files from `/etc/jupyter/`,
|
||||
which is the place to put configuration that you want to affect all of your users.
|
@@ -1,77 +0,0 @@
|
||||
# How JupyterHub works
|
||||
|
||||
JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user Jupyter notebook server.
|
||||
|
||||
There are three basic processes involved:
|
||||
|
||||
- multi-user Hub (Python/Tornado)
|
||||
- [configurable http proxy](https://github.com/jupyterhub/configurable-http-proxy) (node-http-proxy)
|
||||
- multiple single-user IPython notebook servers (Python/IPython/Tornado)
|
||||
|
||||
The proxy is the only process that listens on a public interface.
|
||||
The Hub sits behind the proxy at `/hub`.
|
||||
Single-user servers sit behind the proxy at `/user/[username]`.
|
||||
|
||||
|
||||
## Logging in
|
||||
|
||||
When a new browser logs in to JupyterHub, the following events take place:
|
||||
|
||||
- Login data is handed to the [Authenticator](#authentication) instance for validation
|
||||
- The Authenticator returns the username, if login information is valid
|
||||
- A single-user server instance is [Spawned](#spawning) for the logged-in user
|
||||
- When the server starts, the proxy is notified to forward `/user/[username]/*` to the single-user server
|
||||
- Two cookies are set, one for `/hub/` and another for `/user/[username]`,
|
||||
containing an encrypted token.
|
||||
- The browser is redirected to `/user/[username]`, which is handled by the single-user server
|
||||
|
||||
Logging into a single-user server is authenticated via the Hub:
|
||||
|
||||
- On request, the single-user server forwards the encrypted cookie to the Hub for verification
|
||||
- The Hub replies with the username if it is a valid cookie
|
||||
- If the user is the owner of the server, access is allowed
|
||||
- If it is the wrong user or an invalid cookie, the browser is redirected to `/hub/login`
|
||||
|
||||
|
||||
## Customizing JupyterHub
|
||||
|
||||
There are two basic extension points for JupyterHub: How users are authenticated,
|
||||
and how their server processes are started.
|
||||
Each is governed by a customizable class,
|
||||
and JupyterHub ships with just the most basic version of each.
|
||||
|
||||
To enable custom authentication and/or spawning,
|
||||
subclass Authenticator or Spawner,
|
||||
and override the relevant methods.
|
||||
|
||||
|
||||
### Authentication
|
||||
|
||||
Authentication is customizable via the Authenticator class.
|
||||
Authentication can be replaced by any mechanism,
|
||||
such as OAuth, Kerberos, etc.
|
||||
|
||||
JupyterHub only ships with [PAM](https://en.wikipedia.org/wiki/Pluggable_authentication_module) authentication,
|
||||
which requires the server to be run as root,
|
||||
or at least with access to the PAM service,
|
||||
which regular users typically do not have
|
||||
(on Ubuntu, this requires being added to the `shadow` group).
|
||||
|
||||
[More info on custom Authenticators](authenticators.html).
|
||||
|
||||
See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||
|
||||
|
||||
### Spawning
|
||||
|
||||
Each single-user server is started by a Spawner.
|
||||
The Spawner represents an abstract interface to a process,
|
||||
and needs to be able to take three actions:
|
||||
|
||||
1. start the process
|
||||
2. poll whether the process is still running
|
||||
3. stop the process
|
||||
|
||||
[More info on custom Spawners](spawners.html).
|
||||
|
||||
See a list of custom Spawners [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Spawners).
|
BIN
docs/source/images/instance.png
Normal file
BIN
docs/source/images/instance.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 61 KiB |
BIN
docs/source/images/security.png
Normal file
BIN
docs/source/images/security.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
BIN
docs/source/images/token-request-success.png
Normal file
BIN
docs/source/images/token-request-success.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 102 KiB |
BIN
docs/source/images/token-request.png
Normal file
BIN
docs/source/images/token-request.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
@@ -1,106 +1,87 @@
|
||||
JupyterHub
|
||||
==========
|
||||
|
||||
With JupyterHub you can create a **multi-user Hub** which spawns, manages,
|
||||
and proxies multiple instances of the single-user
|
||||
`Jupyter notebook <https://jupyter-notebook.readthedocs.io/en/latest/>`_ server.
|
||||
Due to its flexibility and customization options, JupyterHub can be used to
|
||||
serve notebooks to a class of students, a corporate data science group, or a
|
||||
scientific research group.
|
||||
|
||||
`JupyterHub`_, a multi-user **Hub**, spawns, manages, and proxies multiple
|
||||
instances of the single-user `Jupyter notebook`_ server.
|
||||
JupyterHub can be used to serve notebooks to a class of students, a corporate
|
||||
data science group, or a scientific research group.
|
||||
|
||||
.. image:: images/jhub-parts.png
|
||||
:alt: JupyterHub subsystems
|
||||
:width: 40%
|
||||
:align: right
|
||||
|
||||
|
||||
Three subsystems make up JupyterHub:
|
||||
|
||||
* a multi-user **Hub** (tornado process)
|
||||
* a **configurable http proxy** (node-http-proxy)
|
||||
* multiple **single-user Jupyter notebook servers** (Python/IPython/tornado)
|
||||
|
||||
JupyterHub's basic flow of operations includes:
|
||||
JupyterHub performs the following functions:
|
||||
|
||||
- The Hub spawns a proxy
|
||||
- The proxy forwards all requests to the Hub by default
|
||||
- The Hub handles user login and spawns single-user servers on demand
|
||||
- The Hub configures the proxy to forward URL prefixes to the single-user notebook servers
|
||||
- The Hub configures the proxy to forward URL prefixes to the single-user
|
||||
notebook servers
|
||||
|
||||
For convenient administration of the Hub, its users, and :doc:`services`
|
||||
(added in version 7.0), JupyterHub also provides a
|
||||
`REST API <http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default>`__.
|
||||
For convenient administration of the Hub, its users, and services,
|
||||
JupyterHub also provides a `REST API`_.
|
||||
|
||||
Contents
|
||||
--------
|
||||
|
||||
**User Guide**
|
||||
**Installation Guide**
|
||||
|
||||
* :doc:`installation-guide`
|
||||
* :doc:`quickstart`
|
||||
* :doc:`getting-started`
|
||||
* :doc:`howitworks`
|
||||
* :doc:`websecurity`
|
||||
* :doc:`rest`
|
||||
* :doc:`quickstart-docker`
|
||||
* :doc:`installation-basics`
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:hidden:
|
||||
:caption: User Guide
|
||||
**Getting Started**
|
||||
|
||||
quickstart
|
||||
getting-started
|
||||
howitworks
|
||||
websecurity
|
||||
rest
|
||||
* :doc:`getting-started/index`
|
||||
* :doc:`getting-started/config-basics`
|
||||
* :doc:`getting-started/networking-basics`
|
||||
* :doc:`getting-started/security-basics`
|
||||
* :doc:`getting-started/authenticators-users-basics`
|
||||
* :doc:`getting-started/spawners-basics`
|
||||
* :doc:`getting-started/services-basics`
|
||||
|
||||
**Configuration Guide**
|
||||
|
||||
* :doc:`authenticators`
|
||||
* :doc:`spawners`
|
||||
* :doc:`services`
|
||||
* :doc:`config-examples`
|
||||
* :doc:`upgrading`
|
||||
* :doc:`troubleshooting`
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:hidden:
|
||||
:caption: Configuration Guide
|
||||
|
||||
authenticators
|
||||
spawners
|
||||
services
|
||||
config-examples
|
||||
upgrading
|
||||
troubleshooting
|
||||
**Technical Reference**
|
||||
|
||||
* :doc:`reference/index`
|
||||
* :doc:`reference/technical-overview`
|
||||
* :doc:`reference/websecurity`
|
||||
* :doc:`reference/authenticators`
|
||||
* :doc:`reference/spawners`
|
||||
* :doc:`reference/services`
|
||||
* :doc:`reference/rest`
|
||||
* :doc:`reference/upgrading`
|
||||
* :doc:`reference/config-examples`
|
||||
|
||||
**API Reference**
|
||||
|
||||
* :doc:`api/index`
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:hidden:
|
||||
:caption: API Reference
|
||||
**Tutorials**
|
||||
|
||||
api/index
|
||||
* :doc:`tutorials/index`
|
||||
* :doc:`tutorials/upgrade-dot-eight`
|
||||
* `Zero to JupyterHub with Kubernetes <https://zero-to-jupyterhub.readthedocs.io/en/latest/>`_
|
||||
|
||||
**Troubleshooting**
|
||||
|
||||
* :doc:`troubleshooting`
|
||||
|
||||
**About JupyterHub**
|
||||
|
||||
* :doc:`changelog`
|
||||
* :doc:`contributor-list`
|
||||
* :doc:`gallery-jhub-deployments`
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:hidden:
|
||||
:caption: About JupyterHub
|
||||
|
||||
changelog
|
||||
contributor-list
|
||||
**Changelog**
|
||||
|
||||
* :doc:`changelog`
|
||||
|
||||
Indices and tables
|
||||
------------------
|
||||
@@ -114,3 +95,26 @@ Questions? Suggestions?
|
||||
|
||||
- `Jupyter mailing list <https://groups.google.com/forum/#!forum/jupyter>`_
|
||||
- `Jupyter website <https://jupyter.org>`_
|
||||
|
||||
.. _contents:
|
||||
|
||||
Full Table of Contents
|
||||
----------------------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
installation-guide
|
||||
getting-started/index
|
||||
reference/index
|
||||
api/index
|
||||
tutorials/index
|
||||
troubleshooting
|
||||
contributor-list
|
||||
gallery-jhub-deployments
|
||||
changelog
|
||||
|
||||
|
||||
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub
|
||||
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
|
||||
.. _REST API: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default
|
||||
|
40
docs/source/installation-basics.md
Normal file
40
docs/source/installation-basics.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Installation Basics
|
||||
|
||||
## Platform support
|
||||
|
||||
JupyterHub is supported on Linux/Unix based systems. To use JupyterHub, you need
|
||||
a Unix server (typically Linux) running somewhere that is accessible to your
|
||||
team on the network. The JupyterHub server can be on an internal network at your
|
||||
organization, or it can run on the public internet (in which case, take care
|
||||
with the Hub's [security](./security-basics.html)).
|
||||
|
||||
JupyterHub officially **does not** support Windows. You may be able to use
|
||||
JupyterHub on Windows if you use a Spawner and Authenticator that work on
|
||||
Windows, but the JupyterHub defaults will not. Bugs reported on Windows will not
|
||||
be accepted, and the test suite will not run on Windows. Small patches that fix
|
||||
minor Windows compatibility issues (such as basic installation) **may** be accepted,
|
||||
however. For Windows-based systems, we would recommend running JupyterHub in a
|
||||
docker container or Linux VM.
|
||||
|
||||
[Additional Reference:](http://www.tornadoweb.org/en/stable/#installation)
|
||||
Tornado's documentation on Windows platform support
|
||||
|
||||
## Planning your installation
|
||||
|
||||
Prior to beginning installation, it's helpful to consider some of the following:
|
||||
|
||||
- deployment system (bare metal, Docker)
|
||||
- Authentication (PAM, OAuth, etc.)
|
||||
- Spawner of singleuser notebook servers (Docker, Batch, etc.)
|
||||
- Services (nbgrader, etc.)
|
||||
- JupyterHub database (default SQLite; traditional RDBMS such as PostgreSQL,)
|
||||
MySQL, or other databases supported by [SQLAlchemy](http://www.sqlalchemy.org))
|
||||
|
||||
## Folders and 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
|
9
docs/source/installation-guide.rst
Normal file
9
docs/source/installation-guide.rst
Normal file
@@ -0,0 +1,9 @@
|
||||
Installation Guide
|
||||
==================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
quickstart
|
||||
quickstart-docker
|
||||
installation-basics
|
49
docs/source/quickstart-docker.rst
Normal file
49
docs/source/quickstart-docker.rst
Normal file
@@ -0,0 +1,49 @@
|
||||
Using Docker
|
||||
============
|
||||
|
||||
.. important::
|
||||
|
||||
We highly recommend following the `Zero to JupyterHub`_ tutorial for
|
||||
installing JupyterHub.
|
||||
|
||||
Alternate installation using Docker
|
||||
-----------------------------------
|
||||
|
||||
A ready to go `docker image <https://hub.docker.com/r/jupyterhub/jupyterhub/>`_
|
||||
gives a straightforward deployment of JupyterHub.
|
||||
|
||||
.. note::
|
||||
|
||||
This ``jupyterhub/jupyterhub`` docker image is only an image for running
|
||||
the Hub service itself. It does not provide the other Jupyter components,
|
||||
such as Notebook installation, which are needed by the single-user servers.
|
||||
To run the single-user servers, which may be on the same system as the Hub or
|
||||
not, Jupyter Notebook version 4 or greater must be installed.
|
||||
|
||||
Starting JupyterHub with docker
|
||||
-------------------------------
|
||||
|
||||
The JupyterHub docker image can be started with the following command::
|
||||
|
||||
docker run -d --name jupyterhub jupyterhub/jupyterhub jupyterhub
|
||||
|
||||
This command will create a container named ``jupyterhub`` that you can
|
||||
**stop and resume** with ``docker stop/start``.
|
||||
|
||||
The Hub service will be listening on all interfaces at port 8000, which makes
|
||||
this a good choice for **testing JupyterHub on your desktop or laptop**.
|
||||
|
||||
If you want to run docker on a computer that has a public IP then you should
|
||||
(as in MUST) **secure it with ssl** by adding ssl options to your docker
|
||||
configuration or using a ssl enabled proxy.
|
||||
|
||||
`Mounting volumes <https://docs.docker.com/engine/userguide/containers/dockervolumes/>`_
|
||||
will allow you to store data outside the docker image (host system) so it will
|
||||
be persistent, even when you start a new image.
|
||||
|
||||
The command ``docker exec -it jupyterhub bash`` will spawn a root shell in your
|
||||
docker container. You can use the root shell to **create system users in the container**.
|
||||
These accounts will be used for authentication in JupyterHub's default
|
||||
configuration.
|
||||
|
||||
.. _Zero to JupyterHub: https://zero-to-jupyterhub.readthedocs.io/en/latest/
|
@@ -1,73 +1,60 @@
|
||||
# Quickstart - Installation
|
||||
# Quickstart
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Before installing JupyterHub**, you will need:
|
||||
Before installing JupyterHub, you will need:
|
||||
|
||||
- [Python](https://www.python.org/downloads/) 3.3 or greater
|
||||
|
||||
An understanding of using [`pip`](https://pip.pypa.io/en/stable/) or
|
||||
[`conda`](http://conda.pydata.org/docs/get-started.html) for
|
||||
- a Linux/Unix based system
|
||||
- [Python](https://www.python.org/downloads/) 3.4 or greater. An understanding
|
||||
of using [`pip`](https://pip.pypa.io/en/stable/) or
|
||||
[`conda`](https://conda.io/docs/get-started.html) for
|
||||
installing Python packages is helpful.
|
||||
|
||||
- [nodejs/npm](https://www.npmjs.com/)
|
||||
|
||||
[Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
||||
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
||||
using your operating system's package manager. For example, install on Linux
|
||||
(Debian/Ubuntu) using:
|
||||
Debian/Ubuntu using:
|
||||
|
||||
```bash
|
||||
sudo apt-get install npm nodejs-legacy
|
||||
```
|
||||
|
||||
(The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for npm to work on Debian/Ubuntu.)
|
||||
|
||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
||||
required for `npm` to work on Debian/Ubuntu.
|
||||
- TLS certificate and key for HTTPS communication
|
||||
|
||||
- Domain name
|
||||
|
||||
**Before running the single-user notebook servers** (which may be on the same
|
||||
system as the Hub or not):
|
||||
Before running the single-user notebook servers (which may be on the same
|
||||
system as the Hub or not), you will need:
|
||||
|
||||
- [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html)
|
||||
version 4 or greater
|
||||
|
||||
## Installation
|
||||
|
||||
JupyterHub can be installed with `pip` or `conda` and the proxy with `npm`:
|
||||
JupyterHub can be installed with `pip` (and the proxy with `npm`) or `conda`:
|
||||
|
||||
**pip, npm:**
|
||||
|
||||
```bash
|
||||
python3 -m pip install jupyterhub
|
||||
npm install -g configurable-http-proxy
|
||||
python3 -m pip install notebook # needed if running the notebook servers locally
|
||||
```
|
||||
|
||||
**conda** (one command installs jupyterhub and proxy):
|
||||
|
||||
```bash
|
||||
conda install -c conda-forge jupyterhub
|
||||
conda install -c conda-forge jupyterhub # installs jupyterhub and proxy
|
||||
conda install notebook # needed if running the notebook servers locally
|
||||
```
|
||||
|
||||
To test your installation:
|
||||
Test your installation. If installed, these commands should return the packages'
|
||||
help contents:
|
||||
|
||||
```bash
|
||||
jupyterhub -h
|
||||
configurable-http-proxy -h
|
||||
```
|
||||
|
||||
If you plan to run notebook servers locally, you will need also to install
|
||||
Jupyter notebook:
|
||||
|
||||
**pip:**
|
||||
```bash
|
||||
python3 -m pip install notebook
|
||||
```
|
||||
|
||||
**conda:**
|
||||
```bash
|
||||
conda install notebook
|
||||
```
|
||||
|
||||
## Start the Hub server
|
||||
|
||||
To start the Hub server, run the command:
|
||||
@@ -79,82 +66,13 @@ jupyterhub
|
||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
||||
credentials.
|
||||
|
||||
To allow multiple users to sign into the Hub server, you must start `jupyterhub` as a *privileged user*, such as root:
|
||||
To **allow multiple users to sign in** to the Hub server, you must start
|
||||
`jupyterhub` as a *privileged user*, such as root:
|
||||
|
||||
```bash
|
||||
sudo jupyterhub
|
||||
```
|
||||
|
||||
The [wiki](https://github.com/jupyterhub/jupyterhub/wiki/Using-sudo-to-run-JupyterHub-without-root-privileges)
|
||||
describes how to run the server as a *less privileged user*, which requires
|
||||
describes how to run the server as a *less privileged user*. This requires
|
||||
additional configuration of the system.
|
||||
|
||||
----
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
The [getting started document](docs/source/getting-started.md) contains
|
||||
detailed information abouts configuring a JupyterHub deployment.
|
||||
|
||||
The JupyterHub **tutorial** provides a video and documentation that explains
|
||||
and illustrates the fundamental steps for installation and configuration.
|
||||
[Repo](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||
| [Tutorial documentation](http://jupyterhub-tutorial.readthedocs.io/en/latest/)
|
||||
|
||||
#### Generate a default configuration file
|
||||
|
||||
Generate a default config file:
|
||||
|
||||
jupyterhub --generate-config
|
||||
|
||||
#### Customize the configuration, authentication, and process spawning
|
||||
|
||||
Spawn the server on ``10.0.1.2:443`` with **https**:
|
||||
|
||||
jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert
|
||||
|
||||
The authentication and process spawning mechanisms can be replaced,
|
||||
which should allow plugging into a variety of authentication or process
|
||||
control environments. Some examples, meant as illustration and testing of this
|
||||
concept, are:
|
||||
|
||||
- Using GitHub OAuth instead of PAM with [OAuthenticator](https://github.com/jupyterhub/oauthenticator)
|
||||
- Spawning single-user servers with Docker, using the [DockerSpawner](https://github.com/jupyterhub/dockerspawner)
|
||||
|
||||
----
|
||||
|
||||
## Alternate Installation using Docker
|
||||
|
||||
A ready to go [docker image for JupyterHub](https://hub.docker.com/r/jupyterhub/jupyterhub/)
|
||||
gives a straightforward deployment of JupyterHub.
|
||||
|
||||
*Note: This `jupyterhub/jupyterhub` docker image is only an image for running
|
||||
the Hub service itself. It does not provide the other Jupyter components, such
|
||||
as Notebook installation, which are needed by the single-user servers.
|
||||
To run the single-user servers, which may be on the same system as the Hub or
|
||||
not, Jupyter Notebook version 4 or greater must be installed.*
|
||||
|
||||
#### Starting JupyterHub with docker
|
||||
|
||||
The JupyterHub docker image can be started with the following command:
|
||||
|
||||
docker run -d --name jupyterhub jupyterhub/jupyterhub jupyterhub
|
||||
|
||||
This command will create a container named `jupyterhub` that you can
|
||||
**stop and resume** with `docker stop/start`.
|
||||
|
||||
The Hub service will be listening on all interfaces at port 8000, which makes
|
||||
this a good choice for **testing JupyterHub on your desktop or laptop**.
|
||||
|
||||
If you want to run docker on a computer that has a public IP then you should
|
||||
(as in MUST) **secure it with ssl** by adding ssl options to your docker
|
||||
configuration or using a ssl enabled proxy.
|
||||
|
||||
[Mounting volumes](https://docs.docker.com/engine/userguide/containers/dockervolumes/)
|
||||
will allow you to **store data outside the docker image (host system) so it will be persistent**,
|
||||
even when you start a new image.
|
||||
|
||||
The command `docker exec -it jupyterhub bash` will spawn a root shell in your
|
||||
docker container. You can **use the root shell to create system users in the container**.
|
||||
These accounts will be used for authentication in JupyterHub's default
|
||||
configuration.
|
||||
|
@@ -1,37 +1,67 @@
|
||||
# Authenticators
|
||||
|
||||
The [Authenticator][] is the mechanism for authorizing users.
|
||||
Basic authenticators use simple username and password authentication.
|
||||
JupyterHub ships only with a [PAM][]-based Authenticator,
|
||||
for logging in with local user accounts.
|
||||
The [Authenticator][] is the mechanism for authorizing users to use the
|
||||
Hub and single user notebook servers.
|
||||
|
||||
You can use custom Authenticator subclasses to enable authentication via other systems.
|
||||
One such example is using [GitHub OAuth][].
|
||||
## The default PAM Authenticator
|
||||
|
||||
Because the username is passed from the Authenticator to the Spawner,
|
||||
a custom Authenticator and Spawner are often used together.
|
||||
JupyterHub ships only with the default [PAM][]-based Authenticator,
|
||||
for logging in with local user accounts via a username and password.
|
||||
|
||||
See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||
## The OAuthenticator
|
||||
|
||||
Some login mechanisms, such as [OAuth][], don't map onto username and
|
||||
password authentication, and instead use tokens. When using these
|
||||
mechanisms, you can override the login handlers.
|
||||
|
||||
## Basics of Authenticators
|
||||
You can see an example implementation of an Authenticator that uses
|
||||
[GitHub OAuth][] at [OAuthenticator][].
|
||||
|
||||
A basic Authenticator has one central method:
|
||||
JupyterHub's [OAuthenticator][] currently supports the following
|
||||
popular services:
|
||||
|
||||
### Authenticator.authenticate
|
||||
- Auth0
|
||||
- Bitbucket
|
||||
- CILogon
|
||||
- GitHub
|
||||
- GitLab
|
||||
- Globus
|
||||
- Google
|
||||
- MediaWiki
|
||||
- Okpy
|
||||
- OpenShift
|
||||
|
||||
A generic implementation, which you can use for OAuth authentication
|
||||
with any provider, is also available.
|
||||
|
||||
## Additional Authenticators
|
||||
|
||||
- ldapauthenticator for LDAP
|
||||
- tmpauthenticator for temporary accounts
|
||||
|
||||
## Technical Overview of Authentication
|
||||
|
||||
### How the Base Authenticator works
|
||||
|
||||
The base authenticator uses simple username and password authentication.
|
||||
|
||||
The base Authenticator has one central method:
|
||||
|
||||
#### Authenticator.authenticate method
|
||||
|
||||
Authenticator.authenticate(handler, data)
|
||||
|
||||
This method is passed the tornado RequestHandler and the POST data from the login form.
|
||||
Unless the login form has been customized, `data` will have two keys:
|
||||
This method is passed the Tornado `RequestHandler` and the `POST data`
|
||||
from JupyterHub's login form. Unless the login form has been customized,
|
||||
`data` will have two keys:
|
||||
|
||||
- `username` (self-explanatory)
|
||||
- `password` (also self-explanatory)
|
||||
- `username`
|
||||
- `password`
|
||||
|
||||
`authenticate`'s job is simple:
|
||||
The `authenticate` method's job is simple:
|
||||
|
||||
- return a username (non-empty str)
|
||||
of the authenticated user if authentication is successful
|
||||
- return the username (non-empty str) of the authenticated user if
|
||||
authentication is successful
|
||||
- return `None` otherwise
|
||||
|
||||
Writing an Authenticator that looks up passwords in a dictionary
|
||||
@@ -54,14 +84,7 @@ class DictionaryAuthenticator(Authenticator):
|
||||
return data['username']
|
||||
```
|
||||
|
||||
### Authenticator.whitelist
|
||||
|
||||
Authenticators can specify a whitelist of usernames to allow authentication.
|
||||
For local user authentication (e.g. PAM), this lets you limit which users
|
||||
can login.
|
||||
|
||||
|
||||
## Normalizing and validating usernames
|
||||
#### Normalize usernames
|
||||
|
||||
Since the Authenticator and Spawner both use the same username,
|
||||
sometimes you want to transform the name coming from the authentication service
|
||||
@@ -77,7 +100,7 @@ c.Authenticator.username_map = {
|
||||
}
|
||||
```
|
||||
|
||||
### Validating usernames
|
||||
#### Validate usernames
|
||||
|
||||
In most cases, there is a very limited set of acceptable usernames.
|
||||
Authenticators can define `validate_username(username)`,
|
||||
@@ -93,18 +116,24 @@ To only allow usernames that start with 'w':
|
||||
c.Authenticator.username_pattern = r'w.*'
|
||||
```
|
||||
|
||||
## OAuth and other non-password logins
|
||||
### How to write a custom authenticator
|
||||
|
||||
Some login mechanisms, such as [OAuth][], don't map onto username+password.
|
||||
For these, you can override the login handlers.
|
||||
You can use custom Authenticator subclasses to enable authentication
|
||||
via other mechanisms. One such example is using [GitHub OAuth][].
|
||||
|
||||
You can see an example implementation of an Authenticator that uses [GitHub OAuth][]
|
||||
at [OAuthenticator][].
|
||||
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/jupyterhub/jupyterhub/wiki/Authenticators).
|
||||
|
||||
If you are interested in writing a custom authenticator, you can read
|
||||
[this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html).
|
||||
|
||||
|
||||
## Writing a custom authenticator
|
||||
## JupyterHub as an OAuth provider
|
||||
|
||||
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||
|
||||
If you are interested in writing a custom authenticator, you can read [this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html).
|
||||
|
||||
[Authenticator]: https://github.com/jupyterhub/jupyterhub/blob/master/jupyterhub/auth.py
|
||||
[PAM]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
@@ -1,29 +1,30 @@
|
||||
# Configuration examples
|
||||
|
||||
This section provides configuration files and tips for the following
|
||||
configurations:
|
||||
This section provides examples, including configuration files and tips, for the
|
||||
following configurations:
|
||||
|
||||
- Example with GitHub OAuth
|
||||
- Example with nginx reverse proxy
|
||||
- Using GitHub OAuth
|
||||
- Using nginx reverse proxy
|
||||
|
||||
## Using GitHub OAuth
|
||||
|
||||
## Example with GitHub OAuth
|
||||
In this example, we show a configuration file for a fairly standard JupyterHub
|
||||
deployment with the following assumptions:
|
||||
|
||||
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
|
||||
* Running JupyterHub on a single cloud server
|
||||
* Using SSL on the standard HTTPS port 443
|
||||
* You want to use GitHub OAuth (using 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.
|
||||
* Using GitHub OAuth (using oauthenticator) for login
|
||||
* Users exist locally on the server
|
||||
* Users' notebooks to be served from `~/assignments` to allow users to browse
|
||||
for notebooks within other users' home directories
|
||||
* You want the landing page for each user to be a `Welcome.ipynb` notebook in
|
||||
their assignments directory.
|
||||
* All runtime files are put into `/srv/jupyterhub` and log files in `/var/log`.
|
||||
|
||||
Let's start out with `jupyterhub_config.py`:
|
||||
The `jupyterhub_config.py` file would have these settings:
|
||||
|
||||
```python
|
||||
# jupyterhub_config.py
|
||||
# jupyterhub_config.py file
|
||||
c = get_config()
|
||||
|
||||
import os
|
||||
@@ -34,6 +35,8 @@ ssl_dir = pjoin(runtime_dir, 'ssl')
|
||||
if not os.path.exists(ssl_dir):
|
||||
os.makedirs(ssl_dir)
|
||||
|
||||
# Allows multiple single-server per user
|
||||
c.JupyterHub.allow_named_servers = True
|
||||
|
||||
# https on :443
|
||||
c.JupyterHub.port = 443
|
||||
@@ -50,9 +53,9 @@ c.JupyterHub.db_url = pjoin(runtime_dir, 'jupyterhub.sqlite')
|
||||
c.JupyterHub.extra_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
|
||||
|
||||
@@ -63,39 +66,47 @@ 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
|
||||
# /etc/jupyter/jupyter_notebook_config.py
|
||||
c.Spawner.notebook_dir = '~/assignments'
|
||||
c.Spawner.args = ['--NotebookApp.default_url=/notebooks/Welcome.ipynb']
|
||||
```
|
||||
|
||||
Using the GitHub Authenticator [requires a few additional env variables][oauth-setup],
|
||||
which we will need to set when we launch the server:
|
||||
Using the GitHub Authenticator requires a few additional
|
||||
environment variable to be set prior to launching JupyterHub:
|
||||
|
||||
```bash
|
||||
export GITHUB_CLIENT_ID=github_id
|
||||
export GITHUB_CLIENT_SECRET=github_secret
|
||||
export OAUTH_CALLBACK_URL=https://example.com/hub/oauth_callback
|
||||
export CONFIGPROXY_AUTH_TOKEN=super-secret
|
||||
jupyterhub -f /path/to/aboveconfig.py
|
||||
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py
|
||||
```
|
||||
|
||||
## Example with nginx reverse proxy
|
||||
## Using nginx reverse proxy
|
||||
|
||||
In the following example, we show configuration files for a JupyterHub server running locally on port `8000` but accessible from the outside on the standard SSL port `443`. This could be useful if the JupyterHub server machine is also hosting other domains or content on `443`. The goal here is to have the following be true:
|
||||
In the following example, we show configuration files for a JupyterHub server
|
||||
running locally on port `8000` but accessible from the outside on the standard
|
||||
SSL port `443`. This could be useful if the JupyterHub server machine is also
|
||||
hosting other domains or content on `443`. The goal in this example is to
|
||||
satisfy the following:
|
||||
|
||||
* JupyterHub is running on a server, accessed *only* via `HUB.DOMAIN.TLD:443`
|
||||
* On the same machine, `NO_HUB.DOMAIN.TLD` strictly serves different content, also on port `443`
|
||||
* `nginx` is used to manage the web servers / reverse proxy (which means that only nginx will be able to bind two servers to `443`)
|
||||
* After testing, the server in question should be able to score an A+ on the Qualys SSL Labs [SSL Server Test](https://www.ssllabs.com/ssltest/)
|
||||
* On the same machine, `NO_HUB.DOMAIN.TLD` strictly serves different content,
|
||||
also on port `443`
|
||||
* `nginx` is used to manage the web servers / reverse proxy (which means that
|
||||
only nginx will be able to bind two servers to `443`)
|
||||
* After testing, the server in question should be able to score an A+ on the
|
||||
Qualys SSL Labs [SSL Server Test](https://www.ssllabs.com/ssltest/)
|
||||
|
||||
Let's start out with `jupyterhub_config.py`:
|
||||
Let's start out with needed JupyterHub configuration in `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
# Force the proxy to only listen to connections to 127.0.0.1
|
||||
c.JupyterHub.ip = '127.0.0.1'
|
||||
```
|
||||
|
||||
The `nginx` server config files are fairly standard fare except for the two `location` blocks within the `HUB.DOMAIN.TLD` config file:
|
||||
The **`nginx` server config file** is fairly standard fare except for the two
|
||||
`location` blocks within the `HUB.DOMAIN.TLD` config file:
|
||||
|
||||
```bash
|
||||
# HTTP server to redirect all 80 traffic to SSL/HTTPS
|
||||
@@ -114,8 +125,8 @@ server {
|
||||
|
||||
server_name HUB.DOMAIN.TLD;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
||||
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
||||
ssl_certificate /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
ssl_prefer_server_ciphers on;
|
||||
@@ -158,7 +169,11 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
`nginx` will now be the front facing element of JupyterHub on `443` which means it is also free to bind other servers, like `NO_HUB.DOMAIN.TLD` to the same port on the same machine and network interface. In fact, one can simply use the same server blocks as above for `NO_HUB` and simply add line for the root directory of the site as well as the applicable location call:
|
||||
`nginx` will now be the front facing element of JupyterHub on `443` which means
|
||||
it is also free to bind other servers, like `NO_HUB.DOMAIN.TLD` to the same port
|
||||
on the same machine and network interface. In fact, one can simply use the same
|
||||
server blocks as above for `NO_HUB` and simply add line for the root directory
|
||||
of the site as well as the applicable location call:
|
||||
|
||||
```bash
|
||||
server {
|
||||
@@ -191,4 +206,6 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Now just restart `nginx`, restart the JupyterHub, and enjoy accessing https://HUB.DOMAIN.TLD while serving other content securely on https://NO_HUB.DOMAIN.TLD.
|
||||
Now just restart `nginx`, restart the JupyterHub, and enjoy accessing
|
||||
`https://HUB.DOMAIN.TLD` while serving other content securely on
|
||||
`https://NO_HUB.DOMAIN.TLD`.
|
14
docs/source/reference/index.rst
Normal file
14
docs/source/reference/index.rst
Normal file
@@ -0,0 +1,14 @@
|
||||
Technical Reference
|
||||
===================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
technical-overview
|
||||
websecurity
|
||||
authenticators
|
||||
spawners
|
||||
services
|
||||
rest
|
||||
upgrading
|
||||
config-examples
|
132
docs/source/reference/rest.md
Normal file
132
docs/source/reference/rest.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Using JupyterHub's REST API
|
||||
|
||||
This section will give you information on:
|
||||
|
||||
- what you can do with the API
|
||||
- create an API token
|
||||
- add API tokens to the config files
|
||||
- make an API request programmatically using the requests library
|
||||
- learn more about JupyterHub's API
|
||||
|
||||
## What you can do with the API
|
||||
|
||||
Using the [JupyterHub REST API][], you can perform actions on the Hub,
|
||||
such as:
|
||||
|
||||
- checking which users are active
|
||||
- adding or removing users
|
||||
- stopping or starting single user notebook servers
|
||||
- authenticating services
|
||||
|
||||
A [REST](https://en.wikipedia.org/wiki/Representational_state_transfer)
|
||||
API provides a standard way for users to get and send information to the
|
||||
Hub.
|
||||
|
||||
## Create an API token
|
||||
|
||||
To send requests using JupyterHub API, you must pass an API token with
|
||||
the request.
|
||||
|
||||
As of [version 0.6.0](../changelog.html), the preferred way of
|
||||
generating an API token is:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
This `openssl` command generates a potential token that can then be
|
||||
added to JupyterHub using `.api_tokens` configuration setting in
|
||||
`jupyterhub_config.py`.
|
||||
|
||||
Alternatively, use the `jupyterhub token` command to generate a token
|
||||
for a specific hub user by passing the 'username':
|
||||
|
||||
```bash
|
||||
jupyterhub token <username>
|
||||
```
|
||||
|
||||
This command generates a random string to use as a token and registers
|
||||
it for the given user with the Hub's database.
|
||||
|
||||
In [version 0.8.0](../changelog.html), a TOKEN request page for
|
||||
generating an API token is available from the JupyterHub user interface:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Add API tokens to the config file
|
||||
|
||||
You may also add a dictionary of API tokens and usernames to the hub's
|
||||
configuration file, `jupyterhub_config.py` (note that
|
||||
the **key** is the 'secret-token' while the **value** is the 'username'):
|
||||
|
||||
```python
|
||||
c.JupyterHub.api_tokens = {
|
||||
'secret-token': 'username',
|
||||
}
|
||||
```
|
||||
|
||||
## Make an API request
|
||||
|
||||
To authenticate your requests, pass the API token in the request's
|
||||
Authorization header.
|
||||
|
||||
### Use requests
|
||||
|
||||
Using the popular Python [requests](http://docs.python-requests.org/en/master/)
|
||||
library, here's example code to make an API request for the users of a JupyterHub
|
||||
deployment. An API GET request is made, and the request sends an API token for
|
||||
authorization. The response contains information about the users:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
api_url = 'http://127.0.0.1:8081/hub/api'
|
||||
|
||||
r = requests.get(api_url + '/users',
|
||||
headers={
|
||||
'Authorization': 'token %s' % token,
|
||||
}
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
users = r.json()
|
||||
```
|
||||
|
||||
This example provides a slightly more complicated request, yet the
|
||||
process is very similar:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
api_url = 'http://127.0.0.1:8081/hub/api'
|
||||
|
||||
data = {'name': 'mygroup', 'users': ['user1', 'user2']}
|
||||
|
||||
r = requests.post(api_url + '/groups/formgrade-data301/users',
|
||||
headers={
|
||||
'Authorization': 'token %s' % token,
|
||||
},
|
||||
json=data
|
||||
)
|
||||
r.raise_for_status()
|
||||
r.json()
|
||||
```
|
||||
|
||||
Note that the API token authorizes **JupyterHub** REST API requests. The same
|
||||
token does **not** authorize access to the [Jupyter Notebook REST API][]
|
||||
provided by notebook servers managed by JupyterHub. A different token is used
|
||||
to access the **Jupyter Notebook** API.
|
||||
|
||||
## Learn more about the API
|
||||
|
||||
You can see the full [JupyterHub REST API][] for details. This REST API Spec can
|
||||
be viewed in a more [interactive style on swagger's petstore][].
|
||||
Both resources contain the same information and differ only in its display.
|
||||
Note: The Swagger specification is being renamed the [OpenAPI Initiative][].
|
||||
|
||||
[interactive style on swagger's petstore]: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default
|
||||
[OpenAPI Initiative]: https://www.openapis.org/
|
||||
[JupyterHub REST API]: ../_static/rest-api/index.html
|
||||
[Jupyter Notebook REST API]: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/master/notebook/services/api/api.yaml
|
@@ -4,13 +4,13 @@ With version 0.7, JupyterHub adds support for **Services**.
|
||||
|
||||
This section provides the following information about Services:
|
||||
|
||||
- [Definition of a Service](services.html#definition-of-a-service)
|
||||
- [Properties of a Service](services.html#properties-of-a-service)
|
||||
- [Hub-Managed Services](services.html#hub-managed-services)
|
||||
- [Launching a Hub-Managed Service](services.html#launching-a-hub-managed-service)
|
||||
- [Externally-Managed Services](services.html#externally-managed-services)
|
||||
- [Writing your own Services](services.html#writing-your-own-services)
|
||||
- [Hub Authentication and Services](services.html#hub-authentication-and-services)
|
||||
- [Definition of a Service](#definition-of-a-service)
|
||||
- [Properties of a Service](#properties-of-a-service)
|
||||
- [Hub-Managed Services](#hub-managed-services)
|
||||
- [Launching a Hub-Managed Service](#launching-a-hub-managed-service)
|
||||
- [Externally-Managed Services](#externally-managed-services)
|
||||
- [Writing your own Services](#writing-your-own-services)
|
||||
- [Hub Authentication and Services](#hub-authentication-and-services)
|
||||
|
||||
## Definition of a Service
|
||||
|
||||
@@ -45,6 +45,8 @@ A Service may have the following properties:
|
||||
- `url: str (default - None)` - The URL where the service is/should be. If a
|
||||
url is specified for where the Service runs its own web server,
|
||||
the service will be added to the proxy at `/services/:name`
|
||||
- `api_token: str (default - None)` - For Externally-Managed Services you need to specify
|
||||
an API token to perform API requests to the Hub
|
||||
|
||||
If a service is also to be managed by the Hub, it has a few extra options:
|
||||
|
||||
@@ -346,12 +348,14 @@ and taking note of the following process:
|
||||
```
|
||||
|
||||
An example of using an Externally-Managed Service and authentication is
|
||||
[nbviewer](https://github.com/jupyter/nbviewer#securing-the-notebook-viewer),
|
||||
in [nbviewer README]_ section on securing the notebook viewer,
|
||||
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/master/nbviewer/providers/base.py#L94).
|
||||
nbviewer can also be run as a Hub-Managed Service as described [here](https://github.com/jupyter/nbviewer#securing-the-notebook-viewer).
|
||||
nbviewer can also be run as a Hub-Managed Service as described [nbviewer README]_
|
||||
section on securing the notebook viewer.
|
||||
|
||||
|
||||
[requests]: http://docs.python-requests.org/en/master/
|
||||
[services_auth]: api/services.auth.html
|
||||
[HubAuth]: api/services.auth.html#jupyterhub.services.auth.HubAuth
|
||||
[HubAuthenticated]: api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||
[services_auth]: ../api/services.auth.html
|
||||
[HubAuth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
||||
[HubAuthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
@@ -36,8 +36,7 @@ Some examples include:
|
||||
Information about the user can be retrieved from `self.user`,
|
||||
an object encapsulating the user's name, authentication, and server info.
|
||||
|
||||
When `Spawner.start` returns, it should have stored the IP and port
|
||||
of the single-user server in `self.user.server`.
|
||||
The return value of `Spawner.start` should be the (ip, port) of the running server.
|
||||
|
||||
**NOTE:** When writing coroutines, *never* `yield` in between a database change and a commit.
|
||||
|
||||
@@ -45,10 +44,10 @@ Most `Spawner.start` functions will look similar to this example:
|
||||
|
||||
```python
|
||||
def start(self):
|
||||
self.user.server.ip = 'localhost' # or other host or IP address, as seen by the Hub
|
||||
self.user.server.port = 1234 # port selected somehow
|
||||
self.db.commit() # always commit before yield, if modifying db values
|
||||
self.ip = '127.0.0.1'
|
||||
self.port = random_port()
|
||||
yield self._actually_start_server_somehow()
|
||||
return (self.ip, self.port)
|
||||
```
|
||||
|
||||
When `Spawner.start` returns, the single-user server process should actually be running,
|
||||
@@ -114,7 +113,7 @@ This feature is enabled by setting `Spawner.options_form`, which is an HTML form
|
||||
inserted unmodified into the spawn form.
|
||||
If the `Spawner.options_form` is defined, when a user tries to start their server, they will be directed to a form page, like this:
|
||||
|
||||

|
||||

|
||||
|
||||
If `Spawner.options_form` is undefined, the user's server is spawned directly, and no spawn page is rendered.
|
||||
|
133
docs/source/reference/technical-overview.md
Normal file
133
docs/source/reference/technical-overview.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Technical Overview
|
||||
|
||||
The **Technical Overview** section gives you a high-level view of:
|
||||
|
||||
- JupyterHub's Subsystems: Hub, Proxy, Single-User Notebook Server
|
||||
- how the subsystems interact
|
||||
- the process from JupyterHub access to user login
|
||||
- JupyterHub's default behavior
|
||||
- customizing JupyterHub
|
||||
|
||||
The goal of this section is to share a deeper technical understanding of
|
||||
JupyterHub and how it works.
|
||||
|
||||
## The Subsystems: Hub, Proxy, Single-User Notebook Server
|
||||
|
||||
JupyterHub is a set of processes that together provide a single user Jupyter
|
||||
Notebook server for each person in a group. Three major subsystems are started
|
||||
by the `jupyterhub` command line program:
|
||||
|
||||
- **Hub** (Python/Tornado): manages user accounts, authentication, and
|
||||
coordinates Single User Notebook Servers using a Spawner.
|
||||
|
||||
- **Proxy**: the public facing part of JupyterHub that uses a dynamic proxy
|
||||
to route HTTP requests to the Hub and Single User Notebook Servers.
|
||||
[configurable http proxy](https://github.com/jupyterhub/configurable-http-proxy)
|
||||
(node-http-proxy) is the default proxy.
|
||||
|
||||
- **Single-User Notebook Server** (Python/Tornado): a dedicated,
|
||||
single-user, Jupyter Notebook server is started for each user on the system
|
||||
when the user logs in. The object that starts the single-user notebook
|
||||
servers is called a **Spawner**.
|
||||
|
||||

|
||||
|
||||
## How the Subsystems Interact
|
||||
|
||||
Users access JupyterHub through a web browser, by going to the IP address or
|
||||
the domain name of the server.
|
||||
|
||||
The basic principles of operation are:
|
||||
|
||||
- The Hub spawns the proxy (in the default JupyterHub configuration)
|
||||
- The proxy forwards all requests to the Hub by default
|
||||
- The Hub handles login, and spawns single-user notebook servers on demand
|
||||
- The Hub configures the proxy to forward url prefixes to single-user notebook
|
||||
servers
|
||||
|
||||
The proxy is the only process that listens on a public interface. The Hub sits
|
||||
behind the proxy at `/hub`. Single-user servers sit behind the proxy at
|
||||
`/user/[username]`.
|
||||
|
||||
Different **[authenticators](./authenticators.html)** control access
|
||||
to JupyterHub. The default one (PAM) uses the user accounts on the server where
|
||||
JupyterHub is running. If you use this, you will need to create a user account
|
||||
on the system for each user on your team. Using other authenticators, you can
|
||||
allow users to sign in with e.g. a GitHub account, or with any single-sign-on
|
||||
system your organization has.
|
||||
|
||||
Next, **[spawners](./spawners.html)** control how JupyterHub starts
|
||||
the individual notebook server for each user. The default spawner will
|
||||
start a notebook server on the same machine running under their system username.
|
||||
The other main option is to start each server in a separate container, often
|
||||
using Docker.
|
||||
|
||||
## The Process from JupyterHub Access to User Login
|
||||
|
||||
When a user accesses JupyterHub, the following events take place:
|
||||
|
||||
- Login data is handed to the [Authenticator](./authenticators.html) instance for
|
||||
validation
|
||||
- The Authenticator returns the username if the login information is valid
|
||||
- A single-user notebook server instance is [spawned](./spawners.html) for the
|
||||
logged-in user
|
||||
- When the single-user notebook server starts, the proxy is notified to forward
|
||||
requests to `/user/[username]/*` to the single-user notebook server.
|
||||
- A cookie is set on `/hub/`, containing an encrypted token. (Prior to version
|
||||
0.8, a cookie for `/user/[username]` was used too.)
|
||||
- The browser is redirected to `/user/[username]`, and the request is handled by
|
||||
the single-user notebook server.
|
||||
|
||||
The single-user server identifies the user with the Hub via OAuth:
|
||||
|
||||
- on request, the single-user server checks a cookie
|
||||
- if no cookie is set, redirect to the Hub for verification via OAuth
|
||||
- after verification at the Hub, the browser is redirected back to the
|
||||
single-user server
|
||||
- the token is verified and stored in a cookie
|
||||
- if no user is identified, the browser is redirected back to `/hub/login`
|
||||
|
||||
## Default Behavior
|
||||
|
||||
By default, the **Proxy** listens on all public interfaces on port 8000.
|
||||
Thus you can reach JupyterHub through either:
|
||||
|
||||
- `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 Notebook Servers**, all communicate with each other on localhost
|
||||
only.
|
||||
|
||||
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 which users are running and
|
||||
where, as well as storing other information enabling you to restart parts of
|
||||
JupyterHub separately. It is important to note that this database contains
|
||||
**no** sensitive information other than **Hub** usernames.
|
||||
- `jupyterhub_cookie_secret` is the encryption key used for securing cookies.
|
||||
This file needs to persist so that a **Hub** server restart will avoid
|
||||
invalidating cookies. Conversely, deleting this file and restarting the server
|
||||
effectively invalidates all login cookies. The cookie secret file is discussed
|
||||
in the [Cookie Secret section of the Security Settings document](../getting-started/security-basics.html).
|
||||
|
||||
The location of these files can be specified via configuration settings. It is
|
||||
recommended that these files be stored in standard UNIX filesystem locations,
|
||||
such as `/etc/jupyterhub` for all configuration files and `/srv/jupyterhub` for
|
||||
all security and runtime files.
|
||||
|
||||
## Customizing JupyterHub
|
||||
|
||||
There are two basic extension points for JupyterHub:
|
||||
|
||||
- How users are authenticated by [Authenticators](./authenticators.html)
|
||||
- How user's single-user notebook server processes are started by
|
||||
[Spawners](./spawners.html)
|
||||
|
||||
Each is governed by a customizable class, and JupyterHub ships with basic
|
||||
defaults for each.
|
||||
|
||||
To enable custom authentication and/or spawning, subclass `Authenticator` or
|
||||
`Spawner`, and override the relevant methods.
|
@@ -28,7 +28,7 @@ where traditional RDBMS may be a better choice](https://sqlite.org/whentouse.htm
|
||||
|
||||
## The upgrade process
|
||||
|
||||
Four fundamental process steps are needed when upgrading JupyterHub and its
|
||||
Five fundamental process steps are needed when upgrading JupyterHub and its
|
||||
database:
|
||||
|
||||
1. Backup JupyterHub database
|
112
docs/source/reference/websecurity.md
Normal file
112
docs/source/reference/websecurity.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Security Overview
|
||||
|
||||
The **Security Overview** section helps you learn about:
|
||||
|
||||
- the design of JupyterHub with respect to web security
|
||||
- the semi-trusted user
|
||||
- the available mitigations to protect untrusted users from each other
|
||||
- the value of periodic security audits.
|
||||
|
||||
This overview also helps you obtain a deeper understanding of how JupyterHub
|
||||
works.
|
||||
|
||||
## Semi-trusted and untrusted users
|
||||
|
||||
JupyterHub is designed to be a *simple multi-user server for modestly sized
|
||||
groups* of **semi-trusted** users. While the design reflects serving semi-trusted
|
||||
users, JupyterHub is not necessarily unsuitable for serving **untrusted** users.
|
||||
|
||||
Using JupyterHub with **untrusted** users does mean more work by the
|
||||
administrator. Much care is required to secure a Hub, with extra caution on
|
||||
protecting users from each other as the Hub is serving untrusted users.
|
||||
|
||||
One aspect of JupyterHub's *design simplicity* for **semi-trusted** users is that
|
||||
the Hub and single-user servers are placed in a *single domain*, behind a
|
||||
[*proxy*][configurable-http-proxy]. If the Hub is serving untrusted
|
||||
users, many of the web's cross-site protections are not applied between
|
||||
single-user servers and the Hub, or between single-user servers and each
|
||||
other, since browsers see the whole thing (proxy, Hub, and single user
|
||||
servers) as a single website (i.e. single domain).
|
||||
|
||||
## Protect users from each other
|
||||
|
||||
To protect users from each other, a user must **never** be able to write arbitrary
|
||||
HTML and serve it to another user on the Hub's domain. JupyterHub's
|
||||
authentication setup prevents a user writing arbitrary HTML and serving it to
|
||||
another user because only the owner of a given single-user notebook server is
|
||||
allowed to view user-authored pages served by the given single-user notebook
|
||||
server.
|
||||
|
||||
To protect all users from each other, JupyterHub administrators must
|
||||
ensure that:
|
||||
|
||||
* A user **does not have permission** to modify their single-user notebook server,
|
||||
including:
|
||||
- A user **may not** install new packages in the Python environment that runs
|
||||
their single-user server.
|
||||
- If the `PATH` is used to resolve the single-user executable (instead of
|
||||
using an absolute path), a user **may not** create new files in any `PATH`
|
||||
directory that precedes the directory containing `jupyterhub-singleuser`.
|
||||
- A user may not modify environment variables (e.g. PATH, PYTHONPATH) for
|
||||
their single-user server.
|
||||
* A user **may not** modify the configuration of the notebook server
|
||||
(the `~/.jupyter` or `JUPYTER_CONFIG_DIR` directory).
|
||||
|
||||
If any additional services are run on the same domain as the Hub, the services
|
||||
**must never** display user-authored HTML that is neither *sanitized* nor *sandboxed*
|
||||
(e.g. IFramed) to any user that lacks authentication as the author of a file.
|
||||
|
||||
## Mitigate security issues
|
||||
|
||||
Several approaches to mitigating these issues with configuration
|
||||
options provided by JupyterHub include:
|
||||
|
||||
### Enable subdomains
|
||||
|
||||
JupyterHub provides the ability to run single-user servers on their own
|
||||
subdomains. This means the cross-origin protections between servers has the
|
||||
desired effect, and user servers and the Hub are protected from each other. A
|
||||
user's single-user server will be at `username.jupyter.mydomain.com`. This also
|
||||
requires all user subdomains to point to the same address, which is most easily
|
||||
accomplished with wildcard DNS. Since this spreads the service across multiple
|
||||
domains, you will need wildcard SSL, as well. Unfortunately, for many
|
||||
institutional domains, wildcard DNS and SSL are not available. **If you do plan
|
||||
to serve untrusted users, enabling subdomains is highly encouraged**, as it
|
||||
resolves the cross-site issues.
|
||||
|
||||
### Disable user config
|
||||
|
||||
If subdomains are not available or not desirable, JupyterHub provides a a
|
||||
configuration option `Spawner.disable_user_config`, which can be set to prevent
|
||||
the user-owned configuration files from being loaded. After implementing this
|
||||
option, PATHs and package installation and PATHs are the other things that the
|
||||
admin must enforce.
|
||||
|
||||
### Prevent spawners from evaluating shell configuration files
|
||||
|
||||
For most Spawners, `PATH` is not something users can influence, but care should
|
||||
be taken to ensure that the Spawner does *not* evaluate shell configuration
|
||||
files prior to launching the server.
|
||||
|
||||
### Isolate packages using virtualenv
|
||||
|
||||
Package isolation is most easily handled by running the single-user server in
|
||||
a virtualenv with disabled system-site-packages. The user should not have
|
||||
permission to install packages into this environment.
|
||||
|
||||
It is important to note that the control over the environment only affects the
|
||||
single-user server, and not the environment(s) in which the user's kernel(s)
|
||||
may run. Installing additional packages in the kernel environment does not
|
||||
pose additional risk to the web application's security.
|
||||
|
||||
## Security audits
|
||||
|
||||
We recommend that you do periodic reviews of your deployment's security. It's
|
||||
good practice to keep JupyterHub, configurable-http-proxy, and nodejs
|
||||
versions up to date.
|
||||
|
||||
A handy website for testing your deployment is
|
||||
[Qualsys' SSL analyzer tool](https://www.ssllabs.com/ssltest/analyze.html).
|
||||
|
||||
|
||||
[configurable-http-proxy]: https://github.com/jupyterhub/configurable-http-proxy
|
@@ -1,70 +0,0 @@
|
||||
# Using JupyterHub's REST API
|
||||
|
||||
Using the [JupyterHub REST API][], you can perform actions on the Hub,
|
||||
such as:
|
||||
|
||||
- checking which users are active
|
||||
- adding or removing users
|
||||
- stopping or starting single user notebook servers
|
||||
- authenticating services
|
||||
|
||||
A [REST](https://en.wikipedia.org/wiki/Representational_state_transfer)
|
||||
API provides a standard way for users to get and send information to the
|
||||
Hub.
|
||||
|
||||
|
||||
## Creating an API token
|
||||
To send requests using JupyterHub API, you must pass an API token with the
|
||||
request. You can create a token for an individual user using the following
|
||||
command:
|
||||
|
||||
jupyterhub token USERNAME
|
||||
|
||||
|
||||
## Adding tokens to the config file
|
||||
You may also add a dictionary of API tokens and usernames to the hub's
|
||||
configuration file, `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
c.JupyterHub.api_tokens = {
|
||||
'secret-token': 'username',
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Making an API request
|
||||
|
||||
To authenticate your requests, pass the API token in the request's
|
||||
Authorization header.
|
||||
|
||||
**Example: List the hub's users**
|
||||
|
||||
Using the popular Python requests library, the following code sends an API
|
||||
request and an API token for authorization:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
api_url = 'http://127.0.0.1:8081/hub/api'
|
||||
|
||||
r = requests.get(api_url + '/users',
|
||||
headers={
|
||||
'Authorization': 'token %s' % token,
|
||||
}
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
users = r.json()
|
||||
```
|
||||
|
||||
|
||||
## Learning more about the API
|
||||
|
||||
You can see the full [JupyterHub REST API][] for details.
|
||||
The same REST API Spec can be viewed in a more interactive style [on swagger's petstore][].
|
||||
Both resources contain the same information and differ only in its display.
|
||||
Note: The Swagger specification is being renamed the [OpenAPI Initiative][].
|
||||
|
||||
[on swagger's petstore]: http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/master/docs/rest-api.yml#!/default
|
||||
[OpenAPI Initiative]: https://www.openapis.org/
|
||||
[JupyterHub REST API]: ./_static/rest-api/index.html
|
@@ -7,6 +7,8 @@ problem and how to resolve it.
|
||||
[*Behavior*](#behavior)
|
||||
- JupyterHub proxy fails to start
|
||||
- sudospawner fails to run
|
||||
- What is the default behavior when none of the lists (admin, whitelist,
|
||||
group whitelist) are set?
|
||||
|
||||
[*Errors*](#errors)
|
||||
- 500 error after spawning my single-user server
|
||||
@@ -18,6 +20,9 @@ problem and how to resolve it.
|
||||
- How do I increase the number of pySpark executors on YARN?
|
||||
- How do I use JupyterLab's prerelease version with JupyterHub?
|
||||
- How do I set up JupyterHub for a workshop (when users are not known ahead of time)?
|
||||
- How do I set up rotating daily logs?
|
||||
- Toree integration with HDFS rack awareness script
|
||||
- Where do I find Docker images and Dockerfiles related to JupyterHub?
|
||||
|
||||
[*Troubleshooting commands*](#troubleshooting-commands)
|
||||
|
||||
@@ -31,6 +36,10 @@ If you have tried to start the JupyterHub proxy and it fails to start:
|
||||
``c.JupyterHub.ip = '*'``; if it is, try ``c.JupyterHub.ip = ''``
|
||||
- Try starting with ``jupyterhub --ip=0.0.0.0``
|
||||
|
||||
**Note**: If this occurs on Ubuntu/Debian, check that the you are using a
|
||||
recent version of node. Some versions of Ubuntu/Debian come with a version
|
||||
of node that is very old, and it is necessary to update node.
|
||||
|
||||
### sudospawner fails to run
|
||||
|
||||
If the sudospawner script is not found in the path, sudospawner will not run.
|
||||
@@ -45,6 +54,16 @@ or add:
|
||||
|
||||
to the config file, `jupyterhub_config.py`.
|
||||
|
||||
### What is the default behavior when none of the lists (admin, whitelist, group whitelist) are set?
|
||||
|
||||
When nothing is given for these lists, there will be no admins, and all users
|
||||
who can authenticate on the system (i.e. all the unix users on the server with
|
||||
a password) will be allowed to start a server. The whitelist lets you limit
|
||||
this to a particular set of users, and the admin_users lets you specify who
|
||||
among them may use the admin interface (not necessary, unless you need to do
|
||||
things like inspect other users' servers, or modify the userlist at runtime).
|
||||
|
||||
|
||||
## Errors
|
||||
|
||||
### 500 error after spawning my single-user server
|
||||
@@ -226,6 +245,31 @@ notebook servers to default to JupyterLab:
|
||||
|
||||
Users will need a GitHub account to login and be authenticated by the Hub.
|
||||
|
||||
### How do I set up rotating daily logs?
|
||||
|
||||
You can do this with [logrotate](https://linux.die.net/man/8/logrotate),
|
||||
or pipe to `logger` to use syslog instead of directly to a file.
|
||||
|
||||
For example, with this logrotate config file:
|
||||
|
||||
```
|
||||
/var/log/jupyterhub.log {
|
||||
copytruncate
|
||||
daily
|
||||
}
|
||||
```
|
||||
|
||||
and run this daily by putting a script in `/etc/cron.daily/`:
|
||||
|
||||
```bash
|
||||
logrotate /path/to/above-config
|
||||
```
|
||||
|
||||
Or use syslog:
|
||||
|
||||
jupyterhub | logger -t jupyterhub
|
||||
|
||||
|
||||
## Troubleshooting commands
|
||||
|
||||
The following commands provide additional detail about installed packages,
|
||||
@@ -250,9 +294,9 @@ jupyter kernelspec list
|
||||
jupyterhub --debug
|
||||
```
|
||||
|
||||
## Toree integration with HDFS rack awareness script
|
||||
### Toree integration with HDFS rack awareness script
|
||||
|
||||
The Apache Toree kernel will an issue, when running with JupyterHub, if the standard HDFS
|
||||
The Apache Toree kernel will an issue, when running with JupyterHub, if the standard HDFS
|
||||
rack awareness script is used. This will materialize in the logs as a repeated WARN:
|
||||
|
||||
```bash
|
||||
@@ -267,8 +311,17 @@ SyntaxError: Missing parentheses in call to 'print'
|
||||
|
||||
In order to resolve this issue, there are two potential options.
|
||||
|
||||
1. Update HDFS core-site.xml, so the parameter "net.topology.script.file.name" points to a custom
|
||||
1. Update HDFS core-site.xml, so the parameter "net.topology.script.file.name" points to a custom
|
||||
script (e.g. /etc/hadoop/conf/custom_topology_script.py). Copy the original script and change the first line point
|
||||
to a python two installation (e.g. /usr/bin/python).
|
||||
2. In spark-env.sh add a Python 2 installation to your path (e.g. export PATH=/opt/anaconda2/bin:$PATH).
|
||||
|
||||
### Where do I find Docker images and Dockerfiles related to JupyterHub?
|
||||
|
||||
Docker images can be found at the [JupyterHub organization on DockerHub](https://hub.docker.com/u/jupyterhub/).
|
||||
The Docker image [jupyterhub/singleuser](https://hub.docker.com/r/jupyterhub/singleuser/)
|
||||
provides an example single user notebook server for use with DockerSpawner.
|
||||
|
||||
Additional single user notebook server images can be found at the [Jupyter
|
||||
organization on DockerHub](https://hub.docker.com/r/jupyter/) and information
|
||||
about each image at the [jupyter/docker-stacks repo](https://github.com/jupyter/docker-stacks).
|
||||
|
14
docs/source/tutorials/index.rst
Normal file
14
docs/source/tutorials/index.rst
Normal file
@@ -0,0 +1,14 @@
|
||||
Tutorials
|
||||
=========
|
||||
|
||||
This section provides links to documentation that helps a user do a specific
|
||||
task.
|
||||
|
||||
* :doc:`upgrade-dot-eight`
|
||||
* `Zero to JupyterHub with Kubernetes <https://zero-to-jupyterhub.readthedocs.io/en/latest/>`_
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:hidden:
|
||||
|
||||
upgrade-dot-eight
|
93
docs/source/tutorials/upgrade-dot-eight.rst
Normal file
93
docs/source/tutorials/upgrade-dot-eight.rst
Normal file
@@ -0,0 +1,93 @@
|
||||
.. upgrade-dot-eight:
|
||||
|
||||
Upgrading to JupyterHub version 0.8
|
||||
===================================
|
||||
|
||||
This document will assist you in upgrading an existing JupyterHub deployment
|
||||
from version 0.7 to version 0.8.
|
||||
|
||||
Upgrade checklist
|
||||
-----------------
|
||||
|
||||
0. Review the release notes. Review any deprecated features and pay attention
|
||||
to any backwards incompatible changes
|
||||
1. Backup JupyterHub database:
|
||||
- ``jupyterhub.sqlite`` when using the default sqlite database
|
||||
- Your JupyterHub database when using an RDBMS
|
||||
2. Backup the existing JupyterHub configuration file: ``jupyterhub_config.py``
|
||||
3. Shutdown the Hub
|
||||
4. Upgrade JupyterHub
|
||||
- ``pip install -U jupyterhub`` when using ``pip``
|
||||
- ``conda upgrade jupyterhub`` when using ``conda``
|
||||
5. Upgrade the database using run ```jupyterhub upgrade-db``
|
||||
6. Update the JupyterHub configuration file ``jupyterhub_config.py``
|
||||
|
||||
Backup JupyterHub database
|
||||
--------------------------
|
||||
|
||||
To prevent unintended loss of data or configuration information, you should
|
||||
back up the JupyterHub database (the default SQLite database or a RDBMS
|
||||
database using PostgreSQL, MySQL, or others supported by SQLAlchemy):
|
||||
|
||||
- If using the default SQLite database, back up the ``jupyterhub.sqlite``
|
||||
database.
|
||||
- If using an RDBMS database such as PostgreSQL, MySQL, or other supported by
|
||||
SQLAlchemy, back up the JupyterHub database.
|
||||
|
||||
.. note::
|
||||
|
||||
Losing the Hub database is often not a big deal. Information that resides only
|
||||
in the Hub database includes:
|
||||
|
||||
- active login tokens (user cookies, service tokens)
|
||||
- users added via GitHub UI, instead of config files
|
||||
- info about running servers
|
||||
|
||||
If the following conditions are true, you should be fine clearing the Hub
|
||||
database and starting over:
|
||||
|
||||
- users specified in config file
|
||||
- user servers are stopped during upgrade
|
||||
- don't mind causing users to login again after upgrade
|
||||
|
||||
Backup JupyterHub configuration file
|
||||
------------------------------------
|
||||
|
||||
Backup up your configuration file, ``jupyterhub_config.py``, to a secure
|
||||
location.
|
||||
|
||||
Shutdown JupyterHub
|
||||
-------------------
|
||||
|
||||
- Prior to shutting down JupyterHub, you should notify the Hub users of the
|
||||
scheduled downtime.
|
||||
- Shutdown the JupyterHub service.
|
||||
|
||||
Upgrade JupyterHub
|
||||
------------------
|
||||
|
||||
Follow directions that correspond to your package manager, ``pip`` or ``conda``,
|
||||
for the new JupyterHub release:
|
||||
|
||||
- ``pip install -U jupyterhub`` for ``pip``
|
||||
- ``conda upgrade jupyterhub`` for ``conda``
|
||||
|
||||
Upgrade the proxy, authenticator, or spawner if needed.
|
||||
|
||||
Upgrade JupyterHub database
|
||||
---------------------------
|
||||
|
||||
To run the upgrade process for JupyterHub databases, enter::
|
||||
|
||||
jupyterhub upgrade-db
|
||||
|
||||
Update the JupyterHub configuration file
|
||||
----------------------------------------
|
||||
|
||||
Create a new JupyterHub configuration file or edit a copy of the existing
|
||||
file ``jupyterhub_config.py``.
|
||||
|
||||
Start JupyterHub
|
||||
----------------
|
||||
|
||||
Start JupyterHub with the same command that you used before the upgrade.
|
@@ -1,80 +0,0 @@
|
||||
# Web Security in JupyterHub
|
||||
|
||||
JupyterHub is designed to be a simple multi-user server for modestly sized
|
||||
groups of semi-trusted users. While the design reflects serving semi-trusted
|
||||
users, JupyterHub is not necessarily unsuitable for serving untrusted users.
|
||||
Using JupyterHub with untrusted users does mean more work and much care is
|
||||
required to secure a Hub against untrusted users, with extra caution on
|
||||
protecting users from each other as the Hub is serving untrusted users.
|
||||
|
||||
One aspect of JupyterHub's design simplicity for semi-trusted users is that
|
||||
the Hub and single-user servers are placed in a single domain, behind a
|
||||
[proxy][configurable-http-proxy]. As a result, if the Hub is serving untrusted
|
||||
users, many of the web's cross-site protections are not applied between
|
||||
single-user servers and the Hub, or between single-user servers and each
|
||||
other, since browsers see the whole thing (proxy, Hub, and single user
|
||||
servers) as a single website.
|
||||
|
||||
To protect users from each other, a user must never be able to write arbitrary
|
||||
HTML and serve it to another user on the Hub's domain. JupyterHub's
|
||||
authentication setup prevents this because only the owner of a given
|
||||
single-user server is allowed to view user-authored pages served by their
|
||||
server. To protect all users from each other, JupyterHub administrators must
|
||||
ensure that:
|
||||
|
||||
* A user does not have permission to modify their single-user server:
|
||||
- A user may not install new packages in the Python environment that runs
|
||||
their server.
|
||||
- If the PATH is used to resolve the single-user executable (instead of an
|
||||
absolute path), a user may not create new files in any PATH directory
|
||||
that precedes the directory containing jupyterhub-singleuser.
|
||||
- A user may not modify environment variables (e.g. PATH, PYTHONPATH) for
|
||||
their single-user server.
|
||||
* A user may not modify the configuration of the notebook server
|
||||
(the ~/.jupyter or JUPYTER_CONFIG_DIR directory).
|
||||
|
||||
If any additional services are run on the same domain as the Hub, the services
|
||||
must never display user-authored HTML that is neither sanitized nor sandboxed
|
||||
(e.g. IFramed) to any user that lacks authentication as the author of a file.
|
||||
|
||||
|
||||
## Mitigations
|
||||
|
||||
There are two main configuration options provided by JupyterHub to mitigate
|
||||
these issues:
|
||||
|
||||
### Subdomains
|
||||
|
||||
JupyterHub 0.5 adds the ability to run single-user servers on their own
|
||||
subdomains, which means the cross-origin protections between servers has the
|
||||
desired effect, and user servers and the Hub are protected from each other. A
|
||||
user's server will be at `username.jupyter.mydomain.com`, etc. This requires
|
||||
all user subdomains to point to the same address, which is most easily
|
||||
accomplished with wildcard DNS. Since this spreads the service across multiple
|
||||
domains, you will need wildcard SSL, as well. Unfortunately, for many
|
||||
institutional domains, wildcard DNS and SSL are not available, but if you do
|
||||
plan to serve untrusted users, enabling subdomains is highly encouraged, as it
|
||||
resolves all of the cross-site issues.
|
||||
|
||||
### Disabling user config
|
||||
|
||||
If subdomains are not available or not desirable, 0.5 also adds an option
|
||||
`Spawner.disable_user_config`, which you can set to prevent the user-owned
|
||||
configuration files from being loaded. This leaves only package installation
|
||||
and PATHs as things the admin must enforce.
|
||||
|
||||
For most Spawners, PATH is not something users can influence, but care should
|
||||
be taken to ensure that the Spawn does *not* evaluate shell configuration
|
||||
files prior to launching the server.
|
||||
|
||||
Package isolation is most easily handled by running the single-user server in
|
||||
a virtualenv with disabled system-site-packages.
|
||||
|
||||
## Extra notes
|
||||
|
||||
It is important to note that the control over the environment only affects the
|
||||
single-user server, and not the environment(s) in which the user's kernel(s)
|
||||
may run. Installing additional packages in the kernel environment does not
|
||||
pose additional risk to the web application's security.
|
||||
|
||||
[configurable-http-proxy]: https://github.com/jupyterhub/configurable-http-proxy
|
54
docs/sphinxext/autodoc_traits.py
Normal file
54
docs/sphinxext/autodoc_traits.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""autodoc extension for configurable traits"""
|
||||
|
||||
from traitlets import TraitType, Undefined
|
||||
from sphinx.domains.python import PyClassmember
|
||||
from sphinx.ext.autodoc import ClassDocumenter, AttributeDocumenter
|
||||
|
||||
|
||||
class ConfigurableDocumenter(ClassDocumenter):
|
||||
"""Specialized Documenter subclass for traits with config=True"""
|
||||
objtype = 'configurable'
|
||||
directivetype = 'class'
|
||||
|
||||
def get_object_members(self, want_all):
|
||||
"""Add traits with .tag(config=True) to members list"""
|
||||
check, members = super().get_object_members(want_all)
|
||||
get_traits = self.object.class_own_traits if self.options.inherited_members \
|
||||
else self.object.class_traits
|
||||
trait_members = []
|
||||
for name, trait in sorted(get_traits(config=True).items()):
|
||||
# put help in __doc__ where autodoc will look for it
|
||||
trait.__doc__ = trait.help
|
||||
trait_members.append((name, trait))
|
||||
return check, trait_members + members
|
||||
|
||||
|
||||
class TraitDocumenter(AttributeDocumenter):
|
||||
objtype = 'trait'
|
||||
directivetype = 'attribute'
|
||||
member_order = 1
|
||||
priority = 100
|
||||
|
||||
@classmethod
|
||||
def can_document_member(cls, member, membername, isattr, parent):
|
||||
return isinstance(member, TraitType)
|
||||
|
||||
def format_name(self):
|
||||
return 'config c.' + super().format_name()
|
||||
|
||||
def add_directive_header(self, sig):
|
||||
default = self.object.get_default_value()
|
||||
if default is Undefined:
|
||||
default_s = ''
|
||||
else:
|
||||
default_s = repr(default)
|
||||
sig = ' = {}({})'.format(
|
||||
self.object.__class__.__name__,
|
||||
default_s,
|
||||
)
|
||||
return super().add_directive_header(sig)
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.add_autodocumenter(ConfigurableDocumenter)
|
||||
app.add_autodocumenter(TraitDocumenter)
|
130
examples/bootstrap-script/README.md
Normal file
130
examples/bootstrap-script/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Bootstrapping your users
|
||||
|
||||
Before spawning a notebook to the user, it could be useful to
|
||||
do some preparation work in a bootstrapping process.
|
||||
|
||||
Common use cases are:
|
||||
|
||||
*Providing writeable storage for LDAP users*
|
||||
|
||||
Your Jupyterhub is configured to use the LDAPAuthenticator and DockerSpawer.
|
||||
|
||||
* The user has no file directory on the host since your are using LDAP.
|
||||
* When a user has no directory and DockerSpawner wants to mount a volume,
|
||||
the spawner will use docker to create a directory.
|
||||
Since the docker daemon is running as root, the generated directory for the volume
|
||||
mount will not be writeable by the `jovyan` user inside of the container.
|
||||
For the directory to be useful to the user, the permissions on the directory
|
||||
need to be modified for the user to have write access.
|
||||
|
||||
*Prepopulating Content*
|
||||
|
||||
Another use would be to copy initial content, such as tutorial files or reference
|
||||
material, into the user's space when a notebook server is newly spawned.
|
||||
|
||||
You can define your own bootstrap process by implementing a `pre_spawn_hook` on any spawner.
|
||||
The Spawner itself is passed as parameter to your hook and you can easily get the contextual information out of the spawning process.
|
||||
|
||||
If you implement a hook, make sure that it is *idempotent*. It will be executed every time
|
||||
a notebook server is spawned to the user. That means you should somehow
|
||||
ensure that things which should run only once are not running again and again.
|
||||
For example, before you create a directory, check if it exists.
|
||||
|
||||
Bootstrapping examples:
|
||||
|
||||
### Example #1 - Create a user directory
|
||||
|
||||
Create a directory for the user, if none exists
|
||||
|
||||
```python
|
||||
|
||||
# in jupyterhub_config.py
|
||||
import os
|
||||
def create_dir_hook(spawner):
|
||||
username = spawner.user.name # get the username
|
||||
volume_path = os.path.join('/volumes/jupyterhub', username)
|
||||
if not os.path.exists(volume_path):
|
||||
# create a directory with umask 0755
|
||||
# hub and container user must have the same UID to be writeable
|
||||
# still readable by other users on the system
|
||||
os.mkdir(volume_path, 0o755)
|
||||
# now do whatever you think your user needs
|
||||
# ...
|
||||
pass
|
||||
|
||||
# attach the hook function to the spawner
|
||||
c.Spawner.pre_spawn_hook = create_dir_hook
|
||||
```
|
||||
|
||||
### Example #2 - Run a shell script
|
||||
|
||||
You can specify a plain ole' shell script (or any other executable) to be run
|
||||
by the bootstrap process.
|
||||
|
||||
For example, you can execute a shell script and as first parameter pass the name
|
||||
of the user:
|
||||
|
||||
```python
|
||||
|
||||
# in jupyterhub_config.py
|
||||
from subprocess import check_call
|
||||
import os
|
||||
def my_script_hook(spawner):
|
||||
username = spawner.user.name # get the username
|
||||
script = os.path.join(os.path.dirname(__file__), 'bootstrap.sh')
|
||||
check_call([script, username])
|
||||
|
||||
# attach the hook function to the spawner
|
||||
c.Spawner.pre_spawn_hook = my_script_hook
|
||||
|
||||
```
|
||||
|
||||
Here's an example on what you could do in your shell script. See also
|
||||
`/examples/bootstrap-script/`
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# Bootstrap example script
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
# - The first parameter for the Bootstrap Script is the USER.
|
||||
USER=$1
|
||||
if ["$USER" == ""]; then
|
||||
exit 1
|
||||
fi
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# This example script will do the following:
|
||||
# - create one directory for the user $USER in a BASE_DIRECTORY (see below)
|
||||
# - create a "tutorials" directory within and download and unzip
|
||||
# the PythonDataScienceHandbook from GitHub
|
||||
|
||||
# Start the Bootstrap Process
|
||||
echo "bootstrap process running for user $USER ..."
|
||||
|
||||
# Base Directory: All Directories for the user will be below this point
|
||||
BASE_DIRECTORY=/volumes/jupyterhub/
|
||||
|
||||
# User Directory: That's the private directory for the user to be created, if none exists
|
||||
USER_DIRECTORY=$BASE_DIRECTORY/$USER
|
||||
|
||||
if [ -d "$USER_DIRECTORY" ]; then
|
||||
echo "...directory for user already exists. skipped"
|
||||
exit 0 # all good. nothing to do.
|
||||
else
|
||||
echo "...creating a directory for the user: $USER_DIRECTORY"
|
||||
mkdir $USER_DIRECTORY
|
||||
|
||||
echo "...initial content loading for user ..."
|
||||
mkdir $USER_DIRECTORY/tutorials
|
||||
cd $USER_DIRECTORY/tutorials
|
||||
wget https://github.com/jakevdp/PythonDataScienceHandbook/archive/master.zip
|
||||
unzip -o master.zip
|
||||
rm master.zip
|
||||
fi
|
||||
|
||||
exit 0
|
||||
```
|
48
examples/bootstrap-script/bootstrap.sh
Executable file
48
examples/bootstrap-script/bootstrap.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Bootstrap example script
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
# - The first parameter for the Bootstrap Script is the USER.
|
||||
USER=$1
|
||||
if ["$USER" == ""]; then
|
||||
exit 1
|
||||
fi
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# This example script will do the following:
|
||||
# - create one directory for the user $USER in a BASE_DIRECTORY (see below)
|
||||
# - create a "tutorials" directory within and download and unzip the PythonDataScienceHandbook from GitHub
|
||||
|
||||
# Start the Bootstrap Process
|
||||
echo "bootstrap process running for user $USER ..."
|
||||
|
||||
# Base Directory: All Directories for the user will be below this point
|
||||
BASE_DIRECTORY=/volumes/jupyterhub
|
||||
|
||||
# User Directory: That's the private directory for the user to be created, if none exists
|
||||
USER_DIRECTORY=$BASE_DIRECTORY/$USER
|
||||
|
||||
if [ -d "$USER_DIRECTORY" ]; then
|
||||
echo "...directory for user already exists. skipped"
|
||||
exit 0 # all good. nothing to do.
|
||||
else
|
||||
echo "...creating a directory for the user: $USER_DIRECTORY"
|
||||
mkdir $USER_DIRECTORY
|
||||
|
||||
# mkdir did not succeed?
|
||||
if [ $? -ne 0 ] ; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "...initial content loading for user ..."
|
||||
mkdir $USER_DIRECTORY/tutorials
|
||||
cd $USER_DIRECTORY/tutorials
|
||||
wget https://github.com/jakevdp/PythonDataScienceHandbook/archive/master.zip
|
||||
unzip -o master.zip
|
||||
rm master.zip
|
||||
fi
|
||||
|
||||
exit 0
|
26
examples/bootstrap-script/jupyterhub_config.py
Normal file
26
examples/bootstrap-script/jupyterhub_config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Example for a Spawner.pre_spawn_hook
|
||||
# create a directory for the user before the spawner starts
|
||||
|
||||
import os
|
||||
def create_dir_hook(spawner):
|
||||
username = spawner.user.name # get the username
|
||||
volume_path = os.path.join('/volumes/jupyterhub', username)
|
||||
if not os.path.exists(volume_path):
|
||||
os.mkdir(volume_path, 0o755)
|
||||
# now do whatever you think your user needs
|
||||
# ...
|
||||
|
||||
# attach the hook function to the spawner
|
||||
c.Spawner.pre_spawn_hook = create_dir_hook
|
||||
|
||||
# Use the DockerSpawner to serve your users' notebooks
|
||||
c.JupyterHub.spawner_class = 'dockerspawner.DockerSpawner'
|
||||
from jupyter_client.localinterfaces import public_ips
|
||||
c.JupyterHub.hub_ip = public_ips()[0]
|
||||
c.DockerSpawner.hub_ip_connect = public_ips()[0]
|
||||
c.DockerSpawner.container_ip = "0.0.0.0"
|
||||
|
||||
# You can now mount the volume to the docker container as we've
|
||||
# made sure the directory exists
|
||||
c.DockerSpawner.volumes = { '/volumes/jupyterhub/{username}/': '/home/jovyan/work' }
|
||||
|
25
examples/service-notebook/README.md
Normal file
25
examples/service-notebook/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Running a shared notebook as a service
|
||||
|
||||
This directory contains two examples of running a shared notebook server as a service,
|
||||
one as a 'managed' service, and one as an external service with supervisor.
|
||||
|
||||
These examples require jupyterhub >= 0.7.2.
|
||||
|
||||
A single-user notebook server is run as a service,
|
||||
and uses groups to authenticate a collection of users with the Hub.
|
||||
|
||||
In these examples, a JupyterHub group `'shared'` is created,
|
||||
and a notebook server is spawned at `/services/shared-notebook`.
|
||||
Any user in the `'shared'` group will be able to access the notebook server at `/services/shared-notebook/`.
|
||||
|
||||
In both examples, you will want to select the name of the group,
|
||||
and the name of the shared-notebook service.
|
||||
|
||||
In the external example, some extra steps are required to set up supervisor:
|
||||
|
||||
1. select a system user to run the service. This is a user on the system, and does not need to be a Hub user. Add this to the user field in `shared-notebook.conf`, replacing `someuser`.
|
||||
2. generate a secret token for authentication, and replace the `super-secret` fields in `shared-notebook-service` and `jupyterhub_config.py`
|
||||
3. install `shared-notebook-service` somewhere on your system, and update `/path/to/shared-notebook-service` to the absolute path of this destination
|
||||
3. copy `shared-notebook.conf` to `/etc/supervisor/conf.d/`
|
||||
4. `supervisorctl reload`
|
||||
|
24
examples/service-notebook/external/jupyterhub_config.py
vendored
Normal file
24
examples/service-notebook/external/jupyterhub_config.py
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# our user list
|
||||
c.Authenticator.whitelist = [
|
||||
'minrk',
|
||||
'ellisonbg',
|
||||
'willingc',
|
||||
]
|
||||
|
||||
# ellisonbg and willingc have access to a shared server:
|
||||
|
||||
c.JupyterHub.load_groups = {
|
||||
'shared': [
|
||||
'ellisonbg',
|
||||
'willingc',
|
||||
]
|
||||
}
|
||||
|
||||
# start the notebook server as a service
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'shared-notebook',
|
||||
'url': 'http://127.0.0.1:9999',
|
||||
'api_token': 'super-secret',
|
||||
}
|
||||
]
|
9
examples/service-notebook/external/shared-notebook-service
vendored
Executable file
9
examples/service-notebook/external/shared-notebook-service
vendored
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash -l
|
||||
set -e
|
||||
|
||||
export JUPYTERHUB_API_TOKEN=super-secret
|
||||
export JUPYTERHUB_SERVICE_URL=http://127.0.0.1:9999
|
||||
export JUPYTERHUB_SERVICE_NAME=shared-notebook
|
||||
|
||||
jupyterhub-singleuser \
|
||||
--group='shared'
|
14
examples/service-notebook/external/shared-notebook.conf
vendored
Normal file
14
examples/service-notebook/external/shared-notebook.conf
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
[program:jupyterhub-shared-notebook]
|
||||
user=someuser
|
||||
command=bash -l /path/to/shared-notebook-service
|
||||
directory=/home/someuser
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startretries=1
|
||||
exitcodes=0,2
|
||||
stopsignal=TERM
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/jupyterhub-service-shared-notebook.log
|
||||
stdout_logfile_maxbytes=1MB
|
||||
stdout_logfile_backups=10
|
||||
stdout_capture_maxbytes=1MB
|
32
examples/service-notebook/managed/jupyterhub_config.py
Normal file
32
examples/service-notebook/managed/jupyterhub_config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# our user list
|
||||
c.Authenticator.whitelist = [
|
||||
'minrk',
|
||||
'ellisonbg',
|
||||
'willingc',
|
||||
]
|
||||
|
||||
# ellisonbg and willingc have access to a shared server:
|
||||
|
||||
c.JupyterHub.load_groups = {
|
||||
'shared': [
|
||||
'ellisonbg',
|
||||
'willingc',
|
||||
]
|
||||
}
|
||||
|
||||
service_name = 'shared-notebook'
|
||||
service_port = 9999
|
||||
group_name = 'shared'
|
||||
|
||||
# start the notebook server as a service
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': service_name,
|
||||
'url': 'http://127.0.0.1:{}'.format(service_port),
|
||||
'command': [
|
||||
'jupyterhub-singleuser',
|
||||
'--group=shared',
|
||||
'--debug',
|
||||
],
|
||||
}
|
||||
]
|
@@ -8,7 +8,7 @@ Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [f
|
||||
|
||||
jupyterhub --ip=127.0.0.1
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/whoami
|
||||
2. Visit http://127.0.0.1:8000/services/whoami or http://127.0.0.1:8000/services/whoami-oauth
|
||||
|
||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||
|
||||
|
@@ -9,5 +9,13 @@ c.JupyterHub.services = [
|
||||
'environment': {
|
||||
'FLASK_APP': 'whoami-flask.py',
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'whoami-oauth',
|
||||
'url': 'http://127.0.0.1:10201',
|
||||
'command': ['flask', 'run', '--port=10201'],
|
||||
'environment': {
|
||||
'FLASK_APP': 'whoami-oauth.py',
|
||||
}
|
||||
},
|
||||
]
|
||||
|
@@ -17,7 +17,7 @@ prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||
|
||||
auth = HubAuth(
|
||||
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
||||
cookie_cache_max_age=60,
|
||||
cache_max_age=60,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
70
examples/service-whoami-flask/whoami-oauth.py
Normal file
70
examples/service-whoami-flask/whoami-oauth.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
whoami service authentication with the Hub
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Flask, redirect, request, Response, make_response
|
||||
|
||||
from jupyterhub.services.auth import HubOAuth
|
||||
|
||||
|
||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||
|
||||
auth = HubOAuth(
|
||||
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
||||
cache_max_age=60,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def authenticated(f):
|
||||
"""Decorator for authenticating with the Hub via OAuth"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = request.cookies.get(auth.cookie_name)
|
||||
if token:
|
||||
user = auth.user_for_token(token)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
return f(user, *args, **kwargs)
|
||||
else:
|
||||
# redirect to login url on failed auth
|
||||
state = auth.generate_state(next_url=request.path)
|
||||
response = make_response(redirect(auth.login_url + '&state=%s' % state))
|
||||
response.set_cookie(auth.state_cookie_name, state)
|
||||
return response
|
||||
return decorated
|
||||
|
||||
|
||||
@app.route(prefix)
|
||||
@authenticated
|
||||
def whoami(user):
|
||||
return Response(
|
||||
json.dumps(user, indent=1, sort_keys=True),
|
||||
mimetype='application/json',
|
||||
)
|
||||
|
||||
@app.route(prefix + 'oauth_callback')
|
||||
def oauth_callback():
|
||||
code = request.args.get('code', None)
|
||||
if code is None:
|
||||
return 403
|
||||
|
||||
# validate state field
|
||||
arg_state = request.args.get('state', None)
|
||||
cookie_state = request.cookies.get(auth.state_cookie_name)
|
||||
if arg_state != cookie_state:
|
||||
# state doesn't match
|
||||
return 403
|
||||
|
||||
token = auth.token_for_code(code)
|
||||
next_url = auth.get_next_url(cookie_state) or prefix
|
||||
response = make_response(redirect(next_url))
|
||||
response.set_cookie(auth.cookie_name, token)
|
||||
return response
|
@@ -2,13 +2,15 @@
|
||||
|
||||
Uses `jupyterhub.services.HubAuthenticated` to authenticate requests with the Hub.
|
||||
|
||||
There is an implementation each of cookie-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
|
||||
|
||||
## Run
|
||||
|
||||
1. Launch JupyterHub and the `whoami service` with
|
||||
|
||||
jupyterhub --ip=127.0.0.1
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/whoami
|
||||
2. Visit http://127.0.0.1:8000/services/whoami or http://127.0.0.1:8000/services/whoami-oauth
|
||||
|
||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||
|
||||
|
@@ -6,5 +6,10 @@ c.JupyterHub.services = [
|
||||
'name': 'whoami',
|
||||
'url': 'http://127.0.0.1:10101',
|
||||
'command': [sys.executable, './whoami.py'],
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'whoami-oauth',
|
||||
'url': 'http://127.0.0.1:10102',
|
||||
'command': [sys.executable, './whoami-oauth.py'],
|
||||
},
|
||||
]
|
||||
|
@@ -13,10 +13,10 @@ from tornado.ioloop import IOLoop
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.web import RequestHandler, Application, authenticated
|
||||
|
||||
from jupyterhub.services.auth import HubAuthenticated
|
||||
from jupyterhub.services.auth import HubOAuthenticated, HubOAuthCallbackHandler
|
||||
from jupyterhub.utils import url_path_join
|
||||
|
||||
|
||||
class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
||||
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
|
||||
hub_users = {getuser()} # the users allowed to access this service
|
||||
|
||||
@authenticated
|
||||
@@ -27,9 +27,10 @@ class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
||||
|
||||
def main():
|
||||
app = Application([
|
||||
(os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', WhoAmIHandler),
|
||||
(os.environ['JUPYTERHUB_SERVICE_PREFIX'], WhoAmIHandler),
|
||||
(url_path_join(os.environ['JUPYTERHUB_SERVICE_PREFIX'], 'oauth_callback'), HubOAuthCallbackHandler),
|
||||
(r'.*', WhoAmIHandler),
|
||||
], login_url='/hub/login')
|
||||
], cookie_secret=os.urandom(32))
|
||||
|
||||
http_server = HTTPServer(app)
|
||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
@@ -27,7 +27,7 @@ def main():
|
||||
app = Application([
|
||||
(os.environ['JUPYTERHUB_SERVICE_PREFIX'] + '/?', WhoAmIHandler),
|
||||
(r'.*', WhoAmIHandler),
|
||||
], login_url='/hub/login')
|
||||
])
|
||||
|
||||
http_server = HTTPServer(app)
|
||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||
|
@@ -1,2 +1 @@
|
||||
from .version import version_info, __version__
|
||||
|
||||
from ._version import version_info, __version__
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Get the data files for this package."""
|
||||
|
||||
|
||||
def get_data_files():
|
||||
"""Walk up until we find share/jupyter/hub"""
|
||||
import sys
|
||||
@@ -21,4 +22,3 @@ def get_data_files():
|
||||
|
||||
# Package managers can just override this with the appropriate constant
|
||||
DATA_FILES_PATH = get_data_files()
|
||||
|
||||
|
42
jupyterhub/_version.py
Normal file
42
jupyterhub/_version.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""JupyterHub version info"""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
version_info = (
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
'b3',
|
||||
)
|
||||
|
||||
__version__ = '.'.join(map(str, version_info))
|
||||
|
||||
|
||||
def _check_version(hub_version, singleuser_version, log):
|
||||
"""Compare Hub and single-user server versions"""
|
||||
if not hub_version:
|
||||
log.warning("Hub has no version header, which means it is likely < 0.8. Expected %s", __version__)
|
||||
return
|
||||
|
||||
if not singleuser_version:
|
||||
log.warning("Single-user server has no version header, which means it is likely < 0.8. Expected %s", __version__)
|
||||
return
|
||||
|
||||
# compare minor X.Y versions
|
||||
if hub_version != singleuser_version:
|
||||
from distutils.version import LooseVersion as V
|
||||
hub_major_minor = V(hub_version).version[:2]
|
||||
singleuser_major_minor = V(singleuser_version).version[:2]
|
||||
if singleuser_major_minor == hub_major_minor:
|
||||
# patch-level mismatch or lower, log difference at debug-level
|
||||
# because this should be fine
|
||||
log_method = log.debug
|
||||
else:
|
||||
# log warning-level for more significant mismatch, such as 0.8 vs 0.9, etc.
|
||||
log_method = log.warning
|
||||
log_method("jupyterhub version %s != jupyterhub-singleuser version %s",
|
||||
hub_version, singleuser_version,
|
||||
)
|
||||
else:
|
||||
log.debug("jupyterhub and jupyterhub-singleuser both on version %s" % hub_version)
|
@@ -62,5 +62,6 @@ level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
class = jupyterhub.log.CoroutineLogFormatter
|
||||
format = %(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s
|
||||
datefmt = %Y-%m-%d %H:%M:%S
|
||||
|
@@ -1,6 +1,8 @@
|
||||
from __future__ import with_statement
|
||||
import sys
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
@@ -9,7 +11,17 @@ config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
if 'jupyterhub' in sys.modules:
|
||||
from jupyterhub.app import JupyterHub
|
||||
if JupyterHub.initialized():
|
||||
app = JupyterHub.instance()
|
||||
alembic_logger = logging.getLogger('alembic')
|
||||
alembic_logger.propagate = True
|
||||
alembic_logger.parent = app.log
|
||||
else:
|
||||
fileConfig(config.config_file_name)
|
||||
else:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
|
@@ -0,0 +1,64 @@
|
||||
"""0.8 changes
|
||||
|
||||
- encrypted auth_state
|
||||
- remove proxy/hub data from db
|
||||
|
||||
OAuth data was also added in this revision,
|
||||
but no migration to do because they are entirely new tables,
|
||||
which will be created on launch.
|
||||
|
||||
Revision ID: 3ec6993fe20c
|
||||
Revises: af4cbdb2d13c
|
||||
Create Date: 2017-07-28 16:44:40.413648
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3ec6993fe20c'
|
||||
down_revision = 'af4cbdb2d13c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('alembic')
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from jupyterhub.orm import JSONDict
|
||||
|
||||
|
||||
def upgrade():
|
||||
# proxy/table info is no longer in the database
|
||||
op.drop_table('proxies')
|
||||
op.drop_table('hubs')
|
||||
|
||||
# drop some columns no longer in use
|
||||
try:
|
||||
op.drop_column('users', 'auth_state')
|
||||
op.drop_column('users', '_server_id')
|
||||
except sa.exc.OperationalError:
|
||||
# this won't be a problem moving forward, but downgrade will fail
|
||||
if op.get_context().dialect.name == 'sqlite':
|
||||
logger.warning("sqlite cannot drop columns. Leaving unused old columns in place.")
|
||||
else:
|
||||
raise
|
||||
|
||||
op.add_column('users', sa.Column('encrypted_auth_state', sa.types.LargeBinary))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# drop all the new tables
|
||||
engine = op.get_bind().engine
|
||||
for table in ('oauth_clients',
|
||||
'oauth_codes',
|
||||
'oauth_access_tokens',
|
||||
'spawners'):
|
||||
if engine.has_table(table):
|
||||
op.drop_table(table)
|
||||
|
||||
op.drop_column('users', 'encrypted_auth_state')
|
||||
|
||||
op.add_column('users', sa.Column('auth_state', JSONDict))
|
||||
op.add_column('users', sa.Column('_server_id', sa.Integer, sa.ForeignKey('servers.id')))
|
||||
|
@@ -6,32 +6,51 @@
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from oauth2.web.tornado import OAuth2Handler
|
||||
from tornado import web, gen
|
||||
|
||||
from .. import orm
|
||||
from ..utils import token_authenticated
|
||||
from .base import APIHandler
|
||||
from .base import BaseHandler, APIHandler
|
||||
|
||||
|
||||
class TokenAPIHandler(APIHandler):
|
||||
@token_authenticated
|
||||
def get(self, token):
|
||||
orm_token = orm.APIToken.find(self.db, token)
|
||||
if orm_token is None:
|
||||
orm_token = orm.OAuthAccessToken.find(self.db, token)
|
||||
if orm_token is None:
|
||||
raise web.HTTPError(404)
|
||||
self.write(json.dumps(self.user_model(self.users[orm_token.user])))
|
||||
if orm_token.user:
|
||||
model = self.user_model(self.users[orm_token.user])
|
||||
elif orm_token.service:
|
||||
model = self.service_model(orm_token.service)
|
||||
else:
|
||||
self.log.warning("%s has no user or service. Deleting..." % orm_token)
|
||||
self.db.delete(orm_token)
|
||||
self.db.commit()
|
||||
raise web.HTTPError(404)
|
||||
self.write(json.dumps(model))
|
||||
|
||||
@gen.coroutine
|
||||
def post(self):
|
||||
if self.authenticator is not None:
|
||||
data = self.get_json_body()
|
||||
username = yield self.authenticator.authenticate(self, data)
|
||||
if username is None:
|
||||
raise web.HTTPError(403)
|
||||
user = self.find_user(username)
|
||||
api_token = user.new_api_token()
|
||||
self.write(json.dumps({"Authentication":api_token}))
|
||||
else:
|
||||
raise web.HTTPError(404)
|
||||
user = self.get_current_user()
|
||||
if user is None:
|
||||
# allow requesting a token with username and password
|
||||
# for authenticators where that's possible
|
||||
data = self.get_json_body()
|
||||
try:
|
||||
authenticated = yield self.authenticate(self, data)
|
||||
except Exception as e:
|
||||
self.log.error("Failure trying to authenticate with form data: %s" % e)
|
||||
authenticated = None
|
||||
if authenticated is None:
|
||||
raise web.HTTPError(403)
|
||||
user = self.find_user(authenticated['name'])
|
||||
api_token = user.new_api_token()
|
||||
self.write(json.dumps({'token': api_token}))
|
||||
|
||||
|
||||
class CookieAPIHandler(APIHandler):
|
||||
@token_authenticated
|
||||
@@ -48,8 +67,24 @@ class CookieAPIHandler(APIHandler):
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
|
||||
|
||||
class OAuthHandler(BaseHandler, OAuth2Handler):
|
||||
"""Implement OAuth provider handlers
|
||||
|
||||
OAuth2Handler sets `self.provider` in initialize,
|
||||
but we are already passing the Provider object via settings.
|
||||
"""
|
||||
@property
|
||||
def provider(self):
|
||||
return self.settings['oauth_provider']
|
||||
|
||||
def initialize(self):
|
||||
pass
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler),
|
||||
(r"/api/authorizations/token/([^/]+)", TokenAPIHandler),
|
||||
(r"/api/authorizations/token", TokenAPIHandler),
|
||||
(r"/api/oauth2/authorize", OAuthHandler),
|
||||
(r"/api/oauth2/token", OAuthHandler),
|
||||
]
|
||||
|
@@ -13,6 +13,14 @@ from ..utils import url_path_join
|
||||
|
||||
class APIHandler(BaseHandler):
|
||||
|
||||
@property
|
||||
def content_security_policy(self):
|
||||
return '; '.join([super().content_security_policy, "default-src 'none'"])
|
||||
|
||||
def set_default_headers(self):
|
||||
self.set_header('Content-Type', 'application/json')
|
||||
super().set_default_headers()
|
||||
|
||||
def check_referer(self):
|
||||
"""Check Origin for cross-site API requests.
|
||||
|
||||
@@ -32,7 +40,7 @@ class APIHandler(BaseHandler):
|
||||
self.log.warning("Blocking API request with no referer")
|
||||
return False
|
||||
|
||||
host_path = url_path_join(host, self.hub.server.base_url)
|
||||
host_path = url_path_join(host, self.hub.base_url)
|
||||
referer_path = referer.split('://', 1)[-1]
|
||||
if not (referer_path + '/').startswith(host_path):
|
||||
self.log.warning("Blocking Cross Origin API request. Referer: %s, Host: %s",
|
||||
@@ -80,7 +88,6 @@ class APIHandler(BaseHandler):
|
||||
reason = getattr(exception, 'reason', '')
|
||||
if reason:
|
||||
status_message = reason
|
||||
self.set_header('Content-Type', 'application/json')
|
||||
self.write(json.dumps({
|
||||
'status': status_code,
|
||||
'message': message or status_message,
|
||||
@@ -89,6 +96,7 @@ class APIHandler(BaseHandler):
|
||||
def user_model(self, user):
|
||||
"""Get the JSON model for a User object"""
|
||||
model = {
|
||||
'kind': 'user',
|
||||
'name': user.name,
|
||||
'admin': user.admin,
|
||||
'groups': [ g.name for g in user.groups ],
|
||||
@@ -96,17 +104,33 @@ class APIHandler(BaseHandler):
|
||||
'pending': None,
|
||||
'last_activity': user.last_activity.isoformat(),
|
||||
}
|
||||
if user.spawn_pending:
|
||||
model['pending'] = 'spawn'
|
||||
elif user.stop_pending:
|
||||
model['pending'] = 'stop'
|
||||
model['pending'] = user.spawners[''].pending or None
|
||||
|
||||
if self.allow_named_servers:
|
||||
servers = model['servers'] = {}
|
||||
for name, spawner in user.spawners.items():
|
||||
if spawner.ready:
|
||||
servers[name] = s = {'name': name}
|
||||
if spawner.pending:
|
||||
s['pending'] = spawner.pending
|
||||
if spawner.server:
|
||||
s['url'] = user.url + name + '/'
|
||||
return model
|
||||
|
||||
def group_model(self, group):
|
||||
"""Get the JSON model for a Group object"""
|
||||
return {
|
||||
'kind': 'group',
|
||||
'name': group.name,
|
||||
'users': [ u.name for u in group.users ]
|
||||
'users': [ u.name for u in group.users ],
|
||||
}
|
||||
|
||||
def service_model(self, service):
|
||||
"""Get the JSON model for a Service object"""
|
||||
return {
|
||||
'kind': 'service',
|
||||
'name': service.name,
|
||||
'admin': service.admin,
|
||||
}
|
||||
|
||||
_user_model_types = {
|
||||
@@ -152,6 +176,7 @@ class APIHandler(BaseHandler):
|
||||
if not isinstance(groupname, str):
|
||||
raise web.HTTPError(400, ("group names must be str, not %r", type(groupname)))
|
||||
|
||||
|
||||
def options(self, *args, **kwargs):
|
||||
self.set_header('Access-Control-Allow-Headers', 'accept, content-type')
|
||||
self.finish()
|
||||
|
@@ -11,7 +11,7 @@ from tornado.ioloop import IOLoop
|
||||
|
||||
from ..utils import admin_only
|
||||
from .base import APIHandler
|
||||
from ..version import __version__
|
||||
from .._version import __version__
|
||||
|
||||
|
||||
class ShutdownAPIHandler(APIHandler):
|
||||
|
@@ -4,6 +4,7 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from tornado import gen, web
|
||||
|
||||
@@ -11,25 +12,29 @@ from .. import orm
|
||||
from ..utils import admin_only
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
class ProxyAPIHandler(APIHandler):
|
||||
|
||||
@admin_only
|
||||
@gen.coroutine
|
||||
def get(self):
|
||||
"""GET /api/proxy fetches the routing table
|
||||
|
||||
|
||||
This is the same as fetching the routing table directly from the proxy,
|
||||
but without clients needing to maintain separate
|
||||
"""
|
||||
routes = yield self.proxy.get_routes()
|
||||
routes = yield self.proxy.get_all_routes()
|
||||
self.write(json.dumps(routes))
|
||||
|
||||
|
||||
@admin_only
|
||||
@gen.coroutine
|
||||
def post(self):
|
||||
"""POST checks the proxy to ensure"""
|
||||
"""POST checks the proxy to ensure that it's up to date.
|
||||
|
||||
Can be used to jumpstart a newly launched proxy
|
||||
without waiting for the check_routes interval.
|
||||
"""
|
||||
yield self.proxy.check_routes(self.users, self.services)
|
||||
|
||||
|
||||
@admin_only
|
||||
@gen.coroutine
|
||||
@@ -48,17 +53,11 @@ class ProxyAPIHandler(APIHandler):
|
||||
if not isinstance(model, dict):
|
||||
raise web.HTTPError(400, "Request body must be JSON dict")
|
||||
|
||||
server = self.proxy.api_server
|
||||
if 'ip' in model:
|
||||
server.ip = model['ip']
|
||||
if 'port' in model:
|
||||
server.port = model['port']
|
||||
if 'protocol' in model:
|
||||
server.proto = model['protocol']
|
||||
if 'api_url' in model:
|
||||
self.proxy.api_url = model['api_url']
|
||||
if 'auth_token' in model:
|
||||
self.proxy.auth_token = model['auth_token']
|
||||
self.db.commit()
|
||||
self.log.info("Updated proxy at %s", server.bind_url)
|
||||
self.log.info("Updated proxy at %s", self.proxy)
|
||||
yield self.proxy.check_routes(self.users, self.services)
|
||||
|
||||
|
||||
|
@@ -12,6 +12,22 @@ from ..utils import admin_only
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
class SelfAPIHandler(APIHandler):
|
||||
"""Return the authenticated user's model
|
||||
|
||||
Based on the authentication info. Acts as a 'whoami' for auth tokens.
|
||||
"""
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
user = self.get_current_user()
|
||||
if user is None:
|
||||
# whoami can be accessed via oauth token
|
||||
user = self.get_current_user_oauth_token()
|
||||
if user is None:
|
||||
raise web.HTTPError(403)
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
|
||||
|
||||
class UserListAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
def get(self):
|
||||
@@ -76,7 +92,7 @@ class UserListAPIHandler(APIHandler):
|
||||
|
||||
def admin_or_self(method):
|
||||
"""Decorator for restricting access to either the target user or admin"""
|
||||
def m(self, name):
|
||||
def m(self, name, *args, **kwargs):
|
||||
current = self.get_current_user()
|
||||
if current is None:
|
||||
raise web.HTTPError(403)
|
||||
@@ -86,7 +102,7 @@ def admin_or_self(method):
|
||||
# raise 404 if not found
|
||||
if not self.find_user(name):
|
||||
raise web.HTTPError(404)
|
||||
return method(self, name)
|
||||
return method(self, name, *args, **kwargs)
|
||||
return m
|
||||
|
||||
class UserAPIHandler(APIHandler):
|
||||
@@ -130,19 +146,19 @@ class UserAPIHandler(APIHandler):
|
||||
raise web.HTTPError(404)
|
||||
if user.name == self.get_current_user().name:
|
||||
raise web.HTTPError(400, "Cannot delete yourself!")
|
||||
if user.stop_pending:
|
||||
if user.spawner._stop_pending:
|
||||
raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name)
|
||||
if user.running:
|
||||
yield self.stop_single_user(user)
|
||||
if user.stop_pending:
|
||||
if user.spawner._stop_pending:
|
||||
raise web.HTTPError(400, "%s's server is in the process of stopping, please wait." % name)
|
||||
|
||||
yield gen.maybe_future(self.authenticator.delete_user(user))
|
||||
# remove from registry
|
||||
del self.users[user]
|
||||
|
||||
|
||||
self.set_status(204)
|
||||
|
||||
|
||||
@admin_only
|
||||
def patch(self, name):
|
||||
user = self.find_user(name)
|
||||
@@ -150,6 +166,10 @@ class UserAPIHandler(APIHandler):
|
||||
raise web.HTTPError(404)
|
||||
data = self.get_json_body()
|
||||
self._check_user_model(data)
|
||||
if 'name' in data and data['name'] != name:
|
||||
# check if the new name is already taken inside db
|
||||
if self.find_user(data['name']):
|
||||
raise web.HTTPError(400, "User %s already exists, username must be unique" % data['name'])
|
||||
for key, value in data.items():
|
||||
setattr(user, key, value)
|
||||
self.db.commit()
|
||||
@@ -157,38 +177,74 @@ class UserAPIHandler(APIHandler):
|
||||
|
||||
|
||||
class UserServerAPIHandler(APIHandler):
|
||||
@gen.coroutine
|
||||
@admin_or_self
|
||||
def post(self, name):
|
||||
user = self.find_user(name)
|
||||
if user.running:
|
||||
# include notify, so that a server that died is noticed immediately
|
||||
state = yield user.spawner.poll_and_notify()
|
||||
if state is None:
|
||||
raise web.HTTPError(400, "%s's server is already running" % name)
|
||||
|
||||
options = self.get_json_body()
|
||||
yield self.spawn_single_user(user, options=options)
|
||||
status = 202 if user.spawn_pending else 201
|
||||
self.set_status(status)
|
||||
"""Start and stop single-user servers"""
|
||||
|
||||
@gen.coroutine
|
||||
@admin_or_self
|
||||
def delete(self, name):
|
||||
def post(self, name, server_name=''):
|
||||
user = self.find_user(name)
|
||||
if user.stop_pending:
|
||||
if server_name and not self.allow_named_servers:
|
||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||
if self.allow_named_servers and not server_name:
|
||||
server_name = user.default_server_name()
|
||||
spawner = user.spawners[server_name]
|
||||
pending = spawner.pending
|
||||
if pending == 'spawn':
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(202)
|
||||
return
|
||||
if not user.running:
|
||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||
# include notify, so that a server that died is noticed immediately
|
||||
status = yield user.spawner.poll_and_notify()
|
||||
if status is not None:
|
||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||
yield self.stop_single_user(user)
|
||||
status = 202 if user.stop_pending else 204
|
||||
elif pending:
|
||||
raise web.HTTPError(400, "%s is pending %s" % (spawner._log_name, pending))
|
||||
|
||||
if spawner.ready:
|
||||
# include notify, so that a server that died is noticed immediately
|
||||
# set _spawn_pending flag to prevent races while we wait
|
||||
spawner._spawn_pending = True
|
||||
try:
|
||||
state = yield spawner.poll_and_notify()
|
||||
finally:
|
||||
spawner._spawn_pending = False
|
||||
if state is None:
|
||||
raise web.HTTPError(400, "%s is already running" % spawner._log_name)
|
||||
|
||||
options = self.get_json_body()
|
||||
yield self.spawn_single_user(user, server_name, options=options)
|
||||
status = 202 if spawner.pending == 'spawn' else 201
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(status)
|
||||
|
||||
@gen.coroutine
|
||||
@admin_or_self
|
||||
def delete(self, name, server_name=''):
|
||||
user = self.find_user(name)
|
||||
if server_name:
|
||||
if not self.allow_named_servers:
|
||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||
if server_name not in user.spawners:
|
||||
raise web.HTTPError(404, "%s has no server named '%s'" % (name, server_name))
|
||||
|
||||
spawner = user.spawners[server_name]
|
||||
if spawner.pending == 'stop':
|
||||
self.log.debug("%s already stopping", spawner._log_name)
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(202)
|
||||
return
|
||||
|
||||
if not spawner.ready:
|
||||
raise web.HTTPError(
|
||||
400, "%s is not running %s" %
|
||||
(spawner._log_name, '(pending: %s)' % spawner.pending if spawner.pending else '')
|
||||
)
|
||||
# include notify, so that a server that died is noticed immediately
|
||||
status = yield spawner.poll_and_notify()
|
||||
if status is not None:
|
||||
raise web.HTTPError(400, "%s is not running" % spawner._log_name)
|
||||
yield self.stop_single_user(user, server_name)
|
||||
status = 202 if spawner._stop_pending else 204
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(status)
|
||||
|
||||
|
||||
class UserAdminAccessAPIHandler(APIHandler):
|
||||
"""Grant admins access to single-user servers
|
||||
|
||||
@@ -196,6 +252,8 @@ class UserAdminAccessAPIHandler(APIHandler):
|
||||
"""
|
||||
@admin_only
|
||||
def post(self, name):
|
||||
self.log.warning("Deprecated in JupyterHub 0.8."
|
||||
" Admin access API is not needed now that we use OAuth.")
|
||||
current = self.get_current_user()
|
||||
self.log.warning("Admin user %s has requested access to %s's server",
|
||||
current.name, name,
|
||||
@@ -205,15 +263,13 @@ class UserAdminAccessAPIHandler(APIHandler):
|
||||
user = self.find_user(name)
|
||||
if user is None:
|
||||
raise web.HTTPError(404)
|
||||
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 = [
|
||||
(r"/api/user", SelfAPIHandler),
|
||||
(r"/api/users", UserListAPIHandler),
|
||||
(r"/api/users/([^/]+)", UserAPIHandler),
|
||||
(r"/api/users/([^/]+)/server", UserServerAPIHandler),
|
||||
(r"/api/users/([^/]+)/servers/([^/]*)", UserServerAPIHandler),
|
||||
(r"/api/users/([^/]+)/admin-access", UserAdminAccessAPIHandler),
|
||||
]
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -3,16 +3,18 @@
|
||||
# Copyright (c) IPython Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from grp import getgrnam
|
||||
import pipes
|
||||
import pwd
|
||||
import re
|
||||
from shutil import which
|
||||
import sys
|
||||
from subprocess import Popen, PIPE, STDOUT
|
||||
|
||||
from tornado import gen
|
||||
import pamela
|
||||
try:
|
||||
import pamela
|
||||
except Exception as e:
|
||||
pamela = None
|
||||
_pamela_error = e
|
||||
|
||||
from traitlets.config import LoggingConfigurable
|
||||
from traitlets import Bool, Set, Unicode, Dict, Any, default, observe
|
||||
@@ -22,16 +24,44 @@ from .utils import url_path_join
|
||||
from .traitlets import Command
|
||||
|
||||
|
||||
|
||||
def getgrnam(name):
|
||||
"""Wrapper function to protect against `grp` not being available
|
||||
on Windows
|
||||
"""
|
||||
import grp
|
||||
return grp.getgrnam(name)
|
||||
|
||||
|
||||
class Authenticator(LoggingConfigurable):
|
||||
"""Base class for implementing an authentication provider for JupyterHub"""
|
||||
|
||||
db = Any()
|
||||
|
||||
enable_auth_state = Bool(False, config=True,
|
||||
help="""Enable persisting auth_state (if available).
|
||||
|
||||
auth_state will be encrypted and stored in the Hub's database.
|
||||
This can include things like authentication tokens, etc.
|
||||
to be passed to Spawners as environment variables.
|
||||
|
||||
Encrypting auth_state requires the cryptography package.
|
||||
|
||||
Additionally, the JUPYTERHUB_CRYPTO_KEY envirionment variable must
|
||||
contain one (or more, separated by ;) 32B encryption keys.
|
||||
These can be either base64 or hex-encoded.
|
||||
|
||||
If encryption is unavailable, auth_state cannot be persisted.
|
||||
|
||||
New in JupyterHub 0.8
|
||||
""",
|
||||
)
|
||||
|
||||
admin_users = Set(
|
||||
help="""
|
||||
Set of users that will have admin rights on this JupyterHub.
|
||||
|
||||
Admin users have extra privilages:
|
||||
Admin users have extra privileges:
|
||||
- Use the admin panel to see list of users logged in
|
||||
- Add / remove users in some authenticators
|
||||
- Restart / halt the hub
|
||||
@@ -125,6 +155,23 @@ class Authenticator(LoggingConfigurable):
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
delete_invalid_users = Bool(False,
|
||||
help="""Delete any users from the database that do not pass validation
|
||||
|
||||
When JupyterHub starts, `.add_user` will be called
|
||||
on each user in the database to verify that all users are still valid.
|
||||
|
||||
If `delete_invalid_users` is True,
|
||||
any users that do not pass validation will be deleted from the database.
|
||||
Use this if users might be deleted from an external system,
|
||||
such as local user accounts.
|
||||
|
||||
If False (default), invalid users remain in the Hub's database
|
||||
and a warning will be issued.
|
||||
This is the default to avoid data loss due to config changes.
|
||||
"""
|
||||
)
|
||||
|
||||
def normalize_username(self, username):
|
||||
"""Normalize the given username and return it
|
||||
|
||||
@@ -149,36 +196,49 @@ class Authenticator(LoggingConfigurable):
|
||||
# No whitelist means any name is allowed
|
||||
return True
|
||||
return username in self.whitelist
|
||||
|
||||
|
||||
@gen.coroutine
|
||||
def get_authenticated_user(self, handler, data):
|
||||
"""Authenticate the user who is attempting to log in
|
||||
|
||||
Returns normalized username if successful, None otherwise.
|
||||
Returns user dict if successful, None otherwise.
|
||||
|
||||
This calls `authenticate`, which should be overridden in subclasses,
|
||||
normalizes the username if any normalization should be done,
|
||||
and then validates the name in the whitelist.
|
||||
|
||||
This is the outer API for authenticating a user.
|
||||
Subclasses should not need to override this method.
|
||||
Subclasses should not override this method.
|
||||
|
||||
The various stages can be overridden separately:
|
||||
- `authenticate` turns formdata into a username
|
||||
- `normalize_username` normalizes the username
|
||||
- `check_whitelist` checks against the user whitelist
|
||||
|
||||
.. versionchanged:: 0.8
|
||||
return dict instead of username
|
||||
"""
|
||||
username = yield self.authenticate(handler, data)
|
||||
if username is None:
|
||||
authenticated = yield self.authenticate(handler, data)
|
||||
if authenticated is None:
|
||||
return
|
||||
username = self.normalize_username(username)
|
||||
if isinstance(authenticated, dict):
|
||||
if 'name' not in authenticated:
|
||||
raise ValueError("user missing a name: %r" % authenticated)
|
||||
else:
|
||||
authenticated = {
|
||||
'name': authenticated,
|
||||
}
|
||||
authenticated.setdefault('auth_state', None)
|
||||
|
||||
# normalize the username
|
||||
authenticated['name'] = username = self.normalize_username(authenticated['name'])
|
||||
if not self.validate_username(username):
|
||||
self.log.warning("Disallowing invalid username %r.", username)
|
||||
return
|
||||
|
||||
whitelist_pass = yield gen.maybe_future(self.check_whitelist(username))
|
||||
if whitelist_pass:
|
||||
return username
|
||||
return authenticated
|
||||
else:
|
||||
self.log.warning("User %r not in whitelist.", username)
|
||||
return
|
||||
@@ -193,13 +253,20 @@ class Authenticator(LoggingConfigurable):
|
||||
|
||||
Checking the whitelist is handled separately by the caller.
|
||||
|
||||
.. versionchanged:: 0.8
|
||||
Allow `authenticate` to return a dict containing auth_state.
|
||||
|
||||
Args:
|
||||
handler (tornado.web.RequestHandler): the current request handler
|
||||
data (dict): The formdata of the login form.
|
||||
The default form has 'username' and 'password' fields.
|
||||
Returns:
|
||||
username (str or None): The username of the authenticated user,
|
||||
or None if Authentication failed
|
||||
user (str or dict or None): The username of the authenticated user,
|
||||
or None if Authentication failed.
|
||||
If the Authenticator has state associated with the user,
|
||||
it can return a dict with the keys 'name' and 'auth_state',
|
||||
where 'name' is the username and 'auth_state' is a dictionary
|
||||
of auth state that will be persisted.
|
||||
"""
|
||||
|
||||
def pre_spawn_start(self, user, spawner):
|
||||
@@ -250,10 +317,23 @@ class Authenticator(LoggingConfigurable):
|
||||
"""
|
||||
self.whitelist.discard(user.name)
|
||||
|
||||
auto_login = Bool(False, config=True,
|
||||
help="""Automatically begin the login process
|
||||
|
||||
rather than starting with a "Login with..." link at `/hub/login`
|
||||
|
||||
To work, `.login_url()` must give a URL other than the default `/hub/login`,
|
||||
such as an oauth handler or another automatic login handler,
|
||||
registered with `.get_handlers()`.
|
||||
|
||||
.. versionadded:: 0.8
|
||||
"""
|
||||
)
|
||||
|
||||
def login_url(self, base_url):
|
||||
"""Override this when registering a custom login handler
|
||||
|
||||
Generally used by authenticators that do not use simple form based authentication.
|
||||
Generally used by authenticators that do not use simple form-based authentication.
|
||||
|
||||
The subclass overriding this is responsible for making sure there is a handler
|
||||
available to handle the URL returned from this method, using the `get_handlers`
|
||||
@@ -407,6 +487,7 @@ class LocalAuthenticator(Authenticator):
|
||||
@staticmethod
|
||||
def system_user_exists(user):
|
||||
"""Check if the user exists on the system"""
|
||||
import pwd
|
||||
try:
|
||||
pwd.getpwnam(user.name)
|
||||
except KeyError:
|
||||
@@ -456,6 +537,11 @@ class PAMAuthenticator(LocalAuthenticator):
|
||||
this is automatically set to False.
|
||||
"""
|
||||
).tag(config=True)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
if pamela is None:
|
||||
raise _pamela_error from None
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data):
|
||||
|
161
jupyterhub/crypto.py
Normal file
161
jupyterhub/crypto.py
Normal file
@@ -0,0 +1,161 @@
|
||||
|
||||
import base64
|
||||
from binascii import a2b_hex
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import os
|
||||
|
||||
from traitlets.config import SingletonConfigurable, Config
|
||||
from traitlets import (
|
||||
Any, Dict, Integer, List,
|
||||
default, observe, validate,
|
||||
)
|
||||
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography.fernet import Fernet, MultiFernet, InvalidToken
|
||||
except ImportError:
|
||||
cryptography = None
|
||||
class InvalidToken(Exception):
|
||||
pass
|
||||
|
||||
|
||||
KEY_ENV = 'JUPYTERHUB_CRYPT_KEY'
|
||||
|
||||
class EncryptionUnavailable(Exception):
|
||||
pass
|
||||
|
||||
class CryptographyUnavailable(EncryptionUnavailable):
|
||||
def __str__(self):
|
||||
return "cryptography library is required for encryption"
|
||||
|
||||
class NoEncryptionKeys(EncryptionUnavailable):
|
||||
def __str__(self):
|
||||
return "Encryption keys must be specified in %s env" % KEY_ENV
|
||||
|
||||
|
||||
def _validate_key(key):
|
||||
"""Validate and return a 32B key
|
||||
|
||||
Args:
|
||||
key (bytes): The key to be validated.
|
||||
Can be:
|
||||
- base64-encoded (44 bytes)
|
||||
- hex-encoded (64 bytes)
|
||||
- raw 32 byte key
|
||||
|
||||
Returns:
|
||||
key (bytes): raw 32B key
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
key = key.encode('ascii')
|
||||
|
||||
if len(key) == 44:
|
||||
try:
|
||||
key = base64.urlsafe_b64decode(key)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif len(key) == 64:
|
||||
try:
|
||||
# 64B could be 32B, hex-encoded
|
||||
return a2b_hex(key)
|
||||
except ValueError:
|
||||
# not 32B hex
|
||||
pass
|
||||
|
||||
if len(key) != 32:
|
||||
raise ValueError("Encryption keys must be 32 bytes, hex or base64-encoded.")
|
||||
|
||||
return key
|
||||
|
||||
class CryptKeeper(SingletonConfigurable):
|
||||
"""Encapsulate encryption configuration
|
||||
|
||||
Use via the encryption_config singleton below.
|
||||
"""
|
||||
|
||||
n_threads = Integer(max(os.cpu_count(), 1), config=True,
|
||||
help="The number of threads to allocate for encryption",
|
||||
)
|
||||
|
||||
@default('config')
|
||||
def _config_default(self):
|
||||
# load application config by default
|
||||
from .app import JupyterHub
|
||||
if JupyterHub.initialized():
|
||||
return JupyterHub.instance().config
|
||||
else:
|
||||
return Config()
|
||||
|
||||
executor = Any()
|
||||
def _executor_default(self):
|
||||
return ThreadPoolExecutor(self.n_threads)
|
||||
|
||||
keys = List(config=True)
|
||||
def _keys_default(self):
|
||||
if KEY_ENV not in os.environ:
|
||||
return []
|
||||
# key can be a ;-separated sequence for key rotation.
|
||||
# First item in the list is used for encryption.
|
||||
return [ _validate_key(key) for key in os.environ[KEY_ENV].split(';') if key.strip() ]
|
||||
|
||||
@validate('keys')
|
||||
def _ensure_bytes(self, proposal):
|
||||
# cast str to bytes
|
||||
return [ _validate_key(key) for key in proposal.value ]
|
||||
|
||||
fernet = Any()
|
||||
def _fernet_default(self):
|
||||
if cryptography is None or not self.keys:
|
||||
return None
|
||||
return MultiFernet([Fernet(base64.urlsafe_b64encode(key)) for key in self.keys])
|
||||
|
||||
@observe('keys')
|
||||
def _update_fernet(self, change):
|
||||
self.fernet = self._fernet_default()
|
||||
|
||||
def check_available(self):
|
||||
if cryptography is None:
|
||||
raise CryptographyUnavailable()
|
||||
if not self.keys:
|
||||
raise NoEncryptionKeys()
|
||||
|
||||
def _encrypt(self, data):
|
||||
"""Actually do the encryption. Runs in a background thread.
|
||||
|
||||
data is serialized to bytes with pickle.
|
||||
bytes are returned.
|
||||
"""
|
||||
return self.fernet.encrypt(json.dumps(data).encode('utf8'))
|
||||
|
||||
def encrypt(self, data):
|
||||
"""Encrypt an object with cryptography"""
|
||||
self.check_available()
|
||||
return self.executor.submit(self._encrypt, data)
|
||||
|
||||
def _decrypt(self, encrypted):
|
||||
decrypted = self.fernet.decrypt(encrypted)
|
||||
return json.loads(decrypted.decode('utf8'))
|
||||
|
||||
def decrypt(self, encrypted):
|
||||
"""Decrypt an object with cryptography"""
|
||||
self.check_available()
|
||||
return self.executor.submit(self._decrypt, encrypted)
|
||||
|
||||
|
||||
def encrypt(data):
|
||||
"""encrypt some data with the crypt keeper.
|
||||
|
||||
data will be serialized with pickle.
|
||||
Returns a Future whose result will be bytes.
|
||||
"""
|
||||
return CryptKeeper.instance().encrypt(data)
|
||||
|
||||
def decrypt(data):
|
||||
"""decrypt some data with the crypt keeper
|
||||
|
||||
Returns a Future whose result will be the decrypted, deserialized data.
|
||||
"""
|
||||
return CryptKeeper.instance().decrypt(data)
|
||||
|
@@ -18,10 +18,10 @@ ALEMBIC_DIR = os.path.join(_here, 'alembic')
|
||||
|
||||
def write_alembic_ini(alembic_ini='alembic.ini', db_url='sqlite:///jupyterhub.sqlite'):
|
||||
"""Write a complete alembic.ini from our template.
|
||||
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
|
||||
alembic_ini: str
|
||||
path to the alembic.ini file that should be written.
|
||||
db_url: str
|
||||
@@ -29,34 +29,37 @@ def write_alembic_ini(alembic_ini='alembic.ini', db_url='sqlite:///jupyterhub.sq
|
||||
"""
|
||||
with open(ALEMBIC_INI_TEMPLATE_PATH) as f:
|
||||
alembic_ini_tpl = f.read()
|
||||
|
||||
|
||||
with open(alembic_ini, 'w') as f:
|
||||
f.write(
|
||||
alembic_ini_tpl.format(
|
||||
alembic_dir=ALEMBIC_DIR,
|
||||
db_url=db_url,
|
||||
# If there are any %s in the URL, they should be replaced with %%, since ConfigParser
|
||||
# by default uses %() for substitution. You'll get %s in your URL when you have usernames
|
||||
# with special chars (such as '@') that need to be URL encoded. URL Encoding is done with %s.
|
||||
# YAY for nested templates?
|
||||
db_url=str(db_url).replace('%', '%%'),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _temp_alembic_ini(db_url):
|
||||
"""Context manager for temporary JupyterHub alembic directory
|
||||
|
||||
|
||||
Temporarily write an alembic.ini file for use with alembic migration scripts.
|
||||
|
||||
|
||||
Context manager yields alembic.ini path.
|
||||
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
|
||||
db_url: str
|
||||
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
|
||||
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
||||
|
||||
alembic_ini: str
|
||||
The path to the temporary alembic.ini that we have created.
|
||||
This file will be cleaned up on exit from the context manager.
|
||||
@@ -69,7 +72,7 @@ def _temp_alembic_ini(db_url):
|
||||
|
||||
def upgrade(db_url, revision='head'):
|
||||
"""Upgrade the given database to revision.
|
||||
|
||||
|
||||
db_url: str
|
||||
The SQLAlchemy database url, e.g. `sqlite:///jupyterhub.sqlite`.
|
||||
revision: str [default: head]
|
||||
@@ -80,6 +83,7 @@ def upgrade(db_url, revision='head'):
|
||||
['alembic', '-c', alembic_ini, 'upgrade', revision]
|
||||
)
|
||||
|
||||
|
||||
def _alembic(*args):
|
||||
"""Run an alembic command with a temporary alembic.ini"""
|
||||
with _temp_alembic_ini('sqlite:///jupyterhub.sqlite') as alembic_ini:
|
||||
@@ -89,5 +93,4 @@ def _alembic(*args):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
_alembic(*sys.argv[1:])
|
||||
|
@@ -5,6 +5,8 @@ instance of itself that'll allow any method to be called on it.
|
||||
|
||||
Primarily used to mock out the statsd client when statsd is not being used
|
||||
"""
|
||||
|
||||
|
||||
class EmptyClass:
|
||||
def empty_function(self, *args, **kwargs):
|
||||
return self
|
||||
|
@@ -6,7 +6,7 @@
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from http.client import responses
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
||||
|
||||
from jinja2 import TemplateNotFound
|
||||
|
||||
@@ -16,13 +16,14 @@ from tornado.ioloop import IOLoop
|
||||
from tornado.web import RequestHandler
|
||||
from tornado import gen, web
|
||||
|
||||
from .. import __version__
|
||||
from .. import orm
|
||||
from ..user import User
|
||||
from ..objects import Server
|
||||
from ..spawner import LocalProcessSpawner
|
||||
from ..utils import url_path_join
|
||||
from ..utils import default_server_name, url_path_join, exponential_backoff
|
||||
|
||||
# pattern for the authentication token header
|
||||
auth_header_pat = re.compile(r'^token\s+([^\s]+)$')
|
||||
auth_header_pat = re.compile(r'^(?:token|bearer)\s+([^\s]+)$', flags=re.IGNORECASE)
|
||||
|
||||
# mapping of reason: reason_message
|
||||
reasons = {
|
||||
@@ -56,6 +57,10 @@ class BaseHandler(RequestHandler):
|
||||
def subdomain_host(self):
|
||||
return self.settings.get('subdomain_host', '')
|
||||
|
||||
@property
|
||||
def allow_named_servers(self):
|
||||
return self.settings.get('allow_named_servers', False)
|
||||
|
||||
@property
|
||||
def domain(self):
|
||||
return self.settings['domain']
|
||||
@@ -87,9 +92,15 @@ class BaseHandler(RequestHandler):
|
||||
def authenticator(self):
|
||||
return self.settings.get('authenticator', None)
|
||||
|
||||
@property
|
||||
def oauth_provider(self):
|
||||
return self.settings['oauth_provider']
|
||||
|
||||
def finish(self, *args, **kwargs):
|
||||
"""Roll back any uncommitted transactions from the handler."""
|
||||
self.db.rollback()
|
||||
if self.db.dirty:
|
||||
self.log.warning("Rolling back dirty objects %s", self.db.dirty)
|
||||
self.db.rollback()
|
||||
super().finish(*args, **kwargs)
|
||||
|
||||
#---------------------------------------------------------------
|
||||
@@ -99,7 +110,7 @@ class BaseHandler(RequestHandler):
|
||||
@property
|
||||
def csp_report_uri(self):
|
||||
return self.settings.get('csp_report_uri',
|
||||
url_path_join(self.hub.server.base_url, 'security/csp-report')
|
||||
url_path_join(self.hub.base_url, 'security/csp-report')
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -120,11 +131,14 @@ class BaseHandler(RequestHandler):
|
||||
By default sets Content-Security-Policy of frame-ancestors 'self'.
|
||||
"""
|
||||
headers = self.settings.get('headers', {})
|
||||
headers.setdefault("Content-Security-Policy", self.content_security_policy)
|
||||
headers.setdefault("X-JupyterHub-Version", __version__)
|
||||
|
||||
for header_name, header_content in headers.items():
|
||||
self.set_header(header_name, header_content)
|
||||
|
||||
if 'Content-Security-Policy' not in headers:
|
||||
self.set_header('Content-Security-Policy', self.content_security_policy)
|
||||
|
||||
#---------------------------------------------------------------
|
||||
# Login and cookie-related
|
||||
#---------------------------------------------------------------
|
||||
@@ -137,18 +151,40 @@ class BaseHandler(RequestHandler):
|
||||
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"""
|
||||
def get_auth_token(self):
|
||||
"""Get the authorization token from Authorization header"""
|
||||
auth_header = self.request.headers.get('Authorization', '')
|
||||
match = auth_header_pat.match(auth_header)
|
||||
if not match:
|
||||
return None
|
||||
token = match.group(1)
|
||||
return match.group(1)
|
||||
|
||||
def get_current_user_oauth_token(self):
|
||||
"""Get the current user identified by OAuth access token
|
||||
|
||||
Separate from API token because OAuth access tokens
|
||||
can only be used for identifying users,
|
||||
not using the API.
|
||||
"""
|
||||
token = self.get_auth_token()
|
||||
if token is None:
|
||||
return None
|
||||
orm_token = orm.OAuthAccessToken.find(self.db, token)
|
||||
if orm_token is None:
|
||||
return None
|
||||
else:
|
||||
return self._user_from_orm(orm_token.user)
|
||||
|
||||
def get_current_user_token(self):
|
||||
"""get_current_user from Authorization header token"""
|
||||
token = self.get_auth_token()
|
||||
if token is None:
|
||||
return None
|
||||
orm_token = orm.APIToken.find(self.db, token)
|
||||
if orm_token is None:
|
||||
return None
|
||||
else:
|
||||
return orm_token.user or orm_token.service
|
||||
return orm_token.service or self._user_from_orm(orm_token.user)
|
||||
|
||||
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
||||
"""Get the User for a given cookie, if there is one"""
|
||||
@@ -158,7 +194,7 @@ class BaseHandler(RequestHandler):
|
||||
max_age_days=self.cookie_max_age_days,
|
||||
)
|
||||
def clear():
|
||||
self.clear_cookie(cookie_name, path=self.hub.server.base_url)
|
||||
self.clear_cookie(cookie_name, path=self.hub.base_url)
|
||||
|
||||
if cookie_id is None:
|
||||
if self.get_cookie(cookie_name):
|
||||
@@ -182,7 +218,7 @@ class BaseHandler(RequestHandler):
|
||||
|
||||
def get_current_user_cookie(self):
|
||||
"""get_current_user from a cookie token"""
|
||||
return self._user_for_cookie(self.hub.server.cookie_name)
|
||||
return self._user_for_cookie(self.hub.cookie_name)
|
||||
|
||||
def get_current_user(self):
|
||||
"""get current username"""
|
||||
@@ -212,25 +248,20 @@ class BaseHandler(RequestHandler):
|
||||
return user
|
||||
|
||||
def clear_login_cookie(self, name=None):
|
||||
if name is None:
|
||||
user = self.get_current_user()
|
||||
else:
|
||||
user = self.find_user(name)
|
||||
kwargs = {}
|
||||
if self.subdomain_host:
|
||||
kwargs['domain'] = self.domain
|
||||
if user and user.server:
|
||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url, **kwargs)
|
||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url, **kwargs)
|
||||
self.clear_cookie(self.hub.cookie_name, path=self.hub.base_url, **kwargs)
|
||||
self.clear_cookie('jupyterhub-services', path=url_path_join(self.base_url, 'services'))
|
||||
|
||||
def _set_user_cookie(self, user, server):
|
||||
# tornado <4.2 have a bug that consider secure==True as soon as
|
||||
# 'secure' kwarg is passed to set_secure_cookie
|
||||
kwargs = {
|
||||
'httponly': True,
|
||||
}
|
||||
if self.request.protocol == 'https':
|
||||
kwargs = {'secure': True}
|
||||
else:
|
||||
kwargs = {}
|
||||
kwargs['secure'] = True
|
||||
if self.subdomain_host:
|
||||
kwargs['domain'] = self.domain
|
||||
self.log.debug("Setting cookie for %s: %s, %s", user.name, server.cookie_name, kwargs)
|
||||
@@ -248,13 +279,9 @@ class BaseHandler(RequestHandler):
|
||||
base_url=url_path_join(self.base_url, 'services')
|
||||
))
|
||||
|
||||
def set_server_cookie(self, user):
|
||||
"""set the login cookie for the single-user server"""
|
||||
self._set_user_cookie(user, user.server)
|
||||
|
||||
def set_hub_cookie(self, user):
|
||||
"""set the login cookie for the Hub"""
|
||||
self._set_user_cookie(user, self.hub.server)
|
||||
self._set_user_cookie(user, self.hub)
|
||||
|
||||
def set_login_cookie(self, user):
|
||||
"""Set login cookies for the Hub and single-user server."""
|
||||
@@ -262,9 +289,6 @@ class BaseHandler(RequestHandler):
|
||||
self.log.warning(
|
||||
"Possibly setting cookie on wrong domain: %s != %s",
|
||||
self.request.host, self.domain)
|
||||
# create and set a new cookie token for the single-user server
|
||||
if user.server:
|
||||
self.set_server_cookie(user)
|
||||
|
||||
# set single cookie for services
|
||||
if self.db.query(orm.Service).filter(orm.Service.server != None).first():
|
||||
@@ -274,14 +298,56 @@ class BaseHandler(RequestHandler):
|
||||
if not self.get_current_user_cookie():
|
||||
self.set_hub_cookie(user)
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, data):
|
||||
auth = self.authenticator
|
||||
if auth is not None:
|
||||
result = yield auth.get_authenticated_user(self, data)
|
||||
return result
|
||||
return gen.maybe_future(self.authenticator.get_authenticated_user(self, data))
|
||||
|
||||
def get_next_url(self, user=None):
|
||||
"""Get the next_url for login redirect
|
||||
|
||||
Defaults to hub base_url /hub/ if user is not running,
|
||||
otherwise user.url.
|
||||
"""
|
||||
next_url = self.get_argument('next', default='')
|
||||
if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)):
|
||||
# treat absolute URLs for our host as absolute paths:
|
||||
next_url = urlparse(next_url).path
|
||||
if not next_url.startswith('/'):
|
||||
next_url = ''
|
||||
if not next_url:
|
||||
if user and user.running:
|
||||
next_url = user.url
|
||||
else:
|
||||
next_url = self.hub.base_url
|
||||
return next_url
|
||||
|
||||
@gen.coroutine
|
||||
def login_user(self, data=None):
|
||||
"""Login a user"""
|
||||
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||
authenticated = yield self.authenticate(data)
|
||||
auth_timer.stop(send=False)
|
||||
|
||||
if authenticated:
|
||||
username = authenticated['name']
|
||||
auth_state = authenticated.get('auth_state')
|
||||
user = self.user_from_username(username)
|
||||
# always set auth_state and commit,
|
||||
# because there could be key-rotation or clearing of previous values
|
||||
# going on.
|
||||
if not self.authenticator.enable_auth_state:
|
||||
# auth_state is not enabled. Force None.
|
||||
auth_state = None
|
||||
yield user.save_auth_state(auth_state)
|
||||
self.db.commit()
|
||||
self.set_login_cookie(user)
|
||||
self.statsd.incr('login.success')
|
||||
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
||||
self.log.info("User logged in: %s", username)
|
||||
return user
|
||||
else:
|
||||
self.log.error("No authentication function, login is impossible!")
|
||||
self.statsd.incr('login.failure')
|
||||
self.statsd.timing('login.authenticate.failure', auth_timer.ms)
|
||||
self.log.warning("Failed login for %s", data.get('username', 'unknown user'))
|
||||
|
||||
|
||||
#---------------------------------------------------------------
|
||||
@@ -300,105 +366,184 @@ class BaseHandler(RequestHandler):
|
||||
def spawner_class(self):
|
||||
return self.settings.get('spawner_class', LocalProcessSpawner)
|
||||
|
||||
@property
|
||||
def concurrent_spawn_limit(self):
|
||||
return self.settings.get('concurrent_spawn_limit', 0)
|
||||
|
||||
@property
|
||||
def active_server_limit(self):
|
||||
return self.settings.get('active_server_limit', 0)
|
||||
|
||||
@gen.coroutine
|
||||
def spawn_single_user(self, user, options=None):
|
||||
if user.spawn_pending:
|
||||
raise RuntimeError("Spawn already pending for: %s" % user.name)
|
||||
def spawn_single_user(self, user, server_name='', options=None):
|
||||
user_server_name = user.name
|
||||
if self.allow_named_servers and not server_name:
|
||||
server_name = default_server_name(user)
|
||||
|
||||
if server_name:
|
||||
user_server_name = '%s:%s' % (user.name, server_name)
|
||||
|
||||
if server_name in user.spawners and user.spawners[server_name].pending:
|
||||
pending = user.spawners[server_name].pending
|
||||
raise RuntimeError("%s pending %s" % (user_server_name, pending))
|
||||
|
||||
# count active servers and pending spawns
|
||||
# we could do careful bookkeeping to avoid
|
||||
# but for 10k users this takes ~5ms
|
||||
# and saves us from bookkeeping errors
|
||||
active_counts = self.users.count_active_users()
|
||||
spawn_pending_count = active_counts['spawn_pending'] + active_counts['proxy_pending']
|
||||
active_count = active_counts['active']
|
||||
|
||||
concurrent_spawn_limit = self.concurrent_spawn_limit
|
||||
active_server_limit = self.active_server_limit
|
||||
|
||||
if concurrent_spawn_limit and spawn_pending_count >= concurrent_spawn_limit:
|
||||
self.log.info(
|
||||
'%s pending spawns, throttling',
|
||||
spawn_pending_count,
|
||||
)
|
||||
raise web.HTTPError(
|
||||
429,
|
||||
"User startup rate limit exceeded. Try again in a few minutes.",
|
||||
)
|
||||
if active_server_limit and active_count >= active_server_limit:
|
||||
self.log.info(
|
||||
'%s servers active, no space available',
|
||||
active_count,
|
||||
)
|
||||
raise web.HTTPError(429, "Active user limit exceeded. Try again in a few minutes.")
|
||||
|
||||
tic = IOLoop.current().time()
|
||||
|
||||
f = user.spawn(options)
|
||||
self.log.debug("Initiating spawn for %s", user_server_name)
|
||||
|
||||
spawn_future = user.spawn(server_name, options)
|
||||
|
||||
self.log.debug("%i%s concurrent spawns",
|
||||
spawn_pending_count,
|
||||
'/%i' % concurrent_spawn_limit if concurrent_spawn_limit else '')
|
||||
self.log.debug("%i%s active servers",
|
||||
active_count,
|
||||
'/%i' % active_server_limit if active_server_limit else '')
|
||||
|
||||
spawner = user.spawners[server_name]
|
||||
# set spawn_pending now, so there's no gap where _spawn_pending is False
|
||||
# while we are waiting for _proxy_pending to be set
|
||||
spawner._spawn_pending = True
|
||||
|
||||
@gen.coroutine
|
||||
def finish_user_spawn(f=None):
|
||||
def finish_user_spawn():
|
||||
"""Finish the user spawn by registering listeners and notifying the proxy.
|
||||
|
||||
If the spawner is slow to start, this is passed as an async callback,
|
||||
otherwise it is called immediately.
|
||||
"""
|
||||
if f and f.exception() is not None:
|
||||
# failed, don't add to the proxy
|
||||
return
|
||||
# wait for spawn Future
|
||||
try:
|
||||
yield spawn_future
|
||||
except Exception:
|
||||
spawner._spawn_pending = False
|
||||
raise
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic)
|
||||
self.log.info("User %s took %.3f seconds to start", user_server_name, toc-tic)
|
||||
self.statsd.timing('spawner.success', (toc - tic) * 1000)
|
||||
yield self.proxy.add_user(user)
|
||||
user.spawner.add_poll_callback(self.user_stopped, user)
|
||||
spawner._proxy_pending = True
|
||||
try:
|
||||
yield self.proxy.add_user(user, server_name)
|
||||
except Exception:
|
||||
self.log.exception("Failed to add %s to proxy!", user_server_name)
|
||||
self.log.error("Stopping %s to avoid inconsistent state", user_server_name)
|
||||
yield user.stop()
|
||||
else:
|
||||
spawner.add_poll_callback(self.user_stopped, user, server_name)
|
||||
finally:
|
||||
spawner._proxy_pending = False
|
||||
spawner._spawn_pending = False
|
||||
|
||||
try:
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), finish_user_spawn())
|
||||
except gen.TimeoutError:
|
||||
# waiting_for_response indicates server process has started,
|
||||
# but is yet to become responsive.
|
||||
if not user.waiting_for_response:
|
||||
if spawner._spawn_pending and not spawner._waiting_for_response:
|
||||
# still in Spawner.start, which is taking a long time
|
||||
# we shouldn't poll while spawn is incomplete.
|
||||
self.log.warning("User %s's server is slow to start (timeout=%s)",
|
||||
user.name, self.slow_spawn_timeout)
|
||||
# schedule finish for when the user finishes spawning
|
||||
IOLoop.current().add_future(f, finish_user_spawn)
|
||||
else:
|
||||
# start has finished, but the server hasn't come up
|
||||
# check if the server died while we were waiting
|
||||
status = yield user.spawner.poll()
|
||||
if status is None:
|
||||
# hit timeout, but server's running. Hope that it'll show up soon enough,
|
||||
# though it's possible that it started at the wrong URL
|
||||
self.log.warning("User %s's server is slow to become responsive (timeout=%s)",
|
||||
user.name, self.slow_spawn_timeout)
|
||||
self.log.debug("Expecting server for %s at: %s", user.name, user.server.url)
|
||||
# schedule finish for when the user finishes spawning
|
||||
IOLoop.current().add_future(f, finish_user_spawn)
|
||||
else:
|
||||
toc = IOLoop.current().time()
|
||||
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
|
||||
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
||||
else:
|
||||
yield finish_user_spawn()
|
||||
self.log.warning("User %s is slow to start (timeout=%s)",
|
||||
user_server_name, self.slow_spawn_timeout)
|
||||
return
|
||||
|
||||
# start has finished, but the server hasn't come up
|
||||
# check if the server died while we were waiting
|
||||
status = yield spawner.poll()
|
||||
if status is not None:
|
||||
toc = IOLoop.current().time()
|
||||
self.statsd.timing('spawner.failure', (toc - tic) * 1000)
|
||||
raise web.HTTPError(500, "Spawner failed to start [status=%s]" % status)
|
||||
|
||||
if spawner._waiting_for_response:
|
||||
# hit timeout waiting for response, but server's running.
|
||||
# Hope that it'll show up soon enough,
|
||||
# though it's possible that it started at the wrong URL
|
||||
self.log.warning("User %s is slow to become responsive (timeout=%s)",
|
||||
user_server_name, self.slow_spawn_timeout)
|
||||
self.log.debug("Expecting server for %s at: %s",
|
||||
user_server_name, spawner.server.url)
|
||||
if spawner._proxy_pending:
|
||||
# User.spawn finished, but it hasn't been added to the proxy
|
||||
# Could be due to load or a slow proxy
|
||||
self.log.warning("User %s is slow to be added to the proxy (timeout=%s)",
|
||||
user_server_name, self.slow_spawn_timeout)
|
||||
|
||||
@gen.coroutine
|
||||
def user_stopped(self, user):
|
||||
def user_stopped(self, user, server_name):
|
||||
"""Callback that fires when the spawner has stopped"""
|
||||
status = yield user.spawner.poll()
|
||||
spawner = user.spawners[server_name]
|
||||
status = yield spawner.poll()
|
||||
if status is None:
|
||||
status = 'unknown'
|
||||
self.log.warning("User %s server stopped, with exit code: %s",
|
||||
user.name, status,
|
||||
)
|
||||
yield self.proxy.delete_user(user)
|
||||
yield user.stop()
|
||||
yield self.proxy.delete_user(user, server_name)
|
||||
yield user.stop(server_name)
|
||||
|
||||
@gen.coroutine
|
||||
def stop_single_user(self, user):
|
||||
if user.stop_pending:
|
||||
raise RuntimeError("Stop already pending for: %s" % user.name)
|
||||
tic = IOLoop.current().time()
|
||||
yield self.proxy.delete_user(user)
|
||||
f = user.stop()
|
||||
@gen.coroutine
|
||||
def finish_stop(f=None):
|
||||
"""Finish the stop action by noticing that the user is stopped.
|
||||
def stop_single_user(self, user, name=''):
|
||||
if name not in user.spawners:
|
||||
raise KeyError("User %s has no such spawner %r", user.name, name)
|
||||
spawner = user.spawners[name]
|
||||
if spawner.pending:
|
||||
raise RuntimeError("%s pending %s" % (spawner._log_name, spawner.pending))
|
||||
# set user._stop_pending before doing anything async
|
||||
# to avoid races
|
||||
spawner._stop_pending = True
|
||||
|
||||
If the spawner is slow to stop, this is passed as an async callback,
|
||||
otherwise it is called immediately.
|
||||
@gen.coroutine
|
||||
def stop():
|
||||
"""Stop the server
|
||||
|
||||
1. remove it from the proxy
|
||||
2. stop the server
|
||||
3. notice that it stopped
|
||||
"""
|
||||
if f and f.exception() is not None:
|
||||
# failed, don't do anything
|
||||
return
|
||||
tic = IOLoop.current().time()
|
||||
try:
|
||||
yield self.proxy.delete_user(user, name)
|
||||
yield user.stop(name)
|
||||
finally:
|
||||
spawner._stop_pending = False
|
||||
toc = IOLoop.current().time()
|
||||
self.log.info("User %s server took %.3f seconds to stop", user.name, toc-tic)
|
||||
self.log.info("User %s server took %.3f seconds to stop", user.name, toc - tic)
|
||||
|
||||
try:
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), f)
|
||||
yield gen.with_timeout(timedelta(seconds=self.slow_stop_timeout), stop())
|
||||
except gen.TimeoutError:
|
||||
if user.stop_pending:
|
||||
if spawner._stop_pending:
|
||||
# hit timeout, but stop is still pending
|
||||
self.log.warning("User %s server is slow to stop", user.name)
|
||||
# schedule finish for when the server finishes stopping
|
||||
IOLoop.current().add_future(f, finish_stop)
|
||||
self.log.warning("User %s:%s server is slow to stop", user.name, name)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
yield finish_stop()
|
||||
|
||||
#---------------------------------------------------------------
|
||||
# template rendering
|
||||
@@ -417,7 +562,7 @@ class BaseHandler(RequestHandler):
|
||||
def template_namespace(self):
|
||||
user = self.get_current_user()
|
||||
return dict(
|
||||
base_url=self.hub.server.base_url,
|
||||
base_url=self.hub.base_url,
|
||||
prefix=self.base_url,
|
||||
user=user,
|
||||
login_url=self.settings['login_url'],
|
||||
@@ -483,7 +628,7 @@ class PrefixRedirectHandler(BaseHandler):
|
||||
else:
|
||||
path = self.request.path
|
||||
self.redirect(url_path_join(
|
||||
self.hub.server.base_url, path,
|
||||
self.hub.base_url, path,
|
||||
), permanent=False)
|
||||
|
||||
|
||||
@@ -499,7 +644,10 @@ class UserSpawnHandler(BaseHandler):
|
||||
|
||||
@gen.coroutine
|
||||
def get(self, name, user_path):
|
||||
if not user_path:
|
||||
user_path = '/'
|
||||
current_user = self.get_current_user()
|
||||
|
||||
if current_user and current_user.name == name:
|
||||
# If people visit /user/:name directly on the Hub,
|
||||
# the redirects will just loop, because the proxy is bypassed.
|
||||
@@ -509,36 +657,67 @@ class UserSpawnHandler(BaseHandler):
|
||||
port = host_info.port
|
||||
if not port:
|
||||
port = 443 if host_info.scheme == 'https' else 80
|
||||
if port != self.proxy.public_server.port and port == self.hub.server.port:
|
||||
if port != Server.from_url(self.proxy.public_url).connect_port and port == self.hub.connect_port:
|
||||
self.log.warning("""
|
||||
Detected possible direct connection to Hub's private ip: %s, bypassing proxy.
|
||||
This will result in a redirect loop.
|
||||
Make sure to connect to the proxied public URL %s
|
||||
""", self.request.full_url(), self.proxy.public_server.url)
|
||||
""", self.request.full_url(), self.proxy.public_url)
|
||||
|
||||
# logged in as correct user, spawn the server
|
||||
if current_user.spawner:
|
||||
if current_user.spawn_pending:
|
||||
# spawn has started, but not finished
|
||||
self.statsd.incr('redirects.user_spawn_pending', 1)
|
||||
html = self.render_template("spawn_pending.html", user=current_user)
|
||||
self.finish(html)
|
||||
return
|
||||
spawner = current_user.spawner
|
||||
if spawner.pending:
|
||||
self.log.info("%s is pending %s", spawner._log_name, spawner.pending)
|
||||
# spawn has started, but not finished
|
||||
self.statsd.incr('redirects.user_spawn_pending', 1)
|
||||
html = self.render_template("spawn_pending.html", user=current_user)
|
||||
self.finish(html)
|
||||
return
|
||||
|
||||
# spawn has supposedly finished, check on the status
|
||||
status = yield spawner.poll()
|
||||
if status is not None:
|
||||
if spawner.options_form:
|
||||
self.redirect(url_concat(url_path_join(self.hub.base_url, 'spawn'),
|
||||
{'next': self.request.uri}))
|
||||
return
|
||||
else:
|
||||
yield self.spawn_single_user(current_user)
|
||||
|
||||
# We do exponential backoff here - since otherwise we can get stuck in a redirect loop!
|
||||
# This is important in many distributed proxy implementations - those are often eventually
|
||||
# consistent and can take upto a couple of seconds to actually apply throughout the cluster.
|
||||
try:
|
||||
redirects = int(self.get_argument('redirects', 0))
|
||||
except ValueError:
|
||||
self.log.warning("Invalid redirects argument %r", self.get_argument('redirects'))
|
||||
redirects = 0
|
||||
|
||||
if redirects >= self.settings.get('user_redirect_limit', 5):
|
||||
# We stop if we've been redirected too many times.
|
||||
raise web.HTTPError(500, "Redirect loop detected.")
|
||||
|
||||
# spawn has supposedly finished, check on the status
|
||||
status = yield current_user.spawner.poll()
|
||||
if status is not None:
|
||||
if current_user.spawner.options_form:
|
||||
self.redirect(url_path_join(self.hub.server.base_url, 'spawn'))
|
||||
return
|
||||
else:
|
||||
yield self.spawn_single_user(current_user)
|
||||
# set login cookie anew
|
||||
self.set_login_cookie(current_user)
|
||||
without_prefix = self.request.uri[len(self.hub.server.base_url):]
|
||||
without_prefix = self.request.uri[len(self.hub.base_url):]
|
||||
target = url_path_join(self.base_url, without_prefix)
|
||||
if self.subdomain_host:
|
||||
target = current_user.host + target
|
||||
|
||||
# record redirect count in query parameter
|
||||
if redirects:
|
||||
self.log.warning("Redirect loop detected on %s", self.request.uri)
|
||||
# add capped exponential backoff where cap is 10s
|
||||
yield gen.sleep(min(1 * (2 ** redirects), 10))
|
||||
# rewrite target url with new `redirects` query value
|
||||
url_parts = urlparse(target)
|
||||
query_parts = parse_qs(url_parts.query)
|
||||
query_parts['redirects'] = redirects + 1
|
||||
url_parts = url_parts._replace(query=urlencode(query_parts))
|
||||
target = urlunparse(url_parts)
|
||||
else:
|
||||
target = url_concat(target, {'redirects': 1})
|
||||
|
||||
self.redirect(target)
|
||||
self.statsd.incr('redirects.user_after_login')
|
||||
elif current_user:
|
||||
@@ -571,6 +750,9 @@ class UserRedirectHandler(BaseHandler):
|
||||
def get(self, path):
|
||||
user = self.get_current_user()
|
||||
url = url_path_join(user.url, path)
|
||||
if self.request.query:
|
||||
# FIXME: use urlunparse instead?
|
||||
url += '?' + self.request.query
|
||||
self.redirect(url)
|
||||
|
||||
|
||||
|
@@ -3,10 +3,10 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from tornado.escape import url_escape
|
||||
from tornado import gen
|
||||
from tornado.httputil import url_concat
|
||||
from tornado import web
|
||||
|
||||
from .base import BaseHandler
|
||||
|
||||
@@ -18,11 +18,11 @@ class LogoutHandler(BaseHandler):
|
||||
if user:
|
||||
self.log.info("User logged out: %s", user.name)
|
||||
self.clear_login_cookie()
|
||||
for name in user.other_user_cookies:
|
||||
self.clear_login_cookie(name)
|
||||
user.other_user_cookies = set([])
|
||||
self.statsd.incr('logout')
|
||||
self.redirect(self.hub.server.base_url, permanent=False)
|
||||
if self.authenticator.auto_login:
|
||||
self.render('logout.html')
|
||||
else:
|
||||
self.redirect(self.settings['login_url'], permanent=False)
|
||||
|
||||
|
||||
class LoginHandler(BaseHandler):
|
||||
@@ -35,29 +35,39 @@ class LoginHandler(BaseHandler):
|
||||
login_error=login_error,
|
||||
custom_html=self.authenticator.custom_html,
|
||||
login_url=self.settings['login_url'],
|
||||
authenticator_login_url=url_concat(
|
||||
self.authenticator.login_url(self.hub.base_url),
|
||||
{'next': self.get_argument('next', '')},
|
||||
),
|
||||
)
|
||||
|
||||
@gen.coroutine
|
||||
def get(self):
|
||||
self.statsd.incr('login.request')
|
||||
next_url = self.get_argument('next', '')
|
||||
if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)):
|
||||
# treat absolute URLs for our host as absolute paths:
|
||||
next_url = urlparse(next_url).path
|
||||
elif not next_url.startswith('/'):
|
||||
# disallow non-absolute next URLs (e.g. full URLs to other hosts)
|
||||
next_url = ''
|
||||
user = self.get_current_user()
|
||||
if user:
|
||||
if not next_url:
|
||||
if user.running:
|
||||
next_url = user.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)
|
||||
self.redirect(self.get_next_url(user), permanent=False)
|
||||
else:
|
||||
if self.authenticator.auto_login:
|
||||
auto_login_url = self.authenticator.login_url(self.hub.base_url)
|
||||
if auto_login_url == self.settings['login_url']:
|
||||
# auto_login without a custom login handler
|
||||
# means that auth info is already in the request
|
||||
# (e.g. REMOTE_USER header)
|
||||
user = yield self.login_user()
|
||||
if user is None:
|
||||
# auto_login failed, just 403
|
||||
raise web.HTTPError(403)
|
||||
else:
|
||||
self.redirect(self.get_next_url(user))
|
||||
else:
|
||||
if self.get_argument('next', default=False):
|
||||
auto_login_url = url_concat(auto_login_url, {'next': self.get_next_url()})
|
||||
self.redirect(auto_login_url)
|
||||
return
|
||||
username = self.get_argument('username', default='')
|
||||
self.finish(self._render(username=username))
|
||||
|
||||
@@ -66,36 +76,26 @@ class LoginHandler(BaseHandler):
|
||||
# parse the arguments dict
|
||||
data = {}
|
||||
for arg in self.request.arguments:
|
||||
data[arg] = self.get_argument(arg)
|
||||
data[arg] = self.get_argument(arg, strip=False)
|
||||
|
||||
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||
username = yield self.authenticate(data)
|
||||
user = yield self.login_user(data)
|
||||
auth_timer.stop(send=False)
|
||||
|
||||
if username:
|
||||
self.statsd.incr('login.success')
|
||||
self.statsd.timing('login.authenticate.success', auth_timer.ms)
|
||||
user = self.user_from_username(username)
|
||||
if user:
|
||||
already_running = False
|
||||
if user.spawner:
|
||||
if user.spawner.ready:
|
||||
status = yield user.spawner.poll()
|
||||
already_running = (status == None)
|
||||
if not already_running and not user.spawner.options_form:
|
||||
already_running = (status is None)
|
||||
if not already_running and not user.spawner.options_form \
|
||||
and not user.spawner.pending:
|
||||
# logging in triggers spawn
|
||||
yield self.spawn_single_user(user)
|
||||
self.set_login_cookie(user)
|
||||
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)
|
||||
self.redirect(self.get_next_url())
|
||||
else:
|
||||
self.statsd.incr('login.failure')
|
||||
self.statsd.timing('login.authenticate.failure', auth_timer.ms)
|
||||
self.log.debug("Failed login for %s", data.get('username', 'unknown user'))
|
||||
html = self._render(
|
||||
login_error='Invalid username or password',
|
||||
username=username,
|
||||
username=data['username'],
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
|
@@ -7,6 +7,7 @@ from http.client import responses
|
||||
|
||||
from jinja2 import TemplateNotFound
|
||||
from tornado import web, gen
|
||||
from tornado.httputil import url_concat
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only, url_path_join
|
||||
@@ -36,7 +37,7 @@ class RootHandler(BaseHandler):
|
||||
# The next request will be handled by UserSpawnHandler,
|
||||
# ultimately redirecting to the logged-in user's server.
|
||||
without_prefix = next_url[len(self.base_url):]
|
||||
next_url = url_path_join(self.hub.server.base_url, without_prefix)
|
||||
next_url = url_path_join(self.hub.base_url, without_prefix)
|
||||
self.log.warning("Redirecting %s to %s. For sharing public links, use /user-redirect/",
|
||||
self.request.uri, next_url,
|
||||
)
|
||||
@@ -49,10 +50,10 @@ class RootHandler(BaseHandler):
|
||||
self.log.debug("User is running: %s", url)
|
||||
self.set_login_cookie(user) # set cookie
|
||||
else:
|
||||
url = url_path_join(self.hub.server.base_url, 'home')
|
||||
url = url_path_join(self.hub.base_url, 'home')
|
||||
self.log.debug("User is not running: %s", url)
|
||||
else:
|
||||
url = self.authenticator.login_url(self.base_url)
|
||||
url = self.settings['login_url']
|
||||
self.redirect(url)
|
||||
|
||||
|
||||
@@ -68,6 +69,7 @@ class HomeHandler(BaseHandler):
|
||||
yield user.spawner.poll_and_notify()
|
||||
html = self.render_template('home.html',
|
||||
user=user,
|
||||
url=user.url,
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
@@ -85,13 +87,14 @@ class SpawnHandler(BaseHandler):
|
||||
user=user,
|
||||
spawner_options_form=user.spawner.options_form,
|
||||
error_message=message,
|
||||
url=self.request.uri,
|
||||
)
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
"""GET renders form for spawning with user-specified options"""
|
||||
user = self.get_current_user()
|
||||
if user.running:
|
||||
if not self.allow_named_servers and user.running:
|
||||
url = user.url
|
||||
self.log.debug("User is running: %s", url)
|
||||
self.redirect(url)
|
||||
@@ -107,11 +110,15 @@ class SpawnHandler(BaseHandler):
|
||||
def post(self):
|
||||
"""POST spawns with user-specified options"""
|
||||
user = self.get_current_user()
|
||||
if user.running:
|
||||
if not self.allow_named_servers and user.running:
|
||||
url = user.url
|
||||
self.log.warning("User is already running: %s", url)
|
||||
self.redirect(url)
|
||||
return
|
||||
if user.spawner.pending:
|
||||
raise web.HTTPError(
|
||||
400, "%s is pending %s" % (user.spawner._log_name, user.spawner.pending)
|
||||
)
|
||||
form_options = {}
|
||||
for key, byte_list in self.request.body_arguments.items():
|
||||
form_options[key] = [ bs.decode('utf8') for bs in byte_list ]
|
||||
@@ -126,6 +133,13 @@ class SpawnHandler(BaseHandler):
|
||||
return
|
||||
self.set_login_cookie(user)
|
||||
url = user.url
|
||||
|
||||
next_url = self.get_argument('next', '')
|
||||
if next_url and not next_url.startswith('/'):
|
||||
self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
|
||||
elif next_url:
|
||||
url = next_url
|
||||
|
||||
self.redirect(url)
|
||||
|
||||
class AdminHandler(BaseHandler):
|
||||
@@ -136,14 +150,19 @@ class AdminHandler(BaseHandler):
|
||||
available = {'name', 'admin', 'running', 'last_activity'}
|
||||
default_sort = ['admin', 'name']
|
||||
mapping = {
|
||||
'running': '_server_id'
|
||||
'running': orm.Spawner.server_id,
|
||||
}
|
||||
for name in available:
|
||||
if name not in mapping:
|
||||
mapping[name] = getattr(orm.User, name)
|
||||
|
||||
default_order = {
|
||||
'name': 'asc',
|
||||
'last_activity': 'desc',
|
||||
'admin': 'desc',
|
||||
'running': 'desc',
|
||||
}
|
||||
|
||||
sorts = self.get_arguments('sort') or default_sort
|
||||
orders = self.get_arguments('order')
|
||||
|
||||
@@ -166,11 +185,11 @@ class AdminHandler(BaseHandler):
|
||||
|
||||
# this could be one incomprehensible nested list comprehension
|
||||
# get User columns
|
||||
cols = [ getattr(orm.User, mapping.get(c, c)) for c in sorts ]
|
||||
cols = [ mapping[c] for c in sorts ]
|
||||
# get User.col.desc() order objects
|
||||
ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ]
|
||||
|
||||
users = self.db.query(orm.User).order_by(*ordered)
|
||||
users = self.db.query(orm.User).join(orm.Spawner).order_by(*ordered)
|
||||
users = [ self._user_from_orm(u) for u in users ]
|
||||
running = [ u for u in users if u.running ]
|
||||
|
||||
@@ -184,6 +203,15 @@ class AdminHandler(BaseHandler):
|
||||
self.finish(html)
|
||||
|
||||
|
||||
class TokenPageHandler(BaseHandler):
|
||||
"""Handler for page requesting new API tokens"""
|
||||
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
html = self.render_template('token.html')
|
||||
self.finish(html)
|
||||
|
||||
|
||||
class ProxyErrorHandler(BaseHandler):
|
||||
"""Handler for rendering proxy error pages"""
|
||||
|
||||
@@ -192,7 +220,7 @@ class ProxyErrorHandler(BaseHandler):
|
||||
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
||||
# build template namespace
|
||||
|
||||
hub_home = url_path_join(self.hub.server.base_url, 'home')
|
||||
hub_home = url_path_join(self.hub.base_url, 'home')
|
||||
message_html = ''
|
||||
if status_code == 503:
|
||||
message_html = ' '.join([
|
||||
@@ -218,9 +246,10 @@ class ProxyErrorHandler(BaseHandler):
|
||||
|
||||
|
||||
default_handlers = [
|
||||
(r'/', RootHandler),
|
||||
(r'/?', RootHandler),
|
||||
(r'/home', HomeHandler),
|
||||
(r'/admin', AdminHandler),
|
||||
(r'/spawn', SpawnHandler),
|
||||
(r'/token', TokenPageHandler),
|
||||
(r'/error/(\d+)', ProxyErrorHandler),
|
||||
]
|
||||
|
@@ -6,16 +6,16 @@ import json
|
||||
import traceback
|
||||
|
||||
from tornado.log import LogFormatter, access_log
|
||||
from tornado.web import StaticFileHandler
|
||||
from tornado.web import StaticFileHandler, HTTPError
|
||||
|
||||
|
||||
def coroutine_traceback(typ, value, tb):
|
||||
"""Scrub coroutine frames from a traceback
|
||||
|
||||
|
||||
Coroutine tracebacks have a bunch of identical uninformative frames at each yield point.
|
||||
This removes those extra frames, so tracebacks should be easier to read.
|
||||
This might be a horrible idea.
|
||||
|
||||
|
||||
Returns a list of strings (like traceback.format_tb)
|
||||
"""
|
||||
all_frames = traceback.extract_tb(tb)
|
||||
@@ -63,7 +63,7 @@ def _scrub_headers(headers):
|
||||
|
||||
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
|
||||
@@ -80,22 +80,43 @@ def log_request(handler):
|
||||
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()
|
||||
|
||||
try:
|
||||
user = handler.get_current_user()
|
||||
except HTTPError:
|
||||
username = ''
|
||||
else:
|
||||
if user is None:
|
||||
username = ''
|
||||
elif isinstance(user, str):
|
||||
username = user
|
||||
elif isinstance(user, dict):
|
||||
username = user['name']
|
||||
else:
|
||||
username = user.name
|
||||
|
||||
ns = dict(
|
||||
status=status,
|
||||
method=request.method,
|
||||
ip=request.remote_ip,
|
||||
uri=uri,
|
||||
request_time=request_time,
|
||||
user=user.name if user else ''
|
||||
user=username,
|
||||
location='',
|
||||
)
|
||||
msg = "{status} {method} {uri} ({user}@{ip}) {request_time:.2f}ms"
|
||||
msg = "{status} {method} {uri}{location} ({user}@{ip}) {request_time:.2f}ms"
|
||||
if status >= 500 and status != 502:
|
||||
log_method(json.dumps(headers, indent=2))
|
||||
elif status in {301, 302}:
|
||||
# log redirect targets
|
||||
# FIXME: _headers is private, but there doesn't appear to be a public way
|
||||
# to get headers from tornado
|
||||
location = handler._headers.get('Location')
|
||||
if location:
|
||||
ns['location'] = ' → {}'.format(location)
|
||||
log_method(msg.format(**ns))
|
||||
|
||||
|
0
jupyterhub/oauth/__init__.py
Normal file
0
jupyterhub/oauth/__init__.py
Normal file
230
jupyterhub/oauth/store.py
Normal file
230
jupyterhub/oauth/store.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Utilities for hooking up oauth2 to JupyterHub's database
|
||||
|
||||
implements https://python-oauth2.readthedocs.io/en/latest/store.html
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
from oauth2.datatype import Client, AuthorizationCode
|
||||
from oauth2.error import AuthCodeNotFound, ClientNotFoundError, UserNotAuthenticated
|
||||
from oauth2.grant import AuthorizationCodeGrant
|
||||
from oauth2.web import AuthorizationCodeGrantSiteAdapter
|
||||
import oauth2.store
|
||||
from oauth2 import Provider
|
||||
from oauth2.tokengenerator import Uuid4 as UUID4
|
||||
|
||||
from sqlalchemy.orm import scoped_session
|
||||
from tornado.escape import url_escape
|
||||
|
||||
from .. import orm
|
||||
from ..utils import url_path_join, hash_token, compare_token
|
||||
|
||||
|
||||
class JupyterHubSiteAdapter(AuthorizationCodeGrantSiteAdapter):
|
||||
"""
|
||||
This adapter renders a confirmation page so the user can confirm the auth
|
||||
request.
|
||||
"""
|
||||
def __init__(self, login_url):
|
||||
self.login_url = login_url
|
||||
|
||||
def render_auth_page(self, request, response, environ, scopes, client):
|
||||
"""Auth page is a redirect to login page"""
|
||||
response.status_code = 302
|
||||
response.headers['Location'] = self.login_url + '?next={}'.format(
|
||||
url_escape(request.handler.request.path + '?' + request.handler.request.query)
|
||||
)
|
||||
return response
|
||||
|
||||
def authenticate(self, request, environ, scopes, client):
|
||||
handler = request.handler
|
||||
user = handler.get_current_user()
|
||||
if user:
|
||||
return {}, user.id
|
||||
else:
|
||||
raise UserNotAuthenticated()
|
||||
|
||||
def user_has_denied_access(self, request):
|
||||
# user can't deny access
|
||||
return False
|
||||
|
||||
|
||||
class HubDBMixin(object):
|
||||
"""Mixin for connecting to the hub database"""
|
||||
def __init__(self, session_factory):
|
||||
self.db = session_factory()
|
||||
|
||||
|
||||
class AccessTokenStore(HubDBMixin, oauth2.store.AccessTokenStore):
|
||||
"""OAuth2 AccessTokenStore, storing data in the Hub database"""
|
||||
|
||||
def save_token(self, access_token):
|
||||
"""
|
||||
Stores an access token in the database.
|
||||
|
||||
:param access_token: An instance of :class:`oauth2.datatype.AccessToken`.
|
||||
|
||||
"""
|
||||
|
||||
user = self.db.query(orm.User).filter(orm.User.id == access_token.user_id).first()
|
||||
if user is None:
|
||||
raise ValueError("No user for access token: %s" % access_token.user_id)
|
||||
orm_access_token = orm.OAuthAccessToken(
|
||||
client_id=access_token.client_id,
|
||||
grant_type=access_token.grant_type,
|
||||
expires_at=access_token.expires_at,
|
||||
refresh_token=access_token.refresh_token,
|
||||
refresh_expires_at=access_token.refresh_expires_at,
|
||||
token=access_token.token,
|
||||
user=user,
|
||||
)
|
||||
self.db.add(orm_access_token)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
class AuthCodeStore(HubDBMixin, oauth2.store.AuthCodeStore):
|
||||
"""
|
||||
OAuth2 AuthCodeStore, storing data in the Hub database
|
||||
"""
|
||||
def fetch_by_code(self, code):
|
||||
"""
|
||||
Returns an AuthorizationCode fetched from a storage.
|
||||
|
||||
:param code: The authorization code.
|
||||
:return: An instance of :class:`oauth2.datatype.AuthorizationCode`.
|
||||
:raises: :class:`oauth2.error.AuthCodeNotFound` if no data could be retrieved for
|
||||
given code.
|
||||
|
||||
"""
|
||||
orm_code = self.db\
|
||||
.query(orm.OAuthCode)\
|
||||
.filter(orm.OAuthCode.code == code)\
|
||||
.first()
|
||||
if orm_code is None:
|
||||
raise AuthCodeNotFound()
|
||||
else:
|
||||
return AuthorizationCode(
|
||||
client_id=orm_code.client_id,
|
||||
code=code,
|
||||
expires_at=orm_code.expires_at,
|
||||
redirect_uri=orm_code.redirect_uri,
|
||||
scopes=[],
|
||||
user_id=orm_code.user_id,
|
||||
)
|
||||
|
||||
|
||||
def save_code(self, authorization_code):
|
||||
"""
|
||||
Stores the data belonging to an authorization code token.
|
||||
|
||||
:param authorization_code: An instance of
|
||||
:class:`oauth2.datatype.AuthorizationCode`.
|
||||
"""
|
||||
orm_code = orm.OAuthCode(
|
||||
client_id=authorization_code.client_id,
|
||||
code=authorization_code.code,
|
||||
expires_at=authorization_code.expires_at,
|
||||
user_id=authorization_code.user_id,
|
||||
redirect_uri=authorization_code.redirect_uri,
|
||||
)
|
||||
self.db.add(orm_code)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
def delete_code(self, code):
|
||||
"""
|
||||
Deletes an authorization code after its use per section 4.1.2.
|
||||
|
||||
http://tools.ietf.org/html/rfc6749#section-4.1.2
|
||||
|
||||
:param code: The authorization code.
|
||||
"""
|
||||
orm_code = self.db.query(orm.OAuthCode).filter(orm.OAuthCode.code == code).first()
|
||||
if orm_code is not None:
|
||||
self.db.delete(orm_code)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
class HashComparable:
|
||||
"""An object for storing hashed tokens
|
||||
|
||||
Overrides `==` so that it compares as equal to its unhashed original
|
||||
|
||||
Needed for storing hashed client_secrets
|
||||
because python-oauth2 uses::
|
||||
|
||||
secret == client.client_secret
|
||||
|
||||
and we don't want to store unhashed secrets in the database.
|
||||
"""
|
||||
def __init__(self, hashed_token):
|
||||
self.hashed_token = hashed_token
|
||||
|
||||
def __repr__(self):
|
||||
return "<{} '{}'>".format(self.__class__.__name__, self.hashed_token)
|
||||
|
||||
def __eq__(self, other):
|
||||
return compare_token(self.hashed_token, other)
|
||||
|
||||
|
||||
class ClientStore(HubDBMixin, oauth2.store.ClientStore):
|
||||
"""OAuth2 ClientStore, storing data in the Hub database"""
|
||||
|
||||
def fetch_by_client_id(self, client_id):
|
||||
"""Retrieve a client by its identifier.
|
||||
|
||||
:param client_id: Identifier of a client app.
|
||||
:return: An instance of :class:`oauth2.datatype.Client`.
|
||||
:raises: :class:`oauth2.error.ClientNotFoundError` if no data could be retrieved for
|
||||
given client_id.
|
||||
"""
|
||||
orm_client = self.db\
|
||||
.query(orm.OAuthClient)\
|
||||
.filter(orm.OAuthClient.identifier == client_id)\
|
||||
.first()
|
||||
if orm_client is None:
|
||||
raise ClientNotFoundError()
|
||||
return Client(identifier=client_id,
|
||||
redirect_uris=[orm_client.redirect_uri],
|
||||
secret=HashComparable(orm_client.secret),
|
||||
)
|
||||
|
||||
def add_client(self, client_id, client_secret, redirect_uri):
|
||||
"""Add a client
|
||||
|
||||
hash its client_secret before putting it in the database.
|
||||
"""
|
||||
# clear existing clients with same ID
|
||||
for client in self.db\
|
||||
.query(orm.OAuthClient)\
|
||||
.filter(orm.OAuthClient.identifier == client_id):
|
||||
self.db.delete(client)
|
||||
self.db.commit()
|
||||
|
||||
orm_client = orm.OAuthClient(
|
||||
identifier=client_id,
|
||||
secret=hash_token(client_secret),
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
self.db.add(orm_client)
|
||||
self.db.commit()
|
||||
|
||||
|
||||
def make_provider(session_factory, url_prefix, login_url):
|
||||
"""Make an OAuth provider"""
|
||||
token_store = AccessTokenStore(session_factory)
|
||||
code_store = AuthCodeStore(session_factory)
|
||||
client_store = ClientStore(session_factory)
|
||||
|
||||
provider = Provider(
|
||||
access_token_store=token_store,
|
||||
auth_code_store=code_store,
|
||||
client_store=client_store,
|
||||
token_generator=UUID4(),
|
||||
)
|
||||
provider.token_path = url_path_join(url_prefix, 'token')
|
||||
provider.authorize_path = url_path_join(url_prefix, 'authorize')
|
||||
site_adapter = JupyterHubSiteAdapter(login_url=login_url)
|
||||
provider.add_grant(AuthorizationCodeGrant(site_adapter=site_adapter))
|
||||
return provider
|
||||
|
174
jupyterhub/objects.py
Normal file
174
jupyterhub/objects.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Some general objects for use in JupyterHub"""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
import warnings
|
||||
|
||||
from traitlets import (
|
||||
HasTraits, Instance, Integer, Unicode,
|
||||
default, observe,
|
||||
)
|
||||
from .traitlets import URLPrefix
|
||||
from . import orm
|
||||
from .utils import (
|
||||
url_path_join, can_connect, wait_for_server,
|
||||
wait_for_http_server, random_port,
|
||||
)
|
||||
|
||||
class Server(HasTraits):
|
||||
"""An object representing an HTTP endpoint.
|
||||
|
||||
*Some* of these reside in the database (user servers),
|
||||
but others (Hub, proxy) are in-memory only.
|
||||
"""
|
||||
orm_server = Instance(orm.Server, allow_none=True)
|
||||
|
||||
ip = Unicode()
|
||||
connect_ip = Unicode()
|
||||
connect_port = Integer()
|
||||
proto = Unicode('http')
|
||||
port = Integer()
|
||||
base_url = URLPrefix('/')
|
||||
cookie_name = Unicode('')
|
||||
|
||||
@property
|
||||
def _connect_ip(self):
|
||||
"""The address to use when connecting to this server
|
||||
|
||||
When `ip` is set to a real ip address, the same value is used.
|
||||
When `ip` refers to 'all interfaces' (e.g. '0.0.0.0'),
|
||||
clients connect via hostname by default.
|
||||
Setting `connect_ip` explicitly overrides any default behavior.
|
||||
"""
|
||||
if self.connect_ip:
|
||||
return self.connect_ip
|
||||
elif self.ip in {'', '0.0.0.0'}:
|
||||
# if listening on all interfaces, default to hostname for connect
|
||||
return socket.gethostname()
|
||||
else:
|
||||
return self.ip
|
||||
|
||||
@property
|
||||
def _connect_port(self):
|
||||
"""
|
||||
The port to use when connecting to this server.
|
||||
|
||||
Defaults to self.port, but can be overridden by setting self.connect_port
|
||||
"""
|
||||
if self.connect_port:
|
||||
return self.connect_port
|
||||
return self.port
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, orm_server):
|
||||
"""Create a server from an orm.Server"""
|
||||
return cls(orm_server=orm_server)
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, url):
|
||||
"""Create a Server from a given URL"""
|
||||
urlinfo = urlparse(url)
|
||||
proto = urlinfo.scheme
|
||||
ip = urlinfo.hostname or ''
|
||||
port = urlinfo.port
|
||||
if not port:
|
||||
if proto == 'https':
|
||||
port = 443
|
||||
else:
|
||||
port = 80
|
||||
return cls(proto=proto, ip=ip, port=port, base_url=urlinfo.path)
|
||||
|
||||
@default('port')
|
||||
def _default_port(self):
|
||||
return random_port()
|
||||
|
||||
@observe('orm_server')
|
||||
def _orm_server_changed(self, change):
|
||||
"""When we get an orm_server, get attributes from there."""
|
||||
obj = change.new
|
||||
self.proto = obj.proto
|
||||
self.ip = obj.ip
|
||||
self.port = obj.port
|
||||
self.base_url = obj.base_url
|
||||
self.cookie_name = obj.cookie_name
|
||||
|
||||
# setter to pass through to the database
|
||||
@observe('ip', 'proto', 'port', 'base_url', 'cookie_name')
|
||||
def _change(self, change):
|
||||
if self.orm_server and getattr(self.orm_server, change.name) != change.new:
|
||||
# setattr on an sqlalchemy object sets the dirty flag,
|
||||
# even if the value doesn't change.
|
||||
# Avoid calling setattr when there's been no change,
|
||||
# to avoid setting the dirty flag and triggering rollback.
|
||||
setattr(self.orm_server, change.name, change.new)
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
return "{proto}://{ip}:{port}".format(
|
||||
proto=self.proto,
|
||||
ip=self._connect_ip,
|
||||
port=self._connect_port,
|
||||
)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return "{host}{uri}".format(
|
||||
host=self.host,
|
||||
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(self._connect_ip, self.ip or '*', 1)
|
||||
return self.url
|
||||
|
||||
def wait_up(self, timeout=10, http=False):
|
||||
"""Wait for this server to come up"""
|
||||
if http:
|
||||
return wait_for_http_server(self.url, timeout=timeout)
|
||||
else:
|
||||
return wait_for_server(self._connect_ip, self._connect_port, timeout=timeout)
|
||||
|
||||
def is_up(self):
|
||||
"""Is the server accepting connections?"""
|
||||
return can_connect(self._connect_ip, self._connect_port)
|
||||
|
||||
|
||||
class Hub(Server):
|
||||
"""Bring it all together at the hub.
|
||||
|
||||
The Hub is a server, plus its API path suffix
|
||||
|
||||
the api_url is the full URL plus the api_path suffix on the end
|
||||
of the server base_url.
|
||||
"""
|
||||
|
||||
cookie_name = 'jupyter-hub-token'
|
||||
|
||||
@property
|
||||
def server(self):
|
||||
warnings.warn("Hub.server is deprecated in JupyterHub 0.8. Access attributes on the Hub directly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self
|
||||
public_host = Unicode()
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
"""return the full API url (with proto://host...)"""
|
||||
return url_path_join(self.url, 'api')
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s %s:%s>" % (
|
||||
self.__class__.__name__, self.server.ip, self.server.port,
|
||||
)
|
@@ -4,17 +4,19 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from datetime import datetime
|
||||
import enum
|
||||
import json
|
||||
|
||||
from tornado import gen
|
||||
import alembic.config
|
||||
import alembic.command
|
||||
from alembic.script import ScriptDirectory
|
||||
from tornado.log import app_log
|
||||
from tornado.httpclient import HTTPRequest, AsyncHTTPClient
|
||||
|
||||
from sqlalchemy.types import TypeDecorator, TEXT
|
||||
from sqlalchemy.types import TypeDecorator, TEXT, LargeBinary
|
||||
from sqlalchemy import (
|
||||
inspect,
|
||||
Column, Integer, ForeignKey, Unicode, Boolean,
|
||||
DateTime,
|
||||
DateTime, Enum
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||
from sqlalchemy.orm import sessionmaker, relationship
|
||||
@@ -22,9 +24,10 @@ from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.sql.expression import bindparam
|
||||
from sqlalchemy import create_engine, Table
|
||||
|
||||
from .dbutil import _temp_alembic_ini
|
||||
from .utils import (
|
||||
random_port, url_path_join, wait_for_server, wait_for_http_server,
|
||||
new_token, hash_token, compare_token, can_connect,
|
||||
random_port,
|
||||
new_token, hash_token, compare_token,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +65,7 @@ class Server(Base):
|
||||
"""
|
||||
__tablename__ = 'servers'
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
proto = Column(Unicode(15), default='http')
|
||||
ip = Column(Unicode(255), default='') # could also be a DNS name
|
||||
port = Column(Integer, default=random_port)
|
||||
@@ -71,248 +75,6 @@ class Server(Base):
|
||||
def __repr__(self):
|
||||
return "<Server(%s:%s)>" % (self.ip, self.port)
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
ip = self.ip
|
||||
if ip in {'', '0.0.0.0'}:
|
||||
# when listening on all interfaces, connect to localhost
|
||||
ip = '127.0.0.1'
|
||||
return "{proto}://{ip}:{port}".format(
|
||||
proto=self.proto,
|
||||
ip=ip,
|
||||
port=self.port,
|
||||
)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return "{host}{uri}".format(
|
||||
host=self.host,
|
||||
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('127.0.0.1', self.ip or '*', 1)
|
||||
return self.url
|
||||
|
||||
@gen.coroutine
|
||||
def wait_up(self, timeout=10, http=False):
|
||||
"""Wait for this server to come up"""
|
||||
if http:
|
||||
yield wait_for_http_server(self.url, timeout=timeout)
|
||||
else:
|
||||
yield wait_for_server(self.ip or '127.0.0.1', self.port, timeout=timeout)
|
||||
|
||||
def is_up(self):
|
||||
"""Is the server accepting connections?"""
|
||||
return can_connect(self.ip or '127.0.0.1', self.port)
|
||||
|
||||
|
||||
class Proxy(Base):
|
||||
"""A configurable-http-proxy instance.
|
||||
|
||||
A proxy consists of the API server info and the public-facing server info,
|
||||
plus an auth token for configuring the proxy table.
|
||||
"""
|
||||
__tablename__ = 'proxies'
|
||||
id = Column(Integer, primary_key=True)
|
||||
auth_token = None
|
||||
_public_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
public_server = relationship(Server, primaryjoin=_public_server_id == Server.id)
|
||||
_api_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
api_server = relationship(Server, primaryjoin=_api_server_id == Server.id)
|
||||
|
||||
def __repr__(self):
|
||||
if self.public_server:
|
||||
return "<%s %s:%s>" % (
|
||||
self.__class__.__name__, self.public_server.ip, self.public_server.port,
|
||||
)
|
||||
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()
|
||||
url = url_path_join(self.api_server.url, path)
|
||||
|
||||
if isinstance(body, dict):
|
||||
body = json.dumps(body)
|
||||
self.log.debug("Fetching %s %s", method, url)
|
||||
req = HTTPRequest(url,
|
||||
method=method,
|
||||
headers={'Authorization': 'token {}'.format(self.auth_token)},
|
||||
body=body,
|
||||
)
|
||||
|
||||
return client.fetch(req)
|
||||
|
||||
@gen.coroutine
|
||||
def add_service(self, service, client=None):
|
||||
"""Add a service's server to the proxy table."""
|
||||
if not service.server:
|
||||
raise RuntimeError(
|
||||
"Service %s does not have an http endpoint to add to the proxy.", service.name)
|
||||
|
||||
self.log.info("Adding service %s to proxy %s => %s",
|
||||
service.name, service.proxy_path, service.server.host,
|
||||
)
|
||||
|
||||
yield self.api_request(service.proxy_path,
|
||||
method='POST',
|
||||
body=dict(
|
||||
target=service.server.host,
|
||||
service=service.name,
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
|
||||
@gen.coroutine
|
||||
def delete_service(self, service, client=None):
|
||||
"""Remove a service's server from the proxy table."""
|
||||
self.log.info("Removing service %s from proxy", service.name)
|
||||
yield self.api_request(service.proxy_path,
|
||||
method='DELETE',
|
||||
client=client,
|
||||
)
|
||||
|
||||
@gen.coroutine
|
||||
def add_user(self, user, client=None):
|
||||
"""Add a user's server to the proxy table."""
|
||||
self.log.info("Adding user %s to proxy %s => %s",
|
||||
user.name, user.proxy_path, user.server.host,
|
||||
)
|
||||
|
||||
if user.spawn_pending:
|
||||
raise RuntimeError(
|
||||
"User %s's spawn is pending, shouldn't be added to the proxy yet!", user.name)
|
||||
|
||||
yield self.api_request(user.proxy_path,
|
||||
method='POST',
|
||||
body=dict(
|
||||
target=user.server.host,
|
||||
user=user.name,
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
|
||||
@gen.coroutine
|
||||
def delete_user(self, user, client=None):
|
||||
"""Remove a user's server from the proxy table."""
|
||||
self.log.info("Removing user %s from proxy", user.name)
|
||||
yield self.api_request(user.proxy_path,
|
||||
method='DELETE',
|
||||
client=client,
|
||||
)
|
||||
|
||||
@gen.coroutine
|
||||
def add_all_services(self, service_dict):
|
||||
"""Update the proxy table from the database.
|
||||
|
||||
Used when loading up a new proxy.
|
||||
"""
|
||||
db = inspect(self).session
|
||||
futures = []
|
||||
for orm_service in db.query(Service):
|
||||
service = service_dict[orm_service.name]
|
||||
if service.server:
|
||||
futures.append(self.add_service(service))
|
||||
# wait after submitting them all
|
||||
for f in futures:
|
||||
yield f
|
||||
|
||||
@gen.coroutine
|
||||
def add_all_users(self, user_dict):
|
||||
"""Update the proxy table from the database.
|
||||
|
||||
Used when loading up a new proxy.
|
||||
"""
|
||||
db = inspect(self).session
|
||||
futures = []
|
||||
for orm_user in db.query(User):
|
||||
user = user_dict[orm_user]
|
||||
if user.running:
|
||||
futures.append(self.add_user(user))
|
||||
# wait after submitting them all
|
||||
for f in futures:
|
||||
yield f
|
||||
|
||||
@gen.coroutine
|
||||
def get_routes(self, client=None):
|
||||
"""Fetch the proxy's routes"""
|
||||
resp = yield self.api_request('', client=client)
|
||||
return json.loads(resp.body.decode('utf8', 'replace'))
|
||||
|
||||
@gen.coroutine
|
||||
def check_routes(self, user_dict, service_dict, routes=None):
|
||||
"""Check that all users are properly routed on the proxy"""
|
||||
if not routes:
|
||||
routes = yield self.get_routes()
|
||||
|
||||
user_routes = { r['user'] for r in routes.values() if 'user' in r }
|
||||
futures = []
|
||||
db = inspect(self).session
|
||||
for orm_user in db.query(User):
|
||||
user = user_dict[orm_user]
|
||||
if user.running:
|
||||
if user.name not in user_routes:
|
||||
self.log.warning("Adding missing route for %s (%s)", user.name, user.server)
|
||||
futures.append(self.add_user(user))
|
||||
else:
|
||||
# User not running, make sure it's not in the table
|
||||
if user.name in user_routes:
|
||||
self.log.warning("Removing route for not running %s", user.name)
|
||||
futures.append(self.delete_user(user))
|
||||
|
||||
# check service routes
|
||||
service_routes = { r['service'] for r in routes.values() if 'service' in r }
|
||||
for orm_service in db.query(Service).filter(Service.server != None):
|
||||
service = service_dict[orm_service.name]
|
||||
if service.server is None:
|
||||
# This should never be True, but seems to be on rare occasion.
|
||||
# catch filter bug, either in sqlalchemy or my understanding of its behavior
|
||||
self.log.error("Service %s has no server, but wasn't filtered out.", service)
|
||||
continue
|
||||
if service.name not in service_routes:
|
||||
self.log.warning("Adding missing route for %s (%s)", service.name, service.server)
|
||||
futures.append(self.add_service(service))
|
||||
for f in futures:
|
||||
yield f
|
||||
|
||||
|
||||
|
||||
class Hub(Base):
|
||||
"""Bring it all together at the hub.
|
||||
|
||||
The Hub is a server, plus its API path suffix
|
||||
|
||||
the api_url is the full URL plus the api_path suffix on the end
|
||||
of the server base_url.
|
||||
"""
|
||||
__tablename__ = 'hubs'
|
||||
id = Column(Integer, primary_key=True)
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
host = ''
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
"""return the full API url (with proto://host...)"""
|
||||
return url_path_join(self.server.url, 'api')
|
||||
|
||||
def __repr__(self):
|
||||
if self.server:
|
||||
return "<%s %s:%s>" % (
|
||||
self.__class__.__name__, self.server.ip, self.server.port,
|
||||
)
|
||||
else:
|
||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
||||
|
||||
|
||||
# user:group many:many mapping table
|
||||
user_group_map = Table('user_group_map', Base.metadata,
|
||||
@@ -320,31 +82,34 @@ user_group_map = Table('user_group_map', Base.metadata,
|
||||
Column('group_id', ForeignKey('groups.id'), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Group(Base):
|
||||
"""User Groups"""
|
||||
__tablename__ = 'groups'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(Unicode(1023), unique=True)
|
||||
name = Column(Unicode(255), unique=True)
|
||||
users = relationship('User', secondary='user_group_map', back_populates='groups')
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s %s (%i users)>" % (
|
||||
self.__class__.__name__, self.name, len(self.users)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find(cls, db, name):
|
||||
"""Find a group by name.
|
||||
|
||||
Returns None if not found.
|
||||
"""
|
||||
return db.query(cls).filter(cls.name==name).first()
|
||||
return db.query(cls).filter(cls.name == name).first()
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""The User table
|
||||
|
||||
Each user has a single server,
|
||||
and multiple tokens used for authorization.
|
||||
Each user can have one or more single user notebook servers.
|
||||
|
||||
Each single user notebook server will have a unique token for authorization.
|
||||
Therefore, a user with multiple notebook servers will have multiple tokens.
|
||||
|
||||
API tokens grant access to the Hub's REST API.
|
||||
These are used by single-user servers to authenticate requests,
|
||||
@@ -355,56 +120,70 @@ class User(Base):
|
||||
|
||||
A `state` column contains a JSON dict,
|
||||
used for restoring state of a Spawner.
|
||||
|
||||
|
||||
`servers` is a list that contains a reference for each of the user's single user notebook servers.
|
||||
The method `server` returns the first entry in the user's `servers` list.
|
||||
"""
|
||||
__tablename__ = 'users'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(Unicode(1023), unique=True)
|
||||
# should we allow multiple servers per user?
|
||||
_server_id = Column(Integer, ForeignKey('servers.id', ondelete="SET NULL"))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
name = Column(Unicode(255), unique=True)
|
||||
|
||||
_orm_spawners = relationship("Spawner", backref="user")
|
||||
@property
|
||||
def orm_spawners(self):
|
||||
return {s.name: s for s in self._orm_spawners}
|
||||
|
||||
admin = Column(Boolean, default=False)
|
||||
last_activity = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
api_tokens = relationship("APIToken", backref="user")
|
||||
cookie_id = Column(Unicode(1023), default=new_token)
|
||||
cookie_id = Column(Unicode(255), default=new_token, nullable=False, unique=True)
|
||||
# User.state is actually Spawner state
|
||||
# We will need to figure something else out if/when we have multiple spawners per user
|
||||
state = Column(JSONDict)
|
||||
# Authenticators can store their state here:
|
||||
auth_state = Column(JSONDict)
|
||||
# Encryption is handled elsewhere
|
||||
encrypted_auth_state = Column(LargeBinary)
|
||||
# group mapping
|
||||
groups = relationship('Group', secondary='user_group_map', back_populates='users')
|
||||
|
||||
other_user_cookies = set([])
|
||||
|
||||
def __repr__(self):
|
||||
if self.server:
|
||||
return "<{cls}({name}@{ip}:{port})>".format(
|
||||
cls=self.__class__.__name__,
|
||||
name=self.name,
|
||||
ip=self.server.ip,
|
||||
port=self.server.port,
|
||||
)
|
||||
else:
|
||||
return "<{cls}({name} [unconfigured])>".format(
|
||||
cls=self.__class__.__name__,
|
||||
name=self.name,
|
||||
)
|
||||
return "<{cls}({name} {running}/{total} running)>".format(
|
||||
cls=self.__class__.__name__,
|
||||
name=self.name,
|
||||
total=len(self._orm_spawners),
|
||||
running=sum(bool(s.server) for s in self._orm_spawners),
|
||||
)
|
||||
|
||||
def new_api_token(self, token=None):
|
||||
def new_api_token(self, token=None, generated=True):
|
||||
"""Create a new API token
|
||||
|
||||
|
||||
If `token` is given, load that token.
|
||||
"""
|
||||
return APIToken.new(token=token, user=self)
|
||||
return APIToken.new(token=token, user=self, generated=generated)
|
||||
|
||||
@classmethod
|
||||
def find(cls, db, name):
|
||||
"""Find a user by name.
|
||||
|
||||
Returns None if not found.
|
||||
"""
|
||||
return db.query(cls).filter(cls.name==name).first()
|
||||
return db.query(cls).filter(cls.name == name).first()
|
||||
|
||||
class Spawner(Base):
|
||||
""""State about a Spawner"""
|
||||
__tablename__ = 'spawners'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
|
||||
server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server = relationship(Server)
|
||||
|
||||
state = Column(JSONDict)
|
||||
name = Column(Unicode(255))
|
||||
|
||||
last_activity = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Service(Base):
|
||||
@@ -414,7 +193,6 @@ class Service(Base):
|
||||
A service can have API tokens for accessing the Hub's API
|
||||
|
||||
It has:
|
||||
|
||||
- name
|
||||
- admin
|
||||
- api tokens
|
||||
@@ -427,9 +205,9 @@ class Service(Base):
|
||||
"""
|
||||
__tablename__ = 'services'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
|
||||
# common user interface:
|
||||
name = Column(Unicode(1023), unique=True)
|
||||
name = Column(Unicode(255), unique=True)
|
||||
admin = Column(Boolean, default=False)
|
||||
|
||||
api_tokens = relationship("APIToken", backref="service")
|
||||
@@ -439,42 +217,33 @@ class Service(Base):
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
pid = Column(Integer)
|
||||
|
||||
def new_api_token(self, token=None):
|
||||
def new_api_token(self, token=None, generated=True):
|
||||
"""Create a new API token
|
||||
|
||||
If `token` is given, load that token.
|
||||
"""
|
||||
return APIToken.new(token=token, service=self)
|
||||
|
||||
return APIToken.new(token=token, service=self, generated=generated)
|
||||
|
||||
@classmethod
|
||||
def find(cls, db, name):
|
||||
"""Find a service by name.
|
||||
|
||||
Returns None if not found.
|
||||
"""
|
||||
return db.query(cls).filter(cls.name==name).first()
|
||||
return db.query(cls).filter(cls.name == name).first()
|
||||
|
||||
|
||||
class APIToken(Base):
|
||||
"""An API token"""
|
||||
__tablename__ = 'api_tokens'
|
||||
|
||||
# _constraint = ForeignKeyConstraint(['user_id', 'server_id'], ['users.id', 'services.id'])
|
||||
@declared_attr
|
||||
def user_id(cls):
|
||||
return Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=True)
|
||||
|
||||
@declared_attr
|
||||
def service_id(cls):
|
||||
return Column(Integer, ForeignKey('services.id', ondelete="CASCADE"), nullable=True)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
hashed = Column(Unicode(1023))
|
||||
prefix = Column(Unicode(1023))
|
||||
class Hashed(object):
|
||||
"""Mixin for tables with hashed tokens"""
|
||||
prefix_length = 4
|
||||
algorithm = "sha512"
|
||||
rounds = 16384
|
||||
salt_bytes = 8
|
||||
min_length = 8
|
||||
|
||||
# values to use for internally generated tokens,
|
||||
# which have good entropy as UUIDs
|
||||
generated = True
|
||||
generated_salt_bytes = 8
|
||||
generated_rounds = 1
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
@@ -484,7 +253,72 @@ class APIToken(Base):
|
||||
def token(self, token):
|
||||
"""Store the hashed value and prefix for a token"""
|
||||
self.prefix = token[:self.prefix_length]
|
||||
self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm)
|
||||
if self.generated:
|
||||
# Generated tokens are UUIDs, which have sufficient entropy on their own
|
||||
# and don't need salt & hash rounds.
|
||||
# ref: https://security.stackexchange.com/a/151262/155114
|
||||
rounds = self.generated_rounds
|
||||
salt_bytes = self.generated_salt_bytes
|
||||
else:
|
||||
rounds = self.rounds
|
||||
salt_bytes = self.salt_bytes
|
||||
self.hashed = hash_token(token, rounds=rounds, salt=salt_bytes, algorithm=self.algorithm)
|
||||
|
||||
def match(self, token):
|
||||
"""Is this my token?"""
|
||||
return compare_token(self.hashed, token)
|
||||
|
||||
@classmethod
|
||||
def check_token(cls, db, token):
|
||||
"""Check if a token is acceptable"""
|
||||
if len(token) < cls.min_length:
|
||||
raise ValueError("Tokens must be at least %i characters, got %r" % (
|
||||
cls.min_length, token)
|
||||
)
|
||||
found = cls.find(db, token)
|
||||
if found:
|
||||
raise ValueError("Collision on token: %s..." % token[:cls.prefix_length])
|
||||
|
||||
@classmethod
|
||||
def find_prefix(cls, db, token):
|
||||
"""Start the query for matching token.
|
||||
|
||||
Returns an SQLAlchemy query already filtered by prefix-matches.
|
||||
"""
|
||||
prefix = token[:cls.prefix_length]
|
||||
# since we can't filter on hashed values, filter on prefix
|
||||
# so we aren't comparing with all tokens
|
||||
return db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix))
|
||||
|
||||
@classmethod
|
||||
def find(cls, db, token):
|
||||
"""Find a token object by value.
|
||||
|
||||
Returns None if not found.
|
||||
|
||||
`kind='user'` only returns API tokens for users
|
||||
`kind='service'` only returns API tokens for services
|
||||
"""
|
||||
prefix_match = cls.find_prefix(db, token)
|
||||
for orm_token in prefix_match:
|
||||
if orm_token.match(token):
|
||||
return orm_token
|
||||
|
||||
class APIToken(Hashed, Base):
|
||||
"""An API token"""
|
||||
__tablename__ = 'api_tokens'
|
||||
|
||||
@declared_attr
|
||||
def user_id(cls):
|
||||
return Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=True)
|
||||
|
||||
@declared_attr
|
||||
def service_id(cls):
|
||||
return Column(Integer, ForeignKey('services.id', ondelete="CASCADE"), nullable=True)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
hashed = Column(Unicode(255), unique=True)
|
||||
prefix = Column(Unicode(16), index=True)
|
||||
|
||||
def __repr__(self):
|
||||
if self.user is not None:
|
||||
@@ -509,14 +343,11 @@ class APIToken(Base):
|
||||
"""Find a token object by value.
|
||||
|
||||
Returns None if not found.
|
||||
|
||||
|
||||
`kind='user'` only returns API tokens for users
|
||||
`kind='service'` only returns API tokens for services
|
||||
"""
|
||||
prefix = token[:cls.prefix_length]
|
||||
# since we can't filter on hashed values, filter on prefix
|
||||
# so we aren't comparing with all tokens
|
||||
prefix_match = db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix))
|
||||
prefix_match = cls.find_prefix(db, token)
|
||||
if kind == 'user':
|
||||
prefix_match = prefix_match.filter(cls.user_id != None)
|
||||
elif kind == 'service':
|
||||
@@ -527,25 +358,23 @@ class APIToken(Base):
|
||||
if orm_token.match(token):
|
||||
return orm_token
|
||||
|
||||
def match(self, token):
|
||||
"""Is this my token?"""
|
||||
return compare_token(self.hashed, token)
|
||||
|
||||
@classmethod
|
||||
def new(cls, token=None, user=None, service=None):
|
||||
def new(cls, token=None, user=None, service=None, generated=True):
|
||||
"""Generate a new API token for a user or service"""
|
||||
assert user or service
|
||||
assert not (user and service)
|
||||
db = inspect(user or service).session
|
||||
if token is None:
|
||||
token = new_token()
|
||||
# Don't need hash + salt rounds on generated tokens,
|
||||
# which already have good entropy
|
||||
generated = True
|
||||
else:
|
||||
if len(token) < 8:
|
||||
raise ValueError("Tokens must be at least 8 characters, got %r" % token)
|
||||
found = APIToken.find(db, token)
|
||||
if found:
|
||||
raise ValueError("Collision on token: %s..." % token[:4])
|
||||
orm_token = APIToken(token=token)
|
||||
cls.check_token(db, token)
|
||||
# two stages to ensure orm_token.generated has been set
|
||||
# before token setter is called
|
||||
orm_token = cls(generated=generated)
|
||||
orm_token.token = token
|
||||
if user:
|
||||
assert user.id is not None
|
||||
orm_token.user_id = user.id
|
||||
@@ -557,6 +386,130 @@ class APIToken(Base):
|
||||
return token
|
||||
|
||||
|
||||
#------------------------------------
|
||||
# OAuth tables
|
||||
#------------------------------------
|
||||
|
||||
|
||||
class GrantType(enum.Enum):
|
||||
# we only use authorization_code for now
|
||||
authorization_code = 'authorization_code'
|
||||
implicit = 'implicit'
|
||||
password = 'password'
|
||||
client_credentials = 'client_credentials'
|
||||
refresh_token = 'refresh_token'
|
||||
|
||||
|
||||
class OAuthAccessToken(Hashed, Base):
|
||||
__tablename__ = 'oauth_access_tokens'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
client_id = Column(Unicode(255))
|
||||
grant_type = Column(Enum(GrantType), nullable=False)
|
||||
expires_at = Column(Integer)
|
||||
refresh_token = Column(Unicode(255))
|
||||
refresh_expires_at = Column(Integer)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
user = relationship(User)
|
||||
service = None # for API-equivalence with APIToken
|
||||
|
||||
# from Hashed
|
||||
hashed = Column(Unicode(255), unique=True)
|
||||
prefix = Column(Unicode(16), index=True)
|
||||
|
||||
def __repr__(self):
|
||||
return "<{cls}('{prefix}...', user='{user}'>".format(
|
||||
cls=self.__class__.__name__,
|
||||
user=self.user and self.user.name,
|
||||
prefix=self.prefix,
|
||||
)
|
||||
|
||||
|
||||
class OAuthCode(Base):
|
||||
__tablename__ = 'oauth_codes'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
client_id = Column(Unicode(255))
|
||||
code = Column(Unicode(36))
|
||||
expires_at = Column(Integer)
|
||||
redirect_uri = Column(Unicode(1023))
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
|
||||
|
||||
class OAuthClient(Base):
|
||||
__tablename__ = 'oauth_clients'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
identifier = Column(Unicode(255), unique=True)
|
||||
secret = Column(Unicode(255))
|
||||
redirect_uri = Column(Unicode(1023))
|
||||
|
||||
|
||||
class DatabaseSchemaMismatch(Exception):
|
||||
"""Exception raised when the database schema version does not match
|
||||
|
||||
the current version of JupyterHub.
|
||||
"""
|
||||
|
||||
def check_db_revision(engine):
|
||||
"""Check the JupyterHub database revision
|
||||
|
||||
After calling this function, an alembic tag is guaranteed to be stored in the db.
|
||||
|
||||
- Checks the alembic tag and raises a ValueError if it's not the current revision
|
||||
- If no tag is stored (Bug in Hub prior to 0.8),
|
||||
guess revision based on db contents and tag the revision.
|
||||
- Empty databases are tagged with the current revision
|
||||
"""
|
||||
# Check database schema version
|
||||
current_table_names = set(engine.table_names())
|
||||
my_table_names = set(Base.metadata.tables.keys())
|
||||
|
||||
with _temp_alembic_ini(engine.url) as ini:
|
||||
cfg = alembic.config.Config(ini)
|
||||
scripts = ScriptDirectory.from_config(cfg)
|
||||
head = scripts.get_heads()[0]
|
||||
base = scripts.get_base()
|
||||
|
||||
if not my_table_names.intersection(current_table_names):
|
||||
# no tables have been created, stamp with current revision
|
||||
app_log.debug("Stamping empty database with alembic revision %s", head)
|
||||
alembic.command.stamp(cfg, head)
|
||||
return
|
||||
|
||||
if 'alembic_version' not in current_table_names:
|
||||
# Has not been tagged or upgraded before.
|
||||
# we didn't start tagging revisions correctly except during `upgrade-db`
|
||||
# until 0.8
|
||||
# This should only occur for databases created prior to JupyterHub 0.8
|
||||
msg_t = "Database schema version not found, guessing that JupyterHub %s created this database."
|
||||
if 'spawners' in current_table_names:
|
||||
# 0.8
|
||||
app_log.warning(msg_t, '0.8.dev')
|
||||
rev = head
|
||||
elif 'services' in current_table_names:
|
||||
# services is present, tag for 0.7
|
||||
app_log.warning(msg_t, '0.7.x')
|
||||
rev = 'af4cbdb2d13c'
|
||||
else:
|
||||
# it's old, mark as first revision
|
||||
app_log.warning(msg_t, '0.6 or earlier')
|
||||
rev = base
|
||||
app_log.debug("Stamping database schema version %s", rev)
|
||||
alembic.command.stamp(cfg, rev)
|
||||
|
||||
# check database schema version
|
||||
# it should always be defined at this point
|
||||
alembic_revision = engine.execute('SELECT version_num FROM alembic_version').first()[0]
|
||||
if alembic_revision == head:
|
||||
app_log.debug("database schema version found: %s", alembic_revision)
|
||||
pass
|
||||
else:
|
||||
raise DatabaseSchemaMismatch("Found database schema version {found} != {head}. "
|
||||
"Backup your database and run `jupyterhub upgrade-db`"
|
||||
" to upgrade to the latest schema.".format(
|
||||
found=alembic_revision,
|
||||
head=head,
|
||||
))
|
||||
|
||||
def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
|
||||
"""Create a new session at url"""
|
||||
if url.startswith('sqlite'):
|
||||
@@ -572,6 +525,9 @@ def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
|
||||
engine = create_engine(url, **kwargs)
|
||||
if reset:
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
# check the db revision (will raise, pointing to `upgrade-db` if version doesn't match)
|
||||
check_db_revision(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user