diff --git a/.env.docker b/.env.docker index 1a94d715..1c1fe872 100644 --- a/.env.docker +++ b/.env.docker @@ -20,7 +20,7 @@ SYNCMASTER__DATABASE__URL=postgresql+asyncpg://syncmaster:changeme@db:5432/syncm # TODO: add to KeycloakAuthProvider documentation about creating new realms, add users, etc. # KEYCLOAK Auth -SYNCMASTER__AUTH__SERVER_URL=http://keycloak:8080/ +SYNCMASTER__AUTH__SERVER_URL=http://keycloak:8080 SYNCMASTER__AUTH__REALM_NAME=manually_created SYNCMASTER__AUTH__CLIENT_ID=manually_created SYNCMASTER__AUTH__CLIENT_SECRET=generated_by_keycloak diff --git a/.env.local b/.env.local index 401a8dd4..1a042d97 100644 --- a/.env.local +++ b/.env.local @@ -18,7 +18,16 @@ export SYNCMASTER__CRYPTO_KEY=UBgPTioFrtH2unlC4XFDiGf5sYfzbdSf_VgiUSaQc94= # Postgres export SYNCMASTER__DATABASE__URL=postgresql+asyncpg://syncmaster:changeme@localhost:5432/syncmaster -# Auth +# Keycloack Auth +export SYNCMASTER__AUTH__SERVER_URL=http://keycloak:8080 +export SYNCMASTER__AUTH__REALM_NAME=manually_created +export SYNCMASTER__AUTH__CLIENT_ID=manually_created +export SYNCMASTER__AUTH__CLIENT_SECRET=generated_by_keycloak +export SYNCMASTER__AUTH__REDIRECT_URI=http://localhost:8000/auth/callback +export SYNCMASTER__AUTH__SCOPE=email +export SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.keycloak_provider.KeycloakAuthProvider + +# Dummy Auth export SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy_provider.DummyAuthProvider export SYNCMASTER__AUTH__ACCESS_TOKEN__SECRET_KEY=secret diff --git a/poetry.lock b/poetry.lock index c149db27..76eea388 100644 --- a/poetry.lock +++ b/poetry.lock @@ -32,13 +32,13 @@ tz = ["backports.zoneinfo"] [[package]] name = "amqp" -version = "5.2.0" +version = "5.3.1" description = "Low-level AMQP client for Python (fork of amqplib)." optional = true python-versions = ">=3.6" files = [ - {file = "amqp-5.2.0-py3-none-any.whl", hash = "sha256:827cb12fb0baa892aad844fd95258143bce4027fdac4fccddbc43330fd281637"}, - {file = "amqp-5.2.0.tar.gz", hash = "sha256:a1ecff425ad063ad42a486c902807d1482311481c8ad95a72694b2975e75f7fd"}, + {file = "amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2"}, + {file = "amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432"}, ] [package.dependencies] @@ -961,13 +961,13 @@ gmpy2 = ["gmpy2"] [[package]] name = "etl-entities" -version = "2.3.1" +version = "2.4.0" description = "ETL Entities lib for onETL" optional = false python-versions = ">=3.7" files = [ - {file = "etl_entities-2.3.1-py3-none-any.whl", hash = "sha256:a5513bf4735ec1bf113a22285c04b4b9f42fc7dc4b42507cb72e44ab048b14bb"}, - {file = "etl_entities-2.3.1.tar.gz", hash = "sha256:81ba23b732cdae5b36e5b5a0e287eece8f1b5cf34f1d728f905b9c7838e6e35a"}, + {file = "etl_entities-2.4.0-py3-none-any.whl", hash = "sha256:44fcbeb790003124cc1fa7ddd226fadbd979f737995519d5fc6d5a5d8e634b29"}, + {file = "etl_entities-2.4.0.tar.gz", hash = "sha256:7bbf28a0d2ad2bff4fac954486f2afeda88e3171e37e1e0e7de18e40c797db93"}, ] [package.dependencies] @@ -1330,13 +1330,13 @@ kerberos = ["requests-kerberos (>=0.7.0)"] [[package]] name = "httpcore" -version = "1.0.6" +version = "1.0.7" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"}, - {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"}, + {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, + {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, ] [package.dependencies] @@ -2708,6 +2708,25 @@ files = [ [package.dependencies] requests = ">=2.0.1,<3.0.0" +[[package]] +name = "responses" +version = "0.25.3" +description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "responses-0.25.3-py3-none-any.whl", hash = "sha256:521efcbc82081ab8daa588e08f7e8a64ce79b91c39f6e62199b19159bea7dbcb"}, + {file = "responses-0.25.3.tar.gz", hash = "sha256:617b9247abd9ae28313d57a75880422d55ec63c29d33d629697590a034358dba"}, +] + +[package.dependencies] +pyyaml = "*" +requests = ">=2.30.0,<3.0" +urllib3 = ">=1.25.10,<3.0" + +[package.extras] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] + [[package]] name = "rsa" version = "4.9" @@ -2724,13 +2743,13 @@ pyasn1 = ">=0.1.3" [[package]] name = "setuptools" -version = "75.4.0" +version = "75.5.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" files = [ - {file = "setuptools-75.4.0-py3-none-any.whl", hash = "sha256:b3c5d862f98500b06ffdf7cc4499b48c46c317d8d56cb30b5c8bce4d88f5c216"}, - {file = "setuptools-75.4.0.tar.gz", hash = "sha256:1dc484f5cf56fd3fe7216d7b8df820802e7246cfb534a1db2aa64f14fcb9cdcb"}, + {file = "setuptools-75.5.0-py3-none-any.whl", hash = "sha256:87cb777c3b96d638ca02031192d40390e0ad97737e27b6b4fa831bea86f2f829"}, + {file = "setuptools-75.5.0.tar.gz", hash = "sha256:5c4ccb41111392671f02bb5f8436dfc5a9a7185e80500531b133f5775c4163ef"}, ] [package.extras] @@ -3512,4 +3531,4 @@ worker = ["asgi-correlation-id", "celery", "coloredlogs", "jinja2", "onetl", "ps [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9bc38b7e0a321ae4e2b15c8fdbd0ad09df33e6ac0ff2dfb35b878119928686b5" +content-hash = "06cb61479a9e8c0857178db7e7e6cff6515cd2ad6d6eb8b9c1bc62ed292a983c" diff --git a/pyproject.toml b/pyproject.toml index 4397b86e..4e24a8ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,7 @@ onetl = {extras = ["spark", "s3", "hdfs"], version = "^0.12.0"} faker = ">=28.4.1,<34.0.0" coverage = "^7.6.1" gevent = "^24.2.1" +responses = "*" [tool.poetry.group.dev.dependencies] mypy = "^1.11.2" diff --git a/syncmaster/backend/providers/auth/keycloak_provider.py b/syncmaster/backend/providers/auth/keycloak_provider.py index b9a97710..0594eb2c 100644 --- a/syncmaster/backend/providers/auth/keycloak_provider.py +++ b/syncmaster/backend/providers/auth/keycloak_provider.py @@ -81,6 +81,8 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: self.redirect_to_auth(request.url.path) try: + # if user is disabled or blocked in Keycloak after the token is issued, he will + # remain authorized until the token expires (not more than 15 minutes in MTS SSO) token_info = self.keycloak_openid.decode_token(token=access_token) except Exception as e: log.info("Access token is invalid or expired: %s", e) diff --git a/tests/conftest.py b/tests/conftest.py index 7e213f9c..b6308b38 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,7 @@ pytest_plugins = [ "tests.test_unit.test_transfers.transfer_fixtures", + "tests.test_unit.test_auth.auth_fixtures", "tests.test_unit.test_runs.run_fixtures", "tests.test_unit.test_connections.connection_fixtures", "tests.test_unit.test_scheduler.scheduler_fixtures", @@ -64,9 +65,9 @@ def event_loop(): loop.close() -@pytest.fixture(scope="session") -def settings(): - return Settings() +@pytest.fixture(scope="session", params=[{}]) +def settings(request: pytest.FixtureRequest) -> Settings: + return Settings.parse_obj(request.param) @pytest.fixture(scope="session") diff --git a/tests/test_unit/test_auth/__init__.py b/tests/test_unit/test_auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_unit/test_auth/auth_fixtures/__init__.py b/tests/test_unit/test_auth/auth_fixtures/__init__.py new file mode 100644 index 00000000..78437abd --- /dev/null +++ b/tests/test_unit/test_auth/auth_fixtures/__init__.py @@ -0,0 +1,7 @@ +from tests.test_unit.test_auth.auth_fixtures.keycloak_fixture import ( + create_session_cookie, + mock_keycloak_realm, + mock_keycloak_token_refresh, + mock_keycloak_well_known, + rsa_keys, +) diff --git a/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py b/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py new file mode 100644 index 00000000..fec5433a --- /dev/null +++ b/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py @@ -0,0 +1,158 @@ +import json +import time +from base64 import b64encode + +import pytest +import responses +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from itsdangerous import TimestampSigner +from jose import jwt + + +@pytest.fixture(scope="session") +def rsa_keys(): + # create private & public keys to emulate Keycloak signing + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + public_key = private_key.public_key() + + return { + "private_key": private_key, + "private_pem": private_pem, + "public_key": public_key, + } + + +def get_public_key_pem(public_key): + public_pem = public_key.public_bytes( + encoding=Encoding.PEM, + format=PublicFormat.SubjectPublicKeyInfo, + ) + public_pem_str = public_pem.decode("utf-8") + public_pem_str = public_pem_str.replace("-----BEGIN PUBLIC KEY-----\n", "") + public_pem_str = public_pem_str.replace("-----END PUBLIC KEY-----\n", "") + public_pem_str = public_pem_str.replace("\n", "") + return public_pem_str + + +@pytest.fixture +def create_session_cookie(rsa_keys, settings): + def _create_session_cookie(user, expire_in_msec=1000) -> str: + private_pem = rsa_keys["private_pem"] + session_secret_key = settings.server.session.secret_key + + payload = { + "sub": str(user.id), + "preferred_username": user.username, + "email": user.email, + "given_name": user.first_name, + "middle_name": user.middle_name, + "family_name": user.last_name, + "exp": int(time.time()) + (expire_in_msec / 1000), + } + + access_token = jwt.encode(payload, private_pem, algorithm="RS256") + refresh_token = "mock_refresh_token" + + session_data = { + "access_token": access_token, + "refresh_token": refresh_token, + } + + signer = TimestampSigner(session_secret_key) + json_bytes = json.dumps(session_data).encode("utf-8") + base64_bytes = b64encode(json_bytes) + signed_data = signer.sign(base64_bytes) + session_cookie = signed_data.decode("utf-8") + + return session_cookie + + return _create_session_cookie + + +@pytest.fixture +def mock_keycloak_well_known(settings): + server_url = settings.auth.server_url + realm_name = settings.auth.client_id + well_known_url = f"{server_url}/realms/{realm_name}/.well-known/openid-configuration" + + responses.add( + responses.GET, + well_known_url, + json={ + "authorization_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/auth", + "token_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/token", + "userinfo_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/userinfo", + "end_session_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/logout", + "jwks_uri": f"{server_url}/realms/{realm_name}/protocol/openid-connect/certs", + "issuer": f"{server_url}/realms/{realm_name}", + }, + status=200, + content_type="application/json", + ) + + +@pytest.fixture +def mock_keycloak_realm(settings, rsa_keys): + server_url = settings.auth.server_url + realm_name = settings.auth.client_id + realm_url = f"{server_url}/realms/{realm_name}" + public_pem_str = get_public_key_pem(rsa_keys["public_key"]) + + responses.add( + responses.GET, + realm_url, + json={ + "realm": realm_name, + "public_key": public_pem_str, + "token-service": f"{server_url}/realms/{realm_name}/protocol/openid-connect/token", + "account-service": f"{server_url}/realms/{realm_name}/account", + }, + status=200, + content_type="application/json", + ) + + +@pytest.fixture +def mock_keycloak_token_refresh(settings, rsa_keys): + server_url = settings.auth.server_url + realm_name = settings.auth.client_id + token_url = f"{server_url}/realms/{realm_name}/protocol/openid-connect/token" + + # generate new access and refresh tokens + expires_in = int(time.time()) + 1000 + private_pem = rsa_keys["private_pem"] + payload = { + "sub": "mock_user_id", + "preferred_username": "mock_username", + "email": "mock_email@example.com", + "given_name": "Mock", + "middle_name": "User", + "family_name": "Name", + "exp": expires_in, + } + + new_access_token = jwt.encode(payload, private_pem, algorithm="RS256") + new_refresh_token = "mock_new_refresh_token" + + responses.add( + responses.POST, + token_url, + json={ + "access_token": new_access_token, + "refresh_token": new_refresh_token, + "token_type": "bearer", + "expires_in": expires_in, + }, + status=200, + content_type="application/json", + ) diff --git a/tests/test_unit/test_auth/test_auth_keycloak.py b/tests/test_unit/test_auth/test_auth_keycloak.py new file mode 100644 index 00000000..a968340d --- /dev/null +++ b/tests/test_unit/test_auth/test_auth_keycloak.py @@ -0,0 +1,195 @@ +import logging + +import pytest +import responses +from httpx import AsyncClient + +from syncmaster.backend.settings import BackendSettings as Settings +from tests.mocks import MockUser + +KEYCLOAK_PROVIDER = "syncmaster.backend.providers.auth.keycloak_provider.KeycloakAuthProvider" +pytestmark = [pytest.mark.asyncio, pytest.mark.backend] + + +@responses.activate +@pytest.mark.parametrize( + "settings", + [ + { + "auth": { + "provider": KEYCLOAK_PROVIDER, + }, + }, + ], + indirect=True, +) +async def test_get_keycloak_user_unauthorized(client: AsyncClient, mock_keycloak_well_known): + response = await client.get("/v1/users/some_user_id") + + # redirect unauthorized user to Keycloak + assert response.status_code == 307 + assert "protocol/openid-connect/auth?" in str( + response.next_request.url, + ) + + +@responses.activate +@pytest.mark.parametrize( + "settings", + [ + { + "auth": { + "provider": KEYCLOAK_PROVIDER, + }, + }, + ], + indirect=True, +) +async def test_get_keycloak_user_authorized( + client: AsyncClient, + simple_user: MockUser, + settings: Settings, + create_session_cookie, + mock_keycloak_well_known, + mock_keycloak_realm, +): + session_cookie = create_session_cookie(simple_user) + headers = { + "Cookie": f"session={session_cookie}", + } + response = await client.get( + f"/v1/users/{simple_user.id}", + headers=headers, + ) + + assert response.cookies.get("session") == session_cookie + assert response.status_code == 200 + assert response.json() == { + "id": simple_user.id, + "is_superuser": simple_user.is_superuser, + "username": simple_user.username, + } + + +@responses.activate +@pytest.mark.parametrize( + "settings", + [ + { + "auth": { + "provider": KEYCLOAK_PROVIDER, + }, + }, + ], + indirect=True, +) +async def test_get_keycloak_user_expired_access_token( + caplog, + client: AsyncClient, + simple_user: MockUser, + settings: Settings, + create_session_cookie, + mock_keycloak_well_known, + mock_keycloak_realm, + mock_keycloak_token_refresh, +): + session_cookie = create_session_cookie(simple_user, expire_in_msec=-100000000) # expired access token + headers = { + "Cookie": f"session={session_cookie}", + } + + with caplog.at_level(logging.DEBUG): + response = await client.get( + f"/v1/users/{simple_user.id}", + headers=headers, + ) + + assert "Access token is invalid or expired" in caplog.text + assert "Access token refreshed and decoded successfully" in caplog.text + + assert response.cookies.get("session") != session_cookie # cookie is updated + assert response.status_code == 200 + assert response.json() == { + "id": simple_user.id, + "is_superuser": simple_user.is_superuser, + "username": simple_user.username, + } + + +@responses.activate +@pytest.mark.parametrize( + "settings", + [ + { + "auth": { + "provider": KEYCLOAK_PROVIDER, + }, + }, + ], + indirect=True, +) +async def test_get_keycloak_deleted_user( + client: AsyncClient, + simple_user: MockUser, + deleted_user: MockUser, + settings: Settings, + create_session_cookie, + mock_keycloak_well_known, + mock_keycloak_realm, +): + session_cookie = create_session_cookie(simple_user) + headers = { + "Cookie": f"session={session_cookie}", + } + response = await client.get( + f"/v1/users/{deleted_user.id}", + headers=headers, + ) + assert response.status_code == 404 + assert response.json() == { + "error": { + "code": "not_found", + "message": "User not found", + "details": None, + }, + } + + +@responses.activate +@pytest.mark.parametrize( + "settings", + [ + { + "auth": { + "provider": KEYCLOAK_PROVIDER, + }, + }, + ], + indirect=True, +) +async def test_get_keycloak_user_inactive( + client: AsyncClient, + simple_user: MockUser, + inactive_user: MockUser, + settings: Settings, + create_session_cookie, + mock_keycloak_well_known, + mock_keycloak_realm, +): + session_cookie = create_session_cookie(inactive_user) + headers = { + "Cookie": f"session={session_cookie}", + } + + response = await client.get( + f"/v1/users/{simple_user.id}", + headers=headers, + ) + assert response.status_code == 403 + assert response.json() == { + "error": { + "code": "forbidden", + "message": "You have no power here", + "details": None, + }, + }