From 53edc0b2f7e6ff684d33076d5f688b5c2fe7dcb7 Mon Sep 17 00:00:00 2001 From: MinRK Date: Sat, 13 Sep 2014 22:38:59 -0700 Subject: [PATCH 1/9] add a notion of admin users and an @admin_only decorator for restricted methods --- jupyterhub/app.py | 7 +++++ jupyterhub/handlers/base.py | 8 ++++-- jupyterhub/orm.py | 3 ++- jupyterhub/utils.py | 54 ++++++++++++++++++++++++------------- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 76664d00..e97e9c9c 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -158,6 +158,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() @@ -300,6 +306,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, diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index e8e31a66..46e35e7e 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', '') @@ -90,10 +94,10 @@ class BaseHandler(RequestHandler): def user_from_username(self, username): """Get ORM User for username""" - user = self.db.query(orm.User).filter(orm.User.name==username).first() if user is None: - user = orm.User(name=username) + admin = (not self.admin_users) or username in self.admin_users + user = orm.User(name=username, admin=admin) self.db.add(user) self.db.commit() return user diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index e16a031f..2de2bfcd 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 @@ -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/utils.py b/jupyterhub/utils.py index c55dbdca..bee1b718 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) + 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) From a2456418868b45704cc1f88c1ef87161c85d2f07 Mon Sep 17 00:00:00 2001 From: MinRK Date: Sat, 13 Sep 2014 22:41:06 -0700 Subject: [PATCH 2/9] simplify handler setup with default_handlers in modules like IPython's, but a bit simpler since we don't have so many services to deal with. --- jupyterhub/apihandlers/__init__.py | 8 ++++++- jupyterhub/apihandlers/auth.py | 4 ++++ jupyterhub/app.py | 36 +++++++++++------------------- jupyterhub/handlers/__init__.py | 8 ++++++- jupyterhub/handlers/base.py | 4 ++++ jupyterhub/handlers/login.py | 5 +++++ 6 files changed, 40 insertions(+), 25 deletions(-) diff --git a/jupyterhub/apihandlers/__init__.py b/jupyterhub/apihandlers/__init__.py index d7c8ad39..8062bb46 100644 --- a/jupyterhub/apihandlers/__init__.py +++ b/jupyterhub/apihandlers/__init__.py @@ -1 +1,7 @@ -from .auth import * \ No newline at end of file +from . import auth, users +from .auth import * +from .users import * + +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..83299b77 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -20,3 +20,7 @@ class AuthorizationsAPIHandler(BaseHandler): self.write(json.dumps({ 'user' : orm_token.user.name, })) + +default_handlers = [ + (r"/api/authorizations/([^/]+)", AuthorizationsAPIHandler), +] diff --git a/jupyterhub/app.py b/jupyterhub/app.py index e97e9c9c..ed48e957 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 @@ -220,19 +210,19 @@ 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"/user/([^/]+)/?.*", handlers.UserHandler), (r"/?", web.RedirectHandler, {"url" : self.hub_prefix, "permanent": False}), ]) self.handlers.append( - (r'(.*)', Template404) + (r'(.*)', handlers.Template404) ) def init_db(self): diff --git a/jupyterhub/handlers/__init__.py b/jupyterhub/handlers/__init__.py index 4cc535e3..579fa265 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, login + +default_handlers = [] +for mod in (base, login): + default_handlers.extend(mod.default_handlers) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 46e35e7e..c178bf21 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -294,3 +294,7 @@ class UserHandler(BaseHandler): self.redirect(url_concat(self.settings['login_url'], { 'next' : self.request.path, })) + +default_handlers = [ + (r"/", RootHandler), +] diff --git a/jupyterhub/handlers/login.py b/jupyterhub/handlers/login.py index 05d58e84..4976b2fa 100644 --- a/jupyterhub/handlers/login.py +++ b/jupyterhub/handlers/login.py @@ -56,3 +56,8 @@ class LoginHandler(BaseHandler): username=username, ) self.finish(html) + +default_handlers = [ + (r"/login", LoginHandler), + (r"/logout", LogoutHandler), +] From 970e4d2ce25975df44ae70bb44d84a43e01b3bf2 Mon Sep 17 00:00:00 2001 From: MinRK Date: Sat, 13 Sep 2014 22:41:45 -0700 Subject: [PATCH 3/9] minor fixes to testing utils --- jupyterhub/tests/conftest.py | 8 ++++---- jupyterhub/tests/mocking.py | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) 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..25efda19 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -58,12 +58,13 @@ class MockHubApp(JupyterHubApp): 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 some initial users - 1 admin, 1 non-admin + admin = orm.User(name='admin', admin=True) + user = orm.User(name='user') + self.db.add(admin) 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 +73,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() From 833835b0f3a16f4253e3183f4321e5522232d8b9 Mon Sep 17 00:00:00 2001 From: MinRK Date: Sat, 13 Sep 2014 22:42:07 -0700 Subject: [PATCH 4/9] add user list handler, first of many --- jupyterhub/apihandlers/users.py | 29 ++++++++++++++++++++++ jupyterhub/tests/test_api.py | 43 ++++++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 jupyterhub/apihandlers/users.py diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py new file mode 100644 index 00000000..504f8e50 --- /dev/null +++ b/jupyterhub/apihandlers/users.py @@ -0,0 +1,29 @@ +"""Authorization handlers""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import json + +from ..handlers import BaseHandler +from .. import orm +from ..utils import admin_only + + +class UserListAPIHandler(BaseHandler): + @admin_only + def get(self): + users = list(self.db.query(orm.User)) + + data = [] + for user in users: + data.append({ + 'name': user.name, + 'server': user.server.base_url if user.server else None, + }) + + self.write(json.dumps(data)) + +default_handlers = [ + (r"/api/users", UserListAPIHandler), +] diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 109301b0..4b829592 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -5,13 +5,31 @@ import requests from ..utils import url_path_join as ujoin from .. import orm +def add_user(db, **kwargs): + user = orm.User(**kwargs) + db.add(user) + db.commit() + return user + +def auth_header(db, name): + user = db.query(orm.User).filter(orm.User.name==name).first() + 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 +65,22 @@ 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', + 'server': None, + }, + { + 'name': 'user', + 'server': None, + } + ] + + r = api_request(app, 'users', + headers=auth_header(db, 'user'), + ) + assert r.status_code == 403 From a47e390aa053c8db93f58860f42d75acfcea212c Mon Sep 17 00:00:00 2001 From: MinRK Date: Sun, 14 Sep 2014 16:46:49 -0700 Subject: [PATCH 5/9] add mock single-user server for testing --- jupyterhub/tests/mocksu.py | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 jupyterhub/tests/mocksu.py 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 From 0a8759b0a57377275a301c3016409cab83796b21 Mon Sep 17 00:00:00 2001 From: MinRK Date: Sun, 14 Sep 2014 14:33:11 -0700 Subject: [PATCH 6/9] flesh out REST API can now list/view/add/create/modify users and start/stop single-user servers --- jupyterhub/apihandlers/__init__.py | 4 +- jupyterhub/apihandlers/auth.py | 5 +- jupyterhub/apihandlers/base.py | 53 ++++++++++++ jupyterhub/apihandlers/users.py | 131 ++++++++++++++++++++++++++--- jupyterhub/app.py | 9 +- jupyterhub/handlers/base.py | 38 ++++++++- jupyterhub/orm.py | 2 +- jupyterhub/tests/mocking.py | 26 ++++-- jupyterhub/tests/test_api.py | 85 ++++++++++++++++++- jupyterhub/utils.py | 2 +- 10 files changed, 322 insertions(+), 33 deletions(-) create mode 100644 jupyterhub/apihandlers/base.py diff --git a/jupyterhub/apihandlers/__init__.py b/jupyterhub/apihandlers/__init__.py index 8062bb46..9dd87767 100644 --- a/jupyterhub/apihandlers/__init__.py +++ b/jupyterhub/apihandlers/__init__.py @@ -1,7 +1,9 @@ -from . import auth, users +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 83299b77..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() 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 index 504f8e50..c0f3bdbd 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -5,25 +5,132 @@ import json -from ..handlers import BaseHandler +from tornado import gen, web + from .. import orm -from ..utils import admin_only +from ..utils import admin_only, authenticated_403 +from .base import APIHandler +try: + basestring +except NameError: + basestring = str # py3 -class UserListAPIHandler(BaseHandler): +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 = list(self.db.query(orm.User)) - - data = [] - for user in users: - data.append({ - 'name': user.name, - 'server': user.server.base_url if user.server else None, - }) - + 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 ed48e957..1ad3473c 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -229,6 +229,10 @@ class JupyterHubApp(Application): # 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""" @@ -279,8 +283,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.""" @@ -330,7 +333,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/base.py b/jupyterhub/handlers/base.py index c178bf21..43fe235b 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -91,13 +91,19 @@ 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: - admin = (not self.admin_users) or username in self.admin_users - user = orm.User(name=username, admin=admin) + user = orm.User(name=username) self.db.add(user) self.db.commit() return user @@ -165,6 +171,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( @@ -193,6 +211,20 @@ 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() + 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 diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 2de2bfcd..2304ca80 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -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, ) diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 25efda19..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,14 +49,19 @@ 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(): @@ -59,10 +69,8 @@ class MockHubApp(JupyterHubApp): # put initialize in start for SQLAlchemy threading reasons super(MockHubApp, self).initialize(argv=argv) - # add some initial users - 1 admin, 1 non-admin - admin = orm.User(name='admin', admin=True) + # add an initial user user = orm.User(name='user') - self.db.add(admin) self.db.add(user) self.db.commit() self.io_loop.add_callback(evt.set) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 4b829592..be0cd98b 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1,10 +1,15 @@ """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) @@ -12,7 +17,7 @@ def add_user(db, **kwargs): return user def auth_header(db, name): - user = db.query(orm.User).filter(orm.User.name==name).first() + user = find_user(db, name) if user is None: user = add_user(db, name=name) if not user.api_tokens: @@ -72,10 +77,12 @@ def test_get_users(app): assert sorted(r.json(), key=lambda d: d['name']) == [ { 'name': 'admin', + 'admin': True, 'server': None, }, { 'name': 'user', + 'admin': False, 'server': None, } ] @@ -84,3 +91,79 @@ def test_get_users(app): 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 bee1b718..1b48f35c 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -38,7 +38,7 @@ def auth_decorator(check_auth): def decorator(method): def decorated(self, *args, **kwargs): check_auth(self) - return method(self, *args) + return method(self, *args, **kwargs) decorated.__name__ = method.__name__ decorated.__doc__ = method.__doc__ return decorated From 5cba7c50b27310cc2d52a23bc626e54ad95f2e0e Mon Sep 17 00:00:00 2001 From: MinRK Date: Sun, 14 Sep 2014 16:43:41 -0700 Subject: [PATCH 7/9] update base page template - no body params, just write jhapi globals instead - baseUrl is static/js --- share/jupyter/templates/page.html | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) 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 %} - +