diff --git a/config/settings/base.py b/config/settings/base.py index 38982d0f..09a03654 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -87,6 +87,7 @@ "allauth", "allauth.account", "rest_framework", + "rest_framework.authtoken", "django_filters", ] LOCAL_APPS = [ diff --git a/dear_petition/petition/api/tests/test_viewsets.py b/dear_petition/petition/api/tests/test_viewsets.py index 2a0f6ca8..a8cbf872 100644 --- a/dear_petition/petition/api/tests/test_viewsets.py +++ b/dear_petition/petition/api/tests/test_viewsets.py @@ -4,6 +4,7 @@ from rest_framework import status from rest_framework.test import APITestCase from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework.authtoken.models import Token from dear_petition.petition.tests.factories import ( BatchFactory, @@ -198,3 +199,82 @@ def test_basic_inbox_stats(self, user, api_client): assert data["total_emails"] == 1 assert data["total_files"] == 0 assert data["total_petitions"] == 0 + + +@pytest.mark.django_db +class TestAuthTokenViewSet(APITestCase): + def setUp(self): + self.user = UserFactory() + self.other_user = UserFactory() + self.url = reverse("api:auth_token") + self.tokens = get_tokens_for_user(self.user) + self.access = self.tokens["access"] + self.other_user_tokens = get_tokens_for_user(self.other_user) + self.other_user_access = self.other_user_tokens["access"] + + def test_get_existing_token(self): + """Test GET request returns existing token for authenticated user""" + + existing_token = Token.objects.create(user=self.user) + + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["token"], existing_token.key) + self.assertEqual(response.data["user_id"], self.user.pk) + self.assertEqual(response.data["email"], self.user.email) + + def test_get_no_existing_token(self): + """Test GET request returns 400 when no token exists for user""" + + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, "No token found. Please generate one") + + def test_post_create_new_token(self): + """Test POST request creates new token for authenticated user""" + + self.client.force_authenticate(user=self.user) + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("token", response.data) + + token = Token.objects.get(user=self.user) + self.assertEqual(response.data["token"], token.key) + + def test_post_regenerate_existing_token(self): + """Test POST request regenerates existing token for authenticated user""" + + old_token = Token.objects.create(user=self.user) + old_key = old_token.key + + self.client.force_authenticate(user=self.user) + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("token", response.data) + self.assertEqual(response.data["user_id"], self.user.pk) + self.assertEqual(response.data["email"], self.user.email) + + # Verify new token is different from old token + new_token = Token.objects.get(user=self.user) + self.assertEqual(response.data["token"], new_token.key) + self.assertNotEqual(old_key, new_token.key) + + # Verify only one token exists for the user + self.assertEqual(Token.objects.filter(user=self.user).count(), 1) + + def test_unauthorized_access(self): + """Test unauthorized users cannot access token endpoints""" + + with self.subTest("GET without auth"): + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + with self.subTest("POST without auth"): + response = self.client.post(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/dear_petition/petition/api/urls.py b/dear_petition/petition/api/urls.py index f80045bb..1e8eeb0d 100644 --- a/dear_petition/petition/api/urls.py +++ b/dear_petition/petition/api/urls.py @@ -35,4 +35,5 @@ ensure_csrf_cookie(viewsets.TokenRefreshCookieView.as_view()), name="token_refresh", ), + path("authtoken/", viewsets.AuthToken.as_view(), name="auth_token"), ] diff --git a/dear_petition/petition/api/viewsets.py b/dear_petition/petition/api/viewsets.py index 29e35af5..f0325669 100644 --- a/dear_petition/petition/api/viewsets.py +++ b/dear_petition/petition/api/viewsets.py @@ -18,6 +18,8 @@ from rest_framework_simplejwt import exceptions from rest_framework_simplejwt import views as simplejwt_views from rest_framework_simplejwt.serializers import TokenRefreshSerializer +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView from openpyxl import load_workbook from dear_petition.petition import constants @@ -779,3 +781,26 @@ def post(self, request, *args, **kwargs): ) return response + + +class AuthToken(APIView): + permission_classes = [ + permissions.IsAuthenticated, + ] + + def get(self, request, *args, **kwargs): + user = request.user + try: + token = Token.objects.get(user=user) + return Response({"token": token.key, "user_id": user.pk, "email": user.email}) + except Token.DoesNotExist: + return Response( + "No token found. Please generate one", status=status.HTTP_400_BAD_REQUEST + ) + + def post(self, request, *args, **kwargs): + user = request.user + with transaction.atomic(): + Token.objects.filter(user=user).delete() + token = Token.objects.create(user=user) + return Response({"token": token.key, "user_id": user.pk, "email": user.email}) diff --git a/src/components/App.jsx b/src/components/App.jsx index aa3c420f..99e0ebe2 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -11,6 +11,7 @@ import { Button } from './elements/Button'; import Modal from './elements/Modal/Modal'; import GenerationPage from './pages/GenerationPage/GenerationPage'; import FAQPage from './pages/HelpPage/HelpPage'; +import TokenPage from './pages/TokenPage/TokenPage'; import LoginPage from './pages/LoginPage/LoginPage'; import { CSRF_TOKEN_LS_KEY, USER } from '../constants/authConstants'; import useBrowserWarning from '../hooks/useBrowserWarning'; @@ -87,6 +88,9 @@ function App() { + + + diff --git a/src/components/pages/PageBase.jsx b/src/components/pages/PageBase.jsx index 52f9974e..21692948 100644 --- a/src/components/pages/PageBase.jsx +++ b/src/components/pages/PageBase.jsx @@ -106,6 +106,9 @@ function PageBase({ children, className, ...props }) { Help + + Token + {user?.is_admin ? ( { + createToken(); + }; + + return ( + + + +

API Authentication Token

+

+ Your API authentication token allows you to create cases in EZ Expunge from other systems (for example + ZipCase). This token is unique to your account and should be kept secure. +

+

+ Important Security Guidelines: +

+
    +
  • Never share your token with others
  • +
  • Store it securely in environment variables or secure configuration files
  • +
  • If you suspect your token has been compromised, generate a new one immediately
  • +
+
+ + +

Your Authentication Token

+ {error && ( + + Error loading token: {error.message || error.data?.detail || 'Unknown error occurred'} + + )} + + {token && {token}} + +
+ + + {token && ( + + )} +
+
+
+
+ ); +} diff --git a/src/service/api.js b/src/service/api.js index 45dd2d03..6862799f 100644 --- a/src/service/api.js +++ b/src/service/api.js @@ -156,6 +156,12 @@ export const api = createApi({ logout: builder.mutation({ query: () => ({ url: 'token/', method: 'delete' }), }), + obtainAuthToken: builder.query({ + query: () => ({ url: 'authtoken/', method: 'get' }), + }), + createAuthToken: builder.query({ + query: () => ({ url: 'authtoken/', method: 'post' }), + }), users: builder.query({ query: ({ queryString, id }) => { let url = 'users/'; @@ -254,5 +260,7 @@ export const { useCreateUserMutation, useModifyUserMutation, useUsersQuery, + useObtainAuthTokenQuery, + useCreateAuthTokenQuery, useAssignClientToBatchMutation, } = api;