diff --git a/src/backend/core/api/utils.py b/src/backend/core/api/utils.py index 98dc6548..bd185da4 100644 --- a/src/backend/core/api/utils.py +++ b/src/backend/core/api/utils.py @@ -8,6 +8,9 @@ from django.core.cache import cache from django.core.files.storage import default_storage import botocore +import requests +from lasuite.oidc_login.backends import get_oidc_refresh_token, store_tokens +from rest_framework.exceptions import AuthenticationFailed from rest_framework.throttling import BaseThrottle @@ -179,3 +182,31 @@ class AIUserRateThrottle(AIBaseRateThrottle): if x_forwarded_for else request.META.get("REMOTE_ADDR") ) + + +def refresh_access_token(session): + """Refresh the OIDC access token using the refresh token.""" + refresh_token = get_oidc_refresh_token(session) + if not refresh_token: + raise AuthenticationFailed({"error": "Refresh token is missing from session"}) + + response = requests.post( + settings.OIDC_OP_TOKEN_ENDPOINT, + data={ + "grant_type": "refresh_token", + "client_id": settings.OIDC_RP_CLIENT_ID, + "client_secret": settings.OIDC_RP_CLIENT_SECRET, + "refresh_token": refresh_token, + }, + timeout=5, + ) + response.raise_for_status() + token_info = response.json() + + store_tokens( + session, + access_token=token_info.get("access_token"), + id_token=None, + refresh_token=token_info.get("refresh_token"), + ) + return session diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index a1d13221..87e08b07 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -25,7 +25,6 @@ from django.db.models.functions import Greatest, Left, Length from django.http import Http404, StreamingHttpResponse from django.urls import reverse from django.utils import timezone -from django.utils.decorators import method_decorator from django.utils.functional import cached_property from django.utils.http import content_disposition_header from django.utils.text import capfirst, slugify @@ -38,11 +37,11 @@ from botocore.exceptions import ClientError from csp.constants import NONE from csp.decorators import csp_update from lasuite.malware_detection import malware_detection -from lasuite.oidc_login.decorators import refresh_oidc_access_token from lasuite.tools.email import get_domain_from_email from pydantic import ValidationError as PydanticValidationError from rest_framework import filters, status, viewsets from rest_framework import response as drf_response +from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import AllowAny from rest_framework.views import APIView @@ -84,6 +83,7 @@ from .throttling import ( UserListThrottleBurst, UserListThrottleSustained, ) +from .utils import refresh_access_token logger = logging.getLogger(__name__) @@ -1415,7 +1415,6 @@ class DocumentViewSet( return duplicated_document @drf.decorators.action(detail=False, methods=["get"], url_path="search") - @method_decorator(refresh_oidc_access_token) def search(self, request, *args, **kwargs): """ Returns an ordered list of documents best matching the search query parameter 'q'. @@ -1430,6 +1429,15 @@ class DocumentViewSet( if search_type == SearchType.TITLE: return self._title_search(request, params.validated_data, *args, **kwargs) + try: + request.session = refresh_access_token(request.session) + except AuthenticationFailed: + logging.warning( + "User unauthenticated or error while refreshing token, " + "falling back to title search." + ) + return self._title_search(request, params.validated_data, *args, **kwargs) + indexer = get_document_indexer() if indexer is None: # fallback on title search if the indexer is not configured @@ -1440,7 +1448,10 @@ class DocumentViewSet( indexer, request, params=params, search_type=search_type ) except requests.exceptions.RequestException as e: - logger.error("Error while searching documents with indexer: %s", e) + logger.error( + "Error while searching documents with indexer \n%s \nfall back on title search", + e, + ) # fallback on title search if the indexer is not reached return self._title_search(request, params.validated_data, *args, **kwargs) diff --git a/src/backend/core/tests/conftest.py b/src/backend/core/tests/conftest.py index 0af57d9f..5782a752 100644 --- a/src/backend/core/tests/conftest.py +++ b/src/backend/core/tests/conftest.py @@ -7,6 +7,8 @@ from django.core.cache import cache import pytest import responses +from cryptography.fernet import Fernet +from lasuite.oidc_login.backends import get_cipher_suite from core import factories from core.tests.utils.urls import reload_urls @@ -143,3 +145,28 @@ def user_token(): A fixture to create a user token for testing. """ return build_authorization_bearer("some_token") + + +@pytest.fixture(name="oidc_settings") +def fixture_oidc_settings(settings): + """Fixture to configure OIDC settings for the tests.""" + settings.OIDC_OP_TOKEN_ENDPOINT = "https://auth.example.com/token" + settings.OIDC_OP_AUTHORIZATION_ENDPOINT = "https://auth.example.com/authorize" + settings.OIDC_RP_CLIENT_ID = "client_id" + settings.OIDC_RP_CLIENT_SECRET = "client_secret" + settings.OIDC_AUTHENTICATION_CALLBACK_URL = "oidc_authentication_callback" + settings.OIDC_RP_SCOPES = "openid email" + settings.OIDC_USE_NONCE = True + settings.OIDC_STATE_SIZE = 32 + settings.OIDC_NONCE_SIZE = 32 + settings.OIDC_VERIFY_SSL = True + settings.OIDC_TOKEN_USE_BASIC_AUTH = False + settings.OIDC_STORE_ACCESS_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key() + + get_cipher_suite.cache_clear() + + yield settings + + get_cipher_suite.cache_clear() diff --git a/src/backend/core/tests/documents/test_api_documents_search.py b/src/backend/core/tests/documents/test_api_documents_search.py index f6c8dd8b..c6064037 100644 --- a/src/backend/core/tests/documents/test_api_documents_search.py +++ b/src/backend/core/tests/documents/test_api_documents_search.py @@ -2,7 +2,9 @@ Tests for Documents API endpoint in impress's core app: search """ +import re from unittest import mock +from unittest.mock import patch import pytest import responses @@ -12,7 +14,7 @@ from rest_framework.test import APIClient from waffle.testutils import override_flag from core import factories -from core.enums import FeatureFlag, SearchType +from core.enums import FeatureFlag from core.services.search_indexers import get_document_indexer fake = Faker() @@ -26,47 +28,29 @@ def enable_flag_find_hybrid_search(): yield -@mock.patch("core.services.search_indexers.FindDocumentIndexer.search_query") +@mock.patch("core.api.viewsets.DocumentViewSet.list") @responses.activate -def test_api_documents_search_anonymous(search_query, indexer_settings): +def test_api_documents_search_anonymous(mock_list, indexer_settings): """ - Anonymous users should be allowed to search documents with Find. + Anonymous users should not be allowed to search documents with Find. + they should fall back on title search. """ indexer_settings.SEARCH_URL = "http://find/api/v1.0/search" - # mock Find response - responses.add( - responses.POST, - "http://find/api/v1.0/search", - json=[], - status=200, - ) + mocked_response = { + "count": 0, + "next": None, + "previous": None, + "results": [{"title": "mocked list result"}], + } + mock_list.return_value = drf_response.Response(mocked_response) q = "alpha" response = APIClient().get("/api/v1.0/documents/search/", data={"q": q}) - assert search_query.call_count == 1 - assert search_query.call_args[1] == { - "data": { - "q": q, - "visited": [], - "services": ["docs"], - "nb_results": 50, - "order_by": "updated_at", - "order_direction": "desc", - "path": None, - "search_type": SearchType.HYBRID, - }, - "token": None, - } - - assert response.status_code == 200 - assert response.json() == { - "count": 0, - "next": None, - "previous": None, - "results": [], - } + assert mock_list.call_count == 1 + assert mock_list.call_args[0][0].GET.get("q") == q + assert response.json() == mocked_response @mock.patch("core.api.viewsets.DocumentViewSet.list") @@ -194,8 +178,13 @@ def test_api_documents_search_invalid_params(indexer_settings): @responses.activate -def test_api_documents_search_success(indexer_settings): +@patch("core.api.viewsets.refresh_access_token") +def test_api_documents_search_success( + mocked_refresh_access_token, indexer_settings, oidc_settings, settings +): # pylint: disable=unused-argument """Validate the format of documents as returned by the search view.""" + mocked_refresh_access_token.side_effect = lambda session: session + indexer_settings.SEARCH_URL = "http://find/api/v1.0/search" assert get_document_indexer() is not None @@ -204,7 +193,7 @@ def test_api_documents_search_success(indexer_settings): # Find response responses.add( responses.POST, - "http://find/api/v1.0/search", + indexer_settings.SEARCH_URL, json=[ { "_id": str(document["id"]), @@ -213,7 +202,11 @@ def test_api_documents_search_success(indexer_settings): ], status=200, ) - response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"}) + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"}) assert response.status_code == 200 content = response.json() diff --git a/src/backend/core/tests/documents/test_api_documents_search_feature_flag.py b/src/backend/core/tests/documents/test_api_documents_search_feature_flag.py index 72119de0..6fc8c2db 100644 --- a/src/backend/core/tests/documents/test_api_documents_search_feature_flag.py +++ b/src/backend/core/tests/documents/test_api_documents_search_feature_flag.py @@ -4,6 +4,7 @@ Tests for Find search feature flags from unittest import mock +from django.contrib.sessions.backends.cache import SessionStore from django.http import HttpResponse import pytest @@ -11,6 +12,7 @@ import responses from rest_framework.test import APIClient from waffle.testutils import override_flag +from core import factories from core.enums import FeatureFlag, SearchType from core.services.search_indexers import get_document_indexer @@ -18,6 +20,7 @@ pytestmark = pytest.mark.django_db @responses.activate +@mock.patch("core.api.viewsets.refresh_access_token") @mock.patch("core.api.viewsets.DocumentViewSet._title_search") @mock.patch("core.api.viewsets.DocumentViewSet._search_with_indexer") @pytest.mark.parametrize( @@ -44,12 +47,14 @@ pytestmark = pytest.mark.django_db def test_api_documents_search_success( # noqa : PLR0913 mock_search_with_indexer, mock_title_search, + mocked_refresh_access_token, activated_flags, expected_search_type, expected_search_with_indexer_called, expected_title_search_called, indexer_settings, -): + oidc_settings, +): # pylint: disable=unused-argument """ Test that the API endpoint for searching documents returns a successful response with the expected search type according to the activated feature flags, @@ -59,7 +64,11 @@ def test_api_documents_search_success( # noqa : PLR0913 mock_search_with_indexer.return_value = HttpResponse() mock_title_search.return_value = HttpResponse() + mocked_refresh_access_token.side_effect = lambda session: session + user = factories.UserFactory() + client = APIClient() + client.force_login(user) with override_flag( FeatureFlag.FLAG_FIND_HYBRID_SEARCH, active=FeatureFlag.FLAG_FIND_HYBRID_SEARCH in activated_flags, @@ -68,9 +77,7 @@ def test_api_documents_search_success( # noqa : PLR0913 FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH, active=FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH in activated_flags, ): - response = APIClient().get( - "/api/v1.0/documents/search/", data={"q": "alpha"} - ) + response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"}) assert response.status_code == 200