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

View File

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

View File

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

View File

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

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
requests
ipython[notebook]