From 93375b612f16585f2f80a33102ee2c11aa35de8e Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Thu, 11 Dec 2025 21:58:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E7=AC=AC=E4=B8=89=E9=98=B6=E6=AE=B5=20-=20?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E9=87=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现通过邮件自助重置密码功能: - 新增发送密码重置邮件API (/api/forgot-password) - 新增密码重置页面路由 (/reset-password/) - 新增确认密码重置API (/api/reset-password-confirm) 新增文件: - templates/email/reset_password.html - 密码重置邮件模板 - templates/reset_password.html - 密码重置页面 修改文件: - email_service.py - 添加密码重置相关函数 - send_password_reset_email() - verify_password_reset_token() - confirm_password_reset() - app.py - 添加密码重置相关API - templates/login.html - 忘记密码支持两种方式: - 启用邮件功能:通过邮件自助重置 - 未启用邮件:提交申请等待管理员审核 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app.py | 95 ++++++++++ email_service.py | 160 +++++++++++++++++ templates/email/reset_password.html | 76 ++++++++ templates/login.html | 98 +++++++++- templates/reset_password.html | 266 ++++++++++++++++++++++++++++ 5 files changed, 691 insertions(+), 4 deletions(-) create mode 100644 templates/email/reset_password.html create mode 100644 templates/reset_password.html diff --git a/app.py b/app.py index 40f48d7..e7e6d20 100755 --- a/app.py +++ b/app.py @@ -846,6 +846,101 @@ def get_email_verify_status(): }) +# ==================== 密码重置(邮件方式)API ==================== + +@app.route('/api/forgot-password', methods=['POST']) +@require_ip_not_locked +def forgot_password(): + """发送密码重置邮件""" + data = request.json + email = data.get('email', '').strip() + captcha_session = data.get('captcha_session', '') + captcha_code = data.get('captcha', '').strip() + + if not email: + return jsonify({"error": "请输入邮箱"}), 400 + + # 获取客户端IP + client_ip = get_client_ip() + + # 检查IP限流 + allowed, error_msg = check_ip_rate_limit(client_ip) + if not allowed: + return jsonify({"error": error_msg}), 429 + + # 验证验证码 + success, message = verify_and_consume_captcha(captcha_session, captcha_code, captcha_storage, MAX_CAPTCHA_ATTEMPTS) + if not success: + is_locked = record_failed_captcha(client_ip) + if is_locked: + return jsonify({"error": "验证码错误次数过多,IP已被锁定1小时"}), 429 + return jsonify({"error": message}), 400 + + # 检查邮件功能是否启用 + email_settings = email_service.get_email_settings() + if not email_settings.get('enabled', False): + return jsonify({"error": "邮件功能未启用,请联系管理员"}), 400 + + # 查找用户(防止用户枚举,统一返回成功消息) + user = database.get_user_by_email(email) + if user and user.get('status') == 'approved': + # 发送重置邮件 + result = email_service.send_password_reset_email( + email=email, + username=user['username'], + user_id=user['id'] + ) + if not result['success']: + logger.error(f"密码重置邮件发送失败: {result['error']}") + # 即使失败也返回统一消息,防止信息泄露 + + # 统一返回成功消息 + return jsonify({ + "success": True, + "message": "如果该邮箱已注册,您将收到密码重置邮件" + }) + + +@app.route('/reset-password/') +def reset_password_page(token): + """密码重置页面""" + result = email_service.verify_password_reset_token(token) + if result: + return render_template('reset_password.html', token=token, valid=True, error_message='') + else: + return render_template('reset_password.html', token=token, valid=False, + error_message='重置链接无效或已过期,请重新申请密码重置') + + +@app.route('/api/reset-password-confirm', methods=['POST']) +def reset_password_confirm(): + """确认密码重置""" + data = request.json + token = data.get('token', '').strip() + new_password = data.get('new_password', '').strip() + + if not token or not new_password: + return jsonify({"error": "参数不完整"}), 400 + + # 验证密码强度 + is_valid, error_msg = validate_password(new_password) + if not is_valid: + return jsonify({"error": error_msg}), 400 + + # 验证并消费token + result = email_service.confirm_password_reset(token) + if not result: + return jsonify({"error": "重置链接无效或已过期"}), 400 + + # 更新用户密码 + user_id = result['user_id'] + if database.admin_reset_user_password(user_id, new_password): + logger.info(f"用户密码重置成功: user_id={user_id}") + return jsonify({"success": True, "message": "密码重置成功"}) + else: + return jsonify({"error": "密码重置失败"}), 500 + + # ==================== 验证码API ==================== import random from task_checkpoint import get_checkpoint_manager, TaskStage diff --git a/email_service.py b/email_service.py index 6882fac..d4fef49 100644 --- a/email_service.py +++ b/email_service.py @@ -1225,6 +1225,166 @@ def resend_register_verification_email(user_id: int, email: str, username: str) return send_register_verification_email(email, username, user_id) +# ============ 密码重置邮件 ============ + +def send_password_reset_email( + email: str, + username: str, + user_id: int, + base_url: str = None +) -> Dict[str, Any]: + """ + 发送密码重置邮件 + + Args: + email: 用户邮箱 + username: 用户名 + user_id: 用户ID + base_url: 网站基础URL + + Returns: + {'success': bool, 'error': str, 'token': str} + """ + # 检查发送频率限制(密码重置限制5分钟) + if not check_rate_limit(email, EMAIL_TYPE_RESET): + return { + 'success': False, + 'error': '发送太频繁,请5分钟后再试', + 'token': None + } + + # 使旧的重置token失效 + with db_pool.get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE email_tokens SET used = 1 + WHERE user_id = ? AND token_type = ? AND used = 0 + """, (user_id, EMAIL_TYPE_RESET)) + conn.commit() + + # 生成新的验证Token + token = generate_email_token(email, EMAIL_TYPE_RESET, user_id) + + # 获取base_url + if not base_url: + settings = get_email_settings() + base_url = settings.get('base_url', '') + + if not base_url: + try: + from app_config import Config + base_url = Config.BASE_URL + except: + base_url = 'http://localhost:51233' + + # 生成重置链接 + reset_url = f"{base_url.rstrip('/')}/reset-password/{token}" + + # 读取邮件模板 + template_path = os.path.join(os.path.dirname(__file__), 'templates', 'email', 'reset_password.html') + try: + with open(template_path, 'r', encoding='utf-8') as f: + html_template = f.read() + except FileNotFoundError: + html_template = """ + + +

密码重置

+

您好,{{ username }}!

+

请点击下面的链接重置您的密码:

+

{{ reset_url }}

+

此链接30分钟内有效。

+ + + """ + + # 替换模板变量 + html_body = html_template.replace('{{ username }}', username) + html_body = html_body.replace('{{ reset_url }}', reset_url) + + # 纯文本版本 + text_body = f""" +您好,{username}! + +我们收到了您的密码重置请求。请点击下面的链接重置您的密码: + +{reset_url} + +此链接30分钟内有效。 + +如果您没有申请过密码重置,请忽略此邮件。 +""" + + # 发送邮件 + result = send_email( + to_email=email, + subject='【知识管理平台】密码重置', + body=text_body, + html_body=html_body, + email_type=EMAIL_TYPE_RESET, + user_id=user_id + ) + + if result['success']: + return {'success': True, 'error': '', 'token': token} + else: + return {'success': False, 'error': result['error'], 'token': None} + + +def verify_password_reset_token(token: str) -> Optional[Dict[str, Any]]: + """ + 验证密码重置Token(不标记为已使用) + + Returns: + 成功返回 {'user_id': int, 'email': str},失败返回 None + """ + with db_pool.get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT id, user_id, email, expires_at, used + FROM email_tokens + WHERE token = ? AND token_type = ? + """, (token, EMAIL_TYPE_RESET)) + + row = cursor.fetchone() + if not row: + return None + + token_id, user_id, email, expires_at, used = row + + # 检查是否已使用 + if used: + return None + + # 检查是否过期 + if datetime.strptime(expires_at, '%Y-%m-%d %H:%M:%S') < datetime.now(): + return None + + return {'user_id': user_id, 'email': email, 'token_id': token_id} + + +def confirm_password_reset(token: str) -> Optional[Dict[str, Any]]: + """ + 确认密码重置(标记Token为已使用) + + Returns: + 成功返回 {'user_id': int, 'email': str},失败返回 None + """ + result = verify_password_reset_token(token) + if not result: + return None + + # 标记为已使用 + with db_pool.get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE email_tokens SET used = 1 WHERE id = ? + """, (result['token_id'],)) + conn.commit() + + return {'user_id': result['user_id'], 'email': result['email']} + + # ============ 异步发送队列 ============ class EmailQueue: diff --git a/templates/email/reset_password.html b/templates/email/reset_password.html new file mode 100644 index 0000000..401d3aa --- /dev/null +++ b/templates/email/reset_password.html @@ -0,0 +1,76 @@ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+

知识管理平台

+

密码重置验证

+
+

+ 您好,{{ username }}! +

+

+ 我们收到了您的密码重置请求。请点击下方按钮重置您的密码。 +

+ + + + + + +
+ + 重置密码 + +
+ +

+ 如果按钮无法点击,请复制以下链接到浏览器打开: +

+

+ {{ reset_url }} +

+ +
+

+ 注意:此链接有效期为 30 分钟,过期后需要重新申请。 +

+
+ +

+ 如果您没有申请过密码重置,请忽略此邮件,您的密码不会被修改。 +

+
+

+ 此邮件由系统自动发送,请勿直接回复。 +

+

+ © 知识管理平台 +

+
+
+ + diff --git a/templates/login.html b/templates/login.html index 14ea6e0..3d8e38f 100644 --- a/templates/login.html +++ b/templates/login.html @@ -200,10 +200,23 @@ @@ -244,13 +257,16 @@ + +