diff --git a/.env.local b/.env.local index 401a8dd4..2165bb07 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/tests/conftest.py b/tests/conftest.py index 7e213f9c..6656a212 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -64,9 +64,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/mocks/__init__.py b/tests/test_unit/test_auth/mocks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_unit/test_auth/mocks/keycloak.py b/tests/test_unit/test_auth/mocks/keycloak.py new file mode 100644 index 00000000..73bf3543 --- /dev/null +++ b/tests/test_unit/test_auth/mocks/keycloak.py @@ -0,0 +1,118 @@ +import json +from base64 import b64encode + +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 + +# copied from .env.docker as backend tries to send requests to corresponding +KEYCLOAK_CONFIG = { + "server_url": "http://keycloak:8080", + "realm_name": "manually_created", + "redirect_uri": "http://localhost:8000/v1/auth/callback", + "client_secret": "generated_by_keycloak", + "scope": "email", + "client_id": "test-client", +} +# 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() + + +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 + + +def create_session_cookie(payload: dict, session_secret_key: str) -> str: + 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) + return signed_data.decode("utf-8") + + +def mock_keycloak_well_known(responses_mock): + server_url = KEYCLOAK_CONFIG.get("server_url") + realm_name = KEYCLOAK_CONFIG.get("realm_name") + well_known_url = f"{server_url}/realms/{realm_name}/.well-known/openid-configuration" + + responses_mock.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", + ) + + +def mock_keycloak_token_endpoint(responses_mock, access_token: str, refresh_token: str): + server_url = KEYCLOAK_CONFIG.get("server_url") + realm_name = KEYCLOAK_CONFIG.get("realm_name") + token_url = f"{server_url}/realms/{realm_name}/protocol/openid-connect/token" + + responses_mock.add( + responses.POST, + token_url, + body=json.dumps( + { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "expires_in": 3600, + }, + ), + status=200, + content_type="application/json", + ) + + +def mock_keycloak_realm(responses_mock): + server_url = KEYCLOAK_CONFIG.get("server_url") + realm_name = KEYCLOAK_CONFIG.get("realm_name") + realm_url = f"{server_url}/realms/{realm_name}" + + responses_mock.add( + responses.GET, + realm_url, + json={ + "realm": realm_name, + "public_key": get_public_key_pem(PUBLIC_KEY), + "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", + ) 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..5bec26b7 --- /dev/null +++ b/tests/test_unit/test_auth/test_auth_keycloak.py @@ -0,0 +1,147 @@ +import time + +import pytest +import responses +from httpx import AsyncClient + +from syncmaster.backend.settings import BackendSettings as Settings +from tests.mocks import MockUser +from tests.test_unit.test_auth.mocks.keycloak import ( + create_session_cookie, + mock_keycloak_realm, + mock_keycloak_well_known, +) + +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(responses) + + 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, + access_token_factory, +): + payload = { + "sub": str(simple_user.id), + "preferred_username": simple_user.username, + "email": simple_user.email, + "given_name": simple_user.first_name, + "middle_name": simple_user.middle_name, + "family_name": simple_user.last_name, + "exp": time.time() + 1000, + } + + mock_keycloak_well_known(responses) + mock_keycloak_realm(responses) + + session_cookie = create_session_cookie(payload, settings.server.session.secret_key) + headers = { + "Cookie": f"session={session_cookie}", + } + response = await client.get( + f"/v1/users/{simple_user.id}", + headers=headers, + ) + + 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, +): + payload = { + "sub": str(simple_user.id), + "preferred_username": simple_user.username, + "email": simple_user.email, + "given_name": simple_user.first_name, + "middle_name": simple_user.middle_name, + "family_name": simple_user.last_name, + "exp": time.time() + 1000, + } + + mock_keycloak_well_known(responses) + mock_keycloak_realm(responses) + + session_cookie = create_session_cookie(payload, settings.server.session.secret_key) + 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, +): + payload = { + "sub": str(inactive_user.id), + "preferred_username": inactive_user.username, + "email": inactive_user.email, + "given_name": inactive_user.first_name, + "middle_name": inactive_user.middle_name, + "family_name": inactive_user.last_name, + "exp": time.time() + 1000, + } + + mock_keycloak_well_known(responses) + mock_keycloak_realm(responses) + + session_cookie = create_session_cookie(payload, settings.server.session.secret_key) + 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, + }, + }