mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-10 19:43:01 +00:00
Merge pull request #81 from minrk/crypto-db
hash tokens in database closes #80 closes #83
This commit is contained in:
@@ -15,7 +15,7 @@ from .base import APIHandler
|
|||||||
class TokenAPIHandler(APIHandler):
|
class TokenAPIHandler(APIHandler):
|
||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, token):
|
def get(self, token):
|
||||||
orm_token = self.db.query(orm.APIToken).filter(orm.APIToken.token == token).first()
|
orm_token = orm.APIToken.find(self.db, token)
|
||||||
if orm_token is None:
|
if orm_token is None:
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
self.write(json.dumps({
|
self.write(json.dumps({
|
||||||
@@ -26,15 +26,11 @@ class CookieAPIHandler(APIHandler):
|
|||||||
@token_authenticated
|
@token_authenticated
|
||||||
def get(self, cookie_name):
|
def get(self, cookie_name):
|
||||||
cookie_value = self.request.body
|
cookie_value = self.request.body
|
||||||
btoken = self.get_secure_cookie(cookie_name, cookie_value)
|
user = self._user_for_cookie(cookie_name, cookie_value)
|
||||||
if not btoken:
|
if user is None:
|
||||||
raise web.HTTPError(404)
|
|
||||||
token = btoken.decode('utf8', 'replace')
|
|
||||||
orm_token = self.db.query(orm.CookieToken).filter(orm.CookieToken.token == token).first()
|
|
||||||
if orm_token is None:
|
|
||||||
raise web.HTTPError(404)
|
raise web.HTTPError(404)
|
||||||
self.write(json.dumps({
|
self.write(json.dumps({
|
||||||
'user' : orm_token.user.name,
|
'user' : user.name,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
|
@@ -4,9 +4,11 @@
|
|||||||
# Copyright (c) IPython Development Team.
|
# Copyright (c) IPython Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
import binascii
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
|
|
||||||
@@ -16,19 +18,21 @@ except NameError:
|
|||||||
# py3
|
# py3
|
||||||
raw_input = input
|
raw_input = input
|
||||||
|
|
||||||
|
from six import text_type
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
import tornado.httpserver
|
import tornado.httpserver
|
||||||
import tornado.options
|
import tornado.options
|
||||||
|
from tornado.httpclient import HTTPError
|
||||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
from tornado.ioloop import IOLoop, PeriodicCallback
|
||||||
from tornado.log import LogFormatter, app_log, access_log, gen_log
|
from tornado.log import LogFormatter, app_log, access_log, gen_log
|
||||||
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, Bool, Any,
|
Unicode, Integer, Dict, TraitError, List, Bool, Any,
|
||||||
Type, Set, Instance,
|
Type, Set, Instance, Bytes,
|
||||||
)
|
)
|
||||||
from IPython.config import Application, catch_config_error
|
from IPython.config import Application, catch_config_error
|
||||||
|
|
||||||
@@ -39,7 +43,7 @@ from . import handlers, apihandlers
|
|||||||
from . import orm
|
from . import orm
|
||||||
from ._data import DATA_FILES_PATH
|
from ._data import DATA_FILES_PATH
|
||||||
from .utils import (
|
from .utils import (
|
||||||
url_path_join, random_hex, TimeoutError,
|
url_path_join, TimeoutError,
|
||||||
ISO8601_ms, ISO8601_s, getuser_unicode,
|
ISO8601_ms, ISO8601_s, getuser_unicode,
|
||||||
)
|
)
|
||||||
# classes for config
|
# classes for config
|
||||||
@@ -69,6 +73,8 @@ flags = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SECRET_BYTES = 2048 # the number of bytes to use when generating new secrets
|
||||||
|
|
||||||
|
|
||||||
class JupyterHubApp(Application):
|
class JupyterHubApp(Application):
|
||||||
"""An Application for starting a Multi-User Jupyter Notebook server."""
|
"""An Application for starting a Multi-User Jupyter Notebook server."""
|
||||||
@@ -166,7 +172,16 @@ class JupyterHubApp(Application):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
def _proxy_auth_token_default(self):
|
def _proxy_auth_token_default(self):
|
||||||
return os.environ.get('CONFIGPROXY_AUTH_TOKEN', orm.new_token())
|
token = os.environ.get('CONFIGPROXY_AUTH_TOKEN', None)
|
||||||
|
if not token:
|
||||||
|
self.log.warn('\n'.join([
|
||||||
|
"",
|
||||||
|
"Generating CONFIGPROXY_AUTH_TOKEN. Restarting the Hub will require restarting the proxy.",
|
||||||
|
"Set CONFIGPROXY_AUTH_TOKEN env or JupyterHubApp.proxy_auth_token config to avoid this message.",
|
||||||
|
"",
|
||||||
|
]))
|
||||||
|
token = orm.new_token()
|
||||||
|
return token
|
||||||
|
|
||||||
proxy_api_ip = Unicode('localhost', config=True,
|
proxy_api_ip = Unicode('localhost', config=True,
|
||||||
help="The ip for the proxy API handlers"
|
help="The ip for the proxy API handlers"
|
||||||
@@ -203,14 +218,16 @@ class JupyterHubApp(Application):
|
|||||||
if newnew != new:
|
if newnew != new:
|
||||||
self.hub_prefix = newnew
|
self.hub_prefix = newnew
|
||||||
|
|
||||||
cookie_secret = Unicode(config=True,
|
cookie_secret = Bytes(config=True, env='JPY_COOKIE_SECRET',
|
||||||
help="""The cookie secret to use to encrypt cookies.
|
help="""The cookie secret to use to encrypt cookies.
|
||||||
|
|
||||||
Loaded from the JPY_COOKIE_SECRET env variable by default.
|
Loaded from the JPY_COOKIE_SECRET env variable by default.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
def _cookie_secret_default(self):
|
|
||||||
return os.environ.get('JPY_COOKIE_SECRET', random_hex(64))
|
cookie_secret_file = Unicode('jupyterhub_cookie_secret', config=True,
|
||||||
|
help="""File in which to store the cookie secret."""
|
||||||
|
)
|
||||||
|
|
||||||
authenticator_class = Type(PAMAuthenticator, Authenticator,
|
authenticator_class = Type(PAMAuthenticator, Authenticator,
|
||||||
config=True,
|
config=True,
|
||||||
@@ -361,6 +378,51 @@ class JupyterHubApp(Application):
|
|||||||
if os.path.exists(path) and not os.access(path, os.W_OK):
|
if os.path.exists(path) and not os.access(path, os.W_OK):
|
||||||
self.log.error("%s cannot edit %s", user, path)
|
self.log.error("%s cannot edit %s", user, path)
|
||||||
|
|
||||||
|
def init_secrets(self):
|
||||||
|
trait_name = 'cookie_secret'
|
||||||
|
trait = self.traits()[trait_name]
|
||||||
|
env_name = trait.get_metadata('env')
|
||||||
|
secret_file = os.path.abspath(
|
||||||
|
os.path.expanduser(self.cookie_secret_file)
|
||||||
|
)
|
||||||
|
secret = self.cookie_secret
|
||||||
|
secret_from = 'config'
|
||||||
|
# load priority: 1. config, 2. env, 3. file
|
||||||
|
if not secret and os.environ.get(env_name):
|
||||||
|
secret_from = 'env'
|
||||||
|
self.log.info("Loading %s from env[%s]", trait_name, env_name)
|
||||||
|
secret = binascii.a2b_hex(os.environ[env_name])
|
||||||
|
if not secret and os.path.exists(secret_file):
|
||||||
|
secret_from = 'file'
|
||||||
|
perm = os.stat(secret_file).st_mode
|
||||||
|
if perm & 0o077:
|
||||||
|
self.log.error("Bad permissions on %s", secret_file)
|
||||||
|
else:
|
||||||
|
self.log.info("Loading %s from %s", trait_name, secret_file)
|
||||||
|
with io.open(secret_file) as f:
|
||||||
|
b64_secret = f.read()
|
||||||
|
try:
|
||||||
|
secret = binascii.a2b_base64(b64_secret)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error("%s does not contain b64 key: %s", secret_file, e)
|
||||||
|
if not secret:
|
||||||
|
secret_from = 'new'
|
||||||
|
self.log.debug("Generating new %s", trait_name)
|
||||||
|
secret = os.urandom(SECRET_BYTES)
|
||||||
|
|
||||||
|
if secret_file and secret_from == 'new':
|
||||||
|
# if we generated a new secret, store it in the secret_file
|
||||||
|
self.log.info("Writing %s to %s", trait_name, secret_file)
|
||||||
|
b64_secret = text_type(binascii.b2a_base64(secret))
|
||||||
|
with io.open(secret_file, 'w', encoding='utf8') as f:
|
||||||
|
f.write(b64_secret)
|
||||||
|
try:
|
||||||
|
os.chmod(secret_file, 0o600)
|
||||||
|
except OSError:
|
||||||
|
self.log.warn("Failed to set permissions on %s", secret_file)
|
||||||
|
# store the loaded trait value
|
||||||
|
self.cookie_secret = secret
|
||||||
|
|
||||||
def init_db(self):
|
def init_db(self):
|
||||||
"""Create the database connection"""
|
"""Create the database connection"""
|
||||||
self.log.debug("Connecting to db: %s", self.db_url)
|
self.log.debug("Connecting to db: %s", self.db_url)
|
||||||
@@ -388,7 +450,6 @@ class JupyterHubApp(Application):
|
|||||||
ip=self.hub_ip,
|
ip=self.hub_ip,
|
||||||
port=self.hub_port,
|
port=self.hub_port,
|
||||||
base_url=self.hub_prefix,
|
base_url=self.hub_prefix,
|
||||||
cookie_secret=self.cookie_secret,
|
|
||||||
cookie_name=u'jupyter-hub-token',
|
cookie_name=u'jupyter-hub-token',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -506,28 +567,39 @@ class JupyterHubApp(Application):
|
|||||||
self.proxy = orm.Proxy(
|
self.proxy = orm.Proxy(
|
||||||
public_server=orm.Server(),
|
public_server=orm.Server(),
|
||||||
api_server=orm.Server(),
|
api_server=orm.Server(),
|
||||||
auth_token = self.proxy_auth_token,
|
|
||||||
)
|
)
|
||||||
self.db.add(self.proxy)
|
self.db.add(self.proxy)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
self.proxy.auth_token = self.proxy_auth_token # not persisted
|
||||||
self.proxy.log = self.log
|
self.proxy.log = self.log
|
||||||
self.proxy.public_server.ip = self.ip
|
self.proxy.public_server.ip = self.ip
|
||||||
self.proxy.public_server.port = self.port
|
self.proxy.public_server.port = self.port
|
||||||
self.proxy.api_server.ip = self.proxy_api_ip
|
self.proxy.api_server.ip = self.proxy_api_ip
|
||||||
self.proxy.api_server.port = self.proxy_api_port
|
self.proxy.api_server.port = self.proxy_api_port
|
||||||
self.proxy.api_server.base_url = u'/api/routes/'
|
self.proxy.api_server.base_url = u'/api/routes/'
|
||||||
if self.proxy.auth_token is None:
|
|
||||||
self.proxy.auth_token = self.proxy_auth_token
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def start_proxy(self):
|
def start_proxy(self):
|
||||||
"""Actually start the configurable-http-proxy"""
|
"""Actually start the configurable-http-proxy"""
|
||||||
if self.proxy.public_server.is_up() and \
|
# check for proxy
|
||||||
self.proxy.api_server.is_up():
|
if self.proxy.public_server.is_up() or self.proxy.api_server.is_up():
|
||||||
self.log.warn("Proxy already running at: %s", self.proxy.public_server.url)
|
# check for *authenticated* access to the proxy (auth token can change)
|
||||||
self.proxy_process = None
|
try:
|
||||||
return
|
yield self.proxy.get_routes()
|
||||||
|
except (HTTPError, OSError, socket.error) as e:
|
||||||
|
if isinstance(e, HTTPError) and e.code == 403:
|
||||||
|
msg = "Did CONFIGPROXY_AUTH_TOKEN change?"
|
||||||
|
else:
|
||||||
|
msg = "Is something else using %s?" % self.proxy.public_server.url
|
||||||
|
self.log.error("Proxy appears to be running at %s, but I can't access it (%s)\n%s",
|
||||||
|
self.proxy.public_server.url, e, msg)
|
||||||
|
self.exit(1)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.log.info("Proxy already running at: %s", self.proxy.public_server.url)
|
||||||
|
self.proxy_process = None
|
||||||
|
return
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token
|
env['CONFIGPROXY_AUTH_TOKEN'] = self.proxy.auth_token
|
||||||
@@ -545,7 +617,8 @@ class JupyterHubApp(Application):
|
|||||||
cmd.extend(['--ssl-key', self.ssl_key])
|
cmd.extend(['--ssl-key', self.ssl_key])
|
||||||
if self.ssl_cert:
|
if self.ssl_cert:
|
||||||
cmd.extend(['--ssl-cert', self.ssl_cert])
|
cmd.extend(['--ssl-cert', self.ssl_cert])
|
||||||
self.log.info("Starting proxy: %s", cmd)
|
self.log.info("Starting proxy @ %s", self.proxy.public_server.url)
|
||||||
|
self.log.debug("Proxy cmd: %s", cmd)
|
||||||
self.proxy_process = Popen(cmd, env=env)
|
self.proxy_process = Popen(cmd, env=env)
|
||||||
def _check():
|
def _check():
|
||||||
status = self.proxy_process.poll()
|
status = self.proxy_process.poll()
|
||||||
@@ -601,7 +674,7 @@ class JupyterHubApp(Application):
|
|||||||
authenticator=self.authenticator,
|
authenticator=self.authenticator,
|
||||||
spawner_class=self.spawner_class,
|
spawner_class=self.spawner_class,
|
||||||
base_url=self.base_url,
|
base_url=self.base_url,
|
||||||
cookie_secret=self.hub.server.cookie_secret,
|
cookie_secret=self.cookie_secret,
|
||||||
login_url=login_url,
|
login_url=login_url,
|
||||||
logout_url=logout_url,
|
logout_url=logout_url,
|
||||||
static_path=os.path.join(self.data_files_path, 'static'),
|
static_path=os.path.join(self.data_files_path, 'static'),
|
||||||
@@ -633,6 +706,7 @@ class JupyterHubApp(Application):
|
|||||||
self.init_logging()
|
self.init_logging()
|
||||||
self.write_pid_file()
|
self.write_pid_file()
|
||||||
self.init_ports()
|
self.init_ports()
|
||||||
|
self.init_secrets()
|
||||||
self.init_db()
|
self.init_db()
|
||||||
self.init_hub()
|
self.init_hub()
|
||||||
self.init_proxy()
|
self.init_proxy()
|
||||||
@@ -703,7 +777,7 @@ class JupyterHubApp(Application):
|
|||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def update_last_activity(self):
|
def update_last_activity(self):
|
||||||
"""Update User.last_activity timestamps from the proxy"""
|
"""Update User.last_activity timestamps from the proxy"""
|
||||||
routes = yield self.proxy.fetch_routes()
|
routes = yield self.proxy.get_routes()
|
||||||
for prefix, route in routes.items():
|
for prefix, route in routes.items():
|
||||||
if 'user' not in route:
|
if 'user' not in route:
|
||||||
# not a user route, ignore it
|
# not a user route, ignore it
|
||||||
|
@@ -85,21 +85,26 @@ class BaseHandler(RequestHandler):
|
|||||||
user = orm_token.user
|
user = orm_token.user
|
||||||
user.last_activity = datetime.utcnow()
|
user.last_activity = datetime.utcnow()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
def _user_for_cookie(self, cookie_name, cookie_value=None):
|
||||||
|
"""Get the User for a given cookie, if there is one"""
|
||||||
|
cookie_id = self.get_secure_cookie(cookie_name, cookie_value)
|
||||||
|
if cookie_id is None:
|
||||||
|
return
|
||||||
|
cookie_id = cookie_id.decode('utf8', 'replace')
|
||||||
|
return self.db.query(orm.User).filter(orm.User.cookie_id==cookie_id).first()
|
||||||
|
|
||||||
def get_current_user_cookie(self):
|
def get_current_user_cookie(self):
|
||||||
"""get_current_user from a cookie token"""
|
"""get_current_user from a cookie token"""
|
||||||
btoken = self.get_secure_cookie(self.hub.server.cookie_name)
|
user = self._user_for_cookie(self.hub.server.cookie_name)
|
||||||
if btoken:
|
if user:
|
||||||
token = btoken.decode('utf8', 'replace')
|
return user
|
||||||
cookie_token = orm.CookieToken.find(self.db, token)
|
else:
|
||||||
if cookie_token:
|
# don't log the token itself
|
||||||
return cookie_token.user
|
self.log.warn("Invalid cookie token")
|
||||||
else:
|
# have cookie, but it's not valid. Clear it and start over.
|
||||||
# don't log the token itself
|
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
||||||
self.log.warn("Invalid cookie token")
|
|
||||||
# have cookie, but it's not valid. Clear it and start over.
|
|
||||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
|
||||||
|
|
||||||
def get_current_user(self):
|
def get_current_user(self):
|
||||||
"""get current username"""
|
"""get current username"""
|
||||||
user = self.get_current_user_token()
|
user = self.get_current_user_token()
|
||||||
@@ -133,23 +138,17 @@ class BaseHandler(RequestHandler):
|
|||||||
"""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:
|
if user.server:
|
||||||
cookie_token = user.new_cookie_token()
|
|
||||||
self.db.add(cookie_token)
|
|
||||||
self.db.commit()
|
|
||||||
self.set_secure_cookie(
|
self.set_secure_cookie(
|
||||||
user.server.cookie_name,
|
user.server.cookie_name,
|
||||||
cookie_token.token,
|
user.cookie_id,
|
||||||
path=user.server.base_url,
|
path=user.server.base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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():
|
if not self.get_current_user_cookie():
|
||||||
cookie_token = user.new_cookie_token()
|
|
||||||
self.db.add(cookie_token)
|
|
||||||
self.db.commit()
|
|
||||||
self.set_secure_cookie(
|
self.set_secure_cookie(
|
||||||
self.hub.server.cookie_name,
|
self.hub.server.cookie_name,
|
||||||
cookie_token.token,
|
user.cookie_id,
|
||||||
path=self.hub.server.base_url)
|
path=self.hub.server.base_url)
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
|
@@ -7,9 +7,6 @@ from datetime import datetime
|
|||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
import uuid
|
|
||||||
|
|
||||||
from six import text_type
|
|
||||||
|
|
||||||
from tornado import gen
|
from tornado import gen
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
@@ -18,23 +15,19 @@ from tornado.httpclient import HTTPRequest, AsyncHTTPClient, HTTPError
|
|||||||
from sqlalchemy.types import TypeDecorator, VARCHAR
|
from sqlalchemy.types import TypeDecorator, VARCHAR
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
inspect,
|
inspect,
|
||||||
Column, Integer, String, ForeignKey, Unicode, Binary, Boolean,
|
Column, Integer, ForeignKey, Unicode, Boolean,
|
||||||
DateTime,
|
DateTime,
|
||||||
)
|
)
|
||||||
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
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlalchemy.sql.expression import bindparam
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
from .utils import random_port, url_path_join, wait_for_server, wait_for_http_server
|
from .utils import (
|
||||||
|
random_port, url_path_join, wait_for_server, wait_for_http_server,
|
||||||
|
new_token, hash_token, compare_token,
|
||||||
def new_token(*args, **kwargs):
|
)
|
||||||
"""generator for new random tokens
|
|
||||||
|
|
||||||
For now, just UUIDs.
|
|
||||||
"""
|
|
||||||
return text_type(uuid.uuid4().hex)
|
|
||||||
|
|
||||||
|
|
||||||
class JSONDict(TypeDecorator):
|
class JSONDict(TypeDecorator):
|
||||||
@@ -74,7 +67,6 @@ class Server(Base):
|
|||||||
ip = Column(Unicode, default=u'localhost')
|
ip = Column(Unicode, default=u'localhost')
|
||||||
port = Column(Integer, default=random_port)
|
port = Column(Integer, default=random_port)
|
||||||
base_url = Column(Unicode, default=u'/')
|
base_url = Column(Unicode, default=u'/')
|
||||||
cookie_secret = Column(Unicode, default=u'')
|
|
||||||
cookie_name = Column(Unicode, default=u'cookie')
|
cookie_name = Column(Unicode, default=u'cookie')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -124,7 +116,7 @@ class Proxy(Base):
|
|||||||
"""
|
"""
|
||||||
__tablename__ = 'proxies'
|
__tablename__ = 'proxies'
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
auth_token = Column(Unicode, default=new_token)
|
auth_token = None
|
||||||
_public_server_id = Column(Integer, ForeignKey('servers.id'))
|
_public_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||||
public_server = relationship(Server, primaryjoin=_public_server_id == Server.id)
|
public_server = relationship(Server, primaryjoin=_public_server_id == Server.id)
|
||||||
_api_server_id = Column(Integer, ForeignKey('servers.id'))
|
_api_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||||
@@ -196,7 +188,7 @@ class Proxy(Base):
|
|||||||
yield f
|
yield f
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def fetch_routes(self, client=None):
|
def get_routes(self, client=None):
|
||||||
"""Fetch the proxy's routes"""
|
"""Fetch the proxy's routes"""
|
||||||
resp = yield self.api_request('', client=client)
|
resp = yield self.api_request('', client=client)
|
||||||
raise gen.Return(json.loads(resp.body.decode('utf8', 'replace')))
|
raise gen.Return(json.loads(resp.body.decode('utf8', 'replace')))
|
||||||
@@ -236,9 +228,11 @@ class User(Base):
|
|||||||
and multiple tokens used for authorization.
|
and multiple tokens used for authorization.
|
||||||
|
|
||||||
API tokens grant access to the Hub's REST API.
|
API tokens grant access to the Hub's REST API.
|
||||||
These are used by single-user servers to authenticate requests.
|
These are used by single-user servers to authenticate requests,
|
||||||
|
and external services to manipulate the Hub.
|
||||||
|
|
||||||
Cookie tokens are used to authenticate browser sessions.
|
Cookies are set with a single ID.
|
||||||
|
Resetting the Cookie ID invalidates all cookies, forcing user to login again.
|
||||||
|
|
||||||
A `state` column contains a JSON dict,
|
A `state` column contains a JSON dict,
|
||||||
used for restoring state of a Spawner.
|
used for restoring state of a Spawner.
|
||||||
@@ -253,7 +247,7 @@ class User(Base):
|
|||||||
last_activity = Column(DateTime, default=datetime.utcnow)
|
last_activity = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
api_tokens = relationship("APIToken", backref="user")
|
api_tokens = relationship("APIToken", backref="user")
|
||||||
cookie_tokens = relationship("CookieToken", backref="user")
|
cookie_id = Column(Unicode, default=new_token)
|
||||||
state = Column(JSONDict)
|
state = Column(JSONDict)
|
||||||
spawner = None
|
spawner = None
|
||||||
|
|
||||||
@@ -271,18 +265,17 @@ class User(Base):
|
|||||||
name=self.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):
|
def new_api_token(self):
|
||||||
"""Return a new API token"""
|
"""Create a new API token"""
|
||||||
return self._new_token(APIToken)
|
assert self.id is not None
|
||||||
|
db = inspect(self).session
|
||||||
|
token = new_token()
|
||||||
|
orm_token = APIToken(user_id=self.id)
|
||||||
|
orm_token.token = token
|
||||||
|
db.add(orm_token)
|
||||||
|
db.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
def new_cookie_token(self):
|
|
||||||
"""Return a new cookie token"""
|
|
||||||
return self._new_token(CookieToken)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find(cls, db, name):
|
def find(cls, db, name):
|
||||||
"""Find a user by name.
|
"""Find a user by name.
|
||||||
@@ -305,7 +298,6 @@ class User(Base):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
api_token = self.new_api_token()
|
api_token = self.new_api_token()
|
||||||
db.add(api_token)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -316,7 +308,7 @@ class User(Base):
|
|||||||
)
|
)
|
||||||
# we are starting a new server, make sure it doesn't restore state
|
# we are starting a new server, make sure it doesn't restore state
|
||||||
spawner.clear_state()
|
spawner.clear_state()
|
||||||
spawner.api_token = api_token.token
|
spawner.api_token = api_token
|
||||||
|
|
||||||
yield spawner.start()
|
yield spawner.start()
|
||||||
spawner.start_polling()
|
spawner.start_polling()
|
||||||
@@ -348,17 +340,36 @@ class User(Base):
|
|||||||
inspect(self).session.commit()
|
inspect(self).session.commit()
|
||||||
|
|
||||||
|
|
||||||
class Token(object):
|
class APIToken(Base):
|
||||||
"""Mixin for token tables, since we have two"""
|
"""An API token"""
|
||||||
token = Column(String, primary_key=True)
|
__tablename__ = 'api_tokens'
|
||||||
|
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def user_id(cls):
|
def user_id(cls):
|
||||||
return Column(Integer, ForeignKey('users.id'))
|
return Column(Integer, ForeignKey('users.id'))
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
hashed = Column(Unicode)
|
||||||
|
prefix = Column(Unicode)
|
||||||
|
prefix_length = 4
|
||||||
|
algorithm = "sha512"
|
||||||
|
rounds = 16384
|
||||||
|
salt_bytes = 8
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token(self):
|
||||||
|
raise AttributeError("token is write-only")
|
||||||
|
|
||||||
|
@token.setter
|
||||||
|
def token(self, token):
|
||||||
|
"""Store the hashed value and prefix for a token"""
|
||||||
|
self.prefix = token[:self.prefix_length]
|
||||||
|
self.hashed = hash_token(token, rounds=self.rounds, salt=self.salt_bytes, algorithm=self.algorithm)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<{cls}('{t}', user='{u}')>".format(
|
return "<{cls}('{pre}...', user='{u}')>".format(
|
||||||
cls=self.__class__.__name__,
|
cls=self.__class__.__name__,
|
||||||
t=self.token,
|
pre=self.prefix,
|
||||||
u=self.user.name,
|
u=self.user.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -368,17 +379,17 @@ class Token(object):
|
|||||||
|
|
||||||
Returns None if not found.
|
Returns None if not found.
|
||||||
"""
|
"""
|
||||||
return db.query(cls).filter(cls.token==token).first()
|
prefix = token[:cls.prefix_length]
|
||||||
|
# since we can't filter on hashed values, filter on prefix
|
||||||
|
# so we aren't comparing with all tokens
|
||||||
class APIToken(Token, Base):
|
prefix_match = db.query(cls).filter(bindparam('prefix', prefix).startswith(cls.prefix))
|
||||||
"""An API token"""
|
for orm_token in prefix_match:
|
||||||
__tablename__ = 'api_tokens'
|
if orm_token.match(token):
|
||||||
|
return orm_token
|
||||||
|
|
||||||
class CookieToken(Token, Base):
|
def match(self, token):
|
||||||
"""A cookie token"""
|
"""Is this my token?"""
|
||||||
__tablename__ = 'cookie_tokens'
|
return compare_token(self.hashed, token)
|
||||||
|
|
||||||
|
|
||||||
def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
|
def new_session_factory(url="sqlite:///:memory:", reset=False, **kwargs):
|
||||||
|
@@ -90,8 +90,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
get_state, clear_state
|
get_state, clear_state
|
||||||
"""
|
"""
|
||||||
if state.get('api_token'):
|
pass
|
||||||
self.api_token = state['api_token']
|
|
||||||
|
|
||||||
def get_state(self):
|
def get_state(self):
|
||||||
"""store the state necessary for load_state
|
"""store the state necessary for load_state
|
||||||
@@ -106,8 +105,6 @@ class Spawner(LoggingConfigurable):
|
|||||||
a JSONable dict of state
|
a JSONable dict of state
|
||||||
"""
|
"""
|
||||||
state = {}
|
state = {}
|
||||||
if self.api_token:
|
|
||||||
state['api_token'] = self.api_token
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def clear_state(self):
|
def clear_state(self):
|
||||||
@@ -117,7 +114,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
Subclasses should call super, to ensure that state is properly cleared.
|
Subclasses should call super, to ensure that state is properly cleared.
|
||||||
"""
|
"""
|
||||||
self.api_token = ''
|
self.api_token = u''
|
||||||
|
|
||||||
def get_args(self):
|
def get_args(self):
|
||||||
"""Return the arguments to be passed after self.cmd"""
|
"""Return the arguments to be passed after self.cmd"""
|
||||||
|
@@ -44,13 +44,8 @@ def auth_header(db, name):
|
|||||||
user = find_user(db, name)
|
user = find_user(db, name)
|
||||||
if user is None:
|
if user is None:
|
||||||
user = add_user(db, name=name)
|
user = add_user(db, name=name)
|
||||||
if not user.api_tokens:
|
token = user.new_api_token()
|
||||||
token = user.new_api_token()
|
return {'Authorization': 'token %s' % token}
|
||||||
db.add(token)
|
|
||||||
db.commit()
|
|
||||||
else:
|
|
||||||
token = user.api_tokens[0]
|
|
||||||
return {'Authorization': 'token %s' % token.token}
|
|
||||||
|
|
||||||
@check_db_locks
|
@check_db_locks
|
||||||
def api_request(app, *api_path, **kwargs):
|
def api_request(app, *api_path, **kwargs):
|
||||||
@@ -74,25 +69,21 @@ def test_auth_api(app):
|
|||||||
# make a new cookie token
|
# make a new cookie token
|
||||||
user = db.query(orm.User).first()
|
user = db.query(orm.User).first()
|
||||||
api_token = user.new_api_token()
|
api_token = user.new_api_token()
|
||||||
db.add(api_token)
|
|
||||||
cookie_token = user.new_cookie_token()
|
|
||||||
db.add(cookie_token)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# check success:
|
# check success:
|
||||||
r = api_request(app, 'authorizations/token', api_token.token)
|
r = api_request(app, 'authorizations/token', api_token)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert reply['user'] == user.name
|
assert reply['user'] == user.name
|
||||||
|
|
||||||
# check fail
|
# check fail
|
||||||
r = api_request(app, 'authorizations/token', api_token.token,
|
r = api_request(app, 'authorizations/token', api_token,
|
||||||
headers={'Authorization': 'no sir'},
|
headers={'Authorization': 'no sir'},
|
||||||
)
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
r = api_request(app, 'authorizations/token', api_token.token,
|
r = api_request(app, 'authorizations/token', api_token,
|
||||||
headers={'Authorization': 'token: %s' % cookie_token.token},
|
headers={'Authorization': 'token: %s' % user.cookie_id},
|
||||||
)
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
@@ -21,7 +21,6 @@ def test_server(db):
|
|||||||
assert server.proto == 'http'
|
assert server.proto == 'http'
|
||||||
assert isinstance(server.port, int)
|
assert isinstance(server.port, int)
|
||||||
assert isinstance(server.cookie_name, unicode)
|
assert isinstance(server.cookie_name, unicode)
|
||||||
assert isinstance(server.cookie_secret, unicode)
|
|
||||||
assert server.url == 'http://localhost:%i/' % server.port
|
assert server.url == 'http://localhost:%i/' % server.port
|
||||||
|
|
||||||
|
|
||||||
@@ -81,18 +80,11 @@ def test_tokens(db):
|
|||||||
user = orm.User(name=u'inara')
|
user = orm.User(name=u'inara')
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
token = user.new_cookie_token()
|
token = user.new_api_token()
|
||||||
db.add(token)
|
assert any(t.match(token) for t in user.api_tokens)
|
||||||
db.commit()
|
user.new_api_token()
|
||||||
assert token in user.cookie_tokens
|
assert len(user.api_tokens) == 2
|
||||||
db.add(user.new_cookie_token())
|
found = orm.APIToken.find(db, token=token)
|
||||||
db.add(user.new_cookie_token())
|
assert found.match(token)
|
||||||
db.add(user.new_api_token())
|
found = orm.APIToken.find(db, 'something else')
|
||||||
db.commit()
|
|
||||||
assert len(user.api_tokens) == 1
|
|
||||||
assert len(user.cookie_tokens) == 3
|
|
||||||
|
|
||||||
found = orm.CookieToken.find(db, token=token.token)
|
|
||||||
assert found.token == token.token
|
|
||||||
found = orm.APIToken.find(db, token.token)
|
|
||||||
assert found is None
|
assert found is None
|
||||||
|
@@ -36,7 +36,7 @@ def new_spawner(db, **kwargs):
|
|||||||
kwargs.setdefault('cmd', [sys.executable, '-c', _echo_sleep])
|
kwargs.setdefault('cmd', [sys.executable, '-c', _echo_sleep])
|
||||||
kwargs.setdefault('user', db.query(orm.User).first())
|
kwargs.setdefault('user', db.query(orm.User).first())
|
||||||
kwargs.setdefault('hub', db.query(orm.Hub).first())
|
kwargs.setdefault('hub', db.query(orm.Hub).first())
|
||||||
kwargs.setdefault('INTERRUPT_TIMEOUT', 2)
|
kwargs.setdefault('INTERRUPT_TIMEOUT', 1)
|
||||||
kwargs.setdefault('TERM_TIMEOUT', 1)
|
kwargs.setdefault('TERM_TIMEOUT', 1)
|
||||||
kwargs.setdefault('KILL_TIMEOUT', 1)
|
kwargs.setdefault('KILL_TIMEOUT', 1)
|
||||||
return LocalProcessSpawner(**kwargs)
|
return LocalProcessSpawner(**kwargs)
|
||||||
|
@@ -3,11 +3,13 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
import binascii
|
from binascii import b2a_hex
|
||||||
import getpass
|
|
||||||
import errno
|
import errno
|
||||||
|
import getpass
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import uuid
|
||||||
|
|
||||||
from six import text_type
|
from six import text_type
|
||||||
from tornado import web, gen, ioloop
|
from tornado import web, gen, ioloop
|
||||||
@@ -44,13 +46,6 @@ def random_port():
|
|||||||
ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ'
|
ISO8601_ms = '%Y-%m-%dT%H:%M:%S.%fZ'
|
||||||
ISO8601_s = '%Y-%m-%dT%H:%M:%SZ'
|
ISO8601_s = '%Y-%m-%dT%H:%M:%SZ'
|
||||||
|
|
||||||
def random_hex(nbytes):
|
|
||||||
"""Return nbytes random bytes as a unicode hex string
|
|
||||||
|
|
||||||
It will have length nbytes * 2
|
|
||||||
"""
|
|
||||||
return binascii.hexlify(os.urandom(nbytes)).decode('ascii')
|
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def wait_for_server(ip, port, timeout=10):
|
def wait_for_server(ip, port, timeout=10):
|
||||||
"""wait for any server to show up at ip:port"""
|
"""wait for any server to show up at ip:port"""
|
||||||
@@ -101,6 +96,9 @@ def wait_for_http_server(url, timeout=10):
|
|||||||
|
|
||||||
raise TimeoutError
|
raise TimeoutError
|
||||||
|
|
||||||
|
|
||||||
|
# Decorators for authenticated Handlers
|
||||||
|
|
||||||
def auth_decorator(check_auth):
|
def auth_decorator(check_auth):
|
||||||
"""Make an authentication decorator
|
"""Make an authentication decorator
|
||||||
|
|
||||||
@@ -140,3 +138,48 @@ def admin_only(self):
|
|||||||
user = self.get_current_user()
|
user = self.get_current_user()
|
||||||
if user is None or not user.admin:
|
if user is None or not user.admin:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
|
|
||||||
|
# Token utilities
|
||||||
|
|
||||||
|
def new_token(*args, **kwargs):
|
||||||
|
"""generator for new random tokens
|
||||||
|
|
||||||
|
For now, just UUIDs.
|
||||||
|
"""
|
||||||
|
return text_type(uuid.uuid4().hex)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token, salt=8, rounds=16384, algorithm='sha512'):
|
||||||
|
"""hash a token, and return it as `algorithm:salt:hash`
|
||||||
|
|
||||||
|
If `salt` is an integer, a random salt of that many bytes will be used.
|
||||||
|
"""
|
||||||
|
h = hashlib.new(algorithm)
|
||||||
|
if isinstance(salt, int):
|
||||||
|
salt = b2a_hex(os.urandom(salt))
|
||||||
|
if isinstance(salt, bytes):
|
||||||
|
bsalt = salt
|
||||||
|
salt = salt.decode('utf8')
|
||||||
|
else:
|
||||||
|
bsalt = salt.encode('utf8')
|
||||||
|
btoken = token.encode('utf8', 'replace')
|
||||||
|
h.update(bsalt)
|
||||||
|
for i in range(rounds):
|
||||||
|
h.update(btoken)
|
||||||
|
digest = h.hexdigest()
|
||||||
|
|
||||||
|
return u"{algorithm}:{rounds}:{salt}:{digest}".format(**locals())
|
||||||
|
|
||||||
|
|
||||||
|
def compare_token(compare, token):
|
||||||
|
"""compare a token with a hashed token
|
||||||
|
|
||||||
|
uses the same algorithm and salt of the hashed token for comparison
|
||||||
|
"""
|
||||||
|
algorithm, srounds, salt, _ = compare.split(':')
|
||||||
|
hashed = hash_token(token, salt=salt, rounds=int(srounds), algorithm=algorithm)
|
||||||
|
if compare == hashed:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user