"""API 文档(Swagger UI / ReDoc)的访问保护。 默认 FastAPI 暴露 docs_url / redoc_url / openapi_url 是公开的。 本模块关闭这些公开路由,改为注册受 HTTP Basic 认证保护的等价路由。 用法(在 app/main.py 中): from app.docs_auth import setup_protected_docs app = FastAPI( ... docs_url=None, redoc_url=None, openapi_url=None, ) setup_protected_docs(app) """ from __future__ import annotations import secrets from fastapi import Depends, FastAPI, HTTPException, status from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.security import HTTPBasic, HTTPBasicCredentials from app.config import settings # auto_error=False 让我们自己处理无凭据的情况, # 否则 FastAPI 抛出的 401 不带 WWW-Authenticate 头,浏览器不会弹登录框 _security = HTTPBasic(auto_error=False) # 受保护后的实际访问路径(修改这里同时改变所有三个文档路径) DOCS_URL = "/faq/docs" REDOC_URL = "/faq/redoc" OPENAPI_URL = "/faq/openapi.json" def verify_docs_auth( credentials: HTTPBasicCredentials | None = Depends(_security), ) -> str: """校验 HTTP Basic 凭据。 无凭据 / 凭据错误时统一返回 401 + WWW-Authenticate: Basic, 浏览器看到此头会弹出登录框(首次访问 / 凭据错误均如此)。 使用 secrets.compare_digest 进行常量时间比较,防止时序攻击。 """ if credentials is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="需要 API 文档访问凭据", headers={"WWW-Authenticate": "Basic"}, ) expected_user = settings.docs_username.encode("utf-8") expected_pass = settings.docs_password.encode("utf-8") actual_user = credentials.username.encode("utf-8") actual_pass = credentials.password.encode("utf-8") user_ok = secrets.compare_digest(actual_user, expected_user) pass_ok = secrets.compare_digest(actual_pass, expected_pass) if not (user_ok and pass_ok): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="API 文档访问凭据错误", headers={"WWW-Authenticate": "Basic"}, ) return credentials.username def setup_protected_docs(app: FastAPI) -> None: """注册受 HTTP Basic 认证保护的 Swagger / ReDoc / OpenAPI 路由。 必须在 FastAPI 实例化时关闭默认 docs(docs_url=None / redoc_url=None / openapi_url=None), 否则会与本模块注册的同名路径冲突。 本函数应在 setup_custom_openapi 之后调用,确保读取到的 schema 是覆写过的。 """ @app.get(OPENAPI_URL, include_in_schema=False) async def protected_openapi( _user: str = Depends(verify_docs_auth), ) -> dict: return app.openapi() @app.get(DOCS_URL, include_in_schema=False) async def protected_swagger( _user: str = Depends(verify_docs_auth), ): return get_swagger_ui_html( openapi_url=OPENAPI_URL, title=f"{app.title} - Swagger UI", ) @app.get(REDOC_URL, include_in_schema=False) async def protected_redoc( _user: str = Depends(verify_docs_auth), ): return get_redoc_html( openapi_url=OPENAPI_URL, title=f"{app.title} - ReDoc", )