Add API token management: implement APIToken model, views, and templates for creating and revoking tokens

This commit is contained in:
Ross
2026-02-23 11:57:57 +00:00
parent 83d1b48213
commit d7f3ebf950
8 changed files with 309 additions and 17 deletions
+10
View File
@@ -22,6 +22,7 @@ from .models import (
CaseDisplaySet,
UserReportAnswer,
CidReportAnswer,
APIToken,
)
from django.forms import ModelForm
@@ -142,3 +143,12 @@ class SeriesAdmin(VersionAdmin):
admin.site.register(Series, SeriesAdmin)
class APITokenAdmin(admin.ModelAdmin):
list_display = ("user", "name", "revoked", "created", "expires", "last_used")
search_fields = ("user__username", "name")
readonly_fields = ("token_hash", "created", "last_used")
admin.site.register(APIToken, APITokenAdmin)
+112 -17
View File
@@ -18,10 +18,13 @@ from ninja.security import django_auth
from ninja import NinjaAPI, File
from ninja.files import UploadedFile
import hashlib
import secrets
from django.utils import timezone
from generic.models import Examination, Modality
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom, APIToken
from atlas.helpers import get_cases_available_to_user
from loguru import logger
@@ -30,6 +33,41 @@ from loguru import logger
router = Router()
class TokenAuth:
"""Simple bearer token auth for Ninja endpoints.
Integrates with `APIToken` model; use as `auth=TokenAuth()` on router
endpoints that should accept tokens in `Authorization: Bearer <token>`.
"""
def __call__(self, request):
# Extract header
auth = request.META.get("HTTP_AUTHORIZATION")
if not auth:
return None
parts = auth.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1]
token_hash = hashlib.sha256(token.encode()).hexdigest()
try:
api_token = APIToken.objects.get(token_hash=token_hash, revoked=False)
except APIToken.DoesNotExist:
return None
if api_token.expires and api_token.expires < timezone.now():
return None
# mark last used (best-effort)
try:
api_token.mark_used()
except Exception:
pass
# Attach token object to request for handlers
request.api_token = api_token
return api_token.user
class SeriesSchema(ModelSchema):
case_id: List[int] = []
@@ -54,7 +92,7 @@ class CaseSchema(ModelSchema):
fields = ["id", "title"]
@router.post("/upload_dicom", auth=django_auth)
@router.post("/upload_dicom", auth=TokenAuth())
def upload_dicom(request, files: List[UploadedFile] = File(...)):
uploaded = []
duplicate = []
@@ -87,7 +125,7 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
}
@router.post("/generate_image_hash", auth=django_auth)
@router.post("/generate_image_hash", auth=TokenAuth())
def generate_image_hash(request, id: int):
s = SeriesImage.objects.get(pk=id)
s.generate_hashes()
@@ -99,7 +137,7 @@ def generate_image_hash(request, id: int):
}
@router.post("/clear_dicoms", auth=django_auth)
@router.post("/clear_dicoms", auth=TokenAuth())
def clear_dicoms(request):
if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(
@@ -114,7 +152,7 @@ def clear_dicoms(request):
return True
@router.get("/uncategorised_dicoms", auth=django_auth)
@router.get("/uncategorised_dicoms", auth=TokenAuth())
def uncategorised_dicoms(request):
dicoms = UncategorisedDicom.objects.filter(user=request.user)
@@ -213,7 +251,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
@router.post(
"/import_dicoms", auth=django_auth, response=List[Tuple[SeriesSchema, str]]
"/import_dicoms", auth=TokenAuth(), response=List[Tuple[SeriesSchema, str]]
)
def import_dicoms(request):
return import_dicoms_helper(request)
@@ -221,14 +259,14 @@ def import_dicoms(request):
@router.post(
"/import_dicoms/{case_id}",
auth=django_auth,
auth=TokenAuth(),
response=List[Tuple[SeriesSchema, str]],
)
def import_dicoms_case(request, case_id: int):
return import_dicoms_helper(request, case_id=case_id)
@router.post("/upload_dicom_case/{case_id}", auth=django_auth, response=List[Tuple[SeriesSchema, str]])
@router.post("/upload_dicom_case/{case_id}", auth=TokenAuth(), response=List[Tuple[SeriesSchema, str]])
def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(...)):
"""Upload DICOM files and immediately import them into the given case.
@@ -248,12 +286,12 @@ def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(..
return import_dicoms_helper(request, case_id=case_id)
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
@router.get("/orphan_series", auth=TokenAuth(), response=List[SeriesSchema])
def orphan_series(request):
return request.user.series.filter(case=None)
@router.get("/series_remove_duplicate_images", auth=django_auth)
@router.get("/series_remove_duplicate_images", auth=TokenAuth())
def series_remove_duplicate_images(request, series_id: int):
series = get_object_or_404(Series, pk=series_id)
@@ -270,19 +308,76 @@ def series_remove_duplicate_images(request, series_id: int):
return len(dupes)
@router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema])
@router.get("/get_cases_user", auth=TokenAuth(), response=List[CaseSchema])
def get_cases_user(request):
return Case.objects.filter(author=request.user)
@router.get("/get_cases_available", auth=django_auth, response=List[CaseSchema])
@router.get("/get_cases_available", auth=TokenAuth(), response=List[CaseSchema])
def get_cases_available(request):
"""Return cases available to the authenticated user (via get_cases_available_to_user)."""
qs = get_cases_available_to_user(request.user)
return qs.order_by('-created_date')[:200]
@router.get("/check_image_hash/{hash}", auth=django_auth)
class APITokenOut(Schema):
id: int
name: str
scopes: str = ""
created: str
expires: str | None = None
revoked: bool = False
last_used: str | None = None
@router.get("/api_tokens", auth=TokenAuth(), response=List[APITokenOut])
def list_api_tokens(request):
tokens = APIToken.objects.filter(user=request.user).order_by("-created")
out = []
for t in tokens:
out.append(
{
"id": t.id,
"name": t.name,
"scopes": t.scopes,
"created": t.created.isoformat(),
"expires": t.expires.isoformat() if t.expires else None,
"revoked": t.revoked,
"last_used": t.last_used.isoformat() if t.last_used else None,
}
)
return out
class CreateTokenIn(Schema):
name: str = Field("", description="Friendly name for token")
scopes: str = Field("", description="Space-separated scopes")
expires_days: int | None = Field(None, description="Expire after N days")
@router.post("/api_tokens", auth=TokenAuth())
def create_api_token(request, payload: CreateTokenIn):
expires = None
if payload.expires_days:
expires = timezone.now() + timezone.timedelta(days=payload.expires_days)
token, obj = APIToken.create_token(request.user, name=payload.name, scopes=payload.scopes, expires=expires)
# Return raw token once — callers must store it
return {"token": token, "id": obj.id}
@router.post("/api_tokens/{token_id}/revoke", auth=TokenAuth())
def revoke_api_token(request, token_id: int):
try:
t = APIToken.objects.get(pk=token_id, user=request.user)
except APIToken.DoesNotExist:
return {"status": "not found"}
t.revoked = True
t.save(update_fields=["revoked"])
return {"status": "revoked"}
@router.get("/check_image_hash/{hash}", auth=TokenAuth())
def check_image_hash(request, hash: str):
try:
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
@@ -297,7 +392,7 @@ def check_image_hash(request, hash: str):
return data
@router.post("/check_image_hashes/", auth=django_auth)
@router.post("/check_image_hashes/", auth=TokenAuth())
def check_images_hashes(request, hashes: List[str]):
"""Checks a list of image hashes and returns the series id / url if found
@@ -345,13 +440,13 @@ def check_images_hashes(request, hashes: List[str]):
# print(series_image)
@router.get("/view_dicom_tags/{hash}", auth=django_auth)
@router.get("/view_dicom_tags/{hash}", auth=TokenAuth())
def view_dicom_tags(request, hash: str):
item = SeriesImage.objects.get(image_blake3_hash=hash)
return item.get_dicom_json()
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
def series_split_by_tag(request, series_id: int, dicom_tag: str):
series = get_object_or_404(Series, pk=series_id)
@@ -399,7 +494,7 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
return new_series
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
def series_order_by_tag(request, series_id: int, dicom_tag: str):
series = get_object_or_404(Series, pk=series_id)
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 6.0.1 on 2026-02-23 11:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0093_procedure_case_procedures'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='APIToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, help_text='Friendly name for this token', max_length=200)),
('token_hash', models.CharField(db_index=True, max_length=64)),
('scopes', models.CharField(blank=True, help_text='Space-separated scopes', max_length=255)),
('created', models.DateTimeField(auto_now_add=True)),
('expires', models.DateTimeField(blank=True, null=True)),
('revoked', models.BooleanField(default=False)),
('last_used', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'API token',
'verbose_name_plural': 'API tokens',
},
),
]
+38
View File
@@ -230,6 +230,44 @@ class SynMixin(object):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
class APIToken(models.Model):
"""Personal Access Token for API access.
Stores only a hash of the token. The raw token is returned once at
creation and never stored in plaintext.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens")
name = models.CharField(max_length=200, blank=True, help_text="Friendly name for this token")
token_hash = models.CharField(max_length=64, db_index=True)
scopes = models.CharField(max_length=255, blank=True, help_text="Space-separated scopes")
created = models.DateTimeField(auto_now_add=True)
expires = models.DateTimeField(null=True, blank=True)
revoked = models.BooleanField(default=False)
last_used = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "API token"
verbose_name_plural = "API tokens"
def __str__(self):
return f"APIToken(user={self.user}, name={self.name})"
@classmethod
def create_token(cls, user, name="", scopes="", expires=None):
import secrets, hashlib
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
obj = cls.objects.create(user=user, name=name, token_hash=token_hash, scopes=scopes, expires=expires)
return token, obj
def mark_used(self):
from django.utils import timezone
self.last_used = timezone.now()
self.save(update_fields=["last_used"])
class Finding(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
# New canonical/alias field: if set, this Finding is an alias and points
+64
View File
@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block content %}
<div class="container my-4">
<h3>API Tokens</h3>
<div class="card mb-3">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="row g-2">
<div class="col-md-4">
<input name="name" class="form-control" placeholder="Name (e.g. CLI)" />
</div>
<div class="col-md-4">
<input name="scopes" class="form-control" placeholder="Scopes (space separated)" />
</div>
<div class="col-md-2">
<input name="expires_days" type="number" min="0" class="form-control" placeholder="Days" />
</div>
<div class="col-md-2">
<button class="btn btn-primary w-100" type="submit" name="action" value="create">Create</button>
</div>
</div>
</form>
</div>
</div>
{% if created_token %}
<div class="alert alert-info">Created token. Save it now — it won't be shown again.
<pre class="mt-2">{{ created_token }}</pre>
</div>
{% endif %}
<div class="card">
<div class="card-header">Your tokens</div>
<div class="card-body">
<table class="table table-sm">
<thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Expires</th><th>Revoked</th><th></th></tr></thead>
<tbody>
{% for t in tokens %}
<tr>
<td>{{ t.name }}</td>
<td>{{ t.scopes }}</td>
<td>{{ t.created }}</td>
<td>{{ t.expires }}</td>
<td>{{ t.revoked }}</td>
<td>
{% if not t.revoked %}
<form method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="revoke_id" value="{{ t.id }}" />
<button class="btn btn-sm btn-outline-danger" type="submit" name="action" value="revoke">Revoke</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-muted">No tokens</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+1
View File
@@ -520,6 +520,7 @@ urlpatterns = [
views.SeriesImagesZipView.as_view(),
name="series_download",
),
path("api_tokens/", views.api_tokens_page, name="api_tokens"),
path(
"series/<int:pk>/order_dicom_instance",
views.series_order_dicom_instance,
+47
View File
@@ -119,6 +119,7 @@ from .models import (
UncategorisedDicom,
UserReportAnswer,
PrerequisiteRequired
, APIToken
)
from .models import Procedure
from .models import NormalCase
@@ -519,6 +520,52 @@ def case_search_page(request):
},
)
@login_required
def api_tokens_page(request):
"""Simple UI to create and revoke API tokens for the logged-in user."""
created_token = None
if request.method == "POST":
action = request.POST.get("action")
if action == "create":
name = request.POST.get("name", "")
scopes = request.POST.get("scopes", "")
expires_days = request.POST.get("expires_days")
expires = None
if expires_days:
try:
expires = timezone.now() + timezone.timedelta(days=int(expires_days))
except Exception:
expires = None
token, obj = APIToken.create_token(request.user, name=name, scopes=scopes, expires=expires)
created_token = token
elif action == "revoke":
revoke_id = request.POST.get("revoke_id")
try:
t = APIToken.objects.get(pk=revoke_id, user=request.user)
t.revoked = True
t.save(update_fields=["revoked"])
except APIToken.DoesNotExist:
pass
tokens_qs = APIToken.objects.filter(user=request.user).order_by("-created")
tokens = [
{
"id": t.id,
"name": t.name,
"scopes": t.scopes,
"created": t.created,
"expires": t.expires,
"revoked": t.revoked,
}
for t in tokens_qs
]
return render(request, "atlas/api_tokens.html", {"tokens": tokens, "created_token": created_token})
@login_required
@user_has_case_view_access
@require_http_methods(["GET", "POST"])
+3
View File
@@ -90,6 +90,9 @@
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
{% endif %}
<a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}">Edit profile</a>
{% if request.user == user %}
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'atlas:api_tokens' %}">API tokens</a>
{% endif %}
{% if request.user.is_superuser %}
<a class="btn btn-outline-danger d-block" href="{% url 'admin:auth_user_change' user.pk %}" target="_blank" rel="noopener">Admin edit</a>
{% endif %}