core: move impersonation to core, add tests, add better permission checks

This commit is contained in:
Jens Langhammer 2020-09-17 16:24:53 +02:00
parent da15a8878f
commit 5ff1dd8426
11 changed files with 180 additions and 36 deletions

View File

@ -1,26 +0,0 @@
"""passbook admin Middleware to impersonate users"""
from passbook.core.models import User
def impersonate(get_response):
"""Middleware to impersonate users"""
def middleware(request):
"""Middleware to impersonate users"""
# User is superuser and has __impersonate ID set
if request.user.is_superuser and "__impersonate" in request.GET:
request.session["impersonate_id"] = request.GET["__impersonate"]
# user wants to stop impersonation
elif "__unimpersonate" in request.GET and "impersonate_id" in request.session:
del request.session["impersonate_id"]
# Actually impersonate user
if request.user.is_superuser and "impersonate_id" in request.session:
request.user = User.objects.get(pk=request.session["impersonate_id"])
response = get_response(request)
return response
return middleware

View File

@ -1,5 +0,0 @@
"""passbook admin settings"""
MIDDLEWARE = [
"passbook.admin.middleware.impersonate",
]

View File

@ -55,7 +55,7 @@
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:user-update' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a> <a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:user-update' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a> <a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
<a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_admin:user-password-reset' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Reset Password' %}</a> <a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_admin:user-password-reset' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Reset Password' %}</a>
<a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_core:overview' %}?__impersonate={{ user.pk }}">{% trans 'Impersonate' %}</a> <a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_core:impersonate-init' user_id=user.pk %}">{% trans 'Impersonate' %}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -0,0 +1,26 @@
"""passbook admin Middleware to impersonate users"""
from typing import Callable
from django.http import HttpRequest, HttpResponse
SESSION_IMPERSONATE_USER = "passbook_impersonate_user"
SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user"
class ImpersonateMiddleware:
"""Middleware to impersonate users"""
get_response: Callable[[HttpRequest], HttpResponse]
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
# No permission checks are done here, they need to be checked before
# SESSION_IMPERSONATE_USER is set.
if SESSION_IMPERSONATE_USER in request.session:
request.user = request.session[SESSION_IMPERSONATE_USER]
return self.get_response(request)

View File

@ -0,0 +1,24 @@
# Generated by Django 3.1.1 on 2020-09-17 10:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0009_group_is_superuser"),
]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": (
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
),
"verbose_name": "User",
"verbose_name_plural": "Users",
},
),
]

View File

@ -98,7 +98,10 @@ class User(GuardianUserMixin, AbstractUser):
class Meta: class Meta:
permissions = (("reset_user_password", "Reset Password"),) permissions = (
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
)
verbose_name = _("User") verbose_name = _("User")
verbose_name_plural = _("Users") verbose_name_plural = _("Users")

View File

@ -21,13 +21,13 @@
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
{% if 'impersonate_id' in request.session %} {% if 'passbook_impersonate_user' in request.session %}
<div class="pf-c-banner pf-m-warning pf-c-alert pf-m-sticky"> <div class="pf-c-banner pf-m-warning pf-c-alert pf-m-sticky">
<div class="pf-l-flex pf-m-justify-content-center pf-m-justify-content-space-between-on-lg pf-m-nowrap" style="height: 100%;"> <div class="pf-l-flex pf-m-justify-content-center pf-m-justify-content-space-between-on-lg pf-m-nowrap" style="height: 100%;">
<div class=""></div> <div class=""></div>
<div class="pf-u-display-none pf-u-display-block-on-lg"> <div class="pf-u-display-none pf-u-display-block-on-lg">
{% blocktrans with user=user %}You're currently impersonating {{ user }}.{% endblocktrans %} {% blocktrans with user=user %}You're currently impersonating {{ user }}.{% endblocktrans %}
<a href="?__unimpersonate=True" id="acceptMessage">{% trans 'Stop impersonation' %}</a> <a href="{% url 'passbook_core:impersonate-end' %}?back={{ request.get_full_path }}" id="acceptMessage">{% trans 'Stop impersonation' %}</a>
</div> </div>
<div class=""></div> <div class=""></div>
</div> </div>

View File

@ -0,0 +1,55 @@
"""impersonation tests"""
from django.shortcuts import reverse
from django.test.testcases import TestCase
from passbook.core.models import User
class TestImpersonation(TestCase):
"""impersonation tests"""
def setUp(self) -> None:
super().setUp()
self.other_user = User.objects.create(username="to-impersonate")
self.pbadmin = User.objects.get(username="pbadmin")
def test_impersonate_simple(self):
"""test simple impersonation and un-impersonation"""
self.client.force_login(self.pbadmin)
self.client.get(
reverse(
"passbook_core:impersonate-init", kwargs={"user_id": self.other_user.pk}
)
)
response = self.client.get(reverse("passbook_core:overview"))
self.assertIn(self.other_user.username, response.content.decode())
self.assertNotIn(self.pbadmin.username, response.content.decode())
self.client.get(reverse("passbook_core:impersonate-end"))
response = self.client.get(reverse("passbook_core:overview"))
self.assertNotIn(self.other_user.username, response.content.decode())
self.assertIn(self.pbadmin.username, response.content.decode())
def test_impersonate_denied(self):
"""test impersonation without permissions"""
self.client.force_login(self.other_user)
self.client.get(
reverse(
"passbook_core:impersonate-init", kwargs={"user_id": self.pbadmin.pk}
)
)
response = self.client.get(reverse("passbook_core:overview"))
self.assertIn(self.other_user.username, response.content.decode())
self.assertNotIn(self.pbadmin.username, response.content.decode())
def test_un_impersonate_empty(self):
"""test un-impersonation without impersonating first"""
self.client.force_login(self.other_user)
response = self.client.get(reverse("passbook_core:impersonate-end"))
self.assertRedirects(response, reverse("passbook_core:overview"))

View File

@ -1,11 +1,22 @@
"""passbook URL Configuration""" """passbook URL Configuration"""
from django.urls import path from django.urls import path
from passbook.core.views import overview, user from passbook.core.views import impersonate, overview, user
urlpatterns = [ urlpatterns = [
# User views # User views
path("-/user/", user.UserSettingsView.as_view(), name="user-settings"), path("-/user/", user.UserSettingsView.as_view(), name="user-settings"),
# Overview # Overview
path("", overview.OverviewView.as_view(), name="overview"), path("", overview.OverviewView.as_view(), name="overview"),
# Impersonation
path(
"-/impersonation/<int:user_id>/",
impersonate.ImpersonateInitView.as_view(),
name="impersonate-init",
),
path(
"-/impersonation/end/",
impersonate.ImpersonateEndView.as_view(),
name="impersonate-end",
),
] ]

View File

@ -0,0 +1,55 @@
"""passbook impersonation views"""
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from structlog import get_logger
from passbook.core.middleware import (
SESSION_IMPERSONATE_ORIGINAL_USER,
SESSION_IMPERSONATE_USER,
)
from passbook.core.models import User
LOGGER = get_logger()
class ImpersonateInitView(View):
"""Initiate Impersonation"""
def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
"""Impersonation handler, checks permissions"""
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_IMPERSONATE_ORIGINAL_USER] = request.user
request.session[SESSION_IMPERSONATE_USER] = user_to_be
# TODO Audit log entry
return redirect("passbook_core:overview")
class ImpersonateEndView(View):
"""End User impersonation"""
def get(self, request: HttpRequest) -> HttpResponse:
"""End Impersonation handler"""
if (
SESSION_IMPERSONATE_USER not in request.session
or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session
):
LOGGER.debug("Can't end impersonation", user=request.user)
return redirect("passbook_core:overview")
del request.session[SESSION_IMPERSONATE_USER]
del request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
# TODO: Audit log entry
return redirect("passbook_core:overview")

View File

@ -179,6 +179,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"passbook.core.middleware.ImpersonateMiddleware",
"django_prometheus.middleware.PrometheusAfterMiddleware", "django_prometheus.middleware.PrometheusAfterMiddleware",
] ]