From a11897cd1011309ba117fd9cb41f1c91219941c8 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 20 May 2025 00:35:09 +0200 Subject: [PATCH] feat: basic api key management --- apikey/__init__.py | 0 apikey/admin.py | 55 +++++++++++++++ apikey/apps.py | 6 ++ apikey/auth.py | 22 ++++++ apikey/hashers.py | 42 +++++++++++ apikey/migrations/0001_initial.py | 113 ++++++++++++++++++++++++++++++ apikey/migrations/__init__.py | 0 apikey/models.py | 94 +++++++++++++++++++++++++ apikey/tests.py | 29 ++++++++ docs/reference/apikey/auth.md | 6 ++ docs/reference/apikey/hashers.md | 8 +++ docs/reference/apikey/models.md | 7 ++ locale/fr/LC_MESSAGES/django.po | 71 ++++++++++++++++--- locale/fr/LC_MESSAGES/djangojs.po | 10 +-- mkdocs.yml | 4 ++ sith/settings.py | 1 + 16 files changed, 455 insertions(+), 13 deletions(-) create mode 100644 apikey/__init__.py create mode 100644 apikey/admin.py create mode 100644 apikey/apps.py create mode 100644 apikey/auth.py create mode 100644 apikey/hashers.py create mode 100644 apikey/migrations/0001_initial.py create mode 100644 apikey/migrations/__init__.py create mode 100644 apikey/models.py create mode 100644 apikey/tests.py create mode 100644 docs/reference/apikey/auth.md create mode 100644 docs/reference/apikey/hashers.md create mode 100644 docs/reference/apikey/models.md diff --git a/apikey/__init__.py b/apikey/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apikey/admin.py b/apikey/admin.py new file mode 100644 index 00000000..ef6a247c --- /dev/null +++ b/apikey/admin.py @@ -0,0 +1,55 @@ +from django.contrib import admin, messages +from django.db.models import QuerySet +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ + +from apikey.hashers import generate_key +from apikey.models import ApiClient, ApiKey + + +@admin.register(ApiClient) +class ApiClientAdmin(admin.ModelAdmin): + list_display = ("name", "owner", "created_at", "updated_at") + search_fields = ( + "name", + "owner__first_name", + "owner__last_name", + "owner__nick_name", + ) + autocomplete_fields = ("owner", "groups", "client_permissions") + + +@admin.register(ApiKey) +class ApiKeyAdmin(admin.ModelAdmin): + list_display = ("name", "client", "created_at", "revoked") + list_filter = ("revoked",) + date_hierarchy = "created_at" + + readonly_fields = ("prefix", "hashed_key") + actions = ("revoke_keys",) + + def save_model(self, request: HttpRequest, obj: ApiKey, form, change): + if not change: + key, hashed = generate_key() + obj.prefix = key[: ApiKey.PREFIX_LENGTH] + obj.hashed_key = hashed + self.message_user( + request, + _( + "The API key for %(name)s is: %(key)s. " + "Please store it somewhere safe: " + "you will not be able to see it again." + ) + % {"name": obj.name, "key": key}, + level=messages.WARNING, + ) + return super().save_model(request, obj, form, change) + + def get_readonly_fields(self, request, obj: ApiKey | None = None): + if obj is None or obj.revoked: + return ["revoked", *self.readonly_fields] + return self.readonly_fields + + @admin.action(description=_("Revoke selected API keys")) + def revoke_keys(self, _request: HttpRequest, queryset: QuerySet[ApiKey]): + queryset.update(revoked=True) diff --git a/apikey/apps.py b/apikey/apps.py new file mode 100644 index 00000000..4ce409b2 --- /dev/null +++ b/apikey/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApikeyConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apikey" diff --git a/apikey/auth.py b/apikey/auth.py new file mode 100644 index 00000000..3d45a4eb --- /dev/null +++ b/apikey/auth.py @@ -0,0 +1,22 @@ +from django.http import HttpRequest +from ninja.security import APIKeyHeader + +from apikey.hashers import get_hasher +from apikey.models import ApiClient, ApiKey + +_UUID_LENGTH = 36 + + +class ApiKeyAuth(APIKeyHeader): + param_name = "X-APIKey" + + def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None: + if not key or len(key) != _UUID_LENGTH: + return None + hasher = get_hasher() + hashed_key = hasher.encode(key) + try: + key_obj = ApiKey.objects.get(hashed_key=hashed_key) + except ApiKey.DoesNotExist: + return None + return key_obj.client diff --git a/apikey/hashers.py b/apikey/hashers.py new file mode 100644 index 00000000..3a623177 --- /dev/null +++ b/apikey/hashers.py @@ -0,0 +1,42 @@ +import functools +import hashlib +import uuid + +from django.contrib.auth.hashers import BasePasswordHasher +from django.utils.crypto import constant_time_compare + + +class Sha256ApiKeyHasher(BasePasswordHasher): + """ + An API key hasher using the sha256 algorithm. + + This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting. + It is insecure for use in hashing passwords, but is safe for hashing + high entropy, randomly generated API keys. + """ + + algorithm = "sha256" + + def salt(self) -> str: + # No need for a salt on a high entropy key. + return "" + + def encode(self, password: str, salt: str = "") -> str: + hashed = hashlib.sha256(password.encode()).hexdigest() + return f"{self.algorithm}$${hashed}" + + def verify(self, password: str, encoded: str) -> bool: + encoded_2 = self.encode(password, "") + return constant_time_compare(encoded, encoded_2) + + +@functools.cache +def get_hasher(): + return Sha256ApiKeyHasher() + + +def generate_key() -> tuple[str, str]: + """Generate a [key, hash] couple.""" + key = str(uuid.uuid4()) + hasher = get_hasher() + return key, hasher.encode(key) diff --git a/apikey/migrations/0001_initial.py b/apikey/migrations/0001_initial.py new file mode 100644 index 00000000..cda9e9f6 --- /dev/null +++ b/apikey/migrations/0001_initial.py @@ -0,0 +1,113 @@ +# Generated by Django 5.2 on 2025-06-01 08:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0046_permissionrights"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ApiClient", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=64, verbose_name="name")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "client_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this api client.", + related_name="clients", + to="auth.permission", + verbose_name="client permissions", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + related_name="api_clients", + to="core.group", + verbose_name="groups", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_clients", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "api client", + "verbose_name_plural": "api clients", + }, + ), + migrations.CreateModel( + name="ApiKey", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(blank=True, default="", verbose_name="name")), + ( + "prefix", + models.CharField( + editable=False, max_length=5, verbose_name="prefix" + ), + ), + ( + "hashed_key", + models.CharField( + db_index=True, + editable=False, + max_length=150, + verbose_name="hashed key", + ), + ), + ("revoked", models.BooleanField(default=False, verbose_name="revoked")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "client", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_keys", + to="apikey.apiclient", + verbose_name="api client", + ), + ), + ], + options={ + "verbose_name": "api key", + "verbose_name_plural": "api keys", + "permissions": [("revoke_apikey", "Revoke API keys")], + }, + ), + ] diff --git a/apikey/migrations/__init__.py b/apikey/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apikey/models.py b/apikey/models.py new file mode 100644 index 00000000..b172bb7a --- /dev/null +++ b/apikey/models.py @@ -0,0 +1,94 @@ +from typing import Iterable + +from django.contrib.auth.models import Permission +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import pgettext_lazy + +from core.models import Group, User + + +class ApiClient(models.Model): + name = models.CharField(_("name"), max_length=64) + owner = models.ForeignKey( + User, + verbose_name=_("owner"), + related_name="api_clients", + on_delete=models.CASCADE, + ) + groups = models.ManyToManyField( + Group, + verbose_name=_("groups"), + related_name="api_clients", + ) + client_permissions = models.ManyToManyField( + Permission, + verbose_name=_("client permissions"), + blank=True, + help_text=_("Specific permissions for this api client."), + related_name="clients", + ) + 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 + + def has_perm(self, perm: str): + """Return True if the client has the specified permission.""" + + 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. + """ + 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) + + +class ApiKey(models.Model): + PREFIX_LENGTH = 5 + + name = models.CharField(_("name"), blank=True, default="") + prefix = models.CharField(_("prefix"), max_length=PREFIX_LENGTH, editable=False) + hashed_key = models.CharField( + _("hashed key"), max_length=150, db_index=True, editable=False + ) + client = models.ForeignKey( + ApiClient, + verbose_name=_("api client"), + related_name="api_keys", + on_delete=models.CASCADE, + ) + revoked = models.BooleanField(pgettext_lazy("api key", "revoked"), default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = _("api key") + verbose_name_plural = _("api keys") + permissions = [("revoke_apikey", "Revoke API keys")] + + def __str__(self): + return f"{self.name} ({self.prefix}***)" diff --git a/apikey/tests.py b/apikey/tests.py new file mode 100644 index 00000000..a971af2d --- /dev/null +++ b/apikey/tests.py @@ -0,0 +1,29 @@ +import pytest +from django.test import RequestFactory +from model_bakery import baker + +from apikey.auth import ApiKeyAuth +from apikey.hashers import generate_key +from apikey.models import ApiClient, ApiKey + + +@pytest.mark.django_db +def test_api_key_auth(): + key, hashed = generate_key() + client = baker.make(ApiClient) + baker.make(ApiKey, client=client, hashed_key=hashed) + auth = ApiKeyAuth() + + assert auth.authenticate(RequestFactory().get(""), key) == client + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("key", "hashed"), [(generate_key()[0], generate_key()[1]), (generate_key()[0], "")] +) +def test_api_key_auth_invalid(key, hashed): + client = baker.make(ApiClient) + baker.make(ApiKey, client=client, hashed_key=hashed) + auth = ApiKeyAuth() + + assert auth.authenticate(RequestFactory().get(""), key) is None diff --git a/docs/reference/apikey/auth.md b/docs/reference/apikey/auth.md new file mode 100644 index 00000000..f6261037 --- /dev/null +++ b/docs/reference/apikey/auth.md @@ -0,0 +1,6 @@ +::: apikey.auth + handler: python + options: + heading_level: 3 + members: + - ApiKeyAuth \ No newline at end of file diff --git a/docs/reference/apikey/hashers.md b/docs/reference/apikey/hashers.md new file mode 100644 index 00000000..eb728802 --- /dev/null +++ b/docs/reference/apikey/hashers.md @@ -0,0 +1,8 @@ +::: apikey.hashers + handler: python + options: + heading_level: 3 + members: + - Sha256ApiKeyHasher + - get_hasher + - generate_key \ No newline at end of file diff --git a/docs/reference/apikey/models.md b/docs/reference/apikey/models.md new file mode 100644 index 00000000..52da58df --- /dev/null +++ b/docs/reference/apikey/models.md @@ -0,0 +1,7 @@ +::: apikey.auth + handler: python + options: + heading_level: 3 + members: + - ApiKey + - ApiClient \ No newline at end of file diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index c7c4d34f..cd399bfd 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -35,6 +35,69 @@ msgstr "" "True si gardé à jour par le biais d'un fournisseur externe de domains " "toxics, False sinon" +#: apikey/admin.py +#, python-format +msgid "" +"The API key for %(name)s is: %(key)s. Please store it somewhere safe: you " +"will not be able to see it again." +msgstr "" +"La clef d'API pour %(name)s est : %(key)s. Gardez-là dans un emplacement " +"sûr : vous ne pourrez pas la revoir à nouveau." + +#: apikey/admin.py +msgid "Revoke selected API keys" +msgstr "Révoquer les clefs d'API sélectionnées" + +#: apikey/models.py club/models.py com/models.py counter/models.py +#: forum/models.py launderette/models.py +msgid "name" +msgstr "nom" + +#: apikey/models.py core/models.py +msgid "owner" +msgstr "propriétaire" + +#: apikey/models.py core/models.py +msgid "groups" +msgstr "groupes" + +#: apikey/models.py +msgid "client permissions" +msgstr "permissions du client" + +#: apikey/models.py +msgid "Specific permissions for this api client." +msgstr "Permissions spécifiques pour ce client d'API" + +#: apikey/models.py +msgid "api client" +msgstr "client d'api" + +#: apikey/models.py +msgid "api clients" +msgstr "clients d'api" + +#: apikey/models.py +msgid "prefix" +msgstr "préfixe" + +#: apikey/models.py +msgid "hashed key" +msgstr "hash de la clef" + +#: apikey/models.py +msgctxt "api key" +msgid "revoked" +msgstr "révoquée" + +#: apikey/models.py +msgid "api key" +msgstr "clef d'api" + +#: apikey/models.py +msgid "api keys" +msgstr "clefs d'api" + #: club/forms.py msgid "Users to add" msgstr "Utilisateurs à ajouter" @@ -1257,10 +1320,6 @@ msgstr "surnom" msgid "last update" msgstr "dernière mise à jour" -#: core/models.py -msgid "groups" -msgstr "groupes" - #: core/models.py msgid "" "The groups this user belongs to. A user will get all permissions granted to " @@ -1497,10 +1556,6 @@ msgstr "version allégée" msgid "thumbnail" msgstr "miniature" -#: core/models.py -msgid "owner" -msgstr "propriétaire" - #: core/models.py msgid "edit group" msgstr "groupe d'édition" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index ce90c30b..c084a4ff 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-13 00:18+0200\n" +"POT-Creation-Date: 2025-05-18 12:17+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -37,14 +37,14 @@ msgstr "Supprimer" msgid "Copy calendar link" msgstr "Copier le lien du calendrier" -#: com/static/bundled/com/components/ics-calendar-index.ts -msgid "How to use calendar link" -msgstr "Comment utiliser le lien du calendrier" - #: com/static/bundled/com/components/ics-calendar-index.ts msgid "Link copied" msgstr "Lien copié" +#: com/static/bundled/com/components/ics-calendar-index.ts +msgid "How to use calendar link" +msgstr "Comment utiliser le lien du calendrier" + #: com/static/bundled/com/components/moderation-alert-index.ts #, javascript-format msgid "" diff --git a/mkdocs.yml b/mkdocs.yml index a89bbeea..7201fd9d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -84,6 +84,10 @@ nav: - antispam: - reference/antispam/models.md - reference/antispam/forms.md + - apikey: + - reference/apikey/auth.md + - reference/apikey/hashers.md + - reference/apikey/models.md - club: - reference/club/models.md - reference/club/views.md diff --git a/sith/settings.py b/sith/settings.py index 5a7e8e1b..759161e2 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -124,6 +124,7 @@ INSTALLED_APPS = ( "pedagogy", "galaxy", "antispam", + "apikey", ) MIDDLEWARE = (