diff --git a/jupyterhub/apihandlers/__init__.py b/jupyterhub/apihandlers/__init__.py index d7c8ad39..9dd87767 100644 --- a/jupyterhub/apihandlers/__init__.py +++ b/jupyterhub/apihandlers/__init__.py @@ -1 +1,9 @@ -from .auth import * \ No newline at end of file +from .base import * +from .auth import * +from .users import * + +from . import auth, users + +default_handlers = [] +for mod in (auth, users): + default_handlers.extend(mod.default_handlers) diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 34bfc721..193e4ce9 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -6,12 +6,13 @@ import json from tornado import web -from ..handlers import BaseHandler from .. import orm from ..utils import token_authenticated +from .base import APIHandler -class AuthorizationsAPIHandler(BaseHandler): + +class AuthorizationsAPIHandler(APIHandler): @token_authenticated def get(self, token): orm_token = self.db.query(orm.CookieToken).filter(orm.CookieToken.token == token).first() @@ -20,3 +21,7 @@ class AuthorizationsAPIHandler(BaseHandler): self.write(json.dumps({ 'user' : orm_token.user.name, })) + +default_handlers = [ + (r"/api/authorizations/([^/]+)", AuthorizationsAPIHandler), +] diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py new file mode 100644 index 00000000..8aa0046a --- /dev/null +++ b/jupyterhub/apihandlers/base.py @@ -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, + })) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py new file mode 100644 index 00000000..c0f3bdbd --- /dev/null +++ b/jupyterhub/apihandlers/users.py @@ -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), +] diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 76664d00..4bb0c437 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -14,29 +14,19 @@ from jinja2 import Environment, FileSystemLoader import tornado.httpserver import tornado.options from tornado.ioloop import IOLoop -from tornado.log import LogFormatter, app_log +from tornado.log import LogFormatter from tornado import gen, web from IPython.utils.traitlets import ( - Unicode, Integer, Dict, TraitError, List, Instance, Bool, Bytes, Any, - DottedObjectName, + Unicode, Integer, Dict, TraitError, List, Bool, Bytes, Any, + DottedObjectName, Set, ) from IPython.config import Application, catch_config_error from IPython.utils.importstring import import_item here = os.path.dirname(__file__) -from .handlers import ( - Template404, - RootHandler, - LoginHandler, - LogoutHandler, - UserHandler, -) - -from .apihandlers import ( - AuthorizationsAPIHandler, -) +from . import handlers, apihandlers from . import orm from ._data import DATA_FILES_PATH @@ -158,6 +148,12 @@ class JupyterHubApp(Application): debug_db = Bool(False) 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) handlers = List() @@ -214,25 +210,26 @@ class JupyterHubApp(Application): return handlers def init_handlers(self): - handlers = [ - (r"/", RootHandler), - (r"/login", LoginHandler), - (r"/logout", LogoutHandler), - (r"/api/authorizations/([^/]+)", AuthorizationsAPIHandler), - ] - self.handlers = self.add_url_prefix(self.hub_prefix, handlers) + h = [] + h.extend(handlers.default_handlers) + h.extend(apihandlers.default_handlers) + + self.handlers = self.add_url_prefix(self.hub_prefix, h) + + # some extra handlers, outside hub_prefix self.handlers.extend([ - (r"/user/([^/]+)/?.*", UserHandler), - (r"/?", web.RedirectHandler, {"url" : self.hub_prefix, "permanent": False}), + (r"(?!%s).*" % self.hub_prefix, handlers.PrefixRedirectHandler), + (r'(.*)', handlers.Template404), ]) - self.handlers.append( - (r'(.*)', Template404) - ) def init_db(self): # TODO: load state from db for resume # TODO: if not resuming, clear existing db contents 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): """Load the Hub config into the database""" @@ -283,8 +280,7 @@ class JupyterHubApp(Application): if self.ssl_cert: cmd.extend(['--ssl-cert', self.ssl_cert]) - - self.proxy = Popen(cmd, env=env) + self.proxy_process = Popen(cmd, env=env) def init_tornado_settings(self): """Set up the tornado settings dict.""" @@ -300,6 +296,7 @@ class JupyterHubApp(Application): log=self.log, db=self.db, hub=self.hub, + admin_users=self.admin_users, authenticator=import_item(self.authenticator)(config=self.config), spawner_class=import_item(self.spawner_class), base_url=base_url, @@ -333,7 +330,7 @@ class JupyterHubApp(Application): @gen.coroutine def cleanup(self): self.log.info("Cleaning up proxy...") - self.proxy.terminate() + self.proxy_process.terminate() self.log.info("Cleaning up single-user servers...") # request (async) process termination diff --git a/jupyterhub/handlers/__init__.py b/jupyterhub/handlers/__init__.py index 4cc535e3..8b2ffd58 100644 --- a/jupyterhub/handlers/__init__.py +++ b/jupyterhub/handlers/__init__.py @@ -1,2 +1,8 @@ from .base import * -from .login import * \ No newline at end of file +from .login import * + +from . import base, pages, login + +default_handlers = [] +for mod in (base, pages, login): + default_handlers.extend(mod.default_handlers) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index e8e31a66..93f6a9df 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -56,6 +56,10 @@ class BaseHandler(RequestHandler): # Login and cookie-related #--------------------------------------------------------------- + @property + def admin_users(self): + return self.settings.setdefault('admin_users', set()) + def get_current_user_token(self): """get_current_user from Authorization header token""" auth_header = self.request.headers.get('Authorization', '') @@ -87,11 +91,17 @@ class BaseHandler(RequestHandler): 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 self.db.query(orm.User).filter(orm.User.name==name).first() def user_from_username(self, username): """Get ORM User for username""" - - user = self.db.query(orm.User).filter(orm.User.name==username).first() + user = self.find_user(username) if user is None: user = orm.User(name=username) self.db.add(user) @@ -104,28 +114,29 @@ class BaseHandler(RequestHandler): 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_login_cookies(self, user): + 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 - 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, - ) - + if 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) - + if not self.get_current_user_cookie(): + 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) @@ -161,6 +172,18 @@ class BaseHandler(RequestHandler): yield wait_for_server(user.server.ip, user.server.port) 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 def spawn_single_user(self, user): user.server = orm.Server( @@ -189,6 +212,21 @@ class BaseHandler(RequestHandler): self.notify_proxy(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 @@ -203,16 +241,12 @@ class BaseHandler(RequestHandler): template = self.get_template(name) return template.render(**ns) - @property - def logged_in(self): - """Is a user currently logged in?""" - return self.get_current_user() is not None - @property def template_namespace(self): + user = self.get_current_user() return dict( base_url=self.hub.server.base_url, - logged_in=self.logged_in, + user=user, login_url=self.settings['login_url'], static_url=self.static_url, ) @@ -260,33 +294,46 @@ class Template404(BaseHandler): raise web.HTTPError(404) -class RootHandler(BaseHandler): - """Render the Hub root page.""" - @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) - - -class UserHandler(BaseHandler): - """Respawn single-user server after logging in. - - This handler shouldn't be called if the proxy is set up correctly. +class PrefixRedirectHandler(BaseHandler): + """Redirect anything outside a prefix inside. + + Redirects /foo to /prefix/foo, etc. """ - @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): - self.log.warn("Hub serving single-user url: %s", self.request.path) current_user = self.get_current_user() if current_user and current_user.name == name: - self.spawn_single_user(current_user) - self.redirect('') + # logged in, spawn the server + if current_user.spawner: + 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) + self.redirect(url_path_join( + self.base_url, 'user', name, + )) else: - self.log.warn("Hub serving single-user url: %s", self.request.path) + # 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.path, - })) + self.redirect(url_concat( + self.settings['login_url'], + {'next': self.request.path, + }), permanent=False) + +default_handlers = [ + (r'/user/([^/]+)/?.*', UserSpawnHandler), +] diff --git a/jupyterhub/handlers/login.py b/jupyterhub/handlers/login.py index 05d58e84..5c3a31b5 100644 --- a/jupyterhub/handlers/login.py +++ b/jupyterhub/handlers/login.py @@ -26,10 +26,14 @@ class LoginHandler(BaseHandler): username=username, message=message, ) - + def get(self): - if self.get_argument('next', False) and self.get_current_user(): - self.redirect(self.get_argument('next'), permanent=False) + next_url = self.get_argument('next', 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: username = self.get_argument('username', default='') self.finish(self._render(username=username)) @@ -46,7 +50,7 @@ class LoginHandler(BaseHandler): if authorized: user = self.user_from_username(username) 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 self.redirect(next_url) else: @@ -56,3 +60,8 @@ class LoginHandler(BaseHandler): username=username, ) self.finish(html) + +default_handlers = [ + (r"/login", LoginHandler), + (r"/logout", LogoutHandler), +] diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py new file mode 100644 index 00000000..7a088835 --- /dev/null +++ b/jupyterhub/handlers/pages.py @@ -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), +] \ No newline at end of file diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index e16a031f..2304ca80 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -8,7 +8,7 @@ import uuid from sqlalchemy.types import TypeDecorator, VARCHAR 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.orm import sessionmaker, relationship, backref @@ -75,7 +75,7 @@ class Server(Base): def host(self): return "{proto}://{ip}:{port}".format( proto=self.proto, - ip=self.ip, + ip=self.ip or '*', port=self.port, ) @@ -157,6 +157,7 @@ class User(Base): # should we allow multiple servers per user? _server_id = Column(Integer, ForeignKey('servers.id')) server = relationship(Server, primaryjoin=_server_id == Server.id) + admin = Column(Boolean, default=False) api_tokens = relationship("APIToken", backref="user") cookie_tokens = relationship("CookieToken", backref="user") diff --git a/jupyterhub/tests/conftest.py b/jupyterhub/tests/conftest.py index a3629874..afe73763 100644 --- a/jupyterhub/tests/conftest.py +++ b/jupyterhub/tests/conftest.py @@ -38,12 +38,12 @@ def db(): @fixture def io_loop(): """Get the current IOLoop""" - ioloop.IOLoop.clear_current() - return ioloop.IOLoop.current() + loop = ioloop.IOLoop() + loop.make_current() + return loop - -@fixture +@fixture(scope='module') def app(request): app = MockHubApp() app.start([]) diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 38850563..1ec61621 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -1,12 +1,13 @@ """mock utilities for testing""" + +import sys +import threading + try: from unittest import mock except ImportError: import mock -import getpass -import threading - from tornado.ioloop import IOLoop from IPython.utils.py3compat import unicode_type @@ -30,12 +31,16 @@ def mock_authenticate(username, password, service='login'): class MockSpawner(LocalProcessSpawner): - def make_preexec_fn(self): + def make_preexec_fn(self, *a, **kw): # skip the setuid stuff return def _set_user_changed(self, name, old, new): pass + + def _cmd_default(self): + return [sys.executable, '-m', 'jupyterhub.tests.mocksu'] + class MockPAMAuthenticator(PAMAuthenticator): def authenticate(self, *args, **kwargs): @@ -44,26 +49,30 @@ class MockPAMAuthenticator(PAMAuthenticator): class MockHubApp(JupyterHubApp): """HubApp with various mock bits""" - # def start_proxy(self): - # pass + + def _ip_default(self): + return 'localhost' + def _authenticator_default(self): return '%s.%s' % (__name__, 'MockPAMAuthenticator') def _spawner_class_default(self): return '%s.%s' % (__name__, 'MockSpawner') + def _admin_users_default(self): + return {'admin'} + def start(self, argv=None): evt = threading.Event() def _start(): self.io_loop = IOLoop.current() # put initialize in start for SQLAlchemy threading reasons 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.commit() - token = user.new_api_token() - self.db.add(token) - self.db.commit() self.io_loop.add_callback(evt.set) super(MockHubApp, self).start() @@ -72,6 +81,6 @@ class MockHubApp(JupyterHubApp): evt.wait(timeout=5) def stop(self): - self.io_loop.stop() + self.io_loop.add_callback(self.io_loop.stop) self._thread.join() diff --git a/jupyterhub/tests/mocksu.py b/jupyterhub/tests/mocksu.py new file mode 100644 index 00000000..e770c146 --- /dev/null +++ b/jupyterhub/tests/mocksu.py @@ -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) \ No newline at end of file diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 109301b0..be0cd98b 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1,17 +1,40 @@ """Tests for the REST API""" +import json + import requests from ..utils import url_path_join as ujoin 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): """Make an API request""" base_url = app.hub.server.url - token = app.db.query(orm.APIToken).first() - kwargs.setdefault('headers', {}) - kwargs['headers'].setdefault('Authorization', 'token %s' % token.token) + headers = kwargs.setdefault('headers', {}) + + if 'Authorization' not in headers: + headers.update(auth_header(app.db, 'admin')) url = ujoin(base_url, 'api', *api_path) method = kwargs.pop('method', 'get') @@ -47,3 +70,100 @@ def test_auth_api(app): 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 + \ No newline at end of file diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index c55dbdca..1b48f35c 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -29,24 +29,42 @@ def wait_for_server(ip, port, timeout=10): else: break +def auth_decorator(check_auth): + """Make an authentication decorator -def token_authenticated(method): - """decorator for a method authenticated only by the Authorization token header""" - def check_token(self, *args, **kwargs): - if self.get_current_user_token() is None: - raise web.HTTPError(403) - return method(self, *args, **kwargs) - check_token.__name__ = method.__name__ - check_token.__doc__ = method.__doc__ - return check_token + I heard you like decorators, so I put a decorator + in your decorator, so you can decorate while you decorate. + """ + 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 -def authenticated_403(method): - """decorator like web.authenticated, but raise 403 instead of redirect to login""" - def check_user(self, *args, **kwargs): - if self.get_current_user() is None: - raise web.HTTPError(403) - return method(self, *args, **kwargs) - check_user.__name__ = method.__name__ - check_user.__doc__ = method.__doc__ - return check_user +@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: + raise web.HTTPError(403) + +@auth_decorator +def authenticated_403(self): + """like web.authenticated, but raise 403 instead of redirect to login""" + if self.get_current_user() is None: + raise web.HTTPError(403) + +@auth_decorator +def admin_only(self): + """decorator for restricting access to admin users""" + user = self.get_current_user() + if user is None or not user.admin: + raise web.HTTPError(403) diff --git a/share/jupyter/static/js/home.js b/share/jupyter/static/js/home.js new file mode 100644 index 00000000..cb9be3ed --- /dev/null +++ b/share/jupyter/static/js/home.js @@ -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(); + } + }); + }); + +}); diff --git a/share/jupyter/static/js/jhapi.js b/share/jupyter/static/js/jhapi.js new file mode 100644 index 00000000..9770d736 --- /dev/null +++ b/share/jupyter/static/js/jhapi.js @@ -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; +}); \ No newline at end of file diff --git a/share/jupyter/static/js/utils.js b/share/jupyter/static/js/utils.js new file mode 100644 index 00000000..251e180f --- /dev/null +++ b/share/jupyter/static/js/utils.js @@ -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 $("
").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; +}); diff --git a/share/jupyter/templates/admin.html b/share/jupyter/templates/admin.html new file mode 100644 index 00000000..6a8a700c --- /dev/null +++ b/share/jupyter/templates/admin.html @@ -0,0 +1,13 @@ +{% extends "page.html" %} + +{% block main %} + +
+
+ +
+
+ +{% endblock %} diff --git a/share/jupyter/templates/home.html b/share/jupyter/templates/home.html new file mode 100644 index 00000000..66a09629 --- /dev/null +++ b/share/jupyter/templates/home.html @@ -0,0 +1,29 @@ +{% extends "page.html" %} + +{% block main %} + +
+
+
+ {% if user.server %} + Stop My Server + {% endif %} + + My Server + + {% if user.admin %} + Admin + {% endif %} +
+
+
+ +{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/share/jupyter/templates/index.html b/share/jupyter/templates/index.html index 72a89b54..49e3888a 100644 --- a/share/jupyter/templates/index.html +++ b/share/jupyter/templates/index.html @@ -1,11 +1,14 @@ {% extends "page.html" %} +{% block login_widget %} +{% endblock %} + {% block main %}
diff --git a/share/jupyter/templates/login.html b/share/jupyter/templates/login.html index b9fcc59d..6b1d525b 100644 --- a/share/jupyter/templates/login.html +++ b/share/jupyter/templates/login.html @@ -1,6 +1,5 @@ {% extends "page.html" %} - {% block login_widget %} {% endblock %} diff --git a/share/jupyter/templates/page.html b/share/jupyter/templates/page.html index c5e4e6da..f568182b 100644 --- a/share/jupyter/templates/page.html +++ b/share/jupyter/templates/page.html @@ -15,11 +15,12 @@ + + {% block meta %} {% endblock %} - +