mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 02:53:06 +00:00 
			
		
		
		
	Compare commits
	
		
			11 Commits
		
	
	
		
			fix-poster
			...
			discord-au
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					d97c86fb99 | ||
| 
						 | 
					19fc4479c2 | ||
| 
						 | 
					375f855d51 | ||
| 
						 | 
					2b832b6522 | ||
| 
						 | 
					e582e750ff | ||
| 
						 | 
					8d02e88743 | ||
| 
						 | 
					5d5451a786 | ||
| 
						 | 
					b5e05e97dc | ||
| 
						 | 
					a2f247047a | ||
| 
						 | 
					7fb955cab9 | ||
| 
						 | 
					766a3bcf6b | 
@@ -17,6 +17,15 @@ class ApiClientAdmin(admin.ModelAdmin):
 | 
				
			|||||||
        "owner__nick_name",
 | 
					        "owner__nick_name",
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    autocomplete_fields = ("owner", "groups", "client_permissions")
 | 
					    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)
 | 
					@admin.register(ApiKey)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										16
									
								
								api/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								api/api.py
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
				
			||||||
							
								
								
									
										35
									
								
								api/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								api/forms.py
									
									
									
									
									
										Normal file
									
								
							@@ -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")}
 | 
				
			||||||
							
								
								
									
										19
									
								
								api/migrations/0002_apiclient_hmac_key.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								api/migrations/0002_apiclient_hmac_key.py
									
									
									
									
									
										Normal file
									
								
							@@ -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"
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@@ -1,13 +1,20 @@
 | 
				
			|||||||
 | 
					import secrets
 | 
				
			||||||
from typing import Iterable
 | 
					from typing import Iterable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.contrib.auth.models import Permission
 | 
					from django.contrib.auth.models import Permission
 | 
				
			||||||
from django.db import models
 | 
					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 gettext_lazy as _
 | 
				
			||||||
from django.utils.translation import pgettext_lazy
 | 
					from django.utils.translation import pgettext_lazy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from core.models import Group, User
 | 
					from core.models import Group, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_hmac_key():
 | 
				
			||||||
 | 
					    return secrets.token_hex(64)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ApiClient(models.Model):
 | 
					class ApiClient(models.Model):
 | 
				
			||||||
    name = models.CharField(_("name"), max_length=64)
 | 
					    name = models.CharField(_("name"), max_length=64)
 | 
				
			||||||
    owner = models.ForeignKey(
 | 
					    owner = models.ForeignKey(
 | 
				
			||||||
@@ -26,11 +33,10 @@ class ApiClient(models.Model):
 | 
				
			|||||||
        help_text=_("Specific permissions for this api client."),
 | 
					        help_text=_("Specific permissions for this api client."),
 | 
				
			||||||
        related_name="clients",
 | 
					        related_name="clients",
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
 | 
				
			||||||
    created_at = models.DateTimeField(auto_now_add=True)
 | 
					    created_at = models.DateTimeField(auto_now_add=True)
 | 
				
			||||||
    updated_at = models.DateTimeField(auto_now=True)
 | 
					    updated_at = models.DateTimeField(auto_now=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _perm_cache: set[str] | None = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        verbose_name = _("api client")
 | 
					        verbose_name = _("api client")
 | 
				
			||||||
        verbose_name_plural = _("api clients")
 | 
					        verbose_name_plural = _("api clients")
 | 
				
			||||||
@@ -38,33 +44,38 @@ class ApiClient(models.Model):
 | 
				
			|||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return self.name
 | 
					        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):
 | 
					    def has_perm(self, perm: str):
 | 
				
			||||||
        """Return True if the client has the specified permission."""
 | 
					        """Return True if the client has the specified permission."""
 | 
				
			||||||
 | 
					        return perm in self.all_permissions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if self._perm_cache is None:
 | 
					    def has_perms(self, perm_list: Iterable[str]) -> bool:
 | 
				
			||||||
            group_permissions = (
 | 
					        """Return True if the client has each of the specified permissions."""
 | 
				
			||||||
                Permission.objects.filter(group__group__in=self.groups.all())
 | 
					 | 
				
			||||||
                .values_list("content_type__app_label", "codename")
 | 
					 | 
				
			||||||
                .order_by()
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            client_permissions = self.client_permissions.values_list(
 | 
					 | 
				
			||||||
                "content_type__app_label", "codename"
 | 
					 | 
				
			||||||
            ).order_by()
 | 
					 | 
				
			||||||
            self._perm_cache = {
 | 
					 | 
				
			||||||
                f"{content_type}.{name}"
 | 
					 | 
				
			||||||
                for content_type, name in (*group_permissions, *client_permissions)
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        return perm in self._perm_cache
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def has_perms(self, perm_list):
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Return True if the client has each of the specified permissions. If
 | 
					 | 
				
			||||||
        object is passed, check if the client has all required perms for it.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
 | 
					        if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
 | 
				
			||||||
            raise ValueError("perm_list must be an iterable of permissions.")
 | 
					            raise ValueError("perm_list must be an iterable of permissions.")
 | 
				
			||||||
        return all(self.has_perm(perm) for perm in perm_list)
 | 
					        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):
 | 
					class ApiKey(models.Model):
 | 
				
			||||||
    PREFIX_LENGTH = 5
 | 
					    PREFIX_LENGTH = 5
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										23
									
								
								api/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								api/schemas.py
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
				
			||||||
							
								
								
									
										32
									
								
								api/templates/api/third_party/auth.jinja
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								api/templates/api/third_party/auth.jinja
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
				
			|||||||
 | 
					{% extends "core/base.jinja" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					  <form method="post">
 | 
				
			||||||
 | 
					    {% csrf_token %}
 | 
				
			||||||
 | 
					    <h3>{% trans %}Confidentiality{% endtrans %}</h3>
 | 
				
			||||||
 | 
					    <p>
 | 
				
			||||||
 | 
					      {% 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 %}
 | 
				
			||||||
 | 
					    </p>
 | 
				
			||||||
 | 
					    <p class="margin-bottom">
 | 
				
			||||||
 | 
					      {% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
 | 
				
			||||||
 | 
					        The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
 | 
				
			||||||
 | 
					        and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
 | 
				
			||||||
 | 
					        applies as soon as the form is submitted.
 | 
				
			||||||
 | 
					      {% endtrans %}
 | 
				
			||||||
 | 
					    </p>
 | 
				
			||||||
 | 
					    <div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
 | 
				
			||||||
 | 
					    <br>
 | 
				
			||||||
 | 
					    <h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
 | 
				
			||||||
 | 
					    <div class="row margin-bottom">
 | 
				
			||||||
 | 
					      {{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    {% for field in form.hidden_fields() %}{{ field }}{% endfor %}
 | 
				
			||||||
 | 
					    <input type="submit" class="btn btn-blue">
 | 
				
			||||||
 | 
					  </form>
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										24
									
								
								api/tests/test_admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								api/tests/test_admin.py
									
									
									
									
									
										Normal file
									
								
							@@ -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]
 | 
				
			||||||
							
								
								
									
										18
									
								
								api/tests/test_api_client_controller.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								api/tests/test_api_client_controller.py
									
									
									
									
									
										Normal file
									
								
							@@ -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()
 | 
				
			||||||
							
								
								
									
										59
									
								
								api/tests/test_client.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								api/tests/test_client.py
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
				
			||||||
							
								
								
									
										114
									
								
								api/tests/test_third_party_auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								api/tests/test_third_party_auth.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,114 @@
 | 
				
			|||||||
 | 
					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.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"]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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"], data=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"], data=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
 | 
				
			||||||
							
								
								
									
										15
									
								
								api/urls.py
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								api/urls.py
									
									
									
									
									
								
							@@ -1,5 +1,9 @@
 | 
				
			|||||||
 | 
					from django.urls import path, register_converter
 | 
				
			||||||
from ninja_extra import NinjaExtraAPI
 | 
					from ninja_extra import NinjaExtraAPI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
 | 
				
			||||||
 | 
					from core.converters import ResultConverter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
api = NinjaExtraAPI(
 | 
					api = NinjaExtraAPI(
 | 
				
			||||||
    title="PICON",
 | 
					    title="PICON",
 | 
				
			||||||
    description="Portail Interactif de Communication avec les Outils Numériques",
 | 
					    description="Portail Interactif de Communication avec les Outils Numériques",
 | 
				
			||||||
@@ -8,3 +12,14 @@ api = NinjaExtraAPI(
 | 
				
			|||||||
    csrf=True,
 | 
					    csrf=True,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
api.auto_discover_controllers()
 | 
					api.auto_discover_controllers()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					register_converter(ResultConverter, "res")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					urlpatterns = [
 | 
				
			||||||
 | 
					    path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
 | 
				
			||||||
 | 
					    path(
 | 
				
			||||||
 | 
					        "auth/<res:result>/",
 | 
				
			||||||
 | 
					        ThirdPartyAuthResultView.as_view(),
 | 
				
			||||||
 | 
					        name="third-party-auth-result",
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										119
									
								
								api/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								api/views.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,119 @@
 | 
				
			|||||||
 | 
					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_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(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"], data=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.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)
 | 
				
			||||||
@@ -1,19 +1,16 @@
 | 
				
			|||||||
class FourDigitYearConverter:
 | 
					from django.urls.converters import IntConverter, StringConverter
 | 
				
			||||||
    regex = "[0-9]{4}"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def to_python(self, value):
 | 
					
 | 
				
			||||||
        return int(value)
 | 
					class FourDigitYearConverter(IntConverter):
 | 
				
			||||||
 | 
					    regex = "[0-9]{4}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def to_url(self, value):
 | 
					    def to_url(self, value):
 | 
				
			||||||
        return str(value).zfill(4)
 | 
					        return str(value).zfill(4)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TwoDigitMonthConverter:
 | 
					class TwoDigitMonthConverter(IntConverter):
 | 
				
			||||||
    regex = "[0-9]{2}"
 | 
					    regex = "[0-9]{2}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def to_python(self, value):
 | 
					 | 
				
			||||||
        return int(value)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def to_url(self, value):
 | 
					    def to_url(self, value):
 | 
				
			||||||
        return str(value).zfill(2)
 | 
					        return str(value).zfill(2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -28,3 +25,9 @@ class BooleanStringConverter:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def to_url(self, value):
 | 
					    def to_url(self, value):
 | 
				
			||||||
        return str(value)
 | 
					        return str(value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ResultConverter(StringConverter):
 | 
				
			||||||
 | 
					    """Converter whose regex match either "success" or "failure"."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    regex = "(success|failure)"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,6 +28,7 @@ from typing import ClassVar, NamedTuple
 | 
				
			|||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.contrib.auth.models import Permission
 | 
					from django.contrib.auth.models import Permission
 | 
				
			||||||
from django.contrib.sites.models import Site
 | 
					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 import call_command
 | 
				
			||||||
from django.core.management.base import BaseCommand
 | 
					from django.core.management.base import BaseCommand
 | 
				
			||||||
from django.db import connection
 | 
					from django.db import connection
 | 
				
			||||||
@@ -104,13 +105,21 @@ class Command(BaseCommand):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
 | 
					        self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
 | 
				
			||||||
        home_root = SithFile.objects.create(name="users", owner=root)
 | 
					        home_root = SithFile.objects.create(name="users", owner=root)
 | 
				
			||||||
 | 
					        club_root = SithFile.objects.create(name="clubs", owner=root)
 | 
				
			||||||
 | 
					        sas = SithFile.objects.create(name="SAS", owner=root)
 | 
				
			||||||
 | 
					        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
 | 
					        # Page needed for club creation
 | 
				
			||||||
        p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
 | 
					        p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
 | 
				
			||||||
        p.save(force_lock=True)
 | 
					        p.save(force_lock=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        club_root = SithFile.objects.create(name="clubs", owner=root)
 | 
					 | 
				
			||||||
        sas = SithFile.objects.create(name="SAS", owner=root)
 | 
					 | 
				
			||||||
        main_club = Club.objects.create(
 | 
					        main_club = Club.objects.create(
 | 
				
			||||||
            id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
 | 
					            id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import math
 | 
				
			||||||
import random
 | 
					import random
 | 
				
			||||||
from datetime import date, timedelta
 | 
					from datetime import date, timedelta
 | 
				
			||||||
from datetime import timezone as tz
 | 
					from datetime import timezone as tz
 | 
				
			||||||
@@ -34,12 +35,17 @@ class Command(BaseCommand):
 | 
				
			|||||||
        super().__init__(*args, **kwargs)
 | 
					        super().__init__(*args, **kwargs)
 | 
				
			||||||
        self.faker = Faker("fr_FR")
 | 
					        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):
 | 
					    def handle(self, *args, **options):
 | 
				
			||||||
        if not settings.DEBUG:
 | 
					        if not settings.DEBUG:
 | 
				
			||||||
            raise Exception("Never call this command in prod. Never.")
 | 
					            raise Exception("Never call this command in prod. Never.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.stdout.write("Creating users...")
 | 
					        self.stdout.write("Creating users...")
 | 
				
			||||||
        users = self.create_users()
 | 
					        users = self.create_users(options["nb_users"])
 | 
				
			||||||
        subscribers = random.sample(users, k=int(0.8 * len(users)))
 | 
					        subscribers = random.sample(users, k=int(0.8 * len(users)))
 | 
				
			||||||
        self.stdout.write("Creating subscriptions...")
 | 
					        self.stdout.write("Creating subscriptions...")
 | 
				
			||||||
        self.create_subscriptions(subscribers)
 | 
					        self.create_subscriptions(subscribers)
 | 
				
			||||||
@@ -78,7 +84,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
        self.stdout.write("Creating products...")
 | 
					        self.stdout.write("Creating products...")
 | 
				
			||||||
        self.create_products()
 | 
					        self.create_products()
 | 
				
			||||||
        self.stdout.write("Creating sales and refills...")
 | 
					        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.create_sales(sellers)
 | 
				
			||||||
        self.stdout.write("Creating permanences...")
 | 
					        self.stdout.write("Creating permanences...")
 | 
				
			||||||
        self.create_permanences(sellers)
 | 
					        self.create_permanences(sellers)
 | 
				
			||||||
@@ -87,7 +93,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        self.stdout.write("Done")
 | 
					        self.stdout.write("Done")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def create_users(self) -> list[User]:
 | 
					    def create_users(self, nb_users: int = 600) -> list[User]:
 | 
				
			||||||
        password = make_password("plop")
 | 
					        password = make_password("plop")
 | 
				
			||||||
        users = [
 | 
					        users = [
 | 
				
			||||||
            User(
 | 
					            User(
 | 
				
			||||||
@@ -104,7 +110,7 @@ class Command(BaseCommand):
 | 
				
			|||||||
                address=self.faker.address(),
 | 
					                address=self.faker.address(),
 | 
				
			||||||
                password=password,
 | 
					                password=password,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            for _ in range(600)
 | 
					            for _ in range(nb_users)
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        # there may a duplicate or two
 | 
					        # there may a duplicate or two
 | 
				
			||||||
        # Not a problem, we will just have 599 users instead of 600
 | 
					        # Not a problem, we will just have 599 users instead of 600
 | 
				
			||||||
@@ -389,8 +395,9 @@ class Command(BaseCommand):
 | 
				
			|||||||
        Permanency.objects.bulk_create(perms)
 | 
					        Permanency.objects.bulk_create(perms)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def create_forums(self):
 | 
					    def create_forums(self):
 | 
				
			||||||
        forumers = random.sample(list(User.objects.all()), 100)
 | 
					        users = list(User.objects.all())
 | 
				
			||||||
        most_actives = random.sample(forumers, 10)
 | 
					        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))
 | 
					        categories = list(Forum.objects.filter(is_category=True))
 | 
				
			||||||
        new_forums = [
 | 
					        new_forums = [
 | 
				
			||||||
            Forum(name=self.faker.text(20), parent=random.choice(categories))
 | 
					            Forum(name=self.faker.text(20), parent=random.choice(categories))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -141,7 +141,6 @@ form {
 | 
				
			|||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
  margin: calc(var(--nf-input-size) * 1.5) auto 10px;
 | 
					  margin: calc(var(--nf-input-size) * 1.5) auto 10px;
 | 
				
			||||||
  line-height: 1;
 | 
					  line-height: 1;
 | 
				
			||||||
  white-space: nowrap;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .helptext {
 | 
					  .helptext {
 | 
				
			||||||
    margin-top: .25rem;
 | 
					    margin-top: .25rem;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,14 +1,11 @@
 | 
				
			|||||||
<div id="quick-notifications"
 | 
					<div id="quick-notifications"
 | 
				
			||||||
     x-data="{
 | 
					     x-data="{
 | 
				
			||||||
             messages: [
 | 
					             messages: [
 | 
				
			||||||
             {% if messages %}
 | 
					             {%- if messages -%}
 | 
				
			||||||
               {% for message in messages %}
 | 
					               {%- for message in messages -%}
 | 
				
			||||||
                 {
 | 
					                 { tag: '{{ message.tags }}', text: '{{ message }}' },
 | 
				
			||||||
                 tag: '{{ message.tags }}',
 | 
					               {%- endfor -%}
 | 
				
			||||||
                 text: '{{ message }}',
 | 
					             {%- endif -%}
 | 
				
			||||||
                 },
 | 
					 | 
				
			||||||
               {% endfor %}
 | 
					 | 
				
			||||||
             {% endif %}
 | 
					 | 
				
			||||||
             ]
 | 
					             ]
 | 
				
			||||||
             }"
 | 
					             }"
 | 
				
			||||||
     @quick-notification-add="(e) => messages.push(e?.detail)"
 | 
					     @quick-notification-add="(e) => messages.push(e?.detail)"
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								core/tests/test_commands.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								core/tests/test_commands.py
									
									
									
									
									
										Normal file
									
								
							@@ -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")
 | 
				
			||||||
@@ -12,22 +12,32 @@
 | 
				
			|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
					# OR WITHIN THE LOCAL FILE "LICENSE"
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					from __future__ import annotations
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import hmac
 | 
				
			||||||
from datetime import date, timedelta
 | 
					from datetime import date, timedelta
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Image utils
 | 
					# Image utils
 | 
				
			||||||
from io import BytesIO
 | 
					from io import BytesIO
 | 
				
			||||||
from typing import Final
 | 
					from typing import TYPE_CHECKING
 | 
				
			||||||
 | 
					from urllib.parse import urlencode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import PIL
 | 
					import PIL
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.files.base import ContentFile
 | 
					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 django.utils.timezone import localdate
 | 
				
			||||||
from PIL import ExifTags
 | 
					from PIL import ExifTags
 | 
				
			||||||
from PIL.Image import Image, Resampling
 | 
					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] = (
 | 
					RED_PIXEL_PNG: Final[bytes] = (
 | 
				
			||||||
    b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
 | 
					    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"
 | 
					    b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
 | 
				
			||||||
@@ -186,7 +196,7 @@ def exif_auto_rotate(image):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
def get_client_ip(request: HttpRequest) -> str | None:
 | 
					def get_client_ip(request: HttpRequest) -> str | None:
 | 
				
			||||||
    headers = (
 | 
					    headers = (
 | 
				
			||||||
        "X_FORWARDED_FOR",  # Common header for proixes
 | 
					        "X_FORWARDED_FOR",  # Common header for proxies
 | 
				
			||||||
        "FORWARDED",  # Standard header defined by RFC 7239.
 | 
					        "FORWARDED",  # Standard header defined by RFC 7239.
 | 
				
			||||||
        "REMOTE_ADDR",  # Default IP Address (direct connection)
 | 
					        "REMOTE_ADDR",  # Default IP Address (direct connection)
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
@@ -195,3 +205,30 @@ def get_client_ip(request: HttpRequest) -> str | None:
 | 
				
			|||||||
            return ip
 | 
					            return ip
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return None
 | 
					    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()
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										1
									
								
								docs/reference/api/schemas.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								docs/reference/api/schemas.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					::: api.schemas
 | 
				
			||||||
							
								
								
									
										1
									
								
								docs/reference/api/views.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								docs/reference/api/views.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					::: api.views
 | 
				
			||||||
							
								
								
									
										353
									
								
								docs/tutorial/api/account-link.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								docs/tutorial/api/account-link.md
									
									
									
									
									
										Normal file
									
								
							@@ -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<br/>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<br/>des conditions<br/>d'utilisation
 | 
				
			||||||
 | 
					        User-->>-Sith: Validation
 | 
				
			||||||
 | 
					        Sith->>+App: URL de retour<br/>avec données utilisateur
 | 
				
			||||||
 | 
					        App->>App: Traitement des <br/>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<br/>avec signature
 | 
				
			||||||
 | 
					        opt
 | 
				
			||||||
 | 
					            Sith->>+App: URL de retour<br/>avec données utilisateur
 | 
				
			||||||
 | 
					            App->>App: Traitement des <br/>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<br/>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 <br>pas signées ou sont mal signées              |
 | 
				
			||||||
 | 
					| 404  | L'URL de retour ne permet pas <br>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<SimpleHmac<Sha512>> {
 | 
				
			||||||
 | 
					                let urlencoded = serde_urlencoded::to_string(self).unwrap();
 | 
				
			||||||
 | 
					                SimpleHmac::<Sha512>::new_from_slice(key)
 | 
				
			||||||
 | 
					                    .unwrap()
 | 
				
			||||||
 | 
					                    .chain_update(urlencoded.as_bytes())
 | 
				
			||||||
 | 
					                    .finalize()
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        impl Into<Url> 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 = <récupération des données POST>
 | 
				
			||||||
 | 
					        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.
 | 
				
			||||||
@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
Voici quelques exemples : 
 | 
					Voici quelques exemples : 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
=== "Python (requests)"
 | 
					=== ":simple-python: Python (requests)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Dépendances :
 | 
					    Dépendances :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -132,7 +132,7 @@ Voici quelques exemples :
 | 
				
			|||||||
        print(response.json())
 | 
					        print(response.json())
 | 
				
			||||||
    ```
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
=== "Python (aiohttp)"
 | 
					=== ":simple-python: Python (aiohttp)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Dépendances :
 | 
					    Dépendances :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -158,7 +158,7 @@ Voici quelques exemples :
 | 
				
			|||||||
    asyncio.run(main())
 | 
					    asyncio.run(main())
 | 
				
			||||||
    ```
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
=== "Javascript (axios)"
 | 
					=== ":simple-javascript: Javascript (axios)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Dépendances :
 | 
					    Dépendances :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -178,7 +178,7 @@ Voici quelques exemples :
 | 
				
			|||||||
    console.log(await instance.get("club/1").json());
 | 
					    console.log(await instance.get("club/1").json());
 | 
				
			||||||
    ```
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
=== "Rust (reqwest)"
 | 
					=== ":simple-rust: Rust (reqwest)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Dépendances :
 | 
					    Dépendances :
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,37 +0,0 @@
 | 
				
			|||||||
#
 | 
					 | 
				
			||||||
# Copyright 2022
 | 
					 | 
				
			||||||
# - Maréchal <thgirod@hotmail.com
 | 
					 | 
				
			||||||
#
 | 
					 | 
				
			||||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
 | 
					 | 
				
			||||||
# http://ae.utbm.fr.
 | 
					 | 
				
			||||||
#
 | 
					 | 
				
			||||||
# This program is free software; you can redistribute it and/or modify it under
 | 
					 | 
				
			||||||
# the terms of the GNU General Public License a published by the Free Software
 | 
					 | 
				
			||||||
# Foundation; either version 3 of the License, or (at your option) any later
 | 
					 | 
				
			||||||
# version.
 | 
					 | 
				
			||||||
#
 | 
					 | 
				
			||||||
# This program is distributed in the hope that it will be useful, but WITHOUT
 | 
					 | 
				
			||||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 | 
					 | 
				
			||||||
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
 | 
					 | 
				
			||||||
# details.
 | 
					 | 
				
			||||||
#
 | 
					 | 
				
			||||||
# You should have received a copy of the GNU General Public License along with
 | 
					 | 
				
			||||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
 | 
					 | 
				
			||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class PaymentResultConverter:
 | 
					 | 
				
			||||||
    """Converter used for url mapping of the `eboutic.views.payment_result` view.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    It's meant to build an url that can match
 | 
					 | 
				
			||||||
    either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
 | 
					 | 
				
			||||||
    but nothing else.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    regex = "(success|failure)"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def to_python(self, value):
 | 
					 | 
				
			||||||
        return str(value)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def to_url(self, value):
 | 
					 | 
				
			||||||
        return str(value)
 | 
					 | 
				
			||||||
@@ -24,7 +24,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from django.urls import path, register_converter
 | 
					from django.urls import path, register_converter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from eboutic.converters import PaymentResultConverter
 | 
					from core.converters import ResultConverter
 | 
				
			||||||
from eboutic.views import (
 | 
					from eboutic.views import (
 | 
				
			||||||
    BillingInfoFormFragment,
 | 
					    BillingInfoFormFragment,
 | 
				
			||||||
    EbouticCheckout,
 | 
					    EbouticCheckout,
 | 
				
			||||||
@@ -34,7 +34,7 @@ from eboutic.views import (
 | 
				
			|||||||
    payment_result,
 | 
					    payment_result,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
register_converter(PaymentResultConverter, "res")
 | 
					register_converter(ResultConverter, "res")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
urlpatterns = [
 | 
					urlpatterns = [
 | 
				
			||||||
    # Subscription views
 | 
					    # Subscription views
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,7 +6,7 @@
 | 
				
			|||||||
msgid ""
 | 
					msgid ""
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
"Report-Msgid-Bugs-To: \n"
 | 
					"Report-Msgid-Bugs-To: \n"
 | 
				
			||||||
"POT-Creation-Date: 2025-10-17 13:41+0200\n"
 | 
					"POT-Creation-Date: 2025-10-26 16:47+0100\n"
 | 
				
			||||||
"PO-Revision-Date: 2016-07-18\n"
 | 
					"PO-Revision-Date: 2016-07-18\n"
 | 
				
			||||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
 | 
					"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
 | 
				
			||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
 | 
					"Language-Team: AE info <ae.info@utbm.fr>\n"
 | 
				
			||||||
@@ -35,6 +35,10 @@ msgstr ""
 | 
				
			|||||||
"True si gardé à jour par le biais d'un fournisseur externe de domains "
 | 
					"True si gardé à jour par le biais d'un fournisseur externe de domains "
 | 
				
			||||||
"toxics, False sinon"
 | 
					"toxics, False sinon"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: api/admin.py
 | 
				
			||||||
 | 
					msgid "Reset HMAC key"
 | 
				
			||||||
 | 
					msgstr "Réinitialiser la clef HMAC"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: api/admin.py
 | 
					#: api/admin.py
 | 
				
			||||||
#, python-format
 | 
					#, python-format
 | 
				
			||||||
msgid ""
 | 
					msgid ""
 | 
				
			||||||
@@ -48,6 +52,23 @@ msgstr ""
 | 
				
			|||||||
msgid "Revoke selected API keys"
 | 
					msgid "Revoke selected API keys"
 | 
				
			||||||
msgstr "Révoquer les clefs d'API sélectionnées"
 | 
					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
 | 
					#: api/models.py club/models.py com/models.py counter/models.py forum/models.py
 | 
				
			||||||
msgid "name"
 | 
					msgid "name"
 | 
				
			||||||
msgstr "nom"
 | 
					msgstr "nom"
 | 
				
			||||||
@@ -68,6 +89,10 @@ msgstr "permissions du client"
 | 
				
			|||||||
msgid "Specific permissions for this api client."
 | 
					msgid "Specific permissions for this api client."
 | 
				
			||||||
msgstr "Permissions spécifiques pour ce client d'API"
 | 
					msgstr "Permissions spécifiques pour ce client d'API"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#: api/models.py
 | 
				
			||||||
 | 
					msgid "HMAC Key"
 | 
				
			||||||
 | 
					msgstr "Clef HMAC"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#: api/models.py
 | 
					#: api/models.py
 | 
				
			||||||
msgid "api client"
 | 
					msgid "api client"
 | 
				
			||||||
msgstr "client d'api"
 | 
					msgstr "client d'api"
 | 
				
			||||||
@@ -97,6 +122,63 @@ msgstr "clef d'api"
 | 
				
			|||||||
msgid "api keys"
 | 
					msgid "api keys"
 | 
				
			||||||
msgstr "clefs d'api"
 | 
					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 <a href=\"%(privacy_link)s\">%(app)s</a> and of <a "
 | 
				
			||||||
 | 
					"href=\"%(sith_cgu_link)s\">the Students' Association</a> applies as soon as "
 | 
				
			||||||
 | 
					"the form is submitted."
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					"Les politiques de confidentialité de <a href=\"%(privacy_link)s\">%(app)s</a> et de <a "
 | 
				
			||||||
 | 
					"href=\"%(sith_cgu_link)s\">l'Association des Etudiants</a> 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
 | 
					#: club/forms.py
 | 
				
			||||||
msgid "Users to add"
 | 
					msgid "Users to add"
 | 
				
			||||||
msgstr "Utilisateurs à ajouter"
 | 
					msgstr "Utilisateurs à ajouter"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -69,6 +69,7 @@ nav:
 | 
				
			|||||||
    - API:
 | 
					    - API:
 | 
				
			||||||
      - Développement: tutorial/api/dev.md
 | 
					      - Développement: tutorial/api/dev.md
 | 
				
			||||||
      - Connexion à l'API: tutorial/api/connect.md
 | 
					      - Connexion à l'API: tutorial/api/connect.md
 | 
				
			||||||
 | 
					      - Liaison avec le compte AE: tutorial/api/account-link.md
 | 
				
			||||||
    - Etransactions: tutorial/etransaction.md
 | 
					    - Etransactions: tutorial/etransaction.md
 | 
				
			||||||
  - How-to:
 | 
					  - How-to:
 | 
				
			||||||
    - L'ORM de Django: howto/querysets.md
 | 
					    - L'ORM de Django: howto/querysets.md
 | 
				
			||||||
@@ -91,6 +92,8 @@ nav:
 | 
				
			|||||||
      - reference/api/hashers.md
 | 
					      - reference/api/hashers.md
 | 
				
			||||||
      - reference/api/models.md
 | 
					      - reference/api/models.md
 | 
				
			||||||
      - reference/api/perms.md
 | 
					      - reference/api/perms.md
 | 
				
			||||||
 | 
					      - reference/api/schemas.md
 | 
				
			||||||
 | 
					      - reference/api/views.md
 | 
				
			||||||
    - club:
 | 
					    - club:
 | 
				
			||||||
      - reference/club/models.md
 | 
					      - reference/club/models.md
 | 
				
			||||||
      - reference/club/views.md
 | 
					      - reference/club/views.md
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -406,6 +406,8 @@ SITH_FORUM_PAGE_LENGTH = 30
 | 
				
			|||||||
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
 | 
					SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
 | 
				
			||||||
SITH_SAS_IMAGES_PER_PAGE = 60
 | 
					SITH_SAS_IMAGES_PER_PAGE = 60
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
SITH_PROFILE_DEPARTMENTS = [
 | 
					SITH_PROFILE_DEPARTMENTS = [
 | 
				
			||||||
    ("TC", _("TC")),
 | 
					    ("TC", _("TC")),
 | 
				
			||||||
    ("IMSI", _("IMSI")),
 | 
					    ("IMSI", _("IMSI")),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -34,6 +34,7 @@ urlpatterns = [
 | 
				
			|||||||
    path("", include(("core.urls", "core"), namespace="core")),
 | 
					    path("", include(("core.urls", "core"), namespace="core")),
 | 
				
			||||||
    path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}),
 | 
					    path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}),
 | 
				
			||||||
    path("api/", api.urls),
 | 
					    path("api/", api.urls),
 | 
				
			||||||
 | 
					    path("api-link/", include(("api.urls", "api-link"), namespace="api-link")),
 | 
				
			||||||
    path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")),
 | 
					    path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")),
 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
        "subscription/",
 | 
					        "subscription/",
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user