11
This commit is contained in:
@@ -15,6 +15,10 @@ REDIS_URL=redis://127.0.0.1:6379/0
|
|||||||
SECRET_KEY=change-me-to-a-long-random-string
|
SECRET_KEY=change-me-to-a-long-random-string
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||||
|
|
||||||
|
# API 传输 AES-256-GCM(与 Tauri AICLIENT_API_AES_KEY 相同;32 字节 Base64)
|
||||||
|
# 生成示例: python -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())"
|
||||||
|
API_AES_KEY=98cde5e64ee0da6ed0c5842f43a950aa0d8ae5087c43c8d8ee5d10bd8d571bb4
|
||||||
|
|
||||||
# CORS(逗号分隔,* 表示允许全部)
|
# CORS(逗号分隔,* 表示允许全部)
|
||||||
CORS_ORIGINS=http://localhost:1420,tauri://localhost
|
CORS_ORIGINS=http://localhost:1420,tauri://localhost
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ class Settings(BaseSettings):
|
|||||||
secret_key: str = Field(default="change-me-in-production")
|
secret_key: str = Field(default="change-me-in-production")
|
||||||
access_token_expire_minutes: int = 60 * 24 * 7
|
access_token_expire_minutes: int = 60 * 24 * 7
|
||||||
|
|
||||||
|
"""AES-256 密钥:32 字节 Base64 或 64 位十六进制;为空则关闭 API 加解密。"""
|
||||||
|
api_aes_key: str = "98cde5e64ee0da6ed0c5842f43a950aa0d8ae5087c43c8d8ee5d10bd8d571bb4"
|
||||||
|
|
||||||
cors_origins: str = "http://localhost:1420"
|
cors_origins: str = "http://localhost:1420"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
69
app/core/aes_crypto.py
Normal file
69
app/core/aes_crypto.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""AES-256-GCM 加解密,与 aiclient Tauri 端一致。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
ENVELOPE_VERSION = 1
|
||||||
|
ENCRYPTED_HEADER = "x-aiclient-encrypted"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_aes_key(raw: str) -> bytes | None:
|
||||||
|
"""解析 32 字节密钥:标准 Base64 或 64 位十六进制。"""
|
||||||
|
s = raw.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if len(s) == 64 and all(c in "0123456789abcdefABCDEF" for c in s):
|
||||||
|
key = bytes.fromhex(s)
|
||||||
|
else:
|
||||||
|
key = base64.b64decode(s)
|
||||||
|
if len(key) != 32:
|
||||||
|
return None
|
||||||
|
return key
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_bytes(plaintext: bytes, key: bytes) -> str:
|
||||||
|
nonce = os.urandom(12)
|
||||||
|
ciphertext = AESGCM(key).encrypt(nonce, plaintext, None)
|
||||||
|
return base64.b64encode(nonce + ciphertext).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_payload(b64_payload: str, key: bytes) -> bytes:
|
||||||
|
raw = base64.b64decode(b64_payload)
|
||||||
|
if len(raw) < 13:
|
||||||
|
raise ValueError("密文过短")
|
||||||
|
nonce, ciphertext = raw[:12], raw[12:]
|
||||||
|
return AESGCM(key).decrypt(nonce, ciphertext, None)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_encrypted(plaintext: bytes, key: bytes) -> bytes:
|
||||||
|
payload = encrypt_bytes(plaintext, key)
|
||||||
|
return json.dumps({"v": ENVELOPE_VERSION, "payload": payload}, ensure_ascii=False).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_encrypted(body: bytes, key: bytes) -> bytes:
|
||||||
|
obj: dict[str, Any] = json.loads(body)
|
||||||
|
if obj.get("v") != ENVELOPE_VERSION or "payload" not in obj:
|
||||||
|
raise ValueError("无效的加密信封")
|
||||||
|
payload = obj["payload"]
|
||||||
|
if not isinstance(payload, str):
|
||||||
|
raise ValueError("payload 应为字符串")
|
||||||
|
return decrypt_payload(payload, key)
|
||||||
|
|
||||||
|
|
||||||
|
def is_encrypted_envelope(body: bytes) -> bool:
|
||||||
|
try:
|
||||||
|
obj = json.loads(body)
|
||||||
|
return isinstance(obj, dict) and "payload" in obj and obj.get("v") == ENVELOPE_VERSION
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
@@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.middleware.api_crypto import ApiCryptoMiddleware
|
||||||
from app.redis_client import close_redis, init_redis
|
from app.redis_client import close_redis, init_redis
|
||||||
from app.schemas.common import ApiResponse
|
from app.schemas.common import ApiResponse
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ def create_app() -> FastAPI:
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.add_middleware(ApiCryptoMiddleware)
|
||||||
|
|
||||||
@app.get("/health", response_model=ApiResponse[dict])
|
@app.get("/health", response_model=ApiResponse[dict])
|
||||||
async def health() -> ApiResponse[dict]:
|
async def health() -> ApiResponse[dict]:
|
||||||
|
|||||||
1
app/middleware/__init__.py
Normal file
1
app/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""ASGI 中间件。"""
|
||||||
183
app/middleware/api_crypto.py
Normal file
183
app/middleware/api_crypto.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""对 /api/v1 请求体解密、响应体加密(需配置 API_AES_KEY)。
|
||||||
|
|
||||||
|
使用纯 ASGI 中间件,避免 BaseHTTPMiddleware 读取 body 后缓存导致路由拿不到明文。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.core.aes_crypto import (
|
||||||
|
ENCRYPTED_HEADER,
|
||||||
|
is_encrypted_envelope,
|
||||||
|
parse_aes_key,
|
||||||
|
unwrap_encrypted,
|
||||||
|
wrap_encrypted,
|
||||||
|
)
|
||||||
|
|
||||||
|
_API_PREFIX = "/api/v1"
|
||||||
|
|
||||||
|
_api_aes_key: bytes | None = None
|
||||||
|
_api_aes_key_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_aes_key() -> bytes | None:
|
||||||
|
print("get_api_aes_key",get_settings().api_aes_key)
|
||||||
|
global _api_aes_key, _api_aes_key_initialized
|
||||||
|
if not _api_aes_key_initialized:
|
||||||
|
_api_aes_key = parse_aes_key(get_settings().api_aes_key)
|
||||||
|
_api_aes_key_initialized = True
|
||||||
|
return _api_aes_key
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_body(receive: Receive) -> bytes:
|
||||||
|
body = b""
|
||||||
|
more = True
|
||||||
|
while more:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] != "http.request":
|
||||||
|
continue
|
||||||
|
body += message.get("body", b"")
|
||||||
|
more = message.get("more_body", False)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _make_receive(body: bytes) -> Receive:
|
||||||
|
sent = False
|
||||||
|
|
||||||
|
async def receive() -> Message:
|
||||||
|
nonlocal sent
|
||||||
|
if sent:
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
sent = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_with_content_length(scope: Scope, length: int) -> Scope:
|
||||||
|
new_scope = dict(scope)
|
||||||
|
headers: list[tuple[bytes, bytes]] = []
|
||||||
|
for name, value in scope.get("headers", []):
|
||||||
|
if name.lower() != b"content-length":
|
||||||
|
headers.append((name, value))
|
||||||
|
headers.append((b"content-length", str(length).encode()))
|
||||||
|
new_scope["headers"] = headers
|
||||||
|
return new_scope
|
||||||
|
|
||||||
|
|
||||||
|
def _header_value(scope: Scope, name: str) -> str | None:
|
||||||
|
target = name.lower().encode()
|
||||||
|
for key, value in scope.get("headers", []):
|
||||||
|
if key.lower() == target:
|
||||||
|
return value.decode("latin-1")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_json_response(headers: list[tuple[bytes, bytes]]) -> bool:
|
||||||
|
for key, value in headers:
|
||||||
|
if key.lower() == b"content-type":
|
||||||
|
return b"json" in value.lower()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_response_headers(headers: list[tuple[bytes, bytes]]) -> list[tuple[bytes, bytes]]:
|
||||||
|
skip = {b"content-length", b"content-encoding"}
|
||||||
|
return [(k, v) for k, v in headers if k.lower() not in skip]
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_plain_json(send: Send, scope: Scope, status: int, payload: bytes) -> None:
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": status,
|
||||||
|
"headers": [(b"content-type", b"application/json; charset=utf-8")],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await send({"type": "http.response.body", "body": payload, "more_body": False})
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypting_send(send: Send, key: bytes) -> Send:
|
||||||
|
status = 200
|
||||||
|
headers: list[tuple[bytes, bytes]] = []
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
started = False
|
||||||
|
|
||||||
|
async def wrapper(message: Message) -> None:
|
||||||
|
nonlocal status, headers, started
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
started = True
|
||||||
|
status = message["status"]
|
||||||
|
headers = list(message.get("headers", []))
|
||||||
|
return
|
||||||
|
|
||||||
|
if message["type"] != "http.response.body":
|
||||||
|
await send(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
chunks.append(message.get("body", b""))
|
||||||
|
if message.get("more_body", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
body = b"".join(chunks)
|
||||||
|
out_headers = _filter_response_headers(headers)
|
||||||
|
if _is_json_response(headers):
|
||||||
|
try:
|
||||||
|
body = wrap_encrypted(body, key)
|
||||||
|
except Exception as exc:
|
||||||
|
err = (
|
||||||
|
'{"ok":false,"message":"响应加密失败: '
|
||||||
|
+ str(exc).replace('"', '\\"')
|
||||||
|
+ '"}'
|
||||||
|
).encode("utf-8")
|
||||||
|
await _send_plain_json(send, {}, 500, err)
|
||||||
|
return
|
||||||
|
out_headers.append((ENCRYPTED_HEADER.encode(), b"1"))
|
||||||
|
|
||||||
|
out_headers.append((b"content-length", str(len(body)).encode()))
|
||||||
|
await send({"type": "http.response.start", "status": status, "headers": out_headers})
|
||||||
|
await send({"type": "http.response.body", "body": body, "more_body": False})
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class ApiCryptoMiddleware:
|
||||||
|
"""纯 ASGI 中间件(勿使用 BaseHTTPMiddleware)。"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = get_api_aes_key()
|
||||||
|
path = scope.get("path", "")
|
||||||
|
|
||||||
|
if key is None or not path.startswith(_API_PREFIX):
|
||||||
|
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
method = scope.get("method", "GET")
|
||||||
|
|
||||||
|
if method in ("POST", "PUT", "PATCH"):
|
||||||
|
body = await _read_body(receive)
|
||||||
|
if (
|
||||||
|
body
|
||||||
|
and _header_value(scope, ENCRYPTED_HEADER) == "1"
|
||||||
|
and is_encrypted_envelope(body)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
body = unwrap_encrypted(body, key)
|
||||||
|
except Exception as exc:
|
||||||
|
err = f'{{"ok":false,"message":"请求解密失败: {exc}"}}'.encode("utf-8")
|
||||||
|
await _send_plain_json(send, scope, 400, err)
|
||||||
|
return
|
||||||
|
|
||||||
|
receive = _make_receive(body)
|
||||||
|
scope = _scope_with_content_length(scope, len(body))
|
||||||
|
|
||||||
|
await self.app(scope, receive, _encrypting_send(send, key))
|
||||||
@@ -8,5 +8,6 @@ alembic>=1.14.0,<2.0
|
|||||||
redis>=5.2.0,<6.0
|
redis>=5.2.0,<6.0
|
||||||
bcrypt>=4.0.0,<6.0
|
bcrypt>=4.0.0,<6.0
|
||||||
python-jose[cryptography]>=3.3.0,<4.0
|
python-jose[cryptography]>=3.3.0,<4.0
|
||||||
|
cryptography>=43.0.0,<45.0
|
||||||
python-multipart>=0.0.12,<1.0
|
python-multipart>=0.0.12,<1.0
|
||||||
greenlet>=3.1.0,<4.0
|
greenlet>=3.1.0,<4.0
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function buildModelConfig() {
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
let apiUrl = pick("LLM_API_URL", "API_URL");
|
let apiUrl = pick("BAILIAN_BASE_URL", "BAILIAN_BASE_URL");
|
||||||
if (apiUrl) {
|
if (apiUrl) {
|
||||||
apiUrl = apiUrl.replace(/\/$/, "");
|
apiUrl = apiUrl.replace(/\/$/, "");
|
||||||
if (!apiUrl.includes("/chat/completions")) {
|
if (!apiUrl.includes("/chat/completions")) {
|
||||||
@@ -87,13 +87,13 @@ function buildModelConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerId: pick("LLM_PROVIDER_ID", "PROVIDER_ID") || "openai",
|
providerId: pick("LLM_PROVIDER_ID", "PROVIDER_ID") || "bailian",
|
||||||
modelId: pick("LLM_MODEL_ID", "MODEL_ID"),
|
modelId: pick("LLM_MODEL_ID", "MODEL_ID"),
|
||||||
apiUrl,
|
apiUrl,
|
||||||
apiKey: pick("LLM_API_KEY", "API_KEY"),
|
apiKey: pick("BAILIAN_API_KEY", "API_KEY"),
|
||||||
type: pick("LLM_TYPE", "TYPE") || "openai",
|
type: pick("LLM_TYPE", "TYPE") || "bailian",
|
||||||
asrMode,
|
asrMode,
|
||||||
aliyunApiKey: pick("DASHSCOPE_API_KEY", "ALIYUN_API_KEY"),
|
aliyunApiKey: pick("BAILIAN_API_KEY", "BAILIAN_API_KEY"),
|
||||||
ossConfig: ossConfig || undefined,
|
ossConfig: ossConfig || undefined,
|
||||||
asrServerInfo: asrServerInfo || undefined,
|
asrServerInfo: asrServerInfo || undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user