feat: basic api key management

This commit is contained in:
imperosol 2025-05-20 00:35:09 +02:00
parent 99be8a56f3
commit a11897cd10
16 changed files with 455 additions and 13 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

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

View File

@ -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 <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\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 ""

View File

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

View File

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