修复多项安全漏洞

安全修复清单:
1. 验证码改为图片方式返回,防止明文泄露
2. CORS配置从环境变量读取,不再使用通配符"*"
3. VIP API添加@admin_required装饰器,统一认证
4. 用户登录统一错误消息,防止用户枚举
5. IP限流不再信任X-Forwarded-For头,防止伪造绕过
6. 密码强度要求提升(8位+字母+数字)
7. 日志不���记录完整session/cookie内容,防止敏感信息泄露
8. XSS防护:日志输出和Bug反馈内容转义HTML
9. SQL注入防护:LIKE查询参数转义
10. 路径遍历防护:截图目录白名单验证
11. 验证码重放防护:验证前删除验证码
12. 数据库连接池健康检查
13. 正则DoS防护:限制数字匹配长度
14. Account类密码私有化,__repr__不暴露密码

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-11 17:53:48 +08:00
parent 4d3e4a09fd
commit b9edc4aaa2
10 changed files with 256 additions and 101 deletions

170
app.py
View File

@@ -60,9 +60,17 @@ app.config.from_object(config)
# 确保SECRET_KEY已设置
if not app.config.get('SECRET_KEY'):
raise RuntimeError("SECRET_KEY未配置请检查app_config.py")
# 安全修复从环境变量获取允许的CORS源不再使用"*"
cors_origins = os.environ.get('CORS_ALLOWED_ORIGINS', '').strip()
if cors_origins:
cors_allowed = [origin.strip() for origin in cors_origins.split(',') if origin.strip()]
else:
# 默认只允许同源生产环<E4BAA7><E78EAF>应该明确配置
cors_allowed = []
socketio = SocketIO(
app,
cors_allowed_origins="*",
cors_allowed_origins=cors_allowed if cors_allowed else None, # None表示只允许同源
async_mode='threading', # 明确指定async模式
ping_timeout=60, # ping超时60秒
ping_interval=25, # 每25秒ping一次
@@ -169,12 +177,22 @@ class Admin(UserMixin):
class Account:
"""账号类"""
"""账号类
安全注意事项:
- 密码以私有属性存储,避免意外泄露
- __repr__不包含密码防止日志意外记录
- 生产环境应考虑使用加密存储如Fernet加密
"""
__slots__ = ['id', 'user_id', 'username', '_password', 'remember', 'remark',
'status', 'is_running', 'should_stop', 'total_items',
'total_attachments', 'automation', 'last_browse_type', 'proxy_config']
def __init__(self, account_id, user_id, username, password, remember=True, remark=''):
self.id = account_id
self.user_id = user_id
self.username = username
self.password = password
self._password = password # 安全修复:使用私有属性存储密码
self.remember = remember
self.remark = remark
self.status = "未开始"
@@ -186,6 +204,15 @@ class Account:
self.last_browse_type = "注册前未读"
self.proxy_config = None # 保存代理配置,浏览和截图共用
@property
def password(self):
"""获取密码(仅供自动化登录使用)"""
return self._password
def __repr__(self):
"""安全的字符串表示,不包含密码"""
return f"Account(id={self.id}, username={self.username}, status={self.status})"
def to_dict(self):
result = {
"id": self.id,
@@ -307,8 +334,8 @@ def admin_required(f):
"""管理员权限装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
logger.debug(f"[admin_required] Session内容: {dict(session)}")
logger.debug(f"[admin_required] Cookies: {request.cookies}")
# 安全修复不再记录完整的session和cookies内容防止敏感信息泄露
logger.debug(f"[admin_required] 检查会话admin_id存在: {'admin_id' in session}")
if 'admin_id' not in session:
logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
return jsonify({"error": "需要管理员权限"}), 403
@@ -319,10 +346,12 @@ def admin_required(f):
def log_to_client(message, user_id=None, account_id=None):
"""发送日志到Web客户端(用户隔离)"""
from app_security import escape_html
timestamp = get_beijing_now().strftime('%H:%M:%S')
# 安全修复转义HTML特殊字符防止XSS攻击
log_data = {
'timestamp': timestamp,
'message': message,
'message': escape_html(str(message)) if message else '',
'account_id': account_id
}
@@ -579,10 +608,17 @@ def check_ip_rate_limit(ip_address):
"""检查IP是否被限流"""
current_time = time.time()
# 清理过期IP记录
expired_ips = [ip for ip, data in ip_rate_limit.items()
if data.get("lock_until", 0) < current_time and
current_time - data.get("first_attempt", current_time) > 3600]
# 安全修复:修正过期IP清理逻辑
# 原问题first_attempt不存在时默认使用current_time导致永远不会被清理
expired_ips = []
for ip, data in ip_rate_limit.items():
lock_expired = data.get("lock_until", 0) < current_time
first_attempt = data.get("first_attempt")
# 修复如果first_attempt不存在或超过1小时视为过期
attempt_expired = first_attempt is None or (current_time - first_attempt > 3600)
if lock_expired and attempt_expired:
expired_ips.append(ip)
for ip in expired_ips:
del ip_rate_limit[ip]
@@ -596,7 +632,8 @@ def check_ip_rate_limit(ip_address):
return False, "IP已被锁定,请{}分钟后再试".format(remaining_time // 60 + 1)
# 如果超过1小时,重置计数
if current_time - ip_data.get("first_attempt", current_time) > 3600:
first_attempt = ip_data.get("first_attempt")
if first_attempt is None or current_time - first_attempt > 3600:
ip_rate_limit[ip_address] = {
"attempts": 0,
"first_attempt": current_time
@@ -627,8 +664,14 @@ def record_failed_captcha(ip_address):
@app.route("/api/generate_captcha", methods=["POST"])
def generate_captcha():
"""生成4位数字验证码"""
"""生成4位数字验证码图片
安全修复验证码不再以明文返回而是生成base64图片
"""
import uuid
import base64
from io import BytesIO
session_id = str(uuid.uuid4())
# 生成4位随机数字
@@ -646,7 +689,60 @@ def generate_captcha():
for k in expired_keys:
del captcha_storage[k]
return jsonify({"session_id": session_id, "captcha": code})
# 生成验证码图片
try:
from PIL import Image, ImageDraw, ImageFont
import io
# 创建图片
width, height = 120, 40
image = Image.new('RGB', (width, height), color=(255, 255, 255))
draw = ImageDraw.Draw(image)
# 添加干扰线
for _ in range(5):
x1 = random.randint(0, width)
y1 = random.randint(0, height)
x2 = random.randint(0, width)
y2 = random.randint(0, height)
draw.line([(x1, y1), (x2, y2)], fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
# 添加干扰点
for _ in range(50):
x = random.randint(0, width)
y = random.randint(0, height)
draw.point((x, y), fill=(random.randint(0, 200), random.randint(0, 200), random.randint(0, 200)))
# 绘制验证码文字
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 28)
except:
font = ImageFont.load_default()
for i, char in enumerate(code):
x = 10 + i * 25 + random.randint(-3, 3)
y = random.randint(2, 8)
color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
draw.text((x, y), char, font=font, fill=color)
# 转换为base64
buffer = io.BytesIO()
image.save(buffer, format='PNG')
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
return jsonify({
"session_id": session_id,
"captcha_image": f"data:image/png;base64,{img_base64}"
})
except ImportError:
# 如果没有PIL退回到简单文本但添加混淆
# 安全警告生产环境应安装PIL
logger.warning("PIL未安装验证码安全性降低")
# 不直接返回验证码,返回混淆后的提示
return jsonify({
"session_id": session_id,
"captcha_hint": "验证码图片生成失败,请联系管理员"
}), 500
@app.route('/api/login', methods=['POST'])
@@ -666,16 +762,12 @@ def login():
if not success:
return jsonify({"error": message}), 400
# 先检查用户是否存在
user_exists = database.get_user_by_username(username)
if not user_exists:
return jsonify({"error": "账号未注册", "need_captcha": True}), 401
# 检查密码是否正确
# 安全修复:使用通用错误消息防止用户枚举
# 不再分开检查用户是否存在和密码是否正确
user = database.verify_user(username, password)
if not user:
# 密码错误
return jsonify({"error": "密码错误", "need_captcha": True}), 401
# 返回通用错误消息,不透露账号是否存在
return jsonify({"error": "用户名或密码错误", "need_captcha": True}), 401
# 检查审核状态
if user['status'] != 'approved':
@@ -699,8 +791,13 @@ def logout():
# ==================== 管理员认证API ====================
@app.route('/yuyx/api/debug-config', methods=['GET'])
@admin_required # 安全修复:添加管理员认证
def debug_config():
"""调试配置信息"""
"""调试配置信息(仅管理员可访问,生产环境应禁用)"""
# 安全修复:生产环境禁用调试端点
if not app.debug:
return jsonify({"error": "调试端点已在生产环境禁用"}), 403
return jsonify({
"secret_key_set": bool(app.secret_key),
"secret_key_length": len(app.secret_key) if app.secret_key else 0,
@@ -711,7 +808,8 @@ def debug_config():
"SESSION_COOKIE_SAMESITE": app.config.get('SESSION_COOKIE_SAMESITE'),
"PERMANENT_SESSION_LIFETIME": str(app.config.get('PERMANENT_SESSION_LIFETIME')),
},
"current_session": dict(session),
# 安全修复移除敏感的session内容只显示有无
"has_session": bool(session),
"cookies_received": list(request.cookies.keys())
})
@@ -751,9 +849,8 @@ def admin_login():
session.permanent = True # 设置为永久会话使用PERMANENT_SESSION_LIFETIME配置
session.modified = True # 强制标记session为已修改确保保存
logger.info(f"[admin_login] 管理员 {username} 登录成功, session已设置: admin_id={admin['id']}")
logger.debug(f"[admin_login] Session内容: {dict(session)}")
logger.debug(f"[admin_login] Cookie将被设置: name={app.config.get('SESSION_COOKIE_NAME', 'session')}")
logger.info(f"[admin_login] 管理员 {username} 登录成功")
# 安全修复不再记录完整session内容
# 根据请求类型返回不同响应
if request.is_json:
@@ -1234,6 +1331,8 @@ def get_accounts():
user_id = current_user.id
refresh = request.args.get('refresh', 'false').lower() == 'true'
# 安全修复:使用锁保护检查和加载操作,防止竞态条件
with user_accounts_lock:
# 如果user_accounts中没有数据或者请求刷新则从数据库加载
if user_id not in user_accounts or len(user_accounts.get(user_id, {})) == 0 or refresh:
load_user_accounts(user_id)
@@ -2252,20 +2351,17 @@ def serve_static(filename):
# ==================== 管理员VIP管理API ====================
@app.route('/yuyx/api/vip/config', methods=['GET'])
@admin_required # 安全修复:使用统一的装饰器
def get_vip_config_api():
"""获取VIP配置"""
if 'admin_id' not in session:
return jsonify({"error": "需要管理员权限"}), 403
config = database.get_vip_config()
return jsonify(config)
@app.route('/yuyx/api/vip/config', methods=['POST'])
@admin_required # 安全修复:使用统一的装饰器
def set_vip_config_api():
"""设置默认VIP天数"""
if 'admin_id' not in session:
return jsonify({"error": "需要管理员权限"}), 403
data = request.json
days = data.get('default_vip_days', 0)
@@ -2277,11 +2373,9 @@ def set_vip_config_api():
@app.route('/yuyx/api/users/<int:user_id>/vip', methods=['POST'])
@admin_required # 安全修复:使用统一的装饰器
def set_user_vip_api(user_id):
"""设置用户VIP"""
if 'admin_id' not in session:
return jsonify({"error": "需要管理员权限"}), 403
data = request.json
days = data.get('days', 30)
@@ -2297,22 +2391,18 @@ def set_user_vip_api(user_id):
@app.route('/yuyx/api/users/<int:user_id>/vip', methods=['DELETE'])
@admin_required # 安全修复:使用统一的装饰器
def remove_user_vip_api(user_id):
"""移除用户VIP"""
if 'admin_id' not in session:
return jsonify({"error": "需要管理员权限"}), 403
if database.remove_user_vip(user_id):
return jsonify({"message": "VIP已移除"})
return jsonify({"error": "移除失败"}), 400
@app.route('/yuyx/api/users/<int:user_id>/vip', methods=['GET'])
@admin_required # 安全修复:使用统一的装饰器
def get_user_vip_info_api(user_id):
"""获取用户VIP信息(管理员)"""
if 'admin_id' not in session:
return jsonify({"error": "需要管理员权限"}), 403
vip_info = database.get_user_vip_info(user_id)
return jsonify(vip_info)

View File

@@ -240,12 +240,15 @@ def validate_username(username):
return True, None
def validate_password(password):
def validate_password(password, require_complexity=True):
"""
验证密码强度
安全修复:增强密码强度要求
Args:
password: 密码
require_complexity: 是否要求复杂度默认True
Returns:
tuple: (is_valid, error_message)
@@ -253,19 +256,19 @@ def validate_password(password):
if not password:
return False, "密码不能为空"
if len(password) < 6:
return False, "密码长度不能少于6个字符"
if len(password) < 8: # 安全修复最少8位
return False, "密码长度不能少于8个字符"
if len(password) > 128:
return False, "密码长度不能超过128个字符"
# 可选:强制密码复杂度
# has_upper = bool(re.search(r'[A-Z]', password))
# has_lower = bool(re.search(r'[a-z]', password))
# has_digit = bool(re.search(r'\d', password))
#
# if not (has_upper and has_lower and has_digit):
# return False, "密码必须包含大写字母、小写字母和数字"
# 安全修复:启用密码复杂度要求
if require_complexity:
has_letter = bool(re.search(r'[a-zA-Z]', password))
has_digit = bool(re.search(r'\d', password))
if not (has_letter and has_digit):
return False, "密码必须包含字母和数字"
return True, None
@@ -392,19 +395,27 @@ def check_security_config():
# ==================== 辅助函数 ====================
def get_client_ip():
def get_client_ip(trust_proxy=False):
"""
获取客户端真实IP地址
安全修复默认不信任代理头防止IP伪造绕过限流
Args:
trust_proxy: 是否信任代理头仅在已知可信代理后设置为True
Returns:
str: IP地址
"""
# 检查代理头
# 安全说明X-Forwarded-For 可被伪造
# 仅在确认请求来自可信代理时才使用代理头
if trust_proxy:
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
elif request.headers.get('X-Real-IP'):
return request.headers.get('X-Real-IP')
else:
# 默认使用remote_addr更安全但可能是代理IP
return request.remote_addr

View File

@@ -282,7 +282,11 @@ def check_user_ownership(user_id: int, resource_type: str,
def verify_and_consume_captcha(session_id: str, code: str, captcha_storage: dict, max_attempts: int = 5) -> Tuple[bool, str]:
"""
验证并消费验证码
验证并消费验证码(安全增强版)
安全特性:
- 先删除验证码再验证,防止重放攻击
- 异常情况下也确保验证码被删除
Args:
session_id: 验证码会话ID
@@ -307,30 +311,37 @@ def verify_and_consume_captcha(session_id: str, code: str, captcha_storage: dict
"""
import time
# 安全修复:先取出并删除验证码,无论验证是否成功都不能重用
captcha_data = captcha_storage.pop(session_id, None)
# 检查验证码是否存在
if session_id not in captcha_storage:
if captcha_data is None:
return False, "验证码已过期或不存在,请重新获取"
captcha_data = captcha_storage[session_id]
try:
# 检查过期时间
if captcha_data["expire_time"] < time.time():
del captcha_storage[session_id]
return False, "验证码已过期,请重新获取"
# 检查尝试次数
if captcha_data.get("failed_attempts", 0) >= max_attempts:
del captcha_storage[session_id]
return False, f"验证码错误次数过多({max_attempts}次),请重新获取"
# 验证代码(不区分大小写)
if captcha_data["code"].lower() != code.lower():
# 验证失败,增加失败计数后放回(允许继续尝试)
captcha_data["failed_attempts"] = captcha_data.get("failed_attempts", 0) + 1
# 只有未超过最大尝试次数才放回
if captcha_data["failed_attempts"] < max_attempts:
captcha_storage[session_id] = captcha_data
return False, "验证码错误"
# 验证成功,删除验证码(防止重复使用)
del captcha_storage[session_id]
# 验证成功,验证码已被删除,不会被重用
return True, "验证成功"
except Exception as e:
# 异常情况下确保验证码不会被重用(已在函数开头删除)
logger.error(f"验证码验证异常: {e}")
return False, "验证码验证失败,请重新获取"
if __name__ == '__main__':

View File

@@ -630,7 +630,10 @@ def remove_user_vip(user_id):
def is_user_vip(user_id):
"""检查用户是否是VIP"""
"""检查用户是否是VIP
注意数据库中存储的时间统一使用CSTAsia/Shanghai时区
"""
cst_tz = pytz.timezone("Asia/Shanghai")
user = get_user_by_id(user_id)
@@ -638,9 +641,12 @@ def is_user_vip(user_id):
return False
try:
# 时区处理说明数据库存储的是CST时间字符串
# 如果将来改为UTC存储需要修改此处逻辑
expire_time_naive = datetime.strptime(user['vip_expire_time'], '%Y-%m-%d %H:%M:%S')
expire_time = cst_tz.localize(expire_time_naive)
return datetime.now(cst_tz) < expire_time
now = datetime.now(cst_tz)
return now < expire_time
except (ValueError, AttributeError) as e:
print(f"检查VIP状态失败 (user_id={user_id}): {e}")
return False
@@ -1137,8 +1143,11 @@ def get_task_logs(limit=100, offset=0, date_filter=None, status_filter=None,
params.append(user_id_filter)
if account_filter:
where_clauses.append("tl.username LIKE ?")
params.append(f"%{account_filter}%")
# 转义LIKE中的特殊字符防止绕过过滤
from app_security import sanitize_sql_like_pattern
safe_filter = sanitize_sql_like_pattern(account_filter)
where_clauses.append("tl.username LIKE ? ESCAPE '\\'")
params.append(f"%{safe_filter}%")
where_sql = " AND ".join(where_clauses)
@@ -1409,16 +1418,24 @@ def clean_old_operation_logs(days=30):
# ==================== Bug反馈管理 ====================
def create_bug_feedback(user_id, username, title, description, contact=''):
"""创建Bug反馈"""
"""创建Bug反馈带XSS防护"""
from app_security import escape_html
with db_pool.get_db() as conn:
cursor = conn.cursor()
cst_tz = pytz.timezone("Asia/Shanghai")
cst_time = datetime.now(cst_tz).strftime("%Y-%m-%d %H:%M:%S")
# 安全修复转义用户输入防止存储型XSS攻击
safe_title = escape_html(title) if title else ''
safe_description = escape_html(description) if description else ''
safe_contact = escape_html(contact) if contact else ''
safe_username = escape_html(username) if username else ''
cursor.execute('''
INSERT INTO bug_feedbacks (user_id, username, title, description, contact, created_at)
VALUES (?, ?, ?, ?, ?, ?)
''', (user_id, username, title, description, contact, cst_time))
''', (user_id, safe_username, safe_title, safe_description, safe_contact, cst_time))
conn.commit()
return cursor.lastrowid

View File

@@ -65,7 +65,7 @@ class ConnectionPool:
def return_connection(self, conn):
"""
归还连接到连接池 [已修复Bug#7]
归还连接到连接池 [已修复Bug#7, Bug#11]
Args:
conn: 要归还的连接
@@ -76,6 +76,8 @@ class ConnectionPool:
try:
# 回滚任何未提交的事务
conn.rollback()
# 安全修复:验证连接是否健康,防止损坏的连接污染连接池
conn.execute("SELECT 1")
self._pool.put(conn, block=False)
except sqlite3.Error as e:
# 数据库相关错误,连接可能损坏

View File

@@ -907,7 +907,8 @@ class PlaywrightAutomation:
# 解析"共XXX记录"获取总数
if expected_total is None:
import re
match = re.search(r'共(\d+)记录', page_text)
# 安全修复限制数字匹配长度防止ReDoS攻击
match = re.search(r'共(\d{1,10})记录', page_text)
if match:
expected_total = int(match.group(1))
self.log(f"[总数] 预期浏览 {expected_total} 条内容")

View File

@@ -19,6 +19,26 @@ def take_screenshot(config):
screenshot_path = config['screenshot_path']
cookies_file = config.get('cookies_file', '')
# 安全修复:验证截图路径在允许的目录内,防止路径遍历攻击
ALLOWED_SCREENSHOT_DIRS = [
'/root/zsglpt/screenshots',
'/root/zsglpt/static/screenshots',
'/tmp/zsglpt_screenshots'
]
def is_safe_screenshot_path(path):
"""验证截图路径是否安全"""
abs_path = os.path.abspath(path)
return any(abs_path.startswith(os.path.abspath(allowed_dir))
for allowed_dir in ALLOWED_SCREENSHOT_DIRS)
if not is_safe_screenshot_path(screenshot_path):
return {
'success': False,
'message': '非法截图路径',
'screenshot_path': ''
}
result = {
'success': False,
'message': '',

View File

@@ -184,7 +184,7 @@
<label for="captcha">验证码</label>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码" style="flex: 1;">
<span id="captchaCode" style="font-size: 20px; font-weight: bold; letter-spacing: 5px; color: #f5576c; user-select: none;">----</span>
<img id="captchaImage" src="" alt="验证码" style="height: 36px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" onclick="refreshCaptcha()" title="点击刷新">
<button type="button" onclick="refreshCaptcha()" style="padding: 8px 15px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">刷新</button>
</div>
</div>
@@ -278,9 +278,9 @@
const data = await response.json();
if (data.session_id && data.captcha) {
if (data.session_id && data.captcha_image) {
captchaSession = data.session_id;
document.getElementById('captchaCode').textContent = data.captcha;
document.getElementById('captchaImage').src = data.captcha_image;
}
} catch (error) {
console.error('生成验证码失败:', error);

View File

@@ -185,7 +185,7 @@
<label for="captcha">验证码</label>
<div class="captcha-row">
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码">
<span id="captchaCode" class="captcha-code">----</span>
<img id="captchaImage" src="" alt="验证码" style="height: 36px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" onclick="refreshCaptcha()" title="点击刷新">
<button type="button" class="captcha-refresh" onclick="refreshCaptcha()">🔄</button>
</div>
</div>
@@ -253,7 +253,7 @@
else { errorDiv.textContent = data.error || '申请失败'; errorDiv.style.display = 'block'; }
} catch (error) { errorDiv.textContent = '网络错误'; errorDiv.style.display = 'block'; }
}
async function generateCaptcha() { try { const response = await fetch('/api/generate_captcha', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (data.session_id && data.captcha) { captchaSession = data.session_id; document.getElementById('captchaCode').textContent = data.captcha; } } catch (error) { console.error('生成验证码失败:', error); } }
async function generateCaptcha() { try { const response = await fetch('/api/generate_captcha', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const data = await response.json(); if (data.session_id && data.captcha_image) { captchaSession = data.session_id; document.getElementById('captchaImage').src = data.captcha_image; } } catch (error) { console.error('生成验证码失败:', error); } }
async function refreshCaptcha() { await generateCaptcha(); document.getElementById('captcha').value = ''; }
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeForgotPassword(); });
</script>

View File

@@ -181,7 +181,7 @@
<label for="captcha">验证码</label>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" id="captcha" placeholder="请输入验证码" required style="flex: 1;">
<span id="captchaCode" style="font-size: 20px; font-weight: bold; letter-spacing: 5px; color: #4CAF50;">----</span>
<img id="captchaImage" src="" alt="验证码" style="height: 40px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" onclick="refreshCaptcha()" title="点击刷新">
<button type="button" onclick="refreshCaptcha()" style="padding: 8px 15px; background: #f0f0f0; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;">刷新</button>
</div>
</div>
@@ -263,7 +263,10 @@
async function generateCaptcha() {
const resp = await fetch('/api/generate_captcha', {method: 'POST', headers: {'Content-Type': 'application/json'}});
const data = await resp.json();
if (data.session_id && data.captcha) { captchaSession = data.session_id; document.getElementById('captchaCode').textContent = data.captcha; }
if (data.session_id && data.captcha_image) {
captchaSession = data.session_id;
document.getElementById('captchaImage').src = data.captcha_image;
}
}
async function refreshCaptcha() { await generateCaptcha(); document.getElementById('captcha').value = ''; }
</script>