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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user