From c3fe57197da5a560f19fa6858d2c638b9e8d6fea Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 16 Jun 2023 14:39:42 +0200 Subject: [PATCH] ATH-01-009: migrate impersonation to use API Signed-off-by: Jens Langhammer # Conflicts: # authentik/core/urls.py # web/src/admin/AdminInterface.ts # web/src/admin/users/RelatedUserList.ts # web/src/admin/users/UserListPage.ts # web/src/admin/users/UserViewPage.ts # web/src/user/UserInterface.ts # Conflicts: # authentik/core/urls.py --- authentik/admin/api/system.py | 1 - authentik/api/authentication.py | 3 +- authentik/core/api/users.py | 55 +++++++++++++++++++- authentik/core/tests/test_impersonation.py | 20 ++++---- authentik/core/urls.py | 13 +---- authentik/core/views/impersonate.py | 60 ---------------------- schema.yml | 55 ++++++++++++++++++++ web/src/admin/AdminInterface.ts | 11 ++-- web/src/admin/users/RelatedUserList.ts | 16 ++++-- web/src/admin/users/UserListPage.ts | 16 ++++-- web/src/admin/users/UserViewPage.ts | 16 ++++-- web/src/user/UserInterface.ts | 32 +++++++----- 12 files changed, 182 insertions(+), 116 deletions(-) delete mode 100644 authentik/core/views/impersonate.py diff --git a/authentik/admin/api/system.py b/authentik/admin/api/system.py index 2b3f43da8..11dc5dfec 100644 --- a/authentik/admin/api/system.py +++ b/authentik/admin/api/system.py @@ -1,5 +1,4 @@ """authentik administration overview""" -import os import platform from datetime import datetime from sys import version as python_version diff --git a/authentik/api/authentication.py b/authentik/api/authentication.py index 32e5949d7..8aa402384 100644 --- a/authentik/api/authentication.py +++ b/authentik/api/authentication.py @@ -1,6 +1,7 @@ """API Authentication""" -from typing import Any, Optional from hmac import compare_digest +from typing import Any, Optional + from django.conf import settings from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.exceptions import AuthenticationFailed diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 9c2f9ed2c..407a27383 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -67,11 +67,12 @@ from authentik.core.models import ( TokenIntents, User, ) -from authentik.events.models import EventAction +from authentik.events.models import Event, EventAction from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.models import FlowToken from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner from authentik.flows.views.executor import QS_KEY_TOKEN +from authentik.lib.config import CONFIG from authentik.stages.email.models import EmailStage from authentik.stages.email.tasks import send_mails from authentik.stages.email.utils import TemplateEmailMessage @@ -543,6 +544,58 @@ class UserViewSet(UsedByMixin, ModelViewSet): send_mails(email_stage, message) return Response(status=204) + @permission_required("authentik_core.impersonate") + @extend_schema( + request=OpenApiTypes.NONE, + responses={ + "204": OpenApiResponse(description="Successfully started impersonation"), + "401": OpenApiResponse(description="Access denied"), + }, + ) + @action(detail=True, methods=["POST"]) + def impersonate(self, request: Request, pk: int) -> Response: + """Impersonate a user""" + if not CONFIG.y_bool("impersonation"): + LOGGER.debug("User attempted to impersonate", user=request.user) + return Response(status=401) + if not request.user.has_perm("impersonate"): + LOGGER.debug("User attempted to impersonate without permissions", user=request.user) + return Response(status=401) + + user_to_be = self.get_object() + + request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user + request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be + + Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) + + return Response(status=201) + + @extend_schema( + request=OpenApiTypes.NONE, + responses={ + "204": OpenApiResponse(description="Successfully started impersonation"), + }, + ) + @action(detail=False, methods=["GET"]) + def impersonate_end(self, request: Request) -> Response: + """End Impersonation a user""" + if ( + SESSION_KEY_IMPERSONATE_USER not in request.session + or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session + ): + LOGGER.debug("Can't end impersonation", user=request.user) + return Response(status=204) + + original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] + + del request.session[SESSION_KEY_IMPERSONATE_USER] + del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] + + Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user) + + return Response(status=204) + def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: """Custom filter_queryset method which ignores guardian, but still supports sorting""" for backend in list(self.filter_backends): diff --git a/authentik/core/tests/test_impersonation.py b/authentik/core/tests/test_impersonation.py index 12d309d59..6d17393a8 100644 --- a/authentik/core/tests/test_impersonation.py +++ b/authentik/core/tests/test_impersonation.py @@ -1,14 +1,14 @@ """impersonation tests""" from json import loads -from django.test.testcases import TestCase from django.urls import reverse +from rest_framework.test import APITestCase from authentik.core.models import User from authentik.core.tests.utils import create_test_admin_user -class TestImpersonation(TestCase): +class TestImpersonation(APITestCase): """impersonation tests""" def setUp(self) -> None: @@ -23,10 +23,10 @@ class TestImpersonation(TestCase): self.other_user.save() self.client.force_login(self.user) - self.client.get( + self.client.post( reverse( - "authentik_core:impersonate-init", - kwargs={"user_id": self.other_user.pk}, + "authentik_api:user-impersonate", + kwargs={"pk": self.other_user.pk}, ) ) @@ -35,7 +35,7 @@ class TestImpersonation(TestCase): self.assertEqual(response_body["user"]["username"], self.other_user.username) self.assertEqual(response_body["original"]["username"], self.user.username) - self.client.get(reverse("authentik_core:impersonate-end")) + self.client.get(reverse("authentik_api:user-impersonate-end")) response = self.client.get(reverse("authentik_api:user-me")) response_body = loads(response.content.decode()) @@ -46,9 +46,7 @@ class TestImpersonation(TestCase): """test impersonation without permissions""" self.client.force_login(self.other_user) - self.client.get( - reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk}) - ) + self.client.get(reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})) response = self.client.get(reverse("authentik_api:user-me")) response_body = loads(response.content.decode()) @@ -58,5 +56,5 @@ class TestImpersonation(TestCase): """test un-impersonation without impersonating first""" self.client.force_login(self.other_user) - response = self.client.get(reverse("authentik_core:impersonate-end")) - self.assertRedirects(response, reverse("authentik_core:if-user")) + response = self.client.get(reverse("authentik_api:user-impersonate-end")) + self.assertEqual(response.status_code, 204) diff --git a/authentik/core/urls.py b/authentik/core/urls.py index 0fc6aab0a..c9aa748c5 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -16,7 +16,7 @@ from authentik.core.api.providers import ProviderViewSet from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet from authentik.core.api.tokens import TokenViewSet from authentik.core.api.users import UserViewSet -from authentik.core.views import apps, impersonate +from authentik.core.views import apps from authentik.core.views.debug import AccessDeniedView from authentik.core.views.interface import FlowInterfaceView, InterfaceView from authentik.core.views.session import EndSessionView @@ -38,17 +38,6 @@ urlpatterns = [ apps.RedirectToAppLaunch.as_view(), name="application-launch", ), - # Impersonation - path( - "-/impersonation//", - impersonate.ImpersonateInitView.as_view(), - name="impersonate-init", - ), - path( - "-/impersonation/end/", - impersonate.ImpersonateEndView.as_view(), - name="impersonate-end", - ), # Interfaces path( "if/admin/", diff --git a/authentik/core/views/impersonate.py b/authentik/core/views/impersonate.py deleted file mode 100644 index c19a47f62..000000000 --- a/authentik/core/views/impersonate.py +++ /dev/null @@ -1,60 +0,0 @@ -"""authentik impersonation views""" - -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect -from django.views import View -from structlog.stdlib import get_logger - -from authentik.core.middleware import ( - SESSION_KEY_IMPERSONATE_ORIGINAL_USER, - SESSION_KEY_IMPERSONATE_USER, -) -from authentik.core.models import User -from authentik.events.models import Event, EventAction -from authentik.lib.config import CONFIG - -LOGGER = get_logger() - - -class ImpersonateInitView(View): - """Initiate Impersonation""" - - def get(self, request: HttpRequest, user_id: int) -> HttpResponse: - """Impersonation handler, checks permissions""" - if not CONFIG.y_bool("impersonation"): - LOGGER.debug("User attempted to impersonate", user=request.user) - return HttpResponse("Unauthorized", status=401) - if not request.user.has_perm("impersonate"): - LOGGER.debug("User attempted to impersonate without permissions", user=request.user) - return HttpResponse("Unauthorized", status=401) - - user_to_be = get_object_or_404(User, pk=user_id) - - request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user - request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be - - Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) - - return redirect("authentik_core:if-user") - - -class ImpersonateEndView(View): - """End User impersonation""" - - def get(self, request: HttpRequest) -> HttpResponse: - """End Impersonation handler""" - if ( - SESSION_KEY_IMPERSONATE_USER not in request.session - or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session - ): - LOGGER.debug("Can't end impersonation", user=request.user) - return redirect("authentik_core:if-user") - - original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] - - del request.session[SESSION_KEY_IMPERSONATE_USER] - del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] - - Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user) - - return redirect("authentik_core:root-redirect") diff --git a/schema.yml b/schema.yml index d0c8ced05..33070d48d 100644 --- a/schema.yml +++ b/schema.yml @@ -4783,6 +4783,38 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /core/users/{id}/impersonate/: + post: + operationId: core_users_impersonate_create + description: Impersonate a user + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this User. + required: true + tags: + - core + security: + - authentik: [] + responses: + '204': + description: Successfully started impersonation + '401': + description: Access denied + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /core/users/{id}/metrics/: get: operationId: core_users_metrics_retrieve @@ -4962,6 +4994,29 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /core/users/impersonate_end/: + get: + operationId: core_users_impersonate_end_retrieve + description: End Impersonation a user + tags: + - core + security: + - authentik: [] + responses: + '204': + description: Successfully started impersonation + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /core/users/me/: get: operationId: core_users_me_retrieve diff --git a/web/src/admin/AdminInterface.ts b/web/src/admin/AdminInterface.ts index 2b7247c0c..115a22951 100644 --- a/web/src/admin/AdminInterface.ts +++ b/web/src/admin/AdminInterface.ts @@ -31,7 +31,7 @@ import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { AdminApi, SessionUser, Version } from "@goauthentik/api"; +import { AdminApi, CoreApi, SessionUser, Version } from "@goauthentik/api"; autoDetectLanguage(); @@ -175,10 +175,11 @@ export class AdminInterface extends Interface { ${this.user?.original ? html` { + new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => { + window.location.reload(); + }); + }} > ${t`You're currently impersonating ${this.user.user.username}. Click to stop.`} { ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) ? html` - { + return new CoreApi(DEFAULT_CONFIG) + .coreUsersImpersonateCreate({ + id: item.pk, + }) + .then(() => { + window.location.href = "/"; + }); + }} > ${t`Impersonate`} - + ` : html``}`, ]; diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index e5519cb31..a475c5f00 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -196,12 +196,20 @@ export class UserListPage extends TablePage { ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) ? html` - { + return new CoreApi(DEFAULT_CONFIG) + .coreUsersImpersonateCreate({ + id: item.pk, + }) + .then(() => { + window.location.href = "/"; + }); + }} > ${t`Impersonate`} - + ` : html``}`, ]; diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index 369dda4b5..0c9e8ac37 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -201,12 +201,20 @@ export class UserViewPage extends AKElement { ) ? html` ` : html``} diff --git a/web/src/user/UserInterface.ts b/web/src/user/UserInterface.ts index 5baa3f087..a38e8968e 100644 --- a/web/src/user/UserInterface.ts +++ b/web/src/user/UserInterface.ts @@ -11,6 +11,7 @@ import { me } from "@goauthentik/common/users"; import { first } from "@goauthentik/common/utils"; import { WebsocketClient } from "@goauthentik/common/ws"; import { Interface } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/notifications/APIDrawer"; import "@goauthentik/elements/notifications/NotificationDrawer"; @@ -36,7 +37,7 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; -import { EventsApi, SessionUser } from "@goauthentik/api"; +import { CoreApi, EventsApi, SessionUser } from "@goauthentik/api"; autoDetectLanguage(); @@ -234,18 +235,23 @@ export class UserInterface extends Interface { : html``} ${this.me.original - ? html`` + ? html`  +
+
+ { + return new CoreApi(DEFAULT_CONFIG) + .coreUsersImpersonateEndRetrieve() + .then(() => { + window.location.reload(); + }); + }} + > + ${t`Stop impersonation`} + +
+
` : html``}