✉️ 添加完整的邮件系统功能

后端新增功能:
- 集成 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 <noreply@anthropic.com>
This commit is contained in:
2025-11-24 14:28:35 +08:00
parent fafd897588
commit 6958655d6e
5 changed files with 684 additions and 53 deletions

View File

@@ -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,
'邮箱验证 - 玩玩云',
`<p>您好,${username}</p>
<p>请点击下面的链接验证您的邮箱30分钟内有效</p>
<p><a href="${verifyLink}" target="_blank">${verifyLink}</a></p>
<p>如果不是您本人操作,请忽略此邮件。</p>`
);
} 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,
'邮箱验证 - 玩玩云',
`<p>您好,${user.username}</p>
<p>请点击下面的链接验证您的邮箱30分钟内有效</p>
<p><a href="${verifyLink}" target="_blank">${verifyLink}</a></p>
<p>如果不是您本人操作,请忽略此邮件。</p>`
);
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,
'密码重置 - 玩玩云',
`<p>您好,${user.username}</p>
<p>请点击下面的链接重置密码30分钟内有效</p>
<p><a href="${resetLink}" target="_blank">${resetLink}</a></p>
<p>如果不是您本人操作,请忽略此邮件。</p>`
);
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测试 - 玩玩云',
`<p>您好这是一封测试邮件说明SMTP配置可用。</p><p>时间:${new Date().toISOString()}</p>`
);
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 {