diff --git a/CHANGES/+self_service_user.feature b/CHANGES/+self_service_user.feature new file mode 100644 index 0000000000..d393c341f0 --- /dev/null +++ b/CHANGES/+self_service_user.feature @@ -0,0 +1 @@ +Added ability for non-admin users to update their personal account fields diff --git a/pulpcore/app/serializers/__init__.py b/pulpcore/app/serializers/__init__.py index 6fa1eb2c66..9647d603a3 100644 --- a/pulpcore/app/serializers/__init__.py +++ b/pulpcore/app/serializers/__init__.py @@ -117,6 +117,7 @@ GroupSerializer, GroupUserSerializer, LoginSerializer, + LoginUpdateSerializer, NestedRoleSerializer, RoleSerializer, UserRoleSerializer, diff --git a/pulpcore/app/serializers/user.py b/pulpcore/app/serializers/user.py index dd54f5822d..bcfc9b546b 100644 --- a/pulpcore/app/serializers/user.py +++ b/pulpcore/app/serializers/user.py @@ -5,7 +5,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth import login as auth_login from django.contrib.auth.models import Permission -from django.contrib.auth.hashers import make_password +from django.contrib.auth.hashers import make_password, check_password from django.contrib.auth.password_validation import validate_password from django.contrib.contenttypes.models import ContentType from rest_framework import serializers @@ -548,3 +548,62 @@ def create(self, validated_data): user = self.context["request"].user auth_login(self.context["request"], user) return user + + +class LoginUpdateSerializer(serializers.ModelSerializer): + username = serializers.CharField( + help_text=_("150 characters or fewer. Letters, digits and @/./+/-/_ only."), + max_length=150, + validators=[UniqueValidator(queryset=User.objects.all())], + required=False, + ) + old_password = serializers.CharField( + help_text=_("Old password"), + write_only=True, + required=False, + style={"input_type": "password"}, + ) + new_password = serializers.CharField( + help_text=_("New password"), + write_only=True, + required=False, + style={"input_type": "password"}, + ) + first_name = serializers.CharField( + help_text=_("First name"), + max_length=150, + allow_blank=True, + required=False, + ) + last_name = serializers.CharField( + help_text=_("Last name"), + max_length=150, + allow_blank=True, + required=False, + ) + email = serializers.EmailField( + help_text=_("Email address"), + allow_blank=True, + required=False, + ) + + def validate(self, data): + data = super().validate(data) + if "new_password" in data and "old_password" not in data: + raise serializers.ValidationError(_("Old password is required to update the password.")) + if "new_password" in data and "old_password" in data: + old_password = data.pop("old_password") + new_password = data.pop("new_password") + if not check_password(old_password, self.context["request"].user.password): + raise serializers.ValidationError(_("Old password is incorrect.")) + if new_password == old_password: + raise serializers.ValidationError( + _("New password cannot be the same as the old password.") + ) + validate_password(new_password) + data["password"] = make_password(new_password) + return data + + class Meta: + model = User + fields = ("username", "old_password", "new_password", "first_name", "last_name", "email") diff --git a/pulpcore/app/viewsets/user.py b/pulpcore/app/viewsets/user.py index ea882dfc34..33422e02bc 100644 --- a/pulpcore/app/viewsets/user.py +++ b/pulpcore/app/viewsets/user.py @@ -26,6 +26,7 @@ GroupUserSerializer, GroupRoleSerializer, LoginSerializer, + LoginUpdateSerializer, RoleSerializer, UserSerializer, UserRoleSerializer, @@ -453,6 +454,20 @@ def delete(self, request): auth_logout(request) return Response(status=204) + @extend_schema( + operation_id="login_update", + request=LoginUpdateSerializer, + responses={200: LoginUpdateSerializer}, + ) + def patch(self, request): + instance = request.user + serializer = LoginUpdateSerializer( + instance, data=request.data, context={"request": request}, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + # Annotate without redefining the post method. extend_schema(operation_id="login")(LoginViewSet.post) diff --git a/pulpcore/tests/functional/api/test_users_groups.py b/pulpcore/tests/functional/api/test_users_groups.py index c046d5849c..17d2a514ee 100644 --- a/pulpcore/tests/functional/api/test_users_groups.py +++ b/pulpcore/tests/functional/api/test_users_groups.py @@ -27,6 +27,72 @@ def test_filter_users(pulpcore_bindings, gen_user): assert len(users.results) == 1 +@pytest.mark.parallel +def test_user_self_service(pulpcore_bindings, gen_user): + """Test that users can update their own user information.""" + prefix = str(uuid.uuid4()) + user = gen_user(username=f"{prefix}_newbee") + pro_user = gen_user(username=f"{prefix}_pro") + new_body = { + "email": "test@example.com", + "first_name": "Test", + "last_name": "User", + } + with user: + user_updated = pulpcore_bindings.LoginApi.login_update(new_body) + assert user_updated.email == new_body["email"] + assert user_updated.first_name == new_body["first_name"] + assert user_updated.last_name == new_body["last_name"] + + with pytest.raises(pulpcore_bindings.ApiException) as exc: + pulpcore_bindings.LoginApi.login_update({"username": pro_user.username}) + assert exc.value.status == 400 + assert "This field must be unique" in exc.value.body + + with pytest.raises(pulpcore_bindings.ApiException) as exc: + pulpcore_bindings.UsersApi.read(user.user.pulp_href) + assert exc.value.status == 403 + + user_read = pulpcore_bindings.UsersApi.read(user.user.pulp_href) + assert user_read.email == user_updated.email + assert user_read.first_name == user_updated.first_name + assert user_read.last_name == user_updated.last_name + + # Try password update scenarios + with user: + new_password = str(uuid.uuid4()) + with pytest.raises(pulpcore_bindings.ApiException) as exc: + pulpcore_bindings.LoginApi.login_update( + {"old_password": "wrong_password", "new_password": new_password} + ) + assert exc.value.status == 400 + assert "Old password is incorrect." in exc.value.body + + with pytest.raises(pulpcore_bindings.ApiException) as exc: + pulpcore_bindings.LoginApi.login_update( + {"old_password": user.password, "new_password": user.password} + ) + assert exc.value.status == 400 + assert "New password cannot be the same as the old password." in exc.value.body + + with pytest.raises(pulpcore_bindings.ApiException) as exc: + pulpcore_bindings.LoginApi.login_update({"new_password": "1234567890"}) + assert exc.value.status == 400 + assert "Old password is required to update the password." in exc.value.body + + pulpcore_bindings.LoginApi.login_update( + {"old_password": user.password, "new_password": new_password} + ) + with pytest.raises(pulpcore_bindings.ApiException) as exc: + pulpcore_bindings.LoginApi.login() + assert exc.value.status == 401 + assert "Invalid username/password" in exc.value.body + user.password = new_password + with user: + user_read = pulpcore_bindings.LoginApi.login_read() + assert user_read.username == user.username + + @pytest.mark.parallel def test_crd_groups(pulpcore_bindings, gen_object_with_cleanup): """Test that a group can be crd."""