Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added pdfding/api/__init__.py
Empty file.
7 changes: 7 additions & 0 deletions pdfding/api/apps.py
Original file line number Diff line number Diff line change
@@ -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"
26 changes: 26 additions & 0 deletions pdfding/api/conftest.py
Original file line number Diff line number Diff line change
@@ -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])
54 changes: 54 additions & 0 deletions pdfding/api/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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')],
},
),
]
13 changes: 13 additions & 0 deletions pdfding/api/migrations/0002_wait_for_knox.py
Original file line number Diff line number Diff line change
@@ -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 = []
Empty file.
42 changes: 42 additions & 0 deletions pdfding/api/models.py
Original file line number Diff line number Diff line change
@@ -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]
145 changes: 145 additions & 0 deletions pdfding/api/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added pdfding/api/tests/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions pdfding/api/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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]
40 changes: 40 additions & 0 deletions pdfding/api/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions pdfding/api/views.py
Original file line number Diff line number Diff line change
@@ -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)
Loading