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 ""
|
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:
|
def _redis_connect_error_detail(host: str, port: int) -> str:
|
||||||
if os.environ.get("NEXUS_DOCKER_WRITE_ENV") != "1":
|
if os.environ.get("NEXUS_DOCKER_WRITE_ENV") != "1":
|
||||||
return ""
|
return ""
|
||||||
@@ -310,6 +319,14 @@ def _test_redis_url(redis_url: str) -> tuple[bool, str]:
|
|||||||
detail = _redis_connect_error_detail(host, port)
|
detail = _redis_connect_error_detail(host, port)
|
||||||
if detail:
|
if detail:
|
||||||
return False, 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}"
|
return False, f"✗ Redis 连接失败: {e}"
|
||||||
|
|
||||||
|
|
||||||
@@ -690,6 +707,32 @@ async def env_check():
|
|||||||
return payload
|
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")
|
@router.get("/connection-check")
|
||||||
async def connection_check():
|
async def connection_check():
|
||||||
"""Step 4: Verify MySQL + Redis using .env written in step 3."""
|
"""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)
|
db_url = _make_db_url(req)
|
||||||
redis_url = _build_redis_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}"
|
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):
|
checks, cred_ok = await _credential_checks(req)
|
||||||
detail = _mysql_connect_error_detail(req.db_host, db_port)
|
if not cred_ok:
|
||||||
|
failed = next((c for c in checks if not c["pass"]), checks[0])
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
400,
|
400,
|
||||||
detail or f"无法连接 MySQL 服务器 {req.db_host}:{db_port}(TCP 不通,请确认服务已启动)",
|
failed.get("current") or "MySQL 或 Redis 连接未通过,请先检测账号密码",
|
||||||
)
|
|
||||||
|
|
||||||
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 不通,请确认服务已启动)",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -174,6 +174,43 @@ def test_vendor_manifest_lists_core_assets():
|
|||||||
assert (manifest_path.parent / name).is_file()
|
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):
|
def test_install_reject_repeat_init_when_env_exists(monkeypatch, tmp_path):
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from server.api import install as install_api
|
from server.api import install as install_api
|
||||||
|
|||||||
+69
-9
@@ -158,7 +158,7 @@
|
|||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-semibold text-sm text-slate-700 mb-1">主机</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-semibold text-sm text-slate-700 mb-1">端口</label>
|
<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 class="grid grid-cols-2 gap-4 mt-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-semibold text-sm text-slate-700 mb-1">数据库名</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-semibold text-sm text-slate-700 mb-1">用户名</label>
|
<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>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label class="block font-semibold text-sm text-slate-700 mb-1">密码</label>
|
<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">
|
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>
|
</div>
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 主机</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 端口</label>
|
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 端口</label>
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 密码(可选)</label>
|
<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">
|
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>
|
</div>
|
||||||
@@ -235,15 +235,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
<b>⚠ 注意:</b>初始化将自动:① 连接数据库并建表 ② 生成 SECRET_KEY/API_KEY
|
||||||
③ 写入 .env + config.json + settings表 ④ 自动计算连接池大小
|
③ 写入 .env + config.json + settings表 ④ 自动计算连接池大小
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-5">
|
<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-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>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
@@ -272,7 +297,7 @@
|
|||||||
✓ MySQL 与 Redis 均已连通,可创建管理员。
|
✓ MySQL 与 Redis 均已连通,可创建管理员。
|
||||||
</div>
|
</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">
|
<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>
|
</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">
|
<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,
|
connLoading: false,
|
||||||
connChecks: [],
|
connChecks: [],
|
||||||
connAllPass: false,
|
connAllPass: false,
|
||||||
|
credLoading: false,
|
||||||
|
credChecks: [],
|
||||||
|
credAllPass: false,
|
||||||
initResult: null,
|
initResult: null,
|
||||||
installToken: '',
|
installToken: '',
|
||||||
installDir: '/opt/nexus',
|
installDir: '/opt/nexus',
|
||||||
@@ -692,6 +720,38 @@ function installWizard() {
|
|||||||
await this.refreshDockerDefaults();
|
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() {
|
async checkConnections() {
|
||||||
this.connLoading = true;
|
this.connLoading = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user