API: auth (using the global token)

See #146
This commit is contained in:
Klaas van Schelven
2025-09-09 10:41:03 +02:00
parent 4c2c26743e
commit a87d846a99
5 changed files with 73 additions and 1 deletions

31
bugsink/authentication.py Normal file
View File

@@ -0,0 +1,31 @@
from django.contrib.auth.models import AnonymousUser
from rest_framework.authentication import BaseAuthentication
from rest_framework import exceptions
from bsmain.models import AuthToken
class BearerTokenAuthentication(BaseAuthentication):
"""
Accepts: Authorization: Bearer <40-hex>
Returns (AnonymousUser, AuthToken) on success; leaves request.user anonymous.
"""
keyword = "Bearer"
def authenticate(self, request):
header = request.headers.get("Authorization")
if not header or not header.startswith(f"{self.keyword} "):
return None
raw = header[len(self.keyword) + 1:].strip()
if len(raw) != 40 or any(c not in "0123456789abcdef" for c in raw):
raise exceptions.AuthenticationFailed("Invalid Bearer token.")
token_obj = AuthToken.objects.filter(token=raw).first()
if not token_obj:
raise exceptions.AuthenticationFailed("Invalid Bearer token.")
return (AnonymousUser(), token_obj)
def authenticate_header(self, request):
# tells DRF what to send in WWW-Authenticate on 401 responses, hinting the required auth scheme
return self.keyword

10
bugsink/permissions.py Normal file
View File

@@ -0,0 +1,10 @@
from rest_framework.permissions import BasePermission
from bsmain.models import AuthToken
class IsGlobalAuthenticated(BasePermission):
"""Allows access only to authenticated users with a valid (global) AuthToken."""
def has_permission(self, request, view):
return isinstance(request.auth, AuthToken)

View File

@@ -73,6 +73,13 @@ REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', # from the tutorial
'PAGE_SIZE': 10,
"DEFAULT_AUTHENTICATION_CLASSES": [
"bugsink.authentication.BearerTokenAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"bugsink.permissions.IsGlobalAuthenticated",
],
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],

24
bugsink/test_api.py Normal file
View File

@@ -0,0 +1,24 @@
import unittest
from django.urls import reverse
from rest_framework.test import APIClient
from bsmain.models import AuthToken
class BearerAuthRouterTests(unittest.TestCase):
def setUp(self):
self.client = APIClient()
def test_ok_on_event_list(self):
tok = AuthToken.objects.create()
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {tok.token}")
resp = self.client.get(reverse("api:event-list"))
self.assertEqual(resp.status_code, 200)
def test_missing_on_event_list(self):
resp = self.client.get(reverse("api:event-list"))
self.assertIn(resp.status_code, (401, 403))
def test_invalid_on_event_list(self):
self.client.credentials(HTTP_AUTHORIZATION="Bearer " + "a" * 40)
resp = self.client.get(reverse("api:event-list"))
self.assertEqual(resp.status_code, 401)

View File

@@ -60,7 +60,7 @@ urlpatterns = [
# many user-related views are directly exposed above (/accounts/), the rest is here:
path("users/", include("users.urls")),
path("api/canonical/0/", include(api_router.urls)),
path("api/canonical/0/", include((api_router.urls, "api"), namespace="api")),
# these are sentry-cli endpoint for uploading; they're unrelated to e.g. the ingestion API.
# the /api/0/ is just a hard prefix (for the ingest API, that position indicates the project id, but here it's just