feat(install): require MySQL/Redis credential check at wizard step 3

Add test-credentials API and UI gate before init-db writes .env.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Deploy
2026-06-06 06:47:22 +08:00
parent d0544c9bd2
commit da061d35db
4 changed files with 181 additions and 21 deletions
@@ -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`
+47 -12
View File
@@ -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:
+37
View File
@@ -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
+69 -9
View File
@@ -158,7 +158,7 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">主机</label>
<input x-model="form.db_host" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
<input x-model="form.db_host" @input="invalidateCredCheck()" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">端口</label>
@@ -168,16 +168,16 @@
<div class="grid grid-cols-2 gap-4 mt-3">
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">数据库名</label>
<input x-model="form.db_name" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
<input x-model="form.db_name" @input="invalidateCredCheck()" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">用户名</label>
<input x-model="form.db_user" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
<input x-model="form.db_user" @input="invalidateCredCheck()" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">密码</label>
<input x-model="form.db_pass" type="password" required placeholder="数据库密码"
<input x-model="form.db_pass" @input="invalidateCredCheck()" type="password" required placeholder="数据库密码"
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
@@ -188,7 +188,7 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 主机</label>
<input x-model="form.redis_host" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
<input x-model="form.redis_host" @input="invalidateCredCheck()" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 端口</label>
@@ -202,7 +202,7 @@
</div>
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 密码(可选)</label>
<input x-model="form.redis_pass" type="password" placeholder="无密码留空"
<input x-model="form.redis_pass" @input="invalidateCredCheck()" type="password" placeholder="无密码留空"
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
</div>
@@ -235,15 +235,40 @@
</div>
</div>
<!-- Step 3: credential check before init -->
<div class="border-t-2 border-slate-100 pt-4 mt-4">
<h3 class="font-bold text-slate-700 mb-2">🔗 MySQL / Redis 账号检测</h3>
<p class="text-xs text-[var(--text-secondary)] mb-2">须先检测通过,才能初始化数据库(避免账号密码错误仍写入 .env)。</p>
<div x-show="credLoading" class="text-sm text-[var(--text-secondary)] py-2">正在检测连接...</div>
<div x-show="!credLoading && credChecks.length" class="bg-slate-50 rounded-lg overflow-hidden mb-2">
<template x-for="c in credChecks" :key="c.name">
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-100 last:border-0 text-sm">
<span class="font-medium text-slate-700" x-text="c.name"></span>
<span class="font-semibold text-right max-w-[70%]" :class="c.pass ? 'text-green-500' : 'text-red-500'" x-text="c.current"></span>
</div>
</template>
</div>
<div x-show="!credLoading && credAllPass" class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg text-sm mb-2">
✓ MySQL 与 Redis 账号密码正确,可以初始化。
</div>
<div x-show="!credLoading && !credAllPass && credChecks.length" class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg text-sm mb-2">
连接未通过:请核对主机、用户名与密码后重新检测。
</div>
<button type="button" @click="testCredentials()" :disabled="credLoading || !form.db_pass"
class="bg-slate-200 hover:bg-slate-300 disabled:opacity-50 text-slate-700 px-4 py-2 rounded-lg text-sm font-semibold transition">
检测 MySQL / Redis 连接
</button>
</div>
<div class="bg-amber-50 border border-amber-200 text-amber-800 px-4 py-3 rounded-lg mt-4 text-sm">
<b>⚠ 注意:</b>初始化将自动:① 连接数据库并建表 ② 生成 SECRET_KEY/API_KEY
③ 写入 .env + config.json + settings表 ④ 自动计算连接池大小
</div>
<div class="flex gap-3 mt-5">
<button type="submit" :disabled="loading" class="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-5 py-2.5 rounded-lg font-semibold transition flex items-center gap-2">
<button type="submit" :disabled="loading || !credAllPass" class="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-5 py-2.5 rounded-lg font-semibold transition flex items-center gap-2">
<span x-show="loading" class="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full"></span>
<span x-text="loading ? '初始化中...' : '初始化数据库 →'"></span>
<span x-text="loading ? '初始化中...' : (credAllPass ? '初始化数据库 →' : '请先检测连接')"></span>
</button>
<button type="button" @click="step = 2" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-5 py-2.5 rounded-lg font-semibold transition">← 上一步</button>
</div>
@@ -272,7 +297,7 @@
✓ MySQL 与 Redis 均已连通,可创建管理员。
</div>
<div x-show="!connLoading && !connAllPass && connChecks.length" class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg text-sm mb-2">
连接未通过:请返回步骤 3 核对数据库与 Redis 配置后重新检测。
连接未通过:步骤 3 已初始化,请在服务器修正 /app/.env 中 NEXUS_DATABASE_URL / NEXUS_REDIS_URL 后重启容器,再点重新检测。
</div>
<button type="button" @click="checkConnections()" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-4 py-2 rounded-lg text-sm font-semibold transition">
重新检测连接
@@ -594,6 +619,9 @@ function installWizard() {
connLoading: false,
connChecks: [],
connAllPass: false,
credLoading: false,
credChecks: [],
credAllPass: false,
initResult: null,
installToken: '',
installDir: '/opt/nexus',
@@ -692,6 +720,38 @@ function installWizard() {
await this.refreshDockerDefaults();
},
invalidateCredCheck() {
this.credAllPass = false;
this.credChecks = [];
},
async testCredentials() {
this.credLoading = true;
this.error = '';
this.credAllPass = false;
try {
const r = await fetch(API + '/api/install/test-credentials', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(this.form),
});
const d = await r.json();
if (r.ok) {
this.credChecks = d.checks || [];
this.credAllPass = !!d.all_pass;
if (!this.credAllPass) {
this.error = 'MySQL 或 Redis 连接未通过,请核对配置后重试。';
}
} else {
this.credChecks = d.checks || [];
this.error = d.detail || '连接检测失败';
}
} catch (e) {
this.error = '连接检测请求失败: ' + e.message;
}
this.credLoading = false;
},
async checkConnections() {
this.connLoading = true;
try {