Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ class ApiClientAdmin(admin.ModelAdmin):
"owner__nick_name",
)
autocomplete_fields = ("owner", "groups", "client_permissions")
readonly_fields = ("hmac_key",)
actions = ("reset_hmac_key",)

@admin.action(permissions=["change"], description=_("Reset HMAC key"))
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
objs = list(queryset)
for obj in objs:
obj.reset_hmac(commit=False)
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])


@admin.register(ApiKey)
Expand Down
16 changes: 16 additions & 0 deletions api/api.py
Original file line number Diff line number Diff line change
@@ -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 changes: 35 additions & 0 deletions api/forms.py
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions api/migrations/0002_apiclient_hmac_key.py
Original file line number Diff line number Diff line change
@@ -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"
),
),
]
55 changes: 33 additions & 22 deletions api/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import secrets
from typing import Iterable

from django.contrib.auth.models import Permission
from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy

from core.models import Group, User


def get_hmac_key():
return secrets.token_hex(64)


class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey(
Expand All @@ -26,45 +33,49 @@ class ApiClient(models.Model):
help_text=_("Specific permissions for this api client."),
related_name="clients",
)
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

_perm_cache: set[str] | None = None

class Meta:
verbose_name = _("api client")
verbose_name_plural = _("api clients")

def __str__(self):
return self.name

@cached_property
def all_permissions(self) -> set[str]:
permissions = (
Permission.objects.filter(
Q(group__group__in=self.groups.all()) | Q(clients=self)
)
.values_list("content_type__app_label", "codename")
.order_by()
)
return {f"{content_type}.{name}" for content_type, name in permissions}

def has_perm(self, perm: str):
"""Return True if the client has the specified permission."""
return perm in self.all_permissions

if self._perm_cache is None:
group_permissions = (
Permission.objects.filter(group__group__in=self.groups.all())
.values_list("content_type__app_label", "codename")
.order_by()
)
client_permissions = self.client_permissions.values_list(
"content_type__app_label", "codename"
).order_by()
self._perm_cache = {
f"{content_type}.{name}"
for content_type, name in (*group_permissions, *client_permissions)
}
return perm in self._perm_cache

def has_perms(self, perm_list):
"""
Return True if the client has each of the specified permissions. If
object is passed, check if the client has all required perms for it.
"""
def has_perms(self, perm_list: Iterable[str]) -> bool:
"""Return True if the client has each of the specified permissions."""
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
raise ValueError("perm_list must be an iterable of permissions.")
return all(self.has_perm(perm) for perm in perm_list)

def reset_hmac(self, *, commit: bool = True) -> str:
"""Reset and return the HMAC key for this client.

Args:
commit: if True (the default), persist the new hmac in db.
"""
self.hmac_key = get_hmac_key()
if commit:
self.save()
return self.hmac_key


class ApiKey(models.Model):
PREFIX_LENGTH = 5
Expand Down
23 changes: 23 additions & 0 deletions api/schemas.py
Original file line number Diff line number Diff line change
@@ -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 changes: 32 additions & 0 deletions api/templates/api/third_party/auth.jinja
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions api/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -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 changes: 18 additions & 0 deletions api/tests/test_api_client_controller.py
Original file line number Diff line number Diff line change
@@ -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 changes: 59 additions & 0 deletions api/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading