From aab4750e89e1d93a904ef6ebacdeae526d85fd87 Mon Sep 17 00:00:00 2001 From: Stefan Janssen Date: Wed, 20 Mar 2024 08:26:34 +0100 Subject: [PATCH 01/61] extended configuration manager with optional OIDC sections --- qiita_core/configuration_manager.py | 51 +++++++++++++++++ qiita_core/support_files/config_test.cfg | 55 +++++++++++++++++++ .../tests/test_configuration_manager.py | 52 ++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/qiita_core/configuration_manager.py b/qiita_core/configuration_manager.py index 4c26583d9..5490e28e2 100644 --- a/qiita_core/configuration_manager.py +++ b/qiita_core/configuration_manager.py @@ -117,6 +117,24 @@ class ConfigurationManager(object): The script used to start the plugins plugin_dir : str The path to the directory containing the plugin configuration files + None (=internal user authentication) or one or several 'oidc_' sections + to use external identity providers (IdP) with following values: + client_id : str + The name you registered Qiita with at the external IdP + client_secret : str + A secret string with which Qiita identifies at the external IdP (not + all IdPs need a secret) + redirect_endpoint : str + The internal Qiita endpoint the IdP shall redirect the user after + logging in + authorize_url : str + The URL of the IdP to obtain a code (step 1) + accesstoken_url : str + The URL of the IdP to exchange the code from step 1 for an access + token (step 2) + userinfo_url : str + The URL of the IdP to obtain information about the user, like email, + username, ... Raises ------ @@ -152,6 +170,7 @@ def __init__(self): self._get_vamps(config) self._get_portal(config) self._iframe(config) + self._get_oidc(config) def _get_main(self, config): """Get the configuration of the main section""" @@ -319,3 +338,35 @@ def _get_portal(self, config): def _iframe(self, config): self.iframe_qiimp = config.get('iframe', 'QIIMP') + + def _get_oidc(self, config): + """Get the configuration of the open ID connect section(s) + User can provide multiple sections with naming schema oidc_foo where + foo is the name of an Identity Provider - Qiita can handle multiple + Identity Providers simultaneously. + """ + PREFIX = 'oidc_' + self.oidc = dict() + for section_name in config.sections(): + if section_name.startswith(PREFIX): + provider = dict() + provider['client_id'] = config.get( + section_name, 'CLIENT_ID', fallback=None) + provider['client_secret'] = config.get( + section_name, 'CLIENT_SECRET', fallback=None) + provider['redirect_endpoint'] = config.get( + section_name, 'REDIRECT_ENDPOINT') + if provider['redirect_endpoint']: + if not provider['redirect_endpoint'].startswith('/'): + provider['redirect_endpoint'] = '/%s' % provider[ + 'redirect_endpoint'] + if provider['redirect_endpoint'].endswith('/'): + provider['redirect_endpoint'] = provider[ + 'redirect_endpoint'][:-1] + provider['authorize_url'] = config.get( + section_name, 'AUTHORIZE_URL') + provider['accesstoken_url'] = config.get( + section_name, 'ACCESS_TOKEN_URL') + provider['userinfo_url'] = config.get( + section_name, 'USERINFO_URL') + self.oidc[section_name[len(PREFIX):]] = provider diff --git a/qiita_core/support_files/config_test.cfg b/qiita_core/support_files/config_test.cfg index e6423f609..0d8cb2435 100644 --- a/qiita_core/support_files/config_test.cfg +++ b/qiita_core/support_files/config_test.cfg @@ -183,3 +183,58 @@ PORTAL_FP = # The real world QIIMP will always need to be accessed with https because Qiita # runs on https too QIIMP = https://localhost:8898/ + + +# --------------------- External Identity Provider settings -------------------- +# user authentication happens per default within Qiita, i.e. when a user logs in, +# the stored password hash and email address is compared against what a user +# just provided. You might however, use an external identity provider (IdP) to +# authenticate the user like +# google: https://developers.google.com/identity/protocols/oauth2 or +# github: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps or +# self hosted keycloak: https://www.keycloak.org/ +# Thus, you don't have to deal with user verification, reset passwords, ... +# Authorization (i.e. if the authorized user is allowed to use Qiita or which +# user level he/she gets assigned is an independent process. You can even use +# multiple independent external identity providers! +# Qiita currently only support the "open ID connect" protocol with the implicit flow. +# Each identity provider comes as its own config section [oidc_foo] and needs +# to specify the following five fields: +# +# Typical identity provider manage multiple "realms" and specific "clients" per realm +# You need to contact your IdP and register Qiita as a new "client". The IdP will +# provide you with the correct values. +# +# The authorization protocol requires three steps to obtain user information: +# 1) you identify as the correct client and ask the IdP for a request code +# You have to forward the user to the login page of your IdP. To let the IdP +# know how to come back to Qiita, you need to provide a redirect URL +# 2) you exchange the code for a user token +# 3) you obtain information about the user for the obtaines user token +# Typically, each step is implemented as a separate URL endpoint +# +# To activate IdP: comment out the following config section + +#[oidc_academicid] +# +## client ID for Qiita as registered at your Identity Provider of choice +#CLIENT_ID = gi-qiita-prod +# +## client secret to verify Qiita as the correct client. Not all IdPs require this +#CLIENT_SECRET = 5M6zKl8SKrlnRP4tPgtrgZpCpcYCj7uK +# +## redirect URL (end point in your Qiita instance), to which the IdP redirects +## after user types in his/her credentials. If you don't want to change code in +## qiita_pet/webserver.py the URL must follow the pattern: +## base_URL/auth/login_OIDC/foo where foo is the name of this config section +## without the oidc_ prefix! +#REDIRECT_ENDPOINT = /auth/login_OIDC/localkeycloak +# +## URL for step 1: obtain code +#AUTHORIZE_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/auth +# +## URL for step 2: obtain user token +#ACCESS_TOKEN_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/token +# +## URL for step 3: obtain user infos +#USERINFO_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/userinfo diff --git a/qiita_core/tests/test_configuration_manager.py b/qiita_core/tests/test_configuration_manager.py index 2a60f353b..765c300ac 100644 --- a/qiita_core/tests/test_configuration_manager.py +++ b/qiita_core/tests/test_configuration_manager.py @@ -214,6 +214,32 @@ def test_get_portal(self): obs._get_portal(self.conf) self.assertEqual(obs.portal_dir, "/gold_portal") + def test_get_postgres(self): + SECTION_NAME = 'oidc_academicid' + obs = ConfigurationManager() + self.assertTrue(len(obs.oidc), 1) + self.assertTrue(obs.oidc.keys(), [SECTION_NAME]) + + # assert endpoint starts with / + self.conf.set(SECTION_NAME, 'REDIRECT_ENDPOINT', 'auth/something') + obs._get_oidc(self.conf) + self.assertEqual(obs.oidc['academicid']['redirect_endpoint'], + '/auth/something') + + # assert endpoint does not end with / + self.conf.set(SECTION_NAME, 'REDIRECT_ENDPOINT', 'auth/something/') + obs._get_oidc(self.conf) + self.assertEqual(obs.oidc['academicid']['redirect_endpoint'], + '/auth/something') + + self.conf.set(SECTION_NAME, 'CLIENT_ID', 'foo') + obs._get_oidc(self.conf) + self.assertEqual(obs.oidc['academicid']['client_id'], "foo") + + self.assertTrue('gwdg.de' in obs.oidc['academicid']['authorize_url']) + self.assertTrue('gwdg.de' in obs.oidc['academicid']['accesstoken_url']) + self.assertTrue('gwdg.de' in obs.oidc['academicid']['userinfo_url']) + CONF = """ # ------------------------------ Main settings -------------------------------- @@ -383,6 +409,32 @@ def test_get_portal(self): # ----------------------------- iframes settings --------------------------- [iframe] QIIMP = https://localhost:8898/ + +# ------------------- External Identity Provider settings ------------------ +[oidc_academicid] + +# client ID for Qiita as registered at your Identity Provider of choice +CLIENT_ID = gi-qiita-prod + +# client secret to verify Qiita as the correct client. Not all IdPs require this +CLIENT_SECRET = 5M6zKl8SKrlnRP4tPgtrgZpCpcYCj7uK + +# redirect URL (end point in your Qiita instance), to which the IdP redirects +# after user types in his/her credentials. If you don't want to change code in +# qiita_pet/webserver.py the URL must follow the pattern: +# base_URL/auth/login_OIDC/foo where foo is the name of this config section +# without the oidc_ prefix! +REDIRECT_ENDPOINT = /auth/login_OIDC/academicid + +# URL for step 1: obtain code +AUTHORIZE_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/auth + +# URL for step 2: obtain user token +ACCESS_TOKEN_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/token + +# URL for step 3: obtain user infos +USERINFO_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/userinfo + """ if __name__ == '__main__': From 49b0448bb2fcaabd4b0c46b5444a27b9953afdf5 Mon Sep 17 00:00:00 2001 From: Stefan Janssen Date: Wed, 20 Mar 2024 08:32:30 +0100 Subject: [PATCH 02/61] flake8 --- qiita_core/support_files/config_test.cfg | 3 ++- qiita_core/tests/test_configuration_manager.py | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/qiita_core/support_files/config_test.cfg b/qiita_core/support_files/config_test.cfg index 0d8cb2435..3ff881c01 100644 --- a/qiita_core/support_files/config_test.cfg +++ b/qiita_core/support_files/config_test.cfg @@ -220,7 +220,8 @@ QIIMP = https://localhost:8898/ ## client ID for Qiita as registered at your Identity Provider of choice #CLIENT_ID = gi-qiita-prod # -## client secret to verify Qiita as the correct client. Not all IdPs require this +## client secret to verify Qiita as the correct client. Not all IdPs require +## a client secret! #CLIENT_SECRET = 5M6zKl8SKrlnRP4tPgtrgZpCpcYCj7uK # ## redirect URL (end point in your Qiita instance), to which the IdP redirects diff --git a/qiita_core/tests/test_configuration_manager.py b/qiita_core/tests/test_configuration_manager.py index 765c300ac..2d7407ad5 100644 --- a/qiita_core/tests/test_configuration_manager.py +++ b/qiita_core/tests/test_configuration_manager.py @@ -214,7 +214,7 @@ def test_get_portal(self): obs._get_portal(self.conf) self.assertEqual(obs.portal_dir, "/gold_portal") - def test_get_postgres(self): + def test_get_oidc(self): SECTION_NAME = 'oidc_academicid' obs = ConfigurationManager() self.assertTrue(len(obs.oidc), 1) @@ -416,7 +416,8 @@ def test_get_postgres(self): # client ID for Qiita as registered at your Identity Provider of choice CLIENT_ID = gi-qiita-prod -# client secret to verify Qiita as the correct client. Not all IdPs require this +# client secret to verify Qiita as the correct client. Not all IdPs require +# a client secret. CLIENT_SECRET = 5M6zKl8SKrlnRP4tPgtrgZpCpcYCj7uK # redirect URL (end point in your Qiita instance), to which the IdP redirects @@ -427,14 +428,13 @@ def test_get_postgres(self): REDIRECT_ENDPOINT = /auth/login_OIDC/academicid # URL for step 1: obtain code -AUTHORIZE_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/auth +AUTHORIZE_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/auth # URL for step 2: obtain user token -ACCESS_TOKEN_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/token +ACCESS_TOKEN_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/token # URL for step 3: obtain user infos -USERINFO_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/userinfo - +USERINFO_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/userinfo """ if __name__ == '__main__': From 28406012b453418ae06031a66094ea351ed524b5 Mon Sep 17 00:00:00 2001 From: Stefan Janssen Date: Wed, 20 Mar 2024 12:12:45 +0100 Subject: [PATCH 03/61] also provide a label for a speaking name of the identity provider --- qiita_core/configuration_manager.py | 6 ++++++ qiita_core/support_files/config_test.cfg | 3 +++ qiita_core/tests/test_configuration_manager.py | 10 ++++++++++ 3 files changed, 19 insertions(+) diff --git a/qiita_core/configuration_manager.py b/qiita_core/configuration_manager.py index 5490e28e2..a8c6a9799 100644 --- a/qiita_core/configuration_manager.py +++ b/qiita_core/configuration_manager.py @@ -135,6 +135,8 @@ class ConfigurationManager(object): userinfo_url : str The URL of the IdP to obtain information about the user, like email, username, ... + label : str + A speaking label for the Identity Provider Raises ------ @@ -369,4 +371,8 @@ def _get_oidc(self, config): section_name, 'ACCESS_TOKEN_URL') provider['userinfo_url'] = config.get( section_name, 'USERINFO_URL') + provider['label'] = config.get(section_name, 'LABEL') + if not provider['label']: + # fallback, if no label is provided + provider['label'] = section_name[len(PREFIX):] self.oidc[section_name[len(PREFIX):]] = provider diff --git a/qiita_core/support_files/config_test.cfg b/qiita_core/support_files/config_test.cfg index 3ff881c01..dccfc8abf 100644 --- a/qiita_core/support_files/config_test.cfg +++ b/qiita_core/support_files/config_test.cfg @@ -239,3 +239,6 @@ QIIMP = https://localhost:8898/ # ## URL for step 3: obtain user infos #USERINFO_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/protocol/openid-connect/userinfo +# +## a speaking label for the Identity Provider. Section name is used if empty. +#LABEL = GWDG Academic Cloud diff --git a/qiita_core/tests/test_configuration_manager.py b/qiita_core/tests/test_configuration_manager.py index 2d7407ad5..5c2044f4d 100644 --- a/qiita_core/tests/test_configuration_manager.py +++ b/qiita_core/tests/test_configuration_manager.py @@ -240,6 +240,13 @@ def test_get_oidc(self): self.assertTrue('gwdg.de' in obs.oidc['academicid']['accesstoken_url']) self.assertTrue('gwdg.de' in obs.oidc['academicid']['userinfo_url']) + self.assertEqual(obs.oidc['academicid']['label'], + 'GWDG Academic Cloud') + # test fallback, if no label is provided + self.conf.set(SECTION_NAME, 'LABEL', '') + obs._get_oidc(self.conf) + self.assertEqual(obs.oidc['academicid']['label'], 'academicid') + CONF = """ # ------------------------------ Main settings -------------------------------- @@ -435,6 +442,9 @@ def test_get_oidc(self): # URL for step 3: obtain user infos USERINFO_URL = https://keycloak.sso.gwdg.de/auth/realms/academiccloud/userinfo + +# a speaking label for the Identity Provider. Section name is used if empty. +LABEL = GWDG Academic Cloud """ if __name__ == '__main__': From f1c91491799173e9a81aecf00834e9f6a0bb126e Mon Sep 17 00:00:00 2001 From: Stefan Janssen Date: Wed, 20 Mar 2024 12:13:20 +0100 Subject: [PATCH 04/61] start implementing the OIDC dance --- qiita_pet/handlers/auth_handlers.py | 19 ++++++++++++++ qiita_pet/templates/sitebase.html | 39 +++++++++++++++++++++++++++++ qiita_pet/webserver.py | 14 ++++++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/qiita_pet/handlers/auth_handlers.py b/qiita_pet/handlers/auth_handlers.py index 38759e77f..7e8762a7b 100644 --- a/qiita_pet/handlers/auth_handlers.py +++ b/qiita_pet/handlers/auth_handlers.py @@ -7,6 +7,7 @@ # ----------------------------------------------------------------------------- from tornado.escape import url_escape, json_encode +from tornado.auth import OAuth2Mixin from qiita_pet.handlers.base_handlers import BaseHandler from qiita_core.qiita_settings import qiita_config, r_client @@ -175,3 +176,21 @@ class AuthLogoutHandler(BaseHandler): def get(self): self.clear_cookie("user") self.redirect("%s/" % qiita_config.portal_dir) + + +class KeycloakMixin(OAuth2Mixin): + pass + + +class AuthLoginOIDCHandler(BaseHandler, KeycloakMixin): + async def get(self, login): + code = self.get_argument('code', False) + if code: + # step 2: we got a code and now want to exchange it for a user + # access token + print("step2") + pass + else: + # step 1: no code from IdP yet, thus retrieve one now + print("step1") + pass diff --git a/qiita_pet/templates/sitebase.html b/qiita_pet/templates/sitebase.html index 03e5dd1d0..091ad4ac7 100644 --- a/qiita_pet/templates/sitebase.html +++ b/qiita_pet/templates/sitebase.html @@ -160,6 +160,18 @@ // Based on http://codepen.io/willvincent/pen/LbeKKW // and https://datatables.net/examples/api/row_details.html + {% if len(qiita_config.oidc) > 0 %} + $('.modal').css({ + 'top': '30%', + 'margin-top': '-'+($('.modal').height() / 2)+'px', + 'margin-left': '+'+($('.modal').width() / 3)+'px'}); + $('#qiita_signin_modal').click(function(e){ + e.preventDefault(); + $('.qiita_auth_signin_options').modal('hide'); + window.location.href = '{% raw qiita_config.portal_dir %}'; + }); + {% end %} + Vue.component('data-table-processing-jobs', { template: '
', props: ['jobs'], @@ -443,6 +455,33 @@ + + {% elif len(qiita_config.oidc) == 1 %} + {% for idp in qiita_config.oidc %} + + {% end %} + + {% elif len(qiita_config.oidc) > 1 %} + {% else %}