feat: add api key authentication
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework.authentication import BaseAuthentication, get_authorization_header
|
||||
from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
|
||||
|
||||
from apps.api.errors import api_error
|
||||
from apps.users.models import ApiKey
|
||||
|
||||
|
||||
class ApiKeyAuthentication(BaseAuthentication):
|
||||
keyword = "Bearer"
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_header = get_authorization_header(request)
|
||||
if not raw_header:
|
||||
return None
|
||||
|
||||
try:
|
||||
header = raw_header.decode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise self.authentication_failed() from exc
|
||||
|
||||
parts = header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != self.keyword.lower():
|
||||
raise self.authentication_failed()
|
||||
|
||||
raw_key = parts[1]
|
||||
if not raw_key:
|
||||
raise self.authentication_failed()
|
||||
|
||||
key_hash = ApiKey.hash_key(raw_key)
|
||||
try:
|
||||
api_key = ApiKey.objects.select_related("user").get(key_hash=key_hash)
|
||||
except ApiKey.DoesNotExist as exc:
|
||||
raise self.authentication_failed() from exc
|
||||
|
||||
if not api_key.matches_key(raw_key):
|
||||
raise self.authentication_failed()
|
||||
|
||||
if not api_key.is_active_key:
|
||||
raise PermissionDenied(
|
||||
api_error("account_disabled", "账号或 API Key 已禁用")
|
||||
)
|
||||
|
||||
if not api_key.user.is_business_active:
|
||||
raise PermissionDenied(
|
||||
api_error("account_disabled", "账号或 API Key 已禁用")
|
||||
)
|
||||
|
||||
now = timezone.now()
|
||||
ApiKey.objects.filter(pk=api_key.pk).update(last_used_at=now)
|
||||
api_key.last_used_at = now
|
||||
return api_key.user, api_key
|
||||
|
||||
def authenticate_header(self, request) -> str:
|
||||
return self.keyword
|
||||
|
||||
@staticmethod
|
||||
def authentication_failed() -> AuthenticationFailed:
|
||||
return AuthenticationFailed(api_error("unauthorized", "缺失或无效 API Key"))
|
||||
@@ -0,0 +1,7 @@
|
||||
def api_error(code: str, message: str) -> dict:
|
||||
return {
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
}
|
||||
+102
-2
@@ -1,3 +1,103 @@
|
||||
from django.test import TestCase
|
||||
import uuid
|
||||
|
||||
# Create your tests here.
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import path
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.api.authentication import ApiKeyAuthentication
|
||||
from apps.api.views import ExternalApiView
|
||||
from apps.users.models import ApiKey
|
||||
|
||||
|
||||
class AuthenticatedEchoView(ExternalApiView):
|
||||
def get(self, request):
|
||||
return Response(
|
||||
{
|
||||
"user_id": request.user.id,
|
||||
"api_key_id": request.auth.id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("api/test-auth/", AuthenticatedEchoView.as_view()),
|
||||
]
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF=__name__)
|
||||
class ApiKeyAuthenticationTests(TestCase):
|
||||
url = "/api/test-auth/"
|
||||
|
||||
def setUp(self):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username=f"api-user-{suffix}",
|
||||
email=f"api-user-{suffix}@example.com",
|
||||
password="password",
|
||||
)
|
||||
self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="test")
|
||||
self.client = APIClient()
|
||||
|
||||
def auth_header(self, raw_key: str | None = None) -> dict:
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {raw_key or self.raw_key}"}
|
||||
|
||||
def test_external_api_view_only_uses_api_key_authentication(self):
|
||||
self.assertEqual(AuthenticatedEchoView.authentication_classes, (ApiKeyAuthentication,))
|
||||
|
||||
def test_valid_bearer_key_authenticates_user_and_api_key(self):
|
||||
response = self.client.get(self.url, **self.auth_header())
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["user_id"], self.user.id)
|
||||
self.assertEqual(response.data["api_key_id"], self.api_key.id)
|
||||
|
||||
self.api_key.refresh_from_db()
|
||||
self.assertIsNotNone(self.api_key.last_used_at)
|
||||
|
||||
def test_missing_api_key_returns_401(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response["WWW-Authenticate"], "Bearer")
|
||||
self.assertEqual(response.data["error"]["code"], "unauthorized")
|
||||
|
||||
def test_invalid_api_key_returns_401(self):
|
||||
response = self.client.get(self.url, **self.auth_header("sk_cmhub_invalid"))
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response["WWW-Authenticate"], "Bearer")
|
||||
self.assertEqual(response.data["error"]["code"], "unauthorized")
|
||||
|
||||
def test_malformed_authorization_header_returns_401(self):
|
||||
response = self.client.get(self.url, HTTP_AUTHORIZATION=f"Token {self.raw_key}")
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.data["error"]["code"], "unauthorized")
|
||||
|
||||
def test_revoked_api_key_returns_403(self):
|
||||
self.api_key.status = ApiKey.Status.REVOKED
|
||||
self.api_key.save(update_fields=("status", "updated_at"))
|
||||
|
||||
response = self.client.get(self.url, **self.auth_header())
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.data["error"]["code"], "account_disabled")
|
||||
|
||||
def test_disabled_user_returns_403(self):
|
||||
self.user.status = self.user.Status.DISABLED
|
||||
self.user.save(update_fields=("status",))
|
||||
|
||||
response = self.client.get(self.url, **self.auth_header())
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.data["error"]["code"], "account_disabled")
|
||||
|
||||
def test_web_session_login_is_not_accepted_for_external_api(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.data["error"]["code"], "unauthorized")
|
||||
|
||||
+15
-2
@@ -1,3 +1,16 @@
|
||||
from django.shortcuts import render
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.views import APIView
|
||||
|
||||
# Create your views here.
|
||||
from apps.api.authentication import ApiKeyAuthentication
|
||||
from apps.api.errors import api_error
|
||||
|
||||
|
||||
class ExternalApiView(APIView):
|
||||
authentication_classes = (ApiKeyAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def permission_denied(self, request, message=None, code=None):
|
||||
if request.authenticators and not request.successful_authenticator:
|
||||
raise AuthenticationFailed(api_error("unauthorized", "缺失或无效 API Key"))
|
||||
super().permission_denied(request, message=message, code=code)
|
||||
|
||||
Reference in New Issue
Block a user