mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-14 21:07:40 +00:00
错误码(t16)
- HTTP 语义只由 core/exceptions.py 的 RET.code → fastapi.status 映射决定;
映射表显式登记 -1→400 与全部「数值即 HTTP 状态」的 RET 成员,_DEFAULT_BUSINESS_STATUS
降为「新增码忘登记」的安全网
- 移除冗余 status_code:131 处调用点改为纯 code 表达;security.py/dependencies.py
的 status_code=401/403 去除(映射表已给);get_or_404 → code=RET.NOT_FOUND.code
- 新增静态护栏(AST):app/ 下除 exceptions.py 外不得出现 status_code 传参,
路由装饰器 201 白名单显式放行并断言白名单非空
异常写法统一(t19)
- 删除 CustomException.internal 及 132 处包装:意外异常直接冒泡,由全局
@app.exception_handler(Exception) 统一映射 5xx + 通用文案 + 日志;
需要运维上下文处改为 logger.exception("<业务动作>失败") + raise
- 构造器不再接受 status_code 参数(属性保留,由 code 派生)→ 结构上杜绝第二来源
- 修复 6 处「宽泛 except 把 400 包成 500」的语义反转(补 except CustomException: raise),
并加 AST 护栏防止回潮
- 全局 SQLAlchemyError:非完整性错误由 400 改为 503(连接类)/500(其它),
不再把驱动原文/异常类型拼进客户端 msg;顺带修掉不存在的
status.HTTP_403_SERVICE_UNAVAILABLE 常量(命中即 AttributeError→500)
验证:pytest 97 passed / ruff 全绿;AST 审计残留 0;护栏红绿测试通过;
线上实测验证码过期仍 400 并保留业务文案、无令牌 401。
98 lines
4.2 KiB
Python
98 lines
4.2 KiB
Python
"""静态数据加密工具(Fernet 对称加密)。
|
||
|
||
用于统一「落库/落缓存的敏感字段」加密方式(存储源口令、AI 密钥等),
|
||
密钥与 JWT 签名密钥分离:
|
||
|
||
- 主密钥优先取 ``DATA_ENCRYPTION_KEY``,未配置时由 ``SECRET_KEY`` 经
|
||
HKDF-SHA256(带域分隔 info)派生,避免与令牌签名密钥直接同源;
|
||
- 解密按 [主密钥, ``DATA_ENCRYPTION_OLD_KEYS`` 中的旧密钥..., 历史 SHA256(SECRET_KEY)]
|
||
顺序尝试,因此支持密钥轮换,也不会打断升级前已加密的存量数据。
|
||
"""
|
||
|
||
import base64
|
||
import hashlib
|
||
|
||
from cryptography.fernet import Fernet, InvalidToken
|
||
from cryptography.hazmat.primitives import hashes
|
||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||
|
||
from app.config.setting import settings
|
||
from app.core.logger import logger
|
||
|
||
# HKDF 域分隔:与 SECRET_KEY 用于其它目的时派生出不同密钥
|
||
_INFO = b"fastapiadmin:data-encryption:v1"
|
||
# Fernet token 首字节为版本 0x80,url-safe base64 编码后固定以 "gAAAAA" 开头
|
||
_TOKEN_PREFIX = "gAAAAA"
|
||
|
||
|
||
class CryptoUtil:
|
||
"""敏感字段加解密(进程内缓存密钥环,配置变更时自动重建)。"""
|
||
|
||
_cache_signature: tuple | None = None
|
||
_fernets: list[Fernet] = []
|
||
|
||
@classmethod
|
||
def _derive_key(cls, secret: str) -> bytes:
|
||
return base64.urlsafe_b64encode(
|
||
HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=_INFO).derive(secret.encode("utf-8"))
|
||
)
|
||
|
||
@classmethod
|
||
def _legacy_key(cls, secret: str) -> bytes:
|
||
"""升级前的派生方式:直接对 SECRET_KEY 取 SHA-256,仅用于解密历史密文。"""
|
||
return base64.urlsafe_b64encode(hashlib.sha256(secret.encode("utf-8")).digest())
|
||
|
||
@classmethod
|
||
def _key_ring(cls) -> list[Fernet]:
|
||
signature = (settings.DATA_ENCRYPTION_KEY, settings.DATA_ENCRYPTION_OLD_KEYS, settings.SECRET_KEY)
|
||
if cls._cache_signature != signature:
|
||
primaries = [settings.DATA_ENCRYPTION_KEY or settings.SECRET_KEY]
|
||
primaries += [key.strip() for key in (settings.DATA_ENCRYPTION_OLD_KEYS or "").split(",") if key.strip()]
|
||
fernets = [Fernet(cls._derive_key(secret)) for secret in primaries]
|
||
fernets.append(Fernet(cls._legacy_key(settings.SECRET_KEY)))
|
||
cls._fernets = fernets
|
||
cls._cache_signature = signature
|
||
return cls._fernets
|
||
|
||
@classmethod
|
||
def encrypt(cls, plain: str | None) -> str:
|
||
"""明文 → 密文;空值原样返回空串(始终使用主密钥加密)。"""
|
||
if not plain:
|
||
return ""
|
||
return cls._key_ring()[0].encrypt(plain.encode("utf-8")).decode("utf-8")
|
||
|
||
@classmethod
|
||
def decrypt(cls, cipher: str | None) -> str:
|
||
"""密文 → 明文;所有密钥均解不开视为密钥已变更/数据损坏,抛出可定位的异常。"""
|
||
if not cipher:
|
||
return ""
|
||
for fernet in cls._key_ring():
|
||
try:
|
||
return fernet.decrypt(cipher.encode("utf-8")).decode("utf-8")
|
||
except (InvalidToken, ValueError):
|
||
continue
|
||
logger.error("敏感数据解密失败,可能原因:DATA_ENCRYPTION_KEY/SECRET_KEY 变更或数据损坏")
|
||
raise RuntimeError("敏感数据解密失败,可能原因:DATA_ENCRYPTION_KEY/SECRET_KEY 变更或数据损坏")
|
||
|
||
@classmethod
|
||
def decrypt_or_keep(cls, value: str | None) -> str:
|
||
"""兼容历史明文的解密:值不是 Fernet 密文格式时原样返回。
|
||
|
||
用于加密能力上线前就已存在的明文(如 Redis 中的 AI 密钥缓存,
|
||
带 TTL,会随用户重新保存自然收敛为密文)。
|
||
"""
|
||
if not value or not cls.is_token(value):
|
||
return value or ""
|
||
return cls.decrypt(value)
|
||
|
||
@staticmethod
|
||
def is_token(value: str) -> bool:
|
||
"""判断字符串是否具备 Fernet 密文的外形(前缀 + 可 base64 解码 + 最小长度)。"""
|
||
if not value.startswith(_TOKEN_PREFIX) or len(value) < 60:
|
||
return False
|
||
try:
|
||
base64.urlsafe_b64decode(value.encode("utf-8"))
|
||
except ValueError:
|
||
return False
|
||
return True
|