From 804b04de602770eefd04709c278b2e5d057497b9 Mon Sep 17 00:00:00 2001 From: nxxy335 Date: Fri, 11 Sep 2026 17:25:22 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E7=99=BB=E5=BD=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=99=90=E6=B5=81=EF=BC=9B=E4=BF=AE=E5=A4=8D=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E4=BF=A1=E6=81=AF=E8=A2=AB=20e2e=20=E4=B8=B2=E5=91=B3?= =?UTF-8?q?=E4=B8=BA=20127.0.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bootstrap.php/login.php:整体移除「5 次失败锁 10 分钟」限流,登录失败仅即时提示原因 - 新增 tools/fix-config.php:一键双写修正连接地址(默认 exam.nxxy335.top),清空账号密码让用户重输真实卷王凭据 - NY_DATA 隔离落地:run-all.sh 给写配置的 e2e 指向临时目录;修 Windows Git Bash /c/... 路径规范化 bug;.ny-test-data 加 .gitignore - 清理 dead throttle 引用(e2e 脚本 + docs) - 修 login.php 真实 bug:ny_is_auth() 分支在加载 $cfg 前引用导致 Undefined variable 警告 - 全量测试 116 通过(PHP 44 + e2e 72) --- .gitignore | 3 ++ bootstrap.php | 59 +++++++---------------------------- docs/dev/testing.md | 2 -- docs/guide/faq.md | 5 +-- docs/index.md | 2 +- docs/security/index.md | 8 ++--- login.php | 55 ++++++++++++++------------------ tests/run-all.sh | 14 +++++++-- tools/fix-config.php | 64 ++++++++++++++++++++++++++++++++++++++ tools/test-config-save.js | 26 ++++++++++------ tools/test-upload-guard.js | 26 +++++++++------- 11 files changed, 151 insertions(+), 113 deletions(-) create mode 100644 tools/fix-config.php diff --git a/.gitignore b/.gitignore index 6104594..eb87c9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # 运行时数据:含账号密码、API 令牌、学生名单,禁止入库 data/ +# e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库) +.ny-test-data/ + # 依赖与系统文件 node_modules/ vendor/ diff --git a/bootstrap.php b/bootstrap.php index faae70a..099ca8e 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -9,7 +9,14 @@ const NY_APP_NAME = 'NY问卷考试系统数据平台'; const NY_VERSION = '1.0.0'; define('NY_ROOT', str_replace('\\', '/', __DIR__)); -define('NY_DATA', NY_ROOT . '/data'); +// 数据目录默认指向项目下的 data/;测试可通过 NY_DATA 环境变量整体重定向到临时目录, +// 避免 e2e 把 mock 服务的连接配置写进生产库(曾因此把生产 base_url 改成 127.0.0.1 导致登录失败)。 +$nyData = NY_ROOT . '/data'; +$nyDataEnv = getenv('NY_DATA'); +if ($nyDataEnv !== false && $nyDataEnv !== '') { + $nyData = rtrim(str_replace('\\', '/', $nyDataEnv), '/'); +} +define('NY_DATA', $nyData); // ---------------- 环境预检 ---------------- if (PHP_VERSION_ID < 80000) { @@ -141,53 +148,9 @@ function ny_csrf_ok(): bool return $given !== '' && hash_equals($token, $given); } -// ---------------- 登录失败限流 ---------------- -/** - * 登录失败限流:按 IP 记录连续失败次数与时间戳(写入 data/login_fail.json)。 - * 5 次失败后锁 10 分钟。仅防御暴力破解,不做超强语义(单机小工具足够)。 - */ -function ny_login_throttle_check(): array -{ - $path = NY_DATA . '/login_fail.json'; - $ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'); - $rec = ['count' => 0, 'until' => 0]; - if (is_file($path)) { - $j = json_decode((string)file_get_contents($path), true); - if (is_array($j) && isset($j[$ip])) { - $rec = array_merge($rec, $j[$ip]); - } - } - return $rec; -} - -function ny_login_throttle_hit(): void -{ - $path = NY_DATA . '/login_fail.json'; - $ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'); - $all = is_file($path) ? (json_decode((string)file_get_contents($path), true) ?: []) : []; - $rec = isset($all[$ip]) ? $all[$ip] : ['count' => 0, 'until' => 0]; - $rec['count'] = ((int)($rec['count'] ?? 0)) + 1; - if ($rec['count'] >= 5) { - $rec['count'] = 0; // 重新计数,进入冷却 - $rec['until'] = time() + 600; // 锁 10 分钟 - } - $all[$ip] = $rec; - @file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX); -} - -function ny_login_throttle_reset(): void -{ - $path = NY_DATA . '/login_fail.json'; - $ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'); - if (!is_file($path)) { - return; - } - $all = json_decode((string)file_get_contents($path), true) ?: []; - if (isset($all[$ip])) { - unset($all[$ip]); - @file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX); - } -} +// ---------------- 登录失败限流(已移除) ---------------- +// 注:单机内部工具场景下,5 次失败锁 10 分钟的限制常因配置写错/测环境串味而把正常用户挡在门外, +// 且无实际爆破价值(仅内网使用)。应需求已整体移除:登录不再限流,失败只即时提示原因。 /** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */ function ny_is_installed(): bool diff --git a/docs/dev/testing.md b/docs/dev/testing.md index d7ac254..a8570bc 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -64,8 +64,6 @@ e2e 会备份并修改 `data/ny.db`、`data/config.json`、`data/roster.json`, ```bash git status data/ # data/ 已被 gitignore,需手工核对 ``` - -另外测试前请删除 `data/login_fail.json`,否则可能触发登录限流。 ::: ## 写新测试时注意 diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 6609336..88d7548 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -18,9 +18,10 @@ 务必从登录响应的 `Set-Cookie` 中取出 `sk-token`,并在后续每个请求带上 `Cookie: sk-token=`。本平台已处理此细节。 -**Q:登录一直失败并被锁住** +**Q:登录一直失败** -连续 5 次失败会锁定 10 分钟。等待即可;如需立即解锁,删除 `data/login_fail.json`。 +登录失败只会在登录页即时提示原因(账号/密码错、地址不可达等),不会因多次失败被锁。 +若登录页连接信息显示成 `127.0.0.1` 等错误地址,用 `php tools/fix-config.php` 修正即可。 **Q:卷王改了密码后平台一直提示未登录** diff --git a/docs/index.md b/docs/index.md index 0a7f76a..1900a13 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,7 +40,7 @@ features: details: 零依赖生成 .xlsx,支持多工作表、最新成绩视图、成绩明细与缺考名单打印。 - icon: 🔒 title: 安全内建 - details: 会话登录、CSRF 令牌、登录失败限流,上传文件白名单 + 随机名 + 解析后即删。 + details: 会话登录、CSRF 令牌,上传文件白名单 + 随机名 + 解析后即删。 --- ## 三分钟了解它 diff --git a/docs/security/index.md b/docs/security/index.md index acd1ff2..8dc17d4 100644 --- a/docs/security/index.md +++ b/docs/security/index.md @@ -11,11 +11,11 @@ - 卷王令牌缓存在 `data/cache/token.json`;令牌失效时平台会自动重新登录; 若卷王侧也认证失败,前端会先调用 `logout.php` 清会话再跳转登录页,避免出现跳转死循环。 -## 登录限流 +## 登录失败处理 -连续 **5 次**登录失败 → 锁定 **10 分钟**。状态记录在 `data/login_fail.json`。 - -应急解锁:删除 `data/login_fail.json`。 +登录失败(卷王账号/密码错、地址不可达等)只会在登录页即时提示原因,**不做失败次数限流**—— +单机内网工具场景下,限流常因配置写错或测试串味把正常用户挡在门外,且无实际防爆破价值。 +若怀疑连接配置被测试污染导致连不上真实卷王,用 `php tools/fix-config.php` 一键修正连接地址。 ## CSRF 防护 diff --git a/login.php b/login.php index d71650b..47e4f71 100644 --- a/login.php +++ b/login.php @@ -7,6 +7,9 @@ declare(strict_types=1); require __DIR__ . '/bootstrap.php'; +// 先加载配置:下面已登录分支要用 $cfg 构造 SurveyKingClient 探活,必须在前面就准备好 +$cfg = ny_load_config(); + if (ny_is_auth()) { // 已登录但卷王 token 可能已过期:用当前 token 探一次卷王 /system,能 200 即视为有效,否则清掉本地会话强制重新登录 try { @@ -23,42 +26,32 @@ if (ny_is_auth()) { } } -$cfg = ny_load_config(); $err = ''; $ok = false; if ($_SERVER['REQUEST_METHOD'] === 'POST') { - // 登录失败限流:5 次失败锁 10 分钟,防止暴力破解 - $throttle = ny_login_throttle_check(); - if ($throttle['until'] > time()) { - $wait = (int)(($throttle['until'] - time()) / 60) + 1; - $err = '尝试次数过多,请 ' . $wait . ' 分钟后再试。'; + $user = trim((string)($_POST['username'] ?? $cfg['username'])); + $pwd = (string)($_POST['password'] ?? ''); + // 允许用户在本页调整账号;密码必须输入 + if ($user === '' || $pwd === '') { + $err = '请输入账号与密码。'; } else { - $user = trim((string)($_POST['username'] ?? $cfg['username'])); - $pwd = (string)($_POST['password'] ?? ''); - // 允许用户在本页调整账号;密码必须输入 - if ($user === '' || $pwd === '') { - $err = '请输入账号与密码。'; - } else { - // 用当前配置的连接信息 + 本页输入的账密做一次卷王登录 - $test = $cfg; - $test['username'] = $user; - $test['password'] = $pwd; - try { - $client = new SurveyKingClient($test); - $client->getToken(true); - // 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token - // 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态) - $cfg['username'] = $user; - $cfg['password'] = $pwd; - ny_save_config($cfg); - ny_login(); - ny_login_throttle_reset(); - $ok = true; - } catch (Throwable $e) { - ny_login_throttle_hit(); - $err = '登录失败:' . $e->getMessage(); - } + // 用当前配置的连接信息 + 本页输入的账密做一次卷王登录 + $test = $cfg; + $test['username'] = $user; + $test['password'] = $pwd; + try { + $client = new SurveyKingClient($test); + $client->getToken(true); + // 登录成功:把账号与密码写回配置(本地单用户工具,持久化密码以便 token + // 失效时平台能自动重新登录卷王,避免后台刷新陷入"未配置账号"的死状态) + $cfg['username'] = $user; + $cfg['password'] = $pwd; + ny_save_config($cfg); + ny_login(); + $ok = true; + } catch (Throwable $e) { + $err = '登录失败:' . $e->getMessage(); } } } diff --git a/tests/run-all.sh b/tests/run-all.sh index 3246385..63aa169 100644 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -26,11 +26,19 @@ done echo "" echo "================ Node e2e(需 mock-server + PHP 内置服务器) ================" -for f in tools/test-config-save.js tools/test-draw-alloc.js tools/test-upload-guard.js; do +# e2e 会把 mock 连接配置写进数据目录;用 NY_DATA 把这两个会改写配置的测试整体指向临时目录, +# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。 +NY_DATA_TMP="$(pwd)/.ny-test-data" +mkdir -p "$NY_DATA_TMP" +for f in tools/test-config-save.js tools/test-upload-guard.js; do echo "" - echo ">>> $f" - "$NODE" "$f" + echo ">>> $f (NY_DATA=$NY_DATA_TMP)" + NY_DATA="$NY_DATA_TMP" "$NODE" "$f" done +# test-draw-alloc.js 不读写连接配置,沿用项目 data/ 即可 +echo "" +echo ">>> tools/test-draw-alloc.js" +"$NODE" "tools/test-draw-alloc.js" echo "" echo "全部测试完成。" diff --git a/tools/fix-config.php b/tools/fix-config.php new file mode 100644 index 0000000..d9ba788 --- /dev/null +++ b/tools/fix-config.php @@ -0,0 +1,64 @@ + { cleanup(); process.exit(1); }); diff --git a/tools/test-upload-guard.js b/tools/test-upload-guard.js index cb021f6..ff96e23 100644 --- a/tools/test-upload-guard.js +++ b/tools/test-upload-guard.js @@ -16,9 +16,20 @@ const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.e const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite'; const DEBUG = !!process.env.NY_DEBUG; -const DATA = path.join(ROOT, 'data'); +// 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录, +// 不污染生产库(曾因写 mock 配置把生产 base_url 改成 127.0.0.1)。 +// Git Bash 风格 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原;并把规范化后的 +// Windows 绝对路径写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确路径。 +function resolveDataDir() { + let p = process.env.NY_DATA || path.join(ROOT, 'data'); + if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2); + p = path.resolve(p); + process.env.NY_DATA = p; + return p; +} +const DATA = resolveDataDir(); +fs.mkdirSync(DATA, { recursive: true }); const UPLOADS = path.join(DATA, 'uploads'); -const throttleFile = path.join(DATA, 'login_fail.json'); // ---------- 备份 / 还原运行时数据 ---------- // ny.db 必须移走:配置是 DB 优先,留着库里的真实配置会盖掉本测试写入的 config.json。 @@ -41,13 +52,6 @@ for (const f of ['config.json', 'roster.json', 'project_config.json']) { catch (e) { console.error(' 备份失败(复制)' + f + ':' + e.message); } } } -// 清空登录限流记录:用覆盖写而不是删除(部分环境下删除会被安全策略拦掉) -function clearThrottle() { - try { fs.writeFileSync(throttleFile, '{}'); } catch (e) {} - try { if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile); } catch (e) {} -} -clearThrottle(); - const cfgFile = path.join(DATA, 'config.json'); fs.writeFileSync(cfgFile, JSON.stringify({ base_url: 'http://127.0.0.1:' + MOCK_PORT, @@ -81,7 +85,6 @@ function cleanup() { const p = path.join(DATA, f); try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {} } - clearThrottle(); // 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败 for (const [p, bak] of moved.concat(copied)) { try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {} @@ -199,9 +202,8 @@ function chk(name, cond, extra) { process.exit(1); } - // 登录(失败会触发 5 次锁定,所以每次尝试前清掉限流记录) + // 登录(最多重试 3 次) for (let i = 0; i < 3 && CSRF === ''; i++) { - clearThrottle(); // 上一次尝试若失败会累计限流,这里先清零 const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' }); const ac = await get('/api.php?action=auth_check'); if (DEBUG) {