From 6958655d6e106b4bd5aae23b8191c668a8915c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=96=BB=E5=8B=87=E7=A5=A5?= <237899745@qq.com> Date: Mon, 24 Nov 2025 14:28:35 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=89=EF=B8=8F=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E7=9A=84=E9=82=AE=E4=BB=B6=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增功能: - 集成 nodemailer 支持 SMTP 邮件发送 - 新增邮箱验证系统(VerificationDB) - 新增密码重置令牌系统(PasswordResetTokenDB) - 实现邮件发送限流(30分钟3次,全天10次) - 添加 SMTP 配置管理接口 - 支持邮箱激活和密码重置邮件发送 前端新增功能: - 注册时邮箱必填并需验证 - 邮箱验证激活流程 - 重发激活邮件功能 - 基于邮箱的密码重置流程(替代管理员审核) - 管理后台 SMTP 配置界面 - SMTP 测试邮件发送功能 安全改进: - 邮件发送防刷限流保护 - 验证令牌随机生成(48字节) - 重置链接有效期限制 - 支持 SSL/TLS 加密传输 支持的邮箱服务:QQ邮箱、163邮箱、企业邮箱等主流SMTP服务 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/database.js | 105 ++++++++++++- backend/package.json | 1 + backend/server.js | 347 +++++++++++++++++++++++++++++++++++++++++-- frontend/app.html | 100 +++++++++++-- frontend/app.js | 184 +++++++++++++++++++---- 5 files changed, 684 insertions(+), 53 deletions(-) diff --git a/backend/database.js b/backend/database.js index df52677..295ff7b 100644 --- a/backend/database.js +++ b/backend/database.js @@ -123,6 +123,48 @@ function initDatabase() { console.error('数据库迁移(share_type)失败:', error); } + // 数据库迁移:邮箱验证字段 + try { + const columns = db.prepare("PRAGMA table_info(users)").all(); + const hasVerified = columns.some(col => col.name === 'is_verified'); + const hasVerifyToken = columns.some(col => col.name === 'verification_token'); + const hasVerifyExpires = columns.some(col => col.name === 'verification_expires_at'); + + if (!hasVerified) { + db.exec(`ALTER TABLE users ADD COLUMN is_verified INTEGER DEFAULT 0`); + } + if (!hasVerifyToken) { + db.exec(`ALTER TABLE users ADD COLUMN verification_token TEXT`); + } + if (!hasVerifyExpires) { + db.exec(`ALTER TABLE users ADD COLUMN verification_expires_at DATETIME`); + } + + // 将现有用户标记为已验证(避免老账号被拦截登录) + db.exec(`UPDATE users SET is_verified = 1 WHERE is_verified IS NULL OR is_verified = 0`); + } catch (error) { + console.error('数据库迁移(邮箱验证)失败:', error); + } + + // 数据库迁移:密码重置Token表 + try { + db.exec(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at DATETIME NOT NULL, + used INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_reset_tokens_token ON password_reset_tokens(token);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_reset_tokens_user ON password_reset_tokens(user_id);`); + } catch (error) { + console.error('数据库迁移(密码重置Token)失败:', error); + } + console.log('数据库初始化完成'); } @@ -140,15 +182,16 @@ function createDefaultAdmin() { db.prepare(` INSERT INTO users ( username, email, password, - is_admin, is_active, has_ftp_config - ) VALUES (?, ?, ?, ?, ?, ?) + is_admin, is_active, has_ftp_config, is_verified + ) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( adminUsername, `${adminUsername}@example.com`, hashedPassword, 1, 1, - 0 // 管理员不需要FTP配置 + 0, // 管理员不需要FTP配置 + 1 // 管理员默认已验证 ); console.log('默认管理员账号已创建'); @@ -170,8 +213,9 @@ const UserDB = { INSERT INTO users ( username, email, password, ftp_host, ftp_port, ftp_user, ftp_password, http_download_base_url, - has_ftp_config - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + has_ftp_config, + is_verified, verification_token, verification_expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( @@ -183,7 +227,10 @@ const UserDB = { userData.ftp_user || null, userData.ftp_password || null, userData.http_download_base_url || null, - hasFtpConfig + hasFtpConfig, + userData.is_verified !== undefined ? userData.is_verified : 0, + userData.verification_token || null, + userData.verification_expires_at || null ); return result.lastInsertRowid; @@ -461,6 +508,50 @@ const SettingsDB = { } }; +// 邮箱验证管理 +const VerificationDB = { + setVerification(userId, token, expiresAt) { + db.prepare(` + UPDATE users + SET verification_token = ?, verification_expires_at = ?, is_verified = 0, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(token, expiresAt, userId); + }, + consumeVerificationToken(token) { + const row = db.prepare(` + SELECT * FROM users + WHERE verification_token = ? AND verification_expires_at > CURRENT_TIMESTAMP AND is_verified = 0 + `).get(token); + if (!row) return null; + + db.prepare(` + UPDATE users + SET is_verified = 1, verification_token = NULL, verification_expires_at = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(row.id); + return row; + } +}; + +// 密码重置 Token 管理 +const PasswordResetTokenDB = { + create(userId, token, expiresAt) { + db.prepare(` + INSERT INTO password_reset_tokens (user_id, token, expires_at, used) + VALUES (?, ?, ?, 0) + `).run(userId, token, expiresAt); + }, + use(token) { + const row = db.prepare(` + SELECT * FROM password_reset_tokens + WHERE token = ? AND used = 0 AND expires_at > CURRENT_TIMESTAMP + `).get(token); + if (!row) return null; + db.prepare(`UPDATE password_reset_tokens SET used = 1 WHERE id = ?`).run(row.id); + return row; + } +}; + // 密码重置请求管理 const PasswordResetDB = { // 创建密码重置请求 @@ -594,5 +685,7 @@ module.exports = { UserDB, ShareDB, SettingsDB, + VerificationDB, + PasswordResetTokenDB, PasswordResetDB }; diff --git a/backend/package.json b/backend/package.json index b7dfc4b..1764c4e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "express-validator": "^7.3.0", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", + "nodemailer": "^6.9.14", "ssh2-sftp-client": "^12.0.1", "svg-captcha": "^1.4.0" }, diff --git a/backend/server.js b/backend/server.js index a526e50..b082a28 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,6 +8,7 @@ const session = require('express-session'); const svgCaptcha = require('svg-captcha'); const SftpClient = require('ssh2-sftp-client'); const multer = require('multer'); +const nodemailer = require('nodemailer'); const path = require('path'); const fs = require('fs'); const { body, validationResult } = require('express-validator'); @@ -16,7 +17,7 @@ const { exec, execSync } = require('child_process'); const util = require('util'); const execAsync = util.promisify(exec); -const { db, UserDB, ShareDB, SettingsDB, PasswordResetDB } = require('./database'); +const { db, UserDB, ShareDB, SettingsDB, PasswordResetDB, VerificationDB, PasswordResetTokenDB } = require('./database'); const { generateToken, authMiddleware, adminMiddleware } = require('./auth'); const app = express(); @@ -436,6 +437,19 @@ const shareLimiter = new RateLimiter({ blockDuration: 20 * 60 * 1000 }); +// 邮件发送限流(防刷) +// 半小时最多3次,超过封30分钟;全天最多10次,超过封24小时 +const mailLimiter30Min = new RateLimiter({ + maxAttempts: 3, + windowMs: 30 * 60 * 1000, + blockDuration: 30 * 60 * 1000 +}); +const mailLimiterDay = new RateLimiter({ + maxAttempts: 10, + windowMs: 24 * 60 * 60 * 1000, + blockDuration: 24 * 60 * 60 * 1000 +}); + // 创建验证码获取限流器(30次请求/10分钟,封锁30分钟) const captchaLimiter = new RateLimiter({ maxAttempts: 30, @@ -633,6 +647,81 @@ function formatFileSize(bytes) { return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; } +// 生成随机Token +function generateRandomToken(length = 48) { + return require('crypto').randomBytes(length).toString('hex'); +} + +// 获取SMTP配置 +function getSmtpConfig() { + const host = SettingsDB.get('smtp_host'); + const port = SettingsDB.get('smtp_port'); + const secure = SettingsDB.get('smtp_secure'); + const user = SettingsDB.get('smtp_user'); + const pass = SettingsDB.get('smtp_password'); + const from = SettingsDB.get('smtp_from') || user; + + if (!host || !port || !user || !pass) { + return null; + } + + return { + host, + port: parseInt(port, 10) || 465, + secure: secure === 'true' || secure === true || port === '465', + auth: { user, pass }, + from + }; +} + +// 创建邮件传输器 +function createTransport() { + const config = getSmtpConfig(); + if (!config) return null; + + return nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.auth + }); +} + +// 发送邮件 +async function sendMail(to, subject, html) { + const config = getSmtpConfig(); + const transporter = createTransport(); + if (!config || !transporter) { + throw new Error('SMTP未配置'); + } + + await transporter.sendMail({ + from: config.from, + to, + subject, + html + }); +} + +// 检查邮件发送限流 +function checkMailRateLimit(req, type = 'mail') { + const clientKey = `${type}:${req.get('X-Forwarded-For') || req.ip || req.connection.remoteAddress || 'unknown'}`; + + const res30 = mailLimiter30Min.recordFailure(clientKey); + if (res30.blocked) { + const err = new Error(`请求过于频繁,30分钟内最多3次,请在 ${res30.waitMinutes} 分钟后再试`); + err.status = 429; + throw err; + } + + const resDay = mailLimiterDay.recordFailure(clientKey); + if (resDay.blocked) { + const err = new Error(`今天的次数已用完(最多10次),请稍后再试`); + err.status = 429; + throw err; + } +} + // ===== 公开API ===== // 健康检查 @@ -682,7 +771,7 @@ app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => { app.post('/api/register', [ body('username').isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符'), - body('email').optional({ checkFalsy: true }).isEmail().withMessage('邮箱格式不正确'), + body('email').isEmail().withMessage('邮箱格式不正确'), body('password').isLength({ min: 6 }).withMessage('密码至少6个字符') ], async (req, res) => { @@ -695,6 +784,7 @@ app.post('/api/register', } try { + checkMailRateLimit(req, 'verify'); const { username, email, password } = req.body; // 检查用户名是否存在 @@ -705,24 +795,59 @@ app.post('/api/register', }); } - // 如果提供了邮箱,检查邮箱是否存在 - if (email && UserDB.findByEmail(email)) { + // 检查邮箱是否存在 + if (UserDB.findByEmail(email)) { return res.status(400).json({ success: false, message: '邮箱已被使用' }); } - // 创建用户(不需要FTP配置) + // 检查SMTP配置 + const smtpConfig = getSmtpConfig(); + if (!smtpConfig) { + return res.status(400).json({ + success: false, + message: '管理员尚未配置SMTP,暂时无法注册' + }); + } + + const verifyToken = generateRandomToken(24); + const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30分钟 + + // 创建用户(不需要FTP配置),标记未验证 const userId = UserDB.create({ username, - email: email || `${username}@localhost`, // 如果没提供邮箱,使用默认值 - password + email, + password, + is_verified: 0, + verification_token: verifyToken, + verification_expires_at: expiresAt }); + const verifyLink = `${getProtocol(req)}://${req.get('host')}/?verifyToken=${verifyToken}`; + + try { + await sendMail( + email, + '邮箱验证 - 玩玩云', + `

您好,${username}:

+

请点击下面的链接验证您的邮箱,30分钟内有效:

+

${verifyLink}

+

如果不是您本人操作,请忽略此邮件。

` + ); + } catch (mailErr) { + console.error('发送验证邮件失败:', mailErr); + return res.status(500).json({ + success: false, + message: '注册成功,但发送验证邮件失败,请稍后重试或联系管理员', + needVerify: true + }); + } + res.json({ success: true, - message: '注册成功', + message: '注册成功,请查收邮箱完成验证', user_id: userId }); } catch (error) { @@ -735,6 +860,150 @@ app.post('/api/register', } ); +// 重新发送邮箱验证邮件 +app.post('/api/resend-verification', [ + body('email').optional({ checkFalsy: true }).isEmail().withMessage('邮箱格式不正确'), + body('username').optional({ checkFalsy: true }).isLength({ min: 3 }).withMessage('用户名格式不正确') +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ success: false, errors: errors.array() }); + } + + try { + checkMailRateLimit(req, 'verify'); + + const { email, username } = req.body; + const user = email ? UserDB.findByEmail(email) : UserDB.findByUsername(username); + + if (!user) { + return res.status(400).json({ success: false, message: '用户不存在' }); + } + if (user.is_verified) { + return res.status(400).json({ success: false, message: '该邮箱已验证,无需重复验证' }); + } + + const smtpConfig = getSmtpConfig(); + if (!smtpConfig) { + return res.status(400).json({ success: false, message: 'SMTP未配置,无法发送邮件' }); + } + + const verifyToken = generateRandomToken(24); + const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString(); + VerificationDB.setVerification(user.id, verifyToken, expiresAt); + + const verifyLink = `${getProtocol(req)}://${req.get('host')}/?verifyToken=${verifyToken}`; + await sendMail( + user.email, + '邮箱验证 - 玩玩云', + `

您好,${user.username}:

+

请点击下面的链接验证您的邮箱,30分钟内有效:

+

${verifyLink}

+

如果不是您本人操作,请忽略此邮件。

` + ); + + res.json({ success: true, message: '验证邮件已发送,请查收' }); + } catch (error) { + const status = error.status || 500; + console.error('重发验证邮件失败:', error); + res.status(status).json({ success: false, message: error.message || '发送失败' }); + } +}); + +// 验证邮箱 +app.get('/api/verify-email', async (req, res) => { + const { token } = req.query; + if (!token) { + return res.status(400).json({ success: false, message: '缺少token' }); + } + + try { + const user = VerificationDB.consumeVerificationToken(token); + if (!user) { + return res.status(400).json({ success: false, message: '无效或已过期的验证链接' }); + } + res.json({ success: true, message: '邮箱验证成功,请登录' }); + } catch (error) { + console.error('邮箱验证失败:', error); + res.status(500).json({ success: false, message: '邮箱验证失败' }); + } +}); + +// 发起密码重置(邮件) +app.post('/api/password/forgot', [ + body('email').isEmail().withMessage('邮箱格式不正确') +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ success: false, errors: errors.array() }); + } + + const { email } = req.body; + try { + checkMailRateLimit(req, 'pwd_forgot'); + + const smtpConfig = getSmtpConfig(); + if (!smtpConfig) { + return res.status(400).json({ success: false, message: 'SMTP未配置,无法发送邮件' }); + } + + const user = UserDB.findByEmail(email); + // 为防止枚举账号,统一返回成功 + if (!user) { + return res.json({ success: true, message: '如果邮箱存在,将收到重置邮件' }); + } + + const token = generateRandomToken(24); + const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString(); + PasswordResetTokenDB.create(user.id, token, expiresAt); + + const resetLink = `${getProtocol(req)}://${req.get('host')}/?resetToken=${token}`; + await sendMail( + email, + '密码重置 - 玩玩云', + `

您好,${user.username}:

+

请点击下面的链接重置密码,30分钟内有效:

+

${resetLink}

+

如果不是您本人操作,请忽略此邮件。

` + ); + + res.json({ success: true, message: '如果邮箱存在,将收到重置邮件' }); + } catch (error) { + const status = error.status || 500; + console.error('发送密码重置邮件失败:', error); + res.status(status).json({ success: false, message: error.message || '发送失败' }); + } +}); + +// 使用邮件Token重置密码 +app.post('/api/password/reset', [ + body('token').notEmpty().withMessage('缺少token'), + body('new_password').isLength({ min: 6 }).withMessage('新密码至少6个字符') +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ success: false, errors: errors.array() }); + } + + const { token, new_password } = req.body; + try { + const tokenRow = PasswordResetTokenDB.use(token); + if (!tokenRow) { + return res.status(400).json({ success: false, message: '无效或已过期的重置链接' }); + } + + // 更新密码 + const hashed = require('bcryptjs').hashSync(new_password, 10); + db.prepare('UPDATE users SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .run(hashed, tokenRow.user_id); + + res.json({ success: true, message: '密码重置成功,请重新登录' }); + } catch (error) { + console.error('密码重置失败:', error); + res.status(500).json({ success: false, message: '密码重置失败' }); + } +}); + // 用户登录 app.post('/api/login', loginRateLimitMiddleware, @@ -841,6 +1110,15 @@ app.post('/api/login', }); } + if (!user.is_verified) { + return res.status(403).json({ + success: false, + message: '邮箱未验证,请查收邮件或重新发送验证邮件', + needVerify: true, + email: user.email + }); + } + if (!UserDB.verifyPassword(password, user.password)) { // 记录失败尝试 if (req.rateLimitKeys) { @@ -2511,11 +2789,25 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { try { const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240'); + const smtpHost = SettingsDB.get('smtp_host'); + const smtpPort = SettingsDB.get('smtp_port'); + const smtpSecure = SettingsDB.get('smtp_secure') === 'true'; + const smtpUser = SettingsDB.get('smtp_user'); + const smtpFrom = SettingsDB.get('smtp_from') || smtpUser; + const smtpHasPassword = !!SettingsDB.get('smtp_password'); res.json({ success: true, settings: { - max_upload_size: maxUploadSize + max_upload_size: maxUploadSize, + smtp: { + host: smtpHost || '', + port: smtpPort ? parseInt(smtpPort, 10) : 465, + secure: smtpSecure, + user: smtpUser || '', + from: smtpFrom || '', + has_password: smtpHasPassword + } } }); } catch (error) { @@ -2530,7 +2822,7 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { // 更新系统设置 app.post('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { try { - const { max_upload_size } = req.body; + const { max_upload_size, smtp } = req.body; if (max_upload_size !== undefined) { const size = parseInt(max_upload_size); @@ -2543,6 +2835,20 @@ app.post('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { SettingsDB.set('max_upload_size', size.toString()); } + if (smtp) { + if (!smtp.host || !smtp.port || !smtp.user) { + return res.status(400).json({ success: false, message: 'SMTP配置不完整' }); + } + SettingsDB.set('smtp_host', smtp.host); + SettingsDB.set('smtp_port', smtp.port); + SettingsDB.set('smtp_secure', smtp.secure ? 'true' : 'false'); + SettingsDB.set('smtp_user', smtp.user); + SettingsDB.set('smtp_from', smtp.from || smtp.user); + if (smtp.password) { + SettingsDB.set('smtp_password', smtp.password); + } + } + res.json({ success: true, message: '系统设置已更新' @@ -2556,6 +2862,27 @@ app.post('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { } }); +// 测试SMTP +app.post('/api/admin/settings/test-smtp', authMiddleware, adminMiddleware, async (req, res) => { + const { to } = req.body; + try { + const smtpConfig = getSmtpConfig(); + if (!smtpConfig) { + return res.status(400).json({ success: false, message: 'SMTP未配置' }); + } + const target = to || req.user.email || smtpConfig.user; + await sendMail( + target, + 'SMTP测试 - 玩玩云', + `

您好,这是一封测试邮件,说明SMTP配置可用。

时间:${new Date().toISOString()}

` + ); + res.json({ success: true, message: `测试邮件已发送至 ${target}` }); + } catch (error) { + console.error('测试SMTP失败:', error); + res.status(500).json({ success: false, message: '测试邮件发送失败: ' + error.message }); + } +}); + // 获取服务器存储统计信息 app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, res) => { try { diff --git a/frontend/app.html b/frontend/app.html index bcb9bfe..7ab7d77 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -682,6 +682,9 @@ 点击图片刷新验证码 +
+ 邮箱未验证?点击重发激活邮件 +
忘记密码? @@ -697,8 +700,8 @@
- - + +
@@ -1504,6 +1507,61 @@
+ +
+

+ 系统设置 +

+
+
+ + +
+
+
+

SMTP 邮件配置(用于注册激活和找回密码)

+
+ 支持 QQ/163/企业邮箱等。QQ 邮箱示例:主机 smtp.qq.com,端口 465,勾选 SSL,用户名=邮箱地址,密码=授权码。 +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+

用户管理

@@ -1686,23 +1744,41 @@ + + +