add oauth authentication (#1681)

* add oauth authentication

Add support for OAuth2.

Change the interface to AuthTypeBase
  - better handling of config parameters
  - add a complete_setup function for additional setup steps

Added a function wrapper to enforce authentication for the
routes that require authenticaiton.

* change fsspec requirement

fsspec 0.8.0 breaks our tests
it imports a module that is does not require.
This commit is contained in:
bmccandless
2020-07-31 18:16:57 -07:00
committed by GitHub
parent bb2326525e
commit 2afa48cf11
12 changed files with 319 additions and 69 deletions
+8 -4
View File
@@ -10,7 +10,7 @@ from server.common.errors import AnnotationsError, OntologyLoadFailure
from server.common.utils import series_to_schema
import fsspec
import fastobo
from flask import session, current_app
from flask import session, current_app, has_request_context
from abc import ABCMeta, abstractmethod
@@ -46,8 +46,8 @@ class Annotations(metaclass=ABCMeta):
raise OntologyLoadFailure("Error loading OBO file") from e
def get_schema(self, data_adaptor):
labels = self.read_labels(data_adaptor)
schema = []
labels = self.read_labels(data_adaptor)
if labels is not None and not labels.empty:
for col in labels.columns:
col_schema = dict(name=col, writable=True)
@@ -113,6 +113,10 @@ class AnnotationsLocalFile(Annotations):
return session.get(self.CXG_ANNO_COLLECTION)
def read_labels(self, data_adaptor):
if has_request_context():
if not current_app.auth.is_user_authenticated():
return pd.DataFrame()
fname = self._get_filename(data_adaptor)
with self.label_lock:
if fname is not None and os.path.exists(fname) and os.path.getsize(fname) > 0:
@@ -162,7 +166,7 @@ class AnnotationsLocalFile(Annotations):
Return a short hash that weakly identifies the user and dataset.
Used to create safe annotations output file names.
"""
uid = current_app.auth.get_userid()
uid = current_app.auth.get_user_id()
id = (uid + data_adaptor.get_location()).encode()
idhash = base64.b32encode(blake2b(id, digest_size=5).digest()).decode("utf-8")
return idhash
@@ -249,7 +253,7 @@ class AnnotationsLocalFile(Annotations):
elif session is not None:
collection = self.get_collection()
if current_app.auth.is_authenticated():
if current_app.auth.is_user_authenticated():
params["annotations-user-data-idhash"] = self._get_userdata_idhash(data_adaptor)
params["annotations-data-collection-is-read-only"] = False
params["annotations-data-collection-name"] = collection
+23 -10
View File
@@ -272,11 +272,11 @@ class AppConfig(object):
"diffexp_cellcount_max": server_config.limits__diffexp_cellcount_max,
}
if dataset_config.app__authentication_enable and auth.is_valid():
if dataset_config.app__authentication_enable and auth.is_valid_authentication_type():
config["authentication"] = {
"is_authenticated": auth.is_authenticated(),
"is_authenticated": auth.is_user_authenticated(),
"requires_client_login": auth.requires_client_login(),
"username": auth.get_username(),
"username": auth.get_user_name(),
}
if auth.requires_client_login():
config["authentication"].update({
@@ -393,7 +393,6 @@ class ServerConfig(BaseConfig):
def __init__(self, app_config, default_config):
dictval_cases = [
("app", "csp_directives"),
("authentication", "params"),
("adaptor", "cxg_adaptor", "tiledb_ctx"),
("multi_dataset", "dataroot"),
]
@@ -413,7 +412,11 @@ class ServerConfig(BaseConfig):
self.app__csp_directives = dc["app"]["csp_directives"]
self.authentication__type = dc["authentication"]["type"]
self.authentication__params = dc["authentication"]["params"]
self.authentication__params_oauth__api_base_url = dc["authentication"]["params_oauth"]["api_base_url"]
self.authentication__params_oauth__client_id = dc["authentication"]["params_oauth"]["client_id"]
self.authentication__params_oauth__client_secret = dc["authentication"]["params_oauth"]["client_secret"]
self.authentication__params_oauth__callback_base_url = \
dc["authentication"]["params_oauth"]["callback_base_url"]
self.multi_dataset__dataroot = dc["multi_dataset"]["dataroot"]
self.multi_dataset__index = dc["multi_dataset"]["index"]
@@ -445,7 +448,7 @@ class ServerConfig(BaseConfig):
# The matrix data cache manager is created during the complete_config and stored here.
self.matrix_data_cache_manager = None
# The authentication object (BCM -- better name)
# The authentication object
self.auth = None
def complete_config(self, context):
@@ -521,11 +524,21 @@ class ServerConfig(BaseConfig):
def handle_authentication(self, context):
self.check_attr("authentication__type", (type(None), str))
self.check_attr("authentication__params", (type(None), dict))
self.auth = AuthTypeFactory.create(self.authentication__type)
# oauth
ptypes = str if self.authentication__type == "oauth" else (type(None), str)
self.check_attr("authentication__params_oauth__api_base_url", ptypes)
self.check_attr("authentication__params_oauth__client_id", ptypes)
self.check_attr("authentication__params_oauth__client_secret", ptypes)
self.check_attr("authentication__params_oauth__callback_base_url", (type(None), str))
# secret key: first, from CXG_OAUTH_CLIENT_SECRET environment variable
# second, from config file
self.authentication__params__oauth__client_secret = os.environ.get(
"CXG_OAUTH_CLIENT_SECRET", self.authentication__params_oauth__client_secret)
self.auth = AuthTypeFactory.create(self.authentication__type, self)
if self.auth is None:
raise ConfigurationError(f"Unknown authentication type: {self.authentication__type}")
self.auth.set_params(self.authentication__params)
def handle_data_locator(self, context):
self.check_attr("data_locator__s3__region_name", (type(None), bool, str))
@@ -769,7 +782,7 @@ class DatasetConfig(BaseConfig):
server_config = self.app_config.server_config
if not self.app__authentication_enable:
raise ConfigurationError("user annotations requires authentication to be enabled")
if not server_config.auth.is_valid():
if not server_config.auth.is_valid_authentication_type():
auth_type = server_config.authentication__type
raise ConfigurationError(f"authentication method {auth_type} is not compatible with user annotations")
+16 -5
View File
@@ -5,7 +5,7 @@ server:
app:
verbose: false
debug: false
host: "127.0.0.1"
host: localhost
port : null
open_browser: false
force_https: false
@@ -15,13 +15,24 @@ server:
csp_directives: null
authentication:
# The authentication types may be "none" or "session"
# The authentication types may be "none", "session", "oauth"
# none: No authentication support, features like user_annotations must not be enabled.
# session: A session based userid is automatically generated.
# session: A session based userid is automatically generated. (no params needed)
# oauth: oauth2 is used for authentication; parameters are defined in params_oauth.
type: session
# a dictionary of parameters that may be required for an authentication type
params: null
params_oauth:
# url to the auth server
api_base_url: null
# client_id of this app
client_id: null
# the client_secret known to the auth server and this app
client_secret: null
# cellxgene server location;
# the browser will be redirected to locations relative to this location during login and logout.
# A value of None, indicates the client and server are on the localhost. http://localhost:<port> will be used.
callback_base_url: null
multi_dataset:
# If dataroot is set, then cellxgene may serve multiple datasets. This parameter is not
+4
View File
@@ -41,6 +41,10 @@ define_request_exception(
)
define_request_exception("ExceedsLimitError", "Raised when an HTTP request exceeds a limit/quota")
define_request_exception("ColorFormatException", "Raised when color helper functions encounter an unknown color format")
define_request_exception(
"AuthenticationError",
"Raised when there is an authentication error",
default_status_code=HTTPStatus.UNAUTHORIZED)
define_exception("OntologyLoadFailure", "Raised when reading the ontology file fails")
define_exception("ConfigurationError", "Raised when checking configuration errors")