Skip to content

Commit 5719c0c

Browse files
committed
💩(user-tokens) add back & front for Token auth
This provides: - a frontend to allow user to create/delete User Token - the authentication process to allow any API to be called when authenticating with a User Token.
1 parent 391ac94 commit 5719c0c

File tree

17 files changed

+652
-0
lines changed

17 files changed

+652
-0
lines changed

src/backend/core/api/viewsets.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import requests
2525
import rest_framework as drf
2626
from botocore.exceptions import ClientError
27+
from knox.auth import TokenAuthentication
2728
from lasuite.malware_detection import malware_detection
2829
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
2930
from rest_framework import filters, status, viewsets
@@ -670,6 +671,7 @@ def trashbin(self, request, *args, **kwargs):
670671
authentication_classes=[
671672
authentication.ServerToServerAuthentication,
672673
ResourceServerAuthentication,
674+
TokenAuthentication,
673675
],
674676
detail=False,
675677
methods=["post"],
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""
2+
Test user_token API endpoints in the impress core app.
3+
"""
4+
5+
import pytest
6+
from knox.models import get_token_model
7+
from rest_framework.test import APIClient
8+
9+
from core import factories, models
10+
11+
pytestmark = pytest.mark.django_db
12+
AuthToken = get_token_model()
13+
14+
def test_api_user_token_list_anonymous(client):
15+
"""Anonymous users should not be allowed to list user tokens."""
16+
response = client.get("/api/v1.0/user-tokens/")
17+
assert response.status_code == 403
18+
assert response.json() == {
19+
"detail": "Authentication credentials were not provided."
20+
}
21+
22+
23+
def test_api_user_token_list_authenticated(client):
24+
"""
25+
Authenticated users should be able to list their own tokens.
26+
Tokens are identified by digest, and include created/expiry.
27+
"""
28+
user = factories.UserFactory()
29+
# Knox creates a token instance and a character string token key.
30+
# The create method returns a tuple: (instance, token_key_string)
31+
token_instance_1, _ = AuthToken.objects.create(user=user)
32+
AuthToken.objects.create(user=user) # Another token for the same user
33+
AuthToken.objects.create(user=factories.UserFactory()) # Token for a different user
34+
35+
client.force_login(user)
36+
37+
response = client.get("/api/v1.0/user-tokens/")
38+
assert response.status_code == 200
39+
content = response.json()
40+
assert len(content) == 2
41+
42+
# Check that the response contains the digests of the tokens created for the user
43+
response_token_digests = {item["digest"] for item in content}
44+
assert token_instance_1.digest in response_token_digests
45+
46+
# Ensure the token_key is not listed
47+
for item in content:
48+
assert "token_key" not in item
49+
assert "digest" in item
50+
assert "created" in item
51+
assert "expiry" in item
52+
53+
54+
def test_api_user_token_create_anonymous(client):
55+
"""Anonymous users should not be allowed to create user tokens."""
56+
# The create endpoint does not take any parameters as per TokenCreateSerializer
57+
# (user is implicit, other fields are read_only)
58+
response = client.post("/api/v1.0/user-tokens/", data={})
59+
assert response.status_code == 403
60+
assert response.json() == {
61+
"detail": "Authentication credentials were not provided."
62+
}
63+
64+
65+
def test_api_user_token_create_authenticated(client):
66+
"""
67+
Authenticated users should be able to create a new token.
68+
The token key should be returned in the response upon creation.
69+
"""
70+
user = factories.UserFactory()
71+
72+
client.force_login(user)
73+
74+
# The create endpoint does not take any parameters as per TokenCreateSerializer
75+
response = client.post("/api/v1.0/user-tokens/", data={})
76+
assert response.status_code == 201
77+
content = response.json()
78+
79+
# Based on TokenCreateSerializer, these fields should be in the response
80+
assert "token_key" in content
81+
assert "digest" in content
82+
assert "created" in content
83+
assert "expiry" in content
84+
assert len(content["token_key"]) > 0 # Knox token key should be non-empty
85+
86+
# Verify the token was actually created in the database for the user
87+
assert AuthToken.objects.filter(user=user, digest=content["digest"]).exists()
88+
89+
def test_api_user_token_destroy_anonymous(client):
90+
"""Anonymous users should not be allowed to delete user tokens."""
91+
user = factories.UserFactory()
92+
token_instance, _ = AuthToken.objects.create(user=user)
93+
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
94+
assert response.status_code == 403
95+
assert AuthToken.objects.filter(digest=token_instance.digest).exists()
96+
97+
98+
def test_api_user_token_destroy_authenticated_own_token(client):
99+
"""Authenticated users should be able to delete their own tokens."""
100+
user = factories.UserFactory()
101+
token_instance, _ = AuthToken.objects.create(user=user)
102+
103+
client.force_login(user)
104+
105+
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
106+
assert response.status_code == 204
107+
assert not AuthToken.objects.filter(digest=token_instance.digest).exists()
108+
109+
110+
def test_api_user_token_destroy_authenticated_other_user_token(client):
111+
"""Authenticated users should not be able to delete other users' tokens."""
112+
user = factories.UserFactory()
113+
other_user = factories.UserFactory()
114+
other_user_token_instance, _ = AuthToken.objects.create(user=other_user)
115+
116+
client.force_login(user) # Log in as 'user'
117+
118+
response = client.delete(f"/api/v1.0/user-tokens/{other_user_token_instance.digest}/")
119+
# The default behavior for a non-found or non-permissioned item in DestroyModelMixin
120+
# when the queryset is filtered (as in get_queryset) is often a 404.
121+
assert response.status_code == 404
122+
assert AuthToken.objects.filter(digest=other_user_token_instance.digest).exists()
123+
124+
125+
def test_api_user_token_destroy_non_existent_token(client):
126+
"""Attempting to delete a non-existent token should result in a 404."""
127+
user = factories.UserFactory()
128+
client.force_login(user)
129+
130+
response = client.delete("/api/v1.0/user-tokens/nonexistentdigest/")
131+
assert response.status_code == 404

src/backend/core/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@
88
from rest_framework.routers import DefaultRouter
99

1010
from core.api import viewsets
11+
from core.user_token import viewsets as user_token_viewsets
1112

1213
# - Main endpoints
1314
router = DefaultRouter()
1415
router.register("templates", viewsets.TemplateViewSet, basename="templates")
1516
router.register("documents", viewsets.DocumentViewSet, basename="documents")
1617
router.register("users", viewsets.UserViewSet, basename="users")
18+
router.register(
19+
"user-tokens",
20+
user_token_viewsets.UserTokenViewset,
21+
basename="user_tokens",
22+
)
1723

1824
# - Routes nested under a document
1925
document_related_router = DefaultRouter()

src/backend/core/user_token/__init__.py

Whitespace-only changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from knox.models import get_token_model
2+
from rest_framework import serializers
3+
4+
5+
class TokenReadSerializer(serializers.ModelSerializer):
6+
"""Serialize token for list purpose."""
7+
8+
class Meta:
9+
model = get_token_model()
10+
fields = ["digest", "created", "expiry"]
11+
read_only_fields = ["digest", "created", "expiry"]
12+
13+
14+
class TokenCreateSerializer(serializers.ModelSerializer):
15+
"""Serialize token for creation purpose."""
16+
17+
class Meta:
18+
model = get_token_model()
19+
fields = ["user", "digest", "token_key", "created", "expiry"]
20+
read_only_fields = ["digest", "token_key", "created", "expiry"]
21+
extra_kwargs = {"user": {"write_only": True}}
22+
23+
def create(self, validated_data):
24+
"""The default knox token create manager returns a tuple."""
25+
instance, token = super().create(validated_data)
26+
instance.token_key = token # warning do not save this
27+
return instance
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""API endpoints for user token management"""
2+
3+
from knox.models import get_token_model
4+
from rest_framework import permissions, viewsets, mixins
5+
from rest_framework.authentication import SessionAuthentication
6+
7+
from . import serializers
8+
9+
10+
class UserTokenViewset(
11+
mixins.CreateModelMixin,
12+
mixins.ListModelMixin,
13+
mixins.DestroyModelMixin,
14+
viewsets.GenericViewSet,
15+
):
16+
"""API ViewSet for user invitations to document.
17+
18+
This view access is restricted to the session ie from frontend.
19+
20+
GET /api/v1.0/user-token/
21+
Return list of existing tokens.
22+
23+
POST /api/v1.0/user-token/
24+
Return newly created token.
25+
26+
DELETE /api/v1.0/user-token/<token_id>/
27+
Delete targeted token.
28+
"""
29+
30+
authentication_classes = [SessionAuthentication]
31+
pagination_class = None
32+
permission_classes = [permissions.IsAuthenticated]
33+
queryset = get_token_model().objects.all()
34+
serializer_class = serializers.TokenReadSerializer
35+
36+
def get_queryset(self):
37+
"""Return the queryset restricted to the logged-in user."""
38+
queryset = super().get_queryset()
39+
queryset = queryset.filter(user_id=self.request.user.pk)
40+
return queryset
41+
42+
def get_serializer_class(self):
43+
if self.action == "create":
44+
return serializers.TokenCreateSerializer
45+
return super().get_serializer_class()
46+
47+
def create(self, request, *args, **kwargs):
48+
"""Enforce request data to use current user."""
49+
request.data["user"] = self.request.user.pk
50+
return super().create(request, *args, **kwargs)

src/backend/impress/settings.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
https://docs.djangoproject.com/en/3.1/ref/settings/
1111
"""
1212

13+
# pylint: disable=too-many-lines
14+
15+
import datetime
1316
import os
1417
import tomllib
1518
from socket import gethostbyname, gethostname
@@ -303,6 +306,7 @@ class Base(Configuration):
303306
"django_filters",
304307
"dockerflow.django",
305308
"rest_framework",
309+
"knox",
306310
"parler",
307311
"treebeard",
308312
"easy_thumbnails",
@@ -328,6 +332,7 @@ class Base(Configuration):
328332
REST_FRAMEWORK = {
329333
"DEFAULT_AUTHENTICATION_CLASSES": (
330334
"rest_framework.authentication.SessionAuthentication",
335+
"knox.auth.TokenAuthentication",
331336
"lasuite.oidc_resource_server.authentication.ResourceServerAuthentication",
332337
),
333338
"DEFAULT_PARSER_CLASSES": [
@@ -640,6 +645,18 @@ class Base(Configuration):
640645
[], environ_name="OIDC_RS_SCOPES", environ_prefix=None
641646
)
642647

648+
# User token (knox)
649+
REST_KNOX = {
650+
"SECURE_HASH_ALGORITHM": "hashlib.sha512",
651+
"AUTH_TOKEN_CHARACTER_LENGTH": 64,
652+
"TOKEN_TTL": datetime.timedelta(hours=24 * 7),
653+
"TOKEN_LIMIT_PER_USER": None,
654+
"AUTO_REFRESH": False,
655+
"AUTO_REFRESH_MAX_TTL": None,
656+
"MIN_REFRESH_INTERVAL": 60,
657+
"AUTH_HEADER_PREFIX": "Token",
658+
}
659+
643660
# AI service
644661
AI_FEATURE_ENABLED = values.BooleanValue(
645662
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None

src/backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dependencies = [
3636
"django-lasuite[all]==0.0.8",
3737
"django-parler==2.3",
3838
"django-redis==5.4.0",
39+
"django-rest-knox==5.0.2",
3940
"django-storages[s3]==1.14.6",
4041
"django-timezone-field>=5.1",
4142
"django==5.1.9",

src/frontend/apps/impress/src/features/header/components/Header.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Button } from '@openfun/cunningham-react';
12
import { useTranslation } from 'react-i18next';
23
import { css } from 'styled-components';
34

@@ -59,6 +60,13 @@ export const Header = () => {
5960
) : (
6061
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
6162
<ButtonLogin />
63+
<Button
64+
onClick={() => (window.location.href = '/user-tokens')}
65+
aria-label={t('API Tokens', 'API Tokens')}
66+
color="primary-text"
67+
>
68+
{t('API Tokens', 'API Tokens')}
69+
</Button>
6270
<LanguagePicker />
6371
<LaGaufre />
6472
</Box>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './useListUserTokens';
2+
export * from './useCreateUserToken';
3+
export * from './useDeleteUserToken';
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useMutation } from '@tanstack/react-query';
2+
3+
import { APIError, errorCauses, fetchAPI } from '@/api';
4+
5+
import { NewUserToken } from '../types';
6+
7+
export const createUserToken = async (): Promise<NewUserToken> => {
8+
const response = await fetchAPI(`user-tokens/`, {
9+
method: 'POST',
10+
// The backend test indicates no data is sent for creation, so body is an empty object
11+
body: JSON.stringify({}),
12+
});
13+
14+
if (!response.ok) {
15+
throw new APIError(
16+
'Failed to create user token',
17+
await errorCauses(response),
18+
);
19+
}
20+
21+
return response.json() as Promise<NewUserToken>;
22+
};
23+
24+
export function useCreateUserToken() {
25+
return useMutation<NewUserToken, APIError>({
26+
mutationFn: createUserToken,
27+
});
28+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useMutation } from '@tanstack/react-query';
2+
3+
import { APIError, errorCauses, fetchAPI } from '@/api';
4+
5+
export const deleteUserToken = async (digest: string): Promise<void> => {
6+
const response = await fetchAPI(`user-tokens/${digest}/`, {
7+
method: 'DELETE',
8+
});
9+
10+
if (!response.ok && response.status !== 204) {
11+
// 204 is a valid response for delete
12+
throw new APIError(
13+
'Failed to delete user token',
14+
await errorCauses(response),
15+
);
16+
}
17+
// For 204, there's no content, and for other successful deletions, we don't expect content.
18+
// So, we don't try to parse JSON.
19+
return Promise.resolve();
20+
};
21+
22+
export type DeleteUserTokenParams = {
23+
digest: string;
24+
};
25+
26+
export function useDeleteUserToken() {
27+
return useMutation<void, APIError, DeleteUserTokenParams>({
28+
mutationFn: ({ digest }) => deleteUserToken(digest),
29+
});
30+
}

0 commit comments

Comments
 (0)