feat(system): 增加ApiKey管理, resm paper接口需API Key或登录访问
- 新增 ApiKey 模型, key 保存时自动生成, Django admin 中管理和查看 - 新增 ApiKeyAuthentication/HasApiKey, 支持 X-API-Key 请求头和 api_key 查询参数 - PaperViewSet GET 与 paper_pdf_view 由 AllowAny 改为 HasApiKey | IsAuthenticated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
93ec54a98f
commit
cc6db5ffd9
|
|
@ -163,6 +163,8 @@ Key model:
|
||||||
- `Paper`: stores DOI/OpenAlex metadata, OA flags, abstract/fulltext state, fetch status, failure reason, and local file save helpers
|
- `Paper`: stores DOI/OpenAlex metadata, OA flags, abstract/fulltext state, fetch status, failure reason, and local file save helpers
|
||||||
- `PaperAbstract`: separate abstract storage
|
- `PaperAbstract`: separate abstract storage
|
||||||
|
|
||||||
|
Read access to paper APIs (`GET api/resm/paper/` and the PDF view) requires either a valid API Key or a logged-in JWT user. API Keys live in `apps.system.models.ApiKey` (plaintext `key` auto-generated on save, managed via Django admin) and are validated by `apps.utils.apikey_auth.ApiKeyAuthentication` from the `X-API-Key` header or `?api_key=` query param.
|
||||||
|
|
||||||
The paper fetch pipeline in `apps/resm/tasks.py` currently includes:
|
The paper fetch pipeline in `apps/resm/tasks.py` currently includes:
|
||||||
|
|
||||||
- metadata ingestion from OpenAlex
|
- metadata ingestion from OpenAlex
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.settings import api_settings
|
||||||
from .models import Paper, PaperAbstract
|
from .models import Paper, PaperAbstract
|
||||||
from .serializers import PaperListSerializer
|
from .serializers import PaperListSerializer
|
||||||
from .filters import PaperFilterSet
|
from .filters import PaperFilterSet
|
||||||
from apps.utils.viewsets import CustomGenericViewSet, CustomListModelMixin
|
from apps.utils.viewsets import CustomGenericViewSet, CustomListModelMixin
|
||||||
from apps.utils.mixins import CustomRetrieveModelMixin
|
from apps.utils.mixins import CustomRetrieveModelMixin
|
||||||
|
from apps.utils.apikey_auth import ApiKeyAuthentication, HasApiKey
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@permission_classes([AllowAny])
|
@authentication_classes([ApiKeyAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES)
|
||||||
|
@permission_classes([HasApiKey | IsAuthenticated])
|
||||||
def paper_pdf_view(request, pk):
|
def paper_pdf_view(request, pk):
|
||||||
paper = get_object_or_404(Paper, pk=pk)
|
paper = get_object_or_404(Paper, pk=pk)
|
||||||
if not paper.has_fulltext_pdf:
|
if not paper.has_fulltext_pdf:
|
||||||
|
|
@ -41,11 +44,12 @@ class PaperViewSet(CustomGenericViewSet, CustomListModelMixin, CustomRetrieveMod
|
||||||
ordering = ["-publication_date", "-create_time"]
|
ordering = ["-publication_date", "-create_time"]
|
||||||
|
|
||||||
def get_authenticators(self):
|
def get_authenticators(self):
|
||||||
|
authenticators = super().get_authenticators()
|
||||||
if self.request.method == 'GET':
|
if self.request.method == 'GET':
|
||||||
return []
|
return [ApiKeyAuthentication()] + authenticators
|
||||||
return super().get_authenticators()
|
return authenticators
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.request.method == 'GET':
|
if self.request.method == 'GET':
|
||||||
return [AllowAny()]
|
return [(HasApiKey | IsAuthenticated)()]
|
||||||
return super().get_permissions()
|
return super().get_permissions()
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import User, Dept, Role, Permission, DictType, Dictionary, File
|
from .models import User, Dept, Role, Permission, DictType, Dictionary, File, ApiKey
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
admin.site.register(User)
|
admin.site.register(User)
|
||||||
admin.site.register(Dept)
|
admin.site.register(Dept)
|
||||||
|
|
@ -8,3 +8,12 @@ admin.site.register(Permission)
|
||||||
admin.site.register(DictType)
|
admin.site.register(DictType)
|
||||||
admin.site.register(Dictionary)
|
admin.site.register(Dictionary)
|
||||||
admin.site.register(File)
|
admin.site.register(File)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ApiKey)
|
||||||
|
class ApiKeyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'key', 'is_active', 'expires_at', 'last_used_at', 'create_time')
|
||||||
|
list_filter = ('is_active',)
|
||||||
|
search_fields = ('name', 'key')
|
||||||
|
readonly_fields = ('key', 'last_used_at')
|
||||||
|
fields = ('name', 'key', 'is_active', 'expires_at', 'scopes', 'last_used_at')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Generated by Django 4.2.27 on 2026-07-06 02:30
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('system', '0008_merge_20260116_1509'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ApiKey',
|
||||||
|
fields=[
|
||||||
|
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
|
||||||
|
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
|
||||||
|
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
|
||||||
|
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
|
||||||
|
('name', models.CharField(help_text='调用方标识', max_length=100, verbose_name='名称')),
|
||||||
|
('key', models.CharField(db_index=True, editable=False, max_length=64, unique=True, verbose_name='密钥')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='启用')),
|
||||||
|
('expires_at', models.DateTimeField(blank=True, help_text='留空则永不过期', null=True, verbose_name='过期时间')),
|
||||||
|
('scopes', models.JSONField(blank=True, default=list, help_text='预留, 如 ["resm.paper"]', verbose_name='权限范围')),
|
||||||
|
('last_used_at', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='最后使用')),
|
||||||
|
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
|
||||||
|
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'API密钥',
|
||||||
|
'verbose_name_plural': 'API密钥',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -263,3 +263,33 @@ class MySchedule(CommonAModel):
|
||||||
IntervalSchedule, on_delete=models.PROTECT, null=True, blank=True)
|
IntervalSchedule, on_delete=models.PROTECT, null=True, blank=True)
|
||||||
crontab = models.ForeignKey(
|
crontab = models.ForeignKey(
|
||||||
CrontabSchedule, on_delete=models.PROTECT, null=True, blank=True)
|
CrontabSchedule, on_delete=models.PROTECT, null=True, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(CommonAModel):
|
||||||
|
"""
|
||||||
|
外部程序调用的API Key
|
||||||
|
key保存时自动生成, 明文存库, admin中可直接查看
|
||||||
|
"""
|
||||||
|
name = models.CharField('名称', max_length=100, help_text='调用方标识')
|
||||||
|
key = models.CharField('密钥', max_length=64, unique=True, db_index=True, editable=False)
|
||||||
|
is_active = models.BooleanField('启用', default=True)
|
||||||
|
expires_at = models.DateTimeField('过期时间', null=True, blank=True, help_text='留空则永不过期')
|
||||||
|
scopes = models.JSONField('权限范围', default=list, blank=True, help_text='预留, 如 ["resm.paper"]')
|
||||||
|
last_used_at = models.DateTimeField('最后使用', null=True, blank=True, editable=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'API密钥'
|
||||||
|
verbose_name_plural = verbose_name
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_key() -> str:
|
||||||
|
import secrets
|
||||||
|
return 'pk_' + secrets.token_urlsafe(36)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.key:
|
||||||
|
self.key = self.generate_key()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import exceptions
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
|
from apps.system.models import ApiKey
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyAuthentication(BaseAuthentication):
|
||||||
|
"""
|
||||||
|
API Key认证: 取请求头 X-API-Key 或查询参数 api_key
|
||||||
|
通过后 request.auth 为 ApiKey 实例, request.user 为匿名用户
|
||||||
|
未携带Key时返回None, 交给后续认证类处理
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
raw_key = request.META.get('HTTP_X_API_KEY') or request.GET.get('api_key')
|
||||||
|
if not raw_key:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
apikey = ApiKey.objects.get(key=raw_key)
|
||||||
|
except ApiKey.DoesNotExist:
|
||||||
|
raise exceptions.AuthenticationFailed('无效的API Key')
|
||||||
|
if not apikey.is_active:
|
||||||
|
raise exceptions.AuthenticationFailed('API Key已禁用')
|
||||||
|
if apikey.expires_at and apikey.expires_at <= timezone.now():
|
||||||
|
raise exceptions.AuthenticationFailed('API Key已过期')
|
||||||
|
now = timezone.now()
|
||||||
|
# last_used_at节流更新, 避免每次请求都写库
|
||||||
|
if apikey.last_used_at is None or now - apikey.last_used_at > timedelta(minutes=5):
|
||||||
|
ApiKey.objects.filter(pk=apikey.pk).update(last_used_at=now)
|
||||||
|
return (AnonymousUser(), apikey)
|
||||||
|
|
||||||
|
|
||||||
|
class HasApiKey(BasePermission):
|
||||||
|
"""
|
||||||
|
持有效API Key即放行
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return isinstance(request.auth, ApiKey)
|
||||||
Loading…
Reference in New Issue