diff --git a/CHANGES/5932.feature b/CHANGES/5932.feature new file mode 100644 index 0000000000..c9fb468244 --- /dev/null +++ b/CHANGES/5932.feature @@ -0,0 +1 @@ +Added a login api endpoint to result in an authorization cookie from any other sort of feasible authentication. diff --git a/pulpcore/app/apps.py b/pulpcore/app/apps.py index a3f32540f4..e3cbaa4f79 100644 --- a/pulpcore/app/apps.py +++ b/pulpcore/app/apps.py @@ -265,6 +265,7 @@ def ready(self): def _populate_access_policies(sender, apps, verbosity, **kwargs): from pulpcore.app.util import get_view_urlpattern + from pulpcore.app.viewsets import LoginViewSet try: AccessPolicy = apps.get_model("core", "AccessPolicy") @@ -273,7 +274,8 @@ def _populate_access_policies(sender, apps, verbosity, **kwargs): print(_("AccessPolicy model does not exist. Skipping initialization.")) return - for viewset_batch in sender.named_viewsets.values(): + extra_viewsets = [LoginViewSet] + for viewset_batch in list(sender.named_viewsets.values()) + [extra_viewsets]: for viewset in viewset_batch: access_policy = getattr(viewset, "DEFAULT_ACCESS_POLICY", None) if access_policy is not None: diff --git a/pulpcore/app/serializers/__init__.py b/pulpcore/app/serializers/__init__.py index 3a6047bcc8..9f22212e5b 100644 --- a/pulpcore/app/serializers/__init__.py +++ b/pulpcore/app/serializers/__init__.py @@ -113,6 +113,7 @@ GroupRoleSerializer, GroupSerializer, GroupUserSerializer, + LoginSerializer, NestedRoleSerializer, RoleSerializer, UserRoleSerializer, diff --git a/pulpcore/app/serializers/user.py b/pulpcore/app/serializers/user.py index 0a7627c4de..28d786228a 100644 --- a/pulpcore/app/serializers/user.py +++ b/pulpcore/app/serializers/user.py @@ -3,6 +3,7 @@ from gettext import gettext as _ 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.password_validation import validate_password @@ -490,3 +491,14 @@ def validate(self, data): ) self.group_role_pks.append(qs.get().pk) return data + + +class LoginSerializer(serializers.Serializer): + pulp_href = IdentityField(view_name="users-detail") + prn = PRNField() + username = serializers.CharField(read_only=True) + + def create(self, validated_data): + user = self.context["request"].user + auth_login(self.context["request"], user) + return user diff --git a/pulpcore/app/urls.py b/pulpcore/app/urls.py index e8bca33f2c..da966a60f0 100644 --- a/pulpcore/app/urls.py +++ b/pulpcore/app/urls.py @@ -22,6 +22,7 @@ ) from pulpcore.app.viewsets import ( ListRepositoryVersionViewSet, + LoginViewSet, OrphansCleanupViewset, ReclaimSpaceViewSet, ) @@ -152,6 +153,7 @@ class PulpDefaultRouter(routers.DefaultRouter): vs_tree.add_decendent(ViewSetNode(viewset)) special_views = [ + path("login/", LoginViewSet.as_view()), path("repair/", RepairView.as_view()), path( "orphans/cleanup/", diff --git a/pulpcore/app/viewsets/__init__.py b/pulpcore/app/viewsets/__init__.py index 468850e7ed..9ab6c17bac 100644 --- a/pulpcore/app/viewsets/__init__.py +++ b/pulpcore/app/viewsets/__init__.py @@ -76,6 +76,7 @@ GroupViewSet, GroupRoleViewSet, GroupUserViewSet, + LoginViewSet, RoleViewSet, UserViewSet, UserRoleViewSet, diff --git a/pulpcore/app/viewsets/user.py b/pulpcore/app/viewsets/user.py index a78774302f..ea882dfc34 100644 --- a/pulpcore/app/viewsets/user.py +++ b/pulpcore/app/viewsets/user.py @@ -1,6 +1,7 @@ from gettext import gettext as _ from django.contrib.auth import get_user_model +from django.contrib.auth import logout as auth_logout from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError from django.shortcuts import get_object_or_404 @@ -8,7 +9,7 @@ from django.db.models import Q, Count from django.contrib.auth.models import Permission -from rest_framework import mixins, status +from rest_framework import generics, mixins, status from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import SAFE_METHODS from rest_framework.response import Response @@ -24,6 +25,7 @@ GroupSerializer, GroupUserSerializer, GroupRoleSerializer, + LoginSerializer, RoleSerializer, UserSerializer, UserRoleSerializer, @@ -422,3 +424,35 @@ class GroupRoleViewSet( serializer_class = GroupRoleSerializer queryset = GroupRole.objects.all() ordering = ("-pulp_created",) + + +class LoginViewSet(generics.CreateAPIView): + serializer_class = LoginSerializer + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["*"], + "principal": "authenticated", + "effect": "allow", + }, + ], + "creation_hooks": [], + } + + @staticmethod + def urlpattern(): + return "login" + + @extend_schema(operation_id="login_read") + def get(self, request): + return Response(self.get_serializer(request.user).data) + + @extend_schema(operation_id="logout") + def delete(self, request): + auth_logout(request) + return Response(status=204) + + +# Annotate without redefining the post method. +extend_schema(operation_id="login")(LoginViewSet.post) diff --git a/pulpcore/tests/functional/api/test_login.py b/pulpcore/tests/functional/api/test_login.py new file mode 100644 index 0000000000..3647e06885 --- /dev/null +++ b/pulpcore/tests/functional/api/test_login.py @@ -0,0 +1,112 @@ +import http +import pytest + +pytestmark = [pytest.mark.parallel] + + +@pytest.fixture +def session_user(pulpcore_bindings, gen_user, anonymous_user): + old_cookie = pulpcore_bindings.client.cookie + user = gen_user() + with user: + response = pulpcore_bindings.LoginApi.login_with_http_info() + if isinstance(response, tuple): + # old bindings + _, _, headers = response + else: + # new bindings + headers = response.headers + cookie_jar = http.cookies.SimpleCookie(headers["set-cookie"]) + # Use anonymous_user to remove the basic auth header from the api client. + with anonymous_user: + pulpcore_bindings.client.cookie = "; ".join( + (f"{k}={v.value}" for k, v in cookie_jar.items()) + ) + yield user + pulpcore_bindings.client.cookie = old_cookie + + +def test_login_read_denies_anonymous(pulpcore_bindings, anonymous_user): + with anonymous_user: + with pytest.raises(pulpcore_bindings.module.ApiException) as exc: + pulpcore_bindings.LoginApi.login_read() + assert exc.value.status == 401 + + +def test_login_read_returns_username(pulpcore_bindings, gen_user): + user = gen_user() + with user: + result = pulpcore_bindings.LoginApi.login_read() + assert result.username == user.username + + +def test_login_denies_anonymous(pulpcore_bindings, anonymous_user): + with anonymous_user: + with pytest.raises(pulpcore_bindings.module.ApiException) as exc: + pulpcore_bindings.LoginApi.login() + assert exc.value.status == 401 + + +def test_login_sets_session_cookie(pulpcore_bindings, gen_user): + user = gen_user() + with user: + response = pulpcore_bindings.LoginApi.login_with_http_info() + if isinstance(response, tuple): + # old bindings + result, status, headers = response + else: + # new bindings + result = response.data + status = response.status + headers = response.headers + assert status == 201 + assert result.username == user.username + cookie_jar = http.cookies.SimpleCookie(headers["set-cookie"]) + assert cookie_jar["sessionid"].value != "" + assert cookie_jar["csrftoken"].value != "" + + +def test_session_cookie_is_authorization(pulpcore_bindings, anonymous_user, session_user): + result = pulpcore_bindings.LoginApi.login_read() + assert result.username == session_user.username + + +# For whatever reason, this tests fails with '{"detail":"CSRF Failed: CSRF token missing."}' +# But we sent the csrf token along... +# The test right after this tries to close the gap and uses basic auth to logout. +# Please remove it when this one is fixed. +@pytest.mark.xfail +def test_logout_removes_sessionid(pulpcore_bindings, session_user): + response = pulpcore_bindings.LoginApi.logout_with_http_info() + if isinstance(response, tuple): + # old bindings + _, status, headers = response + else: + # new bindings + status = response.status + headers = response.headers + assert status == 204 + cookie_jar = http.cookies.SimpleCookie(headers["set-cookie"]) + assert cookie_jar["sessionid"].value == "" + + +def test_basicauth_logout_removes_sessionid(pulpcore_bindings, session_user): + with session_user: + response = pulpcore_bindings.LoginApi.logout_with_http_info() + if isinstance(response, tuple): + # old bindings + _, status, headers = response + else: + # new bindings + status = response.status + headers = response.headers + assert status == 204 + cookie_jar = http.cookies.SimpleCookie(headers["set-cookie"]) + assert cookie_jar["sessionid"].value == "" + + +def test_logout_denies_anonymous(pulpcore_bindings, anonymous_user): + with anonymous_user: + with pytest.raises(pulpcore_bindings.module.ApiException) as exc: + pulpcore_bindings.LoginApi.logout() + assert exc.value.status == 401