From f1e60f2cd1bfadd297c28a294cda7b4660d12d13 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 25 Oct 2025 21:45:27 +0200 Subject: [PATCH 01/14] feat: api route to get api client infos --- api/api.py | 16 ++++++++++++++++ api/models.py | 38 ++++++++++++++++---------------------- api/schemas.py | 14 ++++++++++++++ 3 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 api/api.py create mode 100644 api/schemas.py diff --git a/api/api.py b/api/api.py new file mode 100644 index 000000000..0475822e6 --- /dev/null +++ b/api/api.py @@ -0,0 +1,16 @@ +from ninja_extra import ControllerBase, api_controller, route + +from api.auth import ApiKeyAuth +from api.schemas import ApiClientSchema + + +@api_controller("/client") +class ApiClientController(ControllerBase): + @route.get( + "/me", + auth=[ApiKeyAuth()], + response=ApiClientSchema, + url_name="api-client-infos", + ) + def get_client_info(self): + return self.context.request.auth diff --git a/api/models.py b/api/models.py index 36e20287d..4c802b55a 100644 --- a/api/models.py +++ b/api/models.py @@ -2,6 +2,8 @@ from django.contrib.auth.models import Permission from django.db import models +from django.db.models import Q +from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from django.utils.translation import pgettext_lazy @@ -29,8 +31,6 @@ class ApiClient(models.Model): 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") @@ -38,29 +38,23 @@ class Meta: def __str__(self): return self.name + @cached_property + def all_permissions(self) -> set[str]: + permissions = ( + Permission.objects.filter( + Q(group__group__in=self.groups.all()) | Q(clients=self) + ) + .values_list("content_type__app_label", "codename") + .order_by() + ) + return {f"{content_type}.{name}" for content_type, name in permissions} + def has_perm(self, perm: str): """Return True if the client has the specified permission.""" + return perm in self.all_permissions - 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. - """ + def has_perms(self, perm_list: Iterable[str]) -> bool: + """Return True if the client has each of the specified permissions.""" 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) diff --git a/api/schemas.py b/api/schemas.py new file mode 100644 index 000000000..376e90a9e --- /dev/null +++ b/api/schemas.py @@ -0,0 +1,14 @@ +from ninja import ModelSchema +from pydantic import Field + +from api.models import ApiClient +from core.schemas import SimpleUserSchema + + +class ApiClientSchema(ModelSchema): + class Meta: + model = ApiClient + fields = ["id", "name"] + + owner: SimpleUserSchema + permissions: list[str] = Field(alias="all_permissions") From 058b9288996aeb5b4b73f982ca58d36b5712f09a Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 26 Oct 2025 10:52:14 +0100 Subject: [PATCH 02/14] move `ResultConverter` to core app --- core/converters.py | 19 +++++++++++-------- eboutic/converters.py | 37 ------------------------------------- eboutic/urls.py | 4 ++-- 3 files changed, 13 insertions(+), 47 deletions(-) delete mode 100644 eboutic/converters.py diff --git a/core/converters.py b/core/converters.py index d2ad44946..b161eec67 100644 --- a/core/converters.py +++ b/core/converters.py @@ -1,19 +1,16 @@ -class FourDigitYearConverter: - regex = "[0-9]{4}" +from django.urls.converters import IntConverter, StringConverter - def to_python(self, value): - return int(value) + +class FourDigitYearConverter(IntConverter): + regex = "[0-9]{4}" def to_url(self, value): return str(value).zfill(4) -class TwoDigitMonthConverter: +class TwoDigitMonthConverter(IntConverter): regex = "[0-9]{2}" - def to_python(self, value): - return int(value) - def to_url(self, value): return str(value).zfill(2) @@ -28,3 +25,9 @@ def to_python(self, value): def to_url(self, value): return str(value) + + +class ResultConverter(StringConverter): + """Converter whose regex match either "success" or "failure".""" + + regex = "(success|failure)" diff --git a/eboutic/converters.py b/eboutic/converters.py deleted file mode 100644 index fec67ed8c..000000000 --- a/eboutic/converters.py +++ /dev/null @@ -1,37 +0,0 @@ -# -# Copyright 2022 -# - Maréchal Date: Sun, 26 Oct 2025 11:25:06 +0100 Subject: [PATCH 03/14] add `hmac_key` to `ApiClient` --- api/admin.py | 9 +++++++++ api/migrations/0002_apiclient_hmac_key.py | 19 +++++++++++++++++++ api/models.py | 13 +++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 api/migrations/0002_apiclient_hmac_key.py diff --git a/api/admin.py b/api/admin.py index 611bdba05..100dcd690 100644 --- a/api/admin.py +++ b/api/admin.py @@ -17,6 +17,15 @@ class ApiClientAdmin(admin.ModelAdmin): "owner__nick_name", ) autocomplete_fields = ("owner", "groups", "client_permissions") + readonly_fields = ("hmac_key",) + actions = ("reset_hmac_key",) + + @admin.action(permissions=["change"], description=_("Reset HMAC key")) + def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]): + objs = list(queryset) + for obj in objs: + obj.reset_hmac(commit=False) + ApiClient.objects.bulk_update(objs, fields=["hmac_key"]) @admin.register(ApiKey) diff --git a/api/migrations/0002_apiclient_hmac_key.py b/api/migrations/0002_apiclient_hmac_key.py new file mode 100644 index 000000000..d0b3fad45 --- /dev/null +++ b/api/migrations/0002_apiclient_hmac_key.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.3 on 2025-10-26 10:15 + +from django.db import migrations, models + +import api.models + + +class Migration(migrations.Migration): + dependencies = [("api", "0001_initial")] + + operations = [ + migrations.AddField( + model_name="apiclient", + name="hmac_key", + field=models.CharField( + default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key" + ), + ), + ] diff --git a/api/models.py b/api/models.py index 4c802b55a..c0d9c2913 100644 --- a/api/models.py +++ b/api/models.py @@ -1,3 +1,4 @@ +import secrets from typing import Iterable from django.contrib.auth.models import Permission @@ -10,6 +11,10 @@ from core.models import Group, User +def get_hmac_key(): + return secrets.token_hex(64) + + class ApiClient(models.Model): name = models.CharField(_("name"), max_length=64) owner = models.ForeignKey( @@ -28,6 +33,7 @@ class ApiClient(models.Model): help_text=_("Specific permissions for this api client."), related_name="clients", ) + hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -59,6 +65,13 @@ def has_perms(self, perm_list: Iterable[str]) -> bool: raise ValueError("perm_list must be an iterable of permissions.") return all(self.has_perm(perm) for perm in perm_list) + def reset_hmac(self, *, commit: bool = True) -> str: + """Reset and return the HMAC key for this client.""" + self.hmac_key = get_hmac_key() + if commit: + self.save() + return self.hmac_key + class ApiKey(models.Model): PREFIX_LENGTH = 5 From 76ceccb2629e8e218e7af6bc06572d326e53760f Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 26 Oct 2025 14:03:03 +0100 Subject: [PATCH 04/14] `hmac_hexdigest` util function --- api/templates/api/third_party/auth.jinja | 32 ++++++++++++++++++ core/utils.py | 43 ++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 api/templates/api/third_party/auth.jinja diff --git a/api/templates/api/third_party/auth.jinja b/api/templates/api/third_party/auth.jinja new file mode 100644 index 000000000..e4e9e4f6f --- /dev/null +++ b/api/templates/api/third_party/auth.jinja @@ -0,0 +1,32 @@ +{% extends "core/base.jinja" %} + +{% block content %} +
+ {% csrf_token %} +

{% trans %}Confidentiality{% endtrans %}

+

+ {% trans trimmed app=third_party_app %} + By ticking this box and clicking on the send button, you + acknowledge and agree to provide {{ app }} with your + first name, last name, nickname and any other information + that was the third party app was explicitly authorized to fetch + and that it must have acknowledged to you, in a complete and accurate manner. + {% endtrans %} +

+

+ {% trans trimmed app=third_party_app, cgu_link=third_party_cgu, sith_cgu_link=sith_cgu %} + The privacy policies of {{ app }} + and of the Students' Association + applies as soon as the form is submitted. + {% endtrans %} +

+
{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}
+
+

{% trans %}Confirmation of identity{% endtrans %}

+
+ {{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }} +
+ {% for field in form.hidden_fields() %}{{ field }}{% endfor %} + +
+{% endblock %} \ No newline at end of file diff --git a/core/utils.py b/core/utils.py index 9fb7adc89..8205c4366 100644 --- a/core/utils.py +++ b/core/utils.py @@ -12,21 +12,31 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # +from __future__ import annotations +import hmac from datetime import date, timedelta # Image utils from io import BytesIO -from typing import Final +from typing import TYPE_CHECKING +from urllib.parse import urlencode import PIL from django.conf import settings from django.core.files.base import ContentFile -from django.core.files.uploadedfile import UploadedFile -from django.http import HttpRequest from django.utils.timezone import localdate from PIL.Image import Image, Resampling +if TYPE_CHECKING: + from _hashlib import HASH + from collections.abc import Buffer, Mapping, Sequence + from typing import Any, Callable, Final + + from django.core.files.uploadedfile import UploadedFile + from django.http import HttpRequest + + RED_PIXEL_PNG: Final[bytes] = ( b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" @@ -188,3 +198,30 @@ def get_client_ip(request: HttpRequest) -> str | None: return ip return None + + +def hmac_hexdigest( + key: str | bytes, + data: Mapping[str, Any] | Sequence[tuple[str, Any]], + digest: str | Callable[[Buffer], HASH] = "sha256", +) -> str: + """Return the hexdigest of the signature of the given data. + + Args: + key: the HMAC key used for the signature + data: the data to sign + digest: a PEP247 hashing algorithm + + Examples: + ```python + data = { + "foo": 5, + "bar": "somevalue", + } + hmac_key = secrets.token_hex(64) + signature = hmac_hexdigest(hmac_key, data, "sha512") + ``` + """ + if isinstance(key, str): + key = key.encode() + return hmac.digest(key, urlencode(data).encode(), digest).hex() From 6e9ade9c21c258c06c0d31c648d860e663b6e3ca Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 26 Oct 2025 16:29:17 +0100 Subject: [PATCH 05/14] test populate_more command --- core/management/commands/populate_more.py | 19 +++++++++++++------ core/tests/test_commands.py | 13 +++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 core/tests/test_commands.py diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index 14b1c59af..3262fe465 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -1,3 +1,4 @@ +import math import random from datetime import date, timedelta from datetime import timezone as tz @@ -35,12 +36,17 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.faker = Faker("fr_FR") + def add_arguments(self, parser): + parser.add_argument( + "-n", "--nb-users", help="Number of users to create", type=int, default=600 + ) + def handle(self, *args, **options): if not settings.DEBUG: raise Exception("Never call this command in prod. Never.") self.stdout.write("Creating users...") - users = self.create_users() + users = self.create_users(options["nb_users"]) self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users subscribers = random.sample(users, k=int(0.8 * len(users))) self.stdout.write("Creating subscriptions...") @@ -80,7 +86,7 @@ def handle(self, *args, **options): self.stdout.write("Creating products...") self.create_products() self.stdout.write("Creating sales and refills...") - sellers = random.sample(list(User.objects.all()), 100) + sellers = random.sample(users, len(users) // 10) self.create_sales(sellers) self.stdout.write("Creating permanences...") self.create_permanences(sellers) @@ -89,7 +95,7 @@ def handle(self, *args, **options): self.stdout.write("Done") - def create_users(self) -> list[User]: + def create_users(self, nb_users: int = 600) -> list[User]: # Create a single password hash for all users to make it faster. # It's insecure as hell, but it's ok since it's only for dev purposes. password = make_password("plop") @@ -108,7 +114,7 @@ def create_users(self) -> list[User]: address=self.faker.address(), password=password, ) - for _ in range(600) + for _ in range(nb_users) ] # there may a duplicate or two # Not a problem, we will just have 599 users instead of 600 @@ -415,8 +421,9 @@ def create_permanences(self, sellers: list[User]): Permanency.objects.bulk_create(perms) def create_forums(self): - forumers = random.sample(list(User.objects.all()), 100) - most_actives = random.sample(forumers, 10) + users = list(User.objects.all()) + forumers = random.sample(users, math.ceil(len(users) / 10)) + most_actives = random.sample(forumers, math.ceil(len(forumers) / 6)) categories = list(Forum.objects.filter(is_category=True)) new_forums = [ Forum(name=self.faker.text(20), parent=random.choice(categories)) diff --git a/core/tests/test_commands.py b/core/tests/test_commands.py new file mode 100644 index 000000000..5602a4ca6 --- /dev/null +++ b/core/tests/test_commands.py @@ -0,0 +1,13 @@ +import contextlib +import os + +import pytest +from django.core.management import call_command + + +@pytest.mark.django_db +def test_populate_more(settings): + """Just check that populate more doesn't crash""" + settings.DEBUG = True + with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull): + call_command("populate_more", "--nb-users", "50") From e2f1aae2973eea17cdf47c2b50f747ddd3b1484d Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 26 Oct 2025 16:45:24 +0100 Subject: [PATCH 06/14] add CGU/EULA to populate command --- core/management/commands/populate.py | 17 ++++++++++++----- sith/settings.py | 2 ++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 05a650d08..c8963c818 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -28,6 +28,7 @@ from django.conf import settings from django.contrib.auth.models import Permission from django.contrib.sites.models import Site +from django.core.files.base import ContentFile from django.core.management import call_command from django.core.management.base import BaseCommand from django.db import connection @@ -120,15 +121,21 @@ def handle(self, *args, **options): ) self.profiles_root = SithFile.objects.create(name="profiles", owner=root) home_root = SithFile.objects.create(name="users", owner=root) - - # Page needed for club creation - p = Page(name=settings.SITH_CLUB_ROOT_PAGE) - p.save(force_lock=True) - club_root = SithFile.objects.create(name="clubs", owner=root) sas = SithFile.objects.create( name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID ) + SithFile.objects.create( + name="CGU", + is_folder=False, + file=ContentFile( + content="Conditions générales d'utilisation", name="cgu.txt" + ), + owner=root, + ) + # Page needed for club creation + p = Page(name=settings.SITH_CLUB_ROOT_PAGE) + p.save(force_lock=True) clubs = self._create_clubs() self.reset_index("club") diff --git a/sith/settings.py b/sith/settings.py index 5866cd738..1f9cd0cc3 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -417,6 +417,8 @@ def optional_file_parser(value: str) -> Path | None: SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_IMAGES_PER_PAGE = 60 +SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5) + SITH_PROFILE_DEPARTMENTS = [ ("TC", _("TC")), ("IMSI", _("IMSI")), From d2ae90b9b65bc0b5ec5de5e5074fd38ea6845911 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 26 Oct 2025 16:46:51 +0100 Subject: [PATCH 07/14] third-party authentication views --- api/forms.py | 35 ++++++++++++++ api/urls.py | 15 ++++++ api/views.py | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++ sith/urls.py | 1 + 4 files changed, 180 insertions(+) create mode 100644 api/forms.py create mode 100644 api/views.py diff --git a/api/forms.py b/api/forms.py new file mode 100644 index 000000000..1f6d7de07 --- /dev/null +++ b/api/forms.py @@ -0,0 +1,35 @@ +from django import forms +from django.forms import HiddenInput +from django.utils.translation import gettext_lazy as _ + + +class ThirdPartyAuthForm(forms.Form): + """Form to complete to authenticate on the sith from a third-party app. + + For the form to be valid, the user approve the EULA (french: CGU) + and give its username from the third-party app. + """ + + cgu_accepted = forms.BooleanField( + required=True, + label=_("I have read and I accept the terms and conditions of use"), + error_messages={ + "required": _("You must approve the terms and conditions of use.") + }, + ) + is_username_valid = forms.BooleanField( + required=True, + error_messages={"required": _("You must confirm that this is your username.")}, + ) + client_id = forms.IntegerField(widget=HiddenInput()) + third_party_app = forms.CharField(widget=HiddenInput()) + cgu_link = forms.URLField(widget=HiddenInput()) + username = forms.CharField(widget=HiddenInput()) + callback_url = forms.URLField(widget=HiddenInput()) + signature = forms.CharField(widget=HiddenInput()) + + def __init__(self, *args, label_suffix: str = "", initial, **kwargs): + super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs) + self.fields["is_username_valid"].label = _( + "I confirm that %(username)s is my username on %(app)s" + ) % {"username": initial.get("username"), "app": initial.get("third_party_app")} diff --git a/api/urls.py b/api/urls.py index 50300453c..f4f7fdd87 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,6 +1,10 @@ +from django.urls import path, register_converter from ninja.security import SessionAuth from ninja_extra import NinjaExtraAPI +from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView +from core.converters import ResultConverter + api = NinjaExtraAPI( title="PICON", description="Portail Interactif de Communication avec les Outils Numériques", @@ -9,3 +13,14 @@ auth=[SessionAuth()], ) api.auto_discover_controllers() + +register_converter(ResultConverter, "res") + +urlpatterns = [ + path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"), + path( + "auth//", + ThirdPartyAuthResultView.as_view(), + name="third-party-auth-result", + ), +] diff --git a/api/views.py b/api/views.py new file mode 100644 index 000000000..772c7d913 --- /dev/null +++ b/api/views.py @@ -0,0 +1,129 @@ +import hmac +from urllib.parse import unquote + +import pydantic +import requests +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied +from django.urls import reverse, reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import FormView, TemplateView +from ninja import Schema +from ninja_extra.shortcuts import get_object_or_none +from pydantic import HttpUrl + +from api.forms import ThirdPartyAuthForm +from api.models import ApiClient +from core.models import SithFile +from core.schemas import UserProfileSchema +from core.utils import hmac_hexdigest + + +class ThirdPartyAuthParamsSchema(Schema): + client_id: int + third_party_app: str + cgu_link: HttpUrl + username: str + callback_url: HttpUrl + signature: str + + +class ThirdPartyAuthView(LoginRequiredMixin, FormView): + form_class = ThirdPartyAuthForm + template_name = "api/third_party/auth.jinja" + success_url = reverse_lazy("core:index") + + def parse_params(self) -> ThirdPartyAuthParamsSchema: + """Parse and check the authentication parameters. + + Raises: + PermissionDenied: if the verification failed. + """ + # This is here rather than in ThirdPartyAuthForm because + # the given parameters and their signature are checked during both + # POST (for obvious reasons) and GET (in order not to make + # the user fill a form just to get an error he won't understand) + params = self.request.GET or self.request.POST + params = {key: unquote(val) for key, val in params.items()} + try: + params = ThirdPartyAuthParamsSchema(**params) + except pydantic.ValidationError as e: + raise PermissionDenied("Wrong data format") from e + client: ApiClient = get_object_or_none(ApiClient, id=params.client_id) + if not client: + raise PermissionDenied + if not hmac.compare_digest( + hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})), + params.signature, + ): + raise PermissionDenied("Bad signature") + return params + + def dispatch(self, request, *args, **kwargs): + self.params = self.parse_params() + return super().dispatch(request, *args, **kwargs) + + def get(self, *args, **kwargs): + messages.warning( + self.request, + _( + "You are going to link your AE account and your %(app)s account. " + "Continue only if this page was opened from %(app)s." + ) + % {"app": self.params.third_party_app}, + ) + return super().get(*args, **kwargs) + + def get_initial(self): + return self.params.model_dump() + + def form_valid(self, form): + client = ApiClient.objects.get(id=form.cleaned_data["client_id"]) + user = UserProfileSchema.from_orm(self.request.user).model_dump() + data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)} + response = requests.post(form.cleaned_data["callback_url"], json=data) + self.success_url = reverse( + "api-link:third-party-auth-result", + kwargs={"result": "success" if response.ok else "failure"}, + ) + return super().form_valid(form) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "third_party_app": self.params.third_party_app, + "third_party_cgu": self.params.cgu_link, + "sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID), + } + + +class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView): + """View that the user will see if its authentication on sith was successful. + + This can show either a success or a failure message : + - success : everything is good, the user is successfully authenticated + and can close the page + - failure : the authentication has been processed on the sith side, + but the request to the callback url received an error. + In such a case, there is nothing much we can do but to advice + the user to contact the developers of the third-party app. + """ + + template_name = "core/base.jinja" + success_message = _( + "You have been successfully authenticated. You can now close this page." + ) + error_message = _( + "Your authentication on the AE website was successful, " + "but an error happened during the interaction " + "with the third-party application. " + "Please contact the managers of the latter." + ) + + def get(self, request, *args, **kwargs): + if self.kwargs.get("result") == "success": + messages.success(request, self.success_message) + else: + messages.error(request, self.error_message) + return super().get(request, *args, **kwargs) diff --git a/sith/urls.py b/sith/urls.py index e66293735..af3203b31 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -34,6 +34,7 @@ path("", include(("core.urls", "core"), namespace="core")), path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}), path("api/", api.urls), + path("api-link/", include(("api.urls", "api-link"), namespace="api-link")), path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), path( "subscription/", From f781c00c5ed016b0b5daaf44ecdde787eba297a8 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 26 Oct 2025 16:47:24 +0100 Subject: [PATCH 08/14] write tests --- api/tests/test_admin.py | 24 +++++ api/tests/test_api_client_controller.py | 18 ++++ api/tests/test_client.py | 59 +++++++++++++ api/tests/test_third_party_auth.py | 111 ++++++++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 api/tests/test_admin.py create mode 100644 api/tests/test_api_client_controller.py create mode 100644 api/tests/test_client.py create mode 100644 api/tests/test_third_party_auth.py diff --git a/api/tests/test_admin.py b/api/tests/test_admin.py new file mode 100644 index 000000000..134484eed --- /dev/null +++ b/api/tests/test_admin.py @@ -0,0 +1,24 @@ +import pytest +from django.contrib.admin import AdminSite +from django.http import HttpRequest +from model_bakery import baker +from pytest_django.asserts import assertNumQueries + +from api.admin import ApiClientAdmin +from api.models import ApiClient + + +@pytest.mark.django_db +def test_reset_hmac_action(): + client_admin = ApiClientAdmin(ApiClient, AdminSite()) + api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True) + old_hmac_keys = [c.hmac_key for c in api_clients] + with assertNumQueries(2): + qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]]) + client_admin.reset_hmac_key(HttpRequest(), qs) + for c in api_clients: + c.refresh_from_db() + assert api_clients[0].hmac_key == old_hmac_keys[0] + assert api_clients[1].hmac_key == old_hmac_keys[1] + assert api_clients[2].hmac_key != old_hmac_keys[2] + assert api_clients[3].hmac_key != old_hmac_keys[3] diff --git a/api/tests/test_api_client_controller.py b/api/tests/test_api_client_controller.py new file mode 100644 index 000000000..6e25910dd --- /dev/null +++ b/api/tests/test_api_client_controller.py @@ -0,0 +1,18 @@ +import pytest +from django.test import Client +from django.urls import reverse +from model_bakery import baker + +from api.hashers import generate_key +from api.models import ApiClient, ApiKey +from api.schemas import ApiClientSchema + + +@pytest.mark.django_db +def test_api_client_controller(client: Client): + key, hashed = generate_key() + api_client = baker.make(ApiClient) + baker.make(ApiKey, client=api_client, hashed_key=hashed) + res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key}) + assert res.status_code == 200 + assert res.json() == ApiClientSchema.from_orm(api_client).model_dump() diff --git a/api/tests/test_client.py b/api/tests/test_client.py new file mode 100644 index 000000000..b813b06b5 --- /dev/null +++ b/api/tests/test_client.py @@ -0,0 +1,59 @@ +import pytest +from django.contrib.auth.models import Permission +from django.test import TestCase +from model_bakery import baker + +from api.models import ApiClient +from core.models import Group + + +class TestClientPermissions(TestCase): + @classmethod + def setUpTestData(cls): + cls.api_client = baker.make(ApiClient) + cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True) + cls.api_client.groups.set( + [ + baker.make(Group, permissions=cls.perms[0:3]), + baker.make(Group, permissions=cls.perms[3:5]), + ] + ) + cls.api_client.client_permissions.set( + [cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]] + ) + + def test_all_permissions(self): + assert self.api_client.all_permissions == { + f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8] + } + + def test_has_perm(self): + assert self.api_client.has_perm( + f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}" + ) + assert not self.api_client.has_perm( + f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}" + ) + + def test_has_perms(self): + assert self.api_client.has_perms( + [ + f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}", + f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}", + ] + ) + assert not self.api_client.has_perms( + [ + f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}", + f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}", + ], + ) + + +@pytest.mark.django_db +def test_reset_hmac_key(): + client = baker.make(ApiClient) + original_key = client.hmac_key + client.reset_hmac(commit=True) + assert len(client.hmac_key) == len(original_key) + assert client.hmac_key != original_key diff --git a/api/tests/test_third_party_auth.py b/api/tests/test_third_party_auth.py new file mode 100644 index 000000000..ad9a6927b --- /dev/null +++ b/api/tests/test_third_party_auth.py @@ -0,0 +1,111 @@ +from unittest import mock +from unittest.mock import Mock + +from django.db.models import Max +from django.test import TestCase +from django.urls import reverse +from model_bakery import baker +from pytest_django.asserts import assertRedirects + +from api.models import ApiClient, get_hmac_key +from core.baker_recipes import subscriber_user +from core.utils import hmac_hexdigest + + +def mocked_post(*, ok: bool): + class MockedResponse(Mock): + @property + def ok(self): + return ok + + def mocked(): + return MockedResponse() + + return mocked + + +class TestThirdPartyAuth(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = subscriber_user.make() + cls.api_client = baker.make(ApiClient) + + def setUp(self): + self.query = { + "client_id": self.api_client.id, + "third_party_app": "app", + "cgu_link": "https://foobar.fr/", + "username": "bibou", + "callback_url": "https://callback.fr/", + } + self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query) + self.callback_data = {"user_id": self.user.id} + self.callback_data["signature"] = hmac_hexdigest( + self.api_client.hmac_key, self.callback_data + ) + + def test_auth_ok(self): + self.client.force_login(self.user) + res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) + assert res.status_code == 200 + with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked: + res = self.client.post( + reverse("api-link:third-party-auth"), + data={"cgu_accepted": True, "is_username_valid": True, **self.query}, + ) + mocked.assert_called_once_with( + self.query["callback_url"], json=self.callback_data + ) + assertRedirects( + res, + reverse("api-link:third-party-auth-result", kwargs={"result": "success"}), + ) + + def test_callback_error(self): + """Test that the user see the failure page if the callback request failed.""" + self.client.force_login(self.user) + with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked: + res = self.client.post( + reverse("api-link:third-party-auth"), + data={"cgu_accepted": True, "is_username_valid": True, **self.query}, + ) + mocked.assert_called_once_with( + self.query["callback_url"], json=self.callback_data + ) + assertRedirects( + res, + reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}), + ) + + def test_wrong_signature(self): + """Test that a 403 is raised if the signature of the query is wrong.""" + self.client.force_login(subscriber_user.make()) + new_key = get_hmac_key() + del self.query["signature"] + self.query["signature"] = hmac_hexdigest(new_key, self.query) + res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) + assert res.status_code == 403 + + def test_cgu_not_accepted(self): + self.client.force_login(self.user) + res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) + assert res.status_code == 200 + res = self.client.post(reverse("api-link:third-party-auth"), data=self.query) + assert res.status_code == 200 # no redirect means invalid form + res = self.client.post( + reverse("api-link:third-party-auth"), + data={"cgu_accepted": False, "is_username_valid": False, **self.query}, + ) + assert res.status_code == 200 + + def test_invalid_client(self): + self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1 + res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) + assert res.status_code == 403 + + def test_missing_parameter(self): + """Test that a 403 is raised if there is a missing parameter.""" + del self.query["username"] + self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query) + res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) + assert res.status_code == 403 From 3fc95e88286505883bfe9964fed22895e92832d9 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 26 Oct 2025 16:51:30 +0100 Subject: [PATCH 09/14] translation: third-party authentication --- locale/fr/LC_MESSAGES/django.po | 82 +++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 5050adda0..b22e3eb9c 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -35,6 +35,10 @@ msgstr "" "True si gardé à jour par le biais d'un fournisseur externe de domains " "toxics, False sinon" +#: api/admin.py +msgid "Reset HMAC key" +msgstr "Réinitialiser la clef HMAC" + #: api/admin.py #, python-format msgid "" @@ -48,6 +52,23 @@ msgstr "" msgid "Revoke selected API keys" msgstr "Révoquer les clefs d'API sélectionnées" +#: api/forms.py +msgid "I have read and I accept the terms and conditions of use" +msgstr "J'ai lu et j'accepte les conditions générales d'utilisation." + +#: api/forms.py +msgid "You must approve the terms and conditions of use." +msgstr "Vous devez approuver les conditions générales d'utilisation." + +#: api/forms.py +msgid "You must confirm that this is your username." +msgstr "Vous devez confirmer que c'est bien votre nom d'utilisateur." + +#: api/forms.py +#, python-format +msgid "I confirm that %(username)s is my username on %(app)s" +msgstr "Je confirme que %(username)s est mon nom d'utilisateur sur %(app)s" + #: api/models.py club/models.py com/models.py counter/models.py forum/models.py msgid "name" msgstr "nom" @@ -68,6 +89,10 @@ msgstr "permissions du client" msgid "Specific permissions for this api client." msgstr "Permissions spécifiques pour ce client d'API" +#: api/models.py +msgid "HMAC Key" +msgstr "Clef HMAC" + #: api/models.py msgid "api client" msgstr "client d'api" @@ -97,6 +122,63 @@ msgstr "clef d'api" msgid "api keys" msgstr "clefs d'api" +#: api/templates/api/third_party/auth.jinja +msgid "Confidentiality" +msgstr "Confidentialité" + +#: api/templates/api/third_party/auth.jinja +#, python-format +msgid "" +"By ticking this box and clicking on the send button, you acknowledge and " +"agree to provide %(app)s with your first name, last name, nickname and any " +"other information that was the third party app was explicitly authorized to " +"fetch and that it must have acknowledged to you, in a complete and accurate " +"manner." +msgstr "" +"En cochant cette case et en cliquant sur le bouton « Envoyer », vous " +"reconnaissez et acceptez de fournir à %(app)s votre prénom, nom, pseudonyme " +"et toute autre information que l'application tierce a été explicitement " +"autorisée à récupérer et qu'elle doit vous avoir communiqué de manière " +"complète et exacte." + +#: api/templates/api/third_party/auth.jinja +#, python-format +msgid "" +"The privacy policies of %(app)s and of the Students' Association applies as soon as " +"the form is submitted." +msgstr "" +"Les politiques de confidentialité de %(app)s et de l'Association des Etudiants s'appliquent dès la soumission " +"du formulaire." + +#: api/templates/api/third_party/auth.jinja +msgid "Confirmation of identity" +msgstr "Confirmation d'identité" + +#: api/views.py +#, python-format +msgid "" +"You are going to link your AE account and your %(app)s account. Continue " +"only if this page was opened from %(app)s." +msgstr "" +"Vous allez lier votre compte AE et votre compte %(app)s. Poursuivez " +"uniquement si cette page a été ouverte depuis %(app)s." + +#: api/views.py +msgid "You have been successfully authenticated. You can now close this page." +msgstr "Vous avez été authentifié avec succès. Vous pouvez maintenant fermer cette page." + +#: api/views.py +msgid "" +"Your authentication on the AE website was successful, but an error happened " +"during the interaction with the third-party application. Please contact the " +"managers of the latter." +msgstr "" +"Votre authentification sur le site AE a fonctionné, mais une erreur est arrivée " +"durant l'interaction avec l'application tierce. Veuillez contacter les responsables " +"de cette dernière." + #: club/forms.py msgid "Users to add" msgstr "Utilisateurs à ajouter" From 99ed3f4a52f41a9b2c48071c73e48664d22eb202 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 30 Oct 2025 17:22:00 +0100 Subject: [PATCH 10/14] doc: third-party auth --- api/forms.py | 2 +- api/models.py | 6 +- api/schemas.py | 13 +- api/templates/api/third_party/auth.jinja | 4 +- api/tests/test_third_party_auth.py | 9 +- api/views.py | 14 +- core/utils.py | 6 +- docs/reference/api/schemas.md | 1 + docs/reference/api/views.md | 1 + docs/tutorial/api/account-link.md | 353 +++++++++++++++++++++++ docs/tutorial/api/connect.md | 8 +- locale/fr/LC_MESSAGES/django.po | 4 +- mkdocs.yml | 3 + 13 files changed, 394 insertions(+), 30 deletions(-) create mode 100644 docs/reference/api/schemas.md create mode 100644 docs/reference/api/views.md create mode 100644 docs/tutorial/api/account-link.md diff --git a/api/forms.py b/api/forms.py index 1f6d7de07..6bd3b5f19 100644 --- a/api/forms.py +++ b/api/forms.py @@ -23,7 +23,7 @@ class ThirdPartyAuthForm(forms.Form): ) client_id = forms.IntegerField(widget=HiddenInput()) third_party_app = forms.CharField(widget=HiddenInput()) - cgu_link = forms.URLField(widget=HiddenInput()) + privacy_link = forms.URLField(widget=HiddenInput()) username = forms.CharField(widget=HiddenInput()) callback_url = forms.URLField(widget=HiddenInput()) signature = forms.CharField(widget=HiddenInput()) diff --git a/api/models.py b/api/models.py index c0d9c2913..98a19d6ba 100644 --- a/api/models.py +++ b/api/models.py @@ -66,7 +66,11 @@ def has_perms(self, perm_list: Iterable[str]) -> bool: return all(self.has_perm(perm) for perm in perm_list) def reset_hmac(self, *, commit: bool = True) -> str: - """Reset and return the HMAC key for this client.""" + """Reset and return the HMAC key for this client. + + Args: + commit: if True (the default), persist the new hmac in db. + """ self.hmac_key = get_hmac_key() if commit: self.save() diff --git a/api/schemas.py b/api/schemas.py index 376e90a9e..b39bb5c32 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -1,5 +1,5 @@ -from ninja import ModelSchema -from pydantic import Field +from ninja import ModelSchema, Schema +from pydantic import Field, HttpUrl from api.models import ApiClient from core.schemas import SimpleUserSchema @@ -12,3 +12,12 @@ class Meta: owner: SimpleUserSchema permissions: list[str] = Field(alias="all_permissions") + + +class ThirdPartyAuthParamsSchema(Schema): + client_id: int + third_party_app: str + privacy_link: HttpUrl + username: str + callback_url: HttpUrl + signature: str diff --git a/api/templates/api/third_party/auth.jinja b/api/templates/api/third_party/auth.jinja index e4e9e4f6f..3e7124343 100644 --- a/api/templates/api/third_party/auth.jinja +++ b/api/templates/api/third_party/auth.jinja @@ -14,8 +14,8 @@ {% endtrans %}

- {% trans trimmed app=third_party_app, cgu_link=third_party_cgu, sith_cgu_link=sith_cgu %} - The privacy policies of {{ app }} + {% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %} + The privacy policies of {{ app }} and of the Students' Association applies as soon as the form is submitted. {% endtrans %} diff --git a/api/tests/test_third_party_auth.py b/api/tests/test_third_party_auth.py index ad9a6927b..39faebce2 100644 --- a/api/tests/test_third_party_auth.py +++ b/api/tests/test_third_party_auth.py @@ -9,6 +9,7 @@ from api.models import ApiClient, get_hmac_key from core.baker_recipes import subscriber_user +from core.schemas import UserProfileSchema from core.utils import hmac_hexdigest @@ -34,14 +35,16 @@ def setUp(self): self.query = { "client_id": self.api_client.id, "third_party_app": "app", - "cgu_link": "https://foobar.fr/", + "privacy_link": "https://foobar.fr/", "username": "bibou", "callback_url": "https://callback.fr/", } self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query) - self.callback_data = {"user_id": self.user.id} + self.callback_data = { + "user": UserProfileSchema.from_orm(self.user).model_dump() + } self.callback_data["signature"] = hmac_hexdigest( - self.api_client.hmac_key, self.callback_data + self.api_client.hmac_key, self.callback_data["user"] ) def test_auth_ok(self): diff --git a/api/views.py b/api/views.py index 772c7d913..6b66db038 100644 --- a/api/views.py +++ b/api/views.py @@ -10,26 +10,16 @@ from django.urls import reverse, reverse_lazy from django.utils.translation import gettext as _ from django.views.generic import FormView, TemplateView -from ninja import Schema from ninja_extra.shortcuts import get_object_or_none -from pydantic import HttpUrl from api.forms import ThirdPartyAuthForm from api.models import ApiClient +from api.schemas import ThirdPartyAuthParamsSchema from core.models import SithFile from core.schemas import UserProfileSchema from core.utils import hmac_hexdigest -class ThirdPartyAuthParamsSchema(Schema): - client_id: int - third_party_app: str - cgu_link: HttpUrl - username: str - callback_url: HttpUrl - signature: str - - class ThirdPartyAuthView(LoginRequiredMixin, FormView): form_class = ThirdPartyAuthForm template_name = "api/third_party/auth.jinja" @@ -93,7 +83,7 @@ def form_valid(self, form): def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) | { "third_party_app": self.params.third_party_app, - "third_party_cgu": self.params.cgu_link, + "third_party_cgu": self.params.privacy_link, "sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID), } diff --git a/core/utils.py b/core/utils.py index 8205c4366..68a055b77 100644 --- a/core/utils.py +++ b/core/utils.py @@ -203,14 +203,14 @@ def get_client_ip(request: HttpRequest) -> str | None: def hmac_hexdigest( key: str | bytes, data: Mapping[str, Any] | Sequence[tuple[str, Any]], - digest: str | Callable[[Buffer], HASH] = "sha256", + digest: str | Callable[[Buffer], HASH] = "sha512", ) -> str: """Return the hexdigest of the signature of the given data. Args: key: the HMAC key used for the signature data: the data to sign - digest: a PEP247 hashing algorithm + digest: a PEP247 hashing algorithm (by default, sha512) Examples: ```python @@ -219,7 +219,7 @@ def hmac_hexdigest( "bar": "somevalue", } hmac_key = secrets.token_hex(64) - signature = hmac_hexdigest(hmac_key, data, "sha512") + signature = hmac_hexdigest(hmac_key, data, "sha256") ``` """ if isinstance(key, str): diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md new file mode 100644 index 000000000..c01084395 --- /dev/null +++ b/docs/reference/api/schemas.md @@ -0,0 +1 @@ +::: api.schemas \ No newline at end of file diff --git a/docs/reference/api/views.md b/docs/reference/api/views.md new file mode 100644 index 000000000..2a0daef1a --- /dev/null +++ b/docs/reference/api/views.md @@ -0,0 +1 @@ +::: api.views \ No newline at end of file diff --git a/docs/tutorial/api/account-link.md b/docs/tutorial/api/account-link.md new file mode 100644 index 000000000..2a125824e --- /dev/null +++ b/docs/tutorial/api/account-link.md @@ -0,0 +1,353 @@ +Le site AE offre des mécanismes permettant aux applications tierces +de récupérer les informations sur un utilisateur du site AE. +De cette manière, il devient possible de synchroniser les informations +qu possède l'application tierce sur l'utilisateur, directement depuis +le site AE. + +## Fonctionnement général + +Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web +et d'un client d'API (celui auquel est liée votre +[clef d'API](./connect.md#obtenir-une-clef-dapi)). +Deux informations vous sont nécessaires, en plus de votre clef d'API : + +- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info, + soit en appelant la route `GET /client/me` avec votre clef d'API + renseignée dans le header [X-APIKey](./connect.md#x-apikey) +- la clef HMAC du client : vous devez la demander à l'équipe info. + +Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire +au site AE pour qu'il authentifie vos utilisateurs. + +En effet, la démarche d'authentification s'effectue presque entièrement +sur le site : le travail de l'application tierce consiste uniquement +à fournir à l'utilisateur une url avec les bons paramètres, puis +à recevoir la réponse du serveur si tout s'est bien passé. + +Comme un dessin vaut parfois mieux que mille mots, +voici les diagrammes décrivant le processus. +L'un montre l'entièreté de la démarche ; +l'autre dans un souci de simplicité, ne montre que ce qui est visible +directement par l'application tierce. + +=== "Intégralité du processus" + + ```mermaid + sequenceDiagram + actor User + participant App + User->>+App: Authentifie-moi, stp + App-->>-User: url de connexion
avec signature + User->>+Sith: GET url + opt Utilisateur non-connecté + Sith->>+User: Formulaire de connexion + User-->>-Sith: Connexion + end + Sith->>Sith: vérification de la signature + Sith->>+User: Formulaire
des conditions
d'utilisation + User-->>-Sith: Validation + Sith->>+App: URL de retour
avec données utilisateur + App->>App: Traitement des
données utilisateur + App-->>-Sith: 204 OK, No content + Sith-->>-User: Message de succès + App--)User: Message de succès + ``` + +=== "Point de vue de l'application tierce" + + ```mermaid + sequenceDiagram + actor User + participant App + User->>+App: Authentifie-moi, stp + App-->>-User: url de connexion
avec signature + opt + Sith->>+App: URL de retour
avec données utilisateur + App->>App: Traitement des
données utilisateur + App-->>-Sith: 204 OK, No content + App--)User: Message de succès + end + ``` + +## Données attendues + +### URL de connexion + +L'URL de connexion que vous allez fournir à l'utilisateur doit +être `https://ae.utbm.fr/api-link/auth/` +et doit contenir les données décrites dans +[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] : + +- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir + de la manière décrite plus haut +- `third_party_app`(string) : le nom de la plateforme pour laquelle + l'authentification va être réalisée (si votre application est un bot + discord, mettez la valeur "discord") +- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité + qui s'appliquera dans le cadre de l'application + (s'il s'agit d'un bot discord, donnez le lien vers celles de Discord) +- `username`(string) : le pseudonyme que l'utilisateur possède sur + votre application +- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification + réussit +- `signature`(string) : la signature des données de la requête. + +Ces données doivent être url-encodées et passées dans les paramètres GET. + +!!!tip "URL de retour" + + Notre système n'impose aucune contrainte quant à la manière + de construire votre URL (hormis le fait que ce doit être une URL HTTPS valide), + mais il est tout de même conseillé d'utiliser l'identifiant de votre + utilisateur comme paramètre dans l'URL + (par exemple `GET /callback/{int:user_id}/`). + +???Example + + Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord, + avec les données suivantes : + + - l'id du client est 15 + - sa clef HMAC est "beb99dd53" + (c'est pour l'exemple, une vraie clef sera beaucoup plus longue) + - le pseudonyme discord de votre utilisateur est Brian + - son id sur discord est 123456789 + - votre route de callback est `GET /callback/{int:user_id}/`, + accessible au domaine `https://bot.ae.utbm.fr` + + Alors les paramètres de votre URL seront : + + | Paramètre | valeur | + |-----------------|-----------------------------------------------------------------------| + | client_id | 15 | + | third_party_app | discord | + | privacy_link | `https://discord.com/privacy` | + | username | Brian | + | callback_url | `https://bot.ae.utbm.fr/callback/123456789/` | + | signature | 1a383c51060be64f07772aa42e07
18ae096b8f21f2cdb4061c0834a416d12101 | + + Et l'url fournie à l'utilisateur sera : + + `https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord + &privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian + &callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F + &signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101` + +### Données de retour + +Si l'authentification réussit, le site AE enverra une requête HTTP POST +à l'URL de retour fournie dans l'URL de connexion. + +Le corps de la requête de callback et au format JSON +et contient deux paires clef-valeur : + +- `user` : les données utilisateur, telles que décrites + par [UserProfileSchema][core.schemas.UserProfileSchema] +- `signature` : la signature des données utilisateur + +???Example + + En reprenant les mêmes paramètres que dans l'exemple précédent, + le site AE pourra renvoyer à l'application la requête suivante : + + ```http + POST https://bot.ae.utbm.fr/callback/123456789/ + content-type: application/json + body: { + "user": { + "id": 144131, + "nick_name": "inzekitchen", + "first_name": "Brian", + ... + }, + "signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020" + } + ``` + +L'application doit répondre avec un des codes HTTP suivants : + +| Code | Raison | +|------|--------------------------------------------------------------------------------| +| 204 | Tout s'est bien passé | +| 403 | Les données de retour ne sont
pas signées ou sont mal signées | +| 404 | L'URL de retour ne permet pas
d'identifier un utilisateur de l'application | + +!!!note "Code d'erreur par défaut" + + Si l'appel de la route fait face à plusieurs problèmes en même temps + (par exemple, l'URL ne permet pas de retrouver votre utilisateur, + et en plus les données sont mal signées), + le 403 prime et doit être retourné par défaut. + +## Signature des données + +Les données de l'URL de connexion doivent être signées, +et la signature de l'URL de retour doit être vérifiée. + +Dans le deux cas, la signature est le digest HMAC-SHA512 +des données url-encodées, en utilisant la clef HMAC du client d'API. + +???Example "Signature de l'URL de connexion" + + En reprenant le même exemple que les fois précédentes, + l'url-encodage des données est : + + `client_id=15&third_party_app=discord + &privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian + &callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F` + + Notez que la signature n'est pas (encore) dedans. + Cette dernière peut-être obtenue avec le code suivant : + + === ":simple-python: Python" + + Dépendances : + + - `environs` (>=14.1) + + ```python + import hmac + from urllib.parse import urlencode + + from environs import Env + + env = Env() + env.read_env() + + key = env.str("HMAC_KEY").encode() + data = { + "client_id": 15, + "third_party_app": "discord", + "privacy_link": "https://discord.com/privacy/", + "username": "Brian", + "callback_url": "https://bot.ae.utbm.fr/callback/123456789/", + } + urlencoded = urlencode(data) + data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex() + + # URL a fournir à l'utilisateur pour son authentification + user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}" + ``` + + === ":simple-rust: Rust" + + Dépendances : + + - `hmac` (>=0.12.1) + - `url` (>=2.5.7, features `serde`) + - `serde` (>=1.0.228, features `derive`) + - `serde_urlencoded` (>="0.7.1) + - `sha2` (>=0.10.9) + - `dotenvy` (>= 0.15) + + ```rust + use hmac::{Mac, SimpleHmac}; + use serde::Serialize; + use sha2::Sha512; + use url::Url; + + #[derive(Serialize, Debug)] + struct UrlData<'a> { + client_id: u32, + third_party_app: &'a str, + privacy_link: Url, + username: &'a str, + callback_url: Url, + } + + impl<'a> UrlData<'a> { + pub fn signature(&self, key: &[u8]) -> CtOutput> { + let urlencoded = serde_urlencoded::to_string(self).unwrap(); + SimpleHmac::::new_from_slice(key) + .unwrap() + .chain_update(urlencoded.as_bytes()) + .finalize() + } + } + + impl Into for UrlData<'_> { + fn into(self) -> Url { + let key = std::env::var("HMAC_KEY").unwrap(); + let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap(); + url.set_query(Some( + format!( + "{}&signature={:x}", + serde_urlencoded::to_string(&self).unwrap(), + self.signature(key.as_bytes()).into_bytes() + ) + .as_str(), + )); + url + } + } + + fn main() { + dotenvy::dotenv().expect("Couldn't load env"); + let data = UrlData { + client_id: 1, + third_party_app: "discord", + privacy_link: "https://discord.com/privacy/".parse().unwrap(), + username: "Brian", + callback_url: "https://bot.ae.utbm.fr/callback/123456789/" + .parse() + .unwrap(), + }; + let url: Url = data.into(); + println!("{:?}", url); + } + ``` + +???Example "Vérification de la signature de la réponse" + + Les données utilisateur peuvent ressembler à : + + ```json + { + "user": { + "display_name": "Matthieu Vincent", + "profile_url": "/user/380/", + "profile_pict": "/static/core/img/unknown.jpg", + "id": 380, + "nick_name": None, + "first_name": "Matthieu", + "last_name": "Vincent", + }, + "signature": "3802a280fbb01bd9fetc." + } + ``` + + Vous pouvez vérifier la signature ainsi : + + ```python + import hmac + from urllib.parse import urlencode + + from environs import Env + + env = Env() + env.read_env() + + def is_signature_valid(user_data: dict, signature: str) -> bool: + key = env.str("HMAC_KEY").encode() + urlencoded = urlencode(user_data) + return hmac.compare_digest( + hmac.digest(key, urlencoded.encode(), "sha512").hex(), + signature, + ) + + + post_data = + print( + "signature valide :", + is_signature_valid(post_data["user"], post_data["signature"] + ) + ``` + +!!!Warning + + Vous devez impérativement vérifier la signature + des données de la requête de callback ! + + Si l'équipe informatique se rend compte que vous ne le faites pas, + elle se réserve le droit de suspendre votre application, + immédiatement et sans préavis. diff --git a/docs/tutorial/api/connect.md b/docs/tutorial/api/connect.md index 8ce52bddf..8d1d913d2 100644 --- a/docs/tutorial/api/connect.md +++ b/docs/tutorial/api/connect.md @@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_ Voici quelques exemples : -=== "Python (requests)" +=== ":simple-python: Python (requests)" Dépendances : @@ -132,7 +132,7 @@ Voici quelques exemples : print(response.json()) ``` -=== "Python (aiohttp)" +=== ":simple-python: Python (aiohttp)" Dépendances : @@ -158,7 +158,7 @@ Voici quelques exemples : asyncio.run(main()) ``` -=== "Javascript (axios)" +=== ":simple-javascript: Javascript (axios)" Dépendances : @@ -178,7 +178,7 @@ Voici quelques exemples : console.log(await instance.get("club/1").json()); ``` -=== "Rust (reqwest)" +=== ":simple-rust: Rust (reqwest)" Dépendances : diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index b22e3eb9c..4ee742ab3 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -144,11 +144,11 @@ msgstr "" #: api/templates/api/third_party/auth.jinja #, python-format msgid "" -"The privacy policies of %(app)s and of %(app)s and of the Students' Association applies as soon as " "the form is submitted." msgstr "" -"Les politiques de confidentialité de %(app)s et de %(app)s et de l'Association des Etudiants s'appliquent dès la soumission " "du formulaire." diff --git a/mkdocs.yml b/mkdocs.yml index cd6df0dea..4e4cfa3ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,7 @@ nav: - API: - Développement: tutorial/api/dev.md - Connexion à l'API: tutorial/api/connect.md + - Liaison avec le compte AE: tutorial/api/account-link.md - Etransactions: tutorial/etransaction.md - How-to: - L'ORM de Django: howto/querysets.md @@ -94,6 +95,8 @@ nav: - reference/api/hashers.md - reference/api/models.md - reference/api/perms.md + - reference/api/schemas.md + - reference/api/views.md - club: - reference/club/models.md - reference/club/views.md From 18b2c93d11e2a2c70d080c34b87859559eb25795 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 4 Apr 2026 19:24:53 +0200 Subject: [PATCH 11/14] tweak documentation --- docs/tutorial/api/account-link.md | 33 ++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/tutorial/api/account-link.md b/docs/tutorial/api/account-link.md index 2a125824e..04d1920e9 100644 --- a/docs/tutorial/api/account-link.md +++ b/docs/tutorial/api/account-link.md @@ -12,7 +12,7 @@ et d'un client d'API (celui auquel est liée votre Deux informations vous sont nécessaires, en plus de votre clef d'API : - l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info, - soit en appelant la route `GET /client/me` avec votre clef d'API + soit en appelant la route `GET /api/client/me` avec votre clef d'API renseignée dans le header [X-APIKey](./connect.md#x-apikey) - la clef HMAC du client : vous devez la demander à l'équipe info. @@ -91,16 +91,29 @@ et doit contenir les données décrites dans - `callback_url`(URL) : l'URL que le site AE appellera si l'authentification réussit - `signature`(string) : la signature des données de la requête. + Il s'agit d'une signature par clef HMAC dont le fonctionnement + est détaillé plus bas. Ces données doivent être url-encodées et passées dans les paramètres GET. -!!!tip "URL de retour" +!!!warning "URL de retour" - Notre système n'impose aucune contrainte quant à la manière - de construire votre URL (hormis le fait que ce doit être une URL HTTPS valide), - mais il est tout de même conseillé d'utiliser l'identifiant de votre - utilisateur comme paramètre dans l'URL - (par exemple `GET /callback/{int:user_id}/`). + Les URLs fournies doivent être des URLs HTTP valides. + En outre, elles doivent obligatoirement inclure la barre oblique finale. + + === "URL correcte ✔️" + + `https://exemple.ae.utbm.fr/foo/` + + === "URL incorrecte ❌" + + `https://exemple.ae.utbm.fr/foo` + +!!!tip + + Inclure l'id de votre utilisateur dans l'URL de retour + peut être un bon moyen de l'identifier lors du callback. + Par exemple : `GET /callback/{int:user_id}/`. ???Example @@ -186,6 +199,8 @@ et la signature de l'URL de retour doit être vérifiée. Dans le deux cas, la signature est le digest HMAC-SHA512 des données url-encodées, en utilisant la clef HMAC du client d'API. +L'ordre dans lequel ces données sont placées dans l'encodage URL +doit être strictement le même que celui donné plus haut. ???Example "Signature de l'URL de connexion" @@ -348,6 +363,10 @@ des données url-encodées, en utilisant la clef HMAC du client d'API. Vous devez impérativement vérifier la signature des données de la requête de callback ! + Ne pas vérifier la signature permet à n'importe quel acteur + tierce malveillant de vous appeler sur votre callback. + Ce serait une faille de sécurité majeure de votre côté. + Si l'équipe informatique se rend compte que vous ne le faites pas, elle se réserve le droit de suspendre votre application, immédiatement et sans préavis. From 7c66fffbfb41a9af057ac2769f74ddbde39861ed Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 26 Apr 2026 22:35:13 +0200 Subject: [PATCH 12/14] apply review comments --- api/tests/test_third_party_auth.py | 28 +++++++++++++--- api/views.py | 51 +++++++++++++++++++++++------- locale/fr/LC_MESSAGES/django.po | 27 ++++++++++++---- 3 files changed, 83 insertions(+), 23 deletions(-) diff --git a/api/tests/test_third_party_auth.py b/api/tests/test_third_party_auth.py index 39faebce2..1fca8ccbd 100644 --- a/api/tests/test_third_party_auth.py +++ b/api/tests/test_third_party_auth.py @@ -1,6 +1,7 @@ from unittest import mock from unittest.mock import Mock +from django.contrib.messages import Message, get_messages from django.db.models import Max from django.test import TestCase from django.urls import reverse @@ -87,7 +88,15 @@ def test_wrong_signature(self): del self.query["signature"] self.query["signature"] = hmac_hexdigest(new_key, self.query) res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) - assert res.status_code == 403 + assert list(get_messages(res.wsgi_request)) == [ + Message( + level=40, + message=( + "La signature est incorrecte. " + "Nous ne pouvons pas garantir l'authenticité de la requête." + ), + ) + ] def test_cgu_not_accepted(self): self.client.force_login(self.user) @@ -102,13 +111,24 @@ def test_cgu_not_accepted(self): assert res.status_code == 200 def test_invalid_client(self): + self.client.force_login(self.user) self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1 res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) - assert res.status_code == 403 + assert list(get_messages(res.wsgi_request)) == [ + Message( + level=40, + message="Les données fournies pour l'authentification sont incorrectes.", + ) + ] def test_missing_parameter(self): - """Test that a 403 is raised if there is a missing parameter.""" + self.client.force_login(self.user) del self.query["username"] self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query) res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) - assert res.status_code == 403 + assert list(get_messages(res.wsgi_request)) == [ + Message( + level=40, + message="Les données fournies pour l'authentification sont incorrectes.", + ) + ] diff --git a/api/views.py b/api/views.py index 6b66db038..9f519eaaf 100644 --- a/api/views.py +++ b/api/views.py @@ -3,10 +3,11 @@ import pydantic import requests +import sentry_sdk from django.conf import settings from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied +from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin +from django.shortcuts import render from django.urls import reverse, reverse_lazy from django.utils.translation import gettext as _ from django.views.generic import FormView, TemplateView @@ -20,16 +21,19 @@ from core.utils import hmac_hexdigest -class ThirdPartyAuthView(LoginRequiredMixin, FormView): +class ThirdPartyAuthView(AccessMixin, FormView): form_class = ThirdPartyAuthForm template_name = "api/third_party/auth.jinja" success_url = reverse_lazy("core:index") - def parse_params(self) -> ThirdPartyAuthParamsSchema: + def parse_params(self) -> ThirdPartyAuthParamsSchema | None: """Parse and check the authentication parameters. - Raises: - PermissionDenied: if the verification failed. + If parsing fails, messages will be created using the django message + infrastructure. + + Returns: + The parses parameters, or None if the parsing failed. """ # This is here rather than in ThirdPartyAuthForm because # the given parameters and their signature are checked during both @@ -39,20 +43,39 @@ def parse_params(self) -> ThirdPartyAuthParamsSchema: params = {key: unquote(val) for key, val in params.items()} try: params = ThirdPartyAuthParamsSchema(**params) - except pydantic.ValidationError as e: - raise PermissionDenied("Wrong data format") from e + except pydantic.ValidationError: + messages.error( + self.request, _("The data provided for authentication is incorrect") + ) + return None client: ApiClient = get_object_or_none(ApiClient, id=params.client_id) if not client: - raise PermissionDenied + messages.error( + self.request, _("The data provided for authentication is incorrect") + ) + return None if not hmac.compare_digest( hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})), params.signature, ): - raise PermissionDenied("Bad signature") + messages.error( + self.request, + _( + "The signature is incorrect. " + "We cannot ensure the provenance of the request." + ), + ) + return None return params def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() self.params = self.parse_params() + if not self.params: + # if parameters parsing failed, shortcut the operation and display + # an empty page with just the error messages. + return render(request, "core/base.jinja") return super().dispatch(request, *args, **kwargs) def get(self, *args, **kwargs): @@ -73,10 +96,14 @@ def form_valid(self, form): client = ApiClient.objects.get(id=form.cleaned_data["client_id"]) user = UserProfileSchema.from_orm(self.request.user).model_dump() data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)} - response = requests.post(form.cleaned_data["callback_url"], json=data) + try: + ok = requests.post(form.cleaned_data["callback_url"], json=data).ok + except requests.RequestException as e: + sentry_sdk.capture_exception(e) + ok = False self.success_url = reverse( "api-link:third-party-auth-result", - kwargs={"result": "success" if response.ok else "failure"}, + kwargs={"result": "success" if ok else "failure"}, ) return super().form_valid(form) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 4ee742ab3..279485a68 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -148,14 +148,25 @@ msgid "" "href=\"%(sith_cgu_link)s\">the Students' Association applies as soon as " "the form is submitted." msgstr "" -"Les politiques de confidentialité de %(app)s et de l'Association des Etudiants s'appliquent dès la soumission " -"du formulaire." +"Les politiques de confidentialité de %(app)s et de l'Association des Etudiants " +"s'appliquent dès la soumission du formulaire." #: api/templates/api/third_party/auth.jinja msgid "Confirmation of identity" msgstr "Confirmation d'identité" +#: api/views.py +msgid "The data provided for authentication is incorrect" +msgstr "Les données fournies pour l'authentification sont incorrectes." + +#: api/views.py +msgid "" +"The signature is incorrect. We cannot ensure the provenance of the request." +msgstr "" +"La signature est incorrecte. Nous ne pouvons pas garantir l'authenticité de " +"la requête." + #: api/views.py #, python-format msgid "" @@ -167,7 +178,9 @@ msgstr "" #: api/views.py msgid "You have been successfully authenticated. You can now close this page." -msgstr "Vous avez été authentifié avec succès. Vous pouvez maintenant fermer cette page." +msgstr "" +"Vous avez été authentifié avec succès. Vous pouvez maintenant fermer cette " +"page." #: api/views.py msgid "" @@ -175,9 +188,9 @@ msgid "" "during the interaction with the third-party application. Please contact the " "managers of the latter." msgstr "" -"Votre authentification sur le site AE a fonctionné, mais une erreur est arrivée " -"durant l'interaction avec l'application tierce. Veuillez contacter les responsables " -"de cette dernière." +"Votre authentification sur le site AE a fonctionné, mais une erreur est " +"arrivée durant l'interaction avec l'application tierce. Veuillez contacter " +"les responsables de cette dernière." #: club/forms.py msgid "Users to add" From 1640742ce2fe30fc386a33cdea74d1cc7f08c886 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 3 Jul 2026 12:12:56 +0200 Subject: [PATCH 13/14] fix: don't send callback request if data has been modified --- api/tests/test_third_party_auth.py | 18 ++++++++++++------ api/views.py | 10 +++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/api/tests/test_third_party_auth.py b/api/tests/test_third_party_auth.py index 1fca8ccbd..3cd4f6ebd 100644 --- a/api/tests/test_third_party_auth.py +++ b/api/tests/test_third_party_auth.py @@ -47,14 +47,15 @@ def setUp(self): self.callback_data["signature"] = hmac_hexdigest( self.api_client.hmac_key, self.callback_data["user"] ) + self.url = reverse("api-link:third-party-auth", query=self.query) def test_auth_ok(self): self.client.force_login(self.user) - res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) + res = self.client.get(self.url) assert res.status_code == 200 with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked: res = self.client.post( - reverse("api-link:third-party-auth"), + self.url, data={"cgu_accepted": True, "is_username_valid": True, **self.query}, ) mocked.assert_called_once_with( @@ -70,7 +71,7 @@ def test_callback_error(self): self.client.force_login(self.user) with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked: res = self.client.post( - reverse("api-link:third-party-auth"), + self.url, data={"cgu_accepted": True, "is_username_valid": True, **self.query}, ) mocked.assert_called_once_with( @@ -98,14 +99,17 @@ def test_wrong_signature(self): ) ] + res = self.client.post(self.url, data=self.query) + assert res.status_code == 200 + def test_cgu_not_accepted(self): self.client.force_login(self.user) - res = self.client.get(reverse("api-link:third-party-auth", query=self.query)) + res = self.client.get(self.url) assert res.status_code == 200 - res = self.client.post(reverse("api-link:third-party-auth"), data=self.query) + res = self.client.post(self.url, data=self.query) assert res.status_code == 200 # no redirect means invalid form res = self.client.post( - reverse("api-link:third-party-auth"), + self.url, data={"cgu_accepted": False, "is_username_valid": False, **self.query}, ) assert res.status_code == 200 @@ -132,3 +136,5 @@ def test_missing_parameter(self): message="Les données fournies pour l'authentification sont incorrectes.", ) ] + res = self.client.post(self.url, data=self.query) + assert res.status_code == 200 diff --git a/api/views.py b/api/views.py index 9f519eaaf..a587ad8e8 100644 --- a/api/views.py +++ b/api/views.py @@ -39,8 +39,8 @@ def parse_params(self) -> ThirdPartyAuthParamsSchema | None: # the given parameters and their signature are checked during both # POST (for obvious reasons) and GET (in order not to make # the user fill a form just to get an error he won't understand) - params = self.request.GET or self.request.POST - params = {key: unquote(val) for key, val in params.items()} + params = self.request.GET if self.request.method == "GET" else self.request.POST + params = {key: unquote(val) for key, val in params.dict().items()} try: params = ThirdPartyAuthParamsSchema(**params) except pydantic.ValidationError: @@ -48,7 +48,7 @@ def parse_params(self) -> ThirdPartyAuthParamsSchema | None: self.request, _("The data provided for authentication is incorrect") ) return None - client: ApiClient = get_object_or_none(ApiClient, id=params.client_id) + client: ApiClient | None = get_object_or_none(ApiClient, id=params.client_id) if not client: messages.error( self.request, _("The data provided for authentication is incorrect") @@ -71,11 +71,11 @@ def parse_params(self) -> ThirdPartyAuthParamsSchema | None: def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated: return self.handle_no_permission() - self.params = self.parse_params() - if not self.params: + if (params := self.parse_params()) is None: # if parameters parsing failed, shortcut the operation and display # an empty page with just the error messages. return render(request, "core/base.jinja") + self.params = params return super().dispatch(request, *args, **kwargs) def get(self, *args, **kwargs): From 9f2615727cfa6ce6174e7fa54b26ba3386acb59e Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 15 Jul 2026 12:53:24 +0200 Subject: [PATCH 14/14] apply docs review comments --- docs/tutorial/api/account-link.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/tutorial/api/account-link.md b/docs/tutorial/api/account-link.md index 04d1920e9..96eceb613 100644 --- a/docs/tutorial/api/account-link.md +++ b/docs/tutorial/api/account-link.md @@ -16,6 +16,12 @@ Deux informations vous sont nécessaires, en plus de votre clef d'API : renseignée dans le header [X-APIKey](./connect.md#x-apikey) - la clef HMAC du client : vous devez la demander à l'équipe info. +Ces deux éléments ont une fonction différente : l'id du client permet +de dire au serveur quelle est l'application qui s'adresse à lui, +tandis que la [clef HMAC](https://fr.wikipedia.org/wiki/HMAC) +servira à créer une signature unique permettant de s'assurer +que les données transmises n'ont pas été falsifiées. + Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire au site AE pour qu'il authentifie vos utilisateurs. @@ -251,7 +257,7 @@ doit être strictement le même que celui donné plus haut. - `hmac` (>=0.12.1) - `url` (>=2.5.7, features `serde`) - `serde` (>=1.0.228, features `derive`) - - `serde_urlencoded` (>="0.7.1) + - `serde_urlencoded` (>=0.7.1) - `sha2` (>=0.10.9) - `dotenvy` (>= 0.15) @@ -354,7 +360,7 @@ doit être strictement le même que celui donné plus haut. post_data = print( "signature valide :", - is_signature_valid(post_data["user"], post_data["signature"] + is_signature_valid(post_data["user"], post_data["signature"]) ) ```