feat: 添加API Key认证功能 [AC-AISVC-50]

- 新增 ApiKey 模型和数据库表
- 新增 ApiKeyService 服务,支持内存缓存验证
- 新增 ApiKeyMiddleware 中间件,验证所有请求
- 应用启动时自动创建默认 API Key
- 新增 /admin/api-keys 管理接口
This commit is contained in:
MerCry 2026-02-26 02:52:50 +08:00
parent 77033efd34
commit ee2c7c0d0c
7 changed files with 465 additions and 5 deletions

View File

@ -1,8 +1,9 @@
"""
Admin API routes for AI Service management.
[AC-ASA-01, AC-ASA-02, AC-ASA-05, AC-ASA-07, AC-ASA-08] Admin management endpoints.
[AC-ASA-01, AC-ASA-02, AC-ASA-05, AC-ASA-07, AC-ASA-08, AC-AISVC-50] Admin management endpoints.
"""
from app.api.admin.api_key import router as api_key_router
from app.api.admin.dashboard import router as dashboard_router
from app.api.admin.embedding import router as embedding_router
from app.api.admin.kb import router as kb_router
@ -11,4 +12,4 @@ from app.api.admin.rag import router as rag_router
from app.api.admin.sessions import router as sessions_router
from app.api.admin.tenants import router as tenants_router
__all__ = ["dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router", "tenants_router"]
__all__ = ["api_key_router", "dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router", "tenants_router"]

View File

@ -0,0 +1,154 @@
"""
API Key management endpoints.
[AC-AISVC-50] CRUD operations for API keys.
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.entities import ApiKey, ApiKeyCreate
from app.services.api_key import get_api_key_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/api-keys", tags=["API Keys"])
class ApiKeyResponse(BaseModel):
"""Response model for API key."""
id: str = Field(..., description="API key ID")
key: str = Field(..., description="API key value")
name: str = Field(..., description="API key name")
is_active: bool = Field(..., description="Whether the key is active")
created_at: str = Field(..., description="Creation time")
updated_at: str = Field(..., description="Last update time")
class ApiKeyListResponse(BaseModel):
"""Response model for API key list."""
keys: list[ApiKeyResponse] = Field(..., description="List of API keys")
total: int = Field(..., description="Total count")
class CreateApiKeyRequest(BaseModel):
"""Request model for creating API key."""
name: str = Field(..., description="API key name/description")
key: str | None = Field(default=None, description="Custom API key (auto-generated if not provided)")
class ToggleApiKeyRequest(BaseModel):
"""Request model for toggling API key status."""
is_active: bool = Field(..., description="New active status")
def api_key_to_response(api_key: ApiKey) -> ApiKeyResponse:
"""Convert ApiKey entity to response model."""
return ApiKeyResponse(
id=str(api_key.id),
key=api_key.key,
name=api_key.name,
is_active=api_key.is_active,
created_at=api_key.created_at.isoformat(),
updated_at=api_key.updated_at.isoformat(),
)
@router.get("", response_model=ApiKeyListResponse)
async def list_api_keys(
session: Annotated[AsyncSession, Depends(get_session)],
):
"""
[AC-AISVC-50] List all API keys.
"""
service = get_api_key_service()
keys = await service.list_keys(session)
return ApiKeyListResponse(
keys=[api_key_to_response(k) for k in keys],
total=len(keys),
)
@router.post("", response_model=ApiKeyResponse, status_code=status.HTTP_201_CREATED)
async def create_api_key(
request: CreateApiKeyRequest,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""
[AC-AISVC-50] Create a new API key.
"""
service = get_api_key_service()
key_value = request.key or service.generate_key()
key_create = ApiKeyCreate(
key=key_value,
name=request.name,
is_active=True,
)
api_key = await service.create_key(session, key_create)
logger.info(f"[AC-AISVC-50] Created API key: {api_key.name}")
return api_key_to_response(api_key)
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_api_key(
key_id: str,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""
[AC-AISVC-50] Delete an API key.
"""
service = get_api_key_service()
deleted = await service.delete_key(session, key_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found",
)
@router.patch("/{key_id}/toggle", response_model=ApiKeyResponse)
async def toggle_api_key(
key_id: str,
request: ToggleApiKeyRequest,
session: Annotated[AsyncSession, Depends(get_session)],
):
"""
[AC-AISVC-50] Toggle API key active status.
"""
service = get_api_key_service()
api_key = await service.toggle_key(session, key_id, request.is_active)
if not api_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found",
)
return api_key_to_response(api_key)
@router.post("/reload-cache", status_code=status.HTTP_204_NO_CONTENT)
async def reload_api_key_cache(
session: Annotated[AsyncSession, Depends(get_session)],
):
"""
[AC-AISVC-50] Reload API key cache from database.
"""
service = get_api_key_service()
await service.reload_cache(session)

View File

@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import chat_router, health_router
from app.api.admin import dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router, tenants_router
from app.api.admin import api_key_router, dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router, tenants_router
from app.api.admin.kb_optimized import router as kb_optimized_router
from app.core.config import get_settings
from app.core.database import close_db, init_db
@ -24,7 +24,7 @@ from app.core.exceptions import (
generic_exception_handler,
http_exception_handler,
)
from app.core.middleware import TenantContextMiddleware
from app.core.middleware import ApiKeyMiddleware, TenantContextMiddleware
from app.core.qdrant_client import close_qdrant_client
settings = get_settings()
@ -40,7 +40,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
[AC-AISVC-01, AC-AISVC-11] Application lifespan manager.
[AC-AISVC-01, AC-AISVC-11, AC-AISVC-50] Application lifespan manager.
Handles startup and shutdown of database and external connections.
"""
logger.info(f"[AC-AISVC-01] Starting {settings.app_name} v{settings.app_version}")
@ -51,6 +51,19 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"[AC-AISVC-11] Database initialization skipped: {e}")
try:
from app.core.database import async_session_maker
from app.services.api_key import get_api_key_service
async with async_session_maker() as session:
api_key_service = get_api_key_service()
await api_key_service.initialize(session)
default_key = await api_key_service.create_default_key(session)
if default_key:
logger.info(f"[AC-AISVC-50] Default API key created: {default_key.key}")
except Exception as e:
logger.warning(f"[AC-AISVC-50] API key initialization skipped: {e}")
yield
await close_db()
@ -87,6 +100,7 @@ app.add_middleware(
)
app.add_middleware(TenantContextMiddleware)
app.add_middleware(ApiKeyMiddleware)
app.add_exception_handler(AIServiceException, ai_service_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
@ -113,6 +127,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
app.include_router(health_router)
app.include_router(chat_router)
app.include_router(api_key_router)
app.include_router(dashboard_router)
app.include_router(embedding_router)
app.include_router(kb_router)

View File

@ -50,6 +50,7 @@ class ErrorCode(str, Enum):
INVALID_REQUEST = "INVALID_REQUEST"
MISSING_TENANT_ID = "MISSING_TENANT_ID"
INVALID_TENANT_ID = "INVALID_TENANT_ID"
UNAUTHORIZED = "UNAUTHORIZED"
INTERNAL_ERROR = "INTERNAL_ERROR"
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
TIMEOUT = "TIMEOUT"

View File

@ -198,3 +198,27 @@ class DocumentCreate(SQLModel):
file_path: str | None = None
file_size: int | None = None
file_type: str | None = None
class ApiKey(SQLModel, table=True):
"""
[AC-AISVC-50] API Key entity for lightweight authentication.
Keys are loaded into memory on startup for fast validation.
"""
__tablename__ = "api_keys"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
key: str = Field(..., description="API Key (unique)", unique=True, index=True)
name: str = Field(..., description="Key name/description for identification")
is_active: bool = Field(default=True, description="Whether the key is active")
created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation time")
updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update time")
class ApiKeyCreate(SQLModel):
"""Schema for creating a new API key."""
key: str
name: str
is_active: bool = True

View File

@ -0,0 +1,249 @@
"""
API Key management service.
[AC-AISVC-50] Lightweight authentication with in-memory cache.
"""
import logging
import secrets
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.entities import ApiKey, ApiKeyCreate
logger = logging.getLogger(__name__)
class ApiKeyService:
"""
[AC-AISVC-50] API Key management service.
Features:
- In-memory cache for fast validation
- Database persistence
- Hot-reload support
"""
def __init__(self):
self._keys_cache: set[str] = set()
self._initialized: bool = False
async def initialize(self, session: AsyncSession) -> None:
"""
Load all active API keys from database into memory.
Should be called on application startup.
"""
result = await session.execute(
select(ApiKey).where(ApiKey.is_active == True)
)
keys = result.scalars().all()
self._keys_cache = {key.key for key in keys}
self._initialized = True
logger.info(f"[AC-AISVC-50] Loaded {len(self._keys_cache)} API keys into memory")
def validate_key(self, key: str) -> bool:
"""
Validate an API key against the in-memory cache.
Args:
key: The API key to validate
Returns:
True if the key is valid, False otherwise
"""
if not self._initialized:
logger.warning("[AC-AISVC-50] API key service not initialized")
return False
return key in self._keys_cache
def generate_key(self) -> str:
"""
Generate a new secure API key.
Returns:
A URL-safe random string
"""
return secrets.token_urlsafe(32)
async def create_key(
self,
session: AsyncSession,
key_create: ApiKeyCreate
) -> ApiKey:
"""
Create a new API key.
Args:
session: Database session
key_create: Key creation data
Returns:
The created ApiKey entity
"""
api_key = ApiKey(
key=key_create.key,
name=key_create.name,
is_active=key_create.is_active,
)
session.add(api_key)
await session.commit()
await session.refresh(api_key)
if api_key.is_active:
self._keys_cache.add(api_key.key)
logger.info(f"[AC-AISVC-50] Created API key: {api_key.name}")
return api_key
async def create_default_key(self, session: AsyncSession) -> Optional[ApiKey]:
"""
Create a default API key if none exists.
Returns:
The created ApiKey or None if keys already exist
"""
result = await session.execute(select(ApiKey).limit(1))
existing = result.scalar_one_or_none()
if existing:
return None
default_key = secrets.token_urlsafe(32)
api_key = ApiKey(
key=default_key,
name="Default API Key",
is_active=True,
)
session.add(api_key)
await session.commit()
await session.refresh(api_key)
self._keys_cache.add(api_key.key)
logger.info(f"[AC-AISVC-50] Created default API key: {api_key.key}")
return api_key
async def delete_key(
self,
session: AsyncSession,
key_id: str
) -> bool:
"""
Delete an API key.
Args:
session: Database session
key_id: The key ID to delete
Returns:
True if deleted, False if not found
"""
import uuid
try:
key_uuid = uuid.UUID(key_id)
except ValueError:
return False
result = await session.execute(
select(ApiKey).where(ApiKey.id == key_uuid)
)
api_key = result.scalar_one_or_none()
if not api_key:
return False
key_value = api_key.key
await session.delete(api_key)
await session.commit()
self._keys_cache.discard(key_value)
logger.info(f"[AC-AISVC-50] Deleted API key: {api_key.name}")
return True
async def toggle_key(
self,
session: AsyncSession,
key_id: str,
is_active: bool
) -> Optional[ApiKey]:
"""
Toggle API key active status.
Args:
session: Database session
key_id: The key ID to toggle
is_active: New active status
Returns:
The updated ApiKey or None if not found
"""
import uuid
try:
key_uuid = uuid.UUID(key_id)
except ValueError:
return None
result = await session.execute(
select(ApiKey).where(ApiKey.id == key_uuid)
)
api_key = result.scalar_one_or_none()
if not api_key:
return None
api_key.is_active = is_active
api_key.updated_at = datetime.utcnow()
session.add(api_key)
await session.commit()
await session.refresh(api_key)
if is_active:
self._keys_cache.add(api_key.key)
else:
self._keys_cache.discard(api_key.key)
logger.info(f"[AC-AISVC-50] Toggled API key {api_key.name}: active={is_active}")
return api_key
async def list_keys(self, session: AsyncSession) -> list[ApiKey]:
"""
List all API keys.
Args:
session: Database session
Returns:
List of all ApiKey entities
"""
result = await session.execute(select(ApiKey))
return list(result.scalars().all())
async def reload_cache(self, session: AsyncSession) -> None:
"""
Reload all API keys from database into memory.
"""
self._keys_cache.clear()
await self.initialize(session)
logger.info("[AC-AISVC-50] API key cache reloaded")
_api_key_service: ApiKeyService | None = None
def get_api_key_service() -> ApiKeyService:
"""Get the global API key service instance."""
global _api_key_service
if _api_key_service is None:
_api_key_service = ApiKeyService()
return _api_key_service

View File

@ -74,6 +74,18 @@ CREATE TABLE IF NOT EXISTS index_jobs (
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL
);
-- ============================================
-- API Keys Table [AC-AISVC-50]
-- ============================================
CREATE TABLE IF NOT EXISTS api_keys (
id UUID NOT NULL PRIMARY KEY,
key VARCHAR NOT NULL UNIQUE,
name VARCHAR NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL
);
-- ============================================
-- Indexes
-- ============================================
@ -100,6 +112,10 @@ CREATE INDEX IF NOT EXISTS ix_index_jobs_tenant_id ON index_jobs (tenant_id);
CREATE INDEX IF NOT EXISTS ix_index_jobs_tenant_doc ON index_jobs (tenant_id, doc_id);
CREATE INDEX IF NOT EXISTS ix_index_jobs_tenant_status ON index_jobs (tenant_id, status);
-- API Keys Indexes [AC-AISVC-50]
CREATE INDEX IF NOT EXISTS ix_api_keys_key ON api_keys (key);
CREATE INDEX IF NOT EXISTS ix_api_keys_is_active ON api_keys (is_active);
-- ============================================
-- Verification
-- ============================================