diff --git a/poetry.lock b/poetry.lock index d6a545ec..51330e84 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -825,6 +825,7 @@ files = [ {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, @@ -835,6 +836,7 @@ files = [ {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, @@ -2661,23 +2663,18 @@ files = [ 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." +name = "respx" +version = "0.22.0" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." 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"}, + {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"}, + {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"}, ] [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"] +httpx = ">=0.25.0" [[package]] name = "rsa" @@ -3483,4 +3480,4 @@ worker = ["asgi-correlation-id", "celery", "coloredlogs", "jinja2", "onetl", "ps [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "2560b942be32292ce779bdb9241327b4e1f786c0e80f21e6c07e5bc70ee18dda" +content-hash = "9689056cf66080ee26132e97866882e3546f0ac7079f5f5c2d7a644b030668b6" diff --git a/pyproject.toml b/pyproject.toml index 87c8aed0..2dbf8f37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,7 +139,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 = "*" +respx = "*" [tool.poetry.group.dev.dependencies] mypy = "^1.11.2" diff --git a/syncmaster/server/providers/auth/base_provider.py b/syncmaster/server/providers/auth/base_provider.py index 1f5c10d8..1a57e2c2 100644 --- a/syncmaster/server/providers/auth/base_provider.py +++ b/syncmaster/server/providers/auth/base_provider.py @@ -52,7 +52,7 @@ def __init__( ... @abstractmethod - async def get_current_user(self, access_token: Any, *args, **kwargs) -> User: + async def get_current_user(self, access_token: str | None, **kwargs) -> User: """ This method should return currently logged in user. diff --git a/syncmaster/server/providers/auth/dummy_provider.py b/syncmaster/server/providers/auth/dummy_provider.py index 52abb676..0a7c0ca0 100644 --- a/syncmaster/server/providers/auth/dummy_provider.py +++ b/syncmaster/server/providers/auth/dummy_provider.py @@ -37,7 +37,7 @@ def setup(cls, app: FastAPI) -> FastAPI: app.dependency_overrides[DummyAuthProviderSettings] = lambda: settings return app - async def get_current_user(self, access_token: str, *args, **kwargs) -> User: + async def get_current_user(self, access_token: str | None, **kwargs) -> User: if not access_token: raise AuthorizationError("Missing auth credentials") diff --git a/syncmaster/server/providers/auth/keycloak_provider.py b/syncmaster/server/providers/auth/keycloak_provider.py index 603b7039..0a90ba97 100644 --- a/syncmaster/server/providers/auth/keycloak_provider.py +++ b/syncmaster/server/providers/auth/keycloak_provider.py @@ -6,6 +6,7 @@ from fastapi import Depends, FastAPI, Request from keycloak import KeycloakOpenID +from syncmaster.db.models import User from syncmaster.exceptions import EntityNotFoundError from syncmaster.exceptions.auth import AuthorizationError from syncmaster.exceptions.redirect import RedirectException @@ -63,7 +64,7 @@ async def get_token_authorization_code_grant( ) -> dict[str, Any]: try: redirect_uri = redirect_uri or self.settings.keycloak.redirect_uri - token = self.keycloak_openid.token( + token = await self.keycloak_openid.a_token( grant_type="authorization_code", code=code, redirect_uri=redirect_uri, @@ -72,10 +73,8 @@ async def get_token_authorization_code_grant( except Exception as e: raise AuthorizationError("Failed to get token") from e - async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: + async def get_current_user(self, access_token: str | None, **kwargs) -> User: request: Request = kwargs["request"] - refresh_token = request.session.get("refresh_token") - if not access_token: log.debug("No access token found in session.") self.redirect_to_auth(request.url.path) @@ -86,8 +85,9 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: token_info = self.keycloak_openid.decode_token(token=access_token) except Exception as e: log.info("Access token is invalid or expired: %s", e) - token_info = None + token_info = {} + refresh_token = request.session.get("refresh_token") if not token_info and refresh_token: log.debug("Access token invalid. Attempting to refresh.") @@ -99,9 +99,7 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: request.session["access_token"] = new_access_token request.session["refresh_token"] = new_refresh_token - token_info = self.keycloak_openid.decode_token( - token=new_access_token, - ) + token_info = self.keycloak_openid.decode_token(token=new_access_token) log.debug("Access token refreshed and decoded successfully.") except Exception as e: log.debug("Failed to refresh access token: %s", e) @@ -110,19 +108,19 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: # these names are hardcoded in keycloak: # https://github.com/keycloak/keycloak/blob/3ca3a4ad349b4d457f6829eaf2ae05f1e01408be/core/src/main/java/org/keycloak/representations/IDToken.java user_id = token_info.get("sub") + if not user_id: + raise AuthorizationError("Invalid token payload") + login = token_info.get("preferred_username") email = token_info.get("email") first_name = token_info.get("given_name") middle_name = token_info.get("middle_name") last_name = token_info.get("family_name") - if not user_id: - raise AuthorizationError("Invalid token payload") - - async with self._uow: - try: - user = await self._uow.user.read_by_username(login) - except EntityNotFoundError: + try: + user = await self._uow.user.read_by_username(login) + except EntityNotFoundError: + async with self._uow: user = await self._uow.user.create( username=login, email=email, @@ -134,7 +132,7 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any: return user async def refresh_access_token(self, refresh_token: str) -> dict[str, Any]: - new_tokens = self.keycloak_openid.refresh_token(refresh_token) + new_tokens = await self.keycloak_openid.a_refresh_token(refresh_token) return new_tokens def redirect_to_auth(self, path: str) -> None: diff --git a/syncmaster/server/settings/auth/base.py b/syncmaster/server/settings/auth/base.py new file mode 100644 index 00000000..c004f608 --- /dev/null +++ b/syncmaster/server/settings/auth/base.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2023-2024 MTS PJSC +# SPDX-License-Identifier: Apache-2.0 + +from pydantic import BaseModel, Field, ImportString + + +class AuthSettings(BaseModel): + """Authorization-related settings. + + Here you can set auth provider class. + + Examples + -------- + + .. code-block:: bash + + SYNCMASTER__AUTH__PROVIDER=syncmaster.server.providers.auth.dummy_provider.DummyAuthProvider + """ + + provider: ImportString = Field( # type: ignore[assignment] + default="syncmaster.server.providers.auth.dummy_provider.DummyAuthProvider", + description="Full name of auth provider class", + validate_default=True, + ) + + class Config: + extra = "allow" diff --git a/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py b/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py index adc135de..81ffac7d 100644 --- a/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py +++ b/tests/test_unit/test_auth/auth_fixtures/keycloak_fixture.py @@ -3,7 +3,7 @@ from base64 import b64encode import pytest -import responses +import respx from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat @@ -85,9 +85,8 @@ def mock_keycloak_well_known(settings): realm_name = settings.auth.dict()["keycloak"]["client_id"] well_known_url = f"{server_url}/realms/{realm_name}/.well-known/openid-configuration" - responses.add( - responses.GET, - well_known_url, + respx.get(well_known_url).respond( + status_code=200, 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", @@ -96,7 +95,6 @@ def mock_keycloak_well_known(settings): "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", ) @@ -108,16 +106,14 @@ def mock_keycloak_realm(settings, rsa_keys): 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, + respx.get(realm_url).respond( + status_code=200, 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", ) @@ -144,15 +140,13 @@ def mock_keycloak_token_refresh(settings, rsa_keys): new_access_token = jwt.encode(payload, private_pem, algorithm="RS256") new_refresh_token = "mock_new_refresh_token" - responses.add( - responses.POST, - token_url, + respx.post(token_url).respond( + status_code=200, 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 index f4897eba..940af9ac 100644 --- a/tests/test_unit/test_auth/test_auth_keycloak.py +++ b/tests/test_unit/test_auth/test_auth_keycloak.py @@ -1,7 +1,7 @@ import logging import pytest -import responses +import respx from httpx import AsyncClient from syncmaster.server.settings import ServerAppSettings as Settings @@ -11,7 +11,7 @@ pytestmark = [pytest.mark.asyncio, pytest.mark.server] -@responses.activate +@respx.mock @pytest.mark.parametrize( "settings", [ @@ -33,7 +33,7 @@ async def test_get_keycloak_user_unauthorized(client: AsyncClient, mock_keycloak ) -@responses.activate +@respx.mock @pytest.mark.parametrize( "settings", [ @@ -71,7 +71,7 @@ async def test_get_keycloak_user_authorized( } -@responses.activate +@respx.mock @pytest.mark.parametrize( "settings", [ @@ -116,7 +116,7 @@ async def test_get_keycloak_user_expired_access_token( } -@responses.activate +@respx.mock @pytest.mark.parametrize( "settings", [ @@ -155,7 +155,7 @@ async def test_get_keycloak_deleted_user( } -@responses.activate +@respx.mock @pytest.mark.parametrize( "settings", [