mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-16 22:43:00 +00:00
database security
- add files for cookie and database secrets - store cookie secret on disk, instead of in database - encrypt auth tokens with EncryptedType
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
# 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
|
||||||
@@ -16,6 +17,7 @@ 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
|
||||||
@@ -28,7 +30,7 @@ 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 +41,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 +71,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."""
|
||||||
@@ -203,14 +207,27 @@ 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."""
|
||||||
|
)
|
||||||
|
|
||||||
|
db_secret = Bytes(config=True, env='JPY_DB_SECRET',
|
||||||
|
help="""The database secret to use to encrypt sensitive information in the database.
|
||||||
|
|
||||||
|
Loaded from the JPY_DB_SECRET env variable by default.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db_secret_file = Unicode('jupyterhub_db_secret', config=True,
|
||||||
|
help="""File in which to store the database secret."""
|
||||||
|
)
|
||||||
|
|
||||||
authenticator_class = Type(PAMAuthenticator, Authenticator,
|
authenticator_class = Type(PAMAuthenticator, Authenticator,
|
||||||
config=True,
|
config=True,
|
||||||
@@ -360,11 +377,59 @@ 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):
|
||||||
|
traits = self.traits()
|
||||||
|
for key in ('cookie', 'db'):
|
||||||
|
trait_name = '{}_secret'.format(key)
|
||||||
|
env_name = traits[trait_name].get_metadata('env')
|
||||||
|
file_attr_name = '{}_secret_file'.format(key)
|
||||||
|
secret_file = os.path.abspath(
|
||||||
|
os.path.expanduser(getattr(self, file_attr_name))
|
||||||
|
)
|
||||||
|
secret = getattr(self, trait_name)
|
||||||
|
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
|
||||||
|
setattr(self, trait_name, 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)
|
||||||
try:
|
try:
|
||||||
self.db = orm.new_session(self.db_url, reset=self.reset_db, echo=self.debug_db,
|
self.db = orm.new_session(self.db_url, reset=self.reset_db, echo=self.debug_db,
|
||||||
|
crypto_key=self.db_secret,
|
||||||
**self.db_kwargs
|
**self.db_kwargs
|
||||||
)
|
)
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
@@ -383,7 +448,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',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -596,7 +660,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'),
|
||||||
@@ -628,6 +692,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()
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
from binascii import b2a_hex
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
@@ -25,6 +26,7 @@ from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
|||||||
from sqlalchemy.orm import sessionmaker, relationship, backref
|
from sqlalchemy.orm import sessionmaker, relationship, backref
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy_utils.types import EncryptedType
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -74,7 +76,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 +125,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 = Column(EncryptedType(Unicode, key=b''), default=new_token)
|
||||||
_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'))
|
||||||
@@ -350,7 +351,7 @@ class User(Base):
|
|||||||
|
|
||||||
class Token(object):
|
class Token(object):
|
||||||
"""Mixin for token tables, since we have two"""
|
"""Mixin for token tables, since we have two"""
|
||||||
token = Column(String, primary_key=True)
|
token = Column(EncryptedType(Unicode, key=b''), primary_key=True)
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def user_id(cls):
|
def user_id(cls):
|
||||||
return Column(Integer, ForeignKey('users.id'))
|
return Column(Integer, ForeignKey('users.id'))
|
||||||
@@ -381,7 +382,7 @@ class CookieToken(Token, Base):
|
|||||||
__tablename__ = 'cookie_tokens'
|
__tablename__ = 'cookie_tokens'
|
||||||
|
|
||||||
|
|
||||||
def new_session(url="sqlite:///:memory:", reset=False, **kwargs):
|
def new_session(url="sqlite:///:memory:", reset=False, crypto_key=None, **kwargs):
|
||||||
"""Create a new session at url"""
|
"""Create a new session at url"""
|
||||||
if url.startswith('sqlite'):
|
if url.startswith('sqlite'):
|
||||||
kwargs.setdefault('connect_args', {'check_same_thread': False})
|
kwargs.setdefault('connect_args', {'check_same_thread': False})
|
||||||
@@ -391,6 +392,12 @@ def new_session(url="sqlite:///:memory:", reset=False, **kwargs):
|
|||||||
session = Session()
|
session = Session()
|
||||||
if reset:
|
if reset:
|
||||||
Base.metadata.drop_all(engine)
|
Base.metadata.drop_all(engine)
|
||||||
|
# configure encryption key
|
||||||
|
if crypto_key:
|
||||||
|
for table in Base.metadata.tables.values():
|
||||||
|
for column in table.columns.values():
|
||||||
|
if isinstance(column.type, EncryptedType):
|
||||||
|
column.type.key = crypto_key
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@@ -3,5 +3,7 @@ tornado>=3.2
|
|||||||
jinja2
|
jinja2
|
||||||
simplepam
|
simplepam
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
|
sqlalchemy-utils
|
||||||
|
cryptography
|
||||||
requests
|
requests
|
||||||
six
|
six
|
||||||
|
Reference in New Issue
Block a user