feat: basic api key management

This commit is contained in:
imperosol 2025-05-20 00:35:09 +02:00
parent ad91c8ed4f
commit 7a188f815f
16 changed files with 456 additions and 19 deletions

0
apikey/__init__.py Normal file
View File

55
apikey/admin.py Normal file
View File

@ -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)

6
apikey/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApikeyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apikey"

22
apikey/auth.py Normal file
View File

@ -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

42
apikey/hashers.py Normal file
View File

@ -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)

View File

@ -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")],
},
),
]

View File

94
apikey/models.py Normal file
View File

@ -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}***)"

29
apikey/tests.py Normal file
View File

@ -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

View File

@ -0,0 +1,6 @@
::: apikey.auth
handler: python
options:
heading_level: 3
members:
- ApiKeyAuth

View File

@ -0,0 +1,8 @@
::: apikey.hashers
handler: python
options:
heading_level: 3
members:
- Sha256ApiKeyHasher
- get_hasher
- generate_key

View File

@ -0,0 +1,7 @@
::: apikey.auth
handler: python
options:
heading_level: 3
members:
- ApiKey
- ApiClient

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-15 23:39+0200\n" "POT-Creation-Date: 2025-06-01 11:06+0200\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,69 @@ 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"
#: 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 #: club/forms.py
msgid "Users to add" msgid "Users to add"
msgstr "Utilisateurs à ajouter" msgstr "Utilisateurs à ajouter"
@ -120,11 +183,6 @@ msgstr "Vous devez choisir un rôle"
msgid "You do not have the permission to do that" msgid "You do not have the permission to do that"
msgstr "Vous n'avez pas la permission de faire cela" msgstr "Vous n'avez pas la permission de faire cela"
#: club/models.py com/models.py counter/models.py forum/models.py
#: launderette/models.py
msgid "name"
msgstr "nom"
#: club/models.py #: club/models.py
msgid "slug name" msgid "slug name"
msgstr "nom slug" msgstr "nom slug"
@ -1270,10 +1328,6 @@ msgstr "surnom"
msgid "last update" msgid "last update"
msgstr "dernière mise à jour" msgstr "dernière mise à jour"
#: core/models.py
msgid "groups"
msgstr "groupes"
#: core/models.py #: core/models.py
msgid "" msgid ""
"The groups this user belongs to. A user will get all permissions granted to " "The groups this user belongs to. A user will get all permissions granted to "
@ -1510,10 +1564,6 @@ msgstr "version allégée"
msgid "thumbnail" msgid "thumbnail"
msgstr "miniature" msgstr "miniature"
#: core/models.py
msgid "owner"
msgstr "propriétaire"
#: core/models.py #: core/models.py
msgid "edit group" msgid "edit group"
msgstr "groupe d'édition" msgstr "groupe d'édition"

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -37,14 +37,14 @@ msgstr "Supprimer"
msgid "Copy calendar link" msgid "Copy calendar link"
msgstr "Copier le lien du calendrier" 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 #: com/static/bundled/com/components/ics-calendar-index.ts
msgid "Link copied" msgid "Link copied"
msgstr "Lien copié" 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 #: com/static/bundled/com/components/moderation-alert-index.ts
#, javascript-format #, javascript-format
msgid "" msgid ""

View File

@ -84,6 +84,10 @@ nav:
- antispam: - antispam:
- reference/antispam/models.md - reference/antispam/models.md
- reference/antispam/forms.md - reference/antispam/forms.md
- apikey:
- reference/apikey/auth.md
- reference/apikey/hashers.md
- reference/apikey/models.md
- club: - club:
- reference/club/models.md - reference/club/models.md
- reference/club/views.md - reference/club/views.md

View File

@ -125,6 +125,7 @@ INSTALLED_APPS = (
"pedagogy", "pedagogy",
"galaxy", "galaxy",
"antispam", "antispam",
"apikey",
) )
MIDDLEWARE = ( MIDDLEWARE = (