diff --git a/server/app/app.py b/server/app/app.py index e290ca35..1f59513c 100644 --- a/server/app/app.py +++ b/server/app/app.py @@ -85,6 +85,7 @@ def dataset_index(url_dataroot=None, dataset=None): try: cache_manager = current_app.matrix_data_cache_manager with cache_manager.data_adaptor(url_dataroot, location, app_config) as data_adaptor: + data_adaptor.set_uri_path(f"{url_dataroot}/{dataset}") dataset_title = app_config.get_title(data_adaptor) return render_template( "index.html", datasetTitle=dataset_title, SCRIPTS=scripts, INLINE_SCRIPTS=inline_scripts @@ -143,6 +144,7 @@ def rest_get_data_adaptor(func): def wrapped_function(self, dataset=None): try: with get_data_adaptor(self.url_dataroot, dataset) as data_adaptor: + data_adaptor.set_uri_path(f"{self.url_dataroot}/{dataset}") return func(self, data_adaptor) except DatasetAccessError as e: return common_rest.abort_and_log( @@ -160,6 +162,17 @@ def dataroot_test_index(): config = current_app.app_config server_config = config.server_config + + auth = server_config.auth + if auth.is_valid(): + if server_config.auth.is_authenticated(): + data += f"
Logged in as {auth.get_userid()} / {auth.get_username()}
" + if auth.requires_client_login(): + if server_config.auth.is_authenticated(): + data += "" + else: + data += "" + datasets = [] for dataroot_dict in server_config.multi_dataset__dataroot.values(): dataroot = dataroot_dict["dataroot"] @@ -338,10 +351,15 @@ class Server: lambda dataset, url_dataroot=url_dataroot: dataset_index(url_dataroot, dataset), methods=["GET"], ) + else: bp_api = Blueprint("api", __name__, url_prefix=api_version) resources = get_api_resources(bp_api) self.app.register_blueprint(resources.blueprint) + self.app.auth = server_config.auth + if self.app.auth.requires_client_login(): + self.app.auth.add_url_rules(self.app) + self.app.matrix_data_cache_manager = server_config.matrix_data_cache_manager self.app.app_config = app_config diff --git a/server/auth/__init__.py b/server/auth/__init__.py new file mode 100644 index 00000000..2af0d1ef --- /dev/null +++ b/server/auth/__init__.py @@ -0,0 +1,6 @@ + +# import the built in auth types so they can be registered + +import server.auth.auth_none # noqa: F401 +import server.auth.auth_test # noqa: F401 +import server.auth.auth_session # noqa: F401 diff --git a/server/auth/auth.py b/server/auth/auth.py new file mode 100644 index 00000000..3262a9c1 --- /dev/null +++ b/server/auth/auth.py @@ -0,0 +1,80 @@ +from abc import ABC, abstractmethod + + +class AuthTypeBase(ABC): + """Base type for all authentication types.""" + + def __init__(self): + super().__init__() + + @abstractmethod + def set_params(self, params): + """Set the parameters from app config. raise ConfigurationError if any params are invalid""" + pass + + @abstractmethod + def is_valid(self): + """Return True if the auth type can return user info (AuthTypeNone is the only one that cannot)""" + pass + + def requires_client_login(self): + """Return True if the user needs to login from the client (e.g. Login button is shown)""" + return False + + @abstractmethod + def is_authenticated(self): + """Return True if the user is authenticated""" + pass + + @abstractmethod + def get_userid(self): + """Return the id for this user (string)""" + pass + + @abstractmethod + def get_username(self): + """Return the name of the user (string)""" + pass + + +class AuthTypeClientBase(AuthTypeBase): + """Base type for all authentication types that require the client to login""" + + def __init__(self): + super().__init__() + + def requires_client_login(self): + return True + + @abstractmethod + def add_url_rules(self, selfapp): + """Add url rules to the app (like /login, /logout, etc)""" + pass + + @abstractmethod + def get_login_url(self, data_adaptor): + """Return the url for the login route""" + pass + + @abstractmethod + def get_logout_url(self, data_adaptor): + """Return the url for the logout route""" + pass + + +class AuthTypeFactory: + """Factory class to create an authentication type""" + + auth_types = {} + + @staticmethod + def register(name, auth_type): + assert(issubclass(auth_type, AuthTypeBase)) + AuthTypeFactory.auth_types[name] = auth_type + + @staticmethod + def create(name): + auth_type = AuthTypeFactory.auth_types.get(name) + if auth_type is None: + return None + return auth_type() diff --git a/server/auth/auth_none.py b/server/auth/auth_none.py new file mode 100644 index 00000000..9b2b8c0a --- /dev/null +++ b/server/auth/auth_none.py @@ -0,0 +1,27 @@ +from server.auth.auth import AuthTypeBase, AuthTypeFactory +from server.common.errors import ConfigurationError + + +class AuthTypeNone(AuthTypeBase): + + def __init__(self): + super().__init__() + + def is_valid(self): + return False + + def set_params(self, params): + if params: + raise ConfigurationError("not expecting authentication parameters") + + def is_authenticated(self): + return True + + def get_userid(self): + return None + + def get_username(self): + return None + + +AuthTypeFactory.register(None, AuthTypeNone) diff --git a/server/auth/auth_session.py b/server/auth/auth_session.py new file mode 100644 index 00000000..d5754bc4 --- /dev/null +++ b/server/auth/auth_session.py @@ -0,0 +1,36 @@ +from server.auth.auth import AuthTypeBase, AuthTypeFactory +from flask import session +from uuid import uuid4 + + +class AuthTypeSession(AuthTypeBase): + """Session based authentication. The user is always logged. The user id is a random number + associated with the session. This is a good choice for desktop servers.""" + + # key in the session token for userid + CXGUID = "cxguid" + + def __init__(self): + super().__init__() + + def is_valid(self): + return True + + def set_params(self, params): + return + + def is_authenticated(self): + # always authenticated + return True + + def get_userid(self): + if self.CXGUID not in session: + session[self.CXGUID] = uuid4().hex + session.permanent = True + return session[self.CXGUID] + + def get_username(self): + return "anonymous" + + +AuthTypeFactory.register("session", AuthTypeSession) diff --git a/server/auth/auth_test.py b/server/auth/auth_test.py new file mode 100644 index 00000000..d2222c12 --- /dev/null +++ b/server/auth/auth_test.py @@ -0,0 +1,69 @@ +from server.auth.auth import AuthTypeClientBase, AuthTypeFactory +from flask import session, request, redirect, current_app + + +class AuthTypeTest(AuthTypeClientBase): + """An authentication type for testing client based logins. When the login route is accessed + the user is automatically logged in with a default or configured username""" + + # key in session token with userid and username + CXGUID = "cxguid_test" + CXGUNAME = "cxguname_test" + + def __init__(self): + super().__init__() + self.username = "test_account" + self.userid = "id0001" + + def is_valid(self): + return True + + def requires_client_login(self): + return True + + def add_url_rules(self, app): + app.add_url_rule("/login", "login", self.login, methods=["GET"]) + app.add_url_rule("/logout", "logout", self.logout, methods=["GET"]) + + def set_params(self, params): + if params: + self.username = params.get("username", self.username) + self.userid = params.get("userid", self.userid) + + def is_authenticated(self): + return self.CXGUID in session + + def get_userid(self): + return session.get(self.CXGUID) + + def get_username(self): + return session.get(self.CXGUNAME) + + def login(self): + args = request.args + return_to = args.get("dataset", "/") + session[self.CXGUID] = args.get("userid", self.userid) + session[self.CXGUNAME] = args.get("username", self.username) + return redirect(return_to) + + def logout(self): + session.clear() + return_to = request.args.get("dataset", "/") + return redirect(return_to) + + def get_login_url(self, data_adaptor): + """Return the url for the login route""" + if current_app.app_config.is_multi_dataset(): + return f"/login?dataset={data_adaptor.uri_path}" + else: + return "/login" + + def get_logout_url(self, data_adaptor): + """Return the url for the logout route""" + if current_app.app_config.is_multi_dataset(): + return f"/logout?dataset={data_adaptor.uri_path}" + else: + return "/logout" + + +AuthTypeFactory.register("test", AuthTypeTest) diff --git a/server/common/annotations.py b/server/common/annotations.py index 45e53122..25176ccb 100644 --- a/server/common/annotations.py +++ b/server/common/annotations.py @@ -1,6 +1,5 @@ from datetime import datetime import re -from uuid import uuid4 import os import pandas as pd from hashlib import blake2b @@ -11,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 +from flask import session, current_app from abc import ABCMeta, abstractmethod @@ -80,7 +79,6 @@ class Annotations(metaclass=ABCMeta): class AnnotationsLocalFile(Annotations): - CXGUID = "cxguid" CXG_ANNO_COLLECTION = "cxg_anno_collection" def __init__(self, output_dir, output_file): @@ -159,18 +157,12 @@ class AnnotationsLocalFile(Annotations): self.last_fname = fname self.last_labels = df - def _get_userid(self): - if self.CXGUID not in session: - session[self.CXGUID] = uuid4().hex - session.permanent = True - return session[self.CXGUID] - def _get_userdata_idhash(self, data_adaptor): """ Return a short hash that weakly identifies the user and dataset. Used to create safe annotations output file names. """ - uid = self._get_userid() + uid = current_app.auth.get_userid() id = (uid + data_adaptor.get_location()).encode() idhash = base64.b32encode(blake2b(id, digest_size=5).digest()).decode("utf-8") return idhash @@ -257,8 +249,9 @@ class AnnotationsLocalFile(Annotations): elif session is not None: collection = self.get_collection() - 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 + if current_app.auth.is_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 parameters.update(params) diff --git a/server/common/app_config.py b/server/common/app_config.py index 3731f9b4..dab9d6a7 100644 --- a/server/common/app_config.py +++ b/server/common/app_config.py @@ -16,6 +16,7 @@ from server.common.annotations import AnnotationsLocalFile from server.common.utils import custom_format_warning import server.compute.diffexp_cxg as diffexp_tiledb from server.common.data_locator import discover_s3_region_name +from server.auth.auth import AuthTypeFactory DEFAULT_SERVER_PORT = 5005 # anything bigger than this will generate a special message @@ -194,6 +195,7 @@ class AppConfig(object): server_config = self.server_config dataset_config = data_adaptor.dataset_config annotation = dataset_config.user_annotations + auth = server_config.auth # FIXME The current set of config is not consistently presented: # we have camalCase, hyphen-text, and underscore_text @@ -257,6 +259,18 @@ class AppConfig(object): "diffexp_cellcount_max": server_config.limits__diffexp_cellcount_max, } + if dataset_config.app__authentication_enable and auth.is_valid(): + config["authentication"] = { + "is_authenticated": auth.is_authenticated(), + "requires_client_login": auth.requires_client_login(), + "username": auth.get_username(), + } + if auth.requires_client_login(): + config["authentication"].update({ + "login": auth.get_login_url(data_adaptor), + "logout" : auth.get_logout_url(data_adaptor), + }) + return c @@ -366,6 +380,7 @@ 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"), ] @@ -384,6 +399,9 @@ class ServerConfig(BaseConfig): self.app__server_timing_headers = dc["app"]["server_timing_headers"] self.app__csp_directives = dc["app"]["csp_directives"] + self.authentication__type = dc["authentication"]["type"] + self.authentication__params = dc["authentication"]["params"] + self.multi_dataset__dataroot = dc["multi_dataset"]["dataroot"] self.multi_dataset__index = dc["multi_dataset"]["index"] self.multi_dataset__allowed_matrix_types = dc["multi_dataset"]["allowed_matrix_types"] @@ -414,8 +432,12 @@ 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) + self.auth = None + def complete_config(self, context): self.handle_app(context) + self.handle_authentication(context) self.handle_data_locator(context) self.handle_adaptor(context) # may depend on data_locator self.handle_single_dataset(context) # may depend on adaptor @@ -484,6 +506,14 @@ class ServerConfig(BaseConfig): elif not isinstance(v, str): raise ConfigurationError("CSP directive value must be a string or list of strings.") + 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) + 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)) if self.data_locator__s3__region_name is True: @@ -660,6 +690,7 @@ class DatasetConfig(BaseConfig): self.app__inline_scripts = dc["app"]["inline_scripts"] self.app__about_legal_tos = dc["app"]["about_legal_tos"] self.app__about_legal_privacy = dc["app"]["about_legal_privacy"] + self.app__authentication_enable = dc["app"]["authentication_enable"] self.presentation__max_categories = dc["presentation"]["max_categories"] self.presentation__custom_colors = dc["presentation"]["custom_colors"] @@ -696,6 +727,7 @@ class DatasetConfig(BaseConfig): self.check_attr("app__inline_scripts", list) self.check_attr("app__about_legal_tos", (type(None), str)) self.check_attr("app__about_legal_privacy", (type(None), str)) + self.check_attr("app__authentication_enable", bool) # scripts can be string (filename) or dict (attributes). Convert string to dict. scripts = [] @@ -721,6 +753,13 @@ class DatasetConfig(BaseConfig): self.check_attr("user_annotations__ontology__obo_location", (type(None), str)) if self.user_annotations__enable: + 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(): + auth_type = server_config.authentication__type + raise ConfigurationError(f"authentication method {auth_type} is not compatible with user annotations") + # TODO, replace this with a factory pattern once we have more than one way # to do annotations. currently only local_file_csv if self.user_annotations__type != "local_file_csv": diff --git a/server/common/default_config.py b/server/common/default_config.py index 0b0c5118..bc585391 100644 --- a/server/common/default_config.py +++ b/server/common/default_config.py @@ -14,6 +14,15 @@ server: server_timing_headers: false csp_directives: null + authentication: + # The authentication types may be "none" or "session" + # none: No authentication support, features like user_annotations must not be enabled. + # session: A session based userid is automatically generated. + type: session + + # a dictionary of parameters that may be required for an authentication type + params: null + multi_dataset: # If dataroot is set, then cellxgene may serve multiple datasets. This parameter is not # compatible with single_dataset/datapath. @@ -132,6 +141,9 @@ dataset: about_legal_tos: null about_legal_privacy: null + # allow authentication support + authentication_enable: true + presentation: max_categories: 1000 custom_colors: true diff --git a/server/data_common/data_adaptor.py b/server/data_common/data_adaptor.py index eabec8d1..14a07b5e 100644 --- a/server/data_common/data_adaptor.py +++ b/server/data_common/data_adaptor.py @@ -28,6 +28,11 @@ class DataAdaptor(metaclass=ABCMeta): # parameters set by this data adaptor based on the data. self.parameters = {} + self.uri_path = None + + def set_uri_path(self, path): + # uri path to the dataset, e.g. /d/