docs_auth.py 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. """API 文档(Swagger UI / ReDoc)的访问保护。
  2. 默认 FastAPI 暴露 docs_url / redoc_url / openapi_url 是公开的。
  3. 本模块关闭这些公开路由,改为注册受 HTTP Basic 认证保护的等价路由。
  4. 用法(在 app/main.py 中):
  5. from app.core.docs_auth import setup_protected_docs
  6. app = FastAPI(
  7. ...
  8. docs_url=None,
  9. redoc_url=None,
  10. openapi_url=None,
  11. )
  12. setup_protected_docs(app)
  13. """
  14. from __future__ import annotations
  15. import secrets
  16. from fastapi import Depends, FastAPI, HTTPException, status
  17. from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
  18. from fastapi.security import HTTPBasic, HTTPBasicCredentials
  19. from app.core.config import settings
  20. # auto_error=False 让我们自己处理无凭据的情况,
  21. # 否则 FastAPI 抛出的 401 不带 WWW-Authenticate 头,浏览器不会弹登录框
  22. _security = HTTPBasic(auto_error=False)
  23. # 受保护后的实际访问路径(修改这里同时改变所有三个文档路径)
  24. DOCS_URL = "/faq/docs"
  25. REDOC_URL = "/faq/redoc"
  26. OPENAPI_URL = "/faq/openapi.json"
  27. def verify_docs_auth(
  28. credentials: HTTPBasicCredentials | None = Depends(_security),
  29. ) -> str:
  30. """校验 HTTP Basic 凭据。
  31. 无凭据 / 凭据错误时统一返回 401 + WWW-Authenticate: Basic,
  32. 浏览器看到此头会弹出登录框(首次访问 / 凭据错误均如此)。
  33. 使用 secrets.compare_digest 进行常量时间比较,防止时序攻击。
  34. """
  35. if credentials is None:
  36. raise HTTPException(
  37. status_code=status.HTTP_401_UNAUTHORIZED,
  38. detail="需要 API 文档访问凭据",
  39. headers={"WWW-Authenticate": "Basic"},
  40. )
  41. expected_user = settings.docs_username.encode("utf-8")
  42. expected_pass = settings.docs_password.encode("utf-8")
  43. actual_user = credentials.username.encode("utf-8")
  44. actual_pass = credentials.password.encode("utf-8")
  45. user_ok = secrets.compare_digest(actual_user, expected_user)
  46. pass_ok = secrets.compare_digest(actual_pass, expected_pass)
  47. if not (user_ok and pass_ok):
  48. raise HTTPException(
  49. status_code=status.HTTP_401_UNAUTHORIZED,
  50. detail="API 文档访问凭据错误",
  51. headers={"WWW-Authenticate": "Basic"},
  52. )
  53. return credentials.username
  54. def setup_protected_docs(app: FastAPI) -> None:
  55. """注册受 HTTP Basic 认证保护的 Swagger / ReDoc / OpenAPI 路由。
  56. 必须在 FastAPI 实例化时关闭默认 docs(docs_url=None / redoc_url=None / openapi_url=None),
  57. 否则会与本模块注册的同名路径冲突。
  58. 本函数应在 setup_custom_openapi 之后调用,确保读取到的 schema 是覆写过的。
  59. """
  60. @app.get(OPENAPI_URL, include_in_schema=False)
  61. async def protected_openapi(
  62. _user: str = Depends(verify_docs_auth),
  63. ) -> dict:
  64. return app.openapi()
  65. @app.get(DOCS_URL, include_in_schema=False)
  66. async def protected_swagger(
  67. _user: str = Depends(verify_docs_auth),
  68. ):
  69. return get_swagger_ui_html(
  70. openapi_url=OPENAPI_URL,
  71. title=f"{app.title} - Swagger UI",
  72. )
  73. @app.get(REDOC_URL, include_in_schema=False)
  74. async def protected_redoc(
  75. _user: str = Depends(verify_docs_auth),
  76. ):
  77. return get_redoc_html(
  78. openapi_url=OPENAPI_URL,
  79. title=f"{app.title} - ReDoc",
  80. )