feat(auth): 内测登录改为显式开关,并支持后端单端口托管前端产物
This commit is contained in:
@@ -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 的语义检索能力直接不可用。
|
||||
|
||||
+13
-2
@@ -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))
|
||||
|
||||
|
||||
@@ -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/<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")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user