diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index e0e7c82f..6458112a 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -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"], diff --git a/src/backend/core/tests/test_user_token_api.py b/src/backend/core/tests/test_user_token_api.py new file mode 100644 index 00000000..ab067295 --- /dev/null +++ b/src/backend/core/tests/test_user_token_api.py @@ -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 \ No newline at end of file diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index fae84820..73e73c06 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -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() diff --git a/src/backend/core/user_token/__init__.py b/src/backend/core/user_token/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/user_token/serializers.py b/src/backend/core/user_token/serializers.py new file mode 100644 index 00000000..0c7a61cb --- /dev/null +++ b/src/backend/core/user_token/serializers.py @@ -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 diff --git a/src/backend/core/user_token/viewsets.py b/src/backend/core/user_token/viewsets.py new file mode 100644 index 00000000..fb462092 --- /dev/null +++ b/src/backend/core/user_token/viewsets.py @@ -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// + 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) diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index ea4335b0..f1d06c42 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -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 diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 1e4e9cc6..335e31b4 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -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", diff --git a/src/frontend/apps/impress/src/features/header/components/Header.tsx b/src/frontend/apps/impress/src/features/header/components/Header.tsx index 39f3ce63..62754c5b 100644 --- a/src/frontend/apps/impress/src/features/header/components/Header.tsx +++ b/src/frontend/apps/impress/src/features/header/components/Header.tsx @@ -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 = () => { ) : ( + diff --git a/src/frontend/apps/impress/src/features/user-tokens/api/index.ts b/src/frontend/apps/impress/src/features/user-tokens/api/index.ts new file mode 100644 index 00000000..2cf8ba16 --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/api/index.ts @@ -0,0 +1,3 @@ +export * from './useListUserTokens'; +export * from './useCreateUserToken'; +export * from './useDeleteUserToken'; diff --git a/src/frontend/apps/impress/src/features/user-tokens/api/useCreateUserToken.ts b/src/frontend/apps/impress/src/features/user-tokens/api/useCreateUserToken.ts new file mode 100644 index 00000000..49959bdd --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/api/useCreateUserToken.ts @@ -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 => { + 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; +}; + +export function useCreateUserToken() { + return useMutation({ + mutationFn: createUserToken, + }); +} diff --git a/src/frontend/apps/impress/src/features/user-tokens/api/useDeleteUserToken.ts b/src/frontend/apps/impress/src/features/user-tokens/api/useDeleteUserToken.ts new file mode 100644 index 00000000..b30bb0fd --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/api/useDeleteUserToken.ts @@ -0,0 +1,30 @@ +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +export const deleteUserToken = async (digest: string): Promise => { + 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({ + mutationFn: ({ digest }) => deleteUserToken(digest), + }); +} diff --git a/src/frontend/apps/impress/src/features/user-tokens/api/useListUserTokens.ts b/src/frontend/apps/impress/src/features/user-tokens/api/useListUserTokens.ts new file mode 100644 index 00000000..63c43917 --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/api/useListUserTokens.ts @@ -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 => { + 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; +}; + +export function useListUserTokens() { + return useQuery({ + queryKey: ['userTokens'], + queryFn: listUserTokens, + }); +} diff --git a/src/frontend/apps/impress/src/features/user-tokens/components/UserTokenManager.tsx b/src/frontend/apps/impress/src/features/user-tokens/components/UserTokenManager.tsx new file mode 100644 index 00000000..070ac978 --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/components/UserTokenManager.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [newToken, setNewToken] = useState(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 }) => ( + { + handleDeleteToken(row.digest); + }} + color="danger" + size="small" + icon={delete} + aria-label="Delete token" + > + Delete + + ), + }, + ]; + + return ( + + +

+ User token management +

+ void handleCreateToken()} + disabled={isLoading} + > + {isLoading ? 'Generating...' : 'Generate New Token'} + +
+ + {newToken && ( + + + New Token: {newToken.token_key} + + + Digest: {newToken.digest} + + + Expires:{' '} + {new Date(newToken.expiry).toLocaleString()} + + + )} + + {isLoading && !tokens.length && ( + + Loading... + + )} + {error && ( + + {error} + + )} + + + rows={tokens} + columns={columns} + isLoading={isLoading} + emptyCta={
No tokens found.
} + /> + {modalState.isOpen && ( + setModalState((prev) => ({ ...prev, isOpen: false }))} + title={modalState.title} + size={modalState.size} // Use ModalSize enum directly + actions={ + modalState.isConfirmation ? ( + + + setModalState((prev) => ({ ...prev, isOpen: false })) + } + color="secondary" + > + Cancel + + { + if (modalState.onConfirm) { + modalState.onConfirm(); + } + setModalState((prev) => ({ ...prev, isOpen: false })); + }} + color="danger" + > + {modalState.confirmText || 'Confirm'} + + + ) : ( + + setModalState((prev) => ({ ...prev, isOpen: false })) + } + color="primary" + > + Close + + ) + } + > + {modalState.message} + + )} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/user-tokens/index.ts b/src/frontend/apps/impress/src/features/user-tokens/index.ts new file mode 100644 index 00000000..04713863 --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/index.ts @@ -0,0 +1 @@ +export { UserTokenManager } from './components/UserTokenManager'; diff --git a/src/frontend/apps/impress/src/features/user-tokens/types.ts b/src/frontend/apps/impress/src/features/user-tokens/types.ts new file mode 100644 index 00000000..ce15e091 --- /dev/null +++ b/src/frontend/apps/impress/src/features/user-tokens/types.ts @@ -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; +} diff --git a/src/frontend/apps/impress/src/pages/user-tokens/index.tsx b/src/frontend/apps/impress/src/pages/user-tokens/index.tsx new file mode 100644 index 00000000..a958705d --- /dev/null +++ b/src/frontend/apps/impress/src/pages/user-tokens/index.tsx @@ -0,0 +1,13 @@ +import { UserTokenManager } from '@/features/user-tokens'; +import { MainLayout } from '@/layouts'; +import { NextPageWithLayout } from '@/types/next'; + +const UserTokensPage: NextPageWithLayout = () => { + return ; +}; + +UserTokensPage.getLayout = function getLayout(page: React.ReactElement) { + return {page}; +}; + +export default UserTokensPage;