add configurable authentication

and coroutine auth and spawning
This commit is contained in:
MinRK
2014-08-20 11:05:10 -07:00
parent 26fe357f11
commit a156d09d11
8 changed files with 243 additions and 72 deletions

View File

@@ -1 +1,2 @@
mock
pytest pytest

View File

@@ -9,7 +9,7 @@ import tornado.httpserver
import tornado.ioloop import tornado.ioloop
import tornado.options import tornado.options
from tornado.log import LogFormatter from tornado.log import LogFormatter
from tornado import web from tornado import gen, web
from IPython.utils.traitlets import ( from IPython.utils.traitlets import (
Unicode, Integer, Dict, TraitError, List, Instance, Bool, Bytes, Any, Unicode, Integer, Dict, TraitError, List, Instance, Bool, Bytes, Any,
@@ -87,8 +87,27 @@ class JupyterHubApp(Application):
def _cookie_secret_default(self): def _cookie_secret_default(self):
return b'secret!' 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 # 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) db_url = Unicode('sqlite:///:memory:', config=True)
debug_db = Bool(False) debug_db = Bool(False)
@@ -201,6 +220,7 @@ class JupyterHubApp(Application):
log=self.log, log=self.log,
db=self.db, db=self.db,
hub=self.hub, hub=self.hub,
authenticator=import_item(self.authenticator)(config=self.config),
spawner_class=import_item(self.spawner_class), spawner_class=import_item(self.spawner_class),
base_url=base_url, base_url=base_url,
cookie_secret=self.cookie_secret, cookie_secret=self.cookie_secret,
@@ -225,15 +245,25 @@ class JupyterHubApp(Application):
self.init_tornado_settings() self.init_tornado_settings()
self.init_tornado_application() self.init_tornado_application()
@gen.coroutine
def cleanup(self): def cleanup(self):
self.log.info("Cleaning up proxy...") self.log.info("Cleaning up proxy...")
self.proxy.terminate() self.proxy.terminate()
self.log.info("Cleaning up single-user servers...") 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): for user in self.db.query(db.User):
if user.spawner is not None: 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") self.log.info("...done")
tornado.ioloop.IOLoop.instance().stop()
def start(self): def start(self):
"""Start the whole thing""" """Start the whole thing"""
@@ -242,12 +272,17 @@ class JupyterHubApp(Application):
# start the webserver # start the webserver
http_server = tornado.httpserver.HTTPServer(self.tornado_application) http_server = tornado.httpserver.HTTPServer(self.tornado_application)
http_server.listen(self.hub_port) http_server.listen(self.hub_port)
loop = tornado.ioloop.IOLoop.instance()
try: try:
tornado.ioloop.IOLoop.instance().start() loop.start()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nInterrupted") print("\nInterrupted")
finally: 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 main = JupyterHubApp.launch_instance

45
jupyterhub/auth.py Normal file
View 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)

View File

@@ -12,11 +12,11 @@ from tornado.log import app_log
from tornado.escape import url_escape from tornado.escape import url_escape
from tornado.httputil import url_concat from tornado.httputil import url_concat
from tornado.web import RequestHandler from tornado.web import RequestHandler
from tornado import web from tornado import gen, web
from . import db from . import db
from .spawner import LocalProcessSpawner 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): class BaseHandler(RequestHandler):
@@ -59,7 +59,7 @@ class BaseHandler(RequestHandler):
def clear_login_cookie(self): def clear_login_cookie(self):
username = self.get_current_user() username = self.get_current_user()
if username is not None: 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: if user is not None:
self.clear_cookie(user.server.cookie_name, path=user.server.base_url) self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
self.clear_cookie(self.cookie_name, path=self.hub.base_url) self.clear_cookie(self.cookie_name, path=self.hub.base_url)
@@ -103,10 +103,10 @@ class LogoutHandler(BaseHandler):
class LoginHandler(BaseHandler): class LoginHandler(BaseHandler):
"""Render the login page.""" """Render the login page."""
def _render(self, message=None, user=None): def _render(self, message=None, username=None):
self.render('login.html', self.render('login.html',
next=url_escape(self.get_argument('next', default='')), next=url_escape(self.get_argument('next', default='')),
user=user, username=username,
message=message, message=message,
) )
@@ -114,9 +114,10 @@ class LoginHandler(BaseHandler):
if False and self.get_current_user(): if False and self.get_current_user():
self.redirect(self.get_argument('next', default='/')) self.redirect(self.get_argument('next', default='/'))
else: else:
user = self.get_argument('user', default='') username = self.get_argument('username', default='')
self._render(user=user) self._render(username=username)
@gen.coroutine
def notify_proxy(self, user): def notify_proxy(self, user):
proxy = self.db.query(db.Proxy).first() proxy = self.db.query(db.Proxy).first()
r = requests.post( r = requests.post(
@@ -130,9 +131,10 @@ class LoginHandler(BaseHandler):
)), )),
headers={'Authorization': "token %s" % proxy.auth_token}, 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() r.raise_for_status()
@gen.coroutine
def spawn_single_user(self, name): def spawn_single_user(self, name):
user = db.User(name=name, user = db.User(name=name,
server=db.Server( server=db.Server(
@@ -154,51 +156,69 @@ class LoginHandler(BaseHandler):
hub=self.hub, hub=self.hub,
api_token=api_token.token, api_token=api_token.token,
) )
spawner.start() yield spawner.start()
# store state # store state
user.state = spawner.get_state() user.state = spawner.get_state()
self.db.commit() self.db.commit()
self.notify_proxy(user) self.notify_proxy(user)
return user raise gen.Return(user)
def post(self): def set_login_cookies(self, user):
name = self.get_argument('user', default='') """Set login cookies for the Hub and single-user server."""
pwd = self.get_argument('password', default=u'') # create and set a new cookie token for the single-user server
next_url = self.get_argument('next', default='') or '/user/%s/' % name cookie_token = user.new_cookie_token()
if name and pwd == 'password': self.db.add(cookie_token)
user = self.db.query(db.User).filter(db.User.name == name).first() self.db.commit()
if user is None:
user = self.spawn_single_user(name) self.set_cookie(
user.server.cookie_name,
# create and set a new cookie token for the single-user server cookie_token.token,
cookie_token = user.new_cookie_token() path=user.server.base_url,
self.db.add(cookie_token) )
self.db.commit()
# create and set a new cookie token for the hub
self.set_cookie( cookie_token = user.new_cookie_token()
user.server.cookie_name, self.db.add(cookie_token)
cookie_token.token, self.db.commit()
path=user.server.base_url, self.set_cookie(
) self.hub.server.cookie_name,
cookie_token.token,
# create and set a new cookie token for the hub path=self.hub.server.base_url)
cookie_token = user.new_cookie_token()
self.db.add(cookie_token) @gen.coroutine
self.db.commit() def authenticate(self, data):
self.set_cookie( auth = self.settings.get('authenticator', None)
self.hub.server.cookie_name, if auth is not None:
cookie_token.token, result = yield auth.authenticate(self, data)
path=self.hub.server.base_url) raise gen.Return(result)
else: 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( self._render(
message={'error': 'Invalid username or password'}, message={'error': 'Invalid username or password'},
user=user, username=username,
) )
return
self.redirect(next_url)
#------------------------------------------------------------------------------ #------------------------------------------------------------------------------
# API Handlers # API Handlers
@@ -216,7 +236,6 @@ def token_authorized(method):
raise web.HTTPError(403) raise web.HTTPError(403)
token = match.group(1) token = match.group(1)
db_token = self.db.query(db.APIToken).filter(db.APIToken.token == token).first() 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: if db_token is None:
raise web.HTTPError(403) raise web.HTTPError(403)
return method(self, *args, **kwargs) return method(self, *args, **kwargs)

View File

@@ -11,6 +11,7 @@ import time
from subprocess import Popen from subprocess import Popen
from tornado import gen from tornado import gen
from tornado.ioloop import IOLoop
from IPython.config import LoggingConfigurable from IPython.config import LoggingConfigurable
from IPython.utils.traitlets import ( 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): class Spawner(LoggingConfigurable):
@@ -103,15 +104,18 @@ class Spawner(LoggingConfigurable):
'--hub-prefix=%s' % self.hub.server.base_url, '--hub-prefix=%s' % self.hub.server.base_url,
'--hub-api-url=%s' % self.hub.api_url, '--hub-api-url=%s' % self.hub.api_url,
] ]
@gen.coroutine
def start(self): def start(self):
raise NotImplementedError("Override in subclass") raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
@gen.coroutine
def stop(self): def stop(self):
raise NotImplementedError("Override in subclass") raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
@gen.coroutine
def poll(self): def poll(self):
raise NotImplementedError("Override in subclass") raise NotImplementedError("Override in subclass. Must be a Tornado gen.coroutine.")
class LocalProcessSpawner(Spawner): class LocalProcessSpawner(Spawner):
@@ -125,6 +129,7 @@ class LocalProcessSpawner(Spawner):
def get_state(self): def get_state(self):
return dict(pid=self.pid) return dict(pid=self.pid)
@gen.coroutine
def start(self): def start(self):
self.user.server.port = random_port() self.user.server.port = random_port()
cmd = self.cmd + self.get_args() cmd = self.cmd + self.get_args()
@@ -136,10 +141,11 @@ class LocalProcessSpawner(Spawner):
) )
self.pid = self.proc.pid self.pid = self.proc.pid
@gen.coroutine
def poll(self): def poll(self):
# if we started the process, poll with Popen # if we started the process, poll with Popen
if self.proc is not None: if self.proc is not None:
return self.proc.poll() raise gen.Return(self.proc.poll())
# if we resumed from stored state, # if we resumed from stored state,
# we don't have the Popen handle anymore # we don't have the Popen handle anymore
@@ -150,38 +156,62 @@ class LocalProcessSpawner(Spawner):
except OSError as e: except OSError as e:
if e.errno == errno.ESRCH: if e.errno == errno.ESRCH:
# no such process, return exitcode == 0, since we don't know the exit status # no such process, return exitcode == 0, since we don't know the exit status
return 0 raise gen.Return(0)
else: else:
# None indicates the process is running # None indicates the process is running
return None raise gen.Return(None)
@gen.coroutine
def _wait_for_death(self, timeout=10): def _wait_for_death(self, timeout=10):
"""wait for the process to die, up to timeout seconds""" """wait for the process to die, up to timeout seconds"""
for i in range(int(timeout * 10)): for i in range(int(timeout * 10)):
if self.poll() is not None: status = yield self.poll()
if status is not None:
break break
else: else:
time.sleep(0.1) yield gen.Task(IOLoop.instance().add_timeout, time.time() + 0.1)
@gen.coroutine
def stop(self, now=False): def stop(self, now=False):
"""stop the subprocess""" """stop the subprocess
if `now`, skip waiting for clean shutdown
"""
if not now: if not now:
# double-sigint to request clean shutdown # SIGINT to request clean shutdown
os.kill(self.pid, signal.SIGINT) self.log.debug("Interrupting %i", self.pid)
os.kill(self.pid, signal.SIGINT) try:
self._wait_for_death(10) 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 # clean shutdown failed, use TERM
if self.poll() is None: status = yield self.poll()
os.kill(self.pid, signal.SIGTERM) if status is None:
self._wait_for_death(5) 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 # TERM failed, use KILL
if self.poll() is None: status = yield self.poll()
os.kill(self.pid, signal.SIGKILL) if status is None:
self._wait_for_death(5) 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 # it all failed, zombie process
self.log.warn("Process %i never died", self.pid) self.log.warn("Process %i never died", self.pid)

View File

@@ -7,7 +7,7 @@
<div id="message">{{message}}</div> <div id="message">{{message}}</div>
{% end if %} {% end if %}
<form action="?next={{next}}" method="post"> <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"> <input type="password" name="password" id="password_input">
<button type="submit" id="login_submit">Log in</button> <button type="submit" id="login_submit">Log in</button>
</form> </form>

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

View File

@@ -1,3 +1,4 @@
ipython[notebook]
simplepam
sqlalchemy sqlalchemy
requests requests
ipython[notebook]