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 @@

{{ object.name }} {{ version.min_qg_version }} {{ version.max_qg_version }} {{ version.downloads }} + {% if version.is_from_token %} + Token {{ version.token.description|default:"" }} + {% else %} {{ version.created_by }} + {% endif %} {{ version.created_on|local_timezone }} {% if user.is_staff or user in version.plugin.approvers or user in version.plugin.editors %}
{% csrf_token %} {% if user.is_staff or user in version.plugin.approvers %} @@ -270,6 +274,7 @@

{{ object.name }}
{% trans "Edit" %} {% trans "Add version" %} + {% trans "Tokens" %} {% if user.is_staff %} {% if object.featured %} {% else %} diff --git a/qgis-app/plugins/templates/plugins/plugin_token_delete_confirm.html b/qgis-app/plugins/templates/plugins/plugin_token_delete_confirm.html new file mode 100644 index 00000000..3cebe239 --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_delete_confirm.html @@ -0,0 +1,9 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% block content %} +

Delete token of "{{ username }}"

+ {% csrf_token %} +

{% trans "You asked to delete a token.
The token will be permanently deleted and this action cannot be undone.
Please confirm." %}

+

{% trans "Cancel" %}

+ + +{% endblock %} diff --git a/qgis-app/plugins/templates/plugins/plugin_token_detail.html b/qgis-app/plugins/templates/plugins/plugin_token_detail.html new file mode 100644 index 00000000..1ab1101f --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_detail.html @@ -0,0 +1,114 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% load local_timezone %} +{% block content %} +

{% trans "Token for" %} {{ plugin.name }}

+
+ + To enhance the security of the plugin token, + it will be displayed only once. Please ensure + to save it in a secure location. If the token + is lost, you can generate a new one at any time. +
+
+
{% trans "User"%}
+
+ {{ object.user }} +
+
{% trans "Jti"%}
+
+ {{object.jti}} +
+
{% trans "Created at"%}
+
+ {{ object.created_at|local_timezone }} +
+
{% trans "Expires at"%}
+
+ {{ object.expires_at|local_timezone }} +
+
{% trans "Access token"%}
+
+ +
+ +
+ +
+ +
+
+ {% trans "Back to the list" %} + {% trans "Edit description" %} +
+{% endblock %} +{% block extracss %} +{{ block.super }} + +{% endblock %} + +{% block extrajs %} + +{% endblock %} \ No newline at end of file diff --git a/qgis-app/plugins/templates/plugins/plugin_token_form.html b/qgis-app/plugins/templates/plugins/plugin_token_form.html new file mode 100644 index 00000000..c3a4908a --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_form.html @@ -0,0 +1,26 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% load local_timezone %} +{% block content %} +

{% trans "Edit token description " %} {{ token.jti }}

+ +{% if form.errors %} +
+ +

{% trans "The form contains errors and cannot be submitted, please check the fields highlighted in red." %}

+
+{% endif %} +{% if form.non_field_errors %} +
+ + {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+{% endif %} +
{% csrf_token %} + {% include "plugins/form_snippet.html" %} +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/qgis-app/plugins/templates/plugins/plugin_token_invalid_or_expired.html b/qgis-app/plugins/templates/plugins/plugin_token_invalid_or_expired.html new file mode 100644 index 00000000..3f6b286a --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_invalid_or_expired.html @@ -0,0 +1,4 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% block content %} +
{% trans "Token is invalid or expired." %}
+{% endblock %} diff --git a/qgis-app/plugins/templates/plugins/plugin_token_list.html b/qgis-app/plugins/templates/plugins/plugin_token_list.html new file mode 100644 index 00000000..530042b4 --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_list.html @@ -0,0 +1,77 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% load local_timezone %} +{% block content %} +

{% trans "Tokens for" %} {{ plugin.name }}

+
{% csrf_token %} +
+

+ +

+
+
+{% if object_list.count %} +
+ + + + + + + + + + + + + {% for plugin_token in object_list %} + + + + + + + + + {% endfor %} + +
{% trans "User" %}{% trans "Description" %}{% trans "Jti" %}{% trans "Created at" %}{% trans "Last used at" %}{% trans "Manage" %}
{{ plugin_token.token.user }}{{ plugin_token.description|default:"-" }} + + {{ plugin_token.token.jti }} + + {{ plugin_token.token.created_at|local_timezone }}{{ plugin_token.last_used_on|default:"-"|local_timezone }} +   + + +
+
+{% else %} +
+ + {% trans "This list is empty!" %} +
+{% endif %} + +{% endblock %} + +{% block extracss %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/qgis-app/plugins/templates/plugins/plugin_token_permission_deny.html b/qgis-app/plugins/templates/plugins/plugin_token_permission_deny.html new file mode 100644 index 00000000..7850ee82 --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_token_permission_deny.html @@ -0,0 +1,4 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% block content %} +
{% trans "You cannot see tokens for this plugin." %}
+{% endblock %} diff --git a/qgis-app/plugins/templates/plugins/version_detail.html b/qgis-app/plugins/templates/plugins/version_detail.html index ce807471..b9cd4874 100644 --- a/qgis-app/plugins/templates/plugins/version_detail.html +++ b/qgis-app/plugins/templates/plugins/version_detail.html @@ -4,7 +4,7 @@

{% trans "Version" %}: {{ version }}

- {% if not version.created_by.is_active %} + {% if not version.created_by.is_active and not version.is_from_token %}
{% trans "The plugin author has been blocked." %}
@@ -21,7 +21,13 @@

{% trans "Version" %}: {{ version }}

{% if version.changelog %}
{% trans "Changelog" %}
{{ version.changelog|wordwrap:80 }}
{% endif %}
{% trans "Approved" %}
{{ version.approved|yesno }}
-
{% trans "Author" %}
{{ version.created_by }}
+
{% trans "Author" %}
+ {% if version.is_from_token %} + Token {{ version.token.description|default:"" }} + {% else %} + {{ version.created_by }} + {% endif %} +
{% trans "Uploaded" %}
{{ version.created_on|local_timezone }}
{% trans "Minimum QGIS version" %}
{{ version.min_qg_version }}
{% trans "Maximum QGIS version" %}
{{ version.max_qg_version }}
diff --git a/qgis-app/plugins/tests/test_token_auth.py b/qgis-app/plugins/tests/test_token_auth.py new file mode 100644 index 00000000..cfbca6d8 --- /dev/null +++ b/qgis-app/plugins/tests/test_token_auth.py @@ -0,0 +1,163 @@ +import os +from unittest.mock import patch + +from django.urls import reverse +from django.test import Client, TestCase, override_settings +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from plugins.models import Plugin, PluginVersion +from plugins.forms import PackageUploadForm +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken +from rest_framework_simplejwt.tokens import RefreshToken + +def do_nothing(*args, **kwargs): + pass + +TESTFILE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "testfiles")) + +class UploadWithTokenTestCase(TestCase): + fixtures = [ + "fixtures/styles.json", + "fixtures/auth.json", + "fixtures/simplemenu.json", + ] + + @override_settings(MEDIA_ROOT="api/tests") + def setUp(self): + self.client = Client() + self.url_upload = reverse('plugin_upload') + + # Create a test user + self.user = User.objects.create_user( + username='testuser', + password='testpassword', + email='test@example.com' + ) + + # Log in the test user + self.client.login(username='testuser', password='testpassword') + + # Upload a plugin for renaming test. + # This process is already tested in test_plugin_upload + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin.zip_", file.read(), + content_type="application/zip") + + self.client.post(self.url_upload, { + 'package': uploaded_file, + }) + + self.plugin = Plugin.objects.get(name='Test Plugin') + + package_name = self.plugin.package_name + version = '0.0.1' + self.url_add_version = reverse('version_create_api', args=[package_name]) + self.url_update_version = reverse('version_update_api', args=[package_name, version]) + self.url_token_list = reverse('plugin_token_list', args=[package_name]) + self.url_token_create = reverse('plugin_token_create', args=[package_name]) + + def test_token_create(self): + # Test token create + response = self.client.post(self.url_token_create, {}) + self.assertEqual(response.status_code, 302) + tokens = OutstandingToken.objects.all() + self.assertEqual(tokens.count(), 1) + + def test_upload_new_version_with_valid_token(self): + # Generate a token for the authenticated user + self.client.post(self.url_token_create, {}) + outstanding_token = OutstandingToken.objects.last().token + refresh = RefreshToken(outstanding_token) + refresh['plugin_id'] = self.plugin.pk + refresh['refresh_jti'] = refresh['jti'] + access_token = str(refresh.access_token) + + # Log out the user and use the token + self.client.logout() + + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin_0.0.2.zip_", file.read(), + content_type="application/zip_") + + c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Test POST request with access token + response = c.post(self.url_add_version, { + 'package': uploaded_file, + }) + self.assertEqual(response.status_code, 302) + self.assertTrue(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.2').exists()) + + def test_upload_new_version_with_invalid_token(self): + # Log out the user and use the token + self.client.logout() + + access_token = 'invalid_token' + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin_0.0.2.zip_", file.read(), + content_type="application/zip_") + + c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Test POST request with access token + response = c.post(self.url_add_version, { + 'package': uploaded_file, + }) + self.assertEqual(response.status_code, 403) + self.assertFalse(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.2').exists()) + + def test_update_version_with_valid_token(self): + # Generate a token for the authenticated user + self.client.post(self.url_token_create, {}) + outstanding_token = OutstandingToken.objects.last().token + refresh = RefreshToken(outstanding_token) + refresh['plugin_id'] = self.plugin.pk + refresh['refresh_jti'] = refresh['jti'] + access_token = str(refresh.access_token) + + # Log out the user and use the token + self.client.logout() + + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin_0.0.2.zip_", file.read(), + content_type="application/zip_") + + c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Test POST request with access token + response = c.post(self.url_update_version, { + 'package': uploaded_file, + }) + self.assertEqual(response.status_code, 302) + # This will create a new version because this one is using token and doesn't have a created_by column + self.assertTrue(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.1').exists()) + self.assertTrue(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.2').exists()) + + def test_update_version_with_invalid_token(self): + # Log out the user and use the token + self.client.logout() + access_token = 'invalid_token' + + valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_") + with open(valid_plugin, "rb") as file: + uploaded_file = SimpleUploadedFile( + "valid_plugin_0.0.2.zip_", file.read(), + content_type="application/zip_") + + c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Test POST request with access token + response = c.post(self.url_update_version, { + 'package': uploaded_file, + }) + self.assertEqual(response.status_code, 403) + self.assertTrue(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.1').exists()) + self.assertFalse(PluginVersion.objects.filter(plugin__name='Test Plugin', version='0.0.2').exists()) \ No newline at end of file diff --git a/qgis-app/plugins/urls.py b/qgis-app/plugins/urls.py index 52abab9a..dd021892 100644 --- a/qgis-app/plugins/urls.py +++ b/qgis-app/plugins/urls.py @@ -47,6 +47,34 @@ {}, name="plugin_update", ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/$", + PluginTokenListView.as_view(), + name="plugin_token_list", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/(?P\d+)/$", + PluginTokenDetailView.as_view(), + name="plugin_token_detail", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/create/$", + plugin_token_create, + {}, + name="plugin_token_create", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/(?P\d+)/update$", + plugin_token_update, + {}, + name="plugin_token_update", + ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/tokens/(?P[^\/]+)/delete/$", + plugin_token_delete, + {}, + name="plugin_token_delete", + ), url( r"^(?P[A-Za-z][A-Za-z0-9-_]+)/set_featured/$", plugin_set_featured, @@ -224,6 +252,12 @@ {}, name="version_create", ), + url( + r"^api/(?P[A-Za-z][A-Za-z0-9-_]+)/version/add/$", + version_create_api, + {}, + name="version_create_api", + ), url( r"^(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/$", version_detail, @@ -242,6 +276,12 @@ {}, name="version_update", ), + url( + r"^api/(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/update/$", + version_update_api, + {}, + name="version_update_api", + ), url( r"^(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/download/$", version_download, diff --git a/qgis-app/plugins/views.py b/qgis-app/plugins/views.py index eeddbeba..963bcd35 100644 --- a/qgis-app/plugins/views.py +++ b/qgis-app/plugins/views.py @@ -23,16 +23,22 @@ from django.utils.encoding import DjangoUnicodeDecodeError from django.utils.translation import ugettext_lazy as _ from django.views.decorators.cache import never_cache -from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie +from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt, csrf_protect from django.views.decorators.http import require_POST from django.views.generic.detail import DetailView +from django.db import transaction # from sortable_listview import SortableListView from django.views.generic.list import ListView +from plugins.decorators import has_valid_token from plugins.forms import * -from plugins.models import Plugin, PluginVersion, PluginVersionDownload, vjust +from plugins.models import Plugin, PluginOutstandingToken, PluginVersion, PluginVersionDownload, vjust from plugins.validator import PLUGIN_REQUIRED_METADATA +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken +from rest_framework_simplejwt.tokens import RefreshToken, api_settings +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +import time try: from urllib import unquote, urlencode @@ -250,6 +256,16 @@ def check_plugin_access(user, plugin): """ return user.is_staff or user in plugin.editors +def check_plugin_token_access(user, plugin): + """ + Returns true if the user can access all the plugin's token: + + * is_staff + * is maintainer + + """ + return user.is_staff or user.pk == plugin.created_by.pk + def check_plugin_version_approval_rights(user, plugin): """ @@ -594,6 +610,199 @@ def plugin_update(request, package_name): ) + +class PluginTokenListView(ListView): + """ + Plugin token list + """ + model = PluginOutstandingToken + queryset = PluginOutstandingToken.objects.all().order_by("-token__created_at") + template_name = "plugins/plugin_token_list.html" + + @method_decorator(ensure_csrf_cookie) + def dispatch(self, *args, **kwargs): + return super(PluginTokenListView, self).dispatch(*args, **kwargs) + + def get_filtered_queryset(self, qs): + package_name = self.kwargs.get('package_name') + plugin = get_object_or_404(Plugin, package_name=package_name) + if not check_plugin_token_access(self.request.user, plugin): + return qs.filter( + plugin__pk=plugin.pk, + is_blacklisted=False, + token__user=self.request.user + ) + return qs.filter( + plugin__pk=plugin.pk, + is_blacklisted=False, + ) + + def get_queryset(self): + qs = super(PluginTokenListView, self).get_queryset() + qs = self.get_filtered_queryset(qs) + return qs + + def get_context_data(self, **kwargs): + package_name = self.kwargs.get('package_name') + plugin = get_object_or_404(Plugin, package_name=package_name) + if not check_plugin_access(self.request.user, plugin): + context = {} + self.template_name = "plugins/plugin_token_permission_deny.html" + return context + context = super(PluginTokenListView, self).get_context_data(**kwargs) + context.update( + { + "plugin": plugin + } + ) + return context + +class PluginTokenDetailView(DetailView): + """ + Plugin token detail + """ + model = OutstandingToken + queryset = OutstandingToken.objects.all() + template_name = "plugins/plugin_token_detail.html" + + @method_decorator(ensure_csrf_cookie) + def dispatch(self, *args, **kwargs): + return super(PluginTokenDetailView, self).dispatch(*args, **kwargs) + + def get_context_data(self, **kwargs): + context = super(PluginTokenDetailView, self).get_context_data(**kwargs) + package_name = self.kwargs.get('package_name') + token_id = self.kwargs.get('pk') + plugin = get_object_or_404(Plugin, package_name=package_name) + if not check_plugin_access(self.request.user, plugin): + context = {} + self.template_name = "plugins/plugin_token_permission_deny.html" + return context + + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id, user=self.request.user) + plugin_token = get_object_or_404( + PluginOutstandingToken, + token__pk=outstanding_token.pk, + is_blacklisted=False, + is_newly_created=True + ) + try: + token = RefreshToken(outstanding_token.token) + token['plugin_id'] = plugin.pk + token['refresh_jti'] = token[api_settings.JTI_CLAIM] + del token['user_id'] + except (InvalidToken, TokenError) as e: + context = {} + self.template_name = "plugins/plugin_token_invalid_or_expired.html" + return context + timestamp_from_last_edit = int(time.time()) + context.update( + { + "access_token": str(token.access_token), + "plugin": plugin, + "object": outstanding_token, + 'timestamp_from_last_edit': timestamp_from_last_edit + } + ) + plugin_token.is_newly_created = False + plugin_token.save() + return context + +@login_required +@transaction.atomic +def plugin_token_create(request, package_name): + if request.method == "POST": + plugin = get_object_or_404(Plugin, package_name=package_name) + user = request.user + if not check_plugin_access(user, plugin): + return render(request, "plugins/plugin_permission_deny.html", {}) + + refresh = RefreshToken.for_user(user) + refresh["plugin_id"] = plugin.pk + + jti = refresh[api_settings.JTI_CLAIM] + + outstanding_token = OutstandingToken.objects.get(jti=jti) + + plugin_token = PluginOutstandingToken.objects.create( + plugin=plugin, + token=outstanding_token, + is_blacklisted=False, + is_newly_created=True + ) + + return HttpResponseRedirect( + reverse("plugin_token_detail", args=(plugin.package_name, plugin_token.pk)) + ) + +@login_required +@transaction.atomic +def plugin_token_update(request, package_name, token_id): + plugin = get_object_or_404(Plugin, package_name=package_name) + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id) + if not check_plugin_token_access(request.user, plugin): + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id, user=request.user) + plugin_token = get_object_or_404( + PluginOutstandingToken, + token__pk=outstanding_token.pk, + is_blacklisted=False + ) + if not check_plugin_access(request.user, plugin): + return render(request, "plugins/version_permission_deny.html", {}) + if request.method == "POST": + form = PluginTokenForm(request.POST, instance=plugin_token) + if form.is_valid(): + form.save() + msg = _("The token description has been successfully updated.") + messages.success(request, msg, fail_silently=True) + return HttpResponseRedirect( + reverse("plugin_token_list", args=(plugin.package_name,)) + ) + else: + form = PluginTokenForm(instance=plugin_token) + + return render( + request, + "plugins/plugin_token_form.html", + {"form": form, "token": plugin_token} + ) + +@login_required +@transaction.atomic +def plugin_token_delete(request, package_name, token_id): + plugin = get_object_or_404(Plugin, package_name=package_name) + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id) + if not check_plugin_token_access(request.user, plugin): + outstanding_token = get_object_or_404(OutstandingToken, pk=token_id, user=request.user) + plugin_token = get_object_or_404( + PluginOutstandingToken, + token__pk=outstanding_token.pk, + is_blacklisted=False + ) + + if not check_plugin_access(request.user, plugin): + return render(request, "plugins/version_permission_deny.html", {}) + if "delete_confirm" in request.POST: + try: + token = RefreshToken(outstanding_token.token) + token.blacklist() + plugin_token.is_blacklisted = True + except (InvalidToken, TokenError) as e: + plugin_token.is_blacklisted = True + plugin_token.save() + + msg = _("The token has been successfully deleted.") + messages.success(request, msg, fail_silently=True) + return HttpResponseRedirect( + reverse("plugin_token_list", args=(plugin.package_name,)) + ) + return render( + request, + "plugins/plugin_token_delete_confirm.html", + {"plugin": plugin, "username": outstanding_token.user}, + ) + + class PluginsList(ListView): model = Plugin queryset = Plugin.approved_objects.all() @@ -923,28 +1132,44 @@ def _main_plugin_update(request, plugin, form): ) plugin.save() +@has_valid_token +@csrf_exempt +def version_create_api(request, package_name): + """ + Create a new version using a valid token. + We make sure that the token is valid before + disabling CSRF protection. + """ + plugin = get_object_or_404(Plugin, package_name=package_name) + version = PluginVersion(plugin=plugin, is_from_token=True, token=request.plugin_token) + + return _version_create(request, plugin, version) + @login_required def version_create(request, package_name): - """ - The form will create versions according to permissions, - plugin name and description are updated according to the info - contained in the package metadata - """ plugin = get_object_or_404(Plugin, package_name=package_name) if not check_plugin_access(request.user, plugin): return render( request, "plugins/version_permission_deny.html", {"plugin": plugin} ) - version = PluginVersion(plugin=plugin, created_by=request.user) + is_trusted=request.user.has_perm("plugins.can_approve") + return _version_create(request, plugin, version, is_trusted=is_trusted) + +def _version_create(request, plugin, version, is_trusted=False): + """ + The form will create versions according to permissions, + plugin name and description are updated according to the info + contained in the package metadata + """ if request.method == "POST": form = PluginVersionForm( request.POST, request.FILES, instance=version, - is_trusted=request.user.has_perm("plugins.can_approve"), + is_trusted=is_trusted ) if form.is_valid(): try: @@ -953,7 +1178,7 @@ def version_create(request, package_name): messages.success(request, msg, fail_silently=True) # The approved flag is also controlled in the form, but we # are checking it here in any case for additional security - if not request.user.has_perm("plugins.can_approve"): + if not is_trusted: new_object.approved = False new_object.save() messages.warning( @@ -986,7 +1211,7 @@ def version_create(request, package_name): return HttpResponseRedirect(plugin.get_absolute_url()) else: form = PluginVersionForm( - is_trusted=request.user.has_perm("plugins.can_approve") + is_trusted=is_trusted ) return render( @@ -996,24 +1221,42 @@ def version_create(request, package_name): ) -@login_required -def version_update(request, package_name, version): +@has_valid_token +@csrf_exempt +def version_update_api(request, package_name, version): """ - The form will update versions according to permissions + Update a version using a valid token. + We make sure that the token is valid before + disabling CSRF protection. """ + plugin = get_object_or_404(Plugin, package_name=package_name) + version = PluginVersion(plugin=plugin, is_from_token=True, token=request.plugin_token) + return _version_update(request, plugin, version) + + +@login_required +def version_update(request, package_name, version): plugin = get_object_or_404(Plugin, package_name=package_name) version = get_object_or_404(PluginVersion, plugin=plugin, version=version) if not check_plugin_access(request.user, plugin): return render( request, "plugins/version_permission_deny.html", {"plugin": plugin} ) + version = PluginVersion(plugin=plugin, created_by=request.user) + is_trusted=request.user.has_perm("plugins.can_approve") + return _version_update(request, plugin, version, is_trusted=is_trusted) + +def _version_update(request, plugin, version, is_trusted=False): + """ + The form will update versions according to permissions + """ if request.method == "POST": form = PluginVersionForm( request.POST, request.FILES, instance=version, - is_trusted=request.user.has_perm("plugins.can_approve"), + is_trusted=is_trusted, ) if form.is_valid(): try: @@ -1039,7 +1282,7 @@ def version_update(request, package_name, version): return HttpResponseRedirect(plugin.get_absolute_url()) else: form = PluginVersionForm( - instance=version, is_trusted=request.user.has_perm("plugins.can_approve") + instance=version, is_trusted=is_trusted ) return render( diff --git a/qgis-app/settings.py b/qgis-app/settings.py index e79a9821..8e6e8ef3 100644 --- a/qgis-app/settings.py +++ b/qgis-app/settings.py @@ -3,6 +3,7 @@ # ABP: More portable config import os +from datetime import timedelta SITE_ROOT = os.path.dirname(os.path.realpath(__file__)) TEMPLATE_DEBUG = False @@ -148,6 +149,9 @@ "leaflet", "bootstrapform", "rest_framework", + 'rest_framework.authtoken', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', "rest_framework_gis", "preferences", # styles: @@ -331,5 +335,11 @@ CELERY_BROKER_URL = BROKER_URL CELERY_RESULT_BACKEND = CELERY_BROKER_URL +# Token access and refresh validity +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=15), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=15), +} + MATOMO_SITE_ID="1" MATOMO_URL="//matomo.qgis.org/" diff --git a/qgis-app/settings_docker.py b/qgis-app/settings_docker.py index 25784d91..05d109c3 100644 --- a/qgis-app/settings_docker.py +++ b/qgis-app/settings_docker.py @@ -4,6 +4,7 @@ from settings import * SITE_ROOT = os.path.dirname(os.path.realpath(__file__)) +from datetime import timedelta DEBUG = ast.literal_eval(os.environ.get("DEBUG", "True")) THUMBNAIL_DEBUG = DEBUG @@ -63,6 +64,9 @@ "feedjack", "preferences", "rest_framework", + 'rest_framework.authtoken', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', "sorl_thumbnail_serializer", # serialize image "drf_multiple_model", "drf_yasg", @@ -122,5 +126,11 @@ "TEST_REQUEST_DEFAULT_FORMAT": "json", } +# Set plugin token access and refresh validity to a very long duration +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=365*1000), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=365*1000) +} + MATOMO_SITE_ID="1" -MATOMO_URL="//matomo.qgis.org/" \ No newline at end of file +MATOMO_URL="//matomo.qgis.org/" diff --git a/qgis-app/urls.py b/qgis-app/urls.py index 28e46ef0..7166f5c1 100644 --- a/qgis-app/urls.py +++ b/qgis-app/urls.py @@ -112,7 +112,6 @@ url(r"^__debug__/", include(debug_toolbar.urls)), ] - simplemenu.register( "/admin/", "/planet/", diff --git a/readme.md b/readme.md index 6aa42d92..ec2ea04c 100644 --- a/readme.md +++ b/readme.md @@ -31,6 +31,22 @@ To update QGIS versions, go to **[Admin](https://plugins.qgis.org/admin/)** -> * This application is based on Django, written in python and deployed on the server using docker and rancher. +## Token based authentication + +Users have the ability to generate a Simple JWT token by providing their credentials, which can then be utilized to access endpoints requiring authentication. +Users can create specific tokens for a plugin at `https://plugins.qgis.org//tokens/`. + + +```sh +# A specific plugin token can be used to upload or update a plugin version. For example: +curl \ + -H "Authorization: Bearer the_access_token" \ + https://plugins.qgis.org/plugins/api//version/add/ + +curl \ + -H "Authorization: Bearer the_access_token" \ + https://plugins.qgis.org/plugins/api//version//update +``` ## Contributing