From 9ac3b4643087b3ea5dea254ddfc2349d1f01de04 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 30 Oct 2026 18:49:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=86=85=E6=B5=8B=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F=E5=BC=80=E5=85=B3?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E5=90=8E=E7=AB=AF=E5=8D=95?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E6=89=98=E7=AE=A1=E5=89=8D=E7=AB=AF=E4=BA=A7?= =?UTF-8?q?=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 8 ++++++- backend/app/config.py | 15 ++++++++++-- backend/app/main.py | 28 ++++++++++++++++++++++ backend/tests/test_config_prod.py | 39 +++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_config_prod.py diff --git a/backend/.env.example b/backend/.env.example index f975055..3da997c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,14 +24,20 @@ JWT_SECRET=dev-only-change-me ACCESS_TOKEN_MINUTES=30 REFRESH_TOKEN_DAYS=14 -# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败 +# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败; +# 内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true(启动会打 WARNING) SMS_PROVIDER=dev SMS_DEV_CODE=123456 +INTERNAL_TEST_LOGIN=false # 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭 SMS_THROTTLE_SECONDS=60 CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180 +# 生产单端口部署:后端直接托管前端构建产物(同源,可留空 CORS) +# 留空则只提供 API;部署方式见 2.Design/DEPLOY.md +# WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist + # ── 知识库向量库(独立的 PG + pgvector,M3 起用)────────────────────── # 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。 # 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。 diff --git a/backend/app/config.py b/backend/app/config.py index e7b58bb..78465ea 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -57,6 +57,9 @@ class Settings(BaseSettings): sms_throttle_seconds: int = 60 sms_dev_code: str = "123456" sms_provider: Literal["dev", "aliyun", "tencent"] = "dev" + # 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开, + # 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。 + internal_test_login: bool = False # ── 幂等 ────────────────────────────────────────────── idempotency_enabled: bool = True @@ -100,6 +103,11 @@ class Settings(BaseSettings): embedding_dim: int = 1024 embedding_model: str = "BAAI/bge-m3" + # ── 前端产物 ────────────────────────────────────────── + # 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。 + # 指向 web/dist 的绝对路径;留空则只提供 API。 + web_dist_dir: str | None = None + # ── 构建标记(部署校验用)────────────────────────────── build_commit: str = "unknown" build_time: str = "unknown" @@ -121,8 +129,11 @@ class Settings(BaseSettings): problems.append("JWT_SECRET 仍是开发默认值或长度不足 32") if "dadao_dev_pw" in self.database_url: problems.append("DATABASE_URL 仍在用开发默认口令") - if self.sms_provider == "dev": - problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)") + if self.sms_provider == "dev" and not self.internal_test_login: + problems.append( + "生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);" + "内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true" + ) if problems: raise RuntimeError("配置校验失败:" + ";".join(problems)) diff --git a/backend/app/main.py b/backend/app/main.py index d6377e7..b621093 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ from __future__ import annotations from contextlib import asynccontextmanager +from pathlib import Path from typing import Any from fastapi import FastAPI, Request @@ -11,6 +12,9 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from sqlalchemy import text from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.responses import Response +from starlette.staticfiles import StaticFiles +from starlette.types import Scope from .api import attachments, auth, items, knowledge, tickets, workspaces from .config import get_settings @@ -27,6 +31,11 @@ logger = get_logger("app") @asynccontextmanager async def lifespan(app: FastAPI): settings.validate_runtime() + if settings.env == "prod" and settings.internal_test_login: + logger.warning( + "内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网", + extra={"extra_fields": {"sms_provider": settings.sms_provider}}, + ) logger.info( "服务启动", extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}}, @@ -138,3 +147,22 @@ async def version() -> dict: "api_prefix": settings.api_prefix, "db_role": role, } + + +# ── 前端产物:单端口部署(没有 nginx 时也让一个端口提供完整站点)──────── +# 顺序很重要:必须挂在所有 API 路由之后,否则挂载 "/" 会吞掉 /api、/health、/version。 +class _SPAStaticFiles(StaticFiles): + """静态命中就返回,未命中回退 index.html——前端路由(如 /w/)交给浏览器处理。""" + + async def get_response(self, path: str, scope: Scope) -> Response: + try: + return await super().get_response(path, scope) + except StarletteHTTPException as exc: + if exc.status_code == 404: + return await super().get_response("index.html", scope) + raise + + +_web_dist = Path(settings.web_dist_dir) if settings.web_dist_dir else None +if _web_dist is not None and (_web_dist / "index.html").is_file(): + app.mount("/", _SPAStaticFiles(directory=str(_web_dist), html=True), name="web") diff --git a/backend/tests/test_config_prod.py b/backend/tests/test_config_prod.py new file mode 100644 index 0000000..11ce030 --- /dev/null +++ b/backend/tests/test_config_prod.py @@ -0,0 +1,39 @@ +"""生产配置校验:固定验证码必须显式开关,否则启动即失败。 + +这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。 +""" + +from __future__ import annotations + +import pytest + +from app.config import Settings + +_STRONG_SECRET = "x" * 48 +_PROD_DB = "postgresql+asyncpg://dadao_prod_app:pw@192.168.8.160:8432/dadaotask_prod" + + +def _prod(**overrides: object) -> Settings: + values: dict[str, object] = { + "env": "prod", + "jwt_secret": _STRONG_SECRET, + "database_url": _PROD_DB, + "sms_provider": "aliyun", + } + values.update(overrides) + return Settings(**values) + + +def test_prod_rejects_dev_sms_without_switch() -> None: + with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"): + _prod(sms_provider="dev").validate_runtime() + + +def test_prod_allows_dev_sms_with_internal_test_login() -> None: + # 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING + _prod(sms_provider="dev", internal_test_login=True).validate_runtime() + + +def test_prod_still_rejects_weak_jwt_secret() -> None: + with pytest.raises(RuntimeError, match="JWT_SECRET"): + _prod(jwt_secret="short").validate_runtime()