Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add impersonate #679

Merged
merged 10 commits into from
Sep 21, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ name = "pypi"

[scripts]
# See '/docs/pipenv.md'.
# Doesn't work for powershell.

"pipenv:install" = "pipenv install"
"pipenv:update" = "pipenv update"
"pipenv:sync" = "bash -c \"pipenv clean; pipenv sync --dev\""
Expand Down
16 changes: 16 additions & 0 deletions backend/root/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from contextvars import ContextVar

from django.http import HttpRequest


class Environment:
"""
Useful in eg. templates.
Expand All @@ -13,3 +18,14 @@ class Environment:

# Name of exposed csrf-token header in http traffic.
XCSRFTOKEN = 'X-CSRFToken'

# Name of cookie used for impersonation.
COOKIE_IMPERSONATED_USER_ID = 'impersonated_user_id'

# Name of attribute set on response for requested impersonation of user_id.
REQUESTED_IMPERSONATE_USER = 'requested_impersonate_user'

# This token can be imported anywhere to retrieve the values.
request_contextvar: ContextVar[HttpRequest] = ContextVar('request_contextvar', default=None)

AUTH_BACKEND = 'django.contrib.auth.middleware.AuthenticationMiddleware'
98 changes: 94 additions & 4 deletions backend/root/custom_classes/middlewares.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
from __future__ import annotations

import logging
import secrets
from contextvars import ContextVar

from django.http import HttpRequest, HttpResponse
from django.contrib.auth import login
from django.middleware.csrf import get_token

from root.constants import (
request_contextvar,
REQUESTED_IMPERSONATE_USER,
COOKIE_IMPERSONATED_USER_ID,
)

LOG = logging.getLogger(__name__)
from samfundet.models import User

# This token can be imported anywhere to retrieve the values.
request_contextvar: ContextVar[HttpRequest] = ContextVar('request_contextvar', default=None)
LOG = logging.getLogger('root.middlewares')


class RequestLogMiddleware:
Expand Down Expand Up @@ -41,3 +49,85 @@ def process_exception(self, request: HttpRequest, exception: Exception) -> None:
"""Log unhandled exceptions."""

LOG.error('Unhandled exception while processing request', exc_info=exception)


class ImpersonateUserMiddleware:

def __init__(self, get_response: HttpResponse) -> None:
self.get_response = get_response

def __call__(self, request: HttpRequest) -> HttpResponse:

### Handle impersonation before response ###
impersonate = request.get_signed_cookie(COOKIE_IMPERSONATED_USER_ID, default=None)
if impersonate is not None:
impersonated_user = User.objects.get(id=int(impersonate))
request.user = impersonated_user
request._force_auth_user = impersonated_user
request._force_auth_token = get_token(request)
LOG.info(f"EYOO DUDE YOUR'E NOT YOURSELF '{impersonated_user.username}'")
### End: Handle impersonation after response ###

# Handle response.
response: HttpResponse = self.get_response(request)

### Handle impersonation after response ###
if hasattr(response, REQUESTED_IMPERSONATE_USER):
impersonate_user_id = getattr(response, REQUESTED_IMPERSONATE_USER)
if impersonate_user_id is not None:
response.set_signed_cookie(COOKIE_IMPERSONATED_USER_ID, impersonate_user_id)
LOG.info(f'Now impersonating {impersonate_user_id}')
else:
response.delete_cookie(COOKIE_IMPERSONATED_USER_ID)
### End: Handle impersonation after response ###

return response


class ImpersonateUserMiddleware2:
"""wip Emil"""

def __init__(self, get_response: HttpResponse) -> None:
self.get_response = get_response

def __call__(self, request: HttpRequest) -> HttpResponse:
user: User = request.user
impersonated_user_id = request.get_signed_cookie(
key=COOKIE_IMPERSONATED_USER_ID,
default=None,
)

# TODO: if request.user.has_perm(perm=PERM.SAMFUNDET_IMPERSONATE) and impersonate_user:
# if user.is_superuser and impersonated_user_id:
if impersonated_user_id:
# Find user to impersonate.
impersonated_user = User.objects.get(id=int(impersonated_user_id))
# Keep actual user before it gets replaced.
impersonated_by = request.user

# Login (replaces request.user).
login(
request=request,
user=impersonated_user,
backend='django.contrib.auth.middleware.AuthenticationMiddleware',
)
# Set attr on current user to show impersonation.
impersonated_user._impersonated_by = impersonated_by
request.impersonated_by = impersonated_by
request.user = impersonated_user

# Handle response.
response = self.get_response(request)

### Handle impersonation after response ###
if hasattr(response, REQUESTED_IMPERSONATE_USER):
requested_impersonate_user_id = getattr(response, REQUESTED_IMPERSONATE_USER)

if requested_impersonate_user_id is not None:
response.set_signed_cookie(COOKIE_IMPERSONATED_USER_ID, requested_impersonate_user_id)
LOG.info(f'Now impersonating {requested_impersonate_user_id}')
else:
response.delete_cookie(COOKIE_IMPERSONATED_USER_ID)
### End: Handle impersonation after response ###

return response
4 changes: 2 additions & 2 deletions backend/root/custom_classes/request_context_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

from django.http import HttpRequest

from .middlewares import request_contextvar
from root.constants import request_contextvar

LOG = logging.getLogger(__name__)
LOG = logging.getLogger('root.custom_classes')


class RequestContextFilter(logging.Filter):
Expand Down
1 change: 1 addition & 0 deletions backend/root/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'root.custom_classes.middlewares.ImpersonateUserMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
Expand Down
3 changes: 2 additions & 1 deletion backend/root/utils/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
DO NOT WRITE IN THIS FILE, AS IT WILL BE OVERWRITTEN ON NEXT UPDATE.

THIS FILE WAS GENERATED BY: root.management.commands.generate_routes
LAST UPDATE: 2023-08-29 08:57:57.685488+00:00
LAST UPDATE: 2023-09-14 19:45:16.057665+00:00
"""

############################################################
Expand Down Expand Up @@ -424,6 +424,7 @@
samfundet__user = 'samfundet:user'
samfundet__groups = 'samfundet:groups'
samfundet__users = 'samfundet:users'
samfundet__impersonate = 'samfundet:impersonate'
samfundet__eventsperday = 'samfundet:eventsperday'
samfundet__eventsupcomming = 'samfundet:eventsupcomming'
samfundet__isclosed = 'samfundet:isclosed'
Expand Down
17 changes: 17 additions & 0 deletions backend/samfundet/migrations/0037_alter_user_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.3 on 2023-09-19 13:14

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('samfundet', '0036_venue_slug'),
]

operations = [
migrations.AlterModelOptions(
name='user',
options={'permissions': [('debug', 'Can view debug mode'), ('impersonate', 'Can impersonate users')]},
),
]
13 changes: 10 additions & 3 deletions backend/samfundet/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@
# This is required for registering user model in auth
from .general import (
User,
Profile,
UserPreference,
Gang,
Image,
Profile,
UserPreference,
)

from .event import (
Event,
)

__all__ = ['User', 'Profile', 'UserPreference', 'Event', 'Gang', 'Image']
__all__ = [
'User',
'Gang',
'Event',
'Image',
'Profile',
'UserPreference',
]
11 changes: 11 additions & 0 deletions backend/samfundet/models/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class User(AbstractUser):
class Meta:
permissions = [
('debug', 'Can view debug mode'),
('impersonate', 'Can impersonate users'),
]

def has_perm(self, perm: str, obj: Optional[Model] = None) -> bool:
Expand All @@ -113,6 +114,16 @@ def has_perm(self, perm: str, obj: Optional[Model] = None) -> bool:
has_object_perm = super().has_perm(perm=perm, obj=obj)
return has_global_perm or has_object_perm

@property
def is_impersonated(self) -> bool:
return self._impersonated_by is not None

@property
def impersonated_by(self) -> User:
if not self.is_impersonated:
raise Exception('Real user not available unless currently impersonated.')
return self._impersonated_by


class UserPreference(models.Model):
"""Group all preferences and config per user."""
Expand Down
1 change: 1 addition & 0 deletions backend/samfundet/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
path('user/', views.UserView.as_view(), name='user'),
path('groups/', views.AllGroupsView.as_view(), name='groups'),
path('users/', views.AllUsersView.as_view(), name='users'),
path('impersonate/', views.ImpersonateView.as_view(), name='impersonate'),
path('events-per-day/', views.EventPerDayView.as_view(), name='eventsperday'),
path('events-upcomming/', views.EventsUpcomingView.as_view(), name='eventsupcomming'),
path('isclosed/', views.IsClosedView().as_view(), name='isclosed'),
Expand Down
37 changes: 31 additions & 6 deletions backend/samfundet/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet

from root.constants import XCSRFTOKEN
from root.constants import (
XCSRFTOKEN,
AUTH_BACKEND,
REQUESTED_IMPERSONATE_USER,
)

from .homepage import homepage
from .models.event import (Event, EventGroup)
from .models.event import Event, EventGroup
from .models.recruitment import (
Recruitment,
RecruitmentPosition,
Expand Down Expand Up @@ -289,15 +294,20 @@ def post(self, request: Request) -> Response:
serializer = LoginSerializer(data=self.request.data, context={'request': self.request})
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
login(request=request, user=user)
login(request=request, user=user, backend=AUTH_BACKEND)
new_csrf_token = get_token(request=request)

return Response(
response = Response(
status=status.HTTP_202_ACCEPTED,
data=new_csrf_token,
headers={XCSRFTOKEN: new_csrf_token},
)

# Reset impersonation after login.
setattr(response, REQUESTED_IMPERSONATE_USER, None) # noqa: FKA01

return response


@method_decorator(csrf_protect, 'dispatch')
class LogoutView(APIView):
Expand All @@ -309,7 +319,12 @@ def post(self, request: Request) -> Response:
return Response(status=status.HTTP_400_BAD_REQUEST)

logout(request)
return Response(status=status.HTTP_200_OK)
response = Response(status=status.HTTP_200_OK)

# Reset impersonation after logout.
setattr(response, REQUESTED_IMPERSONATE_USER, None) # noqa: FKA01

return response


@method_decorator(csrf_protect, 'dispatch')
Expand All @@ -321,7 +336,7 @@ def post(self, request: Request) -> Response:
serializer = RegisterSerializer(data=self.request.data, context={'request': self.request})
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
login(request=request, user=user)
login(request=request, user=user, backend=AUTH_BACKEND)
new_csrf_token = get_token(request=request)

return Response(
Expand All @@ -344,6 +359,16 @@ class AllUsersView(ListAPIView):
queryset = User.objects.all()


class ImpersonateView(APIView):
permission_classes = [IsAuthenticated] # TODO: Permission check.

def post(self, request: Request) -> Response:
response = Response(status=200)
user_id = request.data.get('user_id', None)
setattr(response, REQUESTED_IMPERSONATE_USER, user_id) # noqa: FKA01
return response


class AllGroupsView(ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = GroupSerializer
Expand Down
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"start:docker": "vite serve",
"dev": "yarn run start",
"build": "vite build",
"ci": "yarn install --frozen-lockfile",
"preview": "vite preview",
"storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006 -s public --quiet",
"storybook-dev": "start-storybook -p 6006 -s public",
Expand Down Expand Up @@ -39,6 +40,7 @@
"path-to-regexp": "^6.2.1",
"postcss": "^8.4.18",
"react": "^18.2.0",
"react-cookie": "^6.1.1",
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-loading-skeleton": "^3.1.0",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { SaksdokumentAdminPage } from './PagesAdmin/SaksdokumentAdminPage';
import { PERM } from './permissions';
import { ROUTES } from './routes';
import { RecruitmentPositionOverviewPage } from './PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage';
import {ImpersonateUserAdminPage} from "~/PagesAdmin/ImpersonateUserAdminPage/ImpersonateUserAdminPage";

export function AppRoutes() {
// Must be called within <BrowserRouter> because it uses hook useLocation().
Expand Down Expand Up @@ -80,6 +81,11 @@ export function AppRoutes() {
ADMIN ROUTES
*/}
<Route element={<ProtectedRoute perms={[PERM.SAMFUNDET_VIEW_GANG]} Page={AdminLayout} />}>
{/* TODO PERMISSION FOR IMPERSONATE */}
<Route
path={ROUTES.frontend.admin_impersonate}
element={<ProtectedRoute perms={[]} Page={ImpersonateUserAdminPage} />}
/>
<Route
path={ROUTES.frontend.admin}
element={<ProtectedRoute perms={[PERM.SAMFUNDET_VIEW_GANG]} Page={AdminPage} />}
Expand Down
Loading