From 861e74b74dac5ba64b5410f2fd150f648ef6f137 Mon Sep 17 00:00:00 2001 From: wh0am1 <48444630+wh0th3h3llam1@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:43:39 -0800 Subject: [PATCH 1/4] add api application --- pdfding/api/__init__.py | 0 pdfding/api/apps.py | 7 ++ pdfding/api/conftest.py | 26 +++++ pdfding/api/migrations/0001_initial.py | 54 +++++++++ pdfding/api/migrations/__init__.py | 0 pdfding/api/models.py | 42 +++++++ pdfding/api/serializers.py | 145 +++++++++++++++++++++++++ pdfding/api/tests/__init__.py | 0 pdfding/api/tests/test_models.py | 23 ++++ pdfding/api/tests/test_views.py | 40 +++++++ pdfding/api/views.py | 15 +++ 11 files changed, 352 insertions(+) create mode 100644 pdfding/api/__init__.py create mode 100644 pdfding/api/apps.py create mode 100644 pdfding/api/conftest.py create mode 100644 pdfding/api/migrations/0001_initial.py create mode 100644 pdfding/api/migrations/__init__.py create mode 100644 pdfding/api/models.py create mode 100644 pdfding/api/serializers.py create mode 100644 pdfding/api/tests/__init__.py create mode 100644 pdfding/api/tests/test_models.py create mode 100644 pdfding/api/tests/test_views.py create mode 100644 pdfding/api/views.py diff --git a/pdfding/api/__init__.py b/pdfding/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pdfding/api/apps.py b/pdfding/api/apps.py new file mode 100644 index 00000000..f4befec8 --- /dev/null +++ b/pdfding/api/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' + verbose_name = "API Authentication" diff --git a/pdfding/api/conftest.py b/pdfding/api/conftest.py new file mode 100644 index 00000000..d8ddd5d4 --- /dev/null +++ b/pdfding/api/conftest.py @@ -0,0 +1,26 @@ +from uuid import uuid4 + +import pytest +from api.models import AccessToken +from django.contrib.auth import get_user_model +from knox.models import AuthToken + + +@pytest.fixture +def user(): + return get_user_model().objects.create_user(username="testuser", password=uuid4().hex) + + +@pytest.fixture +def another_user(): + return get_user_model().objects.create_user(username="anotheruser", password=uuid4().hex) + + +@pytest.fixture +def knox_token(user) -> tuple[AuthToken, str]: + return AuthToken.objects.create(user=user) # type: ignore + + +@pytest.fixture +def token(knox_token): + return AccessToken.objects.create(user=knox_token[0].user, name="MyAPIToken", knox_token=knox_token[0]) diff --git a/pdfding/api/migrations/0001_initial.py b/pdfding/api/migrations/0001_initial.py new file mode 100644 index 00000000..735a522a --- /dev/null +++ b/pdfding/api/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.8 on 2025-11-18 01:11 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.KNOX_TOKEN_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AccessToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + 'name', + models.CharField( + max_length=64, + validators=[django.core.validators.MinLengthValidator(3)], + verbose_name='Token Name', + ), + ), + ('last_used', models.DateTimeField(blank=True, null=True)), + ('modified_at', models.DateTimeField(auto_now=True)), + ( + 'knox_token', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, related_name='meta', to=settings.KNOX_TOKEN_MODEL + ), + ), + ( + 'user', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='access_tokens', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'verbose_name': 'Access Token', + 'verbose_name_plural': 'Access Tokens', + 'constraints': [models.UniqueConstraint(fields=('user', 'name'), name='unique_token_name_per_user')], + }, + ), + ] diff --git a/pdfding/api/migrations/__init__.py b/pdfding/api/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pdfding/api/models.py b/pdfding/api/models.py new file mode 100644 index 00000000..0bcf5c7f --- /dev/null +++ b/pdfding/api/models.py @@ -0,0 +1,42 @@ +from django.contrib.auth import get_user_model +from django.core.validators import MinLengthValidator +from django.db import models +from knox.models import AuthToken + +# Create your models here. + + +User = get_user_model() + + +class AccessToken(models.Model): + """ + Model representing an access token associated with a user. + """ + + user = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name="access_tokens") + name = models.CharField( + verbose_name="Token Name", + max_length=64, + validators=[ + MinLengthValidator(3), + ], + ) + knox_token = models.OneToOneField(to=AuthToken, on_delete=models.CASCADE, related_name="meta") + + last_used = models.DateTimeField(blank=True, null=True) + modified_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Access Token" + verbose_name_plural = "Access Tokens" + constraints = [ + models.UniqueConstraint(fields=["user", "name"], name="unique_token_name_per_user"), + ] + + def __str__(self) -> str: + return self.name + + def token_key_prefix(self) -> str: + # show first 8 chars of token key (safe identifier) + return self.knox_token.token_key[:8] diff --git a/pdfding/api/serializers.py b/pdfding/api/serializers.py new file mode 100644 index 00000000..424297d7 --- /dev/null +++ b/pdfding/api/serializers.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from datetime import timedelta + +from api.models import AccessToken +from django.utils import timezone +from knox.models import AuthToken +from rest_framework import serializers + +# Create your serializers here. + + +def _to_aware(dt): + # Accept naive datetime; make them aware in current TZ + if timezone.is_naive(dt): + return timezone.make_aware(dt, timezone.get_current_timezone()) + return dt + + +class ReadOnlyAccessTokenSerializer(serializers.ModelSerializer): + """Read-only serializer for listing and retrieving access tokens.""" + + created = serializers.DateTimeField(source="knox_token.created") + expiry = serializers.DateTimeField(source="knox_token.expiry") + prefix = serializers.CharField(source="token_key_prefix") + + class Meta: + model = AccessToken + fields = ["id", "name", "prefix", "created", "expiry", "last_used"] + read_only_fields = fields + + +class AccessTokenUpdateSerializer(serializers.ModelSerializer): + """Serializer for updating access token name.""" + + name = serializers.CharField(min_length=3, max_length=64, required=True, allow_blank=False) + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + def validate_name(self, value: str) -> str: + if not value.strip(): + raise serializers.ValidationError("Name cannot be empty.") + return value.strip() + + class Meta: + model = AccessToken + fields = ("name", "user") + + def validate(self, attrs): + user = self.context["request"].user + if self.instance.id != user.id: + raise serializers.ValidationError("Not allowed to update this token.") + return attrs + + +class BaseAccessTokenSerializer(serializers.ModelSerializer): + """ + Base serializer for creating and rotating access tokens. + Handles the `expires_at` field and provides the plaintext token after creation. + """ + + _plaintext_token: str = "" + expires_at = serializers.DateTimeField(required=False, allow_null=True) + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + def validate_expires_at(self, value): + if value is None: + return value + aware = _to_aware(value) + now = timezone.now() + if aware <= now + timedelta(days=1): + raise serializers.ValidationError("`expires_at` must be in the future.") + max_expiry = now + timedelta(days=365) + if aware > max_expiry: + raise serializers.ValidationError("`expires_at` is too far in the future (max 365 days).") + return aware + + class Meta: + model = AccessToken + fields = ("expires_at", "user") + + def to_representation(self, instance: AccessToken): + return { + "token": self._plaintext_token, + "token_key_prefix": instance.token_key_prefix(), + "id": instance.id, # type: ignore + "name": instance.name, + "created": instance.knox_token.created, + "expiry": instance.knox_token.expiry, + } + + +class AccessTokenCreateSerializer(BaseAccessTokenSerializer): + """ + Creates a new access token (optionally with expires_at) and returns the plaintext token + """ + + class Meta: + model = AccessToken + fields = BaseAccessTokenSerializer.Meta.fields + ("name",) + + def create(self, validated_data): + user = validated_data["user"] + + expires_at = validated_data.pop("expires_at", None) + knox_token, token = create_knox_token(user, expires_at) + + meta = create_access_token(user=user, knox_token=knox_token, name=validated_data["name"]) + + self._plaintext_token = token + return meta + + +class AccessTokenRotateSerializer(BaseAccessTokenSerializer): + """ + Rotates an existing access token with a new one. + The old token is deleted. The name and user are preserved. + """ + + def update(self, instance: AccessToken, validated_data: dict) -> AccessToken: + user = self.context["request"].user + if validated_data["user"].id != user.id: + raise serializers.ValidationError("Not allowed to rotate this token.") + + instance.knox_token.delete() + expires_at = validated_data.get("expires_at", None) # type: ignore + knox_token, token = create_knox_token(user, expires_at) + + new_meta = create_access_token(user=user, knox_token=knox_token, name=instance.name) + self._plaintext_token = token + return new_meta + + +def create_access_token(user, knox_token, name) -> AccessToken: + return AccessToken.objects.create(user=user, knox_token=knox_token, name=name) + + +def create_knox_token(user, expires_at) -> tuple[AuthToken, str]: + """Creates a new Knox AuthToken for the given user with optional expiry. + + :param user: User instance + :param expires_at: Optional datetime for expiry + :return: (AuthToken, plaintext token) + """ + + return AuthToken.objects.create(user=user, expiry=expires_at) # type: ignore diff --git a/pdfding/api/tests/__init__.py b/pdfding/api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pdfding/api/tests/test_models.py b/pdfding/api/tests/test_models.py new file mode 100644 index 00000000..e0c2b60a --- /dev/null +++ b/pdfding/api/tests/test_models.py @@ -0,0 +1,23 @@ +import pytest +from api.models import AccessToken + +# Create your tests here. +pytestmark = pytest.mark.django_db + + +class TestAccessTokenModel: + + def test_access_token_creation(self, user, knox_token): + token = AccessToken.objects.create(user=user, name="MyToken", knox_token=knox_token[0]) + assert token.user == user + assert token.name == "MyToken" + assert token.knox_token == knox_token[0] + assert token.last_used is None + assert str(token) == "MyToken" + + def test_token_key_prefix(self, knox_token, user): + token = AccessToken.objects.create(user=user, name="Test Token", knox_token=knox_token[0]) + prefix = token.token_key_prefix() + assert isinstance(prefix, str) + assert len(prefix) == 8 + assert prefix == knox_token[0].token_key[:8] diff --git a/pdfding/api/tests/test_views.py b/pdfding/api/tests/test_views.py new file mode 100644 index 00000000..01da51c4 --- /dev/null +++ b/pdfding/api/tests/test_views.py @@ -0,0 +1,40 @@ +import pytest +from api.models import AccessToken +from django.urls import reverse +from knox.models import AuthToken +from rest_framework import status +from rest_framework.test import APIClient + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def api_client(user) -> APIClient: + client = APIClient() + client.force_authenticate(user=user) + return client + + +class TestAccessTokenView: + + def test_permissions_required(self): + url = reverse("api_token_detail", args=[1]) + client = APIClient() + response = client.patch(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED # type: ignore + + def test_token_update(self, api_client, token): + url = reverse("api_token_detail", args=[token.id]) + data = {"name": "Updated Token Name"} + response = api_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "Updated Token Name" + + def test_token_update_other_user_forbidden(self, api_client, another_user): + knox_token: [AuthToken, str] = AuthToken.objects.create(user=another_user) # type: ignore + other_token = AccessToken.objects.create(user=another_user, name="MyAPIToken", knox_token=knox_token[0]) + + url = reverse("api_token_detail", args=[other_token.id]) # type: ignore + data = {"name": "Hacked Token Name"} + response = api_client.patch(url, data) + assert response.status_code == status.HTTP_404_NOT_FOUND # should not reveal existence diff --git a/pdfding/api/views.py b/pdfding/api/views.py new file mode 100644 index 00000000..cba7932f --- /dev/null +++ b/pdfding/api/views.py @@ -0,0 +1,15 @@ +from api.models import AccessToken +from api.serializers import AccessTokenUpdateSerializer +from rest_framework.generics import UpdateAPIView +from rest_framework.permissions import IsAuthenticated + +# Create your views here. + + +class AccessTokenView(UpdateAPIView): + permission_classes = (IsAuthenticated,) + serializer_class = AccessTokenUpdateSerializer + queryset = AccessToken.objects.all() + + def get_queryset(self): + return self.queryset.filter(user=self.request.user) From 25dc75b716f7912c81037ca8e424a40510fff4de Mon Sep 17 00:00:00 2001 From: wh0am1 <48444630+wh0th3h3llam1@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:43:56 -0800 Subject: [PATCH 2/4] add migration file --- pdfding/api/migrations/0002_wait_for_knox.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 pdfding/api/migrations/0002_wait_for_knox.py diff --git a/pdfding/api/migrations/0002_wait_for_knox.py b/pdfding/api/migrations/0002_wait_for_knox.py new file mode 100644 index 00000000..5084c7a0 --- /dev/null +++ b/pdfding/api/migrations/0002_wait_for_knox.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.8 on 2025-11-18 01:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ('knox', '0009_extend_authtoken_field'), + ] + + operations = [] From 82c21bf2a1e2caf4e5510a4190605714fa7bae19 Mon Sep 17 00:00:00 2001 From: wh0am1 <48444630+wh0th3h3llam1@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:48:20 -0800 Subject: [PATCH 3/4] update settings and urls --- pdfding/core/settings/base.py | 17 +++++++++++++++++ pdfding/core/urls.py | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/pdfding/core/settings/base.py b/pdfding/core/settings/base.py index 918b4743..dcce6578 100644 --- a/pdfding/core/settings/base.py +++ b/pdfding/core/settings/base.py @@ -33,12 +33,15 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', 'allauth', 'allauth.account', 'allauth.socialaccount', 'allauth.socialaccount.providers.openid_connect', + 'knox', 'django_htmx', 'huey.contrib.djhuey', + 'api', 'admin', 'backup', 'pdf', @@ -240,3 +243,17 @@ }, }, } + + +# Django REST Framework +# https://www.django-rest-framework.org/api-guide/settings/ +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), +} + +# Django REST Knox +# https://jazzband.github.io/django-rest-knox/settings/ +KNOX_TOKEN_MODEL = 'knox.AuthToken' # nosec B105 +REST_KNOX = { + 'AUTO_REFRESH': True, +} diff --git a/pdfding/core/urls.py b/pdfding/core/urls.py index d6d4764f..e76936d8 100644 --- a/pdfding/core/urls.py +++ b/pdfding/core/urls.py @@ -27,6 +27,8 @@ pdfding_oidc_login, ) +from pdfding.api.views import AccessTokenView + urlpatterns = [ # overwrite some allauth urls as they are blocked otherwise because of the LoginRequiredMiddleware path('accountlogin/', PdfDingLoginView.as_view(), name='login'), @@ -43,3 +45,8 @@ path('pdf/', include('pdf.urls')), path('healthz', HealthView.as_view(), name='healthz'), ] + +# API URLs +urlpatterns += [ + path('api/token//', AccessTokenView.as_view(), name='api_token_detail'), +] From 9c5c0d88651b682793cb3bd97ac13adc25e94291 Mon Sep 17 00:00:00 2001 From: wh0am1 <48444630+wh0th3h3llam1@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:49:01 -0800 Subject: [PATCH 4/4] update pyproject and poetry lock --- poetry.lock | 33 ++++++++++++++++++++++++++++++++- pyproject.toml | 2 ++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index a6ac665d..a8aaf3c7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -773,6 +773,37 @@ files = [ asgiref = ">=3.6" django = ">=4.2" +[[package]] +name = "django-rest-knox" +version = "5.0.2" +description = "Authentication for django rest framework" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "django_rest_knox-5.0.2-py3-none-any.whl", hash = "sha256:694da5d0ad6eb3edbfd7cdc8d69c089fc074e6b0e548e00ff2750bf2fdfadb6f"}, + {file = "django_rest_knox-5.0.2.tar.gz", hash = "sha256:f283622bcf5d28a6a0203845c065d06c7432efa54399ae32070c61ac03af2d6f"}, +] + +[package.dependencies] +django = ">=4.2" +djangorestframework = "*" + +[[package]] +name = "djangorestframework" +version = "3.16.1" +description = "Web APIs for Django, made easy." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec"}, + {file = "djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7"}, +] + +[package.dependencies] +django = ">=4.2" + [[package]] name = "flake8" version = "7.3.0" @@ -2102,4 +2133,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.1" python-versions = ">=3.11 <4.0" -content-hash = "32e028eba6b6d416b73f901dbdd6e7de4bf1721eeafecd5f78cea2d2f623ddf6" +content-hash = "cf647e433b68727f4d84a8e303a2717f12f536073e231105c8fc49d3f0602384" diff --git a/pyproject.toml b/pyproject.toml index 28e78548..6027efb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ django = "==5.2.8" django-allauth = {extras = ["socialaccount"], version = "==65.13.0"} django-cleanup = "==9.0.0" django-htmx = "==1.26.0" +django-rest-knox = "==5.0.2" +djangorestframework = "==3.16.1" gunicorn = "==23.0.0" huey = "==2.5.4" Markdown = "==3.10"