Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"allauth",
"allauth.account",
"rest_framework",
"rest_framework.authtoken",
"django_filters",
]
LOCAL_APPS = [
Expand Down
80 changes: 80 additions & 0 deletions dear_petition/petition/api/tests/test_viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions dear_petition/petition/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@
ensure_csrf_cookie(viewsets.TokenRefreshCookieView.as_view()),
name="token_refresh",
),
path("authtoken/", viewsets.AuthToken.as_view(), name="auth_token"),
]
25 changes: 25 additions & 0 deletions dear_petition/petition/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})
4 changes: 4 additions & 0 deletions src/components/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,6 +88,9 @@ function App() {
<ProtectedRoute exact path="/help">
<FAQPage />
</ProtectedRoute>
<ProtectedRoute exact path="/token">
<TokenPage />
</ProtectedRoute>
</Switch>
</AppStyled>
</BrowserRouter>
Expand Down
3 changes: 3 additions & 0 deletions src/components/pages/PageBase.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ function PageBase({ children, className, ...props }) {
<LinkWrapper>
<Link to="/help">Help</Link>
</LinkWrapper>
<LinkWrapper>
<Link to="/token">Token</Link>
</LinkWrapper>
{user?.is_admin ? (
<DropdownMenu
items={[
Expand Down
137 changes: 137 additions & 0 deletions src/components/pages/TokenPage/TokenPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import styled from 'styled-components';
import PageBase from '../PageBase';
import { Button } from '../../elements/Button';
import { useObtainAuthTokenQuery, useCreateAuthTokenQuery } from '../../../service/api';

const TokenPageStyled = styled(PageBase)`
display: flex;
`;

const TokenPageContent = styled.div`
width: 75%;
max-width: 1200px;
min-width: 400px;
padding: 2rem;
`;

const TokenSection = styled.div`
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
margin: 1rem 0;
`;

const TokenDisplay = styled.div`
background: #ffffff;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 1rem;
font-family: 'Courier New', monospace;
word-break: break-all;
margin: 1rem 0;
font-size: 1.5rem;
`;

const InstructionText = styled.div`
margin-bottom: 1.5rem;
line-height: 1.6;

h2 {
color: #495057;
margin-bottom: 0.5rem;
}

p {
margin-bottom: 1rem;
color: #6c757d;
}

ul {
padding-left: 1.5rem;
color: #6c757d;
}

li {
margin-bottom: 0.5rem;
}
`;

const ErrorMessage = styled.div`
background: #f8d7da;
color: #721c24;
border: 1px solid #f1aeb5;
border-radius: 4px;
padding: 1rem;
margin: 1rem 0;
`;

export default function TokenPage() {
const {
data: existingTokenData,
isLoading: isLoadingExisting,
error: existingTokenError,
} = useObtainAuthTokenQuery();
const {
data: newTokenData,
isLoading: isCreating,
error: createError,
refetch: createToken,
} = useCreateAuthTokenQuery();
const tokenData = newTokenData || existingTokenData;
const token = tokenData?.token;
const isLoading = isLoadingExisting || isCreating;

const hasCreateError = createError && (createError.status || createError.data);
const hasExistingError = existingTokenError && (existingTokenError.status || existingTokenError.data);
const error = hasCreateError ? createError : hasExistingError ? existingTokenError : null;

const handleCreateToken = () => {
createToken();
};

return (
<TokenPageStyled>
<TokenPageContent>
<InstructionText>
<h2>API Authentication Token</h2>
<p>
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.
</p>
<p>
<strong>Important Security Guidelines:</strong>
</p>
<ul>
<li>Never share your token with others</li>
<li>Store it securely in environment variables or secure configuration files</li>
<li>If you suspect your token has been compromised, generate a new one immediately</li>
</ul>
</InstructionText>

<TokenSection>
<h3>Your Authentication Token</h3>
{error && (
<ErrorMessage>
Error loading token: {error.message || error.data?.detail || 'Unknown error occurred'}
</ErrorMessage>
)}

{token && <TokenDisplay>{token}</TokenDisplay>}

<div style={{ marginTop: '1rem' }}>
<Button onClick={handleCreateToken} disabled={isLoading} style={{ marginRight: '1rem' }}>
{isLoading ? 'Loading...' : token ? 'Regenerate Token' : 'Generate Token'}
</Button>

{token && (
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(token)}>
Copy to Clipboard
</Button>
)}
</div>
</TokenSection>
</TokenPageContent>
</TokenPageStyled>
);
}
8 changes: 8 additions & 0 deletions src/service/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down Expand Up @@ -254,5 +260,7 @@ export const {
useCreateUserMutation,
useModifyUserMutation,
useUsersQuery,
useObtainAuthTokenQuery,
useCreateAuthTokenQuery,
useAssignClientToBatchMutation,
} = api;