diff --git a/config/galaxy.yml.sample b/config/galaxy.yml.sample index 3a2a416a4b93..20890bdc6eee 100644 --- a/config/galaxy.yml.sample +++ b/config/galaxy.yml.sample @@ -1405,6 +1405,15 @@ galaxy: # If OpenID is enabled, consumer cache directory to use. #openid_consumer_cache_path: database/openid_consumer_cache + # Enables and disables OpenID Connect (OIDC) support. + #enable_oidc: false + + # Sets the path to OIDC configuration file. + #oidc_config_file: config/oidc_config.xml + + # Sets the path to OIDC backends configuration file. + #oidc_backends_config_file: config/oidc_backends_config.xml + # XML config file that allows the use of different authentication # providers (e.g. LDAP) instead or in addition to local authentication # (.sample is used if default does not exist). diff --git a/config/oidc_backends_config.xml.sample b/config/oidc_backends_config.xml.sample new file mode 100644 index 000000000000..98df83934f08 --- /dev/null +++ b/config/oidc_backends_config.xml.sample @@ -0,0 +1,19 @@ + + + + ... + ... + http://localhost:8080/authnz/google/callback + + + + + diff --git a/config/oidc_config.xml.sample b/config/oidc_config.xml.sample new file mode 100644 index 000000000000..f8618dd74dd0 --- /dev/null +++ b/config/oidc_config.xml.sample @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/doc/source/admin/galaxy_options.rst b/doc/source/admin/galaxy_options.rst index 440d53a71138..eaa7166d73ad 100644 --- a/doc/source/admin/galaxy_options.rst +++ b/doc/source/admin/galaxy_options.rst @@ -2920,6 +2920,36 @@ :Type: str +~~~~~~~~~~~~~~~ +``enable_oidc`` +~~~~~~~~~~~~~~~ + +:Description: + Enables and disables OpenID Connect (OIDC) support. +:Default: ``false`` +:Type: bool + + +~~~~~~~~~~~~~~~~~~~~ +``oidc_config_file`` +~~~~~~~~~~~~~~~~~~~~ + +:Description: + Sets the path to OIDC configuration file. +:Default: ``config/oidc_config.xml`` +:Type: str + + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``oidc_backends_config_file`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:Description: + Sets the path to OIDC backends configuration file. +:Default: ``config/oidc_backends_config.xml`` +:Type: str + + ~~~~~~~~~~~~~~~~~~~~ ``auth_config_file`` ~~~~~~~~~~~~~~~~~~~~ diff --git a/lib/galaxy/app.py b/lib/galaxy/app.py index 574324b9aab3..1d110a5859dc 100644 --- a/lib/galaxy/app.py +++ b/lib/galaxy/app.py @@ -183,6 +183,11 @@ def __init__(self, **kwargs): ) self.heartbeat.daemon = True self.application_stack.register_postfork_function(self.heartbeat.start) + + if self.config.enable_oidc: + from galaxy.authnz import managers + self.authnz_manager = managers.AuthnzManager(self, self.config.oidc_config, self.config.oidc_backends_config) + self.sentry_client = None if self.config.sentry_dsn: diff --git a/lib/galaxy/authnz/__init__.py b/lib/galaxy/authnz/__init__.py new file mode 100644 index 000000000000..99a9136c3c53 --- /dev/null +++ b/lib/galaxy/authnz/__init__.py @@ -0,0 +1,68 @@ +""" +Contains implementations for authentication and authorization against an +OpenID Connect (OIDC) Identity Provider (IdP). + +This package follows "authorization code flow" authentication protocol to authenticate +Galaxy users against third-party identity providers. + +Additionally, this package implements functionalist's to request temporary access +credentials for cloud-based resource providers (e.g., Amazon AWS, Microsoft Azure). +""" + + +class IdentityProvider(object): + """ + OpenID Connect Identity Provider abstract interface. + """ + + def __init__(self, provider, config): + """ + Initialize the identity provider using the provided configuration, + and raise a ParseError (or any more related specific exception) in + case the configuration is malformed. + + :type provider: string + :param provider: is the name of the identity provider (e.g., Google). + + :type config: xml.etree.ElementTree.Element + :param config: Is the configuration element of the provider + from the configuration file (e.g., oidc_config.xml). + This element contains the all the provider-specific + configuration elements. + """ + raise NotImplementedError() + + def authenticate(self, provider, trans): + """Runs for authentication process. Checks the database if a + valid identity exists in the database; if yes, then the user + is authenticated, if not, it generates a provider-specific + authentication flow and returns redirect URI to the controller. + + :type trans: GalaxyWebTransaction + :param trans: Galaxy web transaction. + + :return: a redirect URI to the provider's authentication + endpoint. + """ + raise NotImplementedError() + + def callback(self, state_token, authz_code, trans, login_redirect_url): + """ + Handles authentication call-backs from identity providers. + This process maps `state-token` to a user + :type state_token: string + :param state_token: is an anti-forgery token which identifies + a Galaxy user to whom the given authorization code belongs to. + :type authz_code: string + :param authz_code: a very short-lived, single-use token to + request a refresh token. + :type trans: GalaxyWebTransaction + :param trans: Galaxy web transaction. + :return boolean: + True: if callback is handled successfully. + False: if processing callback fails, then Galaxy attempts re-authentication. + """ + raise NotImplementedError() + + def disconnect(self, provider, trans, disconnect_redirect_url=None): + raise NotImplementedError() diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py new file mode 100644 index 000000000000..910b34c9bad4 --- /dev/null +++ b/lib/galaxy/authnz/managers.py @@ -0,0 +1,145 @@ + +import importlib +import logging +import xml.etree.ElementTree as ET +from xml.etree.ElementTree import ParseError + +from .psa_authnz import PSAAuthnz + +log = logging.getLogger(__name__) + + +class AuthnzManager(object): + + def __init__(self, app, oidc_config_file, oidc_backends_config_file): + """ + :type app: galaxy.app.UniverseApplication + :param app: + + :type config: string + :param config: sets the path for OIDC configuration + file (e.g., oidc_backends_config.xml). + """ + self._parse_oidc_config(oidc_config_file) + self._parse_oidc_backends_config(oidc_backends_config_file) + + def _parse_oidc_config(self, config_file): + self.oidc_config = {} + try: + tree = ET.parse(config_file) + root = tree.getroot() + if root.tag != 'OIDC': + raise ParseError("The root element in OIDC_Config xml file is expected to be `OIDC`, " + "found `{}` instead -- unable to continue.".format(root.tag)) + for child in root: + if child.tag != 'Setter': + log.error("Expect a node with `Setter` tag, found a node with `{}` tag instead; " + "skipping this node.".format(child.tag)) + continue + if 'Property' not in child.attrib or 'Value' not in child.attrib or 'Type' not in child.attrib: + log.error("Could not find the node attributes `Property` and/or `Value` and/or `Type`;" + " found these attributes: `{}`; skipping this node.".format(child.attrib)) + continue + try: + func = getattr(importlib.import_module('__builtin__'), child.get('Type')) + except AttributeError: + log.error("The value of attribute `Type`, `{}`, is not a valid built-in type;" + " skipping this node").format(child.get('Type')) + continue + self.oidc_config[child.get('Property')] = func(child.get('Value')) + except ImportError: + raise + except ParseError as e: + raise ParseError("Invalid configuration at `{}`: {} -- unable to continue.".format(config_file, e.message)) + + def _parse_oidc_backends_config(self, config_file): + self.oidc_backends_config = {} + try: + tree = ET.parse(config_file) + root = tree.getroot() + if root.tag != 'OIDC': + raise ParseError("The root element in OIDC config xml file is expected to be `OIDC`, " + "found `{}` instead -- unable to continue.".format(root.tag)) + for child in root: + if child.tag != 'provider': + log.error("Expect a node with `provider` tag, found a node with `{}` tag instead; " + "skipping the node.".format(child.tag)) + continue + if 'name' not in child.attrib: + log.error("Could not find a node attribute 'name'; skipping the node '{}'.".format(child.tag)) + continue + idp = child.get('name').lower() + if idp == 'google': + self.oidc_backends_config[idp] = self._parse_google_config(child) + if len(self.oidc_backends_config) == 0: + raise ParseError("No valid provider configuration parsed.") + except ImportError: + raise + except ParseError as e: + raise ParseError("Invalid configuration at `{}`: {} -- unable to continue.".format(config_file, e.message)) + # except Exception as e: + # raise Exception("Malformed OIDC Configuration XML -- unable to continue. {}".format(e.message)) + + def _parse_google_config(self, config_xml): + rtv = { + 'client_id': config_xml.find('client_id').text, + 'client_secret': config_xml.find('client_secret').text, + 'redirect_uri': config_xml.find('redirect_uri').text} + if config_xml.find('prompt') is not None: + rtv['prompt'] = config_xml.find('prompt').text + return rtv + + def _get_authnz_backend(self, provider): + provider = provider.lower() + if provider in self.oidc_backends_config: + try: + return True, "", PSAAuthnz(provider, self.oidc_config, self.oidc_backends_config[provider]) + except Exception as e: + log.exception('An error occurred when loading PSAAuthnz: ', str(e)) + return False, str(e), None + else: + msg = 'The requested identity provider, `{}`, is not a recognized/expected provider'.format(provider) + log.debug(msg) + return False, msg, None + + def authenticate(self, provider, trans): + """ + :type provider: string + :param provider: set the name of the identity provider to be + used for authentication flow. + :type trans: GalaxyWebTransaction + :param trans: Galaxy web transaction. + :return: an identity provider specific authentication redirect URI. + """ + try: + success, message, backend = self._get_authnz_backend(provider) + if success is False: + return False, message, None + return True, "Redirecting to the `{}` identity provider for authentication".format(provider), backend.authenticate(trans) + except Exception as e: + msg = 'An error occurred when authenticating a user on `{}` identity provider: {}'.format(provider, str(e)) + log.exception(msg) + return False, msg, None + + def callback(self, provider, state_token, authz_code, trans, login_redirect_url): + try: + success, message, backend = self._get_authnz_backend(provider) + if success is False: + return False, message, (None, None) + return True, message, backend.callback(state_token, authz_code, trans, login_redirect_url) + except Exception as e: + msg = 'An error occurred when handling callback from `{}` identity provider; {}'.format(provider, str(e)) + log.exception(msg) + return False, msg, (None, None) + + def disconnect(self, provider, trans, disconnect_redirect_url=None): + try: + success, message, backend = self._get_authnz_backend(provider) + if success is False: + return False, message, None + return backend.disconnect(provider, trans, disconnect_redirect_url) + except Exception as e: + msg = 'An error occurred when disconnecting authentication with `{}` identity provider for user `{}`; ' \ + '{}'.format(provider, trans.user.username, str(e)) + log.exception(msg) + return False, msg, None diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py new file mode 100644 index 000000000000..831c349eeeb5 --- /dev/null +++ b/lib/galaxy/authnz/psa_authnz.py @@ -0,0 +1,289 @@ +import six +from social_core.actions import do_auth, do_complete, do_disconnect +from social_core.backends.utils import get_backend +from social_core.strategy import BaseStrategy +from social_core.utils import module_member, setting_name +from sqlalchemy.exc import IntegrityError + +from ..authnz import IdentityProvider +from ..model import PSAAssociation, PSACode, PSANonce, PSAPartial, UserAuthnzToken + + +# key: a component name which PSA requests. +# value: is the name of a class associated with that key. +DEFAULTS = { + 'STRATEGY': 'Strategy', + 'STORAGE': 'Storage' +} + +BACKENDS = { + 'google': 'social_core.backends.google_openidconnect.GoogleOpenIdConnect' +} + +BACKENDS_NAME = { + 'google': 'google-openidconnect' +} + +AUTH_PIPELINE = ( + # Get the information we can about the user and return it in a simple + # format to create the user instance later. On some cases the details are + # already part of the auth response from the provider, but sometimes this + # could hit a provider API. + 'social_core.pipeline.social_auth.social_details', + + # Get the social uid from whichever service we're authing thru. The uid is + # the unique identifier of the given user in the provider. + 'social_core.pipeline.social_auth.social_uid', + + # Verifies that the current auth process is valid within the current + # project, this is where emails and domains whitelists are applied (if + # defined). + 'social_core.pipeline.social_auth.auth_allowed', + + # Checks if the current social-account is already associated in the site. + 'social_core.pipeline.social_auth.social_user', + + # Make up a username for this person, appends a random string at the end if + # there's any collision. + 'social_core.pipeline.user.get_username', + + # Send a validation email to the user to verify its email address. + # 'social_core.pipeline.mail.mail_validation', + + # Associates the current social details with another user account with + # a similar email address. + 'social_core.pipeline.social_auth.associate_by_email', + + # Create a user account if we haven't found one yet. + 'social_core.pipeline.user.create_user', + + # Create the record that associated the social account with this user. + 'social_core.pipeline.social_auth.associate_user', + + # Populate the extra_data field in the social record with the values + # specified by settings (and the default ones like access_token, etc). + 'social_core.pipeline.social_auth.load_extra_data', + + # Update the user record with any changed info from the auth service. + 'social_core.pipeline.user.user_details' +) + +DISCONNECT_PIPELINE = ( + 'galaxy.authnz.psa_authnz.allowed_to_disconnect', + 'galaxy.authnz.psa_authnz.disconnect' +) + + +class PSAAuthnz(IdentityProvider): + def __init__(self, provider, oidc_config, oidc_backend_config): + self.config = {'provider': provider.lower()} + for key, value in oidc_config.iteritems(): + self.config[setting_name(key)] = value + + self.config[setting_name('USER_MODEL')] = 'models.User' + self.config['SOCIAL_AUTH_PIPELINE'] = AUTH_PIPELINE + self.config['DISCONNECT_PIPELINE'] = DISCONNECT_PIPELINE + self.config[setting_name('AUTHENTICATION_BACKENDS')] = (BACKENDS[provider],) + + # The following config sets PSA to call the `_login_user` function for + # logging in a user. If this setting is set to false, the `_login_user` + # would not be called, and as a result Galaxy would not know who is + # the just logged-in user. + self.config[setting_name('INACTIVE_USER_LOGIN')] = True + + if provider == 'google': + self._setup_google_backend(oidc_backend_config) + + def _setup_google_backend(self, oidc_backend_config): + self.config[setting_name('AUTH_EXTRA_ARGUMENTS')] = {'access_type': 'offline'} + self.config['SOCIAL_AUTH_GOOGLE_OPENIDCONNECT_KEY'] = oidc_backend_config.get('client_id') + self.config['SOCIAL_AUTH_GOOGLE_OPENIDCONNECT_SECRET'] = oidc_backend_config.get('client_secret') + self.config['redirect_uri'] = oidc_backend_config.get('redirect_uri') + if oidc_backend_config.get('prompt') is not None: + self.config[setting_name('AUTH_EXTRA_ARGUMENTS')]['prompt'] = oidc_backend_config.get('prompt') + + def _on_the_fly_config(self, trans): + trans.app.model.PSACode.trans = trans + trans.app.model.UserAuthnzToken.trans = trans + trans.app.model.PSANonce.trans = trans + trans.app.model.PSAPartial.trans = trans + trans.app.model.PSAAssociation.trans = trans + + def _get_helper(self, name, do_import=False): + this_config = self.config.get(setting_name(name), DEFAULTS.get(name, None)) + return do_import and module_member(this_config) or this_config + + def _get_current_user(self, trans): + return trans.user if trans.user is not None else None + + def _load_backend(self, strategy, redirect_uri): + backends = self._get_helper('AUTHENTICATION_BACKENDS') + backend = get_backend(backends, BACKENDS_NAME[self.config['provider']]) + return backend(strategy, redirect_uri) + + def _login_user(self, backend, user, social_user): + self.config['user'] = user + + def authenticate(self, trans): + self._on_the_fly_config(trans) + strategy = Strategy(trans, Storage, self.config) + backend = self._load_backend(strategy, self.config['redirect_uri']) + return do_auth(backend) + + def callback(self, state_token, authz_code, trans, login_redirect_url): + self._on_the_fly_config(trans) + self.config[setting_name('LOGIN_REDIRECT_URL')] = login_redirect_url + strategy = Strategy(trans, Storage, self.config) + strategy.session_set(BACKENDS_NAME[self.config['provider']] + '_state', state_token) + backend = self._load_backend(strategy, self.config['redirect_uri']) + redirect_url = do_complete( + backend, + login=lambda backend, user, social_user: self._login_user(backend, user, social_user), + user=self._get_current_user(trans), + state=state_token) + return redirect_url, self.config.get('user', None) + + def disconnect(self, provider, trans, disconnect_redirect_url=None, association_id=None): + self._on_the_fly_config(trans) + self.config[setting_name('DISCONNECT_REDIRECT_URL')] =\ + disconnect_redirect_url if disconnect_redirect_url is not None else () + strategy = Strategy(trans, Storage, self.config) + backend = self._load_backend(strategy, self.config['redirect_uri']) + response = do_disconnect(backend, self._get_current_user(trans), association_id) + if isinstance(response, six.string_types): + return True, "", response + return response.get('success', False), response.get('message', ""), "" + + +class Strategy(BaseStrategy): + + def __init__(self, trans, storage, config, tpl=None): + self.trans = trans + self.request = trans.request + self.session = trans.session if trans.session else {} + self.config = config + self.config['SOCIAL_AUTH_REDIRECT_IS_HTTPS'] = True if self.trans.request.host.startswith('https:') else False + self.config['SOCIAL_AUTH_GOOGLE_OPENIDCONNECT_EXTRA_DATA'] = ['id_token'] + super(Strategy, self).__init__(storage, tpl) + + def get_setting(self, name): + return self.config[name] + + def session_get(self, name, default=None): + return self.session.get(name, default) + + def session_set(self, name, value): + self.session[name] = value + + def session_pop(self, name): + raise NotImplementedError('Not implemented.') + + def request_data(self, merge=True): + if not self.request: + return {} + if merge: + data = self.request.GET.copy() + data.update(self.request.POST) + elif self.request.method == 'POST': + data = self.request.POST + else: + data = self.request.GET + return data + + def request_host(self): + if self.request: + return self.request.host + + def build_absolute_uri(self, path=None): + path = path or '' + if path.startswith('http://') or path.startswith('https://'): + return path + return \ + self.trans.request.host +\ + '/authn' + ('/' + self.config.get('provider')) if self.config.get('provider', None) is not None else '' + + def redirect(self, url): + return url + + def html(self, content): + raise NotImplementedError('Not implemented.') + + def render_html(self, tpl=None, html=None, context=None): + raise NotImplementedError('Not implemented.') + + def start(self): + self.clean_partial_pipeline() + if self.backend.uses_redirect(): + return self.redirect(self.backend.auth_url()) + else: + return self.html(self.backend.auth_html()) + + def complete(self, *args, **kwargs): + return self.backend.auth_complete(*args, **kwargs) + + def continue_pipeline(self, *args, **kwargs): + return self.backend.continue_pipeline(*args, **kwargs) + + +class Storage: + user = UserAuthnzToken + nonce = PSANonce + association = PSAAssociation + code = PSACode + partial = PSAPartial + + @classmethod + def is_integrity_error(cls, exception): + return exception.__class__ is IntegrityError + + +def allowed_to_disconnect(name=None, user=None, user_storage=None, strategy=None, + backend=None, request=None, details=None, **kwargs): + """ + Disconnect is the process of disassociating a Galaxy user and a third-party authnz. + In other words, it is the process of removing any access and/or ID tokens of a user. + This function should raise an exception if disconnection is NOT permitted. Do NOT + return any value (except an empty dictionary) if disconnect is allowed. Because, at + least until PSA social_core v.1.5.0, any returned value (e.g., Boolean) will result + in ignoring the rest of the disconnect pipeline. + See the following condition in `run_pipeline` function: + https://github.com/python-social-auth/social-core/blob/master/social_core/backends/base.py#L114 + :param name: name of the backend (e.g., google-openidconnect) + :type user: galaxy.model.User + :type user_storage: galaxy.model.UserAuthnzToken + :type strategy: galaxy.authnz.psa_authnz.Strategy + :type backend: PSA backend object (e.g., social_core.backends.google_openidconnect.GoogleOpenIdConnect) + :type request: webob.multidict.MultiDict + :type details: dict + :return: empty dict + """ + pass + + +def disconnect(name=None, user=None, user_storage=None, strategy=None, + backend=None, request=None, details=None, **kwargs): + """ + Disconnect is the process of disassociating a Galaxy user and a third-party authnz. + In other words, it is the process of removing any access and/or ID tokens of a user. + :param name: name of the backend (e.g., google-openidconnect) + :type user: galaxy.model.User + :type user_storage: galaxy.model.UserAuthnzToken + :type strategy: galaxy.authnz.psa_authnz.Strategy + :type backend: PSA backend object (e.g., social_core.backends.google_openidconnect.GoogleOpenIdConnect) + :type request: webob.multidict.MultiDict + :type details: dict + :return: void or empty dict. Any key-value pair inside the dictionary will be available + inside PSA only, and will be passed to the next step in the disconnect pipeline. However, + the key-value pair will not be returned as a result of calling the `do_disconnect` function. + Additionally, returning any value except for a(n) (empty) dictionary, will break the + disconnect pipeline, and that value will be returned as a result of calling the `do_disconnect` function. + """ + user_authnz = strategy.trans.sa_session.query(user_storage).filter(user_storage.table.c.user_id == user.id, + user_storage.table.c.provider == name).first() + if user_authnz is None: + return {'success': False, 'message': 'Not authenticated by any identity providers.'} + # option A + strategy.trans.sa_session.delete(user_authnz) + # option B + # user_authnz.extra_data = None + strategy.trans.sa_session.flush() diff --git a/lib/galaxy/config.py b/lib/galaxy/config.py index 9c7a0633da84..79f67c1bc9a9 100644 --- a/lib/galaxy/config.py +++ b/lib/galaxy/config.py @@ -208,6 +208,10 @@ def __init__(self, **kwargs): self.tool_data_path = resolve_path(kwargs.get("tool_data_path", "tool-data"), os.getcwd()) self.builds_file_path = resolve_path(kwargs.get("builds_file_path", os.path.join(self.tool_data_path, 'shared', 'ucsc', 'builds.txt')), self.root) self.len_file_path = resolve_path(kwargs.get("len_file_path", os.path.join(self.tool_data_path, 'shared', 'ucsc', 'chrom')), self.root) + # Galaxy OIDC settings. + self.enable_oidc = kwargs.get("enable_oidc", False) + self.oidc_config = kwargs.get('oidc_config_file', None) + self.oidc_backends_config = kwargs.get("oidc_backends_config_file", None) # The value of migrated_tools_config is the file reserved for containing only those tools that have been eliminated from the distribution # and moved to the tool shed. self.integrated_tool_panel_config = resolve_path(kwargs.get('integrated_tool_panel_config', 'integrated_tool_panel.xml'), self.root) diff --git a/lib/galaxy/dependencies/pipfiles/default/Pipfile b/lib/galaxy/dependencies/pipfiles/default/Pipfile index d8f7744e59f9..1d195551e7f6 100644 --- a/lib/galaxy/dependencies/pipfiles/default/Pipfile +++ b/lib/galaxy/dependencies/pipfiles/default/Pipfile @@ -50,6 +50,8 @@ pyparsing = "*" "Fabric3" = "*" paramiko = "*" python-genomespaceclient = "*" +social_auth_core = "==1.5.0" +pyjwkest = "==1.4.0" [requires] python_version = "2.7" diff --git a/lib/galaxy/dependencies/pipfiles/default/pinned-requirements.txt b/lib/galaxy/dependencies/pipfiles/default/pinned-requirements.txt index 495aff6fe849..474b4f2ff486 100644 --- a/lib/galaxy/dependencies/pipfiles/default/pinned-requirements.txt +++ b/lib/galaxy/dependencies/pipfiles/default/pinned-requirements.txt @@ -5,7 +5,7 @@ babel==2.5.3 bagit==1.6.4 bcrypt==3.1.4 bdbag==1.2.4 -beaker==1.9.0 +beaker==1.9.1 bioblend==0.10.0 bleach==2.1.3 boltons==18.0.0 @@ -19,9 +19,9 @@ chardet==3.0.4 cheetah3==3.1.0 cliff==2.11.0 cloudbridge==0.3.3 -cmd2==0.8.2 +cmd2==0.8.3 contextlib2==0.5.5 -cryptography==2.2.1 +cryptography==2.2.2 debtcollector==1.19.0 decorator==4.2.1 deprecation==2.0 @@ -29,10 +29,11 @@ dictobj==0.4 docopt==0.6.2 docutils==0.14 dogpile.cache==0.6.5 -enum34==1.1.6; python_version < '3' +enum34==1.1.6 fabric3==1.14.post1 -funcsigs==1.0.2 +funcsigs==1.0.2; python_version == '2.7' or python_version == '2.6' functools32==3.2.3.post2; python_version == '2.7' +future==0.16.0 futures==3.2.0; python_version == '2.7' or python_version == '2.6' galaxy-sequence-utils==1.1.2 globus-sdk==1.3.0 @@ -42,7 +43,7 @@ idna==2.6 ipaddress==1.0.19; python_version < '3.3' iso8601==0.1.12 jmespath==0.9.3 -jsonpatch==1.21 +jsonpatch==1.23 jsonpointer==2.0 jsonschema==2.6.0 keystoneauth1==3.4.0 @@ -52,16 +53,17 @@ markupsafe==1.0 mercurial==3.7.3; python_version < '3' monotonic==1.4 msgpack==0.5.6 -munch==2.2.0 +munch==2.3.0 netaddr==0.7.19 netifaces==0.10.6 nose==1.3.7 numpy==1.14.2 +oauthlib==2.0.7 openstacksdk==0.12.0 os-client-config==1.29.0 os-service-types==1.2.0 osc-lib==1.10.0 -oslo.config==5.2.0 +oslo.config==6.0.1 oslo.i18n==3.20.0 oslo.serialization==2.25.0 oslo.utils==3.36.0 @@ -71,7 +73,7 @@ parsley==1.3 paste==2.0.3 pastedeploy==1.5.2 pastescript==2.0.2 -pbr==3.1.1 +pbr==4.0.1 positional==1.2.1 prettytable==0.7.2 psutil==5.4.3 @@ -80,6 +82,9 @@ py2-ipaddress==3.4.1 pyasn1==0.4.2 pycparser==2.18 pycrypto==2.6.1 +pycryptodomex==3.6.0 +pyjwkest==1.4.0 +pyjwt==1.6.1 pykwalify==1.6.1 pynacl==1.2.1 pyparsing==2.2.0 @@ -93,10 +98,12 @@ python-keystoneclient==3.10.0 python-lzo==1.11 python-neutronclient==6.1.0 python-novaclient==7.0.0 +python-openid==2.2.5 python-swiftclient==3.3.0 pytz==2018.3 pyyaml==3.12 repoze.lru==0.7 +requests-oauthlib==0.8.0 requests-toolbelt==0.8.0 requests==2.18.4 requestsexceptions==1.4.0 @@ -105,9 +112,10 @@ rfc3986==1.1.0 routes==2.4.1 simplejson==3.13.2 six==1.11.0 +social-auth-core==1.5.0 sqlalchemy-migrate==0.11.0 -sqlalchemy-utils==0.33.1 -sqlalchemy==1.2.5 +sqlalchemy-utils==0.33.2 +sqlalchemy==1.2.6 sqlparse==0.2.4 stevedore==1.28.0 subprocess32==3.2.7 diff --git a/lib/galaxy/dependencies/pipfiles/develop/Pipfile.lock b/lib/galaxy/dependencies/pipfiles/develop/Pipfile.lock index 8a6c98af4c45..99688635895c 100644 --- a/lib/galaxy/dependencies/pipfiles/develop/Pipfile.lock +++ b/lib/galaxy/dependencies/pipfiles/develop/Pipfile.lock @@ -192,10 +192,10 @@ }, "pbr": { "hashes": [ - "sha256:05f61c71aaefc02d8e37c0a3eeb9815ff526ea28b3b76324769e6158d7f95be1", - "sha256:60c25b7dfd054ef9bb0ae327af949dd4676aa09ac3a9471cdc871d8a9213f9ac" + "sha256:56b7a8ba7d64bf6135a9dfefb85a80d95924b3fde5ed6343a1a1d464a040dae3", + "sha256:de75cf1d510542c746beeff66b52241eb12c8f95f2ef846ee50ed5d72392caa4" ], - "version": "==3.1.1" + "version": "==4.0.1" }, "pygithub3": { "hashes": [ @@ -310,11 +310,11 @@ }, "sphinx-rtd-theme": { "hashes": [ - "sha256:2df74b8ff6fae6965c527e97cca6c6c944886aae474b490e17f92adfbe843417", - "sha256:62ee4752716e698bad7de8a18906f42d33664128eea06c46b718fc7fbd1a9f5c" + "sha256:220dbf14814001c6475f0c6c25ac4129a18fb5e3681251a7c6ffb1646da5cc30", + "sha256:665135dfbdf8f1d218442458a18cf266444354b8c98eed93d1543f7e701cfdba" ], "index": "galaxy", - "version": "==0.2.4" + "version": "==0.3.0" }, "sphinxcontrib-websupport": { "hashes": [ @@ -325,11 +325,11 @@ }, "testfixtures": { "hashes": [ - "sha256:338aed9695c432b7c9b8a271dabb521e3e7e2c96b11f7b4e60552f1c8408a8f0", - "sha256:53b8e493366a910f5690749dbfccabbb94e0aac0747e95d267b5a37ac2fc4fe9" + "sha256:dd3fdc1b252df3ba5a03a8504b942890abe5d9b1755bc280d0bb8aa9fe914371", + "sha256:f6c4cf24d043f9d8e9a9337371ec1d2f6638a0032504bd67dbd724224fd64969" ], "index": "galaxy", - "version": "==5.4.0" + "version": "==6.0.0" }, "twill": { "hashes": [ diff --git a/lib/galaxy/dependencies/pipfiles/develop/pinned-requirements.txt b/lib/galaxy/dependencies/pipfiles/develop/pinned-requirements.txt index 8c075d018a9c..60646900ce07 100644 --- a/lib/galaxy/dependencies/pipfiles/develop/pinned-requirements.txt +++ b/lib/galaxy/dependencies/pipfiles/develop/pinned-requirements.txt @@ -17,7 +17,7 @@ nose==1.3.7 nosehtml==0.4.4 packaging==17.1 pathtools==0.1.2 -pbr==3.1.1 +pbr==4.0.1 pygithub3==0.5.1; python_version < '3' pygments==2.2.0 pyparsing==2.2.0 @@ -28,10 +28,10 @@ requests==2.18.4 selenium==3.11.0 six==1.11.0 snowballstemmer==1.2.1 -sphinx-rtd-theme==0.2.4 +sphinx-rtd-theme==0.3.0 sphinx==1.7.2 sphinxcontrib-websupport==1.0.1 -testfixtures==5.4.0 +testfixtures==6.0.0 twill==0.9.1; python_version < '3' typing==3.6.4; python_version < '3.5' urllib3==1.22 diff --git a/lib/galaxy/dependencies/requirements.txt b/lib/galaxy/dependencies/requirements.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 3513af679caf..daae3897dc45 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -4,6 +4,7 @@ Naming: try to use class names that have a distinct plural form so that the relationship cardinalities are obvious (e.g. prefer Dataset to Data) """ +import base64 import errno import json import logging @@ -11,12 +12,15 @@ import operator import os import pwd +import random +import string import time from datetime import datetime, timedelta from string import Template from uuid import UUID, uuid4 from six import string_types +from social_core.storage import AssociationMixin, CodeMixin, NonceMixin, PartialMixin, UserMixin from sqlalchemy import ( and_, func, @@ -30,7 +34,7 @@ types) from sqlalchemy.ext import hybrid from sqlalchemy.orm import aliased, joinedload, object_session - +from sqlalchemy.schema import UniqueConstraint import galaxy.model.metadata import galaxy.model.orm.now @@ -203,7 +207,7 @@ class User(Dictifiable): # attributes that will be accessed and returned when calling to_dict( view='element' ) dict_element_visible_keys = ['id', 'email', 'username', 'total_disk_usage', 'nice_total_disk_usage', 'deleted', 'active', 'last_password_change'] - def __init__(self, email=None, password=None): + def __init__(self, email=None, password=None, username=None): self.email = email self.password = password self.external = False @@ -211,7 +215,7 @@ def __init__(self, email=None, password=None): self.purged = False self.active = False self.activation_token = None - self.username = None + self.username = username self.last_password_change = None # Relationships self.histories = [] @@ -239,6 +243,14 @@ def set_password_cleartext(self, cleartext): self.password = new_secure_hash(text_type=cleartext) self.last_password_change = datetime.now() + def set_random_password(self, length=16): + """ + Sets user password to a random string of the given length. + :return: void + """ + self.set_password_cleartext( + ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(length))) + def check_password(self, cleartext): """ Check if `cleartext` matches user password when hashed. @@ -426,6 +438,18 @@ def expand_user_properties(user, in_string): environment = User.user_template_environment(user) return Template(in_string).safe_substitute(environment) + def is_active(self): + return self.active + + def is_authenticated(self): + # TODO: is required for python social auth (PSA); however, a user authentication is relative to the backend. + # For instance, a user who is authenticated with Google, is not necessarily authenticated + # with Amazon. Therefore, this function should also receive the backend and check if this + # user is already authenticated on that backend or not. For now, returning always True + # seems reasonable. Besides, this is also how a PSA example is implemented: + # https://github.com/python-social-auth/social-examples/blob/master/example-cherrypy/example/db/user.py + return True + class PasswordResetToken(object): def __init__(self, user, token=None): @@ -4446,6 +4470,223 @@ def __init__(self, user=None, session=None, openid=None): self.openid = openid +class PSAAssociation(AssociationMixin): + + # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction + # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz + trans = None + + def __init__(self, server_url=None, handle=None, secret=None, issued=None, lifetime=None, assoc_type=None): + self.server_url = server_url + self.handle = handle + self.secret = secret + self.issued = issued + self.lifetime = lifetime + self.assoc_type = assoc_type + + def save(self): + self.trans.sa_session.add(self) + self.trans.sa_session.flush() + + @classmethod + def store(cls, server_url, association): + try: + assoc = cls.trans.sa_session.query(cls).filter_by(server_url=server_url, handle=association.handle)[0] + except IndexError: + assoc = cls(server_url=server_url, handle=association.handle) + assoc.secret = base64.encodestring(association.secret).decode() + assoc.issued = association.issued + assoc.lifetime = association.lifetime + assoc.assoc_type = association.assoc_type + cls.trans.sa_session.add(assoc) + cls.trans.sa_session.flush() + + @classmethod + def get(cls, *args, **kwargs): + return cls.trans.sa_session.query(cls).filter_by(*args, **kwargs) + + @classmethod + def remove(cls, ids_to_delete): + cls.trans.sa_session.query(cls).filter(cls.id.in_(ids_to_delete)).delete(synchronize_session='fetch') + + +class PSACode(CodeMixin): + __table_args__ = (UniqueConstraint('code', 'email'),) + + # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction + # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz + trans = None + + def __init__(self, email, code): + self.email = email + self.code = code + + def save(self): + self.trans.sa_session.add(self) + self.trans.sa_session.flush() + + @classmethod + def get_code(cls, code): + return cls.trans.sa_session.query(cls).filter(cls.code == code).first() + + +class PSANonce(NonceMixin): + + # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction + # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz + trans = None + + def __init__(self, server_url, timestamp, salt): + self.server_url = server_url + self.timestamp = timestamp + self.salt = salt + + def save(self): + self.trans.sa_session.add(self) + self.trans.sa_session.flush() + + @classmethod + def use(cls, server_url, timestamp, salt): + try: + return cls.trans.sa_session.query(cls).filter_by(server_url=server_url, timestamp=timestamp, salt=salt)[0] + except IndexError: + instance = cls(server_url=server_url, timestamp=timestamp, salt=salt) + cls.trans.sa_session.add(instance) + cls.trans.sa_session.flush() + return instance + + +class PSAPartial(PartialMixin): + + # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction + # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz + trans = None + + def __init__(self, token, data, next_step, backend): + self.token = token + self.data = data + self.next_step = next_step + self.backend = backend + + def save(self): + self.trans.sa_session.add(self) + self.trans.sa_session.flush() + + @classmethod + def load(cls, token): + return cls.trans.sa_session.query(cls).filter(cls.token == token).first() + + @classmethod + def destroy(cls, token): + partial = cls.load(token) + if partial: + cls.trans.sa_session.delete(partial) + + +class UserAuthnzToken(UserMixin): + __table_args__ = (UniqueConstraint('provider', 'uid'),) + + # This static property is of type: galaxy.web.framework.webapp.GalaxyWebTransaction + # and it is set in: galaxy.authnz.psa_authnz.PSAAuthnz + trans = None + + def __init__(self, provider, uid, extra_data=None, lifetime=None, assoc_type=None, user=None): + self.provider = provider + self.uid = uid + self.user_id = user.id + self.extra_data = extra_data + self.lifetime = lifetime + self.assoc_type = assoc_type + + def get_id_token(self): + return self.extra_data.get('id_token', None) if self.extra_data is not None else None + + def get_access_token(self): + return self.extra_data.get('access_token', None) if self.extra_data is not None else None + + def set_extra_data(self, extra_data=None): + # Note: the following unicode conversion is a temporary solution for a + # database binding error (InterfaceError: (sqlite3.InterfaceError)). + if extra_data is not None: + extra_data = str(extra_data) + if super(UserAuthnzToken, self).set_extra_data(extra_data): + self.trans.sa_session.add(self) + self.trans.sa_session.flush() + + def save(self): + self.trans.sa_session.add(self) + self.trans.sa_session.flush() + + @classmethod + def username_max_length(cls): + # Note: This is the maximum field length set for the username column of the galaxy_user table. + # A better alternative is to retrieve this number from the table, instead of this const value. + return 255 + + @classmethod + def user_model(cls): + return User + + @classmethod + def changed(cls, user): + cls.trans.sa_session.add(user) + cls.trans.sa_session.flush() + + @classmethod + def user_query(cls): + return cls.trans.sa_session.query(cls.user_model()) + + @classmethod + def user_exists(cls, *args, **kwargs): + return cls.user_query().filter_by(*args, **kwargs).count() > 0 + + @classmethod + def get_username(cls, user): + return getattr(user, 'username', None) + + @classmethod + def create_user(cls, *args, **kwargs): + model = cls.user_model() + instance = model(*args, **kwargs) + instance.set_random_password() + cls.trans.sa_session.add(instance) + cls.trans.sa_session.flush() + return instance + + @classmethod + def get_user(cls, pk): + return cls.user_query().get(pk) + + @classmethod + def get_users_by_email(cls, email): + return cls.user_query().filter_by(email=email) + + @classmethod + def get_social_auth(cls, provider, uid): + uid = str(uid) + try: + return cls.trans.sa_session.query(cls).filter_by(provider=provider, uid=uid)[0] + except IndexError: + return None + + @classmethod + def get_social_auth_for_user(cls, user, provider=None, id=None): + qs = cls.trans.sa_session.query(cls).filter_by(user_id=user.id) + if provider: + qs = qs.filter_by(provider=provider) + if id: + qs = qs.filter_by(id=id) + return qs + + @classmethod + def create_social_auth(cls, user, uid, provider): + uid = str(uid) + instance = cls(user=user, uid=uid, provider=provider) + cls.trans.sa_session.add(instance) + cls.trans.sa_session.flush() + return instance + + class Page(Dictifiable): dict_element_visible_keys = ['id', 'title', 'latest_revision_id', 'slug', 'published', 'importable', 'deleted'] diff --git a/lib/galaxy/model/mapping.py b/lib/galaxy/model/mapping.py index 0e2a5be1183e..a46a0af3d6a6 100644 --- a/lib/galaxy/model/mapping.py +++ b/lib/galaxy/model/mapping.py @@ -20,13 +20,13 @@ not_, Numeric, select, - String, - Table, + String, Table, TEXT, Text, true, Unicode, - UniqueConstraint + UniqueConstraint, + VARCHAR ) from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.orderinglist import ordering_list @@ -92,6 +92,47 @@ Column("openid", TEXT, index=True, unique=True), Column("provider", TrimmedString(255))) +model.PSAAssociation.table = Table( + "psa_association", metadata, + Column('id', Integer, primary_key=True), + Column('server_url', VARCHAR(255)), + Column('handle', VARCHAR(255)), + Column('secret', VARCHAR(255)), + Column('issued', Integer), + Column('lifetime', Integer), + Column('assoc_type', VARCHAR(64))) + +model.PSACode.table = Table( + "psa_code", metadata, + Column('id', Integer, primary_key=True), + Column('email', VARCHAR(200)), + Column('code', VARCHAR(32))) + +model.PSANonce.table = Table( + "psa_nonce", metadata, + Column('id', Integer, primary_key=True), + Column('server_url', VARCHAR(255)), + Column('timestamp', Integer), + Column('salt', VARCHAR(40))) + +model.PSAPartial.table = Table( + "psa_partial", metadata, + Column('id', Integer, primary_key=True), + Column('token', VARCHAR(32)), + Column('data', TEXT), + Column('next_step', Integer), + Column('backend', VARCHAR(32))) + +model.UserAuthnzToken.table = Table( + "oidc_user_authnz_tokens", metadata, + Column('id', Integer, primary_key=True), + Column('user_id', Integer, ForeignKey("galaxy_user.id"), index=True), + Column('uid', VARCHAR(255)), + Column('provider', VARCHAR(32)), + Column('extra_data', TEXT), + Column('lifetime', Integer), + Column('assoc_type', VARCHAR(64))) + model.PasswordResetToken.table = Table( "password_reset_token", metadata, Column("token", String(32), primary_key=True, unique=True, index=True), @@ -1422,6 +1463,20 @@ def simple_mapping(model, **kwds): order_by=desc(model.UserOpenID.table.c.update_time)) )) +mapper(model.PSAAssociation, model.PSAAssociation.table, properties=None) + +mapper(model.PSACode, model.PSACode.table, properties=None) + +mapper(model.PSANonce, model.PSANonce.table, properties=None) + +mapper(model.PSAPartial, model.PSAPartial.table, properties=None) + +mapper(model.UserAuthnzToken, model.UserAuthnzToken.table, properties=dict( + user=relation(model.User, + primaryjoin=(model.UserAuthnzToken.table.c.user_id == model.User.table.c.id), + backref='social_auth') +)) + mapper(model.ValidationError, model.ValidationError.table) simple_mapping(model.HistoryDatasetAssociation, diff --git a/lib/galaxy/model/migrate/versions/0141_add_oidc_tables.py b/lib/galaxy/model/migrate/versions/0141_add_oidc_tables.py new file mode 100644 index 000000000000..3d91bbca75e2 --- /dev/null +++ b/lib/galaxy/model/migrate/versions/0141_add_oidc_tables.py @@ -0,0 +1,85 @@ +""" +Migration script to add a new tables for an OpenID Connect authentication and authorization. +""" +from __future__ import print_function + +import logging + +from sqlalchemy import Column, ForeignKey, Integer, MetaData, Table, TEXT, VARCHAR + +log = logging.getLogger(__name__) +metadata = MetaData() + +psa_association = Table( + "psa_association", metadata, + Column('id', Integer, primary_key=True), + Column('server_url', VARCHAR(255)), + Column('handle', VARCHAR(255)), + Column('secret', VARCHAR(255)), + Column('issued', Integer), + Column('lifetime', Integer), + Column('assoc_type', VARCHAR(64))) + + +psa_code = Table( + "psa_code", metadata, + Column('id', Integer, primary_key=True), + Column('email', VARCHAR(200)), + Column('code', VARCHAR(32))) + + +psa_nonce = Table( + "psa_nonce", metadata, + Column('id', Integer, primary_key=True), + Column('server_url', VARCHAR(255)), + Column('timestamp', Integer), + Column('salt', VARCHAR(40))) + + +psa_partial = Table( + "psa_partial", metadata, + Column('id', Integer, primary_key=True), + Column('token', VARCHAR(32)), + Column('data', TEXT), + Column('next_step', Integer), + Column('backend', VARCHAR(32))) + + +oidc_user_authnz_tokens = Table( + "oidc_user_authnz_tokens", metadata, + Column('id', Integer, primary_key=True), + Column('user_id', Integer, ForeignKey("galaxy_user.id"), index=True), + Column('uid', VARCHAR(255)), + Column('provider', VARCHAR(32)), + Column('extra_data', TEXT), + Column('lifetime', Integer), + Column('assoc_type', VARCHAR(64))) + + +def upgrade(migrate_engine): + print(__doc__) + metadata.bind = migrate_engine + metadata.reflect() + + try: + psa_association.create() + psa_code.create() + psa_nonce.create() + psa_partial.create() + oidc_user_authnz_tokens.create() + except Exception as e: + log.exception("Creating OIDC table failed: %s" % str(e)) + + +def downgrade(migrate_engine): + metadata.bind = migrate_engine + metadata.reflect() + + try: + psa_association.drop() + psa_code.drop() + psa_nonce.drop() + psa_partial.drop() + oidc_user_authnz_tokens.drop() + except Exception as e: + log.exception("Dropping OIDC table failed: %s" % str(e)) diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index 0df0054cb8ef..a14865185b7a 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -66,6 +66,11 @@ def app_factory(global_conf, load_app_kwds={}, **kwargs): # Force /activate to go to the controller webapp.add_route('/activate', controller='user', action='activate') + # Authentication endpoints. + webapp.add_route('/authnz/{provider}/login', controller='authnz', action='login', provider=None) + webapp.add_route('/authnz/{provider}/callback', controller='authnz', action='callback', provider=None) + webapp.add_route('/authnz/{provider}/disconnect', controller='authnz', action='disconnect', provider=None) + # These two routes handle our simple needs at the moment webapp.add_route('/async/{tool_id}/{data_id}/{data_secret}', controller='async', action='index', tool_id=None, data_id=None, data_secret=None) webapp.add_route('/{controller}/{action}', action='index') diff --git a/lib/galaxy/webapps/galaxy/config_schema.yml b/lib/galaxy/webapps/galaxy/config_schema.yml index beb194ba5587..5e9b992a07ef 100644 --- a/lib/galaxy/webapps/galaxy/config_schema.yml +++ b/lib/galaxy/webapps/galaxy/config_schema.yml @@ -2170,6 +2170,27 @@ mapping: desc: | If OpenID is enabled, consumer cache directory to use. + enable_oidc: + type: bool + default: false + required: false + desc: | + Enables and disables OpenID Connect (OIDC) support. + + oidc_config_file: + type: str + default: config/oidc_config.xml + required: false + desc: | + Sets the path to OIDC configuration file. + + oidc_backends_config_file: + type: str + default: config/oidc_backends_config.xml + required: false + desc: | + Sets the path to OIDC backends configuration file. + auth_config_file: type: str default: config/auth_conf.xml diff --git a/lib/galaxy/webapps/galaxy/controllers/authnz.py b/lib/galaxy/webapps/galaxy/controllers/authnz.py new file mode 100644 index 000000000000..ee8aabf707cd --- /dev/null +++ b/lib/galaxy/webapps/galaxy/controllers/authnz.py @@ -0,0 +1,82 @@ +""" +OAuth 2.0 and OpenID Connect Authentication and Authorization Controller. +""" + +from __future__ import absolute_import + +import logging + +from galaxy import web +from galaxy.web import url_for +from galaxy.web.base.controller import BaseUIController + +log = logging.getLogger(__name__) + + +class OIDC(BaseUIController): + + @web.expose + def login(self, trans, provider): + if not trans.app.config.enable_oidc: + msg = "Login to Galaxy using third-party identities is not enabled on this Galaxy instance." + log.debug(msg) + return trans.show_error_message(msg) + success, message, redirect_uri = trans.app.authnz_manager.authenticate(provider, trans) + return trans.response.send_redirect(web.url_for(redirect_uri)) + + @web.expose + def callback(self, trans, provider, **kwargs): + user = trans.user.username if trans.user is not None else 'anonymous' + if not bool(kwargs): + log.error("OIDC callback received no data for provider `{}` and user `{}`".format(provider, user)) + return trans.show_error_message( + 'Did not receive any information from the `{}` identity provider to complete user `{}` authentication ' + 'flow. Please try again, and if the problem persists, contact the Galaxy instance admin. Also note ' + 'that this endpoint is to receive authentication callbacks only, and should not be called/reached by ' + 'a user.'.format(provider, user)) + if 'error' in kwargs: + log.error("Error handling authentication callback from `{}` identity provider for user `{}` login request." + " Error message: {}".format(provider, user, kwargs.get('error', 'None'))) + return trans.show_error_message('Failed to handle authentication callback from {}. ' + 'Please try again, and if the problem persists, contact ' + 'the Galaxy instance admin'.format(provider)) + success, message, (redirect_url, user) = trans.app.authnz_manager.callback(provider, + kwargs['state'], + kwargs['code'], + trans, + login_redirect_url=url_for('/')) + if success is False: + return trans.show_error_message(message) + user = user if user is not None else trans.user + if user is None: + return trans.show_error_message("An unknown error occurred when handling the callback from `{}` " + "identity provider. Please try again, and if the problem persists, " + "contact the Galaxy instance admin.".format(provider)) + trans.handle_user_login(user) + return trans.fill_template('/user/login.mako', + login=user.username, + header="", + use_panels=False, + redirect_url=redirect_url, + redirect=redirect_url, + refresh_frames='refresh_frames', + message="You are now logged in as `{}.`".format(user.username), + status='done', + openid_providers=trans.app.openid_providers, + form_input_auto_focus=True, + active_view="user") + + @web.expose + @web.require_login("authenticate against the selected identity provider") + def disconnect(self, trans, provider, **kwargs): + if trans.user is None: + # Only logged in users are allowed here. + return + success, message, redirect_url = trans.app.authnz_manager.disconnect(provider, + trans, + disconnect_redirect_url=url_for('/')) + if success is False: + return trans.show_error_message(message) + if redirect_url is None: + redirect_url = url_for('/') + return trans.response.send_redirect(redirect_url) diff --git a/templates/user/login.mako b/templates/user/login.mako index 027099392c0a..1090acff9607 100644 --- a/templates/user/login.mako +++ b/templates/user/login.mako @@ -48,6 +48,12 @@ def inherit(context): ${render_login_form()} +
+ %if hasattr(trans.app.config, 'enable_oidc') and trans.app.config.enable_oidc: +
+ ${render_oidc_form()} + %endif + %if trans.app.config.enable_openid:
${render_openid_form( redirect, False, openid_providers )} @@ -100,6 +106,27 @@ def inherit(context): +<%def name="render_oidc_form( form_action=None )"> + + <% + if form_action is None: + form_action = h.url_for( controller='authnz', action='login', provider='Google' ) + %> + + %if header: + ${header} + %endif +
+
OR
+
+
+ +
+
+
+ + + <%def name="render_openid_form( redirect, auto_associate, openid_providers )">
OpenID Login