mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
272 lines
8.5 KiB
Python
272 lines
8.5 KiB
Python
"""把应用安全能力适配为 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.cache import cached
|
|
from app.runtime.log import logger
|
|
from app.runtime.settings import RuntimeSettingsCompat
|
|
from app.schemas.token import TokenPayload
|
|
|
|
SuperuserTokenPayloadProvider = Callable[[], TokenPayload]
|
|
TokenEncoder = Callable[..., str]
|
|
TokenDecoder = Callable[[str | None, str], TokenPayload]
|
|
_superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None
|
|
_token_encoder: Optional[TokenEncoder] = None
|
|
_token_decoder: Optional[TokenDecoder] = None
|
|
JWT_ALGORITHM = "HS256"
|
|
|
|
|
|
# 兼容旧鉴权插件覆盖模块级设置;令牌实际策略仍由已注入 codec 和 runtime 配置提供。
|
|
settings = RuntimeSettingsCompat()
|
|
|
|
oauth2_scheme_manual_error = OAuth2PasswordBearer(
|
|
auto_error=False,
|
|
tokenUrl=f"{settings.API_V1_STR}/login/access-token",
|
|
)
|
|
resource_token_cookie = APIKeyCookie(
|
|
name=settings.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 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,
|
|
) -> str | None:
|
|
"""从 URL 查询参数读取兼容 API Token。"""
|
|
return 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
|
|
|
|
|
|
@cached(maxsize=1, ttl=600)
|
|
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 "用户权限不足",
|
|
) from error
|
|
|
|
|
|
def set_or_refresh_resource_token_cookie(
|
|
request: Request,
|
|
response: Response,
|
|
payload: TokenPayload,
|
|
) -> None:
|
|
"""复用匹配的资源令牌,或为当前身份写入新的安全 Cookie。"""
|
|
resource_token = request.cookies.get(settings.PROJECT_NAME)
|
|
if resource_token:
|
|
try:
|
|
decoded = jwt.decode(
|
|
resource_token,
|
|
settings.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=settings.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=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS
|
|
),
|
|
level=payload.level,
|
|
purpose="resource",
|
|
)
|
|
is_https = (
|
|
request.url.scheme == "https"
|
|
or request.headers.get("x-forwarded-proto", "").lower() == "https"
|
|
)
|
|
response.set_cookie(
|
|
key=settings.PROJECT_NAME,
|
|
value=resource_token,
|
|
httponly=True,
|
|
secure=is_https,
|
|
samesite="lax",
|
|
)
|
|
|
|
|
|
def _decode_or_http_error(
|
|
token: str | None,
|
|
purpose: str,
|
|
) -> TokenPayload:
|
|
"""把应用层令牌校验错误转换为 HTTP 403。"""
|
|
try:
|
|
return _decode_token(token, purpose)
|
|
except ValueError as error:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=str(error),
|
|
) 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,并维护资源 Cookie。"""
|
|
if jwt_token:
|
|
payload = _decode_or_http_error(jwt_token, "authentication")
|
|
set_or_refresh_resource_token_cookie(request, response, payload)
|
|
return payload
|
|
if api_key:
|
|
verify_apikey(api_key)
|
|
return _create_superuser_token_payload()
|
|
if api_token:
|
|
verify_apitoken(api_token)
|
|
return _create_superuser_token_payload()
|
|
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 中携带的资源访问令牌。"""
|
|
return _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} 校验不通过",
|
|
)
|
|
return key
|
|
|
|
|
|
def verify_apitoken(
|
|
token: Annotated[str | None, Security(_get_api_token)],
|
|
) -> str:
|
|
"""校验 URL 查询参数中的兼容 API Token。"""
|
|
return _verify_key(token, settings.API_TOKEN, "token")
|
|
|
|
|
|
def verify_apikey(
|
|
apikey: Annotated[str | None, Security(_get_api_key)],
|
|
) -> str:
|
|
"""校验请求头或查询参数中的兼容 API Key。"""
|
|
return _verify_key(apikey, settings.API_TOKEN, "apikey")
|