feat(auth): 内测登录改为显式开关,并支持后端单端口托管前端产物

This commit is contained in:
2026-10-30 18:49:30 +08:00
parent 2dbe4cf675
commit 9ac3b46430
4 changed files with 87 additions and 3 deletions
+7 -1
View File
@@ -24,14 +24,20 @@ JWT_SECRET=dev-only-change-me
ACCESS_TOKEN_MINUTES=30 ACCESS_TOKEN_MINUTES=30
REFRESH_TOKEN_DAYS=14 REFRESH_TOKEN_DAYS=14
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败 # 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败;
# 内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true(启动会打 WARNING)
SMS_PROVIDER=dev SMS_PROVIDER=dev
SMS_DEV_CODE=123456 SMS_DEV_CODE=123456
INTERNAL_TEST_LOGIN=false
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭 # 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
SMS_THROTTLE_SECONDS=60 SMS_THROTTLE_SECONDS=60
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180 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 起用)────────────────────── # ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。 # 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。 # 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
+13 -2
View File
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
sms_throttle_seconds: int = 60 sms_throttle_seconds: int = 60
sms_dev_code: str = "123456" sms_dev_code: str = "123456"
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev" sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
internal_test_login: bool = False
# ── 幂等 ────────────────────────────────────────────── # ── 幂等 ──────────────────────────────────────────────
idempotency_enabled: bool = True idempotency_enabled: bool = True
@@ -100,6 +103,11 @@ class Settings(BaseSettings):
embedding_dim: int = 1024 embedding_dim: int = 1024
embedding_model: str = "BAAI/bge-m3" embedding_model: str = "BAAI/bge-m3"
# ── 前端产物 ──────────────────────────────────────────
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
# 指向 web/dist 的绝对路径;留空则只提供 API。
web_dist_dir: str | None = None
# ── 构建标记(部署校验用)────────────────────────────── # ── 构建标记(部署校验用)──────────────────────────────
build_commit: str = "unknown" build_commit: str = "unknown"
build_time: str = "unknown" build_time: str = "unknown"
@@ -121,8 +129,11 @@ class Settings(BaseSettings):
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32") problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
if "dadao_dev_pw" in self.database_url: if "dadao_dev_pw" in self.database_url:
problems.append("DATABASE_URL 仍在用开发默认口令") problems.append("DATABASE_URL 仍在用开发默认口令")
if self.sms_provider == "dev": if self.sms_provider == "dev" and not self.internal_test_login:
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)") problems.append(
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
)
if problems: if problems:
raise RuntimeError("配置校验失败:" + ";".join(problems)) raise RuntimeError("配置校验失败:" + ";".join(problems))
+28
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any from typing import Any
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@@ -11,6 +12,9 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import text from sqlalchemy import text
from starlette.exceptions import HTTPException as StarletteHTTPException 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 .api import attachments, auth, items, knowledge, tickets, workspaces
from .config import get_settings from .config import get_settings
@@ -27,6 +31,11 @@ logger = get_logger("app")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
settings.validate_runtime() settings.validate_runtime()
if settings.env == "prod" and settings.internal_test_login:
logger.warning(
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
)
logger.info( logger.info(
"服务启动", "服务启动",
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}}, extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
@@ -138,3 +147,22 @@ async def version() -> dict:
"api_prefix": settings.api_prefix, "api_prefix": settings.api_prefix,
"db_role": role, "db_role": role,
} }
# ── 前端产物:单端口部署(没有 nginx 时也让一个端口提供完整站点)────────
# 顺序很重要:必须挂在所有 API 路由之后,否则挂载 "/" 会吞掉 /api、/health、/version。
class _SPAStaticFiles(StaticFiles):
"""静态命中就返回,未命中回退 index.html——前端路由(如 /w/<id>)交给浏览器处理。"""
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")
+39
View File
@@ -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()