mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-11-04 23:43:20 +08:00
354 lines
16 KiB
Python
354 lines
16 KiB
Python
"""Integration tests for the CLI shell plugins and runners."""
|
|
|
|
import html
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from string import Template
|
|
from typing import ClassVar
|
|
from urllib import parse
|
|
|
|
from galaxy import model
|
|
from galaxy.util import requests
|
|
from galaxy_test.base.api import ApiTestInteractor
|
|
from galaxy_test.driver import integration_util
|
|
|
|
KEYCLOAK_ADMIN_USERNAME = "admin"
|
|
KEYCLOAK_ADMIN_PASSWORD = "admin"
|
|
KEYCLOAK_TEST_USERNAME = "gxyuser"
|
|
KEYCLOAK_TEST_PASSWORD = "gxypass"
|
|
KEYCLOAK_HOST_PORT = 9443
|
|
KEYCLOAK_URL = f"https://localhost:{KEYCLOAK_HOST_PORT}/realms/gxyrealm"
|
|
|
|
|
|
OIDC_BACKEND_CONFIG_TEMPLATE = f"""<?xml version="1.0"?>
|
|
<OIDC>
|
|
<provider name="keycloak">
|
|
<url>{KEYCLOAK_URL}</url>
|
|
<client_id>gxyclient</client_id>
|
|
<client_secret>dummyclientsecret</client_secret>
|
|
<redirect_uri>$galaxy_url/authnz/keycloak/callback</redirect_uri>
|
|
<enable_idp_logout>true</enable_idp_logout>
|
|
<accepted_audiences>gxyclient</accepted_audiences>
|
|
</provider>
|
|
</OIDC>
|
|
"""
|
|
|
|
|
|
def wait_till_app_ready(url):
|
|
return (
|
|
subprocess.call(
|
|
[
|
|
"timeout",
|
|
"300",
|
|
"bash",
|
|
"-c",
|
|
f"'until curl --silent --output /dev/null {url}; do sleep 0.5; done'",
|
|
]
|
|
)
|
|
== 0
|
|
)
|
|
|
|
|
|
def start_keycloak_docker(container_name, image="keycloak/keycloak:22.0.1"):
|
|
keycloak_realm_data = os.path.dirname(__file__)
|
|
START_SLURM_DOCKER = [
|
|
"docker",
|
|
"run",
|
|
"-p",
|
|
f"{KEYCLOAK_HOST_PORT}:8443",
|
|
"-d",
|
|
"--name",
|
|
container_name,
|
|
"--rm",
|
|
"-v",
|
|
f"{keycloak_realm_data}:/opt/keycloak/data/import",
|
|
"-e",
|
|
f"KEYCLOAK_ADMIN={KEYCLOAK_ADMIN_USERNAME}",
|
|
"-e",
|
|
f"KEYCLOAK_ADMIN_PASSWORD={KEYCLOAK_ADMIN_PASSWORD}",
|
|
"-e",
|
|
"KC_HOSTNAME_STRICT=false",
|
|
image,
|
|
"start",
|
|
"--optimized",
|
|
"--import-realm",
|
|
"--https-certificate-file=/opt/keycloak/data/import/keycloak-server.crt.pem",
|
|
"--https-certificate-key-file=/opt/keycloak/data/import/keycloak-server.key.pem",
|
|
]
|
|
subprocess.check_call(START_SLURM_DOCKER)
|
|
wait_till_app_ready(f"http://localhost:{KEYCLOAK_HOST_PORT}")
|
|
|
|
|
|
def stop_keycloak_docker(container_name):
|
|
subprocess.check_call(["docker", "rm", "-f", container_name])
|
|
|
|
|
|
class AbstractTestCases:
|
|
@integration_util.skip_unless_docker()
|
|
class BaseKeycloakIntegrationTestCase(integration_util.IntegrationTestCase):
|
|
container_name: ClassVar[str]
|
|
backend_config_file: ClassVar[str]
|
|
saved_oauthlib_insecure_transport: ClassVar[bool]
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
# By default, the oidc callback must be done over a secure transport, so
|
|
# we forcibly disable it for now
|
|
cls.disableOauthlibHttps()
|
|
cls.container_name = f"{cls.__name__}_container"
|
|
start_keycloak_docker(container_name=cls.container_name)
|
|
super().setUpClass()
|
|
# For the oidc callback to work, we need to know Galaxy's hostname and port.
|
|
# However, we won't know what the host and port are until the Galaxy test driver is started.
|
|
# So let it start, then generate the oidc_backend_config.xml with the correct host and port,
|
|
# and finally restart Galaxy so the OIDC config takes effect.
|
|
cls.configure_oidc_and_restart()
|
|
|
|
@classmethod
|
|
def generate_oidc_config_file(cls, server_wrapper):
|
|
with tempfile.NamedTemporaryFile("w+t", delete=False) as tmp_file:
|
|
host = server_wrapper.host
|
|
port = server_wrapper.port
|
|
prefix = server_wrapper.prefix or ""
|
|
galaxy_url = f"http://{host}:{port}{prefix.rstrip('/')}"
|
|
data = Template(OIDC_BACKEND_CONFIG_TEMPLATE).safe_substitute(galaxy_url=galaxy_url)
|
|
tmp_file.write(data)
|
|
return tmp_file.name
|
|
|
|
@classmethod
|
|
def configure_oidc_and_restart(cls):
|
|
server_wrapper = cls._test_driver.server_wrappers[0]
|
|
cls.backend_config_file = cls.generate_oidc_config_file(server_wrapper)
|
|
# Explicitly assign the previously used port, as it's random otherwise
|
|
del os.environ["GALAXY_TEST_PORT_RANDOM"]
|
|
os.environ["GALAXY_TEST_PORT"] = os.environ["GALAXY_WEB_PORT"]
|
|
cls._test_driver.restart(config_object=cls, handle_config=cls.handle_galaxy_oidc_config_kwds)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
stop_keycloak_docker(cls.container_name)
|
|
cls.restoreOauthlibHttps()
|
|
os.remove(cls.backend_config_file)
|
|
super().tearDownClass()
|
|
|
|
@classmethod
|
|
def disableOauthlibHttps(cls):
|
|
if "OAUTHLIB_INSECURE_TRANSPORT" in os.environ:
|
|
cls.saved_oauthlib_insecure_transport = bool(os.environ["OAUTHLIB_INSECURE_TRANSPORT"])
|
|
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "true"
|
|
os.environ["REQUESTS_CA_BUNDLE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem"
|
|
os.environ["SSL_CERT_FILE"] = os.path.dirname(__file__) + "/keycloak-server.crt.pem"
|
|
|
|
@classmethod
|
|
def restoreOauthlibHttps(cls):
|
|
if getattr(cls, "saved_oauthlib_insecure_transport", None):
|
|
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = str(cls.saved_oauthlib_insecure_transport)
|
|
else:
|
|
del os.environ["OAUTHLIB_INSECURE_TRANSPORT"]
|
|
|
|
@classmethod
|
|
def handle_galaxy_oidc_config_kwds(cls, config):
|
|
config["enable_oidc"] = True
|
|
config["oidc_config_file"] = os.path.join(os.path.dirname(__file__), "oidc_config.xml")
|
|
config["oidc_backends_config_file"] = cls.backend_config_file
|
|
|
|
def _get_interactor(self, api_key=None, allow_anonymous=False) -> "ApiTestInteractor":
|
|
return super()._get_interactor(api_key=None, allow_anonymous=True)
|
|
|
|
|
|
class TestGalaxyOIDCLoginIntegration(AbstractTestCases.BaseKeycloakIntegrationTestCase):
|
|
REGEX_KEYCLOAK_LOGIN_ACTION = re.compile(r"action=\"(.*)\"\s+")
|
|
REGEX_GALAXY_CSRF_TOKEN = re.compile(r"session_csrf_token\": \"(.*)\"")
|
|
|
|
def _login_via_keycloak(self, username, password, expected_codes=None, save_cookies=False, session=None):
|
|
if expected_codes is None:
|
|
expected_codes = [200, 404]
|
|
session = session or requests.Session()
|
|
response = session.get(f"{self.url}authnz/keycloak/login")
|
|
provider_url = response.json()["redirect_uri"]
|
|
response = session.get(provider_url, verify=False)
|
|
matches = self.REGEX_KEYCLOAK_LOGIN_ACTION.search(response.text)
|
|
assert matches
|
|
auth_url = html.unescape(str(matches.groups(1)[0]))
|
|
response = session.post(auth_url, data={"username": username, "password": password}, verify=False)
|
|
assert response.status_code in expected_codes, response
|
|
if save_cookies:
|
|
self.galaxy_interactor.cookies = session.cookies
|
|
return session, response
|
|
|
|
def _get_keycloak_access_token(
|
|
self, client_id="gxyclient", username=KEYCLOAK_TEST_USERNAME, password=KEYCLOAK_TEST_PASSWORD, scopes=None
|
|
):
|
|
data = {
|
|
"client_id": client_id,
|
|
"client_secret": "dummyclientsecret",
|
|
"grant_type": "password",
|
|
"username": username,
|
|
"password": password,
|
|
"scope": scopes or [],
|
|
}
|
|
response = requests.post(f"{KEYCLOAK_URL}/protocol/openid-connect/token", data=data, verify=False)
|
|
return response.json()["access_token"]
|
|
|
|
def test_oidc_login_new_user(self):
|
|
_, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD, save_cookies=True)
|
|
# Should have redirected back if auth succeeded
|
|
parsed_url = parse.urlparse(response.url)
|
|
notification = parse.parse_qs(parsed_url.query)["notification"][0]
|
|
assert "Your Keycloak identity has been linked to your Galaxy account." in notification
|
|
response = self._get("users/current")
|
|
self._assert_status_code_is(response, 200)
|
|
assert response.json()["email"] == "gxyuser@galaxy.org"
|
|
|
|
def test_oidc_login_existing_user(self):
|
|
# pre-create a user account manually
|
|
sa_session = self._app.model.session
|
|
user = model.User(email="gxyuser_existing@galaxy.org", username="precreated_user")
|
|
user.set_password_cleartext("test123")
|
|
sa_session.add(user)
|
|
try:
|
|
sa_session.commit()
|
|
except Exception:
|
|
# User already exists
|
|
pass
|
|
|
|
# login with the corresponding OIDC user
|
|
_, response = self._login_via_keycloak("gxyuser_existing", KEYCLOAK_TEST_PASSWORD, save_cookies=True)
|
|
|
|
# Should prompt user to associate accounts
|
|
parsed_url = parse.urlparse(response.url)
|
|
provider = parse.parse_qs(parsed_url.query)["connect_external_provider"][0]
|
|
assert "keycloak" == provider
|
|
|
|
# user should not have been logged in
|
|
response = self._get("users/current")
|
|
assert "id" not in response.json()
|
|
|
|
def test_oidc_login_account_linkup(self):
|
|
# pre-create a user account manually
|
|
sa_session = self._app.model.session
|
|
user = model.User(email="gxyuser_existing@galaxy.org", username="precreated_user")
|
|
user.set_password_cleartext("test123")
|
|
sa_session.add(user)
|
|
try:
|
|
sa_session.commit()
|
|
except Exception:
|
|
# User already exists
|
|
pass
|
|
|
|
# establish a web session
|
|
session = requests.Session()
|
|
response = session.get(self._api_url("../login/start"))
|
|
matches = self.REGEX_GALAXY_CSRF_TOKEN.search(response.text)
|
|
assert matches
|
|
session_csrf_token = str(matches.groups(1)[0])
|
|
response = session.post(
|
|
self._api_url("../user/login"),
|
|
data={
|
|
"login": "gxyuser_existing@galaxy.org",
|
|
"password": "test123",
|
|
"session_csrf_token": session_csrf_token,
|
|
},
|
|
)
|
|
|
|
response = session.get(self._api_url("users/current"))
|
|
self._assert_status_code_is(response, 200)
|
|
assert response.json()["email"] == "gxyuser_existing@galaxy.org"
|
|
assert response.json()["username"] == "precreated_user"
|
|
|
|
# login with the corresponding OIDC user, while preserving the current session
|
|
_, response = self._login_via_keycloak(
|
|
"gxyuser_existing", KEYCLOAK_TEST_PASSWORD, save_cookies=True, session=session
|
|
)
|
|
|
|
# Should now automatically associate account
|
|
parsed_url = parse.urlparse(response.url)
|
|
notification = parse.parse_qs(parsed_url.query)["notification"][0]
|
|
assert "Your Keycloak identity has been linked to your Galaxy account." in notification
|
|
response = session.get(self._api_url("users/current"))
|
|
self._assert_status_code_is(response, 200)
|
|
assert response.json()["email"] == "gxyuser_existing@galaxy.org"
|
|
assert response.json()["username"] == "precreated_user"
|
|
|
|
# Now that the accounts are associated, future logins through OIDC should just work
|
|
session, response = self._login_via_keycloak("gxyuser_existing", KEYCLOAK_TEST_PASSWORD, save_cookies=True)
|
|
response = session.get(self._api_url("users/current"))
|
|
self._assert_status_code_is(response, 200)
|
|
assert response.json()["email"] == "gxyuser_existing@galaxy.org"
|
|
assert response.json()["username"] == "precreated_user"
|
|
|
|
def test_oidc_logout(self):
|
|
# login
|
|
session, _ = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD, save_cookies=True)
|
|
# get the user
|
|
response = session.get(self._api_url("users/current"))
|
|
self._assert_status_code_is(response, 200)
|
|
# now logout
|
|
response = session.get(self._api_url("../authnz/logout"))
|
|
response = session.get(response.json()["redirect_uri"], verify=False)
|
|
# make sure we can no longer request the user
|
|
response = session.get(self._api_url("users/current"))
|
|
self._assert_status_code_is(response, 200)
|
|
assert "email" not in response.json()
|
|
|
|
def test_auth_by_access_token_logged_in_once(self):
|
|
# login at least once
|
|
self._login_via_keycloak("gxyuser_logged_in_once", KEYCLOAK_TEST_PASSWORD)
|
|
access_token = self._get_keycloak_access_token(username="gxyuser_logged_in_once")
|
|
response = self._get("whoami", headers={"Authorization": f"Bearer {access_token}"})
|
|
self._assert_status_code_is(response, 200)
|
|
assert response.json()["email"] == "gxyuser_logged_in_once@galaxy.org"
|
|
|
|
def test_auth_by_access_token_never_logged_in(self):
|
|
# If the user has not previously logged in via OIDC at least once, OIDC API calls are not allowed
|
|
access_token = self._get_keycloak_access_token(username="gxyuser_never_logged_in")
|
|
response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"})
|
|
self._assert_status_code_is(response, 401)
|
|
assert "The user should log into Galaxy at least once" in response.json()["err_msg"]
|
|
|
|
def test_auth_with_expired_token(self):
|
|
_, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD)
|
|
access_token = self._get_keycloak_access_token()
|
|
response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"})
|
|
self._assert_status_code_is(response, 200)
|
|
# token shouldn't expire in 3 seconds, so the call should succeed
|
|
time.sleep(3)
|
|
response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"})
|
|
self._assert_status_code_is(response, 200)
|
|
# token should have expired in 7 seconds, so the call should fail
|
|
time.sleep(7)
|
|
response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"})
|
|
self._assert_status_code_is(response, 401)
|
|
|
|
def test_auth_with_another_authorized_client(self):
|
|
_, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD)
|
|
access_token = self._get_keycloak_access_token(
|
|
client_id="bpaclient", scopes=["https://galaxyproject.org/api:*"]
|
|
)
|
|
response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"})
|
|
self._assert_status_code_is(response, 200)
|
|
assert response.json()["email"] == "gxyuser@galaxy.org"
|
|
|
|
def test_auth_with_authorized_client_but_unauthorized_audience(self):
|
|
_, response = self._login_via_keycloak("bpaonlyuser", KEYCLOAK_TEST_PASSWORD)
|
|
access_token = self._get_keycloak_access_token(client_id="bpaclient", username="bpaonlyuser")
|
|
response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"})
|
|
self._assert_status_code_is(response, 401)
|
|
assert "Invalid access token" in response.json()["err_msg"]
|
|
|
|
def test_auth_with_unauthorized_client(self):
|
|
_, response = self._login_via_keycloak(KEYCLOAK_TEST_USERNAME, KEYCLOAK_TEST_PASSWORD)
|
|
access_token = self._get_keycloak_access_token(client_id="unauthorizedclient")
|
|
response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"})
|
|
self._assert_status_code_is(response, 401)
|
|
assert "Invalid access token" in response.json()["err_msg"]
|
|
|
|
def test_auth_without_required_scopes(self):
|
|
access_token = self._get_keycloak_access_token(client_id="bpaclient")
|
|
response = self._get("users/current", headers={"Authorization": f"Bearer {access_token}"})
|
|
self._assert_status_code_is(response, 401)
|
|
assert "Invalid access token" in response.json()["err_msg"]
|