mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 21:13:01 +00:00
Merge pull request #30 from minrk/restapi
getting started with REST API
This commit is contained in:
@@ -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)
|
||||||
|
@@ -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),
|
||||||
|
]
|
||||||
|
53
jupyterhub/apihandlers/base.py
Normal file
53
jupyterhub/apihandlers/base.py
Normal 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,
|
||||||
|
}))
|
136
jupyterhub/apihandlers/users.py
Normal file
136
jupyterhub/apihandlers/users.py
Normal 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),
|
||||||
|
]
|
@@ -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
|
||||||
|
@@ -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)
|
||||||
|
@@ -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),
|
||||||
|
]
|
||||||
|
@@ -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),
|
||||||
|
]
|
||||||
|
55
jupyterhub/handlers/pages.py
Normal file
55
jupyterhub/handlers/pages.py
Normal 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),
|
||||||
|
]
|
@@ -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")
|
||||||
|
@@ -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([])
|
||||||
|
@@ -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()
|
||||||
|
|
||||||
|
40
jupyterhub/tests/mocksu.py
Normal file
40
jupyterhub/tests/mocksu.py
Normal 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)
|
@@ -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
|
||||||
|
|
@@ -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)
|
||||||
|
19
share/jupyter/static/js/home.js
Normal file
19
share/jupyter/static/js/home.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
89
share/jupyter/static/js/jhapi.js
Normal file
89
share/jupyter/static/js/jhapi.js
Normal 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;
|
||||||
|
});
|
127
share/jupyter/static/js/utils.js
Normal file
127
share/jupyter/static/js/utils.js
Normal 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;
|
||||||
|
});
|
13
share/jupyter/templates/admin.html
Normal file
13
share/jupyter/templates/admin.html
Normal 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 %}
|
29
share/jupyter/templates/home.html
Normal file
29
share/jupyter/templates/home.html
Normal 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 %}
|
@@ -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>
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
{% extends "page.html" %}
|
{% extends "page.html" %}
|
||||||
|
|
||||||
|
|
||||||
{% block login_widget %}
|
{% block login_widget %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
Reference in New Issue
Block a user