From 2557c6bc7783e6be5237a7656889b695f03b4e85 Mon Sep 17 00:00:00 2001 From: Quentin BEY Date: Mon, 27 Jan 2025 15:21:39 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9E=95(backend)=20add=20`django-lasuite`=20d?= =?UTF-8?q?ependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the OIDC backend from the new library and add settings to setup OIDC token storage required for later calls to OIDC Resource Servers. --- CHANGELOG.md | 1 + docs/env.md | 4 +- docs/examples/impress.values.yaml | 4 +- src/backend/core/authentication/backends.py | 133 +++------- src/backend/core/authentication/urls.py | 18 -- src/backend/core/authentication/views.py | 137 ----------- .../tests/authentication/test_backends.py | 158 +++++------- .../core/tests/authentication/test_urls.py | 10 - .../core/tests/authentication/test_views.py | 231 ------------------ src/backend/core/urls.py | 2 +- src/backend/impress/settings.py | 32 ++- src/backend/pyproject.toml | 1 + src/helm/env.d/dev/values.impress.yaml.gotmpl | 4 +- 13 files changed, 126 insertions(+), 609 deletions(-) delete mode 100644 src/backend/core/authentication/urls.py delete mode 100644 src/backend/core/authentication/views.py delete mode 100644 src/backend/core/tests/authentication/test_urls.py delete mode 100644 src/backend/core/tests/authentication/test_views.py diff --git a/CHANGELOG.md b/CHANGELOG.md index af0f381f..8103e8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to - ✨(settings) Allow configuring PKCE for the SSO #886 - 🌐(i18n) activate chinese and spanish languages #884 - 🔧(backend) allow overwriting the data directory #893 +- ➕(backend) add `django-lasuite` dependency #839 ## Changed diff --git a/docs/env.md b/docs/env.md index 22bc601f..d87127c6 100644 --- a/docs/env.md +++ b/docs/env.md @@ -78,8 +78,8 @@ These are the environmental variables you can set for the impress-backend contai | OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true | | OIDC_ALLOW_DUPLICATE_EMAILS | Allow dupplicate emails | false | | USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] | -| USER_OIDC_FIELDS_TO_FULLNAME | OIDC token claims to create full name | ["first_name", "last_name"] | -| USER_OIDC_FIELD_TO_SHORTNAME | OIDC token claims to create shortname | first_name | +| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | +| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | | ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | | AI_API_KEY | AI key to be used for AI Base url | | | AI_BASE_URL | OpenAI compatible AI base url | | diff --git a/docs/examples/impress.values.yaml b/docs/examples/impress.values.yaml index bc090450..26659512 100644 --- a/docs/examples/impress.values.yaml +++ b/docs/examples/impress.values.yaml @@ -33,8 +33,8 @@ backend: OIDC_RP_SIGN_ALGO: RS256 OIDC_RP_SCOPES: "openid email" OIDC_VERIFY_SSL: False - USER_OIDC_FIELD_TO_SHORTNAME: "given_name" - USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name" + OIDC_USERINFO_SHORTNAME_FIELD: "given_name" + OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name" OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index f8a7486d..4ea10718 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -1,130 +1,59 @@ """Authentication Backends for the Impress core app.""" import logging +import os from django.conf import settings from django.core.exceptions import SuspiciousOperation -from django.utils.translation import gettext_lazy as _ -import requests -from mozilla_django_oidc.auth import ( - OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend, +from lasuite.oidc_login.backends import ( + OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, ) -from core.models import DuplicateEmailError, User +from core.models import DuplicateEmailError logger = logging.getLogger(__name__) +# Settings renamed warnings +if os.environ.get("USER_OIDC_FIELDS_TO_FULLNAME"): + logger.warning( + "USER_OIDC_FIELDS_TO_FULLNAME has been renamed to " + "OIDC_USERINFO_FULLNAME_FIELDS please update your settings." + ) -class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend): +if os.environ.get("USER_OIDC_FIELD_TO_SHORTNAME"): + logger.warning( + "USER_OIDC_FIELD_TO_SHORTNAME has been renamed to " + "OIDC_USERINFO_SHORTNAME_FIELD please update your settings." + ) + + +class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): """Custom OpenID Connect (OIDC) Authentication Backend. This class overrides the default OIDC Authentication Backend to accommodate differences in the User and Identity models, and handles signed and/or encrypted UserInfo response. """ - def get_userinfo(self, access_token, id_token, payload): - """Return user details dictionary. + def get_extra_claims(self, user_info): + """ + Return extra claims from user_info. - Parameters: - - access_token (str): The access token. - - id_token (str): The id token (unused). - - payload (dict): The token payload (unused). - - Note: The id_token and payload parameters are unused in this implementation, - but were kept to preserve base method signature. - - Note: It handles signed and/or encrypted UserInfo Response. It is required by - Agent Connect, which follows the OIDC standard. It forces us to override the - base method, which deal with 'application/json' response. + Args: + user_info (dict): The user information dictionary. Returns: - - dict: User details dictionary obtained from the OpenID Connect user endpoint. + dict: A dictionary of extra claims. """ - - user_response = requests.get( - self.OIDC_OP_USER_ENDPOINT, - headers={"Authorization": f"Bearer {access_token}"}, - verify=self.get_settings("OIDC_VERIFY_SSL", True), - timeout=self.get_settings("OIDC_TIMEOUT", None), - proxies=self.get_settings("OIDC_PROXY", None), - ) - user_response.raise_for_status() - - try: - userinfo = user_response.json() - except ValueError: - try: - userinfo = self.verify_token(user_response.text) - except Exception as e: - raise SuspiciousOperation( - _("Invalid response format or token verification failed") - ) from e - - return userinfo - - def verify_claims(self, claims): - """ - Verify the presence of essential claims and the "sub" (which is mandatory as defined - by the OIDC specification) to decide if authentication should be allowed. - """ - essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS - missing_claims = [claim for claim in essential_claims if claim not in claims] - - if missing_claims: - logger.error("Missing essential claims: %s", missing_claims) - return False - - return True - - def get_or_create_user(self, access_token, id_token, payload): - """Return a User based on userinfo. Create a new user if no match is found.""" - - user_info = self.get_userinfo(access_token, id_token, payload) - - if not self.verify_claims(user_info): - raise SuspiciousOperation("Claims verification failed.") - - sub = user_info["sub"] - email = user_info.get("email") - - # Get user's full name from OIDC fields defined in settings - full_name = self.compute_full_name(user_info) - short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME) - - claims = { - "email": email, - "full_name": full_name, - "short_name": short_name, + return { + "full_name": self.compute_full_name(user_info), + "short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD), } + def get_existing_user(self, sub, email): + """Fetch existing user by sub or email.""" + try: - user = User.objects.get_user_by_sub_or_email(sub, email) + return self.UserModel.objects.get_user_by_sub_or_email(sub, email) except DuplicateEmailError as err: raise SuspiciousOperation(err.message) from err - - if user: - if not user.is_active: - raise SuspiciousOperation(_("User account is disabled")) - self.update_user_if_needed(user, claims) - elif self.get_settings("OIDC_CREATE_USER", True): - user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106 - - return user - - def compute_full_name(self, user_info): - """Compute user's full name based on OIDC fields in settings.""" - name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME - full_name = " ".join( - user_info[field] for field in name_fields if user_info.get(field) - ) - return full_name or None - - def update_user_if_needed(self, user, claims): - """Update user claims if they have changed.""" - has_changed = any( - value and value != getattr(user, key) for key, value in claims.items() - ) - if has_changed: - updated_claims = {key: value for key, value in claims.items() if value} - self.UserModel.objects.filter(id=user.id).update(**updated_claims) diff --git a/src/backend/core/authentication/urls.py b/src/backend/core/authentication/urls.py deleted file mode 100644 index 2a66c83d..00000000 --- a/src/backend/core/authentication/urls.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Authentication URLs for the People core app.""" - -from django.urls import path - -from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls - -from .views import OIDCLogoutCallbackView, OIDCLogoutView - -urlpatterns = [ - # Override the default 'logout/' path from Mozilla Django OIDC with our custom view. - path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"), - path( - "logout-callback/", - OIDCLogoutCallbackView.as_view(), - name="oidc_logout_callback", - ), - *mozzila_oidc_urls, -] diff --git a/src/backend/core/authentication/views.py b/src/backend/core/authentication/views.py deleted file mode 100644 index 61fe0acf..00000000 --- a/src/backend/core/authentication/views.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Authentication Views for the People core app.""" - -from urllib.parse import urlencode - -from django.contrib import auth -from django.core.exceptions import SuspiciousOperation -from django.http import HttpResponseRedirect -from django.urls import reverse -from django.utils import crypto - -from mozilla_django_oidc.utils import ( - absolutify, -) -from mozilla_django_oidc.views import ( - OIDCLogoutView as MozillaOIDCOIDCLogoutView, -) - - -class OIDCLogoutView(MozillaOIDCOIDCLogoutView): - """Custom logout view for handling OpenID Connect (OIDC) logout flow. - - Adds support for handling logout callbacks from the identity provider (OP) - by initiating the logout flow if the user has an active session. - - The Django session is retained during the logout process to persist the 'state' OIDC parameter. - This parameter is crucial for maintaining the integrity of the logout flow between this call - and the subsequent callback. - """ - - @staticmethod - def persist_state(request, state): - """Persist the given 'state' parameter in the session's 'oidc_states' dictionary - - This method is used to store the OIDC state parameter in the session, according to the - structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session' - utility function. - """ - - if "oidc_states" not in request.session or not isinstance( - request.session["oidc_states"], dict - ): - request.session["oidc_states"] = {} - - request.session["oidc_states"][state] = {} - request.session.save() - - def construct_oidc_logout_url(self, request): - """Create the redirect URL for interfacing with the OIDC provider. - - Retrieves the necessary parameters from the session and constructs the URL - required to initiate logout with the OpenID Connect provider. - - If no ID token is found in the session, the logout flow will not be initiated, - and the method will return the default redirect URL. - - The 'state' parameter is generated randomly and persisted in the session to ensure - its integrity during the subsequent callback. - """ - - oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT") - - if not oidc_logout_endpoint: - return self.redirect_url - - reverse_url = reverse("oidc_logout_callback") - id_token = request.session.get("oidc_id_token", None) - - if not id_token: - return self.redirect_url - - query = { - "id_token_hint": id_token, - "state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)), - "post_logout_redirect_uri": absolutify(request, reverse_url), - } - - self.persist_state(request, query["state"]) - - return f"{oidc_logout_endpoint}?{urlencode(query)}" - - def post(self, request): - """Handle user logout. - - If the user is not authenticated, redirects to the default logout URL. - Otherwise, constructs the OIDC logout URL and redirects the user to start - the logout process. - - If the user is redirected to the default logout URL, ensure her Django session - is terminated. - """ - - logout_url = self.redirect_url - - if request.user.is_authenticated: - logout_url = self.construct_oidc_logout_url(request) - - # If the user is not redirected to the OIDC provider, ensure logout - if logout_url == self.redirect_url: - auth.logout(request) - - return HttpResponseRedirect(logout_url) - - -class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView): - """Custom view for handling the logout callback from the OpenID Connect (OIDC) provider. - - Handles the callback after logout from the identity provider (OP). - Verifies the state parameter and performs necessary logout actions. - - The Django session is maintained during the logout process to ensure the integrity - of the logout flow initiated in the previous step. - """ - - http_method_names = ["get"] - - def get(self, request): - """Handle the logout callback. - - If the user is not authenticated, redirects to the default logout URL. - Otherwise, verifies the state parameter and performs necessary logout actions. - """ - - if not request.user.is_authenticated: - return HttpResponseRedirect(self.redirect_url) - - state = request.GET.get("state") - - if state not in request.session.get("oidc_states", {}): - msg = "OIDC callback state not found in session `oidc_states`!" - raise SuspiciousOperation(msg) - - del request.session["oidc_states"][state] - request.session.save() - - auth.logout(request) - - return HttpResponseRedirect(self.redirect_url) diff --git a/src/backend/core/tests/authentication/test_backends.py b/src/backend/core/tests/authentication/test_backends.py index 8bd47cab..1636ad46 100644 --- a/src/backend/core/tests/authentication/test_backends.py +++ b/src/backend/core/tests/authentication/test_backends.py @@ -2,14 +2,14 @@ import random import re -from logging import Logger -from unittest import mock from django.core.exceptions import SuspiciousOperation from django.test.utils import override_settings import pytest import responses +from cryptography.fernet import Fernet +from lasuite.oidc_login.backends import get_oidc_refresh_token from core import models from core.authentication.backends import OIDCAuthenticationBackend @@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email( monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) - with django_assert_num_queries(2): + with django_assert_num_queries(3): # user by sub, user by mail, update sub user = klass.get_or_create_user( access_token="test-token", id_token=None, payload=None ) @@ -288,7 +288,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch): assert user.email is None assert user.full_name is None assert user.short_name is None - assert user.password == "!" + assert user.has_usable_password() is False assert models.User.objects.count() == 1 @@ -315,7 +315,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch): assert user.email == email assert user.full_name == "John Doe" assert user.short_name == "John" - assert user.password == "!" + assert user.has_usable_password() is False assert models.User.objects.count() == 1 @@ -345,11 +345,15 @@ def test_authentication_get_userinfo_json_response(): @override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo") @responses.activate -def test_authentication_get_userinfo_token_response(monkeypatch): +def test_authentication_get_userinfo_token_response(monkeypatch, settings): """Test get_userinfo method with a token response.""" - + settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call responses.add( - responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200 + responses.GET, + re.compile(r".*/userinfo"), + body="fake.jwt.token", + status=200, + content_type="application/jwt", ) def mock_verify_token(self, token): # pylint: disable=unused-argument @@ -371,21 +375,25 @@ def test_authentication_get_userinfo_token_response(monkeypatch): @override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo") @responses.activate -def test_authentication_get_userinfo_invalid_response(): +def test_authentication_get_userinfo_invalid_response(settings): """ Test get_userinfo method with an invalid JWT response that causes verify_token to raise an error. """ - + settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call responses.add( - responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200 + responses.GET, + re.compile(r".*/userinfo"), + body="fake.jwt.token", + status=200, + content_type="application/jwt", ) oidc_backend = OIDCAuthenticationBackend() with pytest.raises( SuspiciousOperation, - match="Invalid response format or token verification failed", + match="User info response was not valid JWT", ): oidc_backend.get_userinfo("fake_access_token", None, None) @@ -450,100 +458,54 @@ def test_authentication_getter_existing_disabled_user_via_email( assert models.User.objects.count() == 1 -# Essential claims - - -def test_authentication_verify_claims_default(django_assert_num_queries, monkeypatch): - """The sub claim should be mandatory by default.""" - klass = OIDCAuthenticationBackend() - - def get_userinfo_mocked(*args): - return { - "test": "123", - } - - monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) - - with ( - django_assert_num_queries(0), - pytest.raises( - KeyError, - match="sub", - ), - ): - klass.get_or_create_user(access_token="test-token", id_token=None, payload=None) - - assert models.User.objects.exists() is False - - -@pytest.mark.parametrize( - "essential_claims, missing_claims", - [ - (["email", "sub"], ["email"]), - (["Email", "sub"], ["Email"]), # Case sensitivity - ], -) -@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo") -@mock.patch.object(Logger, "error") -def test_authentication_verify_claims_essential_missing( - mock_logger, - essential_claims, - missing_claims, - django_assert_num_queries, - monkeypatch, +@responses.activate +def test_authentication_session_tokens( + django_assert_num_queries, monkeypatch, rf, settings ): - """Ensure SuspiciousOperation is raised if essential claims are missing.""" + """ + Test that the session contains oidc_refresh_token and oidc_access_token after authentication. + """ + settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token" + settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo" + settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks" + settings.OIDC_STORE_ACCESS_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN = True + settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key() klass = OIDCAuthenticationBackend() + request = rf.get("/some-url", {"state": "test-state", "code": "test-code"}) + request.session = {} - def get_userinfo_mocked(*args): - return { - "sub": "123", - "last_name": "Doe", - } + def verify_token_mocked(*args, **kwargs): + return {"sub": "123", "email": "test@example.com"} - monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked) - with ( - django_assert_num_queries(0), - pytest.raises( - SuspiciousOperation, - match="Claims verification failed", - ), - override_settings(USER_OIDC_ESSENTIAL_CLAIMS=essential_claims), - ): - klass.get_or_create_user(access_token="test-token", id_token=None, payload=None) + responses.add( + responses.POST, + re.compile(settings.OIDC_OP_TOKEN_ENDPOINT), + json={ + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + }, + status=200, + ) - assert models.User.objects.exists() is False - mock_logger.assert_called_once_with("Missing essential claims: %s", missing_claims) - - -@override_settings( - OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo", - USER_OIDC_ESSENTIAL_CLAIMS=["email", "last_name"], -) -def test_authentication_verify_claims_success(django_assert_num_queries, monkeypatch): - """Ensure user is authenticated when all essential claims are present.""" - - klass = OIDCAuthenticationBackend() - - def get_userinfo_mocked(*args): - return { - "email": "john.doe@example.com", - "last_name": "Doe", - "sub": "123", - } - - monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + responses.add( + responses.GET, + re.compile(settings.OIDC_OP_USER_ENDPOINT), + json={"sub": "123", "email": "test@example.com"}, + status=200, + ) with django_assert_num_queries(6): - user = klass.get_or_create_user( - access_token="test-token", id_token=None, payload=None + user = klass.authenticate( + request, + code="test-code", + nonce="test-nonce", + code_verifier="test-code-verifier", ) - assert models.User.objects.filter(id=user.id).exists() - - assert user.sub == "123" - assert user.full_name == "Doe" - assert user.short_name is None - assert user.email == "john.doe@example.com" + assert user is not None + assert request.session["oidc_access_token"] == "test-access-token" + assert get_oidc_refresh_token(request.session) == "test-refresh-token" diff --git a/src/backend/core/tests/authentication/test_urls.py b/src/backend/core/tests/authentication/test_urls.py deleted file mode 100644 index 0e20aac4..00000000 --- a/src/backend/core/tests/authentication/test_urls.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Unit tests for the Authentication URLs.""" - -from core.authentication.urls import urlpatterns - - -def test_urls_override_default_mozilla_django_oidc(): - """Custom URL patterns should override default ones from Mozilla Django OIDC.""" - - url_names = [u.name for u in urlpatterns] - assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout") diff --git a/src/backend/core/tests/authentication/test_views.py b/src/backend/core/tests/authentication/test_views.py deleted file mode 100644 index b06cc8cc..00000000 --- a/src/backend/core/tests/authentication/test_views.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Unit tests for the Authentication Views.""" - -from unittest import mock -from urllib.parse import parse_qs, urlparse - -from django.contrib.auth.models import AnonymousUser -from django.contrib.sessions.middleware import SessionMiddleware -from django.core.exceptions import SuspiciousOperation -from django.test import RequestFactory -from django.test.utils import override_settings -from django.urls import reverse -from django.utils import crypto - -import pytest -from rest_framework.test import APIClient - -from core import factories -from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView - -pytestmark = pytest.mark.django_db - - -@override_settings(LOGOUT_REDIRECT_URL="/example-logout") -def test_view_logout_anonymous(): - """Anonymous users calling the logout url, - should be redirected to the specified LOGOUT_REDIRECT_URL.""" - - url = reverse("oidc_logout_custom") - response = APIClient().get(url) - - assert response.status_code == 302 - assert response.url == "/example-logout" - - -@mock.patch.object( - OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout" -) -def test_view_logout(mocked_oidc_logout_url): - """Authenticated users should be redirected to OIDC provider for logout.""" - - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - url = reverse("oidc_logout_custom") - response = client.get(url) - - mocked_oidc_logout_url.assert_called_once() - - assert response.status_code == 302 - assert response.url == "/example-logout" - - -@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout") -@mock.patch.object( - OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout" -) -def test_view_logout_no_oidc_provider(mocked_oidc_logout_url): - """Authenticated users should be logged out when no OIDC provider is available.""" - - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - url = reverse("oidc_logout_custom") - - with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout: - response = client.get(url) - mocked_oidc_logout_url.assert_called_once() - mock_logout.assert_called_once() - - assert response.status_code == 302 - assert response.url == "/default-redirect-logout" - - -@override_settings(LOGOUT_REDIRECT_URL="/example-logout") -def test_view_logout_callback_anonymous(): - """Anonymous users calling the logout callback url, - should be redirected to the specified LOGOUT_REDIRECT_URL.""" - - url = reverse("oidc_logout_callback") - response = APIClient().get(url) - - assert response.status_code == 302 - assert response.url == "/example-logout" - - -@pytest.mark.parametrize( - "initial_oidc_states", - [{}, {"other_state": "foo"}], -) -def test_view_logout_persist_state(initial_oidc_states): - """State value should be persisted in session's data.""" - - user = factories.UserFactory() - - request = RequestFactory().request() - request.user = user - - middleware = SessionMiddleware(get_response=lambda x: x) - middleware.process_request(request) - - if initial_oidc_states: - request.session["oidc_states"] = initial_oidc_states - request.session.save() - - mocked_state = "mock_state" - - OIDCLogoutView().persist_state(request, mocked_state) - - assert "oidc_states" in request.session - assert request.session["oidc_states"] == { - "mock_state": {}, - **initial_oidc_states, - } - - -@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout") -@mock.patch.object(OIDCLogoutView, "persist_state") -@mock.patch.object(crypto, "get_random_string", return_value="mocked_state") -def test_view_logout_construct_oidc_logout_url( - mocked_get_random_string, mocked_persist_state -): - """Should construct the logout URL to initiate the logout flow with the OIDC provider.""" - - user = factories.UserFactory() - - request = RequestFactory().request() - request.user = user - - middleware = SessionMiddleware(get_response=lambda x: x) - middleware.process_request(request) - - request.session["oidc_id_token"] = "mocked_oidc_id_token" - request.session.save() - - redirect_url = OIDCLogoutView().construct_oidc_logout_url(request) - - mocked_persist_state.assert_called_once() - mocked_get_random_string.assert_called_once() - - params = parse_qs(urlparse(redirect_url).query) - - assert params["id_token_hint"][0] == "mocked_oidc_id_token" - assert params["state"][0] == "mocked_state" - - url = reverse("oidc_logout_callback") - assert url in params["post_logout_redirect_uri"][0] - - -@override_settings(LOGOUT_REDIRECT_URL="/") -def test_view_logout_construct_oidc_logout_url_none_id_token(): - """If no ID token is available in the session, - the user should be redirected to the final URL.""" - - user = factories.UserFactory() - - request = RequestFactory().request() - request.user = user - - middleware = SessionMiddleware(get_response=lambda x: x) - middleware.process_request(request) - - redirect_url = OIDCLogoutView().construct_oidc_logout_url(request) - - assert redirect_url == "/" - - -@pytest.mark.parametrize( - "initial_state", - [None, {"other_state": "foo"}], -) -def test_view_logout_callback_wrong_state(initial_state): - """Should raise an error if OIDC state doesn't match session data.""" - - user = factories.UserFactory() - - request = RequestFactory().request() - request.user = user - - middleware = SessionMiddleware(get_response=lambda x: x) - middleware.process_request(request) - - if initial_state: - request.session["oidc_states"] = initial_state - request.session.save() - - callback_view = OIDCLogoutCallbackView.as_view() - - with pytest.raises(SuspiciousOperation) as excinfo: - callback_view(request) - - assert ( - str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!" - ) - - -@override_settings(LOGOUT_REDIRECT_URL="/example-logout") -def test_view_logout_callback(): - """If state matches, callback should clear OIDC state and redirects.""" - - user = factories.UserFactory() - - request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"}) - request.user = user - - middleware = SessionMiddleware(get_response=lambda x: x) - middleware.process_request(request) - - mocked_state = "mocked_state" - - request.session["oidc_states"] = {mocked_state: {}} - request.session.save() - - callback_view = OIDCLogoutCallbackView.as_view() - - with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout: - - def clear_user(request): - # Assert state is cleared prior to logout - assert request.session["oidc_states"] == {} - request.user = AnonymousUser() - - mock_logout.side_effect = clear_user - response = callback_view(request) - mock_logout.assert_called_once() - - assert response.status_code == 302 - assert response.url == "/example-logout" diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index fec20662..9f568dec 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -3,10 +3,10 @@ from django.conf import settings from django.urls import include, path, re_path +from lasuite.oidc_login.urls import urlpatterns as oidc_urls from rest_framework.routers import DefaultRouter from core.api import viewsets -from core.authentication.urls import urlpatterns as oidc_urls # - Main endpoints router = DefaultRouter() diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 3fc497b7..91623eee 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -533,6 +533,17 @@ class Base(Configuration): OIDC_PKCE_CODE_VERIFIER_SIZE = values.IntegerValue( default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None ) + OIDC_STORE_ACCESS_TOKEN = values.BooleanValue( + default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None + ) + OIDC_STORE_REFRESH_TOKEN = values.BooleanValue( + default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None + ) + OIDC_STORE_REFRESH_TOKEN_KEY = values.Value( + default=None, + environ_name="OIDC_STORE_REFRESH_TOKEN_KEY", + environ_prefix=None, + ) # WARNING: Enabling this setting allows multiple user accounts to share the same email # address. This may cause security issues and is not recommended for production use when @@ -546,14 +557,23 @@ class Base(Configuration): USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue( default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None ) - USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue( - default=["first_name", "last_name"], - environ_name="USER_OIDC_FIELDS_TO_FULLNAME", + + OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue( + default=values.ListValue( # retrocompatibility + default=["first_name", "last_name"], + environ_name="USER_OIDC_FIELDS_TO_FULLNAME", + environ_prefix=None, + ), + environ_name="OIDC_USERINFO_FULLNAME_FIELDS", environ_prefix=None, ) - USER_OIDC_FIELD_TO_SHORTNAME = values.Value( - default="first_name", - environ_name="USER_OIDC_FIELD_TO_SHORTNAME", + OIDC_USERINFO_SHORTNAME_FIELD = values.Value( + default=values.Value( # retrocompatibility + default="first_name", + environ_name="USER_OIDC_FIELD_TO_SHORTNAME", + environ_prefix=None, + ), + environ_name="OIDC_USERINFO_SHORTNAME_FIELD", environ_prefix=None, ) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index db688832..f4e93d04 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "django-cors-headers==4.7.0", "django-countries==7.6.1", "django-filter==25.1", + "django-lasuite==0.0.7", "django-parler==2.3", "redis==5.2.1", "django-redis==5.4.0", diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl index 30cb3353..4f210aea 100644 --- a/src/helm/env.d/dev/values.impress.yaml.gotmpl +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -31,8 +31,8 @@ backend: LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR LOGGING_LEVEL_LOGGERS_ROOT: INFO LOGGING_LEVEL_LOGGERS_APP: INFO - USER_OIDC_FIELD_TO_SHORTNAME: "given_name" - USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name" + OIDC_USERINFO_SHORTNAME_FIELD: "given_name" + OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name" OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token