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 %} +
+ {% 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, 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 %} +

+
{{ 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/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 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/",