mirror of
https://github.com/jlengrand/bugsink.git
synced 2026-03-10 08:01:17 +00:00
AuthToken: barebones implementation
This commit is contained in:
@@ -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",)
|
||||
|
||||
44
bsmain/migrations/0001_initial.py
Normal file
44
bsmain/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
bsmain/migrations/__init__.py
Normal file
0
bsmain/migrations/__init__.py
Normal 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})"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user