From d2f940a37918d99b11f8841e635830977f1307d0 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 19 Dec 2024 03:01:51 +0000 Subject: [PATCH] restore pre-2.3.0 behaviour --- api/app/settings/common.py | 7 +-- api/custom_auth/serializers.py | 30 +---------- .../test_unit_custom_auth_serializer.py | 50 +++++++++++++------ api/users/models.py | 2 +- 4 files changed, 41 insertions(+), 48 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 2f1e468eb4d3..5bc918b87a34 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -390,6 +390,7 @@ ] AUTHENTICATION_BACKENDS = [ + "djoser.auth_backends.LoginFieldBackend", "admin_sso.auth.DjangoSSOAuthBackend", "django.contrib.auth.backends.ModelBackend", ] @@ -804,10 +805,10 @@ # FE uri to redirect user to from activation email "ACTIVATION_URL": "activate/{uid}/{token}", # register or activation endpoint will send confirmation email to user + "LOGIN_FIELD": "email", "SEND_CONFIRMATION_EMAIL": False, "SERIALIZERS": { "token": "custom_auth.serializers.CustomTokenSerializer", - "token_create": "custom_auth.serializers.CustomTokenCreateSerializer", "user_create": "custom_auth.serializers.CustomUserCreateSerializer", "user_delete": "custom_auth.serializers.CustomUserDelete", "current_user": "users.serializers.CustomCurrentUserSerializer", @@ -1153,8 +1154,9 @@ LDAP_INSTALLED = importlib.util.find_spec("flagsmith_ldap") # The URL of the LDAP server. LDAP_AUTH_URL = env.str("LDAP_AUTH_URL", None) +LDAP_ENABLED = LDAP_INSTALLED and LDAP_AUTH_URL -if LDAP_INSTALLED and LDAP_AUTH_URL: # pragma: no cover +if LDAP_ENABLED: # pragma: no cover AUTHENTICATION_BACKENDS.insert(0, "django_python3_ldap.auth.LDAPBackend") INSTALLED_APPS.append("flagsmith_ldap") @@ -1227,7 +1229,6 @@ # The LDAP user username and password used by `sync_ldap_users_and_groups` command LDAP_SYNC_USER_USERNAME = env.str("LDAP_SYNC_USER_USERNAME", None) LDAP_SYNC_USER_PASSWORD = env.str("LDAP_SYNC_USER_PASSWORD", None) - DJOSER["LOGIN_FIELD"] = "username" SEGMENT_CONDITION_VALUE_LIMIT = env.int("SEGMENT_CONDITION_VALUE_LIMIT", default=1000) if not 0 <= SEGMENT_CONDITION_VALUE_LIMIT < 2000000: diff --git a/api/custom_auth/serializers.py b/api/custom_auth/serializers.py index d1322c1d50be..238fe17adb96 100644 --- a/api/custom_auth/serializers.py +++ b/api/custom_auth/serializers.py @@ -1,8 +1,7 @@ from typing import Any from django.conf import settings -from djoser.conf import settings as djoser_settings -from djoser.serializers import TokenCreateSerializer, UserCreateSerializer +from djoser.serializers import UserCreateSerializer from rest_framework import serializers from rest_framework.authtoken.models import Token from rest_framework.exceptions import PermissionDenied @@ -20,33 +19,6 @@ ) -class CustomTokenCreateSerializer(TokenCreateSerializer): - """ - NOTE: Some authentication backends (e.g., LDAP) support only - username and password authentication. However, the front-end - currently sends the email as the login key. To accommodate - this, we override the serializer to rename the username field - to the email (or any other field configurable using djoser settings) field. - - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if djoser_settings.LOGIN_FIELD != FFAdminUser.USERNAME_FIELD: - # Because djoser have created a field named username(djoser_settings.LOGIN_FIELD) in the serializer - # We have to remove this and add the email(FFAdminUser.USERNAME_FIELD) field back - self.fields.pop(djoser_settings.LOGIN_FIELD) - self.fields[FFAdminUser.USERNAME_FIELD] = serializers.CharField( - required=False - ) - - def validate(self, attrs): - if djoser_settings.LOGIN_FIELD != FFAdminUser.USERNAME_FIELD: - attrs[djoser_settings.LOGIN_FIELD] = attrs.pop(FFAdminUser.USERNAME_FIELD) - - return super().validate(attrs) - - class CustomTokenSerializer(serializers.ModelSerializer): class Meta: model = Token diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_serializer.py b/api/tests/unit/custom_auth/test_unit_custom_auth_serializer.py index 922cff3a133f..37872a8e9425 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_serializer.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_serializer.py @@ -1,7 +1,9 @@ -from copy import deepcopy +import typing import pytest +from django.contrib.auth.models import AbstractUser from django.test import RequestFactory +from djoser.serializers import TokenCreateSerializer from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture from rest_framework.exceptions import PermissionDenied @@ -9,10 +11,7 @@ from custom_auth.constants import ( USER_REGISTRATION_WITHOUT_INVITE_ERROR_MESSAGE, ) -from custom_auth.serializers import ( - CustomTokenCreateSerializer, - CustomUserCreateSerializer, -) +from custom_auth.serializers import CustomUserCreateSerializer from organisations.invites.models import InviteLink from users.models import FFAdminUser, SignUpType @@ -153,23 +152,44 @@ def test_invite_link_validation_mixin_validate_fails_if_invite_link_hash_not_val assert exc_info.value.detail == USER_REGISTRATION_WITHOUT_INVITE_ERROR_MESSAGE -def test_CustomTokenCreateSerializer_validate_uses_login_field_to_authenticate( - settings: SettingsWrapper, mocker: MockerFixture +@pytest.fixture +def django_ldap_username_field( + django_user_model: type[AbstractUser], +) -> typing.Generator[str, None, None]: + ldap_username_field = "username" + username_field = django_user_model.USERNAME_FIELD + django_user_model.USERNAME_FIELD = ldap_username_field + yield ldap_username_field + django_user_model.USERNAME_FIELD = username_field + + +# Previously, Djoser's default `TokenCreateSerializer` only respected the +# Djoser `LOGIN_FIELD` setting so it was impossible to grab the email from +# serializer and send it as a username to the login backend — which is required for LDAP to work. +# After Djoser 2.3.0, it's possible to alter `TokenCreateSerializer` behaviour +# to support this by setting the Django user model's `USERNAME_FIELD` constant +# to `"username"`, and Djoser's `LOGIN_FIELD` to `"email"`. +# This test is here to make sure Djoser behaves as expected. +def test_djoser_token_create_serializer__user_model_username_field__call_expected( + mocker: MockerFixture, + django_ldap_username_field: str, ) -> None: # Given - djoser_settings = deepcopy(settings.DJOSER) - djoser_settings["LOGIN_FIELD"] = "username" - settings.DJOSER = djoser_settings + expected_username = "some_username" + expected_password = "some_password" mocked_authenticate = mocker.patch("djoser.serializers.authenticate") - serializer = CustomTokenCreateSerializer( - data={"email": "some_username", "password": "some_password"} + serializer = TokenCreateSerializer( + data={"email": expected_username, "password": expected_password} ) + expected_authenticate_kwargs = { + "request": None, + django_ldap_username_field: expected_username, + "password": expected_password, + } # When serializer.is_valid(raise_exception=True) # Then - mocked_authenticate.assert_called_with( - request=None, username="some_username", password="some_password" - ) + mocked_authenticate.assert_called_with(**expected_authenticate_kwargs) diff --git a/api/users/models.py b/api/users/models.py index 2030ad05c3f7..2f0487a4592d 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -113,7 +113,7 @@ class FFAdminUser(LifecycleModel, AbstractUser): uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) - USERNAME_FIELD = "email" + USERNAME_FIELD = "username" if settings.LDAP_ENABLED else "email" REQUIRED_FIELDS = ["first_name", "last_name", "sign_up_type"] class Meta: