Skip to content

Commit 51b0ae5

Browse files
authored
Add api key plugin (#1)
1 parent 35ce464 commit 51b0ae5

18 files changed

Lines changed: 584 additions & 4 deletions

README.md

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,63 @@
1-
请在此处填写插件使用说明和您的联系方式
1+
# API Key 插件
22

3-
如果插件需要付费,请提供付费相关说明
3+
用户自定义 API Key 管理插件,允许用户生成、管理和使用 API Key 进行接口认证。
44

5-
如有配套前端插件,请添加前端插件仓库链接说明
5+
## 配置
66

7-
插件开发文档:[fba plugin dev](https://fastapi-practices.github.io/fastapi_best_architecture_docs/plugin/dev.html)
7+
`backend/core/conf.py` 中添加以下内容:
8+
9+
```
10+
##################################################
11+
# [ Plugin ] api_key
12+
##################################################
13+
API_KEY_GENERATE_PREFIX: str = 'fba-'
14+
```
15+
16+
## 使用方法
17+
18+
### 2. 替换 JWT 认证中间件
19+
20+
编辑 `backend/core/registrar.py`
21+
22+
```python
23+
# 原来的导入
24+
# from backend.middleware.jwt_auth_middleware import JwtAuthMiddleware
25+
26+
# 替换为
27+
from backend.plugin.api_key.middleware import JwtApiKeyAuthMiddleware
28+
29+
# 原来的 JWT auth
30+
# app.add_middleware(
31+
# AuthenticationMiddleware,
32+
# backend=JwtAuthMiddleware(),
33+
# on_error=JwtAuthMiddleware.auth_exception_handler,
34+
# )
35+
36+
# 替换为
37+
app.add_middleware(
38+
AuthenticationMiddleware,
39+
backend=JwtApiKeyAuthMiddleware(),
40+
on_error=JwtApiKeyAuthMiddleware.auth_exception_handler,
41+
)
42+
```
43+
44+
## 认证流程
45+
46+
```mermaid
47+
flowchart TD
48+
A[Authorization: Bearer token] --> B[token.startswith 'fba-' ?]
49+
B -->|Yes| C[API Key 认证]
50+
B -->|No| D[JWT Token 认证]
51+
C --> I[RBAC 权限校验]
52+
D --> I
53+
```
54+
55+
## 权限控制
56+
57+
API Key 完全继承用户权限
58+
59+
如需限制权限,只需创建专门的 API 用户:
60+
61+
1. 创建受限角色(如 `API 只读角色`),分配必要权限
62+
2. 创建 API 用户,分配该角色
63+
3. 使用该用户创建 API Key

__init__.py

Whitespace-only changes.

api/v1/__init__.py

Whitespace-only changes.

api/v1/sys/__init__.py

Whitespace-only changes.

api/v1/sys/api_key.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Depends, Path, Query, Request
4+
5+
from backend.common.response.response_code import CustomResponse
6+
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
7+
from backend.common.security.jwt import DependsJwtAuth
8+
from backend.common.security.permission import RequestPermission
9+
from backend.common.security.rbac import DependsRBAC
10+
from backend.database.db import CurrentSession, CurrentSessionTransaction
11+
from backend.plugin.api_key.schema.api_key import (
12+
CreateApiKeyParam,
13+
DeleteApiKeyParam,
14+
GetApiKeyDetail,
15+
UpdateApiKeyParam,
16+
)
17+
from backend.plugin.api_key.service import api_key_service
18+
19+
router = APIRouter()
20+
21+
22+
@router.get('/{pk}', summary='获取 API Key 详情', dependencies=[DependsJwtAuth])
23+
async def get_api_key(
24+
db: CurrentSession, request: Request, pk: Annotated[int, Path(description='通知公告 ID')]
25+
) -> ResponseSchemaModel[GetApiKeyDetail]:
26+
data = await api_key_service.get(db=db, user_id=request.user.id, is_superuser=request.user.is_superuser, pk=pk)
27+
return response_base.success(data=data)
28+
29+
30+
@router.get('', summary='分页获取所有 API Key', dependencies=[DependsJwtAuth])
31+
async def get_api_keys_paginated(
32+
db: CurrentSession,
33+
request: Request,
34+
name: Annotated[str | None, Query(description='API Key 名称')] = None,
35+
status: Annotated[int | None, Query(description='状态')] = None,
36+
) -> ResponseSchemaModel[list[GetApiKeyDetail]]:
37+
data = await api_key_service.get_list(
38+
db=db, user_id=request.user.id, is_superuser=request.user.is_superuser, name=name, status=status
39+
)
40+
return response_base.success(data=data)
41+
42+
43+
@router.post(
44+
'',
45+
summary='创建 API Key',
46+
dependencies=[
47+
Depends(RequestPermission('sys:apikey:add')),
48+
DependsRBAC,
49+
],
50+
)
51+
async def create_api_key(
52+
db: CurrentSessionTransaction,
53+
request: Request,
54+
obj: CreateApiKeyParam,
55+
) -> ResponseSchemaModel[GetApiKeyDetail]:
56+
data = await api_key_service.create(db=db, user_id=request.user.id, obj=obj)
57+
return response_base.success(
58+
res=CustomResponse(
59+
code=200,
60+
msg='创建成功,请妥善保管此 API Key,仅显示一次',
61+
),
62+
data=data,
63+
)
64+
65+
66+
@router.put(
67+
'/{pk}',
68+
summary='更新 API Key',
69+
dependencies=[
70+
Depends(RequestPermission('sys:apikey:edit')),
71+
DependsRBAC,
72+
],
73+
)
74+
async def update_api_key(
75+
db: CurrentSessionTransaction,
76+
request: Request,
77+
pk: Annotated[int, Path(description='API Key ID')],
78+
obj: UpdateApiKeyParam,
79+
) -> ResponseModel:
80+
await api_key_service.update(db=db, user_id=request.user.id, is_superuser=request.user.is_superuser, pk=pk, obj=obj)
81+
return response_base.success()
82+
83+
84+
@router.delete(
85+
'',
86+
summary='批量删除 API Key',
87+
dependencies=[
88+
Depends(RequestPermission('sys:apikey:del')),
89+
DependsRBAC,
90+
],
91+
)
92+
async def delete_api_keys(
93+
db: CurrentSessionTransaction,
94+
request: Request,
95+
obj: DeleteApiKeyParam,
96+
) -> ResponseModel:
97+
await api_key_service.delete(db=db, user_id=request.user.id, pks=obj.pks)
98+
return response_base.success()

crud/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from backend.plugin.api_key.crud.crud_api_key import api_key_dao as api_key_dao

crud/crud_api_key.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from datetime import timedelta
2+
3+
from sqlalchemy import Select
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
from sqlalchemy_crud_plus import CRUDPlus
6+
7+
from backend.plugin.api_key.model import ApiKey
8+
from backend.plugin.api_key.schema.api_key import CreateApiKeyParam, UpdateApiKeyParam
9+
from backend.utils.timezone import timezone
10+
11+
12+
class CRUDApiKey(CRUDPlus[ApiKey]):
13+
"""API Key 数据库操作类"""
14+
15+
async def get(self, db: AsyncSession, pk: int) -> ApiKey | None:
16+
"""
17+
获取 API Key
18+
19+
:param db: 数据库会话
20+
:param pk: API Key ID
21+
:return:
22+
"""
23+
return await self.select_model(db, pk)
24+
25+
async def get_by_user_id(self, db: AsyncSession, user_id: int, pk: int) -> ApiKey | None:
26+
"""
27+
通过用户 ID 获取 API Key
28+
29+
:param db: 数据库会话
30+
:param user_id: 用户 ID
31+
:param pk: API Key ID
32+
:return:
33+
"""
34+
return await self.select_model(db, pk, user_id=user_id)
35+
36+
async def get_by_key(self, db: AsyncSession, key: str) -> ApiKey | None:
37+
"""
38+
通过 key 获取 API Key
39+
40+
:param db: 数据库会话
41+
:param key: API Key
42+
:return:
43+
"""
44+
return await self.select_model_by_column(db, key=key)
45+
46+
async def get_select(self, user_id: int, is_superuser: bool, name: str | None, status: int | None) -> Select: # noqa: FBT001
47+
"""
48+
获取 API Key 列表查询表达式
49+
50+
:param user_id: 用户 ID
51+
:param is_superuser: 用户超级管理员权限
52+
:param name: API Key 名称
53+
:param status: 状态
54+
:return:
55+
"""
56+
filters = {}
57+
58+
if not is_superuser:
59+
filters['user_id'] = user_id
60+
if name is not None:
61+
filters['name__like'] = f'%{name}%'
62+
if status is not None:
63+
filters['status'] = status
64+
65+
return await self.select_order('id', 'desc', **filters)
66+
67+
async def create(self, db: AsyncSession, user_id: int, key: str, obj: CreateApiKeyParam) -> ApiKey:
68+
"""
69+
创建 API Key
70+
71+
:param db: 数据库会话
72+
:param user_id: 用户 ID
73+
:param key: API Key
74+
:param obj: 创建 API Key 参数
75+
:return:
76+
"""
77+
dict_obj = obj.model_dump(exclude={'expire_days'})
78+
dict_obj['user_id'] = user_id
79+
dict_obj['key'] = key
80+
if obj.expire_days is not None:
81+
dict_obj['expire_time'] = timezone.now() + timedelta(days=obj.expire_days)
82+
83+
new_api_key = self.model(**dict_obj)
84+
db.add(new_api_key)
85+
await db.flush()
86+
87+
return new_api_key
88+
89+
async def update(self, db: AsyncSession, pk: int, obj: UpdateApiKeyParam) -> int:
90+
"""
91+
更新 API Key
92+
93+
:param db: 数据库会话
94+
:param pk: API Key ID
95+
:param obj: 更新 API Key 参数
96+
:return:
97+
"""
98+
expire_time = timezone.now() + timedelta(days=obj.expire_days) if obj.expire_days is not None else None
99+
return await self.update_model(db, pk, obj, expire_time=expire_time)
100+
101+
async def delete(self, db: AsyncSession, pks: list[int]) -> int:
102+
"""
103+
批量删除 API Key
104+
105+
:param db: 数据库会话
106+
:param pks: API Key ID 列表
107+
:return:
108+
"""
109+
return await self.delete_model_by_column(db, allow_multiple=True, id__in=pks)
110+
111+
112+
api_key_dao: CRUDApiKey = CRUDApiKey(ApiKey)

middleware/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from backend.plugin.api_key.middleware.jwt_auth_middleware import (
2+
JwtApiKeyAuthMiddleware as JwtApiKeyAuthMiddleware,
3+
)

middleware/jwt_auth_middleware.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""
2+
扩展的 JWT 认证中间件,支持 API Key 认证
3+
4+
使用方式:
5+
Authorization: Bearer <jwt_token> # JWT 认证
6+
Authorization: Bearer fba_xxxxx # API Key 认证(以 fba_ 开头)
7+
"""
8+
9+
from fastapi import Request
10+
from starlette.authentication import AuthCredentials
11+
12+
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
13+
from backend.common.exception.errors import TokenError
14+
from backend.common.log import log
15+
from backend.common.security.jwt import get_jwt_user
16+
from backend.core.conf import settings
17+
from backend.database.db import async_db_session
18+
from backend.middleware.jwt_auth_middleware import AuthenticationError, JwtAuthMiddleware
19+
from backend.plugin.api_key.utils.key_ops import api_key_verify
20+
21+
22+
class JwtApiKeyAuthMiddleware(JwtAuthMiddleware):
23+
"""JWT 认证中间件(扩展支持 API Key)"""
24+
25+
async def authenticate(self, request: Request) -> tuple[AuthCredentials, GetUserInfoWithRelationDetail] | None:
26+
"""
27+
认证请求(支持 JWT Token 和 API Key)
28+
29+
:param request: FastAPI 请求对象
30+
:return:
31+
"""
32+
token = self.extract_token(request)
33+
if token is None:
34+
return None
35+
36+
if token.startswith(settings.API_KEY_GENERATE_PREFIX):
37+
return await self._api_key_authentication(token)
38+
39+
return await super().authenticate(request)
40+
41+
@staticmethod
42+
async def _api_key_authentication(api_key: str) -> tuple[AuthCredentials, GetUserInfoWithRelationDetail]:
43+
"""
44+
API Key 认证
45+
46+
:param api_key: API Key
47+
:return:
48+
"""
49+
try:
50+
async with async_db_session() as db:
51+
api_key_obj = await api_key_verify(db=db, key=api_key)
52+
user_id = api_key_obj.user_id
53+
user = await get_jwt_user(user_id)
54+
except TokenError as exc:
55+
raise AuthenticationError(code=exc.code, msg=exc.detail, headers=exc.headers)
56+
except Exception as e:
57+
log.exception(f'API Key 授权异常:{e}')
58+
raise AuthenticationError(code=getattr(e, 'code', 500), msg=getattr(e, 'msg', 'Internal Server Error'))
59+
60+
return AuthCredentials(['authenticated']), user

model/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from backend.plugin.api_key.model.api_key import ApiKey as ApiKey

0 commit comments

Comments
 (0)