Impersonation system implementation

This commit is contained in:
JakeBreath
2026-08-03 21:33:31 -05:00
parent 87b0d46232
commit 95acc9b8d2
12 changed files with 545 additions and 8 deletions
+96 -2
View File
@@ -8,14 +8,16 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.http import JsonResponse
from django.core.paginator import Paginator
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from common.markdown import render_markdown
from library.models import FileIndex
from .forms import AvatarForm, BioForm, EmailForm, UsernameForm
from .models import ApiToken, UserProfile
from .models import ApiToken, ImpersonationLog, UserProfile
def register(request):
@@ -47,6 +49,10 @@ def user_login(request):
def user_logout(request):
if request.method == 'POST':
was_authorized = request.session.get('authorized', False)
# While impersonating, "logout" returns to the real account instead of
# ending the session entirely.
if _stop_impersonation(request):
return redirect('landing:home')
logout(request)
# logout() flushes the session (wiping the gate flag). The gate is a
# site-wide master password, separate from user accounts — preserve it.
@@ -56,6 +62,85 @@ def user_logout(request):
return redirect('landing:home')
# ---------- Impersonation (staff only) ----------
def _stop_impersonation(request):
"""Return the session to the original (impersonator) account. Shared by
impersonate_stop and user_logout. login() flushes the session, so the gate
flag must be restored afterwards."""
impersonator_id = request.session.pop('impersonator_id', None)
if not impersonator_id:
return False
original = get_user_model().objects.filter(pk=impersonator_id).first()
if original is None:
return False
ImpersonationLog.objects.filter(
impersonator=original, ended_at__isnull=True,
).update(ended_at=timezone.now())
login(request, original, backend='django.contrib.auth.backends.ModelBackend')
request.session['authorized'] = True
return True
def user_list(request):
"""Browse-style directory of every account on the site."""
q = request.GET.get('q', '').strip()
users = get_user_model().objects.select_related('userprofile').order_by('username')
if q:
users = users.filter(username__icontains=q)
paginator = Paginator(users, 24)
page_obj = paginator.get_page(request.GET.get('page'))
return render(request, 'profiles/users.html', {
'users': page_obj,
'q': q,
})
@login_required
def impersonate_start(request, user_id):
if request.method != 'POST':
return HttpResponse(status=405)
if not request.user.is_staff:
return HttpResponseForbidden('Only staff can impersonate users.')
if request.session.get('impersonator_id'):
messages.error(request, 'Stop the current impersonation first.')
return redirect('profiles:user_profile', username=request.user.username)
actor = request.user
target = get_object_or_404(get_user_model(), pk=user_id)
if target.pk == actor.pk:
messages.error(request, 'You cannot impersonate yourself.')
return redirect('profiles:user_profile', username=target.username)
if target.is_superuser:
messages.error(request, 'Superusers cannot be impersonated.')
return redirect('profiles:user_profile', username=target.username)
if target.is_staff and actor.pk >= target.pk:
# Staff-to-staff: only an older (lower PK) account may impersonate.
messages.error(request, 'You cannot impersonate a more senior staff member.')
return redirect('profiles:user_profile', username=target.username)
reason = request.POST.get('reason', '')[:255]
ImpersonationLog.objects.create(impersonator=actor, target=target, reason=reason)
login(request, target, backend='django.contrib.auth.backends.ModelBackend')
request.session['authorized'] = True
request.session['impersonator_id'] = actor.pk
messages.success(request, f'Now acting as {target.username}.')
return redirect('profiles:user_profile', username=target.username)
@login_required
def impersonate_stop(request):
if request.method != 'POST':
return HttpResponse(status=405)
_stop_impersonation(request)
messages.success(request, 'Impersonation stopped.')
return redirect('landing:home')
@login_required
def settings_page(request):
tokens = request.user.api_tokens.all()
@@ -186,6 +271,14 @@ def user_profile(request, username):
user = get_object_or_404(User, username__iexact=username)
profile, created = UserProfile.objects.get_or_create(user=user)
is_owner = request.user.is_authenticated and request.user.pk == user.pk
can_impersonate = (
request.user.is_authenticated
and request.user.is_staff
and not request.session.get('impersonator_id')
and request.user.pk != user.pk
and not user.is_superuser
and (not user.is_staff or request.user.pk < user.pk)
)
bio_form = None
if is_owner:
if request.method == 'POST' and request.POST.get('action') == 'bio':
@@ -201,6 +294,7 @@ def user_profile(request, username):
'profile': profile,
'profile_user': user,
'is_owner': is_owner,
'can_impersonate': can_impersonate,
'bio_form': bio_form,
'projects': user.projects.select_related('owner', 'thumbnail').prefetch_related(
'tag_links__tag__category', 'versions',