Files
jupyterhub/jupyterhub/handlers/base.py
Min RK a451f11cd3 give Spawners a handle on the Authenticator
band-aid for spawner-authenticator pairs
2015-09-11 11:57:41 +02:00

474 lines
16 KiB
Python

"""HTTP Handlers for the hub server"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import re
from datetime import datetime, timedelta
from http.client import responses
from jinja2 import TemplateNotFound
from tornado.log import app_log
from tornado.httputil import url_concat
from tornado.ioloop import IOLoop
from tornado.web import RequestHandler
from tornado import gen, web
from .. import orm
from ..spawner import LocalProcessSpawner
from ..utils import url_path_join
# pattern for the authentication token header
auth_header_pat = re.compile(r'^token\s+([^\s]+)$')
# mapping of reason: reason_message
reasons = {
'timeout': "Failed to reach your server."
" Please try again later."
" Contact admin if the issue persists.",
'error': "Failed to start your server. Please contact admin.",
}
class BaseHandler(RequestHandler):
"""Base Handler class with access to common methods and properties."""
@property
def log(self):
"""I can't seem to avoid typing self.log"""
return self.settings.get('log', app_log)
@property
def config(self):
return self.settings.get('config', None)
@property
def base_url(self):
return self.settings.get('base_url', '/')
@property
def version_hash(self):
return self.settings.get('version_hash', '')
@property
def db(self):
return self.settings['db']
@property
def hub(self):
return self.settings['hub']
@property
def proxy(self):
return self.settings['proxy']
@property
def authenticator(self):
return self.settings.get('authenticator', None)
def finish(self, *args, **kwargs):
"""Roll back any uncommitted transactions from the handler."""
self.db.rollback()
super().finish(*args, **kwargs)
#---------------------------------------------------------------
# Security policies
#---------------------------------------------------------------
@property
def csp_report_uri(self):
return self.settings.get('csp_report_uri',
url_path_join(self.hub.server.base_url, 'security/csp-report')
)
@property
def content_security_policy(self):
"""The default Content-Security-Policy header
Can be overridden by defining Content-Security-Policy in settings['headers']
"""
return '; '.join([
"frame-ancestors 'self'",
"report-uri " + self.csp_report_uri,
])
def set_default_headers(self):
"""
Set any headers passed as tornado_settings['headers'].
By default sets Content-Security-Policy of frame-ancestors 'self'.
"""
headers = self.settings.get('headers', {})
headers.setdefault("Content-Security-Policy", self.content_security_policy)
for header_name, header_content in headers.items():
self.set_header(header_name, header_content)
#---------------------------------------------------------------
# Login and cookie-related
#---------------------------------------------------------------
@property
def admin_users(self):
return self.settings.setdefault('admin_users', set())
@property
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"""
auth_header = self.request.headers.get('Authorization', '')
match = auth_header_pat.match(auth_header)
if not match:
return None
token = match.group(1)
orm_token = orm.APIToken.find(self.db, token)
if orm_token is None:
return None
else:
return orm_token.user
def _user_for_cookie(self, cookie_name, cookie_value=None):
"""Get the User for a given cookie, if there is one"""
cookie_id = self.get_secure_cookie(
cookie_name,
cookie_value,
max_age_days=self.cookie_max_age_days,
)
def clear():
self.clear_cookie(cookie_name, path=self.hub.server.base_url)
if cookie_id is None:
if self.get_cookie(cookie_name):
self.log.warn("Invalid or expired cookie token")
clear()
return
cookie_id = cookie_id.decode('utf8', 'replace')
user = self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
if user is None:
self.log.warn("Invalid cookie token")
# have cookie, but it's not valid. Clear it and start over.
clear()
return user
def get_current_user_cookie(self):
"""get_current_user from a cookie token"""
return self._user_for_cookie(self.hub.server.cookie_name)
def get_current_user(self):
"""get current username"""
user = self.get_current_user_token()
if user is not None:
return user
return self.get_current_user_cookie()
def find_user(self, name):
"""Get a user by name
return None if no such user
"""
return orm.User.find(self.db, name)
def user_from_username(self, username):
"""Get ORM User for username"""
user = self.find_user(username)
if user is None:
user = orm.User(name=username)
self.db.add(user)
self.db.commit()
return user
def clear_login_cookie(self, name=None):
if name is None:
user = self.get_current_user()
else:
user = self.find_user(name)
if user and user.server:
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
def set_server_cookie(self, user):
"""set the login cookie for the single-user server"""
# tornado <4.2 have a bug that consider secure==True as soon as
# 'secure' kwarg is passed to set_secure_cookie
if self.request.protocol == 'https':
kwargs = {'secure':True}
else:
kwargs = {}
self.set_secure_cookie(
user.server.cookie_name,
user.cookie_id,
path=user.server.base_url,
**kwargs
)
def set_hub_cookie(self, user):
"""set the login cookie for the Hub"""
# tornado <4.2 have a bug that consider secure==True as soon as
# 'secure' kwarg is passed to set_secure_cookie
if self.request.protocol == 'https':
kwargs = {'secure':True}
else:
kwargs = {}
self.set_secure_cookie(
self.hub.server.cookie_name,
user.cookie_id,
path=self.hub.server.base_url,
**kwargs
)
def set_login_cookie(self, user):
"""Set login cookies for the Hub and single-user server."""
# create and set a new cookie token for the single-user server
if user.server:
self.set_server_cookie(user)
# create and set a new cookie token for the hub
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.authenticate(self, data)
return result
else:
self.log.error("No authentication function, login is impossible!")
#---------------------------------------------------------------
# spawning-related
#---------------------------------------------------------------
@property
def slow_spawn_timeout(self):
return self.settings.get('slow_spawn_timeout', 10)
@property
def slow_stop_timeout(self):
return self.settings.get('slow_stop_timeout', 10)
@property
def spawner_class(self):
return self.settings.get('spawner_class', LocalProcessSpawner)
@gen.coroutine
def spawn_single_user(self, user):
if user.spawn_pending:
raise RuntimeError("Spawn already pending for: %s" % user.name)
tic = IOLoop.current().time()
f = user.spawn(
spawner_class=self.spawner_class,
base_url=self.base_url,
hub=self.hub,
config=self.config,
authenticator=self.authenticator,
)
@gen.coroutine
def finish_user_spawn(f=None):
"""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
toc = IOLoop.current().time()
self.log.info("User %s server took %.3f seconds to start", user.name, toc-tic)
yield self.proxy.add_user(user)
user.spawner.add_poll_callback(self.user_stopped, user)
try:
yield gen.with_timeout(timedelta(seconds=self.slow_spawn_timeout), f)
except gen.TimeoutError:
if user.spawn_pending:
# hit timeout, but spawn is still pending
self.log.warn("User %s server is slow to start", user.name)
# schedule finish for when the user finishes spawning
IOLoop.current().add_future(f, finish_user_spawn)
else:
raise
else:
yield finish_user_spawn()
@gen.coroutine
def user_stopped(self, user):
"""Callback that fires when the spawner has stopped"""
status = yield user.spawner.poll()
if status is None:
status = 'unknown'
self.log.warn("User %s server stopped, with exit code: %s",
user.name, status,
)
yield self.proxy.delete_user(user)
yield user.stop()
@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.
If the spawner is slow to stop, this is passed as an async callback,
otherwise it is called immediately.
"""
if f and f.exception() is not None:
# failed, don't do anything
return
toc = IOLoop.current().time()
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)
except gen.TimeoutError:
if user.stop_pending:
# hit timeout, but stop is still pending
self.log.warn("User %s server is slow to stop", user.name)
# schedule finish for when the server finishes stopping
IOLoop.current().add_future(f, finish_stop)
else:
raise
else:
yield finish_stop()
#---------------------------------------------------------------
# template rendering
#---------------------------------------------------------------
def get_template(self, name):
"""Return the jinja template object for a given name"""
return self.settings['jinja2_env'].get_template(name)
def render_template(self, name, **ns):
ns.update(self.template_namespace)
template = self.get_template(name)
return template.render(**ns)
@property
def template_namespace(self):
user = self.get_current_user()
return dict(
base_url=self.hub.server.base_url,
prefix=self.base_url,
user=user,
login_url=self.settings['login_url'],
login_service=self.authenticator.login_service,
logout_url=self.settings['logout_url'],
static_url=self.static_url,
version_hash=self.version_hash,
)
def write_error(self, status_code, **kwargs):
"""render custom error pages"""
exc_info = kwargs.get('exc_info')
message = ''
status_message = responses.get(status_code, 'Unknown HTTP Error')
if exc_info:
exception = exc_info[1]
# get the custom message, if defined
try:
message = exception.log_message % exception.args
except Exception:
pass
# construct the custom reason, if defined
reason = getattr(exception, 'reason', '')
if reason:
message = reasons.get(reason, reason)
# build template namespace
ns = dict(
status_code=status_code,
status_message=status_message,
message=message,
exception=exception,
)
self.set_header('Content-Type', 'text/html')
# render the template
try:
html = self.render_template('%s.html' % status_code, **ns)
except TemplateNotFound:
self.log.debug("No template for %d", status_code)
html = self.render_template('error.html', **ns)
self.write(html)
class Template404(BaseHandler):
"""Render our 404 template"""
def prepare(self):
raise web.HTTPError(404)
class PrefixRedirectHandler(BaseHandler):
"""Redirect anything outside a prefix inside.
Redirects /foo to /prefix/foo, etc.
"""
def get(self):
path = self.request.uri[len(self.base_url):]
self.redirect(url_path_join(
self.hub.server.base_url, path,
), permanent=False)
class UserSpawnHandler(BaseHandler):
"""Requests to /user/name handled by the Hub
should result in spawning the single-user server and
being redirected to the original.
"""
@gen.coroutine
def get(self, name):
current_user = self.get_current_user()
if current_user and current_user.name == name:
# logged in, spawn the server
if current_user.spawner:
if current_user.spawn_pending:
# spawn has started, but not finished
html = self.render_template("spawn_pending.html", user=current_user)
self.finish(html)
return
# spawn has supposedly finished, check on the status
status = yield current_user.spawner.poll()
if status is not None:
yield self.spawn_single_user(current_user)
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):]
target = url_path_join(self.base_url, without_prefix)
self.redirect(target)
else:
# not logged in to the right user,
# clear any cookies and reload (will redirect to login)
self.clear_login_cookie()
self.redirect(url_concat(
self.settings['login_url'],
{'next': self.request.uri,
}))
class CSPReportHandler(BaseHandler):
'''Accepts a content security policy violation report'''
@web.authenticated
def post(self):
'''Log a content security policy violation report'''
self.log.warn("Content security violation: %s",
self.request.body.decode('utf8', 'replace'))
default_handlers = [
(r'/user/([^/]+)/?.*', UserSpawnHandler),
(r'/security/csp-report', CSPReportHandler),
]