Merge pull request #30 from minrk/restapi

getting started with REST API
This commit is contained in:
Min RK
2014-09-14 16:53:24 -07:00
23 changed files with 927 additions and 134 deletions

View File

@@ -1 +1,9 @@
from .base import *
from .auth import * from .auth import *
from .users import *
from . import auth, users
default_handlers = []
for mod in (auth, users):
default_handlers.extend(mod.default_handlers)

View File

@@ -6,12 +6,13 @@
import json import json
from tornado import web from tornado import web
from ..handlers import BaseHandler
from .. import orm from .. import orm
from ..utils import token_authenticated from ..utils import token_authenticated
from .base import APIHandler
class AuthorizationsAPIHandler(BaseHandler):
class AuthorizationsAPIHandler(APIHandler):
@token_authenticated @token_authenticated
def get(self, token): def get(self, token):
orm_token = self.db.query(orm.CookieToken).filter(orm.CookieToken.token == token).first() orm_token = self.db.query(orm.CookieToken).filter(orm.CookieToken.token == token).first()
@@ -20,3 +21,7 @@ class AuthorizationsAPIHandler(BaseHandler):
self.write(json.dumps({ self.write(json.dumps({
'user' : orm_token.user.name, 'user' : orm_token.user.name,
})) }))
default_handlers = [
(r"/api/authorizations/([^/]+)", AuthorizationsAPIHandler),
]

View File

@@ -0,0 +1,53 @@
"""Base API handlers"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
try:
# py3
from http.client import responses
except ImportError:
from httplib import responses
from tornado import web
from ..handlers import BaseHandler
class APIHandler(BaseHandler):
def get_json_body(self):
"""Return the body of the request as JSON data."""
if not self.request.body:
return None
body = self.request.body.strip().decode(u'utf-8')
try:
model = json.loads(body)
except Exception:
self.log.debug("Bad JSON: %r", body)
self.log.error("Couldn't parse JSON", exc_info=True)
raise web.HTTPError(400, 'Invalid JSON in body of request')
return model
def write_error(self, status_code, **kwargs):
"""Write JSON errors instead of HTML"""
exc_info = kwargs.get('exc_info')
message = ''
status_message = responses.get(status_code, 'Unknown 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:
status_message = reason
self.write(json.dumps({
'status': status_code,
'message': message or status_message,
}))

View File

@@ -0,0 +1,136 @@
"""Authorization handlers"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
from tornado import gen, web
from .. import orm
from ..utils import admin_only, authenticated_403
from .base import APIHandler
try:
basestring
except NameError:
basestring = str # py3
class BaseUserHandler(APIHandler):
def user_model(self, user):
return {
'name': user.name,
'admin': user.admin,
'server': user.server.base_url if user.server else None,
}
_model_types = {
'name': basestring,
'admin': bool,
}
def _check_user_model(self, model):
if not isinstance(model, dict):
raise web.HTTPError(400, "Invalid JSON data: %r" % model)
if not set(model).issubset(set(self._model_types)):
raise web.HTTPError(400, "Invalid JSON keys: %r" % model)
for key, value in model.items():
if not isinstance(value, self._model_types[key]):
raise web.HTTPError(400, "user.%s must be %s, not: %r" % (
key, self._model_types[key], type(value)
))
class UserListAPIHandler(BaseUserHandler):
@admin_only
def get(self):
users = self.db.query(orm.User)
data = [ self.user_model(u) for u in users ]
self.write(json.dumps(data))
def admin_or_self(method):
"""Decorator for restricting access to either the target user or admin"""
def m(self, name):
current = self.get_current_user()
if current is None:
raise web.HTTPError(403)
if not (current.name == name or current.admin):
raise web.HTTPError(403)
# raise 404 if not found
if not self.find_user(name):
raise web.HTTPError(404)
return method(self, name)
return m
class UserAPIHandler(BaseUserHandler):
@admin_or_self
def get(self, name):
user = self.find_user(name)
self.write(json.dumps(self.user_model(user)))
@admin_only
def post(self, name):
data = self.get_json_body()
user = self.find_user(name)
if user is not None:
raise web.HTTPError(400, "User %s already exists" % name)
user = self.user_from_username(name)
if data:
self._check_user_model(data)
if 'admin' in data:
user.admin = data['admin']
self.db.commit()
self.write(json.dumps(self.user_model(user)))
self.set_status(201)
@admin_only
def delete(self, name):
user = self.find_user(name)
if user is None:
raise web.HTTPError(404)
if user.name == self.get_current_user().name:
raise web.HTTPError(400, "Cannot delete yourself!")
self.set_status(204)
@admin_only
def patch(self, name):
user = self.find_user(name)
if user is None:
raise web.HTTPError(404)
data = self.get_json_body()
self._check_user_model(data)
for key, value in data.items():
setattr(user, key, value)
self.db.commit()
self.write(json.dumps(self.user_model(user)))
class UserServerAPIHandler(BaseUserHandler):
@gen.coroutine
@admin_or_self
def post(self, name):
user = self.find_user(name)
if user.spawner:
raise web.HTTPError(400, "%s's server is already running" % name)
else:
yield self.spawn_single_user(user)
self.set_status(201)
@gen.coroutine
@admin_or_self
def delete(self, name):
user = self.find_user(name)
if user.spawner is None:
raise web.HTTPError(400, "%s's server is not running" % name)
yield self.stop_single_user(user)
self.set_status(204)
default_handlers = [
(r"/api/users", UserListAPIHandler),
(r"/api/users/([^/]+)", UserAPIHandler),
(r"/api/users/([^/]+)/server", UserServerAPIHandler),
]

View File

@@ -14,29 +14,19 @@ from jinja2 import Environment, FileSystemLoader
import tornado.httpserver import tornado.httpserver
import tornado.options import tornado.options
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
from tornado.log import LogFormatter, app_log from tornado.log import LogFormatter
from tornado import gen, 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, Bool, Bytes, Any,
DottedObjectName, DottedObjectName, Set,
) )
from IPython.config import Application, catch_config_error from IPython.config import Application, catch_config_error
from IPython.utils.importstring import import_item from IPython.utils.importstring import import_item
here = os.path.dirname(__file__) here = os.path.dirname(__file__)
from .handlers import ( from . import handlers, apihandlers
Template404,
RootHandler,
LoginHandler,
LogoutHandler,
UserHandler,
)
from .apihandlers import (
AuthorizationsAPIHandler,
)
from . import orm from . import orm
from ._data import DATA_FILES_PATH from ._data import DATA_FILES_PATH
@@ -158,6 +148,12 @@ class JupyterHubApp(Application):
debug_db = Bool(False) debug_db = Bool(False)
db = Any() db = Any()
admin_users = Set(config=True,
help="""list of usernames of admin users
If unspecified, all users are admin.
"""
)
tornado_settings = Dict(config=True) tornado_settings = Dict(config=True)
handlers = List() handlers = List()
@@ -214,25 +210,26 @@ class JupyterHubApp(Application):
return handlers return handlers
def init_handlers(self): def init_handlers(self):
handlers = [ h = []
(r"/", RootHandler), h.extend(handlers.default_handlers)
(r"/login", LoginHandler), h.extend(apihandlers.default_handlers)
(r"/logout", LogoutHandler),
(r"/api/authorizations/([^/]+)", AuthorizationsAPIHandler), self.handlers = self.add_url_prefix(self.hub_prefix, h)
]
self.handlers = self.add_url_prefix(self.hub_prefix, handlers) # some extra handlers, outside hub_prefix
self.handlers.extend([ self.handlers.extend([
(r"/user/([^/]+)/?.*", UserHandler), (r"(?!%s).*" % self.hub_prefix, handlers.PrefixRedirectHandler),
(r"/?", web.RedirectHandler, {"url" : self.hub_prefix, "permanent": False}), (r'(.*)', handlers.Template404),
]) ])
self.handlers.append(
(r'(.*)', Template404)
)
def init_db(self): def init_db(self):
# TODO: load state from db for resume # TODO: load state from db for resume
# TODO: if not resuming, clear existing db contents # TODO: if not resuming, clear existing db contents
self.db = orm.new_session(self.db_url, echo=self.debug_db) self.db = orm.new_session(self.db_url, echo=self.debug_db)
for name in self.admin_users:
user = orm.User(name=name, admin=True)
self.db.add(user)
self.db.commit()
def init_hub(self): def init_hub(self):
"""Load the Hub config into the database""" """Load the Hub config into the database"""
@@ -283,8 +280,7 @@ class JupyterHubApp(Application):
if self.ssl_cert: if self.ssl_cert:
cmd.extend(['--ssl-cert', self.ssl_cert]) cmd.extend(['--ssl-cert', self.ssl_cert])
self.proxy_process = Popen(cmd, env=env)
self.proxy = Popen(cmd, env=env)
def init_tornado_settings(self): def init_tornado_settings(self):
"""Set up the tornado settings dict.""" """Set up the tornado settings dict."""
@@ -300,6 +296,7 @@ class JupyterHubApp(Application):
log=self.log, log=self.log,
db=self.db, db=self.db,
hub=self.hub, hub=self.hub,
admin_users=self.admin_users,
authenticator=import_item(self.authenticator)(config=self.config), 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,
@@ -333,7 +330,7 @@ class JupyterHubApp(Application):
@gen.coroutine @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_process.terminate()
self.log.info("Cleaning up single-user servers...") self.log.info("Cleaning up single-user servers...")
# request (async) process termination # request (async) process termination

View File

@@ -1,2 +1,8 @@
from .base import * from .base import *
from .login import * from .login import *
from . import base, pages, login
default_handlers = []
for mod in (base, pages, login):
default_handlers.extend(mod.default_handlers)

View File

@@ -56,6 +56,10 @@ class BaseHandler(RequestHandler):
# Login and cookie-related # Login and cookie-related
#--------------------------------------------------------------- #---------------------------------------------------------------
@property
def admin_users(self):
return self.settings.setdefault('admin_users', set())
def get_current_user_token(self): def get_current_user_token(self):
"""get_current_user from Authorization header token""" """get_current_user from Authorization header token"""
auth_header = self.request.headers.get('Authorization', '') auth_header = self.request.headers.get('Authorization', '')
@@ -88,10 +92,16 @@ class BaseHandler(RequestHandler):
return user return user
return self.get_current_user_cookie() return self.get_current_user_cookie()
def find_user(self, name):
"""Get a user by name
return None if no such user
"""
return self.db.query(orm.User).filter(orm.User.name==name).first()
def user_from_username(self, username): def user_from_username(self, username):
"""Get ORM User for username""" """Get ORM User for username"""
user = self.find_user(username)
user = self.db.query(orm.User).filter(orm.User.name==username).first()
if user is None: if user is None:
user = orm.User(name=username) user = orm.User(name=username)
self.db.add(user) self.db.add(user)
@@ -104,13 +114,13 @@ class BaseHandler(RequestHandler):
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.hub.server.cookie_name, path=self.hub.server.base_url) self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
def set_login_cookies(self, user): def set_login_cookie(self, user):
"""Set login cookies for the Hub and single-user server.""" """Set login cookies for the Hub and single-user server."""
# create and set a new cookie token for the single-user server # create and set a new cookie token for the single-user server
if user.server:
cookie_token = user.new_cookie_token() cookie_token = user.new_cookie_token()
self.db.add(cookie_token) self.db.add(cookie_token)
self.db.commit() self.db.commit()
self.set_cookie( self.set_cookie(
user.server.cookie_name, user.server.cookie_name,
cookie_token.token, cookie_token.token,
@@ -118,6 +128,7 @@ class BaseHandler(RequestHandler):
) )
# create and set a new cookie token for the hub # create and set a new cookie token for the hub
if not self.get_current_user_cookie():
cookie_token = user.new_cookie_token() cookie_token = user.new_cookie_token()
self.db.add(cookie_token) self.db.add(cookie_token)
self.db.commit() self.db.commit()
@@ -161,6 +172,18 @@ class BaseHandler(RequestHandler):
yield 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 notify_proxy_delete(self, user):
proxy = self.db.query(orm.Proxy).first()
r = requests.delete(
url_path_join(
proxy.api_server.url,
user.server.base_url,
),
headers={'Authorization': "token %s" % proxy.auth_token},
)
r.raise_for_status()
@gen.coroutine @gen.coroutine
def spawn_single_user(self, user): def spawn_single_user(self, user):
user.server = orm.Server( user.server = orm.Server(
@@ -190,6 +213,21 @@ class BaseHandler(RequestHandler):
self.notify_proxy(user) self.notify_proxy(user)
raise gen.Return(user) raise gen.Return(user)
@gen.coroutine
def stop_single_user(self, user):
if user.spawner is None:
return
status = yield user.spawner.poll()
if status is None:
yield user.spawner.stop()
self.notify_proxy_delete(user)
user.state = {}
user.spawner = None
user.server = None
self.db.commit()
raise gen.Return(user)
#--------------------------------------------------------------- #---------------------------------------------------------------
# template rendering # template rendering
#--------------------------------------------------------------- #---------------------------------------------------------------
@@ -203,16 +241,12 @@ class BaseHandler(RequestHandler):
template = self.get_template(name) template = self.get_template(name)
return template.render(**ns) return template.render(**ns)
@property
def logged_in(self):
"""Is a user currently logged in?"""
return self.get_current_user() is not None
@property @property
def template_namespace(self): def template_namespace(self):
user = self.get_current_user()
return dict( return dict(
base_url=self.hub.server.base_url, base_url=self.hub.server.base_url,
logged_in=self.logged_in, user=user,
login_url=self.settings['login_url'], login_url=self.settings['login_url'],
static_url=self.static_url, static_url=self.static_url,
) )
@@ -260,33 +294,46 @@ class Template404(BaseHandler):
raise web.HTTPError(404) raise web.HTTPError(404)
class RootHandler(BaseHandler): class PrefixRedirectHandler(BaseHandler):
"""Render the Hub root page.""" """Redirect anything outside a prefix inside.
@web.authenticated
def get(self):
user = self.get_current_user()
html = self.render_template('index.html',
server_running = user.server is not None,
server_url = '/user/%s' % user.name,
)
self.finish(html)
Redirects /foo to /prefix/foo, etc.
class UserHandler(BaseHandler):
"""Respawn single-user server after logging in.
This handler shouldn't be called if the proxy is set up correctly.
""" """
@web.authenticated def get(self):
self.redirect(url_path_join(
self.hub.server.base_url, self.request.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): def get(self, name):
self.log.warn("Hub serving single-user url: %s", self.request.path)
current_user = self.get_current_user() current_user = self.get_current_user()
if current_user and current_user.name == name: if current_user and current_user.name == name:
self.spawn_single_user(current_user) # logged in, spawn the server
self.redirect('') if current_user.spawner:
status = yield current_user.spawner.poll()
if status is not None:
yield self.spawn_single_user(current_user)
else: else:
self.log.warn("Hub serving single-user url: %s", self.request.path) yield self.spawn_single_user(current_user)
# set login cookie anew
self.set_login_cookie(current_user)
self.redirect(url_path_join(
self.base_url, 'user', name,
))
else:
# not logged in to the right user,
# clear any cookies and reload (will redirect to login)
self.clear_login_cookie() self.clear_login_cookie()
self.redirect(url_concat(self.settings['login_url'], { self.redirect(url_concat(
'next' : self.request.path, self.settings['login_url'],
})) {'next': self.request.path,
}), permanent=False)
default_handlers = [
(r'/user/([^/]+)/?.*', UserSpawnHandler),
]

View File

@@ -28,8 +28,12 @@ class LoginHandler(BaseHandler):
) )
def get(self): def get(self):
if self.get_argument('next', False) and self.get_current_user(): next_url = self.get_argument('next', False)
self.redirect(self.get_argument('next'), permanent=False) if next_url and self.get_current_user():
# set new login cookie
# because single-user cookie may have been cleared or incorrect
self.set_login_cookie(self.get_current_user())
self.redirect(next_url, permanent=False)
else: else:
username = self.get_argument('username', default='') username = self.get_argument('username', default='')
self.finish(self._render(username=username)) self.finish(self._render(username=username))
@@ -46,7 +50,7 @@ class LoginHandler(BaseHandler):
if authorized: if authorized:
user = self.user_from_username(username) user = self.user_from_username(username)
yield self.spawn_single_user(user) yield self.spawn_single_user(user)
self.set_login_cookies(user) self.set_login_cookie(user)
next_url = self.get_argument('next', default='') or self.hub.server.base_url next_url = self.get_argument('next', default='') or self.hub.server.base_url
self.redirect(next_url) self.redirect(next_url)
else: else:
@@ -56,3 +60,8 @@ class LoginHandler(BaseHandler):
username=username, username=username,
) )
self.finish(html) self.finish(html)
default_handlers = [
(r"/login", LoginHandler),
(r"/logout", LogoutHandler),
]

View File

@@ -0,0 +1,55 @@
"""Basic html-rendering handlers."""
# Copyright (c) IPython Development Team.
# Distributed under the terms of the Modified BSD License.
from tornado import web
from .. import orm
from ..utils import admin_only, url_path_join
from .base import BaseHandler
class RootHandler(BaseHandler):
"""Render the Hub root page.
Currently redirects to home if logged in,
shows big fat login button otherwise.
"""
def get(self):
if self.get_current_user():
self.redirect(
url_path_join(self.hub.server.base_url, 'home'),
permanent=False,
)
return
html = self.render_template('index.html',
login_url=self.settings['login_url'],
)
self.finish(html)
class HomeHandler(BaseHandler):
"""Render the user's home page."""
@web.authenticated
def get(self):
html = self.render_template('home.html',
user=self.get_current_user(),
)
self.finish(html)
class AdminHandler(BaseHandler):
"""Render the admin page."""
@admin_only
def get(self):
html = self.render_template('admin.html',
user=self.get_current_user(),
users=self.db.query(orm.User),
)
self.finish(html)
default_handlers = [
(r'/', RootHandler),
(r'/home', HomeHandler),
(r'/admin', AdminHandler),
]

View File

@@ -8,7 +8,7 @@ import uuid
from sqlalchemy.types import TypeDecorator, VARCHAR from sqlalchemy.types import TypeDecorator, VARCHAR
from sqlalchemy import ( from sqlalchemy import (
Column, Integer, String, ForeignKey, Unicode, Binary, Column, Integer, String, ForeignKey, Unicode, Binary, Boolean,
) )
from sqlalchemy.ext.declarative import declarative_base, declared_attr from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import sessionmaker, relationship, backref from sqlalchemy.orm import sessionmaker, relationship, backref
@@ -75,7 +75,7 @@ class Server(Base):
def host(self): def host(self):
return "{proto}://{ip}:{port}".format( return "{proto}://{ip}:{port}".format(
proto=self.proto, proto=self.proto,
ip=self.ip, ip=self.ip or '*',
port=self.port, port=self.port,
) )
@@ -157,6 +157,7 @@ class User(Base):
# should we allow multiple servers per user? # should we allow multiple servers per user?
_server_id = Column(Integer, ForeignKey('servers.id')) _server_id = Column(Integer, ForeignKey('servers.id'))
server = relationship(Server, primaryjoin=_server_id == Server.id) server = relationship(Server, primaryjoin=_server_id == Server.id)
admin = Column(Boolean, default=False)
api_tokens = relationship("APIToken", backref="user") api_tokens = relationship("APIToken", backref="user")
cookie_tokens = relationship("CookieToken", backref="user") cookie_tokens = relationship("CookieToken", backref="user")

View File

@@ -38,12 +38,12 @@ def db():
@fixture @fixture
def io_loop(): def io_loop():
"""Get the current IOLoop""" """Get the current IOLoop"""
ioloop.IOLoop.clear_current() loop = ioloop.IOLoop()
return ioloop.IOLoop.current() loop.make_current()
return loop
@fixture(scope='module')
@fixture
def app(request): def app(request):
app = MockHubApp() app = MockHubApp()
app.start([]) app.start([])

View File

@@ -1,12 +1,13 @@
"""mock utilities for testing""" """mock utilities for testing"""
import sys
import threading
try: try:
from unittest import mock from unittest import mock
except ImportError: except ImportError:
import mock import mock
import getpass
import threading
from tornado.ioloop import IOLoop from tornado.ioloop import IOLoop
from IPython.utils.py3compat import unicode_type from IPython.utils.py3compat import unicode_type
@@ -30,13 +31,17 @@ def mock_authenticate(username, password, service='login'):
class MockSpawner(LocalProcessSpawner): class MockSpawner(LocalProcessSpawner):
def make_preexec_fn(self): def make_preexec_fn(self, *a, **kw):
# skip the setuid stuff # skip the setuid stuff
return return
def _set_user_changed(self, name, old, new): def _set_user_changed(self, name, old, new):
pass pass
def _cmd_default(self):
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
class MockPAMAuthenticator(PAMAuthenticator): class MockPAMAuthenticator(PAMAuthenticator):
def authenticate(self, *args, **kwargs): def authenticate(self, *args, **kwargs):
with mock.patch('simplepam.authenticate', mock_authenticate): with mock.patch('simplepam.authenticate', mock_authenticate):
@@ -44,26 +49,30 @@ class MockPAMAuthenticator(PAMAuthenticator):
class MockHubApp(JupyterHubApp): class MockHubApp(JupyterHubApp):
"""HubApp with various mock bits""" """HubApp with various mock bits"""
# def start_proxy(self):
# pass def _ip_default(self):
return 'localhost'
def _authenticator_default(self): def _authenticator_default(self):
return '%s.%s' % (__name__, 'MockPAMAuthenticator') return '%s.%s' % (__name__, 'MockPAMAuthenticator')
def _spawner_class_default(self): def _spawner_class_default(self):
return '%s.%s' % (__name__, 'MockSpawner') return '%s.%s' % (__name__, 'MockSpawner')
def _admin_users_default(self):
return {'admin'}
def start(self, argv=None): def start(self, argv=None):
evt = threading.Event() evt = threading.Event()
def _start(): def _start():
self.io_loop = IOLoop.current() self.io_loop = IOLoop.current()
# put initialize in start for SQLAlchemy threading reasons # put initialize in start for SQLAlchemy threading reasons
super(MockHubApp, self).initialize(argv=argv) super(MockHubApp, self).initialize(argv=argv)
user = orm.User(name=getpass.getuser())
# add an initial user
user = orm.User(name='user')
self.db.add(user) self.db.add(user)
self.db.commit() self.db.commit()
token = user.new_api_token()
self.db.add(token)
self.db.commit()
self.io_loop.add_callback(evt.set) self.io_loop.add_callback(evt.set)
super(MockHubApp, self).start() super(MockHubApp, self).start()
@@ -72,6 +81,6 @@ class MockHubApp(JupyterHubApp):
evt.wait(timeout=5) evt.wait(timeout=5)
def stop(self): def stop(self):
self.io_loop.stop() self.io_loop.add_callback(self.io_loop.stop)
self._thread.join() self._thread.join()

View File

@@ -0,0 +1,40 @@
"""Mock single-user server for testing
basic HTTP Server that echos URLs back,
and allow retrieval of sys.argv.
"""
import argparse
import json
import sys
from tornado import web, httpserver, ioloop
class EchoHandler(web.RequestHandler):
def get(self):
self.write(self.request.path)
class ArgsHandler(web.RequestHandler):
def get(self):
self.write(json.dumps(sys.argv))
def main(args):
app = web.Application([
(r'.*/args', ArgsHandler),
(r'.*', EchoHandler),
])
server = httpserver.HTTPServer(app)
server.listen(args.port)
try:
ioloop.IOLoop.instance().start()
except KeyboardInterrupt:
print('\nInterrupted')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int)
args, extra = parser.parse_known_args()
main(args)

View File

@@ -1,17 +1,40 @@
"""Tests for the REST API""" """Tests for the REST API"""
import json
import requests import requests
from ..utils import url_path_join as ujoin from ..utils import url_path_join as ujoin
from .. import orm from .. import orm
def find_user(db, name):
return db.query(orm.User).filter(orm.User.name==name).first()
def add_user(db, **kwargs):
user = orm.User(**kwargs)
db.add(user)
db.commit()
return user
def auth_header(db, name):
user = find_user(db, name)
if user is None:
user = add_user(db, name=name)
if not user.api_tokens:
token = user.new_api_token()
db.add(token)
db.commit()
else:
token = user.api_tokens[0]
return {'Authorization': 'token %s' % token.token}
def api_request(app, *api_path, **kwargs): def api_request(app, *api_path, **kwargs):
"""Make an API request""" """Make an API request"""
base_url = app.hub.server.url base_url = app.hub.server.url
token = app.db.query(orm.APIToken).first() headers = kwargs.setdefault('headers', {})
kwargs.setdefault('headers', {})
kwargs['headers'].setdefault('Authorization', 'token %s' % token.token) if 'Authorization' not in headers:
headers.update(auth_header(app.db, 'admin'))
url = ujoin(base_url, 'api', *api_path) url = ujoin(base_url, 'api', *api_path)
method = kwargs.pop('method', 'get') method = kwargs.pop('method', 'get')
@@ -47,3 +70,100 @@ def test_auth_api(app):
assert r.status_code == 403 assert r.status_code == 403
def test_get_users(app):
db = app.db
r = api_request(app, 'users')
assert r.status_code == 200
assert sorted(r.json(), key=lambda d: d['name']) == [
{
'name': 'admin',
'admin': True,
'server': None,
},
{
'name': 'user',
'admin': False,
'server': None,
}
]
r = api_request(app, 'users',
headers=auth_header(db, 'user'),
)
assert r.status_code == 403
def test_add_user(app):
db = app.db
name = 'newuser'
r = api_request(app, 'users', name, method='post')
assert r.status_code == 201
user = find_user(db, name)
assert user is not None
assert user.name == name
assert not user.admin
def test_add_admin(app):
db = app.db
name = 'newadmin'
r = api_request(app, 'users', name, method='post',
data=json.dumps({'admin': True}),
)
assert r.status_code == 201
user = find_user(db, name)
assert user is not None
assert user.name == name
assert user.admin
def test_delete_user(app):
db = app.db
mal = add_user(db, name='mal')
r = api_request(app, 'users', 'mal', method='delete')
assert r.status_code == 204
def test_make_admin(app):
db = app.db
name = 'admin2'
r = api_request(app, 'users', name, method='post')
assert r.status_code == 201
user = find_user(db, name)
assert user is not None
assert user.name == name
assert not user.admin
r = api_request(app, 'users', name, method='patch',
data=json.dumps({'admin': True})
)
assert r.status_code == 200
user = find_user(db, name)
assert user is not None
assert user.name == name
assert user.admin
def test_spawn(app, io_loop):
db = app.db
name = 'wash'
user = add_user(db, name=name)
r = api_request(app, 'users', name, 'server', method='post')
assert r.status_code == 201
assert user.spawner is not None
status = io_loop.run_sync(user.spawner.poll)
assert status is None
assert user.server.base_url == '/user/%s' % name
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url))
assert r.status_code == 200
assert r.text == user.server.base_url
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url, 'args'))
assert r.status_code == 200
argv = r.json()
for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]:
assert expected in argv
r = api_request(app, 'users', name, 'server', method='delete')
assert r.status_code == 204
assert user.spawner is None

View File

@@ -29,24 +29,42 @@ def wait_for_server(ip, port, timeout=10):
else: else:
break break
def auth_decorator(check_auth):
"""Make an authentication decorator
def token_authenticated(method): I heard you like decorators, so I put a decorator
"""decorator for a method authenticated only by the Authorization token header""" in your decorator, so you can decorate while you decorate.
def check_token(self, *args, **kwargs): """
def decorator(method):
def decorated(self, *args, **kwargs):
check_auth(self)
return method(self, *args, **kwargs)
decorated.__name__ = method.__name__
decorated.__doc__ = method.__doc__
return decorated
decorator.__name__ = check_auth.__name__
decorator.__doc__ = check_auth.__doc__
return decorator
@auth_decorator
def token_authenticated(self):
"""decorator for a method authenticated only by the Authorization token header
(no cookies)
"""
if self.get_current_user_token() is None: if self.get_current_user_token() is None:
raise web.HTTPError(403) raise web.HTTPError(403)
return method(self, *args, **kwargs)
check_token.__name__ = method.__name__
check_token.__doc__ = method.__doc__
return check_token
@auth_decorator
def authenticated_403(method): def authenticated_403(self):
"""decorator like web.authenticated, but raise 403 instead of redirect to login""" """like web.authenticated, but raise 403 instead of redirect to login"""
def check_user(self, *args, **kwargs):
if self.get_current_user() is None: if self.get_current_user() is None:
raise web.HTTPError(403) raise web.HTTPError(403)
return method(self, *args, **kwargs)
check_user.__name__ = method.__name__ @auth_decorator
check_user.__doc__ = method.__doc__ def admin_only(self):
return check_user """decorator for restricting access to admin users"""
user = self.get_current_user()
if user is None or not user.admin:
raise web.HTTPError(403)

View File

@@ -0,0 +1,19 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
require(["jquery", "jhapi"], function ($, JHAPI) {
"use strict";
var base_url = window.jhdata.base_url;
var user = window.jhdata.user;
var api = new JHAPI(base_url);
$("#stop").click(function () {
api.stop_server(user, {
success: function () {
$("#stop").hide();
}
});
});
});

View File

@@ -0,0 +1,89 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
define(['jquery', 'utils'], function ($, utils) {
"use strict";
var JHAPI = function (base_url) {
this.base_url = base_url;
};
var default_options = {
type: 'GET',
headers: {'Content-Type': 'application/json'},
cache: false,
dataType : "json",
processData: false,
success: null,
error: utils.log_jax_error,
};
var update = function (d1, d2) {
$.map(d2, function (i, key) {
d1[key] = d2[key];
});
return d1;
};
var ajax_defaults = function (options) {
var d = {};
update(d, default_options);
update(d, options);
return d;
};
JHAPI.prototype.api_request = function (path, options) {
options = options || {};
options = ajax_defaults(options || {});
var url = utils.url_path_join(
this.base_url,
'api',
utils.encode_uri_components(path)
);
$.ajax(url, options);
};
JHAPI.prototype.start_server = function (user, options) {
options = options || {};
options.update({
type: 'POST',
});
this.api_request(
utils.url_path_join('users', user, 'server'),
options
);
};
JHAPI.prototype.stop_server = function (user, options) {
options = update(options || {}, {
type: 'DELETE',
});
this.api_request(
utils.url_path_join('users', user, 'server'),
options
);
};
JHAPI.prototype.list_users = function (options) {
this.api_request('users', options);
};
JHAPI.prototype.get_user = function (user, options) {
this.api_request(
utils.url_path_join('users', user),
options
);
};
JHAPI.prototype.delete_user = function (user, options) {
options = update(options || {}, {
type: 'DELETE',
});
this.api_request(
utils.url_path_join('users', user),
options
);
};
return JHAPI;
});

View File

@@ -0,0 +1,127 @@
// Based on IPython's base.js.utils
// Original Copyright (c) IPython Development Team.
// Distributed under the terms of the Modified BSD License.
// Modifications Copyright (c) Juptyer Development Team.
// Distributed under the terms of the Modified BSD License.
define(['jquery'], function($){
"use strict";
var url_path_join = function () {
// join a sequence of url components with '/'
var url = '';
for (var i = 0; i < arguments.length; i++) {
if (arguments[i] === '') {
continue;
}
if (url.length > 0 && url[url.length-1] != '/') {
url = url + '/' + arguments[i];
} else {
url = url + arguments[i];
}
}
url = url.replace(/\/\/+/, '/');
return url;
};
var parse_url = function (url) {
// an `a` element with an href allows attr-access to the parsed segments of a URL
// a = parse_url("http://localhost:8888/path/name#hash")
// a.protocol = "http:"
// a.host = "localhost:8888"
// a.hostname = "localhost"
// a.port = 8888
// a.pathname = "/path/name"
// a.hash = "#hash"
var a = document.createElement("a");
a.href = url;
return a;
};
var encode_uri_components = function (uri) {
// encode just the components of a multi-segment uri,
// leaving '/' separators
return uri.split('/').map(encodeURIComponent).join('/');
};
var url_join_encode = function () {
// join a sequence of url components with '/',
// encoding each component with encodeURIComponent
return encode_uri_components(url_path_join.apply(null, arguments));
};
var escape_html = function (text) {
// escape text to HTML
return $("<div/>").text(text).html();
};
var get_body_data = function(key) {
// get a url-encoded item from body.data and decode it
// we should never have any encoded URLs anywhere else in code
// until we are building an actual request
return decodeURIComponent($('body').data(key));
};
// http://stackoverflow.com/questions/2400935/browser-detection-in-javascript
var browser = (function() {
if (typeof navigator === 'undefined') {
// navigator undefined in node
return 'None';
}
var N= navigator.appName, ua= navigator.userAgent, tem;
var M= ua.match(/(opera|chrome|safari|firefox|msie)\/?\s*(\.?\d+(\.\d+)*)/i);
if (M && (tem= ua.match(/version\/([\.\d]+)/i)) !== null) M[2]= tem[1];
M= M? [M[1], M[2]]: [N, navigator.appVersion,'-?'];
return M;
})();
// http://stackoverflow.com/questions/11219582/how-to-detect-my-browser-version-and-operating-system-using-javascript
var platform = (function () {
if (typeof navigator === 'undefined') {
// navigator undefined in node
return 'None';
}
var OSName="None";
if (navigator.appVersion.indexOf("Win")!=-1) OSName="Windows";
if (navigator.appVersion.indexOf("Mac")!=-1) OSName="MacOS";
if (navigator.appVersion.indexOf("X11")!=-1) OSName="UNIX";
if (navigator.appVersion.indexOf("Linux")!=-1) OSName="Linux";
return OSName;
})();
var ajax_error_msg = function (jqXHR) {
// Return a JSON error message if there is one,
// otherwise the basic HTTP status text.
if (jqXHR.responseJSON && jqXHR.responseJSON.message) {
return jqXHR.responseJSON.message;
} else {
return jqXHR.statusText;
}
};
var log_ajax_error = function (jqXHR, status, error) {
// log ajax failures with informative messages
var msg = "API request failed (" + jqXHR.status + "): ";
console.log(jqXHR);
msg += ajax_error_msg(jqXHR);
console.log(msg);
};
var utils = {
url_path_join : url_path_join,
url_join_encode : url_join_encode,
encode_uri_components : encode_uri_components,
escape_html : escape_html,
get_body_data : get_body_data,
parse_url : parse_url,
browser : browser,
platform: platform,
ajax_error_msg : ajax_error_msg,
log_ajax_error : log_ajax_error,
};
return utils;
});

View File

@@ -0,0 +1,13 @@
{% extends "page.html" %}
{% block main %}
<div class="container">
<div class="row">
<div class="text-center">
<a class="btn btn-lg btn-primary">Administrate!</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends "page.html" %}
{% block main %}
<div class="container">
<div class="row">
<div class="text-center">
{% if user.server %}
<a id="stop" class="btn btn-lg btn-danger">Stop My Server</a>
{% endif %}
<a id="start" class="btn btn-lg btn-success"
href="{{base_url}}user/{{user.name}}/"
>
My Server
</a>
{% if user.admin %}
<a id="admin" class="btn btn-lg btn-primary" href="{{base_url}}admin">Admin</a>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script type="text/javascript">
require(["home"]);
</script>
{% endblock %}

View File

@@ -1,11 +1,14 @@
{% extends "page.html" %} {% extends "page.html" %}
{% block login_widget %}
{% endblock %}
{% block main %} {% block main %}
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="text-center"> <div class="text-center">
<a id="start" class="btn btn-lg btn-primary" href="{{server_url}}">Dashboard</a> <a id="login" class="btn btn-lg btn-primary" href="{{login_url}}">Log in</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,5 @@
{% extends "page.html" %} {% extends "page.html" %}
{% block login_widget %} {% block login_widget %}
{% endblock %} {% endblock %}

View File

@@ -15,11 +15,12 @@
<script src="{{static_url("components/requirejs/require.js") }}" type="text/javascript" charset="utf-8"></script> <script src="{{static_url("components/requirejs/require.js") }}" type="text/javascript" charset="utf-8"></script>
<script> <script>
require.config({ require.config({
baseUrl: '{{static_url("", include_version=False)}}', baseUrl: '{{static_url("js", include_version=False)}}',
paths: { paths: {
jquery: 'components/jquery/jquery.min', components: '../components',
bootstrap: 'components/bootstrap/js/bootstrap.min', jquery: '../components/jquery/jquery.min',
moment: "components/moment/moment", bootstrap: '../components/bootstrap/js/bootstrap.min',
moment: "../components/moment/moment",
}, },
shim: { shim: {
bootstrap: { bootstrap: {
@@ -30,12 +31,21 @@
}); });
</script> </script>
<script type="text/javascript">
window.jhdata = {
base_url: "{{base_url}}",
{% if user %}
user: "{{user.name}}",
{% endif %}
}
</script>
{% block meta %} {% block meta %}
{% endblock %} {% endblock %}
</head> </head>
<body {% block params %}{% endblock %}> <body>
<noscript> <noscript>
<div id='noscript'> <div id='noscript'>
@@ -51,7 +61,7 @@
{% block login_widget %} {% block login_widget %}
<span id="login_widget"> <span id="login_widget">
{% if logged_in %} {% if user %}
<a id="logout" class="btn navbar-btn btn-default pull-right" href="{{base_url}}logout">Logout</a> <a id="logout" class="btn navbar-btn btn-default pull-right" href="{{base_url}}logout">Logout</a>
{% else %} {% else %}
<a id="login" class="btn navbar-btn btn-default pull-right" href="{{base_url}}login">Login</a> <a id="login" class="btn navbar-btn btn-default pull-right" href="{{base_url}}login">Login</a>