[Feature] Create API Key Authenticaton for v1 API (#327)

Add API key authentication to v1 API
Also includes:
- management command to create keys for users
- Improvements to API tests

Minor:
- more robust way to start docker dev container.

Reviewed-on: enviPath/enviPy#327
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
2026-02-11 02:29:54 +13:00
committed by jebus
parent c0cfdb9255
commit 5789f20e7f
15 changed files with 282 additions and 165 deletions

View File

@ -2,20 +2,12 @@ from typing import List
from django.contrib.auth import get_user_model
from ninja import Router, Schema, Field
from ninja.errors import HttpError
from ninja.pagination import paginate
from ninja.security import HttpBearer
from epapi.v1.auth import BearerTokenAuth
from .logic import PackageManager
from .models import User, Compound, APIToken
class BearerTokenAuth(HttpBearer):
def authenticate(self, request, token):
for token_obj in APIToken.objects.select_related("user").all():
if token_obj.check_token(token) and token_obj.is_valid():
return token_obj.user
raise HttpError(401, "Invalid or expired token")
from .models import User, Compound
def _anonymous_or_real(request):

View File

@ -679,7 +679,7 @@ class PackageManager(object):
ai_data = json.loads(res.model_dump_json())
ai_data["uuid"] = f"{uuid4()}"
new_add_inf[res_cls_name].append(ai_data)
except ValidationError:
except (ValidationError, ValueError):
logger.error(f"Failed to convert {name} with {addinf_data}")
scen.additional_information = new_add_inf

View File

@ -0,0 +1,92 @@
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from epdb.models import APIToken
class Command(BaseCommand):
help = "Create an API token for a user"
def add_arguments(self, parser):
parser.add_argument(
"--username",
required=True,
help="Username of the user who will own the token",
)
parser.add_argument(
"--name",
required=True,
help="Descriptive name for the token",
)
parser.add_argument(
"--expires-days",
type=int,
default=90,
help="Days until expiration (0 for no expiration)",
)
parser.add_argument(
"--inactive",
action="store_true",
help="Create the token as inactive",
)
parser.add_argument(
"--curl",
action="store_true",
help="Print a curl example using the token",
)
parser.add_argument(
"--base-url",
default=None,
help="Base URL for curl example (default SERVER_URL or http://localhost:8000)",
)
parser.add_argument(
"--endpoint",
default="/api/v1/compounds/",
help="Endpoint path for curl example",
)
def handle(self, *args, **options):
username = options["username"]
name = options["name"]
expires_days = options["expires_days"]
if expires_days < 0:
raise CommandError("--expires-days must be >= 0")
if expires_days == 0:
expires_days = None
user_model = get_user_model()
try:
user = user_model.objects.get(username=username)
except user_model.DoesNotExist as exc:
raise CommandError(f"User not found for username '{username}'") from exc
token, raw_token = APIToken.create_token(user, name=name, expires_days=expires_days)
if options["inactive"]:
token.is_active = False
token.save(update_fields=["is_active"])
self.stdout.write(f"User: {user.username} ({user.email})")
self.stdout.write(f"Token name: {token.name}")
self.stdout.write(f"Token id: {token.id}")
if token.expires_at:
self.stdout.write(f"Expires at: {token.expires_at.isoformat()}")
else:
self.stdout.write("Expires at: never")
self.stdout.write(f"Active: {token.is_active}")
self.stdout.write("Raw token:")
self.stdout.write(raw_token)
if options["curl"]:
base_url = (
options["base_url"] or getattr(s, "SERVER_URL", None) or "http://localhost:8000"
)
endpoint = options["endpoint"]
endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
url = f"{base_url.rstrip('/')}{endpoint}"
curl_cmd = f'curl -H "Authorization: Bearer {raw_token}" "{url}"'
self.stdout.write("Curl:")
self.stdout.write(curl_cmd)

View File

@ -170,17 +170,18 @@ class APIToken(TimeStampedModel):
return token, raw_key
@classmethod
def authenticate(cls, raw_key: str) -> Optional[User]:
def authenticate(cls, token: str, *, hashed: bool = False) -> Optional[User]:
"""
Authenticate a user using an API token.
Args:
raw_key: Raw token key
token: Raw token key or SHA-256 hash (when hashed=True)
hashed: Whether the token is already hashed
Returns:
User if token is valid, None otherwise
"""
hashed_key = hashlib.sha256(raw_key.encode()).hexdigest()
hashed_key = token if hashed else hashlib.sha256(token.encode()).hexdigest()
try:
token = cls.objects.select_related("user").get(hashed_key=hashed_key)

View File

@ -2600,9 +2600,11 @@ def user(request, user_uuid):
if is_hidden_method and request.POST["hidden"] == "request-api-token":
name = request.POST.get("name", "No Name")
valid_for = min(max(int(request.POST.get("valid-for", 90)), 1), 90)
expires_days = min(max(int(request.POST.get("valid-for", 90)), 1), 90)
token, raw_token = APIToken.create_token(request.user, name=name, valid_for=valid_for)
token, raw_token = APIToken.create_token(
request.user, name=name, expires_days=expires_days
)
return JsonResponse(
{"raw_token": raw_token, "token": {"id": token.id, "name": token.name}}