💩(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.
This commit is contained in:
Quentin BEY
2025-05-26 14:51:08 +02:00
parent e929fcc682
commit 6bd136c76e
17 changed files with 667 additions and 0 deletions
+2
View File
@@ -25,6 +25,7 @@ from django.utils.translation import gettext_lazy as _
import requests
import rest_framework as drf
from botocore.exceptions import ClientError
from knox.auth import TokenAuthentication
from lasuite.malware_detection import malware_detection
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import filters, status, viewsets
@@ -671,6 +672,7 @@ class DocumentViewSet(
authentication_classes=[
authentication.ServerToServerAuthentication,
ResourceServerAuthentication,
TokenAuthentication,
],
detail=False,
methods=["post"],
@@ -0,0 +1,131 @@
"""
Test user_token API endpoints in the impress core app.
"""
import pytest
from knox.models import get_token_model
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
AuthToken = get_token_model()
def test_api_user_token_list_anonymous(client):
"""Anonymous users should not be allowed to list user tokens."""
response = client.get("/api/v1.0/user-tokens/")
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_user_token_list_authenticated(client):
"""
Authenticated users should be able to list their own tokens.
Tokens are identified by digest, and include created/expiry.
"""
user = factories.UserFactory()
# Knox creates a token instance and a character string token key.
# The create method returns a tuple: (instance, token_key_string)
token_instance_1, _ = AuthToken.objects.create(user=user)
AuthToken.objects.create(user=user) # Another token for the same user
AuthToken.objects.create(user=factories.UserFactory()) # Token for a different user
client.force_login(user)
response = client.get("/api/v1.0/user-tokens/")
assert response.status_code == 200
content = response.json()
assert len(content) == 2
# Check that the response contains the digests of the tokens created for the user
response_token_digests = {item["digest"] for item in content}
assert token_instance_1.digest in response_token_digests
# Ensure the token_key is not listed
for item in content:
assert "token_key" not in item
assert "digest" in item
assert "created" in item
assert "expiry" in item
def test_api_user_token_create_anonymous(client):
"""Anonymous users should not be allowed to create user tokens."""
# The create endpoint does not take any parameters as per TokenCreateSerializer
# (user is implicit, other fields are read_only)
response = client.post("/api/v1.0/user-tokens/", data={})
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_user_token_create_authenticated(client):
"""
Authenticated users should be able to create a new token.
The token key should be returned in the response upon creation.
"""
user = factories.UserFactory()
client.force_login(user)
# The create endpoint does not take any parameters as per TokenCreateSerializer
response = client.post("/api/v1.0/user-tokens/", data={})
assert response.status_code == 201
content = response.json()
# Based on TokenCreateSerializer, these fields should be in the response
assert "token_key" in content
assert "digest" in content
assert "created" in content
assert "expiry" in content
assert len(content["token_key"]) > 0 # Knox token key should be non-empty
# Verify the token was actually created in the database for the user
assert AuthToken.objects.filter(user=user, digest=content["digest"]).exists()
def test_api_user_token_destroy_anonymous(client):
"""Anonymous users should not be allowed to delete user tokens."""
user = factories.UserFactory()
token_instance, _ = AuthToken.objects.create(user=user)
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
assert response.status_code == 403
assert AuthToken.objects.filter(digest=token_instance.digest).exists()
def test_api_user_token_destroy_authenticated_own_token(client):
"""Authenticated users should be able to delete their own tokens."""
user = factories.UserFactory()
token_instance, _ = AuthToken.objects.create(user=user)
client.force_login(user)
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
assert response.status_code == 204
assert not AuthToken.objects.filter(digest=token_instance.digest).exists()
def test_api_user_token_destroy_authenticated_other_user_token(client):
"""Authenticated users should not be able to delete other users' tokens."""
user = factories.UserFactory()
other_user = factories.UserFactory()
other_user_token_instance, _ = AuthToken.objects.create(user=other_user)
client.force_login(user) # Log in as 'user'
response = client.delete(f"/api/v1.0/user-tokens/{other_user_token_instance.digest}/")
# The default behavior for a non-found or non-permissioned item in DestroyModelMixin
# when the queryset is filtered (as in get_queryset) is often a 404.
assert response.status_code == 404
assert AuthToken.objects.filter(digest=other_user_token_instance.digest).exists()
def test_api_user_token_destroy_non_existent_token(client):
"""Attempting to delete a non-existent token should result in a 404."""
user = factories.UserFactory()
client.force_login(user)
response = client.delete("/api/v1.0/user-tokens/nonexistentdigest/")
assert response.status_code == 404
+6
View File
@@ -8,12 +8,18 @@ from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_url
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.user_token import viewsets as user_token_viewsets
# - Main endpoints
router = DefaultRouter()
router.register("templates", viewsets.TemplateViewSet, basename="templates")
router.register("documents", viewsets.DocumentViewSet, basename="documents")
router.register("users", viewsets.UserViewSet, basename="users")
router.register(
"user-tokens",
user_token_viewsets.UserTokenViewset,
basename="user_tokens",
)
# - Routes nested under a document
document_related_router = DefaultRouter()
@@ -0,0 +1,27 @@
from knox.models import get_token_model
from rest_framework import serializers
class TokenReadSerializer(serializers.ModelSerializer):
"""Serialize token for list purpose."""
class Meta:
model = get_token_model()
fields = ["digest", "created", "expiry"]
read_only_fields = ["digest", "created", "expiry"]
class TokenCreateSerializer(serializers.ModelSerializer):
"""Serialize token for creation purpose."""
class Meta:
model = get_token_model()
fields = ["user", "digest", "token_key", "created", "expiry"]
read_only_fields = ["digest", "token_key", "created", "expiry"]
extra_kwargs = {"user": {"write_only": True}}
def create(self, validated_data):
"""The default knox token create manager returns a tuple."""
instance, token = super().create(validated_data)
instance.token_key = token # warning do not save this
return instance
+50
View File
@@ -0,0 +1,50 @@
"""API endpoints for user token management"""
from knox.models import get_token_model
from rest_framework import permissions, viewsets, mixins
from rest_framework.authentication import SessionAuthentication
from . import serializers
class UserTokenViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
This view access is restricted to the session ie from frontend.
GET /api/v1.0/user-token/
Return list of existing tokens.
POST /api/v1.0/user-token/
Return newly created token.
DELETE /api/v1.0/user-token/<token_id>/
Delete targeted token.
"""
authentication_classes = [SessionAuthentication]
pagination_class = None
permission_classes = [permissions.IsAuthenticated]
queryset = get_token_model().objects.all()
serializer_class = serializers.TokenReadSerializer
def get_queryset(self):
"""Return the queryset restricted to the logged-in user."""
queryset = super().get_queryset()
queryset = queryset.filter(user_id=self.request.user.pk)
return queryset
def get_serializer_class(self):
if self.action == "create":
return serializers.TokenCreateSerializer
return super().get_serializer_class()
def create(self, request, *args, **kwargs):
"""Enforce request data to use current user."""
request.data["user"] = self.request.user.pk
return super().create(request, *args, **kwargs)
+17
View File
@@ -10,6 +10,9 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
# pylint: disable=too-many-lines
import datetime
import os
import tomllib
from socket import gethostbyname, gethostname
@@ -303,6 +306,7 @@ class Base(Configuration):
"django_filters",
"dockerflow.django",
"rest_framework",
"knox",
"parler",
"treebeard",
"easy_thumbnails",
@@ -328,6 +332,7 @@ class Base(Configuration):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
"knox.auth.TokenAuthentication",
"lasuite.oidc_resource_server.authentication.ResourceServerAuthentication",
),
"DEFAULT_PARSER_CLASSES": [
@@ -647,6 +652,18 @@ class Base(Configuration):
[], environ_name="OIDC_RS_SCOPES", environ_prefix=None
)
# User token (knox)
REST_KNOX = {
"SECURE_HASH_ALGORITHM": "hashlib.sha512",
"AUTH_TOKEN_CHARACTER_LENGTH": 64,
"TOKEN_TTL": datetime.timedelta(hours=24 * 7),
"TOKEN_LIMIT_PER_USER": None,
"AUTO_REFRESH": False,
"AUTO_REFRESH_MAX_TTL": None,
"MIN_REFRESH_INTERVAL": 60,
"AUTH_HEADER_PREFIX": "Token",
}
# AI service
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
+1
View File
@@ -36,6 +36,7 @@ dependencies = [
"django-lasuite[all]==0.0.9",
"django-parler==2.3",
"django-redis==5.4.0",
"django-rest-knox==5.0.2",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.1.9",
@@ -1,3 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -16,6 +18,7 @@ import { Title } from './Title';
export const Header = () => {
const { t } = useTranslation();
const router = useRouter();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
@@ -63,6 +66,13 @@ export const Header = () => {
) : (
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
<ButtonLogin />
<Button
onClick={() => router.push(`/user-tokens`)}
aria-label={t('API Tokens', 'API Tokens')}
color="primary-text"
>
{t('API Tokens', 'API Tokens')}
</Button>
<LanguagePicker />
<LaGaufre />
</Box>
@@ -0,0 +1,3 @@
export * from './useListUserTokens';
export * from './useCreateUserToken';
export * from './useDeleteUserToken';
@@ -0,0 +1,28 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { NewUserToken } from '../types';
export const createUserToken = async (): Promise<NewUserToken> => {
const response = await fetchAPI(`user-tokens/`, {
method: 'POST',
// The backend test indicates no data is sent for creation, so body is an empty object
body: JSON.stringify({}),
});
if (!response.ok) {
throw new APIError(
'Failed to create user token',
await errorCauses(response),
);
}
return response.json() as Promise<NewUserToken>;
};
export function useCreateUserToken() {
return useMutation<NewUserToken, APIError>({
mutationFn: createUserToken,
});
}
@@ -0,0 +1,30 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
export const deleteUserToken = async (digest: string): Promise<void> => {
const response = await fetchAPI(`user-tokens/${digest}/`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
// 204 is a valid response for delete
throw new APIError(
'Failed to delete user token',
await errorCauses(response),
);
}
// For 204, there's no content, and for other successful deletions, we don't expect content.
// So, we don't try to parse JSON.
return Promise.resolve();
};
export type DeleteUserTokenParams = {
digest: string;
};
export function useDeleteUserToken() {
return useMutation<void, APIError, DeleteUserTokenParams>({
mutationFn: ({ digest }) => deleteUserToken(digest),
});
}
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { UserToken } from '../types';
export const listUserTokens = async (): Promise<UserToken[]> => {
const response = await fetchAPI(`user-tokens/`);
if (!response.ok) {
throw new APIError(
'Failed to list user tokens',
await errorCauses(response),
);
}
return response.json() as Promise<UserToken[]>;
};
export function useListUserTokens() {
return useQuery<UserToken[], APIError>({
queryKey: ['userTokens'],
queryFn: listUserTokens,
});
}
@@ -0,0 +1,314 @@
import {
Button as CunninghamButton,
DataGrid,
Modal,
ModalSize,
} from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Card } from '@/components';
import { createUserToken, deleteUserToken, listUserTokens } from '../api/index';
import { NewUserToken, UserToken } from '../types';
const formatTimeAgo = (dateString: string) => {
const now = new Date();
const date = new Date(dateString);
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) {
return `${seconds} seconds ago`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes} minutes ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours} hours ago`;
}
const days = Math.floor(hours / 24);
return `${days} days ago`;
};
// Add id to UserToken type for DataGrid compatibility
interface UserTokenWithId extends UserToken {
id: string;
}
// Define proper type for DataGrid columns
interface ColumnDef {
field: string;
headerName: string;
width?: number;
renderCell: (params: { row: UserTokenWithId }) => React.ReactNode;
}
export const UserTokenManager: React.FC = () => {
const [tokens, setTokens] = useState<UserTokenWithId[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [newToken, setNewToken] = useState<NewUserToken | null>(null);
const [modalState, setModalState] = useState<{
isOpen: boolean;
title: string;
message: React.ReactNode;
onConfirm?: () => void;
confirmText?: string;
isConfirmation: boolean;
type?: 'success' | 'error' | 'warning' | 'info';
size: ModalSize;
}>({
isOpen: false,
title: '',
message: '',
isConfirmation: false,
size: ModalSize.MEDIUM, // Default size using ModalSize enum
});
const showNotification = (
message: string,
type: 'success' | 'error' = 'success',
size: ModalSize = ModalSize.SMALL,
) => {
setModalState({
isOpen: true,
title: type === 'success' ? 'Success' : 'Error',
message,
isConfirmation: false,
type: type,
size,
});
};
const showConfirmation = (
title: string,
message: React.ReactNode,
onConfirm: () => void,
confirmText: string = 'Confirm',
size: ModalSize = ModalSize.MEDIUM,
) => {
setModalState({
isOpen: true,
title,
message,
onConfirm,
confirmText,
isConfirmation: true,
size,
});
};
const fetchTokens = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const fetchedTokens = await listUserTokens();
// Add id to each token
setTokens(fetchedTokens.map((token) => ({ ...token, id: token.digest })));
} catch (err) {
setError(
'Failed to fetch tokens. Please ensure you are logged in and have permissions.',
);
showNotification('Failed to fetch tokens', 'error');
console.error(err);
}
setIsLoading(false);
}, []);
useEffect(() => {
void fetchTokens();
}, [fetchTokens]);
const handleCreateToken = async () => {
setIsLoading(true);
setError(null);
setNewToken(null);
try {
const generatedToken = await createUserToken();
setNewToken(generatedToken);
showNotification(
'Token created successfully! Store the token key safely, it will not be shown again.',
'success',
ModalSize.LARGE,
);
void fetchTokens();
} catch (err) {
setError('Failed to create token.');
showNotification('Failed to create token', 'error');
console.error(err);
}
setIsLoading(false);
};
const handleDeleteToken = (digest: string) => {
showConfirmation(
'Confirm Deletion',
'Are you sure you want to delete this token?',
() => {
void (async () => {
setIsLoading(true);
setError(null);
try {
await deleteUserToken(digest);
showNotification('Token deleted successfully!');
setNewToken(null);
await fetchTokens();
} catch (err) {
setError('Failed to delete token.');
showNotification('Failed to delete token', 'error');
console.error(err);
}
setIsLoading(false);
})();
},
);
};
const columns: ColumnDef[] = [
{
field: 'digest',
headerName: 'Name',
renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.digest}</>,
},
{
field: 'created',
headerName: 'Updated at',
renderCell: ({ row }: { row: UserTokenWithId }) => (
<>{formatTimeAgo(row.created)}</>
),
},
{
field: 'expires',
headerName: 'Expires at',
renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.expiry}</>,
},
{
field: 'actions',
headerName: '',
width: 50,
renderCell: ({ row }: { row: UserTokenWithId }) => (
<CunninghamButton
onClick={() => {
handleDeleteToken(row.digest);
}}
color="danger"
size="small"
icon={<span className="material-icons">delete</span>}
aria-label="Delete token"
>
Delete
</CunninghamButton>
),
},
];
return (
<Card
$direction="column"
$width="100%"
$padding={{
top: 'base',
horizontal: 'md',
bottom: 'md',
}}
>
<Box $direction="row" $justify="space-between" $align="center">
<h2 style={{ marginBottom: 'var(--c--theme--spacing--medium, 16px)' }}>
User token management
</h2>
<CunninghamButton
onClick={() => void handleCreateToken()}
disabled={isLoading}
>
{isLoading ? 'Generating...' : 'Generate New Token'}
</CunninghamButton>
</Box>
{newToken && (
<Box
$background="var(--c--theme--colors--success-100)"
$padding="md"
$radius="10px"
$margin={{ bottom: 'var(--c--theme--spacing--medium, 16px)' }}
$direction="column"
>
<span style={{ marginLeft: 16 }}>
<strong>New Token:</strong> <code>{newToken.token_key}</code>
</span>
<span style={{ marginLeft: 16 }}>
<strong>Digest:</strong> <code>{newToken.digest}</code>
</span>
<span style={{ marginLeft: 16 }}>
<strong>Expires:</strong>{' '}
<code>{new Date(newToken.expiry).toLocaleString()}</code>
</span>
</Box>
)}
{isLoading && !tokens.length && (
<Box $margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}>
Loading...
</Box>
)}
{error && (
<Box
$color="var(--c--theme--colors--danger-500, red)"
$margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}
>
{error}
</Box>
)}
<DataGrid<UserTokenWithId>
rows={tokens}
columns={columns}
isLoading={isLoading}
emptyCta={<div>No tokens found.</div>}
/>
{modalState.isOpen && (
<Modal
isOpen={modalState.isOpen}
onClose={() => setModalState((prev) => ({ ...prev, isOpen: false }))}
title={modalState.title}
size={modalState.size} // Use ModalSize enum directly
actions={
modalState.isConfirmation ? (
<Box $width="100%" $direction="row" $justify="space-between">
<CunninghamButton
onClick={() =>
setModalState((prev) => ({ ...prev, isOpen: false }))
}
color="secondary"
>
Cancel
</CunninghamButton>
<CunninghamButton
onClick={() => {
if (modalState.onConfirm) {
modalState.onConfirm();
}
setModalState((prev) => ({ ...prev, isOpen: false }));
}}
color="danger"
>
{modalState.confirmText || 'Confirm'}
</CunninghamButton>
</Box>
) : (
<CunninghamButton
onClick={() =>
setModalState((prev) => ({ ...prev, isOpen: false }))
}
color="primary"
>
Close
</CunninghamButton>
)
}
>
{modalState.message}
</Modal>
)}
</Card>
);
};
@@ -0,0 +1 @@
export { UserTokenManager } from './components/UserTokenManager';
@@ -0,0 +1,9 @@
export interface UserToken {
digest: string;
created: string; // Assuming ISO date string
expiry: string; // Assuming ISO date string
}
export interface NewUserToken extends UserToken {
token_key: string;
}
@@ -0,0 +1,13 @@
import { UserTokenManager } from '@/features/user-tokens';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const UserTokensPage: NextPageWithLayout = () => {
return <UserTokenManager />;
};
UserTokensPage.getLayout = function getLayout(page: React.ReactElement) {
return <MainLayout backgroundColor="grey">{page}</MainLayout>;
};
export default UserTokensPage;