feat: 注册、重置密码、重发验证邮件添加验证码功能

后端修改:
- 添加通用验证码验证函数 verifyCaptcha()
- /api/register 接口添加验证码验证
- /api/password/forgot 接口添加验证码验证
- /api/resend-verification 接口添加验证码验证

前端修改:
- 注册表单添加验证码输入框和图片
- 忘记密码模态框添加验证码
- 重发验证邮件区域添加验证码输入
- 添加各表单的验证码刷新方法
- 提交失败后自动刷新验证码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-28 13:11:03 +08:00
parent f8c9f8f739
commit 1d65e97b04
3 changed files with 148 additions and 14 deletions

View File

@@ -1100,6 +1100,42 @@ function checkMailRateLimit(req, type = 'mail') {
} }
} }
// ===== 验证码验证辅助函数 =====
/**
* 验证验证码
* @param {Object} req - 请求对象
* @param {string} captcha - 用户输入的验证码
* @returns {{valid: boolean, message?: string}} 验证结果
*/
function verifyCaptcha(req, captcha) {
if (!captcha) {
return { valid: false, message: '请输入验证码' };
}
const sessionCaptcha = req.session.captcha;
const captchaTime = req.session.captchaTime;
if (!sessionCaptcha || !captchaTime) {
return { valid: false, message: '验证码已过期,请刷新验证码' };
}
// 验证码有效期5分钟
if (Date.now() - captchaTime > 5 * 60 * 1000) {
return { valid: false, message: '验证码已过期,请刷新验证码' };
}
if (captcha.toLowerCase() !== sessionCaptcha) {
return { valid: false, message: '验证码错误' };
}
// 验证通过后清除session中的验证码
delete req.session.captcha;
delete req.session.captchaTime;
return { valid: true };
}
// ===== 公开API ===== // ===== 公开API =====
// 健康检查 // 健康检查
@@ -1164,7 +1200,8 @@ app.post('/api/register',
.isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符') .isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符')
.matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'), .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'),
body('email').isEmail().withMessage('邮箱格式不正确'), body('email').isEmail().withMessage('邮箱格式不正确'),
body('password').isLength({ min: 6 }).withMessage('密码至少6个字符') body('password').isLength({ min: 6 }).withMessage('密码至少6个字符'),
body('captcha').notEmpty().withMessage('请输入验证码')
], ],
async (req, res) => { async (req, res) => {
const errors = validationResult(req); const errors = validationResult(req);
@@ -1176,6 +1213,16 @@ app.post('/api/register',
} }
try { try {
// 验证验证码
const { captcha } = req.body;
const captchaResult = verifyCaptcha(req, captcha);
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
message: captchaResult.message
});
}
checkMailRateLimit(req, 'verify'); checkMailRateLimit(req, 'verify');
const { username, email, password } = req.body; const { username, email, password } = req.body;
@@ -1263,7 +1310,8 @@ app.post('/api/resend-verification', [
body('username') body('username')
.optional({ checkFalsy: true }) .optional({ checkFalsy: true })
.isLength({ min: 3 }).withMessage('用户名格式不正确') .isLength({ min: 3 }).withMessage('用户名格式不正确')
.matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线') .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'),
body('captcha').notEmpty().withMessage('请输入验证码')
], async (req, res) => { ], async (req, res) => {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
@@ -1271,6 +1319,16 @@ app.post('/api/resend-verification', [
} }
try { try {
// 验证验证码
const { captcha } = req.body;
const captchaResult = verifyCaptcha(req, captcha);
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
message: captchaResult.message
});
}
checkMailRateLimit(req, 'verify'); checkMailRateLimit(req, 'verify');
const { email, username } = req.body; const { email, username } = req.body;
@@ -1332,15 +1390,25 @@ app.get('/api/verify-email', async (req, res) => {
// 发起密码重置(邮件) // 发起密码重置(邮件)
app.post('/api/password/forgot', [ app.post('/api/password/forgot', [
body('email').isEmail().withMessage('邮箱格式不正确') body('email').isEmail().withMessage('邮箱格式不正确'),
body('captcha').notEmpty().withMessage('请输入验证码')
], async (req, res) => { ], async (req, res) => {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() }); return res.status(400).json({ success: false, errors: errors.array() });
} }
const { email } = req.body; const { email, captcha } = req.body;
try { try {
// 验证验证码
const captchaResult = verifyCaptcha(req, captcha);
if (!captchaResult.valid) {
return res.status(400).json({
success: false,
message: captchaResult.message
});
}
checkMailRateLimit(req, 'pwd_forgot'); checkMailRateLimit(req, 'pwd_forgot');
const smtpConfig = getSmtpConfig(); const smtpConfig = getSmtpConfig();

View File

@@ -1038,7 +1038,14 @@
<small style="color: var(--text-muted); font-size: 12px;">点击图片刷新验证码</small> <small style="color: var(--text-muted); font-size: 12px;">点击图片刷新验证码</small>
</div> </div>
<div v-if="showResendVerify" class="alert alert-info" style="margin-bottom: 10px;"> <div v-if="showResendVerify" class="alert alert-info" style="margin-bottom: 10px;">
邮箱未验证?<a style="color:#667eea; cursor: pointer;" @click="resendVerification">点击重发激活邮件</a> <div>邮箱未验证?请输入验证码后重发激活邮件</div>
<div style="display: flex; gap: 10px; align-items: center; margin-top: 8px;">
<input type="text" class="form-input" v-model="resendVerifyCaptcha" placeholder="验证码" style="flex: 1; height: 40px;" @focus="!resendVerifyCaptchaUrl && refreshResendVerifyCaptcha()">
<img v-if="resendVerifyCaptchaUrl" :src="resendVerifyCaptchaUrl" @click="refreshResendVerifyCaptcha" style="height: 40px; cursor: pointer; border-radius: 4px;" title="点击刷新验证码">
<button type="button" class="btn btn-primary" @click="resendVerification" style="height: 40px; white-space: nowrap;">
重发邮件
</button>
</div>
</div> </div>
<div style="text-align: right; margin-bottom: 15px;"> <div style="text-align: right; margin-bottom: 15px;">
<a @click="showForgotPasswordModal = true" style="color: #667eea; cursor: pointer; font-size: 14px; text-decoration: none;"> <a @click="showForgotPasswordModal = true" style="color: #667eea; cursor: pointer; font-size: 14px; text-decoration: none;">
@@ -1049,7 +1056,7 @@
<i class="fas fa-right-to-bracket"></i> 登录 <i class="fas fa-right-to-bracket"></i> 登录
</button> </button>
</form> </form>
<form v-else @submit.prevent="handleRegister"> <form v-else @submit.prevent="handleRegister" @focusin="!registerCaptchaUrl && refreshRegisterCaptcha()">
<div class="form-group"> <div class="form-group">
<label class="form-label">用户名(3-20字符)</label> <label class="form-label">用户名(3-20字符)</label>
<input type="text" class="form-input" v-model="registerForm.username" required minlength="3" maxlength="20"> <input type="text" class="form-input" v-model="registerForm.username" required minlength="3" maxlength="20">
@@ -1062,6 +1069,13 @@
<label class="form-label">密码 (至少6字符)</label> <label class="form-label">密码 (至少6字符)</label>
<input type="password" class="form-input" v-model="registerForm.password" required minlength="6"> <input type="password" class="form-input" v-model="registerForm.password" required minlength="6">
</div> </div>
<div class="form-group">
<label class="form-label">验证码</label>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" class="form-input" v-model="registerForm.captcha" placeholder="请输入验证码" required style="flex: 1;">
<img v-if="registerCaptchaUrl" :src="registerCaptchaUrl" @click="refreshRegisterCaptcha" style="height: 44px; cursor: pointer; border-radius: 4px;" title="点击刷新验证码">
</div>
</div>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="fas fa-user-plus"></i> 注册 <i class="fas fa-user-plus"></i> 注册
</button> </button>
@@ -2590,7 +2604,7 @@
<!-- 忘记密码模态框 --> <!-- 忘记密码模态框 -->
<div v-if="showForgotPasswordModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showForgotPasswordModal')"> <div v-if="showForgotPasswordModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showForgotPasswordModal')">
<div class="modal-content" @click.stop> <div class="modal-content" @click.stop @focusin="!forgotPasswordCaptchaUrl && refreshForgotPasswordCaptcha()">
<h3 style="margin-bottom: 20px;">忘记密码 - 邮箱重置</h3> <h3 style="margin-bottom: 20px;">忘记密码 - 邮箱重置</h3>
<p style="color: var(--text-secondary); margin-bottom: 15px; font-size: 14px;"> <p style="color: var(--text-secondary); margin-bottom: 15px; font-size: 14px;">
请输入注册邮箱,我们会发送重置链接到您的邮箱 请输入注册邮箱,我们会发送重置链接到您的邮箱
@@ -2599,11 +2613,18 @@
<label class="form-label">邮箱</label> <label class="form-label">邮箱</label>
<input type="email" class="form-input" v-model="forgotPasswordForm.email" placeholder="请输入注册邮箱" required> <input type="email" class="form-input" v-model="forgotPasswordForm.email" placeholder="请输入注册邮箱" required>
</div> </div>
<div class="form-group">
<label class="form-label">验证码</label>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" class="form-input" v-model="forgotPasswordForm.captcha" placeholder="请输入验证码" required style="flex: 1;">
<img v-if="forgotPasswordCaptchaUrl" :src="forgotPasswordCaptchaUrl" @click="refreshForgotPasswordCaptcha" style="height: 44px; cursor: pointer; border-radius: 4px;" title="点击刷新验证码">
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;"> <div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="requestPasswordReset" style="flex: 1;"> <button class="btn btn-primary" @click="requestPasswordReset" style="flex: 1;">
<i class="fas fa-paper-plane"></i> 发送重置邮件 <i class="fas fa-paper-plane"></i> 发送重置邮件
</button> </button>
<button class="btn btn-secondary" @click="showForgotPasswordModal = false; forgotPasswordForm = {email: ''}" style="flex: 1;"> <button class="btn btn-secondary" @click="showForgotPasswordModal = false; forgotPasswordForm = {email: '', captcha: ''}; forgotPasswordCaptchaUrl = ''" style="flex: 1;">
<i class="fas fa-times"></i> 取消 <i class="fas fa-times"></i> 取消
</button> </button>
</div> </div>

View File

@@ -29,8 +29,10 @@ createApp({
registerForm: { registerForm: {
username: '', username: '',
email: '', email: '',
password: '' password: '',
captcha: ''
}, },
registerCaptchaUrl: '',
// 验证码相关 // 验证码相关
showCaptcha: false, showCaptcha: false,
@@ -125,8 +127,10 @@ createApp({
// 忘记密码 // 忘记密码
showForgotPasswordModal: false, showForgotPasswordModal: false,
forgotPasswordForm: { forgotPasswordForm: {
email: '' email: '',
captcha: ''
}, },
forgotPasswordCaptchaUrl: '',
showResetPasswordModal: false, showResetPasswordModal: false,
resetPasswordForm: { resetPasswordForm: {
token: '', token: '',
@@ -134,6 +138,8 @@ createApp({
}, },
showResendVerify: false, showResendVerify: false,
resendVerifyEmail: '', resendVerifyEmail: '',
resendVerifyCaptcha: '',
resendVerifyCaptchaUrl: '',
// 系统设置 // 系统设置
systemSettings: { systemSettings: {
@@ -604,18 +610,37 @@ handleDragLeave(e) {
} }
}, },
// 刷新验证码 // 刷新验证码(登录)
refreshCaptcha() { refreshCaptcha() {
this.captchaUrl = `${this.apiBase}/api/captcha?t=${Date.now()}`; this.captchaUrl = `${this.apiBase}/api/captcha?t=${Date.now()}`;
}, },
// 刷新注册验证码
refreshRegisterCaptcha() {
this.registerCaptchaUrl = `${this.apiBase}/api/captcha?t=${Date.now()}`;
},
// 刷新忘记密码验证码
refreshForgotPasswordCaptcha() {
this.forgotPasswordCaptchaUrl = `${this.apiBase}/api/captcha?t=${Date.now()}`;
},
// 刷新重发验证邮件验证码
refreshResendVerifyCaptcha() {
this.resendVerifyCaptchaUrl = `${this.apiBase}/api/captcha?t=${Date.now()}`;
},
async resendVerification() { async resendVerification() {
if (!this.resendVerifyEmail) { if (!this.resendVerifyEmail) {
this.showToast('error', '错误', '请输入邮箱或用户名后再重试'); this.showToast('error', '错误', '请输入邮箱或用户名后再重试');
return; return;
} }
if (!this.resendVerifyCaptcha) {
this.showToast('error', '错误', '请输入验证码');
return;
}
try { try {
const payload = {}; const payload = { captcha: this.resendVerifyCaptcha };
if (this.resendVerifyEmail.includes('@')) { if (this.resendVerifyEmail.includes('@')) {
payload.email = this.resendVerifyEmail; payload.email = this.resendVerifyEmail;
} else { } else {
@@ -624,10 +649,17 @@ handleDragLeave(e) {
const response = await axios.post(`${this.apiBase}/api/resend-verification`, payload); const response = await axios.post(`${this.apiBase}/api/resend-verification`, payload);
if (response.data.success) { if (response.data.success) {
this.showToast('success', '成功', '验证邮件已发送,请查收'); this.showToast('success', '成功', '验证邮件已发送,请查收');
this.showResendVerify = false;
this.resendVerifyEmail = '';
this.resendVerifyCaptcha = '';
this.resendVerifyCaptchaUrl = '';
} }
} catch (error) { } catch (error) {
console.error('重发验证邮件失败:', error); console.error('重发验证邮件失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '发送失败'); this.showToast('error', '错误', error.response?.data?.message || '发送失败');
// 刷新验证码
this.resendVerifyCaptcha = '';
this.refreshResendVerifyCaptcha();
} }
}, },
@@ -661,8 +693,10 @@ handleDragLeave(e) {
this.registerForm = { this.registerForm = {
username: '', username: '',
email: '', email: '',
password: '' password: '',
captcha: ''
}; };
this.registerCaptchaUrl = '';
} }
} catch (error) { } catch (error) {
const errorData = error.response?.data; const errorData = error.response?.data;
@@ -671,6 +705,9 @@ handleDragLeave(e) {
} else { } else {
this.errorMessage = errorData?.message || '注册失败'; this.errorMessage = errorData?.message || '注册失败';
} }
// 刷新验证码
this.registerForm.captcha = '';
this.refreshRegisterCaptcha();
} }
}, },
@@ -1881,6 +1918,10 @@ handleDragLeave(e) {
this.showToast('error', '错误', '请输入注册邮箱'); this.showToast('error', '错误', '请输入注册邮箱');
return; return;
} }
if (!this.forgotPasswordForm.captcha) {
this.showToast('error', '错误', '请输入验证码');
return;
}
try { try {
const response = await axios.post( const response = await axios.post(
@@ -1891,11 +1932,15 @@ handleDragLeave(e) {
if (response.data.success) { if (response.data.success) {
this.showToast('success', '成功', '如果邮箱存在,将收到重置邮件'); this.showToast('success', '成功', '如果邮箱存在,将收到重置邮件');
this.showForgotPasswordModal = false; this.showForgotPasswordModal = false;
this.forgotPasswordForm = { email: '' }; this.forgotPasswordForm = { email: '', captcha: '' };
this.forgotPasswordCaptchaUrl = '';
} }
} catch (error) { } catch (error) {
console.error('提交密码重置请求失败:', error); console.error('提交密码重置请求失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '提交失败'); this.showToast('error', '错误', error.response?.data?.message || '提交失败');
// 刷新验证码
this.forgotPasswordForm.captcha = '';
this.refreshForgotPasswordCaptcha();
} }
}, },