diff --git a/REQUIREMENTS.txt b/REQUIREMENTS.txt index ab2d4e1f..2e741ec8 100644 --- a/REQUIREMENTS.txt +++ b/REQUIREMENTS.txt @@ -57,6 +57,8 @@ django-bootstrap-pagination==1.7.1 django-sortable-listview==0.43 django-user-map djangorestframework==3.12.2 +pyjwt==1.7.1 +djangorestframework-simplejwt==4.4 django-rest-auth==0.9.5 drf-yasg django-matomo==0.1.6 \ No newline at end of file diff --git a/dockerize/docker/REQUIREMENTS.txt b/dockerize/docker/REQUIREMENTS.txt index d74ab808..4de38450 100644 --- a/dockerize/docker/REQUIREMENTS.txt +++ b/dockerize/docker/REQUIREMENTS.txt @@ -46,6 +46,9 @@ requests==2.23.0 markdown==3.2.1 djangorestframework==3.11.2 +pyjwt==1.7.1 +djangorestframework-simplejwt==4.4 + sorl-thumbnail-serializer-field==0.2.1 django-rest-auth==0.9.5 drf-yasg==1.17.1 diff --git a/qgis-app/plugins/decorators.py b/qgis-app/plugins/decorators.py new file mode 100644 index 00000000..69509b70 --- /dev/null +++ b/qgis-app/plugins/decorators.py @@ -0,0 +1,39 @@ +from functools import wraps +from django.http import HttpResponseForbidden +from rest_framework_simplejwt.authentication import JWTAuthentication +from plugins.models import Plugin, PluginOutstandingToken +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken, OutstandingToken +import datetime + +def has_valid_token(function): + @wraps(function) + def wrap(request, *args, **kwargs): + auth_token = request.META.get("HTTP_AUTHORIZATION") + package_name = kwargs.get('package_name') + if not str(auth_token).startswith('Bearer'): + raise InvalidToken("Invalid token") + + # Validate JWT token + authentication = JWTAuthentication() + try: + validated_token = authentication.get_validated_token(auth_token[7:]) + plugin_id = validated_token.payload.get('plugin_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 plugin_id or is_blacklisted: + raise InvalidToken("Invalid token") + + plugin = Plugin.objects.get(pk=plugin_id) + if not plugin or plugin.package_name != package_name: + raise InvalidToken("Invalid token") + plugin_token = PluginOutstandingToken.objects.get(token__pk=token_id, plugin=plugin) + plugin_token.last_used_on = datetime.datetime.now() + plugin_token.save() + request.plugin_token = plugin_token + return function(request, *args, **kwargs) + except (InvalidToken, TokenError) as e: + return HttpResponseForbidden(str(e)) + + return wrap diff --git a/qgis-app/plugins/forms.py b/qgis-app/plugins/forms.py index 5147bacc..40c8ab5a 100644 --- a/qgis-app/plugins/forms.py +++ b/qgis-app/plugins/forms.py @@ -6,7 +6,7 @@ from django.forms import CharField, ModelForm, ValidationError from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ -from plugins.models import Plugin, PluginVersion, PluginVersionFeedback +from plugins.models import Plugin, PluginOutstandingToken, PluginVersion, PluginVersionFeedback from plugins.validator import validator from taggit.forms import * @@ -259,3 +259,14 @@ def clean(self): self.cleaned_data['tasks'] = tasks return self.cleaned_data + +class PluginTokenForm(ModelForm): + """ + Form for token description editing + """ + + class Meta: + model = PluginOutstandingToken + fields = ( + "description", + ) \ No newline at end of file diff --git a/qgis-app/plugins/middleware.py b/qgis-app/plugins/middleware.py index 059a7848..17adb828 100644 --- a/qgis-app/plugins/middleware.py +++ b/qgis-app/plugins/middleware.py @@ -2,8 +2,7 @@ # Author: A. Pasotti from django.contrib import auth -from django.contrib.auth.models import User - +from rest_framework_simplejwt.authentication import JWTAuthentication def HttpAuthMiddleware(get_response): """ @@ -12,7 +11,7 @@ def HttpAuthMiddleware(get_response): def middleware(request): auth_basic = request.META.get("HTTP_AUTHORIZATION") - if auth_basic: + if auth_basic and not str(auth_basic).startswith('Bearer'): import base64 username, dummy, password = base64.decodestring( @@ -27,7 +26,6 @@ def middleware(request): # by logging the user in. request.user = user auth.login(request, user) - response = get_response(request) # Code to be executed for each request/response after diff --git a/qgis-app/plugins/migrations/0005_pluginoutstandingtoken.py b/qgis-app/plugins/migrations/0005_pluginoutstandingtoken.py new file mode 100644 index 00000000..4e406433 --- /dev/null +++ b/qgis-app/plugins/migrations/0005_pluginoutstandingtoken.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.25 on 2023-12-11 23:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('token_blacklist', '0007_auto_20171017_2214'), + ('plugins', '0004_merge_20231122_0223'), + ] + + operations = [ + migrations.CreateModel( + name='PluginOutstandingToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_blacklisted', models.BooleanField(default=False)), + ('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='plugins.Plugin')), + ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='token_blacklist.OutstandingToken')), + ], + ), + ] diff --git a/qgis-app/plugins/migrations/0006_auto_20231218_0225.py b/qgis-app/plugins/migrations/0006_auto_20231218_0225.py new file mode 100644 index 00000000..60d0af5f --- /dev/null +++ b/qgis-app/plugins/migrations/0006_auto_20231218_0225.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.25 on 2023-12-18 02:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0005_pluginoutstandingtoken'), + ] + + operations = [ + migrations.AddField( + model_name='pluginoutstandingtoken', + name='description', + field=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'), + ), + migrations.AddField( + model_name='pluginoutstandingtoken', + name='is_newly_created', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='pluginoutstandingtoken', + name='last_used_on', + field=models.DateTimeField(blank=True, null=True, verbose_name='Last used on'), + ), + ] diff --git a/qgis-app/plugins/migrations/0007_auto_20240109_0428.py b/qgis-app/plugins/migrations/0007_auto_20240109_0428.py new file mode 100644 index 00000000..6d6af2da --- /dev/null +++ b/qgis-app/plugins/migrations/0007_auto_20240109_0428.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.25 on 2024-01-09 04:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugins', '0006_auto_20231218_0225'), + ] + + operations = [ + migrations.AddField( + model_name='pluginversion', + name='is_from_token', + field=models.BooleanField(default=False, verbose_name='Is uploaded using token'), + ), + migrations.AddField( + model_name='pluginversion', + name='token', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='plugins.PluginOutstandingToken', verbose_name='Token used'), + ), + migrations.AlterField( + model_name='pluginversion', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Created by'), + ), + ] diff --git a/qgis-app/plugins/models.py b/qgis-app/plugins/models.py index db95fda6..9f9cc209 100644 --- a/qgis-app/plugins/models.py +++ b/qgis-app/plugins/models.py @@ -12,6 +12,7 @@ from django.utils import timezone from djangoratings.fields import AnonymousRatingField from taggit_autosuggest.managers import TaggableManager +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken PLUGINS_STORAGE_PATH = getattr(settings, "PLUGINS_STORAGE_PATH", "packages/%Y") PLUGINS_FRESH_DAYS = getattr(settings, "PLUGINS_FRESH_DAYS", 30) @@ -671,6 +672,33 @@ def from_db_value(self, value, expression, connection): return self.to_python(value) +class PluginOutstandingToken(models.Model): + """ + Plugin outstanding token + """ + plugin = models.ForeignKey( + Plugin, + on_delete=models.CASCADE + ) + token = models.ForeignKey( + OutstandingToken, + on_delete=models.CASCADE + ) + is_blacklisted = models.BooleanField(default=False) + is_newly_created = models.BooleanField(default=False) + description = models.CharField( + verbose_name=_("Description"), + help_text=_("Describe this token so that it's easier to remember where you're using it."), + max_length=512, + blank=True, + null=True, + ) + last_used_on = models.DateTimeField( + verbose_name=_("Last used on"), + blank=True, + null=True + ) + class PluginVersion(models.Model): """ Plugin versions @@ -686,7 +714,7 @@ class PluginVersion(models.Model): downloads = models.IntegerField(_("Downloads"), default=0, editable=False) # owners created_by = models.ForeignKey( - User, verbose_name=_("Created by"), on_delete=models.CASCADE + User, verbose_name=_("Created by"), on_delete=models.CASCADE, null=True, blank=True ) # version info, the first should be read from plugin min_qg_version = QGVersionZeroForcedField( @@ -722,6 +750,14 @@ class PluginVersion(models.Model): blank=False, null=True, ) + is_from_token = models.BooleanField( + _("Is uploaded using token"), + default=False + ) + # Link to the token if upload is using token + token = models.ForeignKey( + PluginOutstandingToken, verbose_name=_("Token used"), on_delete=models.CASCADE, null=True, blank=True + ) # Managers, used in xml output objects = models.Manager() diff --git a/qgis-app/plugins/templates/plugins/plugin_detail.html b/qgis-app/plugins/templates/plugins/plugin_detail.html index bc48879e..1b2598c3 100644 --- a/qgis-app/plugins/templates/plugins/plugin_detail.html +++ b/qgis-app/plugins/templates/plugins/plugin_detail.html @@ -242,7 +242,11 @@