diff --git a/docs/changelog/2026-06-06-install-wizard-step3-credential-check.md b/docs/changelog/2026-06-06-install-wizard-step3-credential-check.md new file mode 100644 index 00000000..09bee190 --- /dev/null +++ b/docs/changelog/2026-06-06-install-wizard-step3-credential-check.md @@ -0,0 +1,28 @@ +# 2026-06-06 — 安装向导步骤 3 账号密码预检 + +## 摘要 + +步骤 3 新增「检测 MySQL / Redis 连接」按钮;`init-db` 在写 `.env` 前强制校验账号密码,避免 TCP 通但 Redis/MySQL 认证失败仍进入步骤 4。 + +## 动机 + +用户反馈步骤 3 仅 TCP 检测,Redis 密码错误仍初始化成功,步骤 4 才失败且无法重回步骤 3。 + +## 涉及文件 + +- `server/api/install.py` — `POST /api/install/test-credentials`、`_credential_checks`、`init-db` 前置校验 +- `web/app/install.html` — 步骤 3 检测 UI,未通过禁用「初始化数据库」 +- `tests/test_security_unit.py` — 新端点单测 + +## 验证 + +```bash +.venv/bin/pytest tests/test_security_unit.py -k install_test_credentials -q +bash deploy/sync-install-wizard-to-container.sh +``` + +向导步骤 3:错误 Redis 密码 → 检测失败;修正后检测通过 → 可初始化。 + +## 迁移 / 重启 + +无 DB 迁移。部署后 `sync-install-wizard-to-container.sh` 或 `nx update --no-cache`。 diff --git a/server/api/install.py b/server/api/install.py index 6b9fddfa..bac6fc6e 100644 --- a/server/api/install.py +++ b/server/api/install.py @@ -233,6 +233,15 @@ def _mysql_connect_error_detail(host: str, port: int) -> str: return "" +def _redis_auth_error_detail() -> str: + if os.environ.get("NEXUS_DOCKER_WRITE_ENV") == "1": + return ( + "Redis 密码错误或用户被禁用(1Panel Redis 请在步骤 3 填写应用参数中的密码;" + "无密码则留空)。主机应为 1Panel-redis-xxxx,不是 127.0.0.1。" + ) + return "Redis 密码错误或需要 AUTH;请核对 Redis 密码(无密码则留空)。" + + def _redis_connect_error_detail(host: str, port: int) -> str: if os.environ.get("NEXUS_DOCKER_WRITE_ENV") != "1": return "" @@ -310,6 +319,14 @@ def _test_redis_url(redis_url: str) -> tuple[bool, str]: detail = _redis_connect_error_detail(host, port) if detail: return False, detail + lowered = err.lower() + if ( + "invalid username-password" in lowered + or "wrongpass" in lowered + or "noauth" in lowered + or "authentication required" in lowered + ): + return False, _redis_auth_error_detail() return False, f"✗ Redis 连接失败: {e}" @@ -690,6 +707,32 @@ async def env_check(): return payload +async def _credential_checks(req: InitDbRequest) -> tuple[list[dict], bool]: + """Verify MySQL + Redis with credentials from install step 3 (TCP + AUTH).""" + db_url = _make_db_url(req) + redis_url = _build_redis_url(req) + mysql_ok, mysql_msg = await _test_mysql_url(db_url) + redis_ok, redis_msg = _test_redis_url(redis_url) + checks = [ + {"name": "MySQL", "required": "可连接", "current": mysql_msg, "pass": mysql_ok}, + {"name": "Redis", "required": "可连接", "current": redis_msg, "pass": redis_ok}, + ] + return checks, mysql_ok and redis_ok + + +@router.post("/test-credentials") +async def test_credentials(req: InitDbRequest): + """Step 3: Test MySQL/Redis account password before init-db writes .env.""" + _reject_if_locked() + if _has_env(): + raise HTTPException( + 400, + "系统已初始化,无法重复检测。请继续步骤 4 创建管理员,或修正 .env 后使用连接检测。", + ) + checks, all_pass = await _credential_checks(req) + return {"checks": checks, "all_pass": all_pass} + + @router.get("/connection-check") async def connection_check(): """Step 4: Verify MySQL + Redis using .env written in step 3.""" @@ -719,21 +762,13 @@ async def init_db(req: InitDbRequest): db_url = _make_db_url(req) redis_url = _build_redis_url(req) site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}" - db_port = int(req.db_port) - redis_port = int(req.redis_port) - if not _service_tcp_reachable(req.db_host, db_port): - detail = _mysql_connect_error_detail(req.db_host, db_port) + checks, cred_ok = await _credential_checks(req) + if not cred_ok: + failed = next((c for c in checks if not c["pass"]), checks[0]) raise HTTPException( 400, - detail or f"无法连接 MySQL 服务器 {req.db_host}:{db_port}(TCP 不通,请确认服务已启动)", - ) - - if not _service_tcp_reachable(req.redis_host, redis_port): - detail = _redis_connect_error_detail(req.redis_host, redis_port) - raise HTTPException( - 400, - detail or f"无法连接 Redis 服务器 {req.redis_host}:{redis_port}(TCP 不通,请确认服务已启动)", + failed.get("current") or "MySQL 或 Redis 连接未通过,请先检测账号密码", ) try: diff --git a/tests/test_security_unit.py b/tests/test_security_unit.py index 8b1d092d..0a91b79f 100644 --- a/tests/test_security_unit.py +++ b/tests/test_security_unit.py @@ -174,6 +174,43 @@ def test_vendor_manifest_lists_core_assets(): assert (manifest_path.parent / name).is_file() +@pytest.mark.asyncio +async def test_install_test_credentials_rejects_when_env_exists(monkeypatch, tmp_path): + from fastapi import HTTPException + from server.api import install as install_api + from server.api.install import InitDbRequest, test_credentials + + env_file = tmp_path / ".env" + env_file.write_text("NEXUS_SECRET_KEY=test\n", encoding="utf-8") + monkeypatch.setattr(install_api, "ENV_FILE", env_file) + monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked") + + req = InitDbRequest(db_pass="secret") + with pytest.raises(HTTPException) as exc: + await test_credentials(req) + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_install_test_credentials_all_pass(monkeypatch, tmp_path): + from server.api import install as install_api + from server.api.install import InitDbRequest, test_credentials + + monkeypatch.setattr(install_api, "ENV_FILE", tmp_path / ".env") + monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked") + + async def _mysql_ok(_url): + return True, "✓ MySQL 连接正常" + + monkeypatch.setattr(install_api, "_test_mysql_url", _mysql_ok) + monkeypatch.setattr(install_api, "_test_redis_url", lambda _url: (True, "✓ Redis 连接正常")) + + req = InitDbRequest(db_pass="secret") + out = await test_credentials(req) + assert out["all_pass"] is True + assert len(out["checks"]) == 2 + + def test_install_reject_repeat_init_when_env_exists(monkeypatch, tmp_path): from fastapi import HTTPException from server.api import install as install_api diff --git a/web/app/install.html b/web/app/install.html index 98da6af9..9476e417 100644 --- a/web/app/install.html +++ b/web/app/install.html @@ -158,7 +158,7 @@
须先检测通过,才能初始化数据库(避免账号密码错误仍写入 .env)。
+