Skip to content
Open
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
18 changes: 16 additions & 2 deletions .talismanrc
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ fileignoreconfig:
checksum: 3d899b94bc836e0f97b282d569ca27b513be6df54cf718e96a944a99b634444d
- filename: .env.sample
checksum: aa3f02c8f5d30f989986f9eafaca8bc57d99d09361b9fa9c2158638bcc263f15
- filename: core/settings.py
checksum: c9a7604687c73a1344d6ec4d5979a521981fd11f0ae35cb6351787d824c1c55f
- filename: erp/management/commands/import_apidae.py
checksum: 0bf117b99f9b76db4824cfe5aec647f455eaeb4e57c96425b90e6c580302a972
- filename: .github/workflows/lint.yml
Expand Down Expand Up @@ -93,3 +91,19 @@ fileignoreconfig:
checksum: 253aa05b83f0695dec90299bb58538e736bbce3e0200385c63b6dbc3c5294a75
- filename: erp/views.py
checksum: 40161683c6d36b3a1d5bb3c0b05594fa48ada9dd88ecc379294fa10f64743eb2
- filename: api/authentication.py
checksum: 3d3fdfaf5cb8f79e38238d2f75b6fef32c2658e6e71fd2752630bb0797c08279
- filename: compte/admin.py
checksum: ce749d67704e2fbf4c6257c13a1c11fcdcb0e3d1c49568ae5b1fe4ac149a587a
- filename: compte/migrations/0009_userapikey.py
checksum: 41df5a33bf852a149d6d54fabde1c55955f8290d50cdf7203b93d879a6278542
- filename: compte/migrations/0010_auto_20260715_1512.py
checksum: d1a902acd238fb94e179d809d5ef3ac8c5c027a6a0581e61f8844a91f6aa8f93
- filename: tests/api/tests.py
checksum: 37ddb4f8af1dadeaaaec408016119309fc8d59795f3a3a8de726ec19994f2124
- filename: core/settings.py
checksum: aef0ac9cf58af8afbac59a2bdbaef5425b85e18bf235ea90c1701c9fe3eda3f0
- filename: api/throttling.py
checksum: d97a8420cfd750484e125234abddadc773e41830fcf76c5ecfbf943e8017cd52
- filename: tests/api/test_permissions.py
checksum: 861dc1b82574ecf1de0629bddb553ac2bed11536506622e8a42ae479ac622238
24 changes: 24 additions & 0 deletions api/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from rest_framework.authentication import BaseAuthentication

from compte.models import UserAPIKey


class UserAPIKeyAuthentication(BaseAuthentication):
"""New API Key system, linked to a user. Return None if not found to fallback on legacy system."""

def authenticate(self, request):
auth = request.META.get("HTTP_AUTHORIZATION")
if not auth:
return None

parts = auth.split()
if len(parts) != 2:
return None

key = parts[1]
try:
api_key = UserAPIKey.objects.get_from_key(key)
except UserAPIKey.DoesNotExist:
return None

return (api_key.user, api_key)
12 changes: 5 additions & 7 deletions api/permissions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import sentry_sdk
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext as translate
from rest_framework import permissions
from rest_framework_api_key.models import APIKey

from compte.models import UserAPIKey

SAFE_METHODS = ("GET", "HEAD", "OPTIONS")


Expand All @@ -20,11 +20,9 @@ def has_permission(self, request, view):
return False

key = auth_split[1]
if key == cache.get(settings.INTERNAL_API_KEY_NAME):
if view.action in ("default", "list", "translate"):
# Internal api key is allowed to perform only view/list actions, not write operations (create, update, ...)
return True
return False

if isinstance(request.auth, UserAPIKey):
return True

try:
with sentry_sdk.start_span(description="Check signature of API KEY"):
Expand Down
17 changes: 17 additions & 0 deletions api/throttling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from django.conf import settings
from rest_framework.throttling import SimpleRateThrottle


class FrontendOriginThrottle(SimpleRateThrottle):
"""Generous quota for requests that appear to come from our own
frontend. Not a security mechanism (Origin/Referer are spoofable):
it only avoids penalizing normal site usage while pushing
unregistered scraping toward requesting an API key."""

scope = "frontend"

def get_cache_key(self, request, view):
origin = request.META.get("HTTP_ORIGIN") or request.META.get("HTTP_REFERER", "")
if not origin.startswith(settings.SITE_ROOT_URL):
return None
return self.cache_format % {"scope": self.scope, "ident": self.get_ident(request)}
16 changes: 15 additions & 1 deletion api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.conf import settings
from django.db.models import Q
from django.shortcuts import get_object_or_404
from rest_framework import mixins, viewsets
from rest_framework import mixins, permissions, viewsets
from rest_framework.decorators import action
from rest_framework.filters import BaseFilterBackend
from rest_framework.pagination import PageNumberPagination
Expand Down Expand Up @@ -208,6 +208,7 @@ class AccessibiliteViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, vie
pagination_class = AccessibilitePagination
filter_backends = [AccessibiliteFilterBackend]
schema = AccessibiliteSchema()
permission_classes = [permissions.AllowAny]

@action(detail=False, methods=["get"])
def help(self, request, pk=None):
Expand Down Expand Up @@ -303,6 +304,7 @@ class ActiviteViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets
pagination_class = ActivitePagination
filter_backends = [ActiviteFilterBackend]
schema = ActiviteSchema()
permission_classes = [permissions.AllowAny]


class ErpPagination(PageNumberPagination):
Expand Down Expand Up @@ -571,6 +573,18 @@ def get_pagination_class(self):
return GeoJsonPagination
return ErpPagination

def perform_create(self, serializer):
user = self.request.user if self.request.user.is_authenticated else None
serializer.save(user=user) if user else serializer.save()

def perform_update(self, serializer):
instance = serializer.instance
user = self.request.user if self.request.user.is_authenticated else None
if user and instance.user_id is None:
serializer.save(user=user.id)
else:
serializer.save()

pagination_class = property(fget=get_pagination_class)

@action(methods=["get"], detail=True, url_path="widget", url_name="widget")
Expand Down
19 changes: 18 additions & 1 deletion compte/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from import_export.admin import ExportMixin
from rest_framework_api_key.admin import APIKeyModelAdmin
from rest_framework_api_key.models import APIKey

from compte.models import UserPreferences, UserStats
from compte.models import UserAPIKey, UserPreferences, UserStats
from compte.resources import UserAdminResource


Expand Down Expand Up @@ -84,6 +86,21 @@ class UserPreferencesAdmin(admin.ModelAdmin):
search_fields = ("user__email",)


admin.site.unregister(APIKey)


@admin.register(APIKey)
class LegacyAPIKeyAdmin(APIKeyModelAdmin):
def has_add_permission(self, request):
return False


@admin.register(UserAPIKey)
class UserAPIKeyAdmin(APIKeyModelAdmin):
list_display = [*APIKeyModelAdmin.list_display, "user"]
search_fields = [*APIKeyModelAdmin.search_fields, "user__username", "user__email"]


# Replace the default UserAdmin with our custom one
admin.site.unregister(User)
admin.site.register(User, CustomUserAdmin)
66 changes: 66 additions & 0 deletions compte/migrations/0009_userapikey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Generated by Django 6.0.7 on 2026-07-15 12:31

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("compte", "0008_nb_erp_administrator"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="UserAPIKey",
fields=[
(
"id",
models.CharField(editable=False, max_length=150, primary_key=True, serialize=False, unique=True),
),
("prefix", models.CharField(editable=False, max_length=8, unique=True)),
("hashed_key", models.CharField(editable=False, max_length=150)),
("created", models.DateTimeField(auto_now_add=True, db_index=True)),
(
"name",
models.CharField(
default=None,
help_text="A free-form name for the API key. Need not be unique. 50 characters max.",
max_length=50,
),
),
(
"revoked",
models.BooleanField(
blank=True,
default=False,
help_text="If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)",
),
),
(
"expiry_date",
models.DateTimeField(
blank=True,
help_text="Once API key expires, clients cannot use it anymore.",
null=True,
verbose_name="Expires",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="api_keys",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Clef d'API par utilisateur",
"verbose_name_plural": "Clefs d'API par utilisateur",
"ordering": ("-created",),
"abstract": False,
},
),
]
93 changes: 93 additions & 0 deletions compte/migrations/0010_auto_20260715_1512.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import logging
import re

from django.db import migrations
from django.utils import timezone

logger = logging.getLogger("api_keys_migration")

EMAIL_RE = re.compile(r"[\w\.\-+]+@[\w\-]+\.[\w\.\-]+")


def migrate_legacy_keys(apps, schema_editor):
APIKey = apps.get_model("rest_framework_api_key", "APIKey")
UserAPIKey = apps.get_model("compte", "UserAPIKey")
User = apps.get_model(*settings_auth_user_model(apps))

now = timezone.now()

stats = {"migrated": 0, "no_email_found": 0, "no_user_match": 0, "ambiguous": 0, "skipped_state": 0}

for key in APIKey.objects.all():
if key.revoked:
stats["skipped_state"] += 1
continue
if key.expiry_date is not None and key.expiry_date <= now:
stats["skipped_state"] += 1
continue

match = EMAIL_RE.search(key.name or "")
if not match:
stats["no_email_found"] += 1
logger.warning("No email found in key name: prefix=%s name=%r", key.prefix, key.name)
continue

email = match.group(0)
users = list(User.objects.filter(email__iexact=email))

if len(users) == 0:
stats["no_user_match"] += 1
logger.warning("No user found for email=%s (key prefix=%s)", email, key.prefix)
continue

if len(users) > 1:
stats["ambiguous"] += 1
logger.warning("Multiple users found for email=%s (key prefix=%s), skipping", email, key.prefix)
continue

user = users[0]

UserAPIKey.objects.create(
id=key.id,
prefix=key.prefix,
hashed_key=key.hashed_key,
created=key.created,
name=key.name,
revoked=key.revoked,
expiry_date=key.expiry_date,
user=user,
)

key.revoked = True
key.save(update_fields=["revoked"])

stats["migrated"] += 1

logger.info("Legacy API key migration done: %s", stats)


def settings_auth_user_model(apps):
from django.conf import settings

app_label, model_name = settings.AUTH_USER_MODEL.split(".")
return app_label, model_name


def reverse_migration(apps, schema_editor):
APIKey = apps.get_model("rest_framework_api_key", "APIKey")
UserAPIKey = apps.get_model("compte", "UserAPIKey")

migrated_ids = list(UserAPIKey.objects.values_list("id", flat=True))
APIKey.objects.filter(id__in=migrated_ids).update(revoked=False)
UserAPIKey.objects.filter(id__in=migrated_ids).delete()


class Migration(migrations.Migration):
dependencies = [
("compte", "0009_userapikey"),
("rest_framework_api_key", "0001_initial"),
]

operations = [
migrations.RunPython(migrate_legacy_keys, reverse_migration),
]
13 changes: 13 additions & 0 deletions compte/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as translate_lazy
from rest_framework_api_key.models import AbstractAPIKey


class EmailToken(models.Model):
Expand Down Expand Up @@ -65,3 +66,15 @@ def __str__(self) -> str:
f"for user #{self.user_id}: {self.nb_erp_created}/{self.nb_erp_edited}/{self.nb_erp_attributed}"
f"/{self.nb_profanities}"
)


class UserAPIKey(AbstractAPIKey):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="api_keys",
)

class Meta(AbstractAPIKey.Meta):
verbose_name = translate_lazy("Clef d'API par utilisateur")
verbose_name_plural = translate_lazy("Clefs d'API par utilisateur")
16 changes: 9 additions & 7 deletions core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,19 +173,22 @@


REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"api.authentication.UserAPIKeyAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 50,
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"api.throttling.FrontendOriginThrottle",
"rest_framework.throttling.UserRateThrottle",
"rest_framework.throttling.AnonRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "3/second",
"user": "3/second",
"frontend": "5000/hour",
"user": "10000/hour",
"anon": "20/hour",
},
"DEFAULT_PERMISSION_CLASSES": [
"api.permissions.IsAllowedForAction",
],
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"api.renderers.GeoJSONRenderer",
Expand All @@ -194,7 +197,6 @@
],
}

INTERNAL_API_KEY_NAME = "acceslibre - internal uses only"

ROOT_URLCONF = "core.urls"

Expand Down
Loading