mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-14 13:33:00 +00:00
add configurable authentication
and coroutine auth and spawning
This commit is contained in:
@@ -1 +1,2 @@
|
||||
mock
|
||||
pytest
|
||||
|
@@ -9,7 +9,7 @@ import tornado.httpserver
|
||||
import tornado.ioloop
|
||||
import tornado.options
|
||||
from tornado.log import LogFormatter
|
||||
from tornado import web
|
||||
from tornado import gen, web
|
||||
|
||||
from IPython.utils.traitlets import (
|
||||
Unicode, Integer, Dict, TraitError, List, Instance, Bool, Bytes, Any,
|
||||
@@ -87,8 +87,27 @@ class JupyterHubApp(Application):
|
||||
def _cookie_secret_default(self):
|
||||
return b'secret!'
|
||||
|
||||
authenticator = DottedObjectName("jupyterhub.auth.PAMAuthenticator", config=True,
|
||||
help="""Class for authenticating users.
|
||||
|
||||
This should be a class with the following form:
|
||||
|
||||
- constructor takes one kwarg: `config`, the IPython config object.
|
||||
|
||||
- is a tornado.gen.coroutine
|
||||
- returns username on success, None on failure
|
||||
- takes two arguments: (handler, data),
|
||||
where `handler` is the calling web.RequestHandler,
|
||||
and `data` is the POST form data from the login page.
|
||||
"""
|
||||
)
|
||||
# class for spawning single-user servers
|
||||
spawner_class = DottedObjectName("jupyterhub.spawner.LocalProcessSpawner")
|
||||
spawner_class = DottedObjectName("jupyterhub.spawner.LocalProcessSpawner", config=True,
|
||||
help="""The class to use for spawning single-user servers.
|
||||
|
||||
Should be a subclass of Spawner.
|
||||
"""
|
||||
)
|
||||
|
||||
db_url = Unicode('sqlite:///:memory:', config=True)
|
||||
debug_db = Bool(False)
|
||||
@@ -201,6 +220,7 @@ class JupyterHubApp(Application):
|
||||
log=self.log,
|
||||
db=self.db,
|
||||
hub=self.hub,
|
||||
authenticator=import_item(self.authenticator)(config=self.config),
|
||||
spawner_class=import_item(self.spawner_class),
|
||||
base_url=base_url,
|
||||
cookie_secret=self.cookie_secret,
|
||||
@@ -225,15 +245,25 @@ class JupyterHubApp(Application):
|
||||
self.init_tornado_settings()
|
||||
self.init_tornado_application()
|
||||
|
||||
@gen.coroutine
|
||||
def cleanup(self):
|
||||
self.log.info("Cleaning up proxy...")
|
||||
self.proxy.terminate()
|
||||
self.log.info("Cleaning up single-user servers...")
|
||||
Spawner = import_item(self.spawner_class)
|
||||
|
||||
# request (async) process termination
|
||||
futures = []
|
||||
for user in self.db.query(db.User):
|
||||
if user.spawner is not None:
|
||||
user.spawner.stop()
|
||||
futures.append(user.spawner.stop())
|
||||
|
||||
# wait for the requests to stop finish:
|
||||
for f in futures:
|
||||
yield f
|
||||
|
||||
# finally stop the loop once we are all cleaned up
|
||||
self.log.info("...done")
|
||||
tornado.ioloop.IOLoop.instance().stop()
|
||||
|
||||
def start(self):
|
||||
"""Start the whole thing"""
|
||||
@@ -242,12 +272,17 @@ class JupyterHubApp(Application):
|
||||
# start the webserver
|
||||
http_server = tornado.httpserver.HTTPServer(self.tornado_application)
|
||||
http_server.listen(self.hub_port)
|
||||
|
||||
loop = tornado.ioloop.IOLoop.instance()
|
||||
try:
|
||||
tornado.ioloop.IOLoop.instance().start()
|
||||
loop.start()
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted")
|
||||
finally:
|
||||
self.cleanup()
|
||||
# have to start the loop one more time,
|
||||
# to allow for async cleanup code.
|
||||
loop.add_callback(self.cleanup)
|
||||
tornado.ioloop.IOLoop.instance().start()
|
||||
|
||||
main = JupyterHubApp.launch_instance
|
||||
|
||||
|
45
jupyterhub/auth.py
Normal file
45
jupyterhub/auth.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Simple PAM authenticator"""
|
||||
|
||||
from tornado import gen
|
||||
import simplepam
|
||||
|
||||
from IPython.config import LoggingConfigurable
|
||||
from IPython.utils.traitlets import Unicode
|
||||
|
||||
class Authenticator(LoggingConfigurable):
|
||||
"""A class for authentication.
|
||||
|
||||
The API is one method, `authenticate`, a tornado gen.coroutine.
|
||||
"""
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data):
|
||||
"""Authenticate a user with login form data.
|
||||
|
||||
This must be a tornado gen.coroutine.
|
||||
It must return the username on successful authentication,
|
||||
and return None on failed authentication.
|
||||
"""
|
||||
|
||||
class PAMAuthenticator(Authenticator):
|
||||
encoding = Unicode('utf8', config=True,
|
||||
help="""The encoding to use for PAM """
|
||||
)
|
||||
service = Unicode('login', config=True,
|
||||
help="""The PAM service to use for authentication."""
|
||||
)
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, handler, data):
|
||||
"""Authenticate with PAM, and return the username if login is successful.
|
||||
|
||||
Return None otherwise.
|
||||
"""
|
||||
username = data['username']
|
||||
# simplepam wants bytes, not unicode
|
||||
# see
|
||||
busername = username.encode(self.encoding)
|
||||
bpassword = data['password'].encode(self.encoding)
|
||||
if simplepam.authenticate(busername, bpassword, service=self.service):
|
||||
raise gen.Return(username)
|
||||
|
@@ -12,11 +12,11 @@ from tornado.log import app_log
|
||||
from tornado.escape import url_escape
|
||||
from tornado.httputil import url_concat
|
||||
from tornado.web import RequestHandler
|
||||
from tornado import web
|
||||
from tornado import gen, web
|
||||
|
||||
from . import db
|
||||
from .spawner import LocalProcessSpawner
|
||||
from .utils import random_port, wait_for_server, url_path_join
|
||||
from .utils import wait_for_server, url_path_join
|
||||
|
||||
|
||||
class BaseHandler(RequestHandler):
|
||||
@@ -59,7 +59,7 @@ class BaseHandler(RequestHandler):
|
||||
def clear_login_cookie(self):
|
||||
username = self.get_current_user()
|
||||
if username is not None:
|
||||
user = self.db.query(User).filter(name=username).first()
|
||||
user = self.db.query(db.User).filter(name=username).first()
|
||||
if user is not None:
|
||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
|
||||
self.clear_cookie(self.cookie_name, path=self.hub.base_url)
|
||||
@@ -103,10 +103,10 @@ class LogoutHandler(BaseHandler):
|
||||
class LoginHandler(BaseHandler):
|
||||
"""Render the login page."""
|
||||
|
||||
def _render(self, message=None, user=None):
|
||||
def _render(self, message=None, username=None):
|
||||
self.render('login.html',
|
||||
next=url_escape(self.get_argument('next', default='')),
|
||||
user=user,
|
||||
username=username,
|
||||
message=message,
|
||||
)
|
||||
|
||||
@@ -114,9 +114,10 @@ class LoginHandler(BaseHandler):
|
||||
if False and self.get_current_user():
|
||||
self.redirect(self.get_argument('next', default='/'))
|
||||
else:
|
||||
user = self.get_argument('user', default='')
|
||||
self._render(user=user)
|
||||
username = self.get_argument('username', default='')
|
||||
self._render(username=username)
|
||||
|
||||
@gen.coroutine
|
||||
def notify_proxy(self, user):
|
||||
proxy = self.db.query(db.Proxy).first()
|
||||
r = requests.post(
|
||||
@@ -130,9 +131,10 @@ class LoginHandler(BaseHandler):
|
||||
)),
|
||||
headers={'Authorization': "token %s" % proxy.auth_token},
|
||||
)
|
||||
wait_for_server(user.server.ip, user.server.port)
|
||||
yield wait_for_server(user.server.ip, user.server.port)
|
||||
r.raise_for_status()
|
||||
|
||||
@gen.coroutine
|
||||
def spawn_single_user(self, name):
|
||||
user = db.User(name=name,
|
||||
server=db.Server(
|
||||
@@ -154,24 +156,17 @@ class LoginHandler(BaseHandler):
|
||||
hub=self.hub,
|
||||
api_token=api_token.token,
|
||||
)
|
||||
spawner.start()
|
||||
yield spawner.start()
|
||||
|
||||
# store state
|
||||
user.state = spawner.get_state()
|
||||
self.db.commit()
|
||||
|
||||
self.notify_proxy(user)
|
||||
return user
|
||||
|
||||
def post(self):
|
||||
name = self.get_argument('user', default='')
|
||||
pwd = self.get_argument('password', default=u'')
|
||||
next_url = self.get_argument('next', default='') or '/user/%s/' % name
|
||||
if name and pwd == 'password':
|
||||
user = self.db.query(db.User).filter(db.User.name == name).first()
|
||||
if user is None:
|
||||
user = self.spawn_single_user(name)
|
||||
raise gen.Return(user)
|
||||
|
||||
def set_login_cookies(self, user):
|
||||
"""Set login cookies for the Hub and single-user server."""
|
||||
# create and set a new cookie token for the single-user server
|
||||
cookie_token = user.new_cookie_token()
|
||||
self.db.add(cookie_token)
|
||||
@@ -191,14 +186,39 @@ class LoginHandler(BaseHandler):
|
||||
self.hub.server.cookie_name,
|
||||
cookie_token.token,
|
||||
path=self.hub.server.base_url)
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, data):
|
||||
auth = self.settings.get('authenticator', None)
|
||||
if auth is not None:
|
||||
result = yield auth.authenticate(self, data)
|
||||
raise gen.Return(result)
|
||||
else:
|
||||
self.log.error("No authentication function, login is impossible!")
|
||||
|
||||
@gen.coroutine
|
||||
def post(self):
|
||||
# parse the arguments dict
|
||||
data = {}
|
||||
for arg in self.request.arguments:
|
||||
data[arg] = self.get_argument(arg)
|
||||
|
||||
username = data['username']
|
||||
authorized = yield self.authenticate(data)
|
||||
if authorized:
|
||||
user = self.db.query(db.User).filter(db.User.name == username).first()
|
||||
if user is None:
|
||||
user = yield self.spawn_single_user(username)
|
||||
self.set_login_cookies(user)
|
||||
next_url = self.get_argument('next', default='') or '/user/%s/' % username
|
||||
self.redirect(next_url)
|
||||
else:
|
||||
self.log.debug("Failed login for %s", username)
|
||||
self._render(
|
||||
message={'error': 'Invalid username or password'},
|
||||
user=user,
|
||||
username=username,
|
||||
)
|
||||
return
|
||||
|
||||
self.redirect(next_url)
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# API Handlers
|
||||
@@ -216,7 +236,6 @@ def token_authorized(method):
|
||||
raise web.HTTPError(403)
|
||||
token = match.group(1)
|
||||
db_token = self.db.query(db.APIToken).filter(db.APIToken.token == token).first()
|
||||
self.log.info("Token: %s: %s", token, db_token)
|
||||
if db_token is None:
|
||||
raise web.HTTPError(403)
|
||||
return method(self, *args, **kwargs)
|
||||
|
@@ -11,6 +11,7 @@ import time
|
||||
from subprocess import Popen
|
||||
|
||||
from tornado import gen
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
from IPython.config import LoggingConfigurable
|
||||
from IPython.utils.traitlets import (
|
||||
@@ -18,7 +19,7 @@ from IPython.utils.traitlets import (
|
||||
)
|
||||
|
||||
|
||||
from .utils import random_port, wait_for_server
|
||||
from .utils import random_port
|
||||
|
||||
|
||||
class Spawner(LoggingConfigurable):
|
||||
@@ -104,14 +105,17 @@ class Spawner(LoggingConfigurable):
|
||||
'--hub-api-url=%s' % self.hub.api_url,
|
||||
]
|
||||
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
raise NotImplementedError("Override in subclass")
|
||||
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
|
||||
|
||||
@gen.coroutine
|
||||
def stop(self):
|
||||
raise NotImplementedError("Override in subclass")
|
||||
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
|
||||
|
||||
@gen.coroutine
|
||||
def poll(self):
|
||||
raise NotImplementedError("Override in subclass")
|
||||
raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
|
||||
|
||||
|
||||
class LocalProcessSpawner(Spawner):
|
||||
@@ -125,6 +129,7 @@ class LocalProcessSpawner(Spawner):
|
||||
def get_state(self):
|
||||
return dict(pid=self.pid)
|
||||
|
||||
@gen.coroutine
|
||||
def start(self):
|
||||
self.user.server.port = random_port()
|
||||
cmd = self.cmd + self.get_args()
|
||||
@@ -136,10 +141,11 @@ class LocalProcessSpawner(Spawner):
|
||||
)
|
||||
self.pid = self.proc.pid
|
||||
|
||||
@gen.coroutine
|
||||
def poll(self):
|
||||
# if we started the process, poll with Popen
|
||||
if self.proc is not None:
|
||||
return self.proc.poll()
|
||||
raise gen.Return(self.proc.poll())
|
||||
|
||||
# if we resumed from stored state,
|
||||
# we don't have the Popen handle anymore
|
||||
@@ -150,38 +156,62 @@ class LocalProcessSpawner(Spawner):
|
||||
except OSError as e:
|
||||
if e.errno == errno.ESRCH:
|
||||
# no such process, return exitcode == 0, since we don't know the exit status
|
||||
return 0
|
||||
raise gen.Return(0)
|
||||
else:
|
||||
# None indicates the process is running
|
||||
return None
|
||||
raise gen.Return(None)
|
||||
|
||||
@gen.coroutine
|
||||
def _wait_for_death(self, timeout=10):
|
||||
"""wait for the process to die, up to timeout seconds"""
|
||||
for i in range(int(timeout * 10)):
|
||||
if self.poll() is not None:
|
||||
status = yield self.poll()
|
||||
if status is not None:
|
||||
break
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
yield gen.Task(IOLoop.instance().add_timeout, time.time() + 0.1)
|
||||
|
||||
@gen.coroutine
|
||||
def stop(self, now=False):
|
||||
"""stop the subprocess"""
|
||||
"""stop the subprocess
|
||||
|
||||
if `now`, skip waiting for clean shutdown
|
||||
"""
|
||||
if not now:
|
||||
# double-sigint to request clean shutdown
|
||||
# SIGINT to request clean shutdown
|
||||
self.log.debug("Interrupting %i", self.pid)
|
||||
try:
|
||||
os.kill(self.pid, signal.SIGINT)
|
||||
os.kill(self.pid, signal.SIGINT)
|
||||
self._wait_for_death(10)
|
||||
except OSError as e:
|
||||
if e.errno == errno.ESRCH:
|
||||
return
|
||||
|
||||
yield self._wait_for_death(10)
|
||||
|
||||
# clean shutdown failed, use TERM
|
||||
if self.poll() is None:
|
||||
status = yield self.poll()
|
||||
if status is None:
|
||||
self.log.debug("Terminating %i", self.pid)
|
||||
try:
|
||||
os.kill(self.pid, signal.SIGTERM)
|
||||
self._wait_for_death(5)
|
||||
except OSError as e:
|
||||
if e.errno == errno.ESRCH:
|
||||
return
|
||||
yield self._wait_for_death(5)
|
||||
|
||||
# TERM failed, use KILL
|
||||
if self.poll() is None:
|
||||
status = yield self.poll()
|
||||
if status is None:
|
||||
self.log.debug("Killing %i", self.pid)
|
||||
try:
|
||||
os.kill(self.pid, signal.SIGKILL)
|
||||
self._wait_for_death(5)
|
||||
except OSError as e:
|
||||
if e.errno == errno.ESRCH:
|
||||
return
|
||||
yield self._wait_for_death(5)
|
||||
|
||||
if self.poll() is None:
|
||||
status = yield self.poll()
|
||||
if status is None:
|
||||
# it all failed, zombie process
|
||||
self.log.warn("Process %i never died", self.pid)
|
||||
|
||||
|
@@ -7,7 +7,7 @@
|
||||
<div id="message">{{message}}</div>
|
||||
{% end if %}
|
||||
<form action="?next={{next}}" method="post">
|
||||
<input type="text" name="user" id="user_input" value="{{user}}">
|
||||
<input type="text" name="username" id="user_input" value="{{username}}">
|
||||
<input type="password" name="password" id="password_input">
|
||||
<button type="submit" id="login_submit">Log in</button>
|
||||
</form>
|
||||
|
40
jupyterhub/tests/test_auth.py
Normal file
40
jupyterhub/tests/test_auth.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Tests for PAM authentication"""
|
||||
|
||||
import mock
|
||||
import simplepam
|
||||
from tornado.testing import AsyncTestCase, gen_test
|
||||
from IPython.utils.py3compat import unicode_type
|
||||
|
||||
from ..auth import PAMAuthenticator
|
||||
|
||||
|
||||
def fake_auth(username, password, service='login'):
|
||||
# mimic simplepam's failure to handle unicode
|
||||
if isinstance(username, unicode_type):
|
||||
return False
|
||||
if isinstance(password, unicode_type):
|
||||
return False
|
||||
|
||||
# just use equality
|
||||
if password == username:
|
||||
return True
|
||||
|
||||
class TestPAM(AsyncTestCase):
|
||||
|
||||
@gen_test
|
||||
def test_pam_auth(self):
|
||||
authenticator = PAMAuthenticator()
|
||||
with mock.patch('simplepam.authenticate', fake_auth):
|
||||
authorized = yield authenticator.authenticate(None, {
|
||||
u'username': u'match',
|
||||
u'password': u'match',
|
||||
})
|
||||
self.assertEqual(authorized, u'match')
|
||||
|
||||
with mock.patch('simplepam.authenticate', fake_auth):
|
||||
authorized = yield authenticator.authenticate(None, {
|
||||
u'username': u'match',
|
||||
u'password': u'nomatch',
|
||||
})
|
||||
self.assertEqual(authorized, None)
|
||||
|
@@ -1,3 +1,4 @@
|
||||
ipython[notebook]
|
||||
simplepam
|
||||
sqlalchemy
|
||||
requests
|
||||
ipython[notebook]
|
||||
|
Reference in New Issue
Block a user