Implement API token authentication: add BearerAuth and SessionOrBearer classes, create endpoints for token generation and validation

This commit is contained in:
Ross
2026-02-23 12:35:29 +00:00
parent d7f3ebf950
commit a3ac91437f
+161 -26
View File
@@ -2,6 +2,8 @@ from collections import defaultdict
from typing import List, Tuple
from django.shortcuts import get_object_or_404
from django.urls import reverse
import json
from django.http import JsonResponse, HttpResponse
from ninja import ModelSchema, Router, Schema, Field
from django.db import transaction, IntegrityError
@@ -15,12 +17,17 @@ from generic.decorators import check_user_in_group
from generic.constants import Group
from ninja.security import django_auth
from ninja.security import HttpBearer, HttpError
from ninja import NinjaAPI, File
from ninja.files import UploadedFile
import hashlib
import secrets
from django.utils import timezone
from django.contrib.auth import authenticate
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.csrf import csrf_exempt
from django.middleware.csrf import CsrfViewMiddleware
from generic.models import Examination, Modality
@@ -33,41 +40,63 @@ from loguru import logger
router = Router()
class TokenAuth:
"""Simple bearer token auth for Ninja endpoints.
class BearerAuth(HttpBearer):
"""Ninja HttpBearer auth that validates APIToken entries.
Integrates with `APIToken` model; use as `auth=TokenAuth()` on router
endpoints that should accept tokens in `Authorization: Bearer <token>`.
Returns the token owner user on success or raises HttpError(401) on failure.
"""
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]
def authenticate(self, request, token: str):
if not token:
raise HttpError(status_code=401)
token_hash = hashlib.sha256(token.encode()).hexdigest()
try:
api_token = APIToken.objects.get(token_hash=token_hash, revoked=False)
api_token = APIToken.objects.get(token_hash=token_hash)
except APIToken.DoesNotExist:
return None
raise HttpError(status_code=401)
if api_token.revoked:
raise HttpError(status_code=401)
if api_token.expires and api_token.expires < timezone.now():
return None
raise HttpError(status_code=401)
# mark last used (best-effort)
try:
api_token.mark_used()
except Exception:
pass
# Attach token object to request for handlers
# Attach for downstream handlers
request.api_token = api_token
return api_token.user
class SessionOrBearer:
"""Composite auth: prefer Django session (request.user), fall back to bearer token.
Use as `auth=SessionOrBearer()` on Ninja router endpoints.
"""
def __call__(self, request):
# Session-backed auth already populated by Django middleware
if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
return request.user
# Try bearer token from header
auth = request.META.get("HTTP_AUTHORIZATION")
if not auth:
raise HttpError(status_code=401)
parts = auth.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HttpError(status_code=401)
token = parts[1]
# reuse BearerAuth logic
bearer = BearerAuth()
return bearer.authenticate(request, token)
# Backwards-compatible alias used in existing decorators
TokenAuth = SessionOrBearer
class SeriesSchema(ModelSchema):
case_id: List[int] = []
@@ -355,15 +384,121 @@ class CreateTokenIn(Schema):
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)
class TokenAuthIn(Schema):
username: str
password: str
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}
@csrf_exempt
@router.post("/create_api_token")
def token_auth(request, payload: TokenAuthIn):
"""Authenticate username/password and return a new API token.
Returns JSON: {"token": "<raw-token>", "id": <token_id>}
"""
user = authenticate(request, username=payload.username, password=payload.password)
if user is None:
return HttpResponse(status=401, content=json.dumps({"detail": "Invalid credentials"}), content_type="application/json")
# Create a new token for this login (no scopes by default)
token, obj = APIToken.create_token(user, name=f"login-{timezone.now().isoformat()}")
return {"token": token, "id": obj.id, "username": user.username}
@csrf_exempt
@router.post("/token_check")
def token_check(request):
"""Check whether a token is valid.
Accepts either `Authorization: Bearer <token>` header or POST JSON
body `{ "token": "<token>" }`.
Returns JSON with keys: `valid` (bool), and when valid: `id`, `username`,
`scopes`, `expires`, `revoked`.
"""
auth = request.META.get("HTTP_AUTHORIZATION")
token_obj = None
# If header present, try TokenAuth path (this will set request.api_token)
if auth:
try:
user = TokenAuth()(request)
token_obj = getattr(request, "api_token", None)
except HttpError:
return JsonResponse({"valid": False}, status=401)
else:
# try JSON body
try:
data = json.loads(request.body.decode("utf-8")) if request.body else {}
except Exception:
data = {}
token = data.get("token")
if token:
token_hash = hashlib.sha256(token.encode()).hexdigest()
try:
t = APIToken.objects.get(token_hash=token_hash)
token_obj = t
except APIToken.DoesNotExist:
token_obj = None
if not token_obj:
return JsonResponse({"valid": False}, status=401)
# Validate token state
valid = True
if token_obj.revoked:
valid = False
if token_obj.expires and token_obj.expires < timezone.now():
valid = False
if not valid:
return JsonResponse({"valid": False, "revoked": token_obj.revoked}, status=401)
return JsonResponse(
{
"valid": True,
"id": token_obj.id,
"username": token_obj.user.username,
"scopes": token_obj.scopes,
"expires": token_obj.expires.isoformat() if token_obj.expires else None,
"revoked": token_obj.revoked,
},
status=200,
)
#@csrf_exempt
#@router.post("/create_api_token")
#def create_api_token(request, payload: CreateTokenIn):
# """Create an API token.
#
# This endpoint accepts either normal session auth (`request.user`) or
# a bearer token. If no authenticated user is found a 403 is returned.
# """
# # If caller is not using Authorization header, enforce CSRF via middleware
# if not request.META.get("HTTP_AUTHORIZATION"):
# mw = CsrfViewMiddleware()
# resp = mw.process_view(request, create_api_token, (), {})
# if resp is not None:
# return resp
# # Determine authenticated user: prefer session auth, fall back to token auth
# user = None
# if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
# user = request.user
# else:
# user = TokenAuth()(request)
#
# if not user:
# raise PermissionDenied()
#
# expires = None
# if payload.expires_days:
# expires = timezone.now() + timezone.timedelta(days=payload.expires_days)
#
# token, obj = APIToken.create_token(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())