diff --git a/dev-requirements.txt b/dev-requirements.txt index e079f8a6..93253de9 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1 +1,2 @@ +mock pytest diff --git a/jupyterhub/app.py b/jupyterhub/app.py index d29403a9..39436537 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -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 diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py new file mode 100644 index 00000000..c6d4efdf --- /dev/null +++ b/jupyterhub/auth.py @@ -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) + \ No newline at end of file diff --git a/jupyterhub/handlers.py b/jupyterhub/handlers.py index cdcec270..34f9a366 100644 --- a/jupyterhub/handlers.py +++ b/jupyterhub/handlers.py @@ -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,51 +156,69 @@ 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 + raise gen.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) - - # create and set a new cookie token for the single-user server - cookie_token = user.new_cookie_token() - self.db.add(cookie_token) - self.db.commit() - - self.set_cookie( - user.server.cookie_name, - cookie_token.token, - path=user.server.base_url, - ) - - # create and set a new cookie token for the hub - cookie_token = user.new_cookie_token() - self.db.add(cookie_token) - self.db.commit() - self.set_cookie( - self.hub.server.cookie_name, - cookie_token.token, - path=self.hub.server.base_url) + 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) + self.db.commit() + + self.set_cookie( + user.server.cookie_name, + cookie_token.token, + path=user.server.base_url, + ) + + # create and set a new cookie token for the hub + cookie_token = user.new_cookie_token() + self.db.add(cookie_token) + self.db.commit() + self.set_cookie( + 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) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index f7466497..cf310f9c 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -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): @@ -103,15 +104,18 @@ class Spawner(LoggingConfigurable): '--hub-prefix=%s' % self.hub.server.base_url, '--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 - os.kill(self.pid, signal.SIGINT) - os.kill(self.pid, signal.SIGINT) - self._wait_for_death(10) + # SIGINT to request clean shutdown + self.log.debug("Interrupting %i", self.pid) + try: + os.kill(self.pid, signal.SIGINT) + 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: - os.kill(self.pid, signal.SIGTERM) - self._wait_for_death(5) + status = yield self.poll() + if status is None: + self.log.debug("Terminating %i", self.pid) + try: + os.kill(self.pid, signal.SIGTERM) + 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: - os.kill(self.pid, signal.SIGKILL) - self._wait_for_death(5) + status = yield self.poll() + if status is None: + self.log.debug("Killing %i", self.pid) + try: + os.kill(self.pid, signal.SIGKILL) + 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) diff --git a/jupyterhub/templates/login.html b/jupyterhub/templates/login.html index 843827c6..0efa7cee 100644 --- a/jupyterhub/templates/login.html +++ b/jupyterhub/templates/login.html @@ -7,7 +7,7 @@
{{message}}
{% end if %}
- +
diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py new file mode 100644 index 00000000..67bd656b --- /dev/null +++ b/jupyterhub/tests/test_auth.py @@ -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) + diff --git a/requirements.txt b/requirements.txt index bcb002be..905b1b0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +ipython[notebook] +simplepam sqlalchemy requests -ipython[notebook]