diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..311cd26 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./qgis-app", + "-p", + "test_*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/qgis-app/api/migrations/0001_initial.py b/qgis-app/api/migrations/0001_initial.py index 950f36b..e25acfc 100644 --- a/qgis-app/api/migrations/0001_initial.py +++ b/qgis-app/api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-09-12 07:16 +# Generated by Django 4.2.16 on 2024-11-18 03:02 from django.conf import settings from django.db import migrations, models @@ -22,7 +22,8 @@ class Migration(migrations.Migration): ('is_blacklisted', models.BooleanField(default=False)), ('is_newly_created', models.BooleanField(default=False)), ('description', models.CharField(blank=True, help_text="Describe this token so that it's easier to remember where you're using it.", max_length=512, null=True, verbose_name='Description')), - ('last_used_on', models.DateTimeField(blank=True, null=True, verbose_name='Last used on')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='Last used at')), ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='token_blacklist.outstandingtoken')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], diff --git a/qgis-app/api/models.py b/qgis-app/api/models.py index 6db86a9..3129e17 100644 --- a/qgis-app/api/models.py +++ b/qgis-app/api/models.py @@ -25,8 +25,12 @@ class UserOutstandingToken(models.Model): blank=True, null=True, ) - last_used_on = models.DateTimeField( - verbose_name=_("Last used on"), + created_at = models.DateTimeField( + verbose_name=_("Created at"), + auto_now_add=True, + ) + last_used_at = models.DateTimeField( + verbose_name=_("Last used at"), blank=True, null=True ) \ No newline at end of file diff --git a/qgis-app/api/permissions.py b/qgis-app/api/permissions.py index 58765bf..bfaa8f0 100644 --- a/qgis-app/api/permissions.py +++ b/qgis-app/api/permissions.py @@ -1,4 +1,11 @@ from rest_framework import permissions +from rest_framework.permissions import BasePermission +from rest_framework_simplejwt.authentication import JWTAuthentication +from django.contrib.auth.models import User +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken +import datetime +from api.models import UserOutstandingToken MANAGER_GROUP = "Style Managers" @@ -21,3 +28,31 @@ def has_object_permission(self, request, view, obj): is_manager = user.groups.filter(name=MANAGER_GROUP).exists() return user == obj.creator or user.is_staff or is_manager + +class HasValidToken(BasePermission): + def has_permission(self, request, view): + auth_token = request.META.get("HTTP_AUTHORIZATION") + if not str(auth_token).startswith('Bearer'): + return False + + # Validate JWT token + authentication = JWTAuthentication() + try: + validated_token = authentication.get_validated_token(auth_token[7:]) + user_id = validated_token.payload.get('user_id') + jti = validated_token.payload.get('refresh_jti') + token_id = OutstandingToken.objects.get(jti=jti).pk + is_blacklisted = BlacklistedToken.objects.filter(token_id=token_id).exists() + if not user_id or is_blacklisted: + return False + + user = User.objects.get(pk=user_id) + if not user: + return False + user_token = UserOutstandingToken.objects.get(token__pk=token_id, user=user) + user_token.last_used_at = datetime.datetime.now() + user_token.save() + request.user_token = user_token + return True + except (InvalidToken, TokenError): + return False \ No newline at end of file diff --git a/qgis-app/api/templates/user_token_list.html b/qgis-app/api/templates/user_token_list.html index d94cd0f..f04112f 100644 --- a/qgis-app/api/templates/user_token_list.html +++ b/qgis-app/api/templates/user_token_list.html @@ -21,8 +21,8 @@

{% trans "My Tokens" %}

{% for user_token in object_list %} {{ user_token.description|default:"-" }} - {{ user_token.token.created_at }} UTC - {{ user_token.last_used_on|default:"-" }}{% if user_token.last_used_on %} UTC{% endif %} + {{ user_token.created_at }} UTC + {{ user_token.last_used_at|default:"-" }}{% if user_token.last_used_at %} UTC{% endif %}   diff --git a/qgis-app/api/tests/test_resources_api.py b/qgis-app/api/tests/test_resources_api.py index 45c6e5d..c529a59 100644 --- a/qgis-app/api/tests/test_resources_api.py +++ b/qgis-app/api/tests/test_resources_api.py @@ -15,6 +15,9 @@ from django.core.files.uploadedfile import SimpleUploadedFile from wavefronts.models import Wavefront +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken +from api.models import UserOutstandingToken + GPKG_DIR = join(dirname(dirname(dirname(__file__))), "geopackages", "tests", "gpkgfiles") LAYERDEFINITION_DIR = join(dirname(dirname(dirname(__file__))), "layerdefinitions", "tests", "testfiles") MODELS_DIR = join(dirname(dirname(dirname(__file__))), "models", "tests", "modelfiles") @@ -50,8 +53,21 @@ def setUp(self): super().setUp() self.client = APIClient() self.user = User.objects.create_user(username='testuser', password='testpass') - self.token = RefreshToken.for_user(self.user) - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.token.access_token}') + self.client.login(username='testuser', password='testpass') + self.refresh = RefreshToken.for_user(self.user) + self.outstanding_token = OutstandingToken.objects.get(jti=self.refresh['jti']) + self.user_token = UserOutstandingToken.objects.create( + user=self.user, + token=self.outstanding_token, + is_blacklisted=False, + is_newly_created=True + ) + self.url = reverse('user_token_detail', args=[self.user_token.pk]) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.client.logout() + access_token = response.context.get('access_token') + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}') def test_create_geopackage(self): url = reverse('resource-create') @@ -202,6 +218,44 @@ def test_create_unsupported_resource_type(self): self.assertEqual(response.status_code, 400) self.assertEqual(response.data, {"resource_type": "Resource type not supported"}) + def test_create_with_invalid_token(self): + url = reverse('resource-create') + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_gpkg = SimpleUploadedFile( + self.gpkg_file_content.name, self.gpkg_file_content.read() + ) + data = { + "resource_type": "geopackage", + "name": "Test Geopackage", + "description": "A test geopackage", + "thumbnail_full": uploaded_thumbnail, + "file": uploaded_gpkg, + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 401) + + def test_create_with_blacklisted_token(self): + url = reverse('resource-create') + self.refresh.blacklist() + uploaded_thumbnail = SimpleUploadedFile( + self.thumbnail_content.name, self.thumbnail_content.read() + ) + uploaded_gpkg = SimpleUploadedFile( + self.gpkg_file_content.name, self.gpkg_file_content.read() + ) + data = { + "resource_type": "geopackage", + "name": "Test Geopackage", + "description": "A test geopackage", + "thumbnail_full": uploaded_thumbnail, + "file": uploaded_gpkg, + } + response = self.client.post(url, data, format='multipart') + self.assertEqual(response.status_code, 403) + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) class TestResourceDetailView(SetUpTest, TestCase): @@ -210,8 +264,21 @@ def setUp(self): super().setUp() self.client = APIClient() self.user = User.objects.create_user(username='testuser', password='testpass') - self.token = RefreshToken.for_user(self.user) - self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.token.access_token}') + self.client.login(username='testuser', password='testpass') + self.refresh = RefreshToken.for_user(self.user) + self.outstanding_token = OutstandingToken.objects.get(jti=self.refresh['jti']) + self.user_token = UserOutstandingToken.objects.create( + user=self.user, + token=self.outstanding_token, + is_blacklisted=False, + is_newly_created=True + ) + self.url = reverse('user_token_detail', args=[self.user_token.pk]) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.client.logout() + access_token = response.context.get('access_token') + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}') uploaded_thumbnail = SimpleUploadedFile( self.thumbnail_content.name, self.thumbnail_content.read() @@ -406,4 +473,194 @@ def test_delete_wavefront(self): url = reverse("resource-detail", kwargs={"uuid": self.wavefront.uuid, "resource_type": "3dmodel"}) response = self.client.delete(url) self.assertEqual(response.status_code, 204) - self.assertFalse(Wavefront.objects.filter(uuid=self.wavefront.uuid).exists()) \ No newline at end of file + self.assertFalse(Wavefront.objects.filter(uuid=self.wavefront.uuid).exists()) + + def test_update_geopackage_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.geopackage.uuid, "resource_type": "geopackage"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + data = { + "name": "Updated Geopackage", + "description": "Updated description", + "thumbnail_full": self.geopackage.thumbnail_image, + "file": self.geopackage.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 401) + + def test_update_geopackage_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.geopackage.uuid, "resource_type": "geopackage"}) + self.refresh.blacklist() + data = { + "name": "Updated Geopackage", + "description": "Updated description", + "thumbnail_full": self.geopackage.thumbnail_image, + "file": self.geopackage.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 403) + + def test_update_layerdefinition_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.layerdefinition.uuid, "resource_type": "layerdefinition"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + data = { + "name": "Updated Layer Definition", + "description": "Updated description", + "thumbnail_full": self.layerdefinition.thumbnail_image, + "file": self.layerdefinition.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 401) + + def test_update_layerdefinition_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.layerdefinition.uuid, "resource_type": "layerdefinition"}) + self.refresh.blacklist() + data = { + "name": "Updated Layer Definition", + "description": "Updated description", + "thumbnail_full": self.layerdefinition.thumbnail_image, + "file": self.layerdefinition.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 403) + + def test_update_model_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.model.uuid, "resource_type": "model"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + data = { + "name": "Updated Model", + "description": "Updated description", + "thumbnail_full": self.model.thumbnail_image, + "file": self.model.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 401) + + def test_update_model_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.model.uuid, "resource_type": "model"}) + self.refresh.blacklist() + data = { + "name": "Updated Model", + "description": "Updated description", + "thumbnail_full": self.model.thumbnail_image, + "file": self.model.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 403) + + def test_update_style_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.style.uuid, "resource_type": "style"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + data = { + "name": "Updated Style", + "description": "Updated description", + "thumbnail_full": self.style.thumbnail_image, + "file": self.style.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 401) + + def test_update_style_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.style.uuid, "resource_type": "style"}) + self.refresh.blacklist() + data = { + "name": "Updated Style", + "description": "Updated description", + "thumbnail_full": self.style.thumbnail_image, + "file": self.style.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 403) + + def test_update_wavefront_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.wavefront.uuid, "resource_type": "3dmodel"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + data = { + "name": "Updated 3D Model", + "description": "Updated description", + "thumbnail_full": self.wavefront.thumbnail_image, + "file": self.wavefront.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 401) + + def test_update_wavefront_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.wavefront.uuid, "resource_type": "3dmodel"}) + self.refresh.blacklist() + data = { + "name": "Updated 3D Model", + "description": "Updated description", + "thumbnail_full": self.wavefront.thumbnail_image, + "file": self.wavefront.file, + } + response = self.client.put(url, data, format="multipart") + self.assertEqual(response.status_code, 403) + + def test_delete_geopackage_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.geopackage.uuid, "resource_type": "geopackage"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + response = self.client.delete(url) + self.assertEqual(response.status_code, 401) + self.assertTrue(Geopackage.objects.filter(uuid=self.geopackage.uuid).exists()) + + def test_delete_geopackage_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.geopackage.uuid, "resource_type": "geopackage"}) + self.refresh.blacklist() + response = self.client.delete(url) + self.assertEqual(response.status_code, 403) + self.assertTrue(Geopackage.objects.filter(uuid=self.geopackage.uuid).exists()) + + def test_delete_layerdefinition_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.layerdefinition.uuid, "resource_type": "layerdefinition"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + response = self.client.delete(url) + self.assertEqual(response.status_code, 401) + self.assertTrue(LayerDefinition.objects.filter(uuid=self.layerdefinition.uuid).exists()) + + def test_delete_layerdefinition_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.layerdefinition.uuid, "resource_type": "layerdefinition"}) + self.refresh.blacklist() + response = self.client.delete(url) + self.assertEqual(response.status_code, 403) + self.assertTrue(LayerDefinition.objects.filter(uuid=self.layerdefinition.uuid).exists()) + + def test_delete_model_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.model.uuid, "resource_type": "model"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + response = self.client.delete(url) + self.assertEqual(response.status_code, 401) + self.assertTrue(Model.objects.filter(uuid=self.model.uuid).exists()) + + def test_delete_model_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.model.uuid, "resource_type": "model"}) + self.refresh.blacklist() + response = self.client.delete(url) + self.assertEqual(response.status_code, 403) + self.assertTrue(Model.objects.filter(uuid=self.model.uuid).exists()) + + def test_delete_style_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.style.uuid, "resource_type": "style"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + response = self.client.delete(url) + self.assertEqual(response.status_code, 401) + self.assertTrue(Style.objects.filter(uuid=self.style.uuid).exists()) + + def test_delete_style_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.style.uuid, "resource_type": "style"}) + self.refresh.blacklist() + response = self.client.delete(url) + self.assertEqual(response.status_code, 403) + self.assertTrue(Style.objects.filter(uuid=self.style.uuid).exists()) + + def test_delete_wavefront_with_invalid_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.wavefront.uuid, "resource_type": "3dmodel"}) + self.client.credentials(HTTP_AUTHORIZATION='Bearer invalidtoken') + response = self.client.delete(url) + self.assertEqual(response.status_code, 401) + self.assertTrue(Wavefront.objects.filter(uuid=self.wavefront.uuid).exists()) + + def test_delete_wavefront_with_blacklisted_token(self): + url = reverse("resource-detail", kwargs={"uuid": self.wavefront.uuid, "resource_type": "3dmodel"}) + self.refresh.blacklist() + response = self.client.delete(url) + self.assertEqual(response.status_code, 403) + self.assertTrue(Wavefront.objects.filter(uuid=self.wavefront.uuid).exists()) diff --git a/qgis-app/api/views.py b/qgis-app/api/views.py index 829c6f9..b60b2a6 100644 --- a/qgis-app/api/views.py +++ b/qgis-app/api/views.py @@ -35,9 +35,12 @@ from wavefronts.models import Wavefront from api.models import UserOutstandingToken from rest_framework_simplejwt.token_blacklist.models import OutstandingToken + from rest_framework.response import Response from rest_framework.parsers import MultiPartParser, FormParser from rest_framework import status +from django.contrib.auth.mixins import LoginRequiredMixin +from api.permissions import HasValidToken def filter_resource_type(queryset, request, *args, **kwargs): resource_type = request.query_params["resource_type"] @@ -189,7 +192,7 @@ def get(self, request, *args, **kwargs): return response -class UserTokenDetailView(DetailView): +class UserTokenDetailView(LoginRequiredMixin, DetailView): """ Hub token detail """ @@ -235,7 +238,7 @@ def get_context_data(self, **kwargs): return context -class UserTokenListView(ListView): +class UserTokenListView(LoginRequiredMixin, ListView): """ Hub token list """ @@ -283,7 +286,6 @@ def user_token_create(request): @login_required @transaction.atomic def user_token_update(request, token_id): - print(token_id) user_token = get_object_or_404( UserOutstandingToken, pk=token_id, @@ -379,7 +381,7 @@ class ResourceCreateView(APIView): Create a new Resource """ authentication_classes = [JWTAuthentication] - permission_classes = [IsAuthenticated] + permission_classes = [HasValidToken] parser_classes = [MultiPartParser, FormParser] def post(self, request, *args, **kwargs): @@ -412,7 +414,7 @@ class ResourceDetailView(APIView): Retrieve or update a Resource """ authentication_classes = [JWTAuthentication] - permission_classes = [IsAuthenticated] + permission_classes = [HasValidToken] def get(self, request, *args, **kwargs): uuid = kwargs.get("uuid") diff --git a/qgis-app/settings_docker.py b/qgis-app/settings_docker.py index aa29517..c4a4846 100644 --- a/qgis-app/settings_docker.py +++ b/qgis-app/settings_docker.py @@ -249,3 +249,7 @@ 'order': 4, } ] + +# Set the default timezone +USE_TZ = False +TIME_ZONE = 'UTC' \ No newline at end of file diff --git a/qgis-app/templates/layouts/footer.html b/qgis-app/templates/layouts/footer.html index afd7fc4..1c8f52e 100644 --- a/qgis-app/templates/layouts/footer.html +++ b/qgis-app/templates/layouts/footer.html @@ -48,7 +48,7 @@