feat(auth): 内测登录改为显式开关,并支持后端单端口托管前端产物
This commit is contained in:
@@ -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
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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