mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 15:03:02 +00:00
store the multi-user state in a database
via SQLAlchemy ORM
This commit is contained in:
147
multiuser/app.py
147
multiuser/app.py
@@ -9,10 +9,12 @@ import tornado.options
|
||||
from tornado import web
|
||||
|
||||
from IPython.utils.traitlets import (
|
||||
Unicode, Integer, Dict, TraitError, List,
|
||||
Unicode, Integer, Dict, TraitError, List, Instance, Bool, Bytes, Any,
|
||||
DottedObjectName,
|
||||
)
|
||||
from IPython.config import Application
|
||||
from IPython.html import utils
|
||||
from IPython.html.utils import url_path_join
|
||||
from IPython.utils.importstring import import_item
|
||||
|
||||
here = os.path.dirname(__file__)
|
||||
|
||||
@@ -24,35 +26,79 @@ from .handlers import (
|
||||
UserHandler,
|
||||
)
|
||||
|
||||
from .user import UserManager
|
||||
from . import db
|
||||
|
||||
class MultiUserAuthenticationApp(Application):
|
||||
# from .user import UserManager
|
||||
|
||||
class MultiUserApp(Application):
|
||||
|
||||
ip = Unicode('localhost', config=True,
|
||||
help="The public facing ip of the proxy"
|
||||
)
|
||||
port = Integer(8000, config=True,
|
||||
help="The public facing port of the proxy"
|
||||
)
|
||||
base_url = Unicode('/', config=True,
|
||||
help="The base URL of the entire application"
|
||||
)
|
||||
proxy_auth_token = Unicode(config=True,
|
||||
help="The Proxy Auth token"
|
||||
)
|
||||
def _proxy_auth_token_default(self):
|
||||
return db.new_token()
|
||||
|
||||
proxy_api_ip = Unicode('localhost', config=True,
|
||||
help="The ip for the proxy API handlers"
|
||||
)
|
||||
proxy_api_port = Integer(config=True,
|
||||
help="The port for the proxy API handlers"
|
||||
)
|
||||
def _proxy_api_port_default(self):
|
||||
return self.port + 1
|
||||
|
||||
multiuser_port = Integer(8081, config=True,
|
||||
hub_port = Integer(8081, config=True,
|
||||
help="The port for this process"
|
||||
)
|
||||
|
||||
multiuser_prefix = Unicode('/multiuser/', config=True,
|
||||
help="The prefix for the multi-user server. Must not be '/'"
|
||||
hub_ip = Unicode('localhost', config=True,
|
||||
help="The ip for this process"
|
||||
)
|
||||
def _multiuser_prefix_changed(self, name, old, new):
|
||||
|
||||
hub_prefix = Unicode('/hub/', config=True,
|
||||
help="The prefix for the hub server. Must not be '/'"
|
||||
)
|
||||
def _hub_prefix_default(self):
|
||||
return url_path_join(self.base_url, '/hub/')
|
||||
|
||||
def _hub_prefix_changed(self, name, old, new):
|
||||
if new == '/':
|
||||
raise TraitError("'/' is not a valid multi-user prefix")
|
||||
raise TraitError("'/' is not a valid hub prefix")
|
||||
newnew = new
|
||||
if not new.startswith('/'):
|
||||
newnew = '/' + new
|
||||
if not newnew.endswith('/'):
|
||||
newnew = newnew + '/'
|
||||
if not newnew.startswith(self.base_url):
|
||||
newnew = url_path_join(self.base_url, newnew)
|
||||
if newnew != new:
|
||||
self.multiuser_prefix = newnew
|
||||
self.hub_prefix = newnew
|
||||
|
||||
cookie_secret = Bytes(config=True)
|
||||
def _cookie_secret_default(self):
|
||||
return b'secret!'
|
||||
|
||||
# spawning subprocesses
|
||||
spawner_class = DottedObjectName("multiuser.spawner.ProcessSpawner")
|
||||
def _spawner_class_changed(self, name, old, new):
|
||||
self.spawner = import_item(new)
|
||||
|
||||
spawner = Any()
|
||||
def _spawner_default(self):
|
||||
return import_item(self.spawner_class)
|
||||
|
||||
|
||||
db_url = Unicode('sqlite:///:memory:', config=True)
|
||||
debug_db = Bool(False)
|
||||
db = Any()
|
||||
|
||||
tornado_settings = Dict(config=True)
|
||||
|
||||
@@ -62,7 +108,7 @@ class MultiUserAuthenticationApp(Application):
|
||||
"""add a url prefix to handlers"""
|
||||
for i, tup in enumerate(handlers):
|
||||
lis = list(tup)
|
||||
lis[0] = utils.url_path_join(prefix, tup[0])
|
||||
lis[0] = url_path_join(prefix, tup[0])
|
||||
handlers[i] = tuple(lis)
|
||||
return handlers
|
||||
|
||||
@@ -73,38 +119,62 @@ class MultiUserAuthenticationApp(Application):
|
||||
(r"/logout", LogoutHandler),
|
||||
(r"/api/authorizations/([^/]+)", AuthorizationsHandler),
|
||||
]
|
||||
self.handlers = self.add_url_prefix(self.multiuser_prefix, handlers)
|
||||
self.handlers = self.add_url_prefix(self.hub_prefix, handlers)
|
||||
self.handlers.extend([
|
||||
(r"/user/([^/]+)/?.*", UserHandler),
|
||||
(r"/", web.RedirectHandler, {"url" : self.multiuser_prefix}),
|
||||
(r"/", web.RedirectHandler, {"url" : self.hub_prefix}),
|
||||
])
|
||||
|
||||
def init_user_manager(self):
|
||||
self.user_manager = UserManager(proxy_port=self.proxy_api_port)
|
||||
def init_db(self):
|
||||
# TODO: load state from db for resume
|
||||
self.db = db.new_session(self.db_url, echo=self.debug_db)
|
||||
|
||||
def init_hub(self):
|
||||
self.hub = db.Hub(
|
||||
server=db.Server(
|
||||
ip=self.hub_ip,
|
||||
port=self.hub_port,
|
||||
base_url=self.hub_prefix,
|
||||
cookie_secret=self.cookie_secret,
|
||||
cookie_name='jupyter-hub-token',
|
||||
)
|
||||
)
|
||||
self.db.add(self.hub)
|
||||
self.db.commit()
|
||||
|
||||
def init_proxy(self):
|
||||
self.proxy = db.Proxy(
|
||||
public_server=db.Server(
|
||||
ip=self.ip,
|
||||
port=self.port,
|
||||
),
|
||||
api_server=db.Server(
|
||||
ip=self.proxy_api_ip,
|
||||
port=self.proxy_api_port,
|
||||
base_url='/api/routes/'
|
||||
),
|
||||
auth_token = db.new_token(),
|
||||
)
|
||||
self.db.add(self.proxy)
|
||||
self.db.commit()
|
||||
|
||||
def start_proxy(self):
|
||||
env = os.environ.copy()
|
||||
env['CONFIGPROXY_AUTH_TOKEN'] = self.user_manager.proxy_auth_token
|
||||
env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token
|
||||
self.proxy = Popen(["node", os.path.join(here, 'js', 'main.js'),
|
||||
'--port', str(self.port),
|
||||
'--api-port', str(self.proxy_api_port),
|
||||
'--upstream-port', str(self.multiuser_port),
|
||||
'--port', str(self.proxy.public_server.port),
|
||||
'--api-port', str(self.proxy.api_server.port),
|
||||
'--upstream-port', str(self.hub.server.port),
|
||||
], env=env)
|
||||
|
||||
def init_tornado_settings(self):
|
||||
base_url = self.multiuser_prefix
|
||||
base_url = self.base_url
|
||||
self.tornado_settings.update(
|
||||
db=self.db,
|
||||
hub=self.hub,
|
||||
base_url=base_url,
|
||||
user_manager=self.user_manager,
|
||||
cookie_secret='super secret',
|
||||
cookie_name='multiusertest',
|
||||
multiuser_prefix=base_url,
|
||||
multiuser_api_url=utils.url_path_join(
|
||||
'http://localhost:%i' % self.multiuser_port,
|
||||
base_url,
|
||||
'api',
|
||||
),
|
||||
login_url=utils.url_path_join(base_url, 'login'),
|
||||
cookie_secret=self.cookie_secret,
|
||||
login_url=url_path_join(self.hub.server.base_url, 'login'),
|
||||
template_path=os.path.join(here, 'templates'),
|
||||
)
|
||||
|
||||
@@ -112,25 +182,28 @@ class MultiUserAuthenticationApp(Application):
|
||||
self.tornado_application = web.Application(self.handlers, **self.tornado_settings)
|
||||
|
||||
def initialize(self, *args, **kwargs):
|
||||
super(MultiUserAuthenticationApp, self).initialize(*args, **kwargs)
|
||||
self.init_user_manager()
|
||||
super(MultiUserApp, self).initialize(*args, **kwargs)
|
||||
self.init_db()
|
||||
self.init_hub()
|
||||
self.init_proxy()
|
||||
self.init_handlers()
|
||||
self.init_tornado_settings()
|
||||
self.init_tornado_application()
|
||||
|
||||
def start(self):
|
||||
self.start_proxy()
|
||||
http_server = tornado.httpserver.HTTPServer(self.tornado_application)
|
||||
http_server.listen(self.multiuser_port)
|
||||
http_server.listen(self.hub_port)
|
||||
try:
|
||||
tornado.ioloop.IOLoop.instance().start()
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted")
|
||||
finally:
|
||||
self.proxy.terminate()
|
||||
self.user_manager.cleanup()
|
||||
pass
|
||||
# self.proxy.terminate()
|
||||
# self.user_manager.cleanup()
|
||||
|
||||
main = MultiUserAuthenticationApp.launch_instance
|
||||
main = MultiUserApp.launch_instance
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
213
multiuser/db.py
Normal file
213
multiuser/db.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""sqlalchemy ORM tools for the user database"""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.types import TypeDecorator, VARCHAR
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, ForeignKey, Unicode, Binary,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||
|
||||
from sqlalchemy.orm import sessionmaker, relationship, backref
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from IPython.utils.py3compat import str_to_unicode
|
||||
|
||||
from .utils import random_port
|
||||
|
||||
|
||||
def new_token(*args, **kwargs):
|
||||
"""generator for new random tokens
|
||||
|
||||
For now, just UUIDs.
|
||||
"""
|
||||
return str_to_unicode(uuid.uuid4().hex)
|
||||
|
||||
|
||||
class JSONDict(TypeDecorator):
|
||||
"""Represents an immutable structure as a json-encoded string.
|
||||
|
||||
Usage::
|
||||
|
||||
JSONEncodedDict(255)
|
||||
|
||||
"""
|
||||
|
||||
impl = VARCHAR
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is not None:
|
||||
value = json.dumps(value)
|
||||
|
||||
return value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is not None:
|
||||
value = json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Server(Base):
|
||||
__tablename__ = 'servers'
|
||||
id = Column(Integer, primary_key=True)
|
||||
proto = Column(Unicode, default=u'http')
|
||||
ip = Column(Unicode, default=u'localhost')
|
||||
port = Column(Integer, default=random_port)
|
||||
cookie_secret = Column(Binary)
|
||||
cookie_name = Column(Unicode)
|
||||
base_url = Column(Unicode, default=u'/')
|
||||
|
||||
def __repr__(self):
|
||||
return "<Server(%s:%s)>" % (self.ip, self.port)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return "{proto}://{ip}:{port}{url}".format(
|
||||
proto=self.proto,
|
||||
ip=self.ip,
|
||||
port=self.port,
|
||||
url=self.base_url,
|
||||
)
|
||||
|
||||
|
||||
class Proxy(Base):
|
||||
"""A configurable-http-proxy instance"""
|
||||
__tablename__ = 'proxies'
|
||||
id = Column(Integer, primary_key=True)
|
||||
auth_token = Column(Unicode, default=new_token)
|
||||
_public_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
public_server = relationship(Server, primaryjoin=_public_server_id == Server.id)
|
||||
_api_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
api_server = relationship(Server, primaryjoin=_api_server_id == Server.id)
|
||||
|
||||
def __repr__(self):
|
||||
if self.public_server:
|
||||
return "<%s %s:%s>" % (
|
||||
self.__class__.__name__, self.public_server.ip, self.public_server.port,
|
||||
)
|
||||
else:
|
||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
||||
|
||||
|
||||
class Hub(Base):
|
||||
"""Bring it all together at the hub"""
|
||||
__tablename__ = 'hubs'
|
||||
id = Column(Integer, primary_key=True)
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
api_url = Column(Unicode, default=u'/hub/api/')
|
||||
|
||||
@property
|
||||
def api_host_url(self):
|
||||
"""return the full API url (with proto://host...)"""
|
||||
return "{proto}://{ip}:{port}{url}".format(
|
||||
proto=self.server.proto,
|
||||
ip=self.server.ip,
|
||||
port=self.server.port,
|
||||
url=self.api_url,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
if self.server:
|
||||
return "<%s %s:%s>" % (
|
||||
self.__class__.__name__, self.server.ip, self.server.port,
|
||||
)
|
||||
else:
|
||||
return "<%s [unconfigured]>" % self.__class__.__name__
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""The User table"""
|
||||
__tablename__ = 'users'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(Unicode)
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
|
||||
api_tokens = relationship("APIToken", backref="user")
|
||||
cookie_tokens = relationship("CookieToken", backref="user")
|
||||
state = Column(JSONDict)
|
||||
|
||||
def __repr__(self):
|
||||
if self.server:
|
||||
return "<{cls}({name}@{ip}:{port})>".format(
|
||||
cls=self.__class__.__name__,
|
||||
name=self.name,
|
||||
ip=self.server.ip,
|
||||
port=self.server.port,
|
||||
)
|
||||
else:
|
||||
return "<{cls}({name} [unconfigured])>".format(
|
||||
cls=self.__class__.__name__,
|
||||
name=self.name,
|
||||
)
|
||||
|
||||
def _new_token(self, cls):
|
||||
assert self.id is not None
|
||||
return cls(token=new_token(), user_id=self.id)
|
||||
|
||||
def new_api_token(self):
|
||||
"""Return a new API token"""
|
||||
return self._new_token(APIToken)
|
||||
|
||||
def new_cookie_token(self):
|
||||
"""Return a new cookie token"""
|
||||
return self._new_token(CookieToken)
|
||||
|
||||
|
||||
class Token(object):
|
||||
"""Mixin for token tables, since we have two"""
|
||||
token = Column(String, primary_key=True)
|
||||
@declared_attr
|
||||
def user_id(cls):
|
||||
return Column(Integer, ForeignKey('users.id'))
|
||||
|
||||
def __repr__(self):
|
||||
return "<{cls}('{t}', user='{u}')>".format(
|
||||
cls=self.__class__.__name__,
|
||||
t=self.token,
|
||||
u=self.user.name,
|
||||
)
|
||||
|
||||
|
||||
class APIToken(Token, Base):
|
||||
"""An API token"""
|
||||
__tablename__ = 'api_tokens'
|
||||
|
||||
|
||||
class CookieToken(Token, Base):
|
||||
"""A cookie token"""
|
||||
__tablename__ = 'cookie_tokens'
|
||||
|
||||
|
||||
def new_session(url="sqlite:///:memory:", **kwargs):
|
||||
"""Create a new session at url"""
|
||||
engine = create_engine(url, **kwargs)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
Base.metadata.create_all(engine)
|
||||
return session
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
engine = create_engine('sqlite:///:memory:', echo=True)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
hub = Hub()
|
||||
session.add(hub)
|
||||
session.commit()
|
||||
|
||||
minrk = User(name="minrk")
|
||||
session.add(minrk)
|
||||
session.commit()
|
||||
|
@@ -1,35 +1,61 @@
|
||||
"""HTTP Handlers for the multi-user server"""
|
||||
"""HTTP Handlers for the hub server"""
|
||||
|
||||
# Copyright (c) IPython Development Team.
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from tornado.log import app_log
|
||||
from tornado.escape import url_escape
|
||||
from tornado.httputil import url_concat
|
||||
from tornado.web import RequestHandler
|
||||
from tornado import web
|
||||
|
||||
from IPython.html.utils import url_path_join
|
||||
|
||||
from . import db
|
||||
from .spawner import PopenSpawner
|
||||
from .utils import random_port, wait_for_server
|
||||
|
||||
|
||||
class BaseHandler(RequestHandler):
|
||||
"""Base Handler class with access to common methods and properties."""
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
"""I can't seem to avoid typing self.log"""
|
||||
return app_log
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return self.settings.get('config', None)
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self.settings['db']
|
||||
|
||||
@property
|
||||
def hub(self):
|
||||
return self.settings['hub']
|
||||
|
||||
@property
|
||||
def cookie_name(self):
|
||||
return self.settings.get('cookie_name', 'cookie')
|
||||
|
||||
@property
|
||||
def multiuser_url(self):
|
||||
return self.settings.get('multiuser_url', '')
|
||||
def hub_url(self):
|
||||
return self.settings.get('hub_url', '')
|
||||
|
||||
@property
|
||||
def multiuser_prefix(self):
|
||||
return self.settings.get('multiuser_prefix', '/multiuser/')
|
||||
def hub_prefix(self):
|
||||
return self.settings.get('hub_prefix', '/hub/')
|
||||
|
||||
def get_current_user(self):
|
||||
if 'get_current_user' in self.settings:
|
||||
return self.settings['get_current_user']
|
||||
return self.settings['get_current_user']()
|
||||
|
||||
token = self.get_cookie(self.cookie_name, '')
|
||||
if token:
|
||||
@@ -41,21 +67,27 @@ class BaseHandler(RequestHandler):
|
||||
def base_url(self):
|
||||
return self.settings.setdefault('base_url', '/')
|
||||
|
||||
@property
|
||||
def user_manager(self):
|
||||
return self.settings['user_manager']
|
||||
|
||||
def clear_login_cookie(self):
|
||||
self.clear_cookie(self.cookie_name)
|
||||
|
||||
def spawn_single_user(self, user):
|
||||
session = self.user_manager.get_session(user,
|
||||
cookie_secret=self.settings['cookie_secret'],
|
||||
multiuser_api_url=self.settings['multiuser_api_url'],
|
||||
multiuser_prefix=self.settings['multiuser_prefix'],
|
||||
)
|
||||
self.user_manager.spawn(user)
|
||||
return session
|
||||
@property
|
||||
def spawner_class(self):
|
||||
return self.settings.get('spawner_class', PopenSpawner)
|
||||
|
||||
# def spawn_single_user(self, user):
|
||||
# spawner = self.spawner_class(
|
||||
# user=user,
|
||||
# cookie_secret=self.settings['cookie_secret'],
|
||||
# hub_api_url=self.settings['hub_api_url'],
|
||||
# hub_prefix=self.settings['hub_prefix'],
|
||||
# )
|
||||
# session = self.user_manager.get_session(user,
|
||||
# cookie_secret=self.settings['cookie_secret'],
|
||||
# hub_api_url=self.settings['hub_api_url'],
|
||||
# hub_prefix=self.settings['hub_prefix'],
|
||||
# )
|
||||
# self.user_manager.spawn(user)
|
||||
# return session
|
||||
|
||||
|
||||
class RootHandler(BaseHandler):
|
||||
@@ -72,7 +104,7 @@ class UserHandler(BaseHandler):
|
||||
"""
|
||||
@web.authenticated
|
||||
def get(self, user):
|
||||
self.log.debug("multi-user at single-user url: %s", user)
|
||||
self.log.debug("hub at single-user url: %s", user)
|
||||
if self.get_current_user() == user:
|
||||
self.spawn_single_user(user)
|
||||
self.redirect('')
|
||||
@@ -105,19 +137,83 @@ class LoginHandler(BaseHandler):
|
||||
else:
|
||||
user = self.get_argument('user', default='')
|
||||
self._render(user=user)
|
||||
|
||||
|
||||
def notify_proxy(self, user):
|
||||
proxy = self.db.query(db.Proxy).first()
|
||||
r = requests.post(
|
||||
url_path_join(
|
||||
proxy.api_server.url,
|
||||
user.server.base_url,
|
||||
),
|
||||
data=json.dumps(dict(
|
||||
target=user.server.url,
|
||||
user=user.name,
|
||||
)),
|
||||
headers={'Authorization': "token %s" % proxy.auth_token},
|
||||
)
|
||||
wait_for_server(user.server.ip, user.server.port)
|
||||
r.raise_for_status()
|
||||
|
||||
def spawn_single_user(self, name):
|
||||
user = db.User(name=name,
|
||||
server=db.Server(
|
||||
cookie_name='%s-%s' % (self.hub.server.cookie_name, name),
|
||||
cookie_secret=self.hub.server.cookie_secret,
|
||||
base_url=url_path_join(self.base_url, 'user', name),
|
||||
),
|
||||
)
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
|
||||
api_token = user.new_api_token()
|
||||
self.db.add(api_token)
|
||||
self.db.commit()
|
||||
|
||||
spawner = self.spawner_class(
|
||||
config=self.config,
|
||||
user=user,
|
||||
hub=self.hub,
|
||||
api_token=api_token.token,
|
||||
)
|
||||
spawner.start()
|
||||
|
||||
# store state
|
||||
user.state = spawner.get_state()
|
||||
self.db.commit()
|
||||
|
||||
self.notify_proxy(user)
|
||||
return user
|
||||
|
||||
def post(self):
|
||||
user = self.get_argument('user', default='')
|
||||
name = self.get_argument('user', default='')
|
||||
pwd = self.get_argument('password', default=u'')
|
||||
next_url = self.get_argument('next', default='') or '/user/%s/' % user
|
||||
if user and pwd == 'password':
|
||||
if user not in self.user_manager.users:
|
||||
session = self.spawn_single_user(user)
|
||||
else:
|
||||
session = self.user_manager.users[user]
|
||||
cookie_token = session.cookie_token
|
||||
self.set_cookie(session.cookie_name, cookie_token, path=session.url_prefix)
|
||||
self.set_cookie(self.cookie_name, cookie_token, path=self.base_url)
|
||||
next_url = self.get_argument('next', default='') or '/user/%s/' % name
|
||||
if name and pwd == 'password':
|
||||
import IPython
|
||||
# IPython.embed()
|
||||
user = self.db.query(db.User).filter(db.User.name == name).first()
|
||||
if user is None:
|
||||
user = self.spawn_single_user(name)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# 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)
|
||||
else:
|
||||
self._render(
|
||||
message={'error': 'Invalid username or password'},
|
||||
@@ -142,10 +238,10 @@ def token_authorized(method):
|
||||
if not match:
|
||||
raise web.HTTPError(403)
|
||||
token = match.group(1)
|
||||
session = self.user_manager.user_for_api_token(token)
|
||||
if session is None:
|
||||
db_token = self.db.query(db.APIToken).filter(db.APIToken.token == token).first()
|
||||
self.log.info("Token: %s: %s", token, db_token)
|
||||
if db_token is None:
|
||||
raise web.HTTPError(403)
|
||||
self.request_session = session
|
||||
return method(self, *args, **kwargs)
|
||||
check_token.__name__ = method.__name__
|
||||
check_token.__doc__ = method.__doc__
|
||||
@@ -155,12 +251,14 @@ def token_authorized(method):
|
||||
class AuthorizationsHandler(BaseHandler):
|
||||
@token_authorized
|
||||
def get(self, token):
|
||||
session = self.user_manager.user_for_cookie_token(token)
|
||||
if session is None:
|
||||
app_log.debug('cookie tokens: %r',
|
||||
{ user:s.cookie_token for user,s in self.user_manager.users.items() }
|
||||
)
|
||||
db_token = self.db.query(db.CookieToken).filter(db.CookieToken.token == token).first()
|
||||
import IPython
|
||||
IPython.embed()
|
||||
if db_token is None:
|
||||
# app_log.debug('cookie tokens: %r',
|
||||
# { user:s.cookie_token for user,s in self.user_manager.users.items() }
|
||||
# )
|
||||
raise web.HTTPError(404)
|
||||
self.write(json.dumps({
|
||||
'user' : session.user,
|
||||
'user' : db_token.user.name,
|
||||
}))
|
||||
|
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
"""Extend regular notebook server to be aware of multi-user things."""
|
||||
"""Extend regular notebook server to be aware of multiuser things."""
|
||||
|
||||
import os
|
||||
|
||||
@@ -24,12 +24,12 @@ def verify_token(self, token):
|
||||
# we've seen this token before, don't ask upstream again
|
||||
return token_cache[token]
|
||||
|
||||
multiuser_api_url = self.settings['multiuser_api_url']
|
||||
multiuser_api_key = self.settings['multiuser_api_key']
|
||||
hub_api_url = self.settings['hub_api_url']
|
||||
hub_api_key = self.settings['hub_api_key']
|
||||
r = requests.get(utils.url_path_join(
|
||||
multiuser_api_url, "authorizations", token,
|
||||
hub_api_url, "authorizations", token,
|
||||
),
|
||||
headers = {'Authorization' : 'token %s' % multiuser_api_key}
|
||||
headers = {'Authorization' : 'token %s' % hub_api_key}
|
||||
)
|
||||
if r.status_code == 404:
|
||||
data = {'user' : ''}
|
||||
@@ -53,48 +53,52 @@ def get_current_user(self):
|
||||
if user == my_user:
|
||||
return user
|
||||
else:
|
||||
raise web.HTTPError(403, "User %s does not have access to %s" % (user, my_user))
|
||||
# import IPython
|
||||
# IPython.embed()
|
||||
return None
|
||||
# imoprt
|
||||
# raise web.HTTPError(403, "User %s does not have access to %s" % (user, my_user))
|
||||
else:
|
||||
self.log.debug("No token cookie")
|
||||
return None
|
||||
|
||||
|
||||
# register new multi-user related command-line aliases
|
||||
# register new hub related command-line aliases
|
||||
aliases = NotebookApp.aliases.get_default_value()
|
||||
aliases.update({
|
||||
'user' : 'SingleUserNotebookApp.user',
|
||||
'cookie-name': 'SingleUserNotebookApp.cookie_name',
|
||||
'multiuser-prefix': 'SingleUserNotebookApp.multiuser_prefix',
|
||||
'multiuser-api-url': 'SingleUserNotebookApp.multiuser_api_url',
|
||||
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
|
||||
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
|
||||
'base-url': 'SingleUserNotebookApp.base_url',
|
||||
})
|
||||
|
||||
|
||||
class SingleUserNotebookApp(NotebookApp):
|
||||
"""A Subclass of the regular NotebookApp that is aware of the parent multi-user context."""
|
||||
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""
|
||||
user = Unicode(config=True)
|
||||
cookie_name = Unicode(config=True)
|
||||
multiuser_prefix = Unicode(config=True)
|
||||
multiuser_api_url = Unicode(config=True)
|
||||
hub_prefix = Unicode(config=True)
|
||||
hub_api_url = Unicode(config=True)
|
||||
aliases = aliases
|
||||
browser = False
|
||||
|
||||
def init_webapp(self):
|
||||
# monkeypatch authentication to use the multi-user
|
||||
# monkeypatch authentication to use the hub
|
||||
from IPython.html.base.handlers import AuthenticatedHandler
|
||||
AuthenticatedHandler.verify_token = verify_token
|
||||
AuthenticatedHandler.get_current_user = get_current_user
|
||||
|
||||
# load the multi-user related settings into the tornado settings dict
|
||||
# load the hub related settings into the tornado settings dict
|
||||
env = os.environ
|
||||
s = self.webapp_settings
|
||||
s['token_cache'] = {}
|
||||
s['user'] = self.user
|
||||
s['multiuser_api_key'] = env.get('IPY_API_TOKEN', '')
|
||||
s['hub_api_key'] = env.get('IPY_API_TOKEN', '')
|
||||
s['cookie_secret'] = env.get('IPY_COOKIE_SECRET', '')
|
||||
s['cookie_name'] = self.cookie_name
|
||||
s['login_url'] = utils.url_path_join(self.multiuser_prefix, 'login')
|
||||
s['multiuser_api_url'] = self.multiuser_api_url
|
||||
s['login_url'] = utils.url_path_join(self.hub_prefix, 'login')
|
||||
s['hub_api_url'] = self.hub_api_url
|
||||
super(SingleUserNotebookApp, self).init_webapp()
|
||||
|
||||
|
||||
|
185
multiuser/spawner.py
Normal file
185
multiuser/spawner.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Class for spawning single-user notebook servers."""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import errno
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from subprocess import Popen
|
||||
|
||||
from tornado import gen
|
||||
|
||||
from IPython.config import LoggingConfigurable
|
||||
from IPython.utils.traitlets import (
|
||||
Any, Dict, Instance, Integer, List, Unicode,
|
||||
)
|
||||
|
||||
|
||||
from .utils import random_port, wait_for_server
|
||||
|
||||
|
||||
class Spawner(LoggingConfigurable):
|
||||
"""Base class for spawning single-user notebook servers.
|
||||
|
||||
Subclass this, and override the following methods:
|
||||
|
||||
- load_state
|
||||
- get_state
|
||||
- start
|
||||
- stop
|
||||
- poll
|
||||
"""
|
||||
|
||||
user = Any()
|
||||
hub = Any()
|
||||
api_token = Unicode()
|
||||
|
||||
env_prefix = Unicode('IPY_')
|
||||
def _env_key(self, d, key, value):
|
||||
d['%s%s' % (self.env_prefix, key)] = value
|
||||
|
||||
env = Dict()
|
||||
def _env_default(self):
|
||||
env = os.environ.copy()
|
||||
self._env_key(env, 'COOKIE_SECRET', self.user.server.cookie_secret)
|
||||
self._env_key(env, 'API_TOKEN', self.api_token)
|
||||
return env
|
||||
|
||||
@classmethod
|
||||
def fromJSON(cls, state, **kwargs):
|
||||
"""Create a new instance, and load its JSON state
|
||||
|
||||
state will be a dict, loaded from JSON in the database.
|
||||
"""
|
||||
inst = cls(**kwargs)
|
||||
inst.load_state(state)
|
||||
return inst
|
||||
|
||||
def load_state(self, state):
|
||||
"""load state from the database
|
||||
|
||||
This is the extensible part of state
|
||||
|
||||
Override in a subclass if there is state to load.
|
||||
|
||||
See Also
|
||||
--------
|
||||
|
||||
get_state
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_state(self):
|
||||
"""store the state necessary for load_state
|
||||
|
||||
A black box of extra state for custom spawners
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
||||
state: dict
|
||||
a JSONable dict of state
|
||||
"""
|
||||
return {}
|
||||
|
||||
def get_args(self):
|
||||
"""Return the arguments to be passed after self.cmd"""
|
||||
return [
|
||||
'--user=%s' % self.user.name,
|
||||
'--port=%i' % self.user.server.port,
|
||||
'--cookie-name=%s' % self.user.server.cookie_name,
|
||||
'--base-url=%s' % self.user.server.base_url,
|
||||
|
||||
'--hub-prefix=%s' % self.hub.server.base_url,
|
||||
'--hub-api-url=%s' % self.hub.api_host_url,
|
||||
]
|
||||
|
||||
def start(self):
|
||||
raise NotImplementedError("Override in subclass")
|
||||
|
||||
def stop(self):
|
||||
raise NotImplementedError("Override in subclass")
|
||||
|
||||
def poll(self):
|
||||
raise NotImplementedError("Override in subclass")
|
||||
|
||||
|
||||
class PopenSpawner(Spawner):
|
||||
"""A Spawner that just uses Popen to start local processes."""
|
||||
cmd = List(Unicode, config=True,
|
||||
help="""The command used for starting notebooks."""
|
||||
)
|
||||
def _cmd_default(self):
|
||||
# should have sudo -u self.user
|
||||
return [sys.executable, '-m', 'multiuser.singleuser']
|
||||
|
||||
proc = Instance(Popen)
|
||||
pid = Integer()
|
||||
|
||||
def load_state(self, state):
|
||||
self.pid = state['pid']
|
||||
|
||||
def get_state(self):
|
||||
return dict(pid=self.pid)
|
||||
|
||||
def start(self):
|
||||
self.user.server.port = random_port()
|
||||
cmd = self.cmd + self.get_args()
|
||||
|
||||
self.log.info("Spawning %r", cmd)
|
||||
self.proc = Popen(cmd, env=self.env,
|
||||
# don't forward signals
|
||||
preexec_fn=os.setpgrp,
|
||||
)
|
||||
self.pid = self.proc.pid
|
||||
|
||||
def poll(self):
|
||||
# if we started the process, poll with Popen
|
||||
if self.proc is not None:
|
||||
return self.proc.poll()
|
||||
|
||||
# if we resumed from stored state,
|
||||
# we don't have the Popen handle anymore
|
||||
|
||||
# this doesn't work on Windows.
|
||||
# multi-user doesn't support Windows.
|
||||
try:
|
||||
os.kill(self.pid, 0)
|
||||
except OSError as e:
|
||||
if e.errno == errno.ESRCH:
|
||||
# no such process, return exitcode == 0, since we don't know
|
||||
return 0
|
||||
else:
|
||||
# None indicates the process is running
|
||||
return None
|
||||
|
||||
def _wait_for_death(self, timeout=10):
|
||||
"""wait for the process to die, up to timeout seconds"""
|
||||
for i in range(int(timeout * 10)):
|
||||
if self.poll() is None:
|
||||
break
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
|
||||
def stop(self, now=False):
|
||||
"""stop the subprocess"""
|
||||
if not now:
|
||||
# double-sigint to request clean shutdown
|
||||
os.kill(self.pid, signal.SIGINT)
|
||||
os.kill(self.pid, signal.SIGINT)
|
||||
self._wait_for_death(10)
|
||||
|
||||
# clean shutdown failed, use TERM
|
||||
if self.poll() is None:
|
||||
os.kill(self.pid, signal.SIGTERM)
|
||||
self._wait_for_death(5)
|
||||
|
||||
# TERM failed, use KILL
|
||||
if self.poll() is None:
|
||||
os.kill(self.pid, signal.SIGKILL)
|
||||
self._wait_for_death(5)
|
||||
|
||||
# it all failed, zombie process
|
@@ -1,180 +0,0 @@
|
||||
"""Classes for managing users."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from subprocess import Popen
|
||||
|
||||
import requests
|
||||
|
||||
from tornado.log import app_log
|
||||
|
||||
from IPython.utils.traitlets import Any, Unicode, Integer, Dict
|
||||
from IPython.config import LoggingConfigurable
|
||||
|
||||
from .utils import random_port, wait_for_server
|
||||
|
||||
class UserSession(LoggingConfigurable):
|
||||
env_prefix = Unicode('IPY_')
|
||||
process = Any()
|
||||
port = Integer()
|
||||
user = Unicode()
|
||||
cookie_secret = Unicode()
|
||||
cookie_name = Unicode()
|
||||
def _cookie_name_default(self):
|
||||
return 'ipy-multiuser-%s' % self.user
|
||||
|
||||
multiuser_prefix = Unicode()
|
||||
multiuser_api_url = Unicode()
|
||||
|
||||
url_prefix = Unicode()
|
||||
def _url_prefix_default(self):
|
||||
return '/user/%s/' % self.user
|
||||
|
||||
api_token = Unicode()
|
||||
def _api_token_default(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
cookie_token = Unicode()
|
||||
def _cookie_token_default(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def _env_key(self, d, key, value):
|
||||
d['%s%s' % (self.env_prefix, key)] = value
|
||||
|
||||
env = Dict()
|
||||
def _env_default(self):
|
||||
env = os.environ.copy()
|
||||
self._env_key(env, 'COOKIE_SECRET', self.cookie_secret)
|
||||
self._env_key(env, 'API_TOKEN', self.api_token)
|
||||
return env
|
||||
|
||||
@property
|
||||
def auth_data(self):
|
||||
return dict(
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
def start(self):
|
||||
assert self.process is None or self.process.poll() is not None
|
||||
cmd = [sys.executable, '-m', 'multiuser.singleuser',
|
||||
'--user=%s' % self.user, '--port=%i' % self.port,
|
||||
'--cookie-name=%s' % self.cookie_name,
|
||||
'--multiuser-prefix=%s' % self.multiuser_prefix,
|
||||
'--multiuser-api-url=%s' % self.multiuser_api_url,
|
||||
'--base-url=%s' % self.url_prefix,
|
||||
]
|
||||
app_log.info("Spawning: %s" % cmd)
|
||||
self.process = Popen(cmd, env=self.env,
|
||||
# don't forward signals:
|
||||
preexec_fn=os.setpgrp,
|
||||
)
|
||||
|
||||
def running(self):
|
||||
if self.process is None:
|
||||
return False
|
||||
if self.process.poll() is not None:
|
||||
self.process = None
|
||||
return False
|
||||
return True
|
||||
|
||||
def request_stop(self):
|
||||
if self.running():
|
||||
self.process.send_signal(signal.SIGINT)
|
||||
time.sleep(0.1)
|
||||
if self.running():
|
||||
self.process.send_signal(signal.SIGINT)
|
||||
|
||||
def stop(self):
|
||||
for i in range(100):
|
||||
if self.running():
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
break
|
||||
if self.running():
|
||||
self.process.terminate()
|
||||
|
||||
|
||||
class UserManager(LoggingConfigurable):
|
||||
|
||||
users = Dict()
|
||||
routes_t = Unicode('http://{ip}:{port}/api/routes{uri}')
|
||||
single_user_t = Unicode('http://{ip}:{port}')
|
||||
|
||||
single_user_ip = Unicode('localhost')
|
||||
proxy_ip = Unicode('localhost')
|
||||
proxy_port = Integer(8001)
|
||||
|
||||
proxy_auth_token = Unicode()
|
||||
def _proxy_auth_token_default(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def get_session(self, user, **kwargs):
|
||||
if user not in self.users:
|
||||
kwargs['user'] = user
|
||||
self.users[user] = UserSession(**kwargs)
|
||||
return self.users[user]
|
||||
|
||||
def spawn(self, user):
|
||||
session = self.get_session(user)
|
||||
if session.running():
|
||||
app_log.warn("User session %s already running", user)
|
||||
return
|
||||
session.port = port = random_port()
|
||||
session.start()
|
||||
|
||||
r = requests.post(
|
||||
self.routes_t.format(
|
||||
ip=self.proxy_ip,
|
||||
port=self.proxy_port,
|
||||
uri=session.url_prefix,
|
||||
),
|
||||
data=json.dumps(dict(
|
||||
target=self.single_user_t.format(
|
||||
ip=self.single_user_ip,
|
||||
port=port
|
||||
),
|
||||
user=user,
|
||||
)),
|
||||
headers={'Authorization': "token %s" % self.proxy_auth_token},
|
||||
)
|
||||
wait_for_server(self.single_user_ip, port)
|
||||
r.raise_for_status()
|
||||
|
||||
def user_for_api_token(self, token):
|
||||
"""Get the user session object for a given API token"""
|
||||
for session in self.users.values():
|
||||
if session.api_token == token:
|
||||
return session
|
||||
|
||||
def user_for_cookie_token(self, token):
|
||||
"""Get the user session object for a given cookie token"""
|
||||
for session in self.users.values():
|
||||
if session.cookie_token == token:
|
||||
return session
|
||||
|
||||
def shutdown(self, user):
|
||||
assert user in self.users
|
||||
session = self.users.pop(user)
|
||||
session.stop()
|
||||
r = requests.delete(self.routes_url,
|
||||
data=json.dumps(user=user, port=session.port),
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
def cleanup(self):
|
||||
sessions = list(self.users.values())
|
||||
self.users = {}
|
||||
for session in sessions:
|
||||
self.log.info("Cleaning up %s's server" % session.user)
|
||||
session.request_stop()
|
||||
for i in range(100):
|
||||
if any([ session.running() for session in sessions ]):
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
break
|
||||
for session in sessions:
|
||||
session.stop()
|
@@ -1,11 +1,13 @@
|
||||
"""Miscellaneous utilities"""
|
||||
|
||||
# Copyright (c) IPython Development Team.
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import socket
|
||||
import time
|
||||
|
||||
|
||||
|
||||
def random_port():
|
||||
"""get a single random port"""
|
||||
sock = socket.socket()
|
||||
@@ -14,6 +16,7 @@ def random_port():
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
def wait_for_server(ip, port, timeout=10):
|
||||
"""wait for a server to show up at ip:port"""
|
||||
tic = time.time()
|
||||
|
Reference in New Issue
Block a user