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 bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase
|
||||||
from projects.models import Project, ProjectMembership
|
from projects.models import Project, ProjectMembership
|
||||||
from events.models import Event
|
from events.models import Event
|
||||||
|
from bsmain.models import AuthToken
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class FilesTests(TransactionTestCase):
|
class FilesTests(TransactionTestCase):
|
||||||
|
# Integration-test of file-upload and does-it-render-sourcemaps
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@@ -22,6 +24,28 @@ class FilesTests(TransactionTestCase):
|
|||||||
self.project = Project.objects.create()
|
self.project = Project.objects.create()
|
||||||
ProjectMembership.objects.create(project=self.project, user=self.user)
|
ProjectMembership.objects.create(project=self.project, user=self.user)
|
||||||
self.client.force_login(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):
|
def test_assemble_artifact_bundle(self):
|
||||||
SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples")
|
SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples")
|
||||||
@@ -45,9 +69,7 @@ class FilesTests(TransactionTestCase):
|
|||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/0/organizations/anyorg/chunk-upload/",
|
"/api/0/organizations/anyorg/chunk-upload/",
|
||||||
data={"file_gzip": gzipped_file},
|
data={"file_gzip": gzipped_file},
|
||||||
headers={
|
headers=self.token_headers,
|
||||||
# once we have auth
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -69,9 +91,7 @@ class FilesTests(TransactionTestCase):
|
|||||||
"/api/0/organizations/anyorg/artifactbundle/assemble/",
|
"/api/0/organizations/anyorg/artifactbundle/assemble/",
|
||||||
json.dumps(data),
|
json.dumps(data),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
headers={
|
headers=self.token_headers,
|
||||||
# once we have auth
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from django.contrib.auth.decorators import user_passes_test
|
|||||||
from sentry.assemble import ChunkFileState
|
from sentry.assemble import ChunkFileState
|
||||||
|
|
||||||
from bugsink.app_settings import get_settings
|
from bugsink.app_settings import get_settings
|
||||||
|
from bsmain.models import AuthToken
|
||||||
|
|
||||||
from .models import Chunk, File, FileMetadata
|
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
|
@csrf_exempt
|
||||||
|
@requires_auth_token
|
||||||
def chunk_upload(request, organization_slug):
|
def chunk_upload(request, organization_slug):
|
||||||
# TODO authenticate
|
|
||||||
# Bugsink has a single-organization model; we simply ignore organization_slug
|
# 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.
|
# 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
|
@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):
|
def artifact_bundle_assemble(request, organization_slug):
|
||||||
# TODO authenticate
|
|
||||||
# Bugsink has a single-organization model; we simply ignore organization_slug
|
# 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
|
# 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