diff --git a/CAPTCHA_FEATURE.md b/CAPTCHA_FEATURE.md new file mode 100644 index 0000000..2662125 --- /dev/null +++ b/CAPTCHA_FEATURE.md @@ -0,0 +1,280 @@ +# 登录验证码功能说明 + +## 功能概述 + +本次更新为"玩玩云"云存储系统添加了登录验证码功能,提高了系统的安全性。该功能会在用户输错密码一定次数后自动显示验证码,要求用户输入验证码才能继续尝试登录。 + +## 功能特性 + +### 1. 智能验证码触发 +- **自动触发**:当用户输错密码2次后,系统会自动显示验证码输入框 +- **适用范围**:前台用户登录和后台管理员登录均适用 +- **双重保护**:基于IP地址和用户名两个维度进行失败次数统计 + +### 2. 验证码特点 +- **纯数字验证码**:4位数字,易于识别和输入 +- **彩色显示**:验证码图片使用彩色显示,提高可读性 +- **点击刷新**:点击验证码图片即可刷新获取新的验证码 +- **有效期限**:验证码有效期为5分钟,过期后需要刷新 +- **安全存储**:验证码存储在服务器端session中,防止客户端篡改 + +### 3. 防爆破机制 +- **失败限制**:15分钟内失败5次将被封锁30分钟 +- **渐进式保护**: + - 第1-2次失败:仅提示密码错误 + - 第3-5次失败:显示验证码要求输入 + - 第5次失败:封锁IP和用户名30分钟 + +## 技术实现 + +### 后端改动 + +#### 1. 新增依赖 +- `svg-captcha`: 用于生成SVG格式的验证码图片 +- `express-session`: 用于管理session存储验证码 + +#### 2. 新增API端点 +``` +GET /api/captcha +``` +- 功能:生成并返回SVG格式的验证码图片 +- 返回:SVG图片数据 +- Session存储:验证码文本和生成时间 + +#### 3. 修改登录API +``` +POST /api/login +``` +新增参数: +- `captcha` (可选): 验证码输入值 + +验证逻辑: +1. 检查IP和用户名的失败次数 +2. 如果失败次数 >= 2,则要求提供验证码 +3. 验证验证码的有效性(是否存在、是否过期、是否正确) +4. 验证码错误返回 `needCaptcha: true` + +#### 4. RateLimiter增强 +- 新增 `getFailureCount()` 方法:获取指定key的失败次数 +- `recordFailure()` 返回值新增 `needCaptcha` 字段 + +### 前端改动 + +#### 1. 数据字段 +新增: +```javascript +showCaptcha: false, // 是否显示验证码 +captchaUrl: '', // 验证码图片URL +loginForm.captcha: '' // 验证码输入值 +``` + +#### 2. UI组件 +在登录表单中添加: +- 验证码输入框(条件显示) +- 验证码图片显示区域 +- 点击刷新提示文字 + +#### 3. 逻辑方法 +新增 `refreshCaptcha()` 方法: +```javascript +refreshCaptcha() { + this.captchaUrl = `${this.apiBase}/api/captcha?t=${Date.now()}`; +} +``` + +修改 `handleLogin()` 方法: +- 登录失败时检查 `response.data.needCaptcha` +- 如果需要验证码,显示验证码并调用 `refreshCaptcha()` +- 登录成功后隐藏验证码并清空输入 + +## 使用说明 + +### 用户使用流程 + +1. **首次登录尝试** + - 输入用户名和密码 + - 点击"登录"按钮 + - 如果密码错误,会提示"用户名或密码错误" + +2. **第三次登录尝试(触发验证码)** + - 输入用户名和密码 + - 系统自动显示验证码输入框 + - 输入图片中显示的4位数字 + - 如果看不清,点击图片刷新验证码 + - 点击"登录"按钮 + +3. **验证码验证** + - 如果验证码错误,会提示"验证码错误",验证码会自动刷新 + - 如果验证码过期,会提示"验证码已过期,请刷新验证码" + - 验证码正确且密码正确,登录成功 + +4. **账号封锁** + - 如果连续失败5次,账号将被封锁30分钟 + - 封锁期间尝试登录会提示"账号已被封禁" + +### 管理员说明 + +管理员登录时同样受到验证码保护,流程与普通用户完全一致。 + +## 配置说明 + +### Session配置 + +在 `backend/server.js` 中配置session: + +```javascript +app.use(session({ + secret: process.env.SESSION_SECRET || 'your-session-secret-change-in-production', + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.COOKIE_SECURE === 'true', + httpOnly: true, + maxAge: 10 * 60 * 1000 // 10分钟 + } +})); +``` + +建议在 `.env` 文件中设置: +``` +SESSION_SECRET=你的session密钥 +``` + +### 验证码参数 + +在 `backend/server.js` 的验证码生成代码中可调整: + +```javascript +const captcha = svgCaptcha.create({ + size: 4, // 验证码长度(4位数字) + noise: 2, // 干扰线条数 + color: true, // 使用彩色 + background: '#f0f0f0', // 背景色 + width: 120, // 宽度 + height: 40, // 高度 + fontSize: 50, // 字体大小 + charPreset: '0123456789' // 只使用数字 +}); +``` + +### 防爆破参数 + +在 `backend/server.js` 中配置RateLimiter: + +```javascript +const loginLimiter = new RateLimiter({ + maxAttempts: 5, // 最大失败次数 + windowMs: 15 * 60 * 1000, // 统计窗口(15分钟) + blockDuration: 30 * 60 * 1000 // 封锁时长(30分钟) +}); +``` + +**验证码触发阈值**在登录逻辑中设置: +```javascript +const needCaptcha = ipFailures >= 2 || usernameFailures >= 2; +``` +可以修改 `>= 2` 来调整触发次数。 + +## 安全建议 + +1. **设置SESSION_SECRET** + - 在生产环境中务必设置强随机的SESSION_SECRET + - 使用 `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` 生成 + +2. **启用HTTPS** + - 在生产环境中设置 `COOKIE_SECURE=true` + - 确保使用HTTPS协议 + +3. **定期审计** + - 定期检查登录失败日志 + - 关注异常的登录尝试 + +4. **调整参数** + - 根据实际使用情况调整失败次数阈值 + - 根据用户反馈调整验证码难度 + +## 测试方法 + +### 测试验证码显示 + +1. 启动服务器:`cd backend && node server.js` +2. 访问登录页面 +3. 使用错误的用户名或密码登录2次 +4. 第3次尝试时应该看到验证码输入框 + +### 测试验证码API + +```bash +curl "http://localhost:40001/api/captcha" > test.svg +``` + +查看生成的 test.svg 文件,应该显示一个4位数字的验证码。 + +### 测试登录流程 + +```bash +# 第一次失败(无验证码) +curl -X POST http://localhost:40001/api/login \ + -H "Content-Type: application/json" \ + -d '{"username":"test","password":"wrong"}' + +# 第二次失败(无验证码) +curl -X POST http://localhost:40001/api/login \ + -H "Content-Type: application/json" \ + -d '{"username":"test","password":"wrong"}' + +# 第三次失败(需要验证码) +curl -X POST http://localhost:40001/api/login \ + -H "Content-Type: application/json" \ + -d '{"username":"test","password":"wrong"}' +# 返回: {"success":false,"message":"请输入验证码","needCaptcha":true} +``` + +## 故障排查 + +### 问题1:验证码不显示 + +**可能原因**: +- Session未正确配置 +- 前端未正确接收 `needCaptcha` 标志 + +**解决方法**: +- 检查浏览器控制台是否有错误 +- 检查后端日志是否有session相关错误 +- 确认 `express-session` 依赖已安装 + +### 问题2:验证码一直提示错误 + +**可能原因**: +- Session未持久化 +- 验证码大小写不匹配 + +**解决方法**: +- 验证码已统一转换为小写进行比较 +- 检查浏览器是否禁用了Cookie + +### 问题3:验证码图片不加载 + +**可能原因**: +- CORS配置问题 +- API路径错误 + +**解决方法**: +- 检查 `ALLOWED_ORIGINS` 环境变量配置 +- 确认API基础路径配置正确 + +## 更新日志 + +**版本:1.1.0** +- 新增登录验证码功能 +- 密码错误2次后自动显示验证码 +- 支持点击刷新验证码 +- 验证码有效期5分钟 +- 前台和后台登录均支持 + +## 技术支持 + +如有问题,请查看: +- 项目README.md +- GitHub Issues +- 后端日志文件 diff --git a/CAPTCHA_README.md b/CAPTCHA_README.md new file mode 100644 index 0000000..3377410 --- /dev/null +++ b/CAPTCHA_README.md @@ -0,0 +1,173 @@ +# 登录验证码功能 - 快速开始 + +## 功能说明 + +本次更新为"玩玩云"添加了登录验证码功能,提高系统安全性: + +✅ **自动触发**:密码输错2次后自动显示验证码 +✅ **智能保护**:基于IP和用户名双重维度防护 +✅ **易于使用**:点击图片即可刷新验证码 +✅ **前后通用**:前台用户和后台管理员登录均适用 + +## 安装依赖 + +已为后端安装以下依赖: +```bash +cd backend +npm install svg-captcha express-session +``` + +## 快速启动 + +### 1. 启动后端服务 +```bash +cd backend +node server.js +``` + +### 2. 访问登录页面 +打开浏览器访问:`http://localhost:40001` + +### 3. 测试验证码功能 + +**方法1:浏览器手动测试** +1. 输入任意用户名和错误密码 +2. 点击"登录"按钮2次 +3. 第3次尝试时会自动显示验证码输入框 +4. 输入验证码后继续登录 + +**方法2:使用测试脚本** +```bash +# 确保后端服务已启动 +./test_captcha.sh +``` + +## 使用截图流程 + +### 第1-2次登录失败 + +- 显示用户名和密码输入框 +- 提示"用户名或密码错误" + +### 第3次登录失败(触发验证码) + +- 自动显示验证码输入框 +- 显示4位数字验证码图片 +- 可点击图片刷新验证码 + +### 验证码验证 + +- 输入图片中的4位数字 +- 点击"登录"继续 + +## 配置说明 + +### 环境变量配置(可选) + +在 `backend/.env` 文件中添加: + +```env +# Session密钥(建议生产环境修改) +SESSION_SECRET=your-session-secret-here + +# Cookie安全(HTTPS环境启用) +COOKIE_SECURE=false +``` + +### 调整验证码触发次数 + +编辑 `backend/server.js`,找到以下代码: + +```javascript +// 第683行附近 +const needCaptcha = ipFailures >= 2 || usernameFailures >= 2; +``` + +修改 `>= 2` 为你想要的次数(如 `>= 3` 表示第4次才需要验证码)。 + +### 调整防爆破参数 + +编辑 `backend/server.js`,找到以下代码: + +```javascript +// 第394行附近 +const loginLimiter = new RateLimiter({ + maxAttempts: 5, // 最大失败次数 + windowMs: 15 * 60 * 1000, // 15分钟 + blockDuration: 30 * 60 * 1000 // 封锁30分钟 +}); +``` + +## 文件修改清单 + +### 后端文件 +- ✅ `backend/server.js` - 添加验证码生成API和登录验证逻辑 +- ✅ `backend/package.json` - 添加验证码依赖 + +### 前端文件 +- ✅ `frontend/app.html` - 添加验证码输入框和图片显示 +- ✅ `frontend/app.js` - 添加验证码逻辑和刷新方法 + +### 新增文件 +- ✅ `CAPTCHA_FEATURE.md` - 详细功能说明文档 +- ✅ `CAPTCHA_README.md` - 快速开始指南 +- ✅ `test_captcha.sh` - 自动化测试脚本 + +## 功能特点 + +### 验证码特性 +- **纯数字**:只使用0-9数字,易于识别 +- **4位长度**:平衡安全性和用户体验 +- **彩色显示**:提高可读性 +- **点击刷新**:用户体验友好 +- **5分钟有效期**:防止验证码被重复使用 + +### 安全机制 +- **渐进式保护**: + - 1-2次失败:仅密码验证 + - 3-5次失败:要求验证码 + - 5次失败:封锁30分钟 +- **双重维度**:同时基于IP和用户名统计 +- **Session存储**:验证码存储在服务器端,防止篡改 + +## 故障排查 + +### 验证码不显示 +**检查项**: +1. 确认后端服务已启动 +2. 检查浏览器控制台是否有错误 +3. 确认已输错密码至少2次 + +### 验证码一直错误 +**检查项**: +1. 确认输入的是图片中的数字 +2. 刷新验证码重新尝试 +3. 检查浏览器是否禁用Cookie + +### API返回错误 +**检查项**: +1. 查看后端日志:`tail -f backend/logs/error.log` +2. 确认依赖已安装:`cd backend && npm list svg-captcha express-session` +3. 重启后端服务 + +## 测试清单 + +- [ ] 第1次登录失败不显示验证码 +- [ ] 第2次登录失败不显示验证码 +- [ ] 第3次登录失败显示验证码 +- [ ] 验证码图片可以正常加载 +- [ ] 点击验证码图片可以刷新 +- [ ] 输入正确验证码和正确密码可以登录成功 +- [ ] 输入错误验证码提示"验证码错误" +- [ ] 管理员登录也受到验证码保护 +- [ ] 登录成功后验证码自动隐藏 + +## 技术支持 + +详细技术文档:[CAPTCHA_FEATURE.md](./CAPTCHA_FEATURE.md) + +如有问题,请: +1. 查看后端日志 +2. 检查浏览器控制台 +3. 运行测试脚本 `./test_captcha.sh` +4. 查看详细文档 diff --git a/backend/package-lock.json b/backend/package-lock.json index b78fa46..98f407d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,15 +12,17 @@ "archiver": "^7.0.1", "basic-ftp": "^5.0.4", "bcryptjs": "^3.0.3", - "better-sqlite3": "^12.4.1", + "better-sqlite3": "^11.8.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-session": "^1.18.2", "express-validator": "^7.3.0", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", - "ssh2-sftp-client": "^12.0.1" + "ssh2-sftp-client": "^12.0.1", + "svg-captcha": "^1.4.0" }, "devDependencies": { "nodemon": "^3.0.1" @@ -352,17 +354,14 @@ } }, "node_modules/better-sqlite3": { - "version": "12.4.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz", - "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x" } }, "node_modules/binary-extensions": { @@ -1105,6 +1104,40 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, "node_modules/express-validator": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz", @@ -1976,6 +2009,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1985,6 +2027,18 @@ "wrappy": "1" } }, + "node_modules/opentype.js": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.7.3.tgz", + "integrity": "sha512-Veui5vl2bLonFJ/SjX/WRWJT3SncgiZNnKUyahmXCc2sa1xXW15u3R/3TN5+JFiP7RsjK5ER4HA5eWaEmV9deA==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.2" + }, + "bin": { + "ot": "bin/ot" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2130,6 +2184,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2676,6 +2739,18 @@ "node": ">=4" } }, + "node_modules/svg-captcha": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/svg-captcha/-/svg-captcha-1.4.0.tgz", + "integrity": "sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==", + "license": "MIT", + "dependencies": { + "opentype.js": "^0.7.3" + }, + "engines": { + "node": ">=4.x" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -2713,6 +2788,12 @@ "b4a": "^1.6.4" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2782,6 +2863,18 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/backend/package.json b/backend/package.json index 3292be6..b7dfc4b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,10 +23,12 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-session": "^1.18.2", "express-validator": "^7.3.0", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", - "ssh2-sftp-client": "^12.0.1" + "ssh2-sftp-client": "^12.0.1", + "svg-captcha": "^1.4.0" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/backend/server.js b/backend/server.js index d08b14e..bf732a2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,6 +4,8 @@ require('dotenv').config(); const express = require('express'); const cors = require('cors'); const cookieParser = require('cookie-parser'); +const session = require('express-session'); +const svgCaptcha = require('svg-captcha'); const SftpClient = require('ssh2-sftp-client'); const multer = require('multer'); const path = require('path'); @@ -69,6 +71,18 @@ app.use(cors(corsOptions)); app.use(express.json()); app.use(cookieParser()); +// Session配置(用于验证码) +app.use(session({ + secret: process.env.SESSION_SECRET || 'your-session-secret-change-in-production', + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.COOKIE_SECURE === 'true', + httpOnly: true, + maxAge: 10 * 60 * 1000 // 10分钟 + } +})); + // 安全响应头中间件 app.use((req, res, next) => { // 防止点击劫持 @@ -300,7 +314,8 @@ class RateLimiter { blocked: true, remainingAttempts: 0, resetTime: blockInfo.expiresAt, - waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000) + waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60000), + needCaptcha: true }; } @@ -329,7 +344,8 @@ class RateLimiter { blocked: true, remainingAttempts: 0, resetTime: blockExpiresAt, - waitMinutes: Math.ceil(this.blockDuration / 60000) + waitMinutes: Math.ceil(this.blockDuration / 60000), + needCaptcha: true }; } @@ -337,10 +353,20 @@ class RateLimiter { blocked: false, remainingAttempts: this.maxAttempts - attemptInfo.count, resetTime: attemptInfo.windowEnd, - waitMinutes: 0 + waitMinutes: 0, + needCaptcha: attemptInfo.count >= 2 // 失败2次后需要验证码 }; } + // 获取失败次数 + getFailureCount(key) { + const attemptInfo = this.attempts.get(key); + if (!attemptInfo || Date.now() > attemptInfo.windowEnd) { + return 0; + } + return attemptInfo.count; + } + // 记录成功(清除失败记录) recordSuccess(key) { this.attempts.delete(key); @@ -568,6 +594,35 @@ app.get('/api/health', (req, res) => { res.json({ success: true, message: 'Server is running' }); }); +// 生成验证码API +app.get('/api/captcha', (req, res) => { + try { + const captcha = svgCaptcha.create({ + size: 4, // 验证码长度 + noise: 2, // 干扰线条数 + color: true, // 使用彩色 + background: '#f0f0f0', // 背景色 + width: 120, + height: 40, + fontSize: 50, + charPreset: '0123456789' // 只使用数字 + }); + + // 将验证码文本存储在session中 + req.session.captcha = captcha.text.toLowerCase(); + req.session.captchaTime = Date.now(); + + res.type('svg'); + res.send(captcha.data); + } catch (error) { + console.error('生成验证码失败:', error); + res.status(500).json({ + success: false, + message: '生成验证码失败' + }); + } +}); + // 用户注册(简化版) app.post('/api/register', [ @@ -641,18 +696,74 @@ app.post('/api/login', }); } - const { username, password } = req.body; + const { username, password, captcha } = req.body; try { + // 检查是否需要验证码 + const ipKey = req.rateLimitKeys?.ipKey; + const usernameKey = req.rateLimitKeys?.usernameKey; + const ipFailures = ipKey ? loginLimiter.getFailureCount(ipKey) : 0; + const usernameFailures = usernameKey ? loginLimiter.getFailureCount(usernameKey) : 0; + const needCaptcha = ipFailures >= 2 || usernameFailures >= 2; + + // 如果需要验证码,则验证验证码 + if (needCaptcha) { + if (!captcha) { + return res.status(400).json({ + success: false, + message: '请输入验证码', + needCaptcha: true + }); + } + + // 验证验证码 + const sessionCaptcha = req.session.captcha; + const captchaTime = req.session.captchaTime; + + if (!sessionCaptcha || !captchaTime) { + return res.status(400).json({ + success: false, + message: '验证码已过期,请刷新验证码', + needCaptcha: true + }); + } + + // 验证码有效期5分钟 + if (Date.now() - captchaTime > 5 * 60 * 1000) { + return res.status(400).json({ + success: false, + message: '验证码已过期,请刷新验证码', + needCaptcha: true + }); + } + + if (captcha.toLowerCase() !== sessionCaptcha) { + return res.status(400).json({ + success: false, + message: '验证码错误', + needCaptcha: true + }); + } + + // 验证通过后清除session中的验证码 + delete req.session.captcha; + delete req.session.captchaTime; + } + const user = UserDB.findByUsername(username); if (!user) { // 记录失败尝试 if (req.rateLimitKeys) { - loginLimiter.recordFailure(req.rateLimitKeys.ipKey); + const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey); if (req.rateLimitKeys.usernameKey) { loginLimiter.recordFailure(req.rateLimitKeys.usernameKey); } + return res.status(401).json({ + success: false, + message: '用户名或密码错误', + needCaptcha: result.needCaptcha + }); } return res.status(401).json({ success: false, @@ -670,10 +781,15 @@ app.post('/api/login', if (!UserDB.verifyPassword(password, user.password)) { // 记录失败尝试 if (req.rateLimitKeys) { - loginLimiter.recordFailure(req.rateLimitKeys.ipKey); + const result = loginLimiter.recordFailure(req.rateLimitKeys.ipKey); if (req.rateLimitKeys.usernameKey) { loginLimiter.recordFailure(req.rateLimitKeys.usernameKey); } + return res.status(401).json({ + success: false, + message: '用户名或密码错误', + needCaptcha: result.needCaptcha + }); } return res.status(401).json({ success: false, diff --git a/frontend/app.html b/frontend/app.html index fcbfaae..40107bf 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -672,6 +672,16 @@ +