From a704c7c9d40680ced3c4c854b9ccd1f8dcb28d3f Mon Sep 17 00:00:00 2001 From: Gagan Date: Tue, 27 Aug 2024 15:51:13 +0530 Subject: [PATCH] fix(ldap-login): create custom serializer to fix login field (#4535) Co-authored-by: Matthew Elwell --- api/app/settings/common.py | 4 ++- api/custom_auth/serializers.py | 30 ++++++++++++++++++- .../test_unit_custom_auth_serializer.py | 30 ++++++++++++++++++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 1caf32b07be7..2c21244b6df3 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -808,6 +808,7 @@ "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", @@ -1131,7 +1132,7 @@ # The URL of the LDAP server. LDAP_AUTH_URL = env.str("LDAP_AUTH_URL", None) -if LDAP_INSTALLED and LDAP_AUTH_URL: +if LDAP_INSTALLED and LDAP_AUTH_URL: # pragma: no cover AUTHENTICATION_BACKENDS.insert(0, "django_python3_ldap.auth.LDAPBackend") INSTALLED_APPS.append("flagsmith_ldap") @@ -1204,6 +1205,7 @@ # 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 aabf01eff74a..c4176a86c9ca 100644 --- a/api/custom_auth/serializers.py +++ b/api/custom_auth/serializers.py @@ -1,5 +1,6 @@ from django.conf import settings -from djoser.serializers import UserCreateSerializer +from djoser.conf import settings as djoser_settings +from djoser.serializers import TokenCreateSerializer, UserCreateSerializer from rest_framework import serializers from rest_framework.authtoken.models import Token from rest_framework.exceptions import PermissionDenied @@ -17,6 +18,33 @@ ) +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 010a861f30ab..922cff3a133f 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,12 +1,18 @@ +from copy import deepcopy + import pytest from django.test import RequestFactory from pytest_django.fixtures import SettingsWrapper +from pytest_mock import MockerFixture from rest_framework.exceptions import PermissionDenied from custom_auth.constants import ( USER_REGISTRATION_WITHOUT_INVITE_ERROR_MESSAGE, ) -from custom_auth.serializers import CustomUserCreateSerializer +from custom_auth.serializers import ( + CustomTokenCreateSerializer, + CustomUserCreateSerializer, +) from organisations.invites.models import InviteLink from users.models import FFAdminUser, SignUpType @@ -145,3 +151,25 @@ def test_invite_link_validation_mixin_validate_fails_if_invite_link_hash_not_val # Then assert exc_info.value.detail == USER_REGISTRATION_WITHOUT_INVITE_ERROR_MESSAGE + + +def test_CustomTokenCreateSerializer_validate_uses_login_field_to_authenticate( + settings: SettingsWrapper, mocker: MockerFixture +) -> None: + # Given + djoser_settings = deepcopy(settings.DJOSER) + djoser_settings["LOGIN_FIELD"] = "username" + settings.DJOSER = djoser_settings + + mocked_authenticate = mocker.patch("djoser.serializers.authenticate") + serializer = CustomTokenCreateSerializer( + data={"email": "some_username", "password": "some_password"} + ) + + # When + serializer.is_valid(raise_exception=True) + + # Then + mocked_authenticate.assert_called_with( + request=None, username="some_username", password="some_password" + )