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/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/forms.py b/api/forms.py
new file mode 100644
index 000000000..6bd3b5f19
--- /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())
+ privacy_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/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 36e20287d..98a19d6ba 100644
--- a/api/models.py
+++ b/api/models.py
@@ -1,13 +1,20 @@
+import secrets
from typing import Iterable
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
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(
@@ -26,11 +33,10 @@ 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)
- _perm_cache: set[str] | None = None
-
class Meta:
verbose_name = _("api client")
verbose_name_plural = _("api clients")
@@ -38,33 +44,38 @@ 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)
+ def reset_hmac(self, *, commit: bool = True) -> str:
+ """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()
+ return self.hmac_key
+
class ApiKey(models.Model):
PREFIX_LENGTH = 5
diff --git a/api/schemas.py b/api/schemas.py
new file mode 100644
index 000000000..b39bb5c32
--- /dev/null
+++ b/api/schemas.py
@@ -0,0 +1,23 @@
+from ninja import ModelSchema, Schema
+from pydantic import Field, HttpUrl
+
+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")
+
+
+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
new file mode 100644
index 000000000..3e7124343
--- /dev/null
+++ b/api/templates/api/third_party/auth.jinja
@@ -0,0 +1,32 @@
+{% extends "core/base.jinja" %}
+
+{% block content %}
+
+{% endblock %}
\ No newline at end of file
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..3cd4f6ebd
--- /dev/null
+++ b/api/tests/test_third_party_auth.py
@@ -0,0 +1,140 @@
+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
+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.schemas import UserProfileSchema
+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",
+ "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": UserProfileSchema.from_orm(self.user).model_dump()
+ }
+ 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(self.url)
+ assert res.status_code == 200
+ with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
+ res = self.client.post(
+ self.url,
+ 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(
+ self.url,
+ 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 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."
+ ),
+ )
+ ]
+
+ 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(self.url)
+ assert res.status_code == 200
+ res = self.client.post(self.url, data=self.query)
+ assert res.status_code == 200 # no redirect means invalid form
+ res = self.client.post(
+ self.url,
+ data={"cgu_accepted": False, "is_username_valid": False, **self.query},
+ )
+ 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 list(get_messages(res.wsgi_request)) == [
+ Message(
+ level=40,
+ message="Les données fournies pour l'authentification sont incorrectes.",
+ )
+ ]
+
+ def test_missing_parameter(self):
+ 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 list(get_messages(res.wsgi_request)) == [
+ Message(
+ level=40,
+ 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/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..a587ad8e8
--- /dev/null
+++ b/api/views.py
@@ -0,0 +1,146 @@
+import hmac
+from urllib.parse import unquote
+
+import pydantic
+import requests
+import sentry_sdk
+from django.conf import settings
+from django.contrib import messages
+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
+from ninja_extra.shortcuts import get_object_or_none
+
+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 ThirdPartyAuthView(AccessMixin, FormView):
+ form_class = ThirdPartyAuthForm
+ template_name = "api/third_party/auth.jinja"
+ success_url = reverse_lazy("core:index")
+
+ def parse_params(self) -> ThirdPartyAuthParamsSchema | None:
+ """Parse and check the authentication parameters.
+
+ 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
+ # 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 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:
+ messages.error(
+ self.request, _("The data provided for authentication is incorrect")
+ )
+ return None
+ 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")
+ )
+ return None
+ if not hmac.compare_digest(
+ hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
+ params.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()
+ 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):
+ 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)}
+ 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 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.privacy_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/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/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/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")
diff --git a/core/utils.py b/core/utils.py
index 9fb7adc89..68a055b77 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] = "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 (by default, sha512)
+
+ Examples:
+ ```python
+ data = {
+ "foo": 5,
+ "bar": "somevalue",
+ }
+ hmac_key = secrets.token_hex(64)
+ signature = hmac_hexdigest(hmac_key, data, "sha256")
+ ```
+ """
+ if isinstance(key, str):
+ key = key.encode()
+ return hmac.digest(key, urlencode(data).encode(), digest).hex()
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..96eceb613
--- /dev/null
+++ b/docs/tutorial/api/account-link.md
@@ -0,0 +1,378 @@
+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 /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.
+
+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.
+
+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.
+ 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.
+
+!!!warning "URL de retour"
+
+ 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
+
+ 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.
+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"
+
+ 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 !
+
+ 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.
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/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 %(app)s and of the Students' Association applies as soon as "
+"the form is submitted."
+msgstr ""
+"Les politiques de confidentialité de %(app)s"
+"a> 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 ""
+"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"
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
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")),
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/",