AuthToken: barebones implementation

This commit is contained in:
Klaas van Schelven
2025-04-11 14:00:26 +02:00
parent 1084796763
commit 895da36adc
6 changed files with 129 additions and 8 deletions

View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from .models import AuthToken
@admin.register(AuthToken)
class AuthTokenAdmin(admin.ModelAdmin):
list_display = ("token", "created_at")
list_filter = ("created_at",)
ordering = ("-created_at",)

View File

@@ -0,0 +1,44 @@
# Generated by Django 4.2.19 on 2025-04-11 11:33
import bsmain.models
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="AuthToken",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"token",
models.CharField(
default=bsmain.models.generate_token,
max_length=40,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Token must be a 40-character hexadecimal string.",
regex="^[a-f0-9]{40}$",
)
],
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
]

View File

View File

@@ -0,0 +1,20 @@
import secrets
from django.db import models
from django.core.validators import RegexValidator
def generate_token():
# nchars = nbytes * 2
return secrets.token_hex(nbytes=20)
class AuthToken(models.Model):
"""Global (Bugsink-wide) token for authentication."""
token = models.CharField(max_length=40, unique=True, default=generate_token, validators=[
RegexValidator(regex=r'^[a-f0-9]{40}$', message='Token must be a 40-character hexadecimal string.'),
])
created_at = models.DateTimeField(auto_now_add=True, editable=False)
def __str__(self):
return f"AuthToken(token={self.token})"

View File

@@ -9,12 +9,14 @@ from compat.dsn import get_header_value
from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase
from projects.models import Project, ProjectMembership
from events.models import Event
from bsmain.models import AuthToken
User = get_user_model()
class FilesTests(TransactionTestCase):
# Integration-test of file-upload and does-it-render-sourcemaps
def setUp(self):
super().setUp()
@@ -22,6 +24,28 @@ class FilesTests(TransactionTestCase):
self.project = Project.objects.create()
ProjectMembership.objects.create(project=self.project, user=self.user)
self.client.force_login(self.user)
self.auth_token = AuthToken.objects.create()
self.token_headers = {"Authorization": f"Bearer {self.auth_token.token}"}
def test_auth_no_header(self):
response = self.client.get("/api/0/organizations/anyorg/chunk-upload/", headers={})
self.assertEqual(401, response.status_code)
self.assertEqual({"error": "Authorization header not found"}, response.json())
def test_auth_empty_header(self):
response = self.client.get("/api/0/organizations/anyorg/chunk-upload/", headers={"Authorization": ""})
self.assertEqual(401, response.status_code)
self.assertEqual({"error": "Authorization header not found"}, response.json())
def test_auth_overfull_header(self):
response = self.client.get("/api/0/organizations/anyorg/chunk-upload/", headers={"Authorization": "Bearer a b"})
self.assertEqual(401, response.status_code)
self.assertEqual({"error": "Expecting 'Authorization: Token abc123...' but got 'Bearer a b'"}, response.json())
def test_auth_wrong_token(self):
response = self.client.get("/api/0/organizations/anyorg/chunk-upload/", headers={"Authorization": "Bearer xxx"})
self.assertEqual(401, response.status_code)
self.assertEqual({"error": "Invalid token"}, response.json())
def test_assemble_artifact_bundle(self):
SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples")
@@ -45,9 +69,7 @@ class FilesTests(TransactionTestCase):
response = self.client.post(
"/api/0/organizations/anyorg/chunk-upload/",
data={"file_gzip": gzipped_file},
headers={
# once we have auth
},
headers=self.token_headers,
)
self.assertEqual(
@@ -69,9 +91,7 @@ class FilesTests(TransactionTestCase):
"/api/0/organizations/anyorg/artifactbundle/assemble/",
json.dumps(data),
content_type="application/json",
headers={
# once we have auth
},
headers=self.token_headers,
)
self.assertEqual(

View File

@@ -12,6 +12,7 @@ from django.contrib.auth.decorators import user_passes_test
from sentry.assemble import ChunkFileState
from bugsink.app_settings import get_settings
from bsmain.models import AuthToken
from .models import Chunk, File, FileMetadata
@@ -100,9 +101,35 @@ def get_chunk_upload_settings(request, organization_slug):
})
def requires_auth_token(view_function):
# {"error": "..."} (status=401) response is API-compatible; for that to work we need the present function to be a
# decorator (so we can return, rather than raise, which plain-Django doesn't support for 401)
def first_require_auth_token(request, *args, **kwargs):
header_value = request.META.get("HTTP_AUTHORIZATION")
if not header_value:
return JsonResponse({"error": "Authorization header not found"}, status=401)
header_values = header_value.split()
if len(header_values) != 2:
return JsonResponse(
{"error": "Expecting 'Authorization: Token abc123...' but got '%s'" % header_value}, status=401)
the_word_bearer, token = header_values
if AuthToken.objects.filter(token=token).count() < 1:
return JsonResponse({"error": "Invalid token"}, status=401)
return view_function(request, *args, **kwargs)
first_require_auth_token.__name__ = view_function.__name__
return first_require_auth_token
@csrf_exempt
@requires_auth_token
def chunk_upload(request, organization_slug):
# TODO authenticate
# Bugsink has a single-organization model; we simply ignore organization_slug
# NOTE: we don't check against chunkSize, maxRequestSize and chunksPerRequest (yet), we expect the CLI to behave.
@@ -210,8 +237,8 @@ def assemble_file(checksum, chunk_checksums, filename):
@csrf_exempt # we're in API context here; this could potentially be pulled up to a higher level though
@requires_auth_token
def artifact_bundle_assemble(request, organization_slug):
# TODO authenticate
# Bugsink has a single-organization model; we simply ignore organization_slug
# NOTE a JSON-schema for this endpoint is available under Apache 2 license (2 year anniversary rule) at