Merge pull request #81 from minrk/crypto-db

hash tokens in database

closes #80
closes #83
This commit is contained in:
Min RK
2014-10-30 16:20:16 -07:00
9 changed files with 243 additions and 140 deletions

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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"""

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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