From dc72789c142b88c419523e95b47231a706a02e12 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 20 May 2025 00:35:09 +0200 Subject: [PATCH 01/12] feat: basic api key management --- apikey/__init__.py | 0 apikey/admin.py | 55 +++++++++++++++ apikey/apps.py | 6 ++ apikey/auth.py | 22 ++++++ apikey/hashers.py | 42 +++++++++++ apikey/migrations/0001_initial.py | 113 ++++++++++++++++++++++++++++++ apikey/migrations/__init__.py | 0 apikey/models.py | 94 +++++++++++++++++++++++++ apikey/tests.py | 29 ++++++++ docs/reference/apikey/auth.md | 6 ++ docs/reference/apikey/hashers.md | 8 +++ docs/reference/apikey/models.md | 7 ++ locale/fr/LC_MESSAGES/django.po | 71 ++++++++++++++++--- locale/fr/LC_MESSAGES/djangojs.po | 10 +-- mkdocs.yml | 4 ++ sith/settings.py | 1 + 16 files changed, 455 insertions(+), 13 deletions(-) create mode 100644 apikey/__init__.py create mode 100644 apikey/admin.py create mode 100644 apikey/apps.py create mode 100644 apikey/auth.py create mode 100644 apikey/hashers.py create mode 100644 apikey/migrations/0001_initial.py create mode 100644 apikey/migrations/__init__.py create mode 100644 apikey/models.py create mode 100644 apikey/tests.py create mode 100644 docs/reference/apikey/auth.md create mode 100644 docs/reference/apikey/hashers.md create mode 100644 docs/reference/apikey/models.md diff --git a/apikey/__init__.py b/apikey/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apikey/admin.py b/apikey/admin.py new file mode 100644 index 00000000..ef6a247c --- /dev/null +++ b/apikey/admin.py @@ -0,0 +1,55 @@ +from django.contrib import admin, messages +from django.db.models import QuerySet +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ + +from apikey.hashers import generate_key +from apikey.models import ApiClient, ApiKey + + +@admin.register(ApiClient) +class ApiClientAdmin(admin.ModelAdmin): + list_display = ("name", "owner", "created_at", "updated_at") + search_fields = ( + "name", + "owner__first_name", + "owner__last_name", + "owner__nick_name", + ) + autocomplete_fields = ("owner", "groups", "client_permissions") + + +@admin.register(ApiKey) +class ApiKeyAdmin(admin.ModelAdmin): + list_display = ("name", "client", "created_at", "revoked") + list_filter = ("revoked",) + date_hierarchy = "created_at" + + readonly_fields = ("prefix", "hashed_key") + actions = ("revoke_keys",) + + def save_model(self, request: HttpRequest, obj: ApiKey, form, change): + if not change: + key, hashed = generate_key() + obj.prefix = key[: ApiKey.PREFIX_LENGTH] + obj.hashed_key = hashed + self.message_user( + request, + _( + "The API key for %(name)s is: %(key)s. " + "Please store it somewhere safe: " + "you will not be able to see it again." + ) + % {"name": obj.name, "key": key}, + level=messages.WARNING, + ) + return super().save_model(request, obj, form, change) + + def get_readonly_fields(self, request, obj: ApiKey | None = None): + if obj is None or obj.revoked: + return ["revoked", *self.readonly_fields] + return self.readonly_fields + + @admin.action(description=_("Revoke selected API keys")) + def revoke_keys(self, _request: HttpRequest, queryset: QuerySet[ApiKey]): + queryset.update(revoked=True) diff --git a/apikey/apps.py b/apikey/apps.py new file mode 100644 index 00000000..4ce409b2 --- /dev/null +++ b/apikey/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApikeyConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apikey" diff --git a/apikey/auth.py b/apikey/auth.py new file mode 100644 index 00000000..3d45a4eb --- /dev/null +++ b/apikey/auth.py @@ -0,0 +1,22 @@ +from django.http import HttpRequest +from ninja.security import APIKeyHeader + +from apikey.hashers import get_hasher +from apikey.models import ApiClient, ApiKey + +_UUID_LENGTH = 36 + + +class ApiKeyAuth(APIKeyHeader): + param_name = "X-APIKey" + + def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None: + if not key or len(key) != _UUID_LENGTH: + return None + hasher = get_hasher() + hashed_key = hasher.encode(key) + try: + key_obj = ApiKey.objects.get(hashed_key=hashed_key) + except ApiKey.DoesNotExist: + return None + return key_obj.client diff --git a/apikey/hashers.py b/apikey/hashers.py new file mode 100644 index 00000000..3a623177 --- /dev/null +++ b/apikey/hashers.py @@ -0,0 +1,42 @@ +import functools +import hashlib +import uuid + +from django.contrib.auth.hashers import BasePasswordHasher +from django.utils.crypto import constant_time_compare + + +class Sha256ApiKeyHasher(BasePasswordHasher): + """ + An API key hasher using the sha256 algorithm. + + This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting. + It is insecure for use in hashing passwords, but is safe for hashing + high entropy, randomly generated API keys. + """ + + algorithm = "sha256" + + def salt(self) -> str: + # No need for a salt on a high entropy key. + return "" + + def encode(self, password: str, salt: str = "") -> str: + hashed = hashlib.sha256(password.encode()).hexdigest() + return f"{self.algorithm}$${hashed}" + + def verify(self, password: str, encoded: str) -> bool: + encoded_2 = self.encode(password, "") + return constant_time_compare(encoded, encoded_2) + + +@functools.cache +def get_hasher(): + return Sha256ApiKeyHasher() + + +def generate_key() -> tuple[str, str]: + """Generate a [key, hash] couple.""" + key = str(uuid.uuid4()) + hasher = get_hasher() + return key, hasher.encode(key) diff --git a/apikey/migrations/0001_initial.py b/apikey/migrations/0001_initial.py new file mode 100644 index 00000000..cda9e9f6 --- /dev/null +++ b/apikey/migrations/0001_initial.py @@ -0,0 +1,113 @@ +# Generated by Django 5.2 on 2025-06-01 08:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0046_permissionrights"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ApiClient", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=64, verbose_name="name")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "client_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this api client.", + related_name="clients", + to="auth.permission", + verbose_name="client permissions", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + related_name="api_clients", + to="core.group", + verbose_name="groups", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_clients", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "api client", + "verbose_name_plural": "api clients", + }, + ), + migrations.CreateModel( + name="ApiKey", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(blank=True, default="", verbose_name="name")), + ( + "prefix", + models.CharField( + editable=False, max_length=5, verbose_name="prefix" + ), + ), + ( + "hashed_key", + models.CharField( + db_index=True, + editable=False, + max_length=150, + verbose_name="hashed key", + ), + ), + ("revoked", models.BooleanField(default=False, verbose_name="revoked")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "client", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_keys", + to="apikey.apiclient", + verbose_name="api client", + ), + ), + ], + options={ + "verbose_name": "api key", + "verbose_name_plural": "api keys", + "permissions": [("revoke_apikey", "Revoke API keys")], + }, + ), + ] diff --git a/apikey/migrations/__init__.py b/apikey/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apikey/models.py b/apikey/models.py new file mode 100644 index 00000000..b172bb7a --- /dev/null +++ b/apikey/models.py @@ -0,0 +1,94 @@ +from typing import Iterable + +from django.contrib.auth.models import Permission +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import pgettext_lazy + +from core.models import Group, User + + +class ApiClient(models.Model): + name = models.CharField(_("name"), max_length=64) + owner = models.ForeignKey( + User, + verbose_name=_("owner"), + related_name="api_clients", + on_delete=models.CASCADE, + ) + groups = models.ManyToManyField( + Group, + verbose_name=_("groups"), + related_name="api_clients", + ) + client_permissions = models.ManyToManyField( + Permission, + verbose_name=_("client permissions"), + blank=True, + help_text=_("Specific permissions for this api client."), + related_name="clients", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + _perm_cache: set[str] | None = None + + class Meta: + verbose_name = _("api client") + verbose_name_plural = _("api clients") + + def __str__(self): + return self.name + + def has_perm(self, perm: str): + """Return True if the client has the specified permission.""" + + if self._perm_cache is None: + group_permissions = ( + Permission.objects.filter(group__group__in=self.groups.all()) + .values_list("content_type__app_label", "codename") + .order_by() + ) + client_permissions = self.client_permissions.values_list( + "content_type__app_label", "codename" + ).order_by() + self._perm_cache = { + f"{content_type}.{name}" + for content_type, name in (*group_permissions, *client_permissions) + } + return perm in self._perm_cache + + def has_perms(self, perm_list): + """ + Return True if the client has each of the specified permissions. If + object is passed, check if the client has all required perms for it. + """ + if not isinstance(perm_list, Iterable) or isinstance(perm_list, str): + raise ValueError("perm_list must be an iterable of permissions.") + return all(self.has_perm(perm) for perm in perm_list) + + +class ApiKey(models.Model): + PREFIX_LENGTH = 5 + + name = models.CharField(_("name"), blank=True, default="") + prefix = models.CharField(_("prefix"), max_length=PREFIX_LENGTH, editable=False) + hashed_key = models.CharField( + _("hashed key"), max_length=150, db_index=True, editable=False + ) + client = models.ForeignKey( + ApiClient, + verbose_name=_("api client"), + related_name="api_keys", + on_delete=models.CASCADE, + ) + revoked = models.BooleanField(pgettext_lazy("api key", "revoked"), default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = _("api key") + verbose_name_plural = _("api keys") + permissions = [("revoke_apikey", "Revoke API keys")] + + def __str__(self): + return f"{self.name} ({self.prefix}***)" diff --git a/apikey/tests.py b/apikey/tests.py new file mode 100644 index 00000000..a971af2d --- /dev/null +++ b/apikey/tests.py @@ -0,0 +1,29 @@ +import pytest +from django.test import RequestFactory +from model_bakery import baker + +from apikey.auth import ApiKeyAuth +from apikey.hashers import generate_key +from apikey.models import ApiClient, ApiKey + + +@pytest.mark.django_db +def test_api_key_auth(): + key, hashed = generate_key() + client = baker.make(ApiClient) + baker.make(ApiKey, client=client, hashed_key=hashed) + auth = ApiKeyAuth() + + assert auth.authenticate(RequestFactory().get(""), key) == client + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("key", "hashed"), [(generate_key()[0], generate_key()[1]), (generate_key()[0], "")] +) +def test_api_key_auth_invalid(key, hashed): + client = baker.make(ApiClient) + baker.make(ApiKey, client=client, hashed_key=hashed) + auth = ApiKeyAuth() + + assert auth.authenticate(RequestFactory().get(""), key) is None diff --git a/docs/reference/apikey/auth.md b/docs/reference/apikey/auth.md new file mode 100644 index 00000000..f6261037 --- /dev/null +++ b/docs/reference/apikey/auth.md @@ -0,0 +1,6 @@ +::: apikey.auth + handler: python + options: + heading_level: 3 + members: + - ApiKeyAuth \ No newline at end of file diff --git a/docs/reference/apikey/hashers.md b/docs/reference/apikey/hashers.md new file mode 100644 index 00000000..eb728802 --- /dev/null +++ b/docs/reference/apikey/hashers.md @@ -0,0 +1,8 @@ +::: apikey.hashers + handler: python + options: + heading_level: 3 + members: + - Sha256ApiKeyHasher + - get_hasher + - generate_key \ No newline at end of file diff --git a/docs/reference/apikey/models.md b/docs/reference/apikey/models.md new file mode 100644 index 00000000..52da58df --- /dev/null +++ b/docs/reference/apikey/models.md @@ -0,0 +1,7 @@ +::: apikey.auth + handler: python + options: + heading_level: 3 + members: + - ApiKey + - ApiClient \ No newline at end of file diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index c7c4d34f..cd399bfd 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -35,6 +35,69 @@ msgstr "" "True si gardé à jour par le biais d'un fournisseur externe de domains " "toxics, False sinon" +#: apikey/admin.py +#, python-format +msgid "" +"The API key for %(name)s is: %(key)s. Please store it somewhere safe: you " +"will not be able to see it again." +msgstr "" +"La clef d'API pour %(name)s est : %(key)s. Gardez-là dans un emplacement " +"sûr : vous ne pourrez pas la revoir à nouveau." + +#: apikey/admin.py +msgid "Revoke selected API keys" +msgstr "Révoquer les clefs d'API sélectionnées" + +#: apikey/models.py club/models.py com/models.py counter/models.py +#: forum/models.py launderette/models.py +msgid "name" +msgstr "nom" + +#: apikey/models.py core/models.py +msgid "owner" +msgstr "propriétaire" + +#: apikey/models.py core/models.py +msgid "groups" +msgstr "groupes" + +#: apikey/models.py +msgid "client permissions" +msgstr "permissions du client" + +#: apikey/models.py +msgid "Specific permissions for this api client." +msgstr "Permissions spécifiques pour ce client d'API" + +#: apikey/models.py +msgid "api client" +msgstr "client d'api" + +#: apikey/models.py +msgid "api clients" +msgstr "clients d'api" + +#: apikey/models.py +msgid "prefix" +msgstr "préfixe" + +#: apikey/models.py +msgid "hashed key" +msgstr "hash de la clef" + +#: apikey/models.py +msgctxt "api key" +msgid "revoked" +msgstr "révoquée" + +#: apikey/models.py +msgid "api key" +msgstr "clef d'api" + +#: apikey/models.py +msgid "api keys" +msgstr "clefs d'api" + #: club/forms.py msgid "Users to add" msgstr "Utilisateurs à ajouter" @@ -1257,10 +1320,6 @@ msgstr "surnom" msgid "last update" msgstr "dernière mise à jour" -#: core/models.py -msgid "groups" -msgstr "groupes" - #: core/models.py msgid "" "The groups this user belongs to. A user will get all permissions granted to " @@ -1497,10 +1556,6 @@ msgstr "version allégée" msgid "thumbnail" msgstr "miniature" -#: core/models.py -msgid "owner" -msgstr "propriétaire" - #: core/models.py msgid "edit group" msgstr "groupe d'édition" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index a6e211d0..bcb6d1de 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-13 00:18+0200\n" +"POT-Creation-Date: 2025-05-18 12:17+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -37,14 +37,14 @@ msgstr "Supprimer" msgid "Copy calendar link" msgstr "Copier le lien du calendrier" -#: com/static/bundled/com/components/ics-calendar-index.ts -msgid "How to use calendar link" -msgstr "Comment utiliser le lien du calendrier" - #: com/static/bundled/com/components/ics-calendar-index.ts msgid "Link copied" msgstr "Lien copié" +#: com/static/bundled/com/components/ics-calendar-index.ts +msgid "How to use calendar link" +msgstr "Comment utiliser le lien du calendrier" + #: com/static/bundled/com/moderation-alert-index.ts #, javascript-format msgid "" diff --git a/mkdocs.yml b/mkdocs.yml index a89bbeea..7201fd9d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -84,6 +84,10 @@ nav: - antispam: - reference/antispam/models.md - reference/antispam/forms.md + - apikey: + - reference/apikey/auth.md + - reference/apikey/hashers.md + - reference/apikey/models.md - club: - reference/club/models.md - reference/club/views.md diff --git a/sith/settings.py b/sith/settings.py index 5a7e8e1b..759161e2 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -124,6 +124,7 @@ INSTALLED_APPS = ( "pedagogy", "galaxy", "antispam", + "apikey", ) MIDDLEWARE = ( From 853aa34c180cf1905735c71e58be84c673449655 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 20 May 2025 18:13:44 +0200 Subject: [PATCH 02/12] adapt pedagogy api to api key auth --- pedagogy/api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pedagogy/api.py b/pedagogy/api.py index 9ad0c3f6..b1944ce4 100644 --- a/pedagogy/api.py +++ b/pedagogy/api.py @@ -3,10 +3,12 @@ from typing import Annotated from annotated_types import Ge from ninja import Query +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema +from apikey.auth import ApiKeyAuth from core.auth.api_permissions import HasPerm from pedagogy.models import UV from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema @@ -17,6 +19,7 @@ from pedagogy.utbm_api import UtbmApiClient class UvController(ControllerBase): @route.get( "/{code}", + auth=[SessionAuth(), ApiKeyAuth()], permissions=[ # this route will almost always be called in the context # of a UV creation/edition @@ -42,6 +45,7 @@ class UvController(ControllerBase): "", response=PaginatedResponseSchema[SimpleUvSchema], url_name="fetch_uvs", + auth=[SessionAuth(), ApiKeyAuth()], permissions=[HasPerm("pedagogy.view_uv")], ) @paginate(PageNumberPaginationExtra, page_size=100) From 1d55a5c2dab4d040dc6545a5677afd0beb2efed4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 20 May 2025 18:16:14 +0200 Subject: [PATCH 03/12] Make HasPerm work with ApiKeyAuth --- core/auth/api_permissions.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/core/auth/api_permissions.py b/core/auth/api_permissions.py index 6a28f13c..3d18529e 100644 --- a/core/auth/api_permissions.py +++ b/core/auth/api_permissions.py @@ -96,7 +96,16 @@ class HasPerm(BasePermission): self._perms = perms def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: - return reduce(self._operator, (request.user.has_perm(p) for p in self._perms)) + # if the request has the `auth` property, + # it means that the user has been explicitly authenticated + # using a django-ninja authentication backend + # (whether it is SessionAuth or ApiKeyAuth). + # If not, this authentication has not been done, but the user may + # still be implicitly authenticated through AuthenticationMiddleware + user = request.auth if hasattr(request, "auth") else request.user + # `user` may either be a `core.User` or an `apikey.ApiClient` ; + # they are not the same model, but they both implement the `has_perm` method + return reduce(self._operator, (user.has_perm(p) for p in self._perms)) class IsRoot(BasePermission): From 44e1902693646c32f2e9393c31157012d916b228 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 20 May 2025 18:17:48 +0200 Subject: [PATCH 04/12] Add `GET /api/club/{club_id}` to fetch details about a club --- club/api.py | 15 ++++++++++++++- club/schemas.py | 21 +++++++++++++++++++-- club/tests/test_club_controller.py | 16 ++++++++++++++++ club/widgets/ajax_select.py | 6 +++--- counter/schemas.py | 4 ++-- sith/urls.py | 4 +--- 6 files changed, 55 insertions(+), 11 deletions(-) create mode 100644 club/tests/test_club_controller.py diff --git a/club/api.py b/club/api.py index 2ad0f5c8..147f6379 100644 --- a/club/api.py +++ b/club/api.py @@ -1,13 +1,15 @@ from typing import Annotated from annotated_types import MinLen +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema +from apikey.auth import ApiKeyAuth from club.models import Club from club.schemas import ClubSchema -from core.auth.api_permissions import CanAccessLookup +from core.auth.api_permissions import CanAccessLookup, HasPerm @api_controller("/club") @@ -20,3 +22,14 @@ class ClubController(ControllerBase): @paginate(PageNumberPaginationExtra, page_size=50) def search_club(self, search: Annotated[str, MinLen(1)]): return Club.objects.filter(name__icontains=search).values() + + @route.get( + "/{int:club_id}", + response=ClubSchema, + auth=[SessionAuth(), ApiKeyAuth()], + permissions=[HasPerm("club.view_club")], + ) + def fetch_club(self, club_id: int): + return self.get_object_or_exception( + Club.objects.prefetch_related("members", "members__user"), id=club_id + ) diff --git a/club/schemas.py b/club/schemas.py index 7969f119..b0601af8 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -1,9 +1,10 @@ from ninja import ModelSchema -from club.models import Club +from club.models import Club, Membership +from core.schemas import SimpleUserSchema -class ClubSchema(ModelSchema): +class SimpleClubSchema(ModelSchema): class Meta: model = Club fields = ["id", "name"] @@ -21,3 +22,19 @@ class ClubProfileSchema(ModelSchema): @staticmethod def resolve_url(obj: Club) -> str: return obj.get_absolute_url() + + +class ClubMemberSchema(ModelSchema): + class Meta: + model = Membership + fields = ["start_date", "end_date", "role", "description"] + + user: SimpleUserSchema + + +class ClubSchema(ModelSchema): + class Meta: + model = Club + fields = ["id", "name", "logo", "is_active", "short_description", "address"] + + members: list[ClubMemberSchema] diff --git a/club/tests/test_club_controller.py b/club/tests/test_club_controller.py new file mode 100644 index 00000000..e48a4513 --- /dev/null +++ b/club/tests/test_club_controller.py @@ -0,0 +1,16 @@ +import pytest +from model_bakery import baker +from ninja_extra.testing import TestClient +from pytest_django.asserts import assertNumQueries + +from club.api import ClubController +from club.models import Club, Membership + + +@pytest.mark.django_db +def test_fetch_club(): + club = baker.make(Club) + baker.make(Membership, club=club, _quantity=10, _bulk_create=True) + with assertNumQueries(3): + res = TestClient(ClubController).get(f"/{club.id}") + assert res.status_code == 200 diff --git a/club/widgets/ajax_select.py b/club/widgets/ajax_select.py index 36ad3e9a..ddcc820f 100644 --- a/club/widgets/ajax_select.py +++ b/club/widgets/ajax_select.py @@ -1,7 +1,7 @@ from pydantic import TypeAdapter from club.models import Club -from club.schemas import ClubSchema +from club.schemas import SimpleClubSchema from core.views.widgets.ajax_select import ( AutoCompleteSelect, AutoCompleteSelectMultiple, @@ -13,7 +13,7 @@ _js = ["bundled/club/components/ajax-select-index.ts"] class AutoCompleteSelectClub(AutoCompleteSelect): component_name = "club-ajax-select" model = Club - adapter = TypeAdapter(list[ClubSchema]) + adapter = TypeAdapter(list[SimpleClubSchema]) js = _js @@ -21,6 +21,6 @@ class AutoCompleteSelectClub(AutoCompleteSelect): class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple): component_name = "club-ajax-select" model = Club - adapter = TypeAdapter(list[ClubSchema]) + adapter = TypeAdapter(list[SimpleClubSchema]) js = _js diff --git a/counter/schemas.py b/counter/schemas.py index 978422a5..6ee9f8b1 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -5,7 +5,7 @@ from django.urls import reverse from ninja import Field, FilterSchema, ModelSchema, Schema from pydantic import model_validator -from club.schemas import ClubSchema +from club.schemas import SimpleClubSchema from core.schemas import GroupSchema, SimpleUserSchema from counter.models import Counter, Product, ProductType @@ -82,7 +82,7 @@ class ProductSchema(ModelSchema): ] buying_groups: list[GroupSchema] - club: ClubSchema + club: SimpleClubSchema product_type: SimpleProductTypeSchema | None url: str diff --git a/sith/urls.py b/sith/urls.py index 98608e14..fb3643d9 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -12,7 +12,6 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # - from django.conf import settings from django.conf.urls.static import static from django.contrib import admin @@ -26,8 +25,7 @@ js_info_dict = {"packages": ("sith",)} handler403 = "core.views.forbidden" handler404 = "core.views.not_found" handler500 = "core.views.internal_servor_error" - -api = NinjaExtraAPI(version="0.2.0", urls_namespace="api", csrf=True) +api = NinjaExtraAPI(title="Sith API", version="0.2.0", urls_namespace="api", csrf=True) api.auto_discover_controllers() urlpatterns = [ From b5d65133f3bc1beb2b18791252de0e09ca5cf00a Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 20 May 2025 18:18:36 +0200 Subject: [PATCH 05/12] add doc for external API consumers --- docs/img/api_key_authorize_1.png | Bin 0 -> 33518 bytes docs/img/api_key_authorize_2.png | Bin 0 -> 50366 bytes docs/tutorial/connect-api.md | 215 +++++++++++++++++++++++++++++++ mkdocs.yml | 3 +- 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 docs/img/api_key_authorize_1.png create mode 100644 docs/img/api_key_authorize_2.png create mode 100644 docs/tutorial/connect-api.md diff --git a/docs/img/api_key_authorize_1.png b/docs/img/api_key_authorize_1.png new file mode 100644 index 0000000000000000000000000000000000000000..b01d2e6674f75193c35b99c61ea6ab60def163d5 GIT binary patch literal 33518 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 Z?)mV0@b`xqcYj$jWh52fmx~+v{~r)u^X32m literal 0 HcmV?d00001 diff --git a/docs/img/api_key_authorize_2.png b/docs/img/api_key_authorize_2.png new file mode 100644 index 0000000000000000000000000000000000000000..9d562328ed2e95eda02ae5d3830e3d4d8db26862 GIT binary patch literal 50366 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�^>R#o2IUk)cdu8@)(3j{}5 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 literal 0 HcmV?d00001 diff --git a/docs/tutorial/connect-api.md b/docs/tutorial/connect-api.md new file mode 100644 index 00000000..72c01a85 --- /dev/null +++ b/docs/tutorial/connect-api.md @@ -0,0 +1,215 @@ +La connexion à l'API du site AE peut se faire par deux moyens : + +- par le cookie de session du site ; si vous accédez à l'API depuis le sith + en étant connecté, cette méthode fonctionne par défaut +- par clef d'API ; si vous accédez à l'API depuis une application externe, + vous devez passer par cette méthode. + +Comme la méthode par cookie de session ne devrait pas être utilisée +en dehors du cadre interne au site et qu'elle marche par défaut +dans le cadre de ce dernier, nous ne décrirons pas outre mesure la manière +de l'utiliser. + +## Obtenir une clef d'API + +Il n'y a, à l'heure actuelle, pas d'interface accessible sur le site +pour obtenir une clef d'API. +Si vous désirez en obtenir une, demandez directement au respo info. + +!!!danger + + Votre clef d'API doit rester secrète. + Ne la transmettez à personne, ne l'inscrivez pas en dur dans votre code. + + Si votre clef a fuité, ou que vous soupçonnez qu'elle ait pu fuiter, + informez-en immédiatement l'équipe informatique ! + +## L'interface Swagger + +Avant de commencer à utiliser l'API du site, vous pouvez explorer +les différentes routes qu'elle met à disposition, +avec les schémas de données attendus en requête et en réponse. + +Pour cela, vous pouvez vous rendre sur +[https://ae.utbm.fr/api/docs](https://ae.utbm.fr/api/docs). + +Toutes les routes, à de rares exceptions près, y sont recensées. +Vous pouvez les utiliser dans les limites +de ce à quoi vos permissions vous donnent droit +et de la méthode d'authentification. + +Vous pouvez vous connecter directement sur l'interface Swagger, +en cliquant sur ce bouton, en haut à droite : + +![Swagger auth (1)](../img/api_key_authorize_1.png) +/// caption +Bouton d'autorisation sur Swagger +/// + +Puis rentrez votre clef d'API dans le champ prévu à cet effet, +et cliquez sur authorize : + + +![Swagger auth (2)](../img/api_key_authorize_2.png) +/// caption +Saisie de la clef d'API +/// + +Les routes accessibles avec une clef d'API seront alors marquées par +une icône de cadenas fermé, sur la droite. + +!!!warning "Authentification et permissions" + + L'icône de cadenas signifie que la route accepte l'authentification + basée sur les clefs d'API, mais pas forcément que vous avez les + permissions nécessaires. + + Si une route vous renvoie une erreur 403, + référez-en à l'équipe info, pour qu'elle puisse vous donner + les permissions nécessaires. + +## Utiliser la clef d'API + +### `X-APIKey` + +Maintenant que vous avez la clef d'API, +il faut l'utiliser pour authentifier votre application +lorsqu'elle effectue des requêtes au site. + +Pour cela, vous devez le fournir dans vos requêtes +à travers le header `X-APIKey`. + +Par exemple : + +```shell +curl "https://ae.utbm.fr/api/club/1" \ + -H "X-APIKey: " +``` + +Comme votre clef d'API doit rester absolument secrète, +vous ne devez en aucun cas la mettre dans votre code. +À la place, vous pouvez créer un fichier (par exemple, un `.env`) +qui contiendra votre clef et qui sera gitignoré. + +```dotenv title=".env" +API_KEY="" +``` + +Vous fournirez alors la clef d'API en la chargeant depuis votre environnement. +Notez que c'est une bonne pratique à double-titre, +puisque vous pouvez ainsi aisément changer votre clef d'API. + +### Connexion persistante + +La plupart des librairies permettant d'effectuer des requêtes +HTTP incluent une prise en charge des sessions persistantes. +Nous vous recommandons fortement d'utiliser ces fonctionnalités, +puisqu'elles permettent de rendre votre code plus simple +(vous n'aurez à renseigner votre clef d'API qu'une seule fois) +et plus efficace (réutiliser la même connexion plutôt que d'en créer +une nouvelle à chaque requête peut résulter en un gain de performance significatif ; +cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_persistent_connection)) + +Voici quelques exemples : + +=== "Python (requests)" + + Dépendances : + + - `requests` (>=2.32) + - `environs` (>=14.1) + + ```python + import requests + from environs import Env + + env = Env() + env.read_env() + + with requests.Session() as session: + session.headers["X-APIKey"] = env.str("API_KEY") + response = session.get("https://ae.utbm.fr/api/club/1") + print(response.json()) + ``` + +=== "Python (aiohttp)" + + Dépendances : + + - `aiohttp` (>=3.11) + - `environs` (>=14.1) + + ```python + import aiohttp + import asyncio + from environs import Env + + env = Env() + env.read_env() + + async def main(): + async with aiohttp.ClientSession( + base_url="https://ae.utbm.fr/api/", + headers={"X-APIKey": env.str("API_KEY")} + ) as session: + async with session.get("club/1") as res: + print(await res.json()) + + asyncio.run(main()) + ``` + +=== "Javascript (axios)" + + Dépendances : + + - `axios` (>=1.9) + - `dotenv` (>=16.5) + + ```javascript + import { axios } from "axios"; + import { config } from "dotenv"; + + config(); + + const instance = axios.create({ + baseUrl: "https://ae.utbm.fr/api/", + headers: { "X-APIKey": process.env.API_KEY } + }); + console.log(await instance.get("club/1").json()); + ``` + +=== "Rust (reqwest)" + + Dépendances : + + - `reqwest` (>= 0.12, features `json` et `gzip`) + - `tokio` (>= 1.44, feature `derive`) + - `dotenvy` (>= 0.15) + + ```rust + use reqwest::Client; + use reqwest::header::{HeaderMap, HeaderValue}; + use dotenvy::EnvLoader; + + + #[tokio::main] + async fn main() -> Result<(), Box> { + let env = EnvLoader::new().load()?; + let mut headers = HeaderMap::new(); + let mut api_key = HeaderValue::from_str(env.var("API_KEY")?.as_str()); + api_key.set_sensitive(true); + headers.insert("X-APIKey", api_key); + let client = Client::builder() + .default_headers(headers) + .gzip(true) + .build()?; + let resp = client + .get("https://ae.utbm.fr/api/club/1") + .send() + .await? + .json() + .await?; + println!("{resp:#?}"); + Ok(()) + } + ``` diff --git a/mkdocs.yml b/mkdocs.yml index 7201fd9d..7ad3da91 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,7 +45,6 @@ plugins: members: true members_order: source show_source: true - show_inherited_members: true merge_init_into_class: true show_root_toc_entry: false - include-markdown: @@ -67,6 +66,7 @@ nav: - Gestion des permissions: tutorial/perms.md - Gestion des groupes: tutorial/groups.md - Les fragments: tutorial/fragments.md + - Connexion à l'API: tutorial/connect-api.md - Etransactions: tutorial/etransaction.md - How-to: - L'ORM de Django: howto/querysets.md @@ -157,6 +157,7 @@ markdown_extensions: - pymdownx.details - pymdownx.inlinehilite - pymdownx.keys + - pymdownx.blocks.caption - pymdownx.superfences: custom_fences: - name: mermaid From 52e53da9ef1059b4678978494d82d0c89d583e63 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 20 May 2025 21:04:49 +0200 Subject: [PATCH 06/12] adapt `CanAccessLookup` to api key auth --- club/api.py | 7 ++++-- club/tests/test_club_controller.py | 15 ++++++++----- core/api.py | 4 ++++ core/auth/api_permissions.py | 2 +- core/management/commands/populate.py | 2 ++ core/migrations/0046_permissionrights.py | 28 ++++++++++++++++++++++++ core/models.py | 17 ++++++++++++++ counter/api.py | 4 ++++ pedagogy/tests/test_api.py | 2 +- sas/api.py | 3 +++ 10 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 core/migrations/0046_permissionrights.py diff --git a/club/api.py b/club/api.py index 147f6379..decdf8f8 100644 --- a/club/api.py +++ b/club/api.py @@ -8,7 +8,7 @@ from ninja_extra.schemas import PaginatedResponseSchema from apikey.auth import ApiKeyAuth from club.models import Club -from club.schemas import ClubSchema +from club.schemas import ClubSchema, SimpleClubSchema from core.auth.api_permissions import CanAccessLookup, HasPerm @@ -16,8 +16,10 @@ from core.auth.api_permissions import CanAccessLookup, HasPerm class ClubController(ControllerBase): @route.get( "/search", - response=PaginatedResponseSchema[ClubSchema], + response=PaginatedResponseSchema[SimpleClubSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], + url_name="search_club", ) @paginate(PageNumberPaginationExtra, page_size=50) def search_club(self, search: Annotated[str, MinLen(1)]): @@ -28,6 +30,7 @@ class ClubController(ControllerBase): response=ClubSchema, auth=[SessionAuth(), ApiKeyAuth()], permissions=[HasPerm("club.view_club")], + url_name="fetch_club", ) def fetch_club(self, club_id: int): return self.get_object_or_exception( diff --git a/club/tests/test_club_controller.py b/club/tests/test_club_controller.py index e48a4513..ade8eb4d 100644 --- a/club/tests/test_club_controller.py +++ b/club/tests/test_club_controller.py @@ -1,16 +1,21 @@ import pytest +from django.test import Client +from django.urls import reverse from model_bakery import baker -from ninja_extra.testing import TestClient from pytest_django.asserts import assertNumQueries -from club.api import ClubController from club.models import Club, Membership +from core.baker_recipes import subscriber_user @pytest.mark.django_db -def test_fetch_club(): +def test_fetch_club(client: Client): club = baker.make(Club) baker.make(Membership, club=club, _quantity=10, _bulk_create=True) - with assertNumQueries(3): - res = TestClient(ClubController).get(f"/{club.id}") + user = subscriber_user.make() + client.force_login(user) + with assertNumQueries(7): + # - 4 queries for authentication + # - 3 queries for the actual data + res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id})) assert res.status_code == 200 diff --git a/core/api.py b/core/api.py index 830e06e9..bd74875b 100644 --- a/core/api.py +++ b/core/api.py @@ -5,11 +5,13 @@ from django.conf import settings from django.db.models import F from django.http import HttpResponse from ninja import File, Query +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema +from apikey.auth import ApiKeyAuth from club.models import Mailing from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm from core.models import Group, QuickUploadImage, SithFile, User @@ -90,6 +92,7 @@ class SithFileController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[SithFileSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) @@ -102,6 +105,7 @@ class GroupController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[GroupSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) diff --git a/core/auth/api_permissions.py b/core/auth/api_permissions.py index 3d18529e..73f9fa84 100644 --- a/core/auth/api_permissions.py +++ b/core/auth/api_permissions.py @@ -189,4 +189,4 @@ class IsLoggedInCounter(BasePermission): return Counter.objects.filter(token=token).exists() -CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter +CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup") diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index ea1f0342..8f101d9f 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -805,6 +805,8 @@ class Command(BaseCommand): "add_peoplepicturerelation", "add_page", "add_quickuploadimage", + "view_club", + "access_lookup", ] ) ) diff --git a/core/migrations/0046_permissionrights.py b/core/migrations/0046_permissionrights.py new file mode 100644 index 00000000..33df716b --- /dev/null +++ b/core/migrations/0046_permissionrights.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2 on 2025-05-20 17:50 +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("core", "0045_quickuploadimage")] + + operations = [ + migrations.CreateModel( + name="GlobalPermissionRights", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + options={ + "permissions": [("access_lookup", "Can access any lookup in the sith")], + "managed": False, + "default_permissions": [], + }, + ), + ] diff --git a/core/models.py b/core/models.py index 0a7a5e37..23b863f6 100644 --- a/core/models.py +++ b/core/models.py @@ -754,6 +754,23 @@ class UserBan(models.Model): return f"Ban of user {self.user.id}" +class GlobalPermissionRights(models.Model): + """Little hack to have permissions not linked to a specific db table.""" + + class Meta: + # No database table creation or deletion + # operations will be performed for this model. + managed = False + + # disable "add", "change", "delete" and "view" default permissions + default_permissions = [] + + permissions = [("access_lookup", "Can access any lookup in the sith")] + + def __str__(self): + return self.__class__.__name__ + + class Preferences(models.Model): user = models.OneToOneField( User, related_name="_preferences", on_delete=models.CASCADE diff --git a/counter/api.py b/counter/api.py index 44b58488..11bf56c9 100644 --- a/counter/api.py +++ b/counter/api.py @@ -16,10 +16,12 @@ from django.conf import settings from django.db.models import F from django.shortcuts import get_object_or_404 from ninja import Query +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema +from apikey.auth import ApiKeyAuth from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from counter.models import Counter, Product, ProductType from counter.schemas import ( @@ -62,6 +64,7 @@ class CounterController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[SimplifiedCounterSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) @@ -74,6 +77,7 @@ class ProductController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[SimpleProductSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) diff --git a/pedagogy/tests/test_api.py b/pedagogy/tests/test_api.py index cbb99c18..a95acf20 100644 --- a/pedagogy/tests/test_api.py +++ b/pedagogy/tests/test_api.py @@ -68,7 +68,7 @@ class TestUVSearch(TestCase): def test_permissions(self): # Test with anonymous user response = self.client.get(self.url) - assert response.status_code == 403 + assert response.status_code == 401 # Test with not subscribed user self.client.force_login(baker.make(User)) diff --git a/sas/api.py b/sas/api.py index b82ff5e1..83d7f0c8 100644 --- a/sas/api.py +++ b/sas/api.py @@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError from django.db.models import F from django.urls import reverse from ninja import Body, File, Query +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra @@ -12,6 +13,7 @@ from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema from pydantic import NonNegativeInt +from apikey.auth import ApiKeyAuth from core.auth.api_permissions import ( CanAccessLookup, CanEdit, @@ -53,6 +55,7 @@ class AlbumController(ControllerBase): @route.get( "/autocomplete-search", response=PaginatedResponseSchema[AlbumAutocompleteSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) From 189081f5a82021f839d318417a941309aa7c97fc Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 20 May 2025 21:25:43 +0200 Subject: [PATCH 07/12] api key doc for developers --- .../{connect-api.md => api/connect.md} | 4 +- docs/tutorial/api/dev.md | 132 ++++++++++++++++++ mkdocs.yml | 4 +- 3 files changed, 137 insertions(+), 3 deletions(-) rename docs/tutorial/{connect-api.md => api/connect.md} (98%) create mode 100644 docs/tutorial/api/dev.md diff --git a/docs/tutorial/connect-api.md b/docs/tutorial/api/connect.md similarity index 98% rename from docs/tutorial/connect-api.md rename to docs/tutorial/api/connect.md index 72c01a85..8ce52bdd 100644 --- a/docs/tutorial/connect-api.md +++ b/docs/tutorial/api/connect.md @@ -41,7 +41,7 @@ et de la méthode d'authentification. Vous pouvez vous connecter directement sur l'interface Swagger, en cliquant sur ce bouton, en haut à droite : -![Swagger auth (1)](../img/api_key_authorize_1.png) +![Swagger auth (1)](../../img/api_key_authorize_1.png) /// caption Bouton d'autorisation sur Swagger /// @@ -50,7 +50,7 @@ Puis rentrez votre clef d'API dans le champ prévu à cet effet, et cliquez sur authorize : -![Swagger auth (2)](../img/api_key_authorize_2.png) +![Swagger auth (2)](../../img/api_key_authorize_2.png) /// caption Saisie de la clef d'API /// diff --git a/docs/tutorial/api/dev.md b/docs/tutorial/api/dev.md new file mode 100644 index 00000000..200b0730 --- /dev/null +++ b/docs/tutorial/api/dev.md @@ -0,0 +1,132 @@ + +Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`. +Ce sont des librairies relativement simples et qui présentent +l'immense avantage d'offrir des mécanismes de validation et de sérialisation +de données à la fois simples et expressifs. + +## Schéma de données + +Le cœur de django-ninja étant sa validation de données grâce à Pydantic, +le développement de l'API commence par l'écriture de ses schémas de données. + +Pour en comprendre le fonctionnement, veuillez consulter +[la doc de django-ninja](https://django-ninja.dev/guides/response/). + +Il est également important de consulter +[la doc de pydantic](https://docs.pydantic.dev/latest/). + +Notre surcouche par-dessus les schémas de django-ninja est relativement mince. +Elle ne comprend que [UploadedImage][core.schemas.UploadedImage], qui hérite de +[`UploadedFile`](https://django-ninja.dev/guides/input/file-params/?h=upl) +pour le restreindre uniquement aux images. + +## Authentification et permissions + +### Authentification + +Notre API offre deux moyens d'authentification : + +- par cookie de session (la méthode par défaut de django) +- par clef d'API + +La plus grande partie des routes de l'API utilisent la méthode par cookie de session. + +Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux), +utilisez l'attribut `auth` et les classes `SessionAuth` et +[`ApiKeyAuth`][apikey.auth.ApiKeyAuth]. + +!!!example + + ```python + @api_controller("/foo") + class FooController(ControllerBase): + # Cette route sera accessible uniquement avec l'authentification + # par cookie de session + @route.get("", auth=[SessionAuth()]) + def fetch_foo(self, club_id: int): ... + + # Et celle-ci sera accessible peut importe la méthode d'authentification + @route.get("/bar", auth=[SessionAuth(), ApiKeyAuth()]) + def fetch_bar(self, club_id: int): ... + ``` + +### Permissions + +Si l'utilisateur est connecté, ça ne veut pas dire pour autant qu'il a accès à tout. +Une fois qu'il est authentifié, il faut donc vérifier ses permissions. + +Pour cela, nous utilisons une surcouche +par-dessus `django-ninja`, le système de permissions de django +et notre propre système. +Cette dernière est documentée [ici](../perms.md). + +### Limites des clefs d'API + +Le système des clefs d'API est apparu très tard dans l'histoire du site +(en P25, 10 ans après le début du développement). +Il s'agit ni plus ni moins qu'un système d'authentification parallèle fait maison, +devant interagir avec un système de permissions ayant connu lui-même +une histoire assez chaotique. + +Assez logiquement, on ne peut pas tout faire : +il n'est pas possible que toutes les routes acceptent +l'authentification par clef d'API. + +Cette impossibilité provient majoritairement d'une incompatibilité +entre cette méthode d'authentification et le système de permissions +(qui n'a pas été prévu pour l'implémentation d'un client d'API). +Les principaux points de friction sont : + +- `CanView` et `CanEdit`, qui se basent `User.can_view` et `User.can_edit`, + qui peuvent eux-mêmes se baser sur les méthodes `can_be_viewed_by` + et `can_be_edited_by` des différents modèles. + Or, ces dernières testent spécifiquement la relation entre l'objet et un `User`. + Ce comportement est possiblement changeable, mais au prix d'un certain travail + et au risque de transformer encore plus notre système de permissions + en usine à gaz. +- `IsSubscriber` et `OldSubscriber`, qui vérifient qu'un utilisateur est ou + a été cotisant. + Or, une clef d'API est liée à un client d'API, pas à un utilisateur. + Par définition, un client d'API ne peut pas être cotisant. +- `IsLoggedInCounter`, qui utilise encore un autre système + d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site. + +## Créer un client et une clef d'API + +Le site n'a actuellement pas d'interface permettant à ses utilisateurs +de créer une application et des clefs d'API. + +C'est volontaire : tant que le système ne sera pas suffisamment mature, +toute attribution de clef d'API doit passer par le pôle info. + +Cette opération se fait au travers de l'interface admin. + +Pour commencer, créez un client d'API, en renseignant son nom, +son propriétaire (l'utilisateur qui vous a demandé de le créer) +et les groupes qui lui sont attribués. +Ces groupes sont les mêmes que ceux qui sont attribués aux utilisateurs, +ce qui permet de réutiliser une partie du système d'authentification. + +!!!warning + + N'attribuez pas les groupes "anciens cotisants" et "cotisants" + aux clients d'API. + Un client d'API géré comme un cotisant, ça n'a aucun sens. + + Evitez également de donner à des clients d'API des droits + autres que ceux de lecture sur le site. + + Et surtout, n'attribuez jamais le group Root à un client d'API. + +Une fois le client d'API créé, créez-lui une clef d'API. +Renseignez uniquement son nom et le client d'API auquel elle est lié. +La valeur de cette clef d'API est automatiquement générée +et affichée en haut de la page une fois la création complétée. + +Notez bien la valeur de la clef d'API et transmettez-la à la personne +qui en a besoin. +Dites-lui bien de garder cette clef en lieu sûr ! +Si la clef est perdue, il n'y a pas moyen de la récupérer, +vous devrez en recréer une. + + diff --git a/mkdocs.yml b/mkdocs.yml index 7ad3da91..992cc38e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,7 +66,9 @@ nav: - Gestion des permissions: tutorial/perms.md - Gestion des groupes: tutorial/groups.md - Les fragments: tutorial/fragments.md - - Connexion à l'API: tutorial/connect-api.md + - API: + - Développement: tutorial/api/dev.md + - Connexion à l'API: tutorial/api/connect.md - Etransactions: tutorial/etransaction.md - How-to: - L'ORM de Django: howto/querysets.md From 2c7eb99f31d47c389fdde44cc85528b9a648c9ef Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 25 May 2025 15:34:00 +0200 Subject: [PATCH 08/12] use 54 bytes keys and sha512 hashing --- apikey/auth.py | 4 +--- apikey/hashers.py | 13 +++++++------ apikey/migrations/0001_initial.py | 2 +- apikey/models.py | 8 ++++---- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/apikey/auth.py b/apikey/auth.py index 3d45a4eb..00212c1b 100644 --- a/apikey/auth.py +++ b/apikey/auth.py @@ -4,14 +4,12 @@ from ninja.security import APIKeyHeader from apikey.hashers import get_hasher from apikey.models import ApiClient, ApiKey -_UUID_LENGTH = 36 - class ApiKeyAuth(APIKeyHeader): param_name = "X-APIKey" def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None: - if not key or len(key) != _UUID_LENGTH: + if not key or len(key) != ApiKey.KEY_LENGTH: return None hasher = get_hasher() hashed_key = hasher.encode(key) diff --git a/apikey/hashers.py b/apikey/hashers.py index 3a623177..95c16673 100644 --- a/apikey/hashers.py +++ b/apikey/hashers.py @@ -1,12 +1,12 @@ import functools import hashlib -import uuid +import secrets from django.contrib.auth.hashers import BasePasswordHasher from django.utils.crypto import constant_time_compare -class Sha256ApiKeyHasher(BasePasswordHasher): +class Sha512ApiKeyHasher(BasePasswordHasher): """ An API key hasher using the sha256 algorithm. @@ -15,14 +15,14 @@ class Sha256ApiKeyHasher(BasePasswordHasher): high entropy, randomly generated API keys. """ - algorithm = "sha256" + algorithm = "sha512" def salt(self) -> str: # No need for a salt on a high entropy key. return "" def encode(self, password: str, salt: str = "") -> str: - hashed = hashlib.sha256(password.encode()).hexdigest() + hashed = hashlib.sha512(password.encode()).hexdigest() return f"{self.algorithm}$${hashed}" def verify(self, password: str, encoded: str) -> bool: @@ -32,11 +32,12 @@ class Sha256ApiKeyHasher(BasePasswordHasher): @functools.cache def get_hasher(): - return Sha256ApiKeyHasher() + return Sha512ApiKeyHasher() def generate_key() -> tuple[str, str]: """Generate a [key, hash] couple.""" - key = str(uuid.uuid4()) + # this will result in key with a length of 72 + key = str(secrets.token_urlsafe(54)) hasher = get_hasher() return key, hasher.encode(key) diff --git a/apikey/migrations/0001_initial.py b/apikey/migrations/0001_initial.py index cda9e9f6..b416b749 100644 --- a/apikey/migrations/0001_initial.py +++ b/apikey/migrations/0001_initial.py @@ -88,7 +88,7 @@ class Migration(migrations.Migration): models.CharField( db_index=True, editable=False, - max_length=150, + max_length=136, verbose_name="hashed key", ), ), diff --git a/apikey/models.py b/apikey/models.py index b172bb7a..36e20287 100644 --- a/apikey/models.py +++ b/apikey/models.py @@ -17,9 +17,7 @@ class ApiClient(models.Model): on_delete=models.CASCADE, ) groups = models.ManyToManyField( - Group, - verbose_name=_("groups"), - related_name="api_clients", + Group, verbose_name=_("groups"), related_name="api_clients", blank=True ) client_permissions = models.ManyToManyField( Permission, @@ -70,11 +68,13 @@ class ApiClient(models.Model): class ApiKey(models.Model): PREFIX_LENGTH = 5 + KEY_LENGTH = 72 + HASHED_KEY_LENGTH = 136 name = models.CharField(_("name"), blank=True, default="") prefix = models.CharField(_("prefix"), max_length=PREFIX_LENGTH, editable=False) hashed_key = models.CharField( - _("hashed key"), max_length=150, db_index=True, editable=False + _("hashed key"), max_length=HASHED_KEY_LENGTH, db_index=True, editable=False ) client = models.ForeignKey( ApiClient, From 80866086a8f9e557d74633ddbb8701e1c7f77c7c Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 26 May 2025 07:42:44 +0200 Subject: [PATCH 09/12] Forbid authentication with revoked keys --- apikey/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apikey/auth.py b/apikey/auth.py index 00212c1b..d1489f7f 100644 --- a/apikey/auth.py +++ b/apikey/auth.py @@ -14,7 +14,7 @@ class ApiKeyAuth(APIKeyHeader): hasher = get_hasher() hashed_key = hasher.encode(key) try: - key_obj = ApiKey.objects.get(hashed_key=hashed_key) + key_obj = ApiKey.objects.get(revoked=False, hashed_key=hashed_key) except ApiKey.DoesNotExist: return None return key_obj.client From a23604383b566cc1429d0a039c74ebdb199d395d Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 3 Jun 2025 11:03:20 +0200 Subject: [PATCH 10/12] doc: incompatibility between api keys and csrf --- docs/tutorial/api/dev.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/tutorial/api/dev.md b/docs/tutorial/api/dev.md index 200b0730..4d2de4e8 100644 --- a/docs/tutorial/api/dev.md +++ b/docs/tutorial/api/dev.md @@ -62,6 +62,8 @@ Cette dernière est documentée [ici](../perms.md). ### Limites des clefs d'API +#### Incompatibilité avec certaines permissions + Le système des clefs d'API est apparu très tard dans l'histoire du site (en P25, 10 ans après le début du développement). Il s'agit ni plus ni moins qu'un système d'authentification parallèle fait maison, @@ -91,6 +93,28 @@ Les principaux points de friction sont : - `IsLoggedInCounter`, qui utilise encore un autre système d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site. +#### Incompatibilité avec les tokens csrf + +Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery) +est un des multiples facteurs d'attaque sur le web. +Heureusement, Django vient encore une fois à notre aide, +avec des mécanismes intégrés pour s'en protéger. +Ceux-ci incluent notamment un système de +[token CSRF](https://docs.djangoproject.com/fr/stable/ref/csrf/) +à fournir dans les requêtes POST/PUT/PATCH. + +Ceux-ci sont bien adaptés au cycle requêtes/réponses +typique de l'expérience utilisateur sur un navigateur, +où les requêtes POST sont toujours effectuées après une requête +GET au cours de laquelle on a pu récupérer un token csrf. +Cependant, le flux des requêtes sur une API est bien différent ; +de ce fait, il est à attendre que les requêtes POST envoyées à l'API +par un client externe n'aient pas de token CSRF et se retrouvent +donc bloquées. + +Pour ces raisons, l'accès aux requêtes POST/PUT/PATCH de l'API +par un client externe ne marche pas. + ## Créer un client et une clef d'API Le site n'a actuellement pas d'interface permettant à ses utilisateurs From ae7784a97377d71213f0802a41f81ba59429002a Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 4 Jun 2025 09:49:11 +0200 Subject: [PATCH 11/12] rename apikey to api --- {apikey => api}/__init__.py | 0 {apikey => api}/admin.py | 4 +- {apikey => api}/apps.py | 4 +- {apikey => api}/auth.py | 4 +- {apikey => api}/hashers.py | 0 {apikey => api}/migrations/0001_initial.py | 2 +- {apikey => api}/migrations/__init__.py | 0 {apikey => api}/models.py | 0 .../api_permissions.py => api/permissions.py | 31 +++-- api/tests/__init__.py | 0 apikey/tests.py => api/tests/test_api_key.py | 6 +- club/api.py | 4 +- com/api.py | 2 +- com/models.py | 2 +- core/api.py | 4 +- counter/api.py | 4 +- counter/models.py | 2 +- docs/reference/{apikey => api}/auth.md | 2 +- docs/reference/{apikey => api}/hashers.md | 2 +- docs/reference/{apikey => api}/models.md | 2 +- docs/reference/api/perms.md | 4 + docs/reference/core/auth.md | 9 +- docs/tutorial/api/dev.md | 21 ++- docs/tutorial/perms.md | 2 +- docs/tutorial/structure.md | 130 +++++++++--------- eboutic/api.py | 2 +- locale/fr/LC_MESSAGES/django.po | 44 +++--- mkdocs.yml | 9 +- pedagogy/api.py | 4 +- sas/api.py | 4 +- sith/settings.py | 2 +- 31 files changed, 162 insertions(+), 144 deletions(-) rename {apikey => api}/__init__.py (100%) rename {apikey => api}/admin.py (95%) rename {apikey => api}/apps.py (64%) rename {apikey => api}/auth.py (87%) rename {apikey => api}/hashers.py (100%) rename {apikey => api}/migrations/0001_initial.py (98%) rename {apikey => api}/migrations/__init__.py (100%) rename {apikey => api}/models.py (100%) rename core/auth/api_permissions.py => api/permissions.py (88%) create mode 100644 api/tests/__init__.py rename apikey/tests.py => api/tests/test_api_key.py (85%) rename docs/reference/{apikey => api}/auth.md (86%) rename docs/reference/{apikey => api}/hashers.md (89%) rename docs/reference/{apikey => api}/models.md (88%) create mode 100644 docs/reference/api/perms.md diff --git a/apikey/__init__.py b/api/__init__.py similarity index 100% rename from apikey/__init__.py rename to api/__init__.py diff --git a/apikey/admin.py b/api/admin.py similarity index 95% rename from apikey/admin.py rename to api/admin.py index ef6a247c..611bdba0 100644 --- a/apikey/admin.py +++ b/api/admin.py @@ -3,8 +3,8 @@ from django.db.models import QuerySet from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ -from apikey.hashers import generate_key -from apikey.models import ApiClient, ApiKey +from api.hashers import generate_key +from api.models import ApiClient, ApiKey @admin.register(ApiClient) diff --git a/apikey/apps.py b/api/apps.py similarity index 64% rename from apikey/apps.py rename to api/apps.py index 4ce409b2..878e7d54 100644 --- a/apikey/apps.py +++ b/api/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class ApikeyConfig(AppConfig): +class ApiConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "apikey" + name = "api" diff --git a/apikey/auth.py b/api/auth.py similarity index 87% rename from apikey/auth.py rename to api/auth.py index d1489f7f..787234a6 100644 --- a/apikey/auth.py +++ b/api/auth.py @@ -1,8 +1,8 @@ from django.http import HttpRequest from ninja.security import APIKeyHeader -from apikey.hashers import get_hasher -from apikey.models import ApiClient, ApiKey +from api.hashers import get_hasher +from api.models import ApiClient, ApiKey class ApiKeyAuth(APIKeyHeader): diff --git a/apikey/hashers.py b/api/hashers.py similarity index 100% rename from apikey/hashers.py rename to api/hashers.py diff --git a/apikey/migrations/0001_initial.py b/api/migrations/0001_initial.py similarity index 98% rename from apikey/migrations/0001_initial.py rename to api/migrations/0001_initial.py index b416b749..4ebfe9d4 100644 --- a/apikey/migrations/0001_initial.py +++ b/api/migrations/0001_initial.py @@ -99,7 +99,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="api_keys", - to="apikey.apiclient", + to="api.apiclient", verbose_name="api client", ), ), diff --git a/apikey/migrations/__init__.py b/api/migrations/__init__.py similarity index 100% rename from apikey/migrations/__init__.py rename to api/migrations/__init__.py diff --git a/apikey/models.py b/api/models.py similarity index 100% rename from apikey/models.py rename to api/models.py diff --git a/core/auth/api_permissions.py b/api/permissions.py similarity index 88% rename from core/auth/api_permissions.py rename to api/permissions.py index 73f9fa84..f371910b 100644 --- a/core/auth/api_permissions.py +++ b/api/permissions.py @@ -39,7 +39,7 @@ Example: import operator from functools import reduce -from typing import Any +from typing import Any, Callable from django.contrib.auth.models import Permission from django.http import HttpRequest @@ -67,21 +67,26 @@ class HasPerm(BasePermission): Example: ```python - # this route will require both permissions - @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])] - def foo(self): ... + @api_controller("/foo") + class FooController(ControllerBase): + # this route will require both permissions + @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])] + def foo(self): ... - # This route will require at least one of the perm, - # but it's not mandatory to have all of them - @route.put( - "/bar", - permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)], - ) - def bar(self): ... + # This route will require at least one of the perm, + # but it's not mandatory to have all of them + @route.put( + "/bar", + permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)], + ) + def bar(self): ... + ``` """ def __init__( - self, perms: str | Permission | list[str | Permission], op=operator.and_ + self, + perms: str | Permission | list[str | Permission], + op: Callable[[bool, bool], bool] = operator.and_, ): """ Args: @@ -103,7 +108,7 @@ class HasPerm(BasePermission): # If not, this authentication has not been done, but the user may # still be implicitly authenticated through AuthenticationMiddleware user = request.auth if hasattr(request, "auth") else request.user - # `user` may either be a `core.User` or an `apikey.ApiClient` ; + # `user` may either be a `core.User` or an `api.ApiClient` ; # they are not the same model, but they both implement the `has_perm` method return reduce(self._operator, (user.has_perm(p) for p in self._perms)) diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apikey/tests.py b/api/tests/test_api_key.py similarity index 85% rename from apikey/tests.py rename to api/tests/test_api_key.py index a971af2d..4fad18d3 100644 --- a/apikey/tests.py +++ b/api/tests/test_api_key.py @@ -2,9 +2,9 @@ import pytest from django.test import RequestFactory from model_bakery import baker -from apikey.auth import ApiKeyAuth -from apikey.hashers import generate_key -from apikey.models import ApiClient, ApiKey +from api.auth import ApiKeyAuth +from api.hashers import generate_key +from api.models import ApiClient, ApiKey @pytest.mark.django_db diff --git a/club/api.py b/club/api.py index decdf8f8..ef46e4c7 100644 --- a/club/api.py +++ b/club/api.py @@ -6,10 +6,10 @@ from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema -from apikey.auth import ApiKeyAuth +from api.auth import ApiKeyAuth +from api.permissions import CanAccessLookup, HasPerm from club.models import Club from club.schemas import ClubSchema, SimpleClubSchema -from core.auth.api_permissions import CanAccessLookup, HasPerm @api_controller("/club") diff --git a/com/api.py b/com/api.py index 79ff9c34..b01eef0e 100644 --- a/com/api.py +++ b/com/api.py @@ -8,10 +8,10 @@ from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema +from api.permissions import HasPerm from com.ics_calendar import IcsCalendar from com.models import News, NewsDate from com.schemas import NewsDateFilterSchema, NewsDateSchema -from core.auth.api_permissions import HasPerm from core.views.files import send_raw_file diff --git a/com/models.py b/com/models.py index b6253619..04f6bf56 100644 --- a/com/models.py +++ b/com/models.py @@ -194,7 +194,7 @@ class NewsDateQuerySet(models.QuerySet): class NewsDate(models.Model): """A date associated with news. - A [News][] can have multiple dates, for example if it is a recurring event. + A [News][com.models.News] can have multiple dates, for example if it is a recurring event. """ news = models.ForeignKey( diff --git a/core/api.py b/core/api.py index bd74875b..06b32989 100644 --- a/core/api.py +++ b/core/api.py @@ -11,9 +11,9 @@ from ninja_extra.exceptions import PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema -from apikey.auth import ApiKeyAuth +from api.auth import ApiKeyAuth +from api.permissions import CanAccessLookup, CanView, HasPerm from club.models import Mailing -from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm from core.models import Group, QuickUploadImage, SithFile, User from core.schemas import ( FamilyGodfatherSchema, diff --git a/counter/api.py b/counter/api.py index 11bf56c9..c7bcb540 100644 --- a/counter/api.py +++ b/counter/api.py @@ -21,8 +21,8 @@ from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema -from apikey.auth import ApiKeyAuth -from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot +from api.auth import ApiKeyAuth +from api.permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from counter.models import Counter, Product, ProductType from counter.schemas import ( CounterFilterSchema, diff --git a/counter/models.py b/counter/models.py index 3515e081..0f46176b 100644 --- a/counter/models.py +++ b/counter/models.py @@ -61,7 +61,7 @@ class CustomerQuerySet(models.QuerySet): Returns: The number of updated rows. - Warnings: + Warning: The execution time of this query grows really quickly. When updating 500 customers, it may take around a second. If you try to update all customers at once, the execution time diff --git a/docs/reference/apikey/auth.md b/docs/reference/api/auth.md similarity index 86% rename from docs/reference/apikey/auth.md rename to docs/reference/api/auth.md index f6261037..764dd318 100644 --- a/docs/reference/apikey/auth.md +++ b/docs/reference/api/auth.md @@ -1,4 +1,4 @@ -::: apikey.auth +::: api.auth handler: python options: heading_level: 3 diff --git a/docs/reference/apikey/hashers.md b/docs/reference/api/hashers.md similarity index 89% rename from docs/reference/apikey/hashers.md rename to docs/reference/api/hashers.md index eb728802..91c420b2 100644 --- a/docs/reference/apikey/hashers.md +++ b/docs/reference/api/hashers.md @@ -1,4 +1,4 @@ -::: apikey.hashers +::: api.hashers handler: python options: heading_level: 3 diff --git a/docs/reference/apikey/models.md b/docs/reference/api/models.md similarity index 88% rename from docs/reference/apikey/models.md rename to docs/reference/api/models.md index 52da58df..dbf2676b 100644 --- a/docs/reference/apikey/models.md +++ b/docs/reference/api/models.md @@ -1,4 +1,4 @@ -::: apikey.auth +::: api.auth handler: python options: heading_level: 3 diff --git a/docs/reference/api/perms.md b/docs/reference/api/perms.md new file mode 100644 index 00000000..c481bd28 --- /dev/null +++ b/docs/reference/api/perms.md @@ -0,0 +1,4 @@ +::: api.permissions + handler: python + options: + heading_level: 3 \ No newline at end of file diff --git a/docs/reference/core/auth.md b/docs/reference/core/auth.md index ade23f49..4226bb2c 100644 --- a/docs/reference/core/auth.md +++ b/docs/reference/core/auth.md @@ -20,13 +20,6 @@ - CanCreateMixin - CanEditMixin - CanViewMixin + - CanEditPropMixin - FormerSubscriberMixin - PermissionOrAuthorRequiredMixin - - -## API Permissions - -::: core.auth.api_permissions - handler: python - options: - heading_level: 3 \ No newline at end of file diff --git a/docs/tutorial/api/dev.md b/docs/tutorial/api/dev.md index 4d2de4e8..165d605f 100644 --- a/docs/tutorial/api/dev.md +++ b/docs/tutorial/api/dev.md @@ -4,6 +4,25 @@ Ce sont des librairies relativement simples et qui présentent l'immense avantage d'offrir des mécanismes de validation et de sérialisation de données à la fois simples et expressifs. +## Dossiers et fichiers + +L'API possède une application (`api`) +à la racine du projet, contenant des utilitaires +et de la configuration partagée par toutes les autres applications. +C'est la pièce centrale de notre API, mais ce n'est pas là que +vous trouverez les routes de l'API. + +Les routes en elles-mêmes sont contenues dans les autres applications, +de manière thématiques : +les routes liées aux clubs sont dans `club`, les routes liées +aux photos dans `sas` et ainsi de suite. + +Les fichiers liés à l'API dans chaque application sont +`schemas.py` et `api.py`. +`schemas.py` contient les schémas de validation de données +et `api.py` contient les contrôleurs de l'API. + + ## Schéma de données Le cœur de django-ninja étant sa validation de données grâce à Pydantic, @@ -33,7 +52,7 @@ La plus grande partie des routes de l'API utilisent la méthode par cookie de se Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux), utilisez l'attribut `auth` et les classes `SessionAuth` et -[`ApiKeyAuth`][apikey.auth.ApiKeyAuth]. +[`ApiKeyAuth`][api.auth.ApiKeyAuth]. !!!example diff --git a/docs/tutorial/perms.md b/docs/tutorial/perms.md index c23ca25f..4594c81e 100644 --- a/docs/tutorial/perms.md +++ b/docs/tutorial/perms.md @@ -606,4 +606,4 @@ vous ne devriez pas être perdu, étant donné que le système de permissions de l'API utilise des noms assez similaires : `IsInGroup`, `IsRoot`, `IsSubscriber`... Vous pouvez trouver des exemples d'utilisation de ce système -dans [cette partie](../reference/core/api_permissions.md). +dans [cette partie](../reference/api/perms.md). diff --git a/docs/tutorial/structure.md b/docs/tutorial/structure.md index 1421cddc..a796ee91 100644 --- a/docs/tutorial/structure.md +++ b/docs/tutorial/structure.md @@ -24,62 +24,66 @@ sith/ ├── .github/ │ ├── actions/ (1) │ └── workflows/ (2) -├── club/ (3) +├── api/ (3) │ └── ... -├── com/ (4) +├── antispam/ (4) │ └── ... -├── core/ (5) +├── club/ (5) │ └── ... -├── counter/ (6) +├── com/ (6) │ └── ... -├── docs/ (7) +├── core/ (7) │ └── ... -├── eboutic/ (8) +├── counter/ (8) │ └── ... -├── election/ (9) +├── docs/ (9) │ └── ... -├── forum/ (10) +├── eboutic/ (10) │ └── ... -├── galaxy/ (11) +├── election/ (11) │ └── ... -├── locale/ (12) +├── forum/ (12) │ └── ... -├── matmat/ (13) +├── galaxy/ (13) │ └── ... -├── pedagogy/ (14) +├── locale/ (14) │ └── ... -├── rootplace/ (15) +├── matmat/ (15) │ └── ... -├── sas/ (16) +├── pedagogy/ (16) │ └── ... -├── sith/ (17) +├── rootplace/ (17) │ └── ... -├── subscription/ (18) +├── sas/ (18) │ └── ... -├── trombi/ (19) +├── sith/ (19) │ └── ... -├── antispam/ (20) +├── subscription/ (20) │ └── ... -├── staticfiles/ (21) +├── trombi/ (21) │ └── ... -├── processes/ (22) +├── antispam/ (22) +│ └── ... +├── staticfiles/ (23) +│ └── ... +├── processes/ (24) │ └── ... │ -├── .coveragerc (23) -├── .envrc (24) +├── .coveragerc (25) +├── .envrc (26) ├── .gitattributes ├── .gitignore ├── .mailmap -├── .env (25) -├── .env.example (26) -├── manage.py (27) -├── mkdocs.yml (28) +├── .env (27) +├── .env.example (28) +├── manage.py (29) +├── mkdocs.yml (30) ├── uv.lock -├── pyproject.toml (29) -├── .venv/ (30) -├── .python-version (31) -├── Procfile.static (32) -├── Procfile.service (33) +├── pyproject.toml (31) +├── .venv/ (32) +├── .python-version (33) +├── Procfile.static (34) +├── Procfile.service (35) └── README.md ``` @@ -92,53 +96,55 @@ sith/ des workflows Github. Par exemple, le workflow `docs.yml` compile et publie la documentation à chaque push sur la branche `master`. -3. Application de gestion des clubs et de leurs membres. -4. Application contenant les fonctionnalités +3. Application avec la configuration de l'API +4. Application contenant des utilitaires pour bloquer le spam et les bots +5. Application de gestion des clubs et de leurs membres. +6. Application contenant les fonctionnalités destinées aux responsables communication de l'AE. -5. Application contenant la modélisation centrale du site. +7. Application contenant la modélisation centrale du site. On en reparle plus loin sur cette page. -6. Application de gestion des comptoirs, des permanences +8. Application de gestion des comptoirs, des permanences sur ces comptoirs et des transactions qui y sont effectuées. -7. Dossier contenant la documentation. -8. Application de gestion de la boutique en ligne. -9. Application de gestion des élections. -10. Application de gestion du forum -11. Application de gestion de la galaxie ; la galaxie +9. Dossier contenant la documentation. +10. Application de gestion de la boutique en ligne. +11. Application de gestion des élections. +12. Application de gestion du forum +13. Application de gestion de la galaxie ; la galaxie est un graphe des niveaux de proximité entre les différents étudiants. -12. Dossier contenant les fichiers de traduction. -13. Fonctionnalités de recherche d'utilisateurs. -14. Le guide des UEs du site, sur lequel les utilisateurs +14. Dossier contenant les fichiers de traduction. +15. Fonctionnalités de recherche d'utilisateurs. +16. Le guide des UEs du site, sur lequel les utilisateurs peuvent également laisser leurs avis. -15. Fonctionnalités utiles aux utilisateurs root. -16. Le SAS, où l'on trouve toutes les photos de l'AE. -17. Application principale du projet, contenant sa configuration. -18. Gestion des cotisations des utilisateurs du site. -19. Outil pour faciliter la fabrication des trombinoscopes de promo. -20. Fonctionnalités pour gérer le spam. -21. Gestion des statics du site. Override le système de statics de Django. +17. Fonctionnalités utiles aux utilisateurs root. +18. Le SAS, où l'on trouve toutes les photos de l'AE. +19. Application principale du projet, contenant sa configuration. +20. Gestion des cotisations des utilisateurs du site. +21. Outil pour faciliter la fabrication des trombinoscopes de promo. +22. Fonctionnalités pour gérer le spam. +23. Gestion des statics du site. Override le système de statics de Django. Ajoute l'intégration du scss et du bundler js de manière transparente pour l'utilisateur. -22. Module de gestion des services externes. +24. Module de gestion des services externes. Offre une API simple pour utiliser les fichiers `Procfile.*`. -23. Fichier de configuration de coverage. -24. Fichier de configuration de direnv. -25. Contient les variables d'environnement, qui sont susceptibles +25. Fichier de configuration de coverage. +26. Fichier de configuration de direnv. +27. Contient les variables d'environnement, qui sont susceptibles de varier d'une machine à l'autre. -26. Contient des valeurs par défaut pour le `.env` +28. Contient des valeurs par défaut pour le `.env` pouvant convenir à un environnment de développement local -27. Fichier généré automatiquement par Django. C'est lui +29. Fichier généré automatiquement par Django. C'est lui qui permet d'appeler des commandes de gestion du projet avec la syntaxe `python ./manage.py ` -28. Le fichier de configuration de la documentation, +30. Le fichier de configuration de la documentation, avec ses plugins et sa table des matières. -29. Le fichier où sont déclarés les dépendances et la configuration +31. Le fichier où sont déclarés les dépendances et la configuration de certaines d'entre elles. -30. Dossier d'environnement virtuel généré par uv -31. Fichier qui contrôle quelle version de python utiliser pour le projet -32. Fichier qui contrôle les commandes à lancer pour gérer la compilation +32. Dossier d'environnement virtuel généré par uv +33. Fichier qui contrôle quelle version de python utiliser pour le projet +34. Fichier qui contrôle les commandes à lancer pour gérer la compilation automatique des static et autres services nécessaires à la command runserver. -33. Fichier qui contrôle les services tiers nécessaires au fonctionnement +35. Fichier qui contrôle les services tiers nécessaires au fonctionnement du Sith tel que redis. ## L'application principale diff --git a/eboutic/api.py b/eboutic/api.py index 2041fb87..c44a8cc9 100644 --- a/eboutic/api.py +++ b/eboutic/api.py @@ -1,7 +1,7 @@ from ninja_extra import ControllerBase, api_controller, route from ninja_extra.exceptions import NotFound -from core.auth.api_permissions import CanView +from api.permissions import CanView from counter.models import BillingInfo from eboutic.models import Basket diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index cd399bfd..f8c89349 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-06-04 09:58+0200\n" +"POT-Creation-Date: 2025-06-16 14:54+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -35,7 +35,7 @@ msgstr "" "True si gardé à jour par le biais d'un fournisseur externe de domains " "toxics, False sinon" -#: apikey/admin.py +#: api/admin.py #, python-format msgid "" "The API key for %(name)s is: %(key)s. Please store it somewhere safe: you " @@ -44,57 +44,56 @@ msgstr "" "La clef d'API pour %(name)s est : %(key)s. Gardez-là dans un emplacement " "sûr : vous ne pourrez pas la revoir à nouveau." -#: apikey/admin.py +#: api/admin.py msgid "Revoke selected API keys" msgstr "Révoquer les clefs d'API sélectionnées" -#: apikey/models.py club/models.py com/models.py counter/models.py -#: forum/models.py launderette/models.py +#: api/models.py club/models.py com/models.py counter/models.py forum/models.py msgid "name" msgstr "nom" -#: apikey/models.py core/models.py +#: api/models.py core/models.py msgid "owner" msgstr "propriétaire" -#: apikey/models.py core/models.py +#: api/models.py core/models.py msgid "groups" msgstr "groupes" -#: apikey/models.py +#: api/models.py msgid "client permissions" msgstr "permissions du client" -#: apikey/models.py +#: api/models.py msgid "Specific permissions for this api client." msgstr "Permissions spécifiques pour ce client d'API" -#: apikey/models.py +#: api/models.py msgid "api client" msgstr "client d'api" -#: apikey/models.py +#: api/models.py msgid "api clients" msgstr "clients d'api" -#: apikey/models.py +#: api/models.py msgid "prefix" msgstr "préfixe" -#: apikey/models.py +#: api/models.py msgid "hashed key" msgstr "hash de la clef" -#: apikey/models.py +#: api/models.py msgctxt "api key" msgid "revoked" msgstr "révoquée" -#: apikey/models.py +#: api/models.py msgid "api key" msgstr "clef d'api" -#: apikey/models.py +#: api/models.py msgid "api keys" msgstr "clefs d'api" @@ -182,10 +181,6 @@ msgstr "Vous devez choisir un rôle" msgid "You do not have the permission to do that" msgstr "Vous n'avez pas la permission de faire cela" -#: club/models.py com/models.py counter/models.py forum/models.py -msgid "name" -msgstr "nom" - #: club/models.py msgid "slug name" msgstr "nom slug" @@ -732,8 +727,7 @@ msgstr "message d'info" msgid "weekmail destinations" msgstr "destinataires du weekmail" -#: com/models.py core/templates/core/macros.jinja election/models.py -#: forum/models.py pedagogy/models.py +#: com/models.py election/models.py forum/models.py pedagogy/models.py msgid "title" msgstr "titre" @@ -1158,7 +1152,7 @@ msgstr "Nouvel article" msgid "Articles in no weekmail yet" msgstr "Articles dans aucun weekmail" -#: com/templates/com/weekmail.jinja core/templates/core/macros.jinja +#: com/templates/com/weekmail.jinja msgid "Content" msgstr "Contenu" @@ -2780,10 +2774,6 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s" msgid "Apply rights recursively" msgstr "Appliquer les droits récursivement" -#: core/views/forms.py -msgid "Choose file" -msgstr "Choisir un fichier" - #: core/views/forms.py msgid "Choose user" msgstr "Choisir un utilisateur" diff --git a/mkdocs.yml b/mkdocs.yml index 992cc38e..ffa4a8b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,10 +86,11 @@ nav: - antispam: - reference/antispam/models.md - reference/antispam/forms.md - - apikey: - - reference/apikey/auth.md - - reference/apikey/hashers.md - - reference/apikey/models.md + - api: + - reference/api/auth.md + - reference/api/hashers.md + - reference/api/models.md + - reference/api/perms.md - club: - reference/club/models.md - reference/club/views.md diff --git a/pedagogy/api.py b/pedagogy/api.py index b1944ce4..ae8b7ea8 100644 --- a/pedagogy/api.py +++ b/pedagogy/api.py @@ -8,8 +8,8 @@ from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema -from apikey.auth import ApiKeyAuth -from core.auth.api_permissions import HasPerm +from api.auth import ApiKeyAuth +from api.permissions import HasPerm from pedagogy.models import UV from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema from pedagogy.utbm_api import UtbmApiClient diff --git a/sas/api.py b/sas/api.py index 83d7f0c8..fa37e974 100644 --- a/sas/api.py +++ b/sas/api.py @@ -13,8 +13,8 @@ from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema from pydantic import NonNegativeInt -from apikey.auth import ApiKeyAuth -from core.auth.api_permissions import ( +from api.auth import ApiKeyAuth +from api.permissions import ( CanAccessLookup, CanEdit, CanView, diff --git a/sith/settings.py b/sith/settings.py index 759161e2..2841a9b3 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -124,7 +124,7 @@ INSTALLED_APPS = ( "pedagogy", "galaxy", "antispam", - "apikey", + "api", ) MIDDLEWARE = ( From 50d7b7e731c4da575e226b5b7408ac87fb600571 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 16 Jun 2025 14:46:29 +0200 Subject: [PATCH 12/12] Move api urls to api app --- api/urls.py | 10 ++++++++++ sith/urls.py | 5 ++--- staticfiles/processors.py | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 api/urls.py diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 00000000..ed58c790 --- /dev/null +++ b/api/urls.py @@ -0,0 +1,10 @@ +from ninja_extra import NinjaExtraAPI + +api = NinjaExtraAPI( + title="PICON", + description="Portail Interaction de Communication avec les Services Étudiants", + version="0.2.0", + urls_namespace="api", + csrf=True, +) +api.auto_discover_controllers() diff --git a/sith/urls.py b/sith/urls.py index fb3643d9..dd560626 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -18,15 +18,14 @@ from django.contrib import admin from django.http import Http404 from django.urls import include, path from django.views.i18n import JavaScriptCatalog -from ninja_extra import NinjaExtraAPI + +from api.urls import api js_info_dict = {"packages": ("sith",)} handler403 = "core.views.forbidden" handler404 = "core.views.not_found" handler500 = "core.views.internal_servor_error" -api = NinjaExtraAPI(title="Sith API", version="0.2.0", urls_namespace="api", csrf=True) -api.auto_discover_controllers() urlpatterns = [ path("", include(("core.urls", "core"), namespace="core")), diff --git a/staticfiles/processors.py b/staticfiles/processors.py index 5f44731b..bc1ceeb1 100644 --- a/staticfiles/processors.py +++ b/staticfiles/processors.py @@ -11,7 +11,7 @@ import rjsmin import sass from django.conf import settings -from sith.urls import api +from api.urls import api from staticfiles.apps import BUNDLED_FOLDER_NAME, BUNDLED_ROOT, GENERATED_ROOT