diff --git a/backend/Pipfile b/backend/Pipfile index 5c2d3550d..54c58026d 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -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\"" diff --git a/backend/root/constants.py b/backend/root/constants.py index c40f80bbf..9c10688a3 100644 --- a/backend/root/constants.py +++ b/backend/root/constants.py @@ -1,3 +1,8 @@ +from contextvars import ContextVar + +from django.http import HttpRequest + + class Environment: """ Useful in eg. templates. @@ -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' diff --git a/backend/root/custom_classes/middlewares.py b/backend/root/custom_classes/middlewares.py index bde43f742..b860cf192 100644 --- a/backend/root/custom_classes/middlewares.py +++ b/backend/root/custom_classes/middlewares.py @@ -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: @@ -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 diff --git a/backend/root/custom_classes/request_context_filter.py b/backend/root/custom_classes/request_context_filter.py index 50e3a31bd..9ea943add 100644 --- a/backend/root/custom_classes/request_context_filter.py +++ b/backend/root/custom_classes/request_context_filter.py @@ -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): diff --git a/backend/root/settings/base.py b/backend/root/settings/base.py index 0db4c9fa0..52a9fb97f 100644 --- a/backend/root/settings/base.py +++ b/backend/root/settings/base.py @@ -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', ] diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index 9755f9833..7bc51695e 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -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-09-04 15:22:16.450169+00:00 +LAST UPDATE: 2023-09-14 19:45:16.057665+00:00 """ ############################################################ @@ -426,6 +426,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' diff --git a/backend/samfundet/migrations/0037_alter_user_options.py b/backend/samfundet/migrations/0037_alter_user_options.py new file mode 100644 index 000000000..3d3061362 --- /dev/null +++ b/backend/samfundet/migrations/0037_alter_user_options.py @@ -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')]}, + ), + ] diff --git a/backend/samfundet/models/__init__.py b/backend/samfundet/models/__init__.py index b7ab167ff..e5df49ec2 100644 --- a/backend/samfundet/models/__init__.py +++ b/backend/samfundet/models/__init__.py @@ -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', +] diff --git a/backend/samfundet/models/general.py b/backend/samfundet/models/general.py index a75aa89a3..433b199cc 100644 --- a/backend/samfundet/models/general.py +++ b/backend/samfundet/models/general.py @@ -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: @@ -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.""" diff --git a/backend/samfundet/tests/test_views.py b/backend/samfundet/tests/test_views.py index db0bc6d6e..22682ecd6 100644 --- a/backend/samfundet/tests/test_views.py +++ b/backend/samfundet/tests/test_views.py @@ -27,18 +27,22 @@ def test_csrf(fixture_rest_client: APIClient): assert status.is_success(code=response.status_code) -def test_login_logout( +def test_login( fixture_rest_client: APIClient, fixture_user: User, fixture_user_pw: str, ): - # Login url = reverse(routes.samfundet__login) data = {'username': fixture_user.username, 'password': fixture_user_pw} response: Response = fixture_rest_client.post(path=url, data=data) assert status.is_success(code=response.status_code) - # Logout + +def test_logout( + fixture_rest_client: APIClient, + fixture_user: User, +): + fixture_rest_client.force_authenticate(user=fixture_user) url = reverse(routes.samfundet__logout) response: Response = fixture_rest_client.post(path=url) assert status.is_success(code=response.status_code) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 2985c5d94..d9ae92432 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -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'), diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 8b422b47e..6b6690c69 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -18,9 +18,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, @@ -292,15 +297,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): @@ -312,7 +322,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') @@ -324,7 +339,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( @@ -347,6 +362,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 diff --git a/frontend/package.json b/frontend/package.json index e1400738d..1b5a44b73 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", @@ -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", diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx index f94ddb898..4bc18d702 100644 --- a/frontend/src/AppRoutes.tsx +++ b/frontend/src/AppRoutes.tsx @@ -40,6 +40,7 @@ import { RecruitmentUsersWithoutInterview, SaksdokumentFormAdminPage, } from '~/PagesAdmin'; +import { ImpersonateUserAdminPage } from '~/PagesAdmin/ImpersonateUserAdminPage/ImpersonateUserAdminPage'; import { useGoatCounter } from '~/hooks'; import { ProtectedRoute } from './Components'; import { SamfOutlet } from './Components/SamfOutlet'; @@ -47,10 +48,10 @@ import { SultenOutlet } from './Components/SultenOutlet'; import { VenuePage } from './Pages/VenuePage'; import { AdminLayout } from './PagesAdmin/AdminLayout/AdminLayout'; import { RecruitmentFormAdminPage } from './PagesAdmin/RecruitmentFormAdminPage'; +import { RecruitmentPositionOverviewPage } from './PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage'; import { SaksdokumentAdminPage } from './PagesAdmin/SaksdokumentAdminPage'; import { PERM } from './permissions'; import { ROUTES } from './routes'; -import { RecruitmentPositionOverviewPage } from './PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage'; export function AppRoutes() { // Must be called within because it uses hook useLocation(). @@ -84,6 +85,11 @@ export function AppRoutes() { ADMIN ROUTES */} }> + {/* TODO PERMISSION FOR IMPERSONATE */} + } + /> } diff --git a/frontend/src/Components/Footer/Footer.stories.tsx b/frontend/src/Components/Footer/Footer.stories.tsx index 44d9166a0..32e3635dd 100644 --- a/frontend/src/Components/Footer/Footer.stories.tsx +++ b/frontend/src/Components/Footer/Footer.stories.tsx @@ -7,7 +7,7 @@ export default { component: Footer, } as ComponentMeta; -const Template: ComponentStory = function (args) { +const Template: ComponentStory = function () { return