Files
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

406 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
把应用安全能力适配为 FastAPI 认证依赖和 Cookie 行为。
"""
import datetime
from datetime import timedelta
from typing import Annotated, Any, Callable, Optional
import jwt
from fastapi import HTTPException, Request, Response, Security, status
from fastapi.security import (
APIKeyCookie,
APIKeyHeader,
APIKeyQuery,
HTTPBearer,
OAuth2PasswordBearer,
)
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.token import TokenPayload
SuperuserTokenPayloadProvider = Callable[[], TokenPayload]
TokenIdentityValidator = Callable[[TokenPayload], None]
TokenEncoder = Callable[..., str]
TokenDecoder = Callable[[str | None, str], TokenPayload]
_superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None
_token_identity_validator: Optional[TokenIdentityValidator] = None
_token_encoder: Optional[TokenEncoder] = None
_token_decoder: Optional[TokenDecoder] = None
JWT_ALGORITHM = "HS256"
oauth2_scheme_manual_error = OAuth2PasswordBearer(
auto_error=False,
tokenUrl=f"{get_runtime_setting('API_V1_STR')}/login/access-token",
)
resource_token_cookie = APIKeyCookie(
name=get_runtime_setting('PROJECT_NAME'),
auto_error=False,
scheme_name="resource_token_cookie",
)
api_token_query = APIKeyQuery(
name="token",
auto_error=False,
scheme_name="api_token_query",
)
api_key_header = APIKeyHeader(
name="X-API-KEY",
auto_error=False,
scheme_name="api_key_header",
)
api_key_query = APIKeyQuery(
name="apikey",
auto_error=False,
scheme_name="api_key_query",
)
openai_bearer_scheme = HTTPBearer(auto_error=False)
anthropic_api_key_header = APIKeyHeader(
name="x-api-key",
auto_error=False,
scheme_name="anthropic_api_key_header",
)
def set_superuser_token_payload_provider(
provider: SuperuserTokenPayloadProvider,
) -> None:
"""
由启动组合根注入 API 密钥认证使用的超级用户载荷来源。
"""
global _superuser_token_payload_provider
_superuser_token_payload_provider = provider
def reset_superuser_token_payload_provider() -> None:
"""
清除当前应用生命周期的超级用户载荷来源。
"""
global _superuser_token_payload_provider
_superuser_token_payload_provider = None
def set_token_identity_validator(validator: TokenIdentityValidator) -> None:
"""
由启动组合根注入当前令牌身份校验端口。
"""
global _token_identity_validator
_token_identity_validator = validator
def reset_token_identity_validator() -> None:
"""
清除当前应用生命周期的令牌身份校验端口。
"""
global _token_identity_validator
_token_identity_validator = None
def configure_token_codec(
encoder: TokenEncoder,
decoder: TokenDecoder,
) -> None:
"""
由组合根注入框架无关的令牌编码与解码能力。
"""
global _token_encoder, _token_decoder
_token_encoder = encoder
_token_decoder = decoder
def _encode_token(**claims: Any) -> str:
"""
使用已注入编码器创建令牌,未装配时给出明确错误。
"""
if _token_encoder is None:
raise RuntimeError("Web 认证令牌编码器尚未配置")
return _token_encoder(**claims)
def _decode_token(token: str | None, purpose: str) -> TokenPayload:
"""
使用已注入解码器验证令牌,未装配时给出明确错误。
"""
if _token_decoder is None:
raise RuntimeError("Web 认证令牌解码器尚未配置")
return _token_decoder(token, purpose)
def _get_api_token(
token_query: Annotated[str | None, Security(api_token_query)] = None,
key_header: Annotated[str | None, Security(api_key_header)] = None,
) -> str | None:
"""
优先从请求头、其次从查询参数读取兼容 API Token。
"""
return key_header or token_query
def _get_api_key(
key_query: Annotated[str | None, Security(api_key_query)] = None,
key_header: Annotated[str | None, Security(api_key_header)] = None,
) -> str | None:
"""
优先从请求头、其次从查询参数读取兼容 API Key。
"""
return key_header or key_query
def _create_superuser_token_payload() -> TokenPayload:
"""
使用组合根提供器创建 API 密钥调用的当前超级用户载荷。
"""
if not _superuser_token_payload_provider:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="认证服务尚未初始化",
)
try:
return _superuser_token_payload_provider()
except PermissionError as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(error) or "用户权限不足",
headers={"WWW-Authenticate": "Bearer"},
) from error
def _validate_token_identity(payload: TokenPayload) -> TokenPayload:
"""
校验令牌声明对应的当前账号,并转换应用层失权为 HTTP 401。
"""
if _token_identity_validator is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="认证服务尚未初始化",
)
try:
_token_identity_validator(payload)
except PermissionError as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(error) or "用户不存在或已禁用",
headers={"WWW-Authenticate": "Bearer"},
) from error
return payload
def _validated_superuser_payload() -> TokenPayload:
"""
返回经过当前账号和超级管理员权限校验的 API 凭据身份。
"""
# 载荷提供器直接从当前用户记录读取 active/superuser,避免同一请求重复查库。
return _create_superuser_token_payload()
def validate_api_credential_identity() -> TokenPayload:
"""
校验管理员级兼容 API 凭据当前绑定的超级用户身份。
:return: 载荷提供器读取并校验后的当前超级用户身份
:raises HTTPException: 身份服务未初始化时为 503,账号不存在或权限不足时为 401
"""
return _validated_superuser_payload()
def set_or_refresh_resource_token_cookie(
request: Request,
response: Response,
payload: TokenPayload,
) -> None:
"""
复用匹配的资源令牌,或为当前身份写入新的安全 Cookie。
现有令牌签名、身份和权限匹配且剩余有效期不少于配置时长的三分之一时不更新。
其他情况重新签发;HTTPS 请求或转发协议头为 https 时启用 Secure 属性。
:param request: 当前请求,用于读取资源 Cookie 和原始访问协议
:param response: 写入新 Cookie 的响应对象
:param payload: 已通过身份校验的当前用户声明
"""
project_name = get_runtime_setting('PROJECT_NAME')
resource_token = request.cookies.get(project_name)
if resource_token:
try:
decoded = jwt.decode(
resource_token,
get_runtime_setting('RESOURCE_SECRET_KEY'),
algorithms=[JWT_ALGORITHM],
)
exp = decoded.get("exp")
if exp:
remaining_time = datetime.datetime.fromtimestamp(
exp,
tz=datetime.UTC,
) - datetime.datetime.now(datetime.UTC)
if remaining_time < timedelta(
seconds=(
get_runtime_setting(
"RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS"
)
/ 3
)
):
raise jwt.ExpiredSignatureError
expected_claims = {
"sub": str(payload.sub),
"username": payload.username,
"super_user": payload.super_user,
"level": payload.level,
"purpose": "resource",
}
if any(
decoded.get(claim) != value
for claim, value in expected_claims.items()
):
raise jwt.InvalidTokenError("资源令牌身份或权限上下文不匹配")
except jwt.PyJWTError:
logger.debug("Token error occurred. refreshing token")
except Exception as error:
logger.debug(
f"Unexpected error occurred while decoding token: {error}"
)
else:
return
resource_token = _encode_token(
userid=payload.sub,
username=payload.username or "",
super_user=payload.super_user,
expires_delta=timedelta(
seconds=get_runtime_setting('RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS')
),
level=payload.level,
purpose="resource",
)
is_https = (
request.url.scheme == "https"
or (request.headers.get("x-forwarded-proto") or "").lower() == "https"
)
response.set_cookie(
key=project_name,
value=resource_token,
httponly=True,
secure=is_https,
samesite="lax",
)
def _decode_or_http_error(
token: str | None,
purpose: str,
) -> TokenPayload:
"""
把应用层令牌校验错误转换为带 Bearer 挑战的 HTTP 401。
"""
try:
return _decode_token(token, purpose)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(error),
headers={"WWW-Authenticate": "Bearer"},
) from error
def verify_token(
request: Request,
response: Response,
jwt_token: Annotated[
str | None,
Security(oauth2_scheme_manual_error),
],
api_key: Annotated[str | None, Security(_get_api_key)],
api_token: Annotated[str | None, Security(_get_api_token)],
) -> TokenPayload:
"""
按 JWT、API Key、API Token 的优先级验证当前请求身份。
JWT 成功后刷新资源 Cookie;固定 API 凭据返回当前超级用户身份。
高优先级凭据存在但无效时直接拒绝,不再尝试其他凭据。
:param request: 当前请求,供资源 Cookie 校验使用
:param response: JWT 认证后可能写入资源 Cookie 的响应
:param jwt_token: Bearer 认证令牌;为空时继续检查 API Key
:param api_key: 固定 API 密钥;为空时继续检查兼容 API Token
:param api_token: 固定兼容令牌;三种凭据都为空时拒绝请求
:return: 通过凭据及当前账号校验的身份载荷
:raises HTTPException: 无凭据、凭据无效或账号失权时为 401,身份服务未初始化时为 503
"""
if jwt_token:
payload = _decode_or_http_error(jwt_token, "authentication")
_validate_token_identity(payload)
set_or_refresh_resource_token_cookie(request, response, payload)
return payload
if api_key:
_verify_key(api_key, get_runtime_setting("API_TOKEN"), "apikey")
return validate_api_credential_identity()
if api_token:
_verify_key(api_token, get_runtime_setting("API_TOKEN"), "token")
return validate_api_credential_identity()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
def verify_resource_token(
resource_token: Annotated[
str | None,
Security(resource_token_cookie),
],
) -> TokenPayload:
"""
验证 Cookie 中资源令牌的用途和当前账号状态。
:param resource_token: 资源 Cookie 中的令牌;缺失时也会拒绝
:return: 通过签名、用途和当前账号校验的身份载荷
:raises HTTPException: 令牌无效或账号失权时为 401,身份服务未初始化时为 503
"""
return _validate_token_identity(_decode_or_http_error(resource_token, "resource"))
def _verify_key(key: str | None, expected_key: str, key_type: str) -> str:
"""
校验受信第三方集成使用的固定 API 凭据。
"""
if not key or key != expected_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"{key_type} 校验不通过",
headers={"WWW-Authenticate": "Bearer"},
)
return key
def verify_apitoken(
token: Annotated[str | None, Security(_get_api_token)],
) -> str:
"""
校验兼容 API Token,并确认它绑定的超级用户身份仍然有效。
:param token: 从请求头或 URL 查询参数中提取的固定凭据
:return: 校验通过的原令牌
:raises HTTPException: 凭据无效或账号失权时为 401,身份服务未初始化时为 503
"""
value = _verify_key(token, get_runtime_setting("API_TOKEN"), "token")
validate_api_credential_identity()
return value
def verify_apikey(
apikey: Annotated[str | None, Security(_get_api_key)],
) -> str:
"""
校验 API Key,并确认它绑定的超级用户身份仍然有效。
:param apikey: 从请求头或 URL 查询参数中提取的固定凭据
:return: 校验通过的原密钥
:raises HTTPException: 凭据无效或账号失权时为 401,身份服务未初始化时为 503
"""
value = _verify_key(apikey, get_runtime_setting("API_TOKEN"), "apikey")
validate_api_credential_identity()
return value