From 895da36adca8c46734ef673138a303f4e6002c39 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 11 Apr 2025 14:00:26 +0200 Subject: [PATCH] AuthToken: barebones implementation --- bsmain/admin.py | 10 +++++++ bsmain/migrations/0001_initial.py | 44 +++++++++++++++++++++++++++++++ bsmain/migrations/__init__.py | 0 bsmain/models.py | 20 ++++++++++++++ files/tests.py | 32 +++++++++++++++++----- files/views.py | 31 ++++++++++++++++++++-- 6 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 bsmain/migrations/0001_initial.py create mode 100644 bsmain/migrations/__init__.py diff --git a/bsmain/admin.py b/bsmain/admin.py index e69de29..4a32e00 100644 --- a/bsmain/admin.py +++ b/bsmain/admin.py @@ -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",) diff --git a/bsmain/migrations/0001_initial.py b/bsmain/migrations/0001_initial.py new file mode 100644 index 0000000..97477f0 --- /dev/null +++ b/bsmain/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/bsmain/migrations/__init__.py b/bsmain/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bsmain/models.py b/bsmain/models.py index e69de29..887c439 100644 --- a/bsmain/models.py +++ b/bsmain/models.py @@ -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})" diff --git a/files/tests.py b/files/tests.py index ac7df3a..bfc222e 100644 --- a/files/tests.py +++ b/files/tests.py @@ -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( diff --git a/files/views.py b/files/views.py index fff8d4a..856b532 100644 --- a/files/views.py +++ b/files/views.py @@ -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