移除登录失败限流;修复连接信息被 e2e 串味为 127.0.0.1

- 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)
This commit is contained in:
2026-09-11 17:25:22 +08:00
parent 25a2e8a42c
commit 804b04de60
11 changed files with 151 additions and 113 deletions
+3
View File
@@ -1,6 +1,9 @@
# 运行时数据:含账号密码、API 令牌、学生名单,禁止入库
data/
# e2e 隔离用的临时数据目录(run-all.sh 自动创建,不入库)
.ny-test-data/
# 依赖与系统文件
node_modules/
vendor/
+11 -48
View File
@@ -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
-2
View File
@@ -64,8 +64,6 @@ e2e 会备份并修改 `data/ny.db`、`data/config.json`、`data/roster.json`,
```bash
git status data/ # data/ 已被 gitignore,需手工核对
```
另外测试前请删除 `data/login_fail.json`,否则可能触发登录限流。
:::
## 写新测试时注意
+3 -2
View File
@@ -18,9 +18,10 @@
务必从登录响应的 `Set-Cookie` 中取出 `sk-token`,并在后续每个请求带上
`Cookie: sk-token=<token>`。本平台已处理此细节。
**Q:登录一直失败并被锁住**
**Q:登录一直失败**
连续 5 次失败会锁定 10 分钟。等待即可;如需立即解锁,删除 `data/login_fail.json`。
登录失败只会在登录页即时提示原因(账号/密码错、地址不可达等),不会因多次失败被锁。
若登录页连接信息显示成 `127.0.0.1` 等错误地址,用 `php tools/fix-config.php` 修正即可。
**Q:卷王改了密码后平台一直提示未登录**
+1 -1
View File
@@ -40,7 +40,7 @@ features:
details: 零依赖生成 .xlsx,支持多工作表、最新成绩视图、成绩明细与缺考名单打印。
- icon: 🔒
title: 安全内建
details: 会话登录、CSRF 令牌、登录失败限流,上传文件白名单 + 随机名 + 解析后即删。
details: 会话登录、CSRF 令牌,上传文件白名单 + 随机名 + 解析后即删。
---
## 三分钟了解它
+4 -4
View File
@@ -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 防护
+3 -10
View File
@@ -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,17 +26,10 @@ 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 . ' 分钟后再试。';
} else {
$user = trim((string)($_POST['username'] ?? $cfg['username']));
$pwd = (string)($_POST['password'] ?? '');
// 允许用户在本页调整账号;密码必须输入
@@ -53,15 +49,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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();
}
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
+11 -3
View File
@@ -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 "全部测试完成。"
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* 修复 / 重置系统连接配置(终端一次性工具)
*
* 背景:
* 连接配置同时存在于 data/ny.db(SQLite,加载时优先)与 data/config.json(文件备份)。
* ny_load_config() 以 DB 为准,因此一旦部署实例的 ny.db 被 e2e 测试串味或误改写成
* 127.0.0.1 / mock 地址,登录页就会显示「系统连接信息已配置(127.0.0.1/api)」且连不上真实卷王。
* 本工具通过 ny_save_config 双写,一次性把两处都纠正回正确的生产地址。
*
* 用法:
* php tools/fix-config.php [--base-url=https://exam.nxxy335.top] [--api-prefix=/api] [--keep-creds]
*
* 默认行为:
* - base_url 修正为 https://exam.nxxy335.top(可用 --base-url 覆盖)
* - api_prefix 修正为 /api
* - 账号/密码清空(绝不臆造真实卷王凭据),需在登录页重新输入真实账号
* - 加 --keep-creds 则保留现有账号密码不动
*
* 测试隔离:可用 NY_DATA=/tmp/xxx 指向临时目录,不影响生产库。
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
$args = array_slice($argv, 1);
$baseUrl = 'https://exam.nxxy335.top';
$apiPrefix = '/api';
$keepCreds = false;
foreach ($args as $a) {
if (strncmp($a, '--base-url=', 11) === 0) {
$baseUrl = substr($a, 11);
} elseif (strncmp($a, '--api-prefix=', 13) === 0) {
$apiPrefix = substr($a, 13);
} elseif ($a === '--keep-creds') {
$keepCreds = true;
} elseif ($a === '--help' || $a === '-h') {
echo "用法: php tools/fix-config.php [--base-url=URL] [--api-prefix=/api] [--keep-creds]\n";
exit(0);
}
}
$cfg = ny_load_config();
$before = (string)($cfg['base_url'] ?? '');
$cfg['base_url'] = rtrim(trim($baseUrl), '/');
$cfg['api_prefix'] = '/' . ltrim(trim($apiPrefix), '/');
if (!$keepCreds) {
$cfg['username'] = '';
$cfg['password'] = '';
}
$ok = ny_save_config($cfg);
if (!$ok) {
fwrite(STDERR, "保存失败:DB 与文件均不可写?请检查 " . NY_DATA . " 目录权限。\n");
exit(1);
}
$host = (string) parse_url($cfg['base_url'], PHP_URL_HOST);
echo "修复前 base_url = " . ($before === '' ? '(空)' : $before) . "\n";
echo "修复后 base_url = " . $cfg['base_url'] . "\n";
echo "api_prefix = " . $cfg['api_prefix'] . "\n";
echo "登录页将显示 = 系统连接信息已配置(" . $host . $cfg['api_prefix'] . ")\n";
echo "账号/密码 = " . ($keepCreds ? '保持不变' : '已清空(请在登录页重新输入真实卷王账号)') . "\n";
echo "配置来源 = " . ny_config_source() . "\n";
echo "OK\n";
+16 -10
View File
@@ -11,10 +11,23 @@ const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
// 1) 备份 / 准备文件存储模式
const cfgFile = path.join(ROOT, 'data', 'config.json');
// 数据目录默认用项目下的 data/,但优先读 NY_DATA 环境变量(bootstrap.php 同样支持):
// 测试统一把 NY_DATA 指向临时目录,彻底避免把 mock 配置写进生产库(曾导致生产 base_url 被改成 127.0.0.1)。
// Git Bash 风格路径 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原成 C:\Users\...;
// 规范化后写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确的 Windows 绝对路径。
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 cfgFile = path.join(DATA, 'config.json');
const cfgBak = cfgFile + '.bak';
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
const tokenFile = path.join(ROOT, 'data', 'cache', 'token.json');
const tokenFile = path.join(DATA, 'cache', 'token.json');
const tokenBak = tokenFile + '.bak';
const hasToken = fs.existsSync(tokenFile);
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
@@ -22,15 +35,11 @@ if (hasToken) fs.renameSync(tokenFile, tokenBak);
// SQLite 优先:若不先移走 data/ny.db,里面的旧配置会盖掉本测试写入的 config.json,
// 导致登录用的是上一次遗留的账号密码(表现为"登录成功但 auth_check=false")。
// 同时清掉登录限流记录,避免上一轮失败把本轮锁住。
const dbFile = path.join(ROOT, 'data', 'ny.db');
const dbFile = path.join(DATA, 'ny.db');
const dbBak = dbFile + '.bak';
const hasDb = fs.existsSync(dbFile);
if (hasDb) { try { fs.renameSync(dbFile, dbBak); } catch (e) {} }
const throttleFile = path.join(ROOT, 'data', 'login_fail.json');
if (fs.existsSync(throttleFile)) { try { fs.unlinkSync(throttleFile); } catch (e) {} }
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
@@ -67,9 +76,6 @@ function cleanup() {
if (fs.existsSync(dbFile)) fs.unlinkSync(dbFile);
if (hasDb && fs.existsSync(dbBak)) fs.renameSync(dbBak, dbFile);
} catch(e) { console.error('cleanup db failed:', e.message); }
try {
if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile);
} catch(e) {}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
+14 -12
View File
@@ -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) {