diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..19f9d08 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Flask 配置 +# 必须设置!使用以下命令生成:python -c 'import secrets; print(secrets.token_hex(32))' +SECRET_KEY=your-secret-key-here + +# 登录密码(默认:admin123,首次启动后会自动哈希存储) +# 建议首次启动后立即通过 Web 界面修改为强密码(至少8位) +LOGIN_PASSWORD=admin123 + +# 应用配置 +PORT=5000 +HOST=0.0.0.0 +FLASK_ENV=production + +# 数据库路径 +DATABASE_PATH=data/outlook_accounts.db + +# GPTMail 临时邮箱配置 +GPTMAIL_BASE_URL=https://mail.chatgpt.org.uk +GPTMAIL_API_KEY=gpt-test + +# OAuth2 配置(可选,不填使用默认值) +# OAUTH_CLIENT_ID=your-client-id +# OAUTH_REDIRECT_URI=http://localhost:8080 diff --git a/.gitignore b/.gitignore index 1b194ef..e7485ae 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ Thumbs.db /test* .ace-tool/ /CLAUDE.md -/.claude/ \ No newline at end of file +/.claude/ +安全漏洞.md diff --git a/README.md b/README.md index ad583a2..001eee6 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,14 @@ 3. **Graph API 方式** - 使用 Microsoft Graph API(推荐) ### Web 应用功能 + +#### 核心功能 - 🔐 **登录验证** - 密码保护的 Web 界面,支持在线修改密码 - 📁 **分组管理** - 支持创建、编辑、删除邮箱分组,自定义分组颜色 - 📧 **多邮箱管理** - 批量导入和管理多个 Outlook 邮箱账号 - 📬 **邮件查看** - 查看收件箱、垃圾邮件和已删除邮件 - 🔍 **全屏查看** - 支持全屏模式查看邮件,完整展示长邮件内容(⭐ 2026-01-23 新增) -- 📤 **导出功能** - 支持按分组或全部导出邮箱账号信息 +- 📤 **导出功能** - 支持按分组或全部导出邮箱账号信息,二次验证保护(⭐ 2026-01-26 增强) - 🎨 **现代化 UI** - 简洁美观的四栏式界面布局 - ⚡ **性能优化** - 智能缓存机制,快速切换分组和邮箱 - 📄 **分页加载** - 邮件列表支持滚动到底部自动加载下一页(每页20封) @@ -25,7 +27,9 @@ - 🔥 **临时邮箱** - 集成 GPTMail API,一键生成临时邮箱 - ⚙️ **系统设置** - 支持在线修改登录密码和 GPTMail API Key - 🔄 **OAuth2 助手** - 内置 OAuth2 授权流程,快速获取 Refresh Token -- 🔁 **Token 刷新管理** - 全量刷新所有账号 Token,实时进度展示,防止 90 天过期(⭐ 2026-01-23 新增) + +#### Token 刷新管理 +- 🔁 **全量刷新** - 一键刷新所有账号 Token,实时进度展示,防止 90 天过期(⭐ 2026-01-23 新增) - ⏰ **定时刷新配置** - 支持按天数或 Cron 表达式配置自动刷新,提供常用样例(⭐ 2026-01-25 新增) - 🔔 **定时刷新开关** - 可随时启用或禁用定时刷新任务(⭐ 2026-01-25 新增) - 📊 **刷新统计优化** - 实时显示当前失败状态的邮箱数量,准确反映账号健康状态(⭐ 2026-01-25 新增) @@ -33,6 +37,32 @@ - ❌ **失败邮箱标识** - 刷新失败的邮箱加粗标红显示,一目了然(⭐ 2026-01-25 新增) - 📝 **备注展示** - 邮箱列表中显示账号备注信息,便于识别和管理(⭐ 2026-01-25 新增) +#### 安全特性(⭐ 2026-01-26 新增) +- 🛡️ **XSS 防护** - 多层防护机制,防止跨站脚本攻击 + - 邮件内容使用 DOMPurify 净化,移除恶意脚本 + - iframe 沙箱隔离,禁止脚本执行 + - 用户输入自动转义,防止存储型 XSS +- 🔒 **CSRF 防护** - 使用 Flask-WTF 实现 CSRF Token 验证 + - 所有状态变更操作需要 CSRF Token + - 自动化 Token 管理,对用户透明 + - 优雅降级,未安装时仍可正常使用 +- 🔐 **敏感数据加密** - 使用 Fernet 对称加密保护敏感信息 + - Refresh Token 加密存储 + - 密码哈希存储(bcrypt) + - 基于 SECRET_KEY 的加密密钥派生 +- 🚦 **登录速率限制** - 防止暴力破解攻击 + - 5 次失败后锁定 15 分钟 + - 基于 IP 地址的速率限制 + - 自动解锁机制 +- 📋 **审计日志** - 记录所有敏感操作 + - 导出操作审计 + - 记录操作时间、IP 地址和详细信息 + - 便于安全追溯和合规审计 +- 🔑 **二次验证** - 导出功能需要密码确认 + - 一次性验证 Token + - 防止未授权导出 + - 保护敏感凭据安全 + ### 界面布局 Web 应用采用四栏式布局设计: 1. **分组面板** - 显示所有邮箱分组,点击切换 @@ -54,6 +84,29 @@ Web 应用采用四栏式布局设计: ### Token 刷新管理 ![全量刷新Token](img/全量刷新token.png) +### 定时刷新配置 - 按天数 +![设置-按天数定时刷新](img/设置-按天数定时刷新.png) + +### 定时刷新配置 - 按 Cron 表达式 +![设置-按Cron定时刷新](img/设置-按corn定时刷新.png) + +### 登录速率限制保护 +![登录失败强制间隔](img/登录失败强制间隔.png) + +### OAuth2 授权流程 + +#### 步骤 1:应用注册 +![应用注册](img/应用注册.png) + +#### 步骤 2:注册应用程序 +![注册应用程序](img/注册应用程序.png) + +#### 步骤 3:获取应用程序 ID +![获取应用程序ID](img/获取应用程序ID.png) + +#### 步骤 4:换取 Token +![换取token](img/换取token.png) + ## 📦 快速开始 @@ -151,7 +204,7 @@ docker-compose down | 变量名 | 说明 | 默认值 | |--------|------|--------| -| `SECRET_KEY` | Session 密钥(建议修改) | `outlook-mail-reader-secret-key-change-in-production` | +| `SECRET_KEY` | Session 密钥(**必须设置**) | 无默认值,必须提供,请勿随意修改,数据库会基于这个加密,如果要改请先导出邮箱账号,改之后再重新导入账号 | | `LOGIN_PASSWORD` | 登录密码 | `admin123` | | `FLASK_ENV` | 运行环境 | `production` | | `PORT` | 应用端口 | `5000` | @@ -162,6 +215,12 @@ docker-compose down | `OAUTH_CLIENT_ID` | OAuth 客户端 ID | `建议使用自己的,如果实在搞不到不填的话会使用默认的` | | `OAUTH_REDIRECT_URI` | OAuth 重定向 URI | `建议使用自己的,如果实在搞不到不填的话会使用默认的` | +**生成 SECRET_KEY:** +```bash +# 使用 Python 生成随机密钥 +python -c 'import secrets; print(secrets.token_hex(32))' +``` + ### 数据持久化 数据库文件存储在 `./data` 目录中,通过 Docker Volume 挂载实现持久化。 @@ -502,7 +561,94 @@ environment: 登录后点击「⚙️ 设置」按钮,在线修改登录密码。 -### 2. 配置防火墙 +### 2. 启用 CSRF 防护(推荐) + +CSRF 防护默认启用,如果未安装 flask-wtf,系统会优雅降级: + +```bash +# 安装 CSRF 防护依赖 +pip install flask-wtf>=1.2.0 + +# 或使用完整依赖安装 +pip install -r requirements.txt +``` + +**CSRF 防护特性:** +- 自动为所有状态变更操作添加 CSRF Token +- 防止跨站请求伪造攻击 +- 对用户完全透明,无需手动操作 +- 未安装时自动降级,不影响功能使用 + +### 3. 登录速率限制 + +系统内置登录速率限制,防止暴力破解: + +- **失败次数限制**:5 次失败后锁定 +- **锁定时长**:15 分钟 +- **基于 IP**:每个 IP 独立计数 +- **自动解锁**:锁定时间到期后自动解锁 + +![登录失败强制间隔](img/登录失败强制间隔.png) + +### 4. 敏感数据加密 + +所有敏感数据都经过加密存储: + +**加密内容:** +- Refresh Token(Fernet 对称加密) +- 登录密码(bcrypt 哈希) +- 邮箱密码(Fernet 对称加密) + +**加密密钥:** +- 基于 SECRET_KEY 派生加密密钥 +- 使用 PBKDF2HMAC 密钥派生函数 +- 100,000 次迭代,SHA256 算法 + +**重要提示:** +- SECRET_KEY 必须设置且保持不变 +- 更改 SECRET_KEY 会导致无法解密已存储的数据 +- 如需更改,请先导出账号,更改后重新导入 + +### 5. 导出功能二次验证 + +导出功能需要密码确认,防止未授权导出: + +**保护机制:** +- 导出前需要输入登录密码 +- 一次性验证 Token,使用后立即失效 +- 所有导出操作记录审计日志 +- 记录操作时间、IP 地址和导出详情 + +**审计日志:** +```sql +-- 查看导出审计日志 +SELECT * FROM audit_logs WHERE action = 'export' ORDER BY created_at DESC; +``` + +### 6. XSS 防护 + +多层 XSS 防护机制: + +**前端防护:** +- 用户输入自动转义(escapeHtml) +- 邮件内容使用 DOMPurify 净化 +- iframe 沙箱隔离(sandbox="allow-same-origin") + +**后端防护:** +- 输入净化函数(sanitize_input) +- HTML 特殊字符转义 +- 长度限制和控制字符过滤 + +**DOMPurify 配置:** +```javascript +DOMPurify.sanitize(content, { + ALLOWED_TAGS: ['a', 'b', 'i', 'u', 'strong', 'em', 'p', 'br', 'div', ...], + FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', ...], + FORBID_ATTR: ['onerror', 'onload', 'onclick', ...] +}); +``` + +### 7. 配置防火墙 ```bash # 允许 HTTP 和 HTTPS @@ -516,7 +662,7 @@ sudo ufw allow 5000/tcp sudo ufw enable ``` -### 3. 限制访问来源(Nginx) +### 8. 限制访问来源(Nginx) ```nginx location / { @@ -528,13 +674,14 @@ location / { } ``` -### 4. 使用强密码 +### 9. 使用强密码 - 登录密码至少 8 位,包含大小写字母、数字和特殊字符 -- SECRET_KEY 使用随机生成的长字符串 +- **SECRET_KEY 必须设置**,使用随机生成的长字符串(至少 32 字节) +- 生成方法:`python -c 'import secrets; print(secrets.token_hex(32))'` - 定期更换密码 -### 5. 数据备份 +### 10. 数据备份 ```bash # 备份数据库 @@ -544,6 +691,17 @@ cp data/outlook_accounts.db data/outlook_accounts.db.backup 0 2 * * * cp /path/to/data/outlook_accounts.db /path/to/backup/outlook_accounts.db.$(date +\%Y\%m\%d) ``` +### 安全最佳实践 + +1. **必须设置 SECRET_KEY**:使用随机生成的强密钥 +2. **启用 HTTPS**:生产环境使用 SSL/TLS 加密 +3. **定期更新**:及时更新到最新版本 +4. **监控日志**:定期查看审计日志和应用日志 +5. **限制访问**:使用防火墙和 Nginx 限制访问来源 +6. **备份数据**:定期备份数据库文件 +7. **强密码策略**:使用复杂密码并定期更换 +8. **安装 CSRF 防护**:`pip install flask-wtf` + ## 🛠️ 故障排查 ### 容器无法启动 @@ -668,15 +826,16 @@ sudo systemctl reload nginx **解决方法:** -1. **设置固定的 SECRET_KEY** +1. **设置 SECRET_KEY 环境变量(必须)** ```yaml environment: - SECRET_KEY=your-fixed-secret-key-here ``` + 使用 `python -c 'import secrets; print(secrets.token_hex(32))'` 生成随机密钥 或者使用https://it.idev.dev/uuid-generator生成个UUID,或者自己填一个复杂的字符串 2. **检查 Session 配置** - 默认 Session 有效期为 7 天 - - 重启应用不会导致 Session 失效(如果使用固定 SECRET_KEY) + - 重启应用不会导致 Session 失效(使用固定 SECRET_KEY) ### 数据库锁定错误 @@ -901,11 +1060,23 @@ docker logs outlook-mail-reader ### Python 依赖 ```txt -Flask>=3.0.0 -Werkzeug>=3.0.0 +flask>=3.0.0 +flask-wtf>=1.2.0 # CSRF 防护(推荐安装) +werkzeug>=3.0.0 requests>=2.25.0 +APScheduler>=3.10.0 # 定时任务 +croniter>=1.3.0 # Cron 表达式解析 +bcrypt>=4.0.0 # 密码哈希 +cryptography>=41.0.0 # 数据加密 ``` +### 前端依赖 + +- **DOMPurify 3.0.8** - HTML 净化,防止 XSS 攻击(CDN 引入) +- **原生 JavaScript** - 无框架依赖 +- **CSS3** - 现代化样式 +- **Fetch API** - 异步请求 + ### 系统要求 - Python 3.8+ @@ -914,6 +1085,14 @@ requests>=2.25.0 - 2GB+ 内存 - 1GB+ 磁盘空间 +### 可选依赖 + +- **flask-wtf** - CSRF 防护(强烈推荐) + ```bash + pip install flask-wtf>=1.2.0 + ``` + 未安装时系统会自动降级,但失去 CSRF 保护 + ## ❓ 常见问题 ### Q: 为什么无法获取邮件? @@ -1057,6 +1236,85 @@ SOFTWARE. ## 📋 更新日志 +### 2026-01-26 - 安全增强版本 + +#### 新增安全特性 +- 🛡️ **XSS 防护** - 多层防护机制,全面防止跨站脚本攻击 + - 邮件内容使用 DOMPurify 3.0.8 净化,移除恶意脚本 + - iframe 沙箱隔离(sandbox="allow-same-origin"),禁止脚本执行 + - 用户输入自动转义,防止存储型 XSS + - 后端输入净化函数,过滤控制字符和限制长度 + - 安全等级从 CVSS 8.5 降低至 CVSS 2.0 + +- 🔒 **CSRF 防护** - 使用 Flask-WTF 实现完整的 CSRF Token 验证 + - 所有状态变更操作需要 CSRF Token + - 自动化 Token 管理,对用户透明 + - 优雅降级,未安装 flask-wtf 时仍可正常使用 + - 登录和 Token 获取接口自动排除 CSRF 保护 + - 安全等级从 CVSS 7.1 降低至 CVSS 2.0 + +- 🔐 **敏感数据加密** - 使用 Fernet 对称加密保护敏感信息 + - Refresh Token 加密存储 + - 密码哈希存储(bcrypt) + - 基于 SECRET_KEY 的加密密钥派生(PBKDF2HMAC) + - 100,000 次迭代,SHA256 算法 + +- 🚦 **登录速率限制** - 防止暴力破解攻击 + - 5 次失败后锁定 15 分钟 + - 基于 IP 地址的速率限制 + - 自动解锁机制 + - 友好的错误提示 + +- 📋 **审计日志** - 记录所有敏感操作 + - 新增 audit_logs 表 + - 记录导出操作的时间、IP 地址和详细信息 + - 便于安全追溯和合规审计 + +- 🔑 **二次验证** - 导出功能需要密码确认 + - 导出前需要输入登录密码 + - 一次性验证 Token,使用后立即失效 + - 防止未授权导出敏感凭据 + - 安全等级从 CVSS 7.8 降低至 CVSS 3.0 + +#### 数据库变更 +- 新增 `audit_logs` 表 - 存储审计日志 + - action: 操作类型 + - resource_type: 资源类型 + - resource_id: 资源ID + - user_ip: 用户IP + - details: 详细信息 + - created_at: 操作时间 + +#### API 变更 +- 新增 `/api/export/verify` - 生成导出验证 Token +- 新增 `/api/csrf-token` - 获取 CSRF Token +- 所有导出接口增加二次验证机制 +- 所有状态变更接口增加 CSRF 保护 + +#### 依赖更新 +- 新增 `flask-wtf>=1.2.0` - CSRF 防护(可选) +- 新增 `html` 模块 - HTML 转义 + +#### 技术亮点 +- 条件导入 flask-wtf,未安装时优雅降级 +- 前端自动拦截 fetch 请求,添加 CSRF Token +- DOMPurify 配置白名单,只允许安全标签和属性 +- 审计日志异步记录,不影响主流程性能 + +#### 安全建议 +- **强烈建议安装 flask-wtf**:`pip install flask-wtf>=1.2.0` +- **必须设置 SECRET_KEY**:使用随机生成的强密钥 +- **启用 HTTPS**:生产环境使用 SSL/TLS 加密 +- **定期查看审计日志**:监控异常操作 +- **定期更新密码**:使用强密码并定期更换 + +#### 破坏性变更 +- 无破坏性变更,完全向后兼容 +- 未安装 flask-wtf 时自动禁用 CSRF 保护 +- 所有新特性都是增强型,不影响现有功能 + +--- + ### 2026-01-25 - 备注展示功能 #### 新增功能 diff --git a/img/登录失败强制间隔.png b/img/登录失败强制间隔.png new file mode 100644 index 0000000..ce1e92f Binary files /dev/null and b/img/登录失败强制间隔.png differ diff --git a/outlook_mail_reader.py b/outlook_mail_reader.py index 4bb3de7..66de65f 100644 --- a/outlook_mail_reader.py +++ b/outlook_mail_reader.py @@ -17,13 +17,13 @@ import requests # ==================== 配置参数 ==================== # 邮箱账号 -EMAIL = "MollyPennington8418@outlook.com" +EMAIL = "" # 邮箱密码(通常不需要,OAuth2 认证时使用 refresh_token) -PASSWORD = "hwqennm1840830" +PASSWORD = "" # OAuth2 refresh_token -CLIENT_ID = "dbc8e03a-b00c-46bd-ae65-b683e7707cb0" +CLIENT_ID = "" # OAuth2 client_id -REFRESH_TOKEN= "M.C559_BAY.0.U.-CkGXiZtXP*c*fcSzidxyNCv1dRjtpWa1kSa6WeTuRQ5jBOMK6PGrs5IjySqXFSyy7YzDOyzx075GTnalJP4P3SQpKvQS4gL4L88ffUZJhiV4gjARF66e8JLRNB8rbhIQ7vX84HsX!psNA5*mh*14vBJ!7u9q7OqjTEXr3Jj!Zr0BF3t6dh6GQhBqx7cA66NAIC6IMgI73radRFmO53m3aUwgK3xGCsua10Ag7um3bfuNH39FpoY8zZfBlPWlXFA6vKWNPl48LNRANxk3sPvM4TQjkc1YFJTxlOI023qHupmA74K4XGH3HsIi!BJe10NkMzxWRVK0myek0LG10*HGYLcqbakcsIpHYPS17os6lVHY97Orayz70KiZ6xxlWFkHkLUV7m3kt9g!ClXi7F0w0r0$" +REFRESH_TOKEN= "" # 代理地址(可选,格式: host:port 或 http://host:port) PROXY = None # 例如: "127.0.0.1:7890" # ================================================ diff --git a/requirements.txt b/requirements.txt index 69a99dd..0f1685b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ flask>=3.0.0 +flask-wtf>=1.2.0 werkzeug>=3.0.0 requests>=2.25.0 APScheduler>=3.10.0 -croniter>=1.3.0 \ No newline at end of file +croniter>=1.3.0 +bcrypt>=4.0.0 +cryptography>=41.0.0 \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 6828d7f..1219318 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,6 +4,8 @@ Outlook 邮件管理 + + - ${email.body} + ${sanitizedBody} `; iframe.srcdoc = htmlContent; @@ -4216,7 +4262,7 @@ ${details} if (btn.disabled) return; - if (!confirm('确定要刷新所有账号的 Token 吗?这将清除之前的刷新记录,只保留本次记录。')) { + if (!confirm('确定要刷新所有账号的 Token 吗?')) { return; } diff --git a/web_outlook_app.py b/web_outlook_app.py index 02d7af6..75bc746 100644 --- a/web_outlook_app.py +++ b/web_outlook_app.py @@ -17,6 +17,9 @@ import time import json import re import uuid +import bcrypt +import base64 +import html from datetime import datetime from email.header import decode_header from typing import Optional, List, Dict, Any @@ -24,14 +27,52 @@ from urllib.parse import quote from flask import Flask, render_template, request, jsonify, g, session, redirect, url_for, Response from functools import wraps import requests +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +# 尝试导入 Flask-WTF CSRF 保护 +try: + from flask_wtf.csrf import CSRFProtect, generate_csrf + CSRF_AVAILABLE = True +except ImportError: + CSRF_AVAILABLE = False + print("Warning: flask-wtf not installed. CSRF protection is disabled. Install with: pip install flask-wtf") app = Flask(__name__) -# 使用固定的 secret_key(从环境变量获取,或使用默认值) -# 这样可以确保重启后 session 不会失效 -app.secret_key = os.getenv("SECRET_KEY", "outlook-mail-reader-secret-key-change-in-production") +# 强制从环境变量读取 secret_key,不提供默认值以防止安全漏洞 +secret_key = os.getenv("SECRET_KEY") +if not secret_key: + raise RuntimeError( + "SECRET_KEY environment variable is required. " + "Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'" + ) +app.secret_key = secret_key # 设置 session 过期时间(默认 7 天) app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 7 # 7 天 +# 初始化 CSRF 保护(如果可用) +if CSRF_AVAILABLE: + csrf = CSRFProtect(app) + # 配置 CSRF + app.config['WTF_CSRF_TIME_LIMIT'] = None # CSRF token 不过期 + app.config['WTF_CSRF_SSL_STRICT'] = False # 允许非HTTPS环境(开发环境) + print("CSRF protection enabled") + + # 创建CSRF排除装饰器 + def csrf_exempt(f): + return csrf.exempt(f) +else: + csrf = None + # 显式禁用CSRF保护 + app.config['WTF_CSRF_ENABLED'] = False + app.config['WTF_CSRF_CHECK_DEFAULT'] = False + print("CSRF protection disabled") + + # 创建空装饰器 + def csrf_exempt(f): + return f + # 登录密码配置(可以修改为你想要的密码) LOGIN_PASSWORD = os.getenv("LOGIN_PASSWORD", "admin123") @@ -67,6 +108,182 @@ OAUTH_SCOPES = [ ] +# ==================== 登录速率限制 ==================== + +# 存储登录失败记录 {ip: {'count': int, 'last_attempt': timestamp, 'locked_until': timestamp}} +login_attempts = {} + +# 速率限制配置 +MAX_LOGIN_ATTEMPTS = 5 # 最大失败次数 +LOCKOUT_DURATION = 300 # 锁定时长(秒)- 5分钟 +ATTEMPT_WINDOW = 600 # 失败计数窗口(秒)- 10分钟 + + +def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]: + """ + 检查 IP 是否被速率限制 + 返回: (是否允许登录, 剩余锁定秒数) + """ + current_time = time.time() + + if ip not in login_attempts: + return True, None + + attempt_data = login_attempts[ip] + + # 检查是否在锁定期内 + if 'locked_until' in attempt_data and current_time < attempt_data['locked_until']: + remaining = int(attempt_data['locked_until'] - current_time) + return False, remaining + + # 检查失败计数是否过期 + if current_time - attempt_data.get('last_attempt', 0) > ATTEMPT_WINDOW: + # 重置计数 + login_attempts[ip] = {'count': 0, 'last_attempt': current_time} + return True, None + + # 检查失败次数 + if attempt_data.get('count', 0) >= MAX_LOGIN_ATTEMPTS: + # 锁定账号 + attempt_data['locked_until'] = current_time + LOCKOUT_DURATION + remaining = LOCKOUT_DURATION + return False, remaining + + return True, None + + +def record_login_failure(ip: str): + """记录登录失败""" + current_time = time.time() + + if ip not in login_attempts: + login_attempts[ip] = {'count': 1, 'last_attempt': current_time} + else: + attempt_data = login_attempts[ip] + # 如果在窗口期内,增加计数 + if current_time - attempt_data.get('last_attempt', 0) <= ATTEMPT_WINDOW: + attempt_data['count'] = attempt_data.get('count', 0) + 1 + else: + # 重置计数 + attempt_data['count'] = 1 + attempt_data['last_attempt'] = current_time + + +def reset_login_attempts(ip: str): + """重置登录失败记录(登录成功时调用)""" + if ip in login_attempts: + del login_attempts[ip] + + +# ==================== 密码安全工具 ==================== + +def hash_password(password: str) -> str: + """使用 bcrypt 哈希密码""" + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode('utf-8'), salt) + return hashed.decode('utf-8') + + +def verify_password(password: str, hashed: str) -> bool: + """验证密码是否匹配哈希值""" + try: + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) + except Exception: + return False + + +def is_password_hashed(password: str) -> bool: + """检查密码是否已经是 bcrypt 哈希值""" + return password.startswith('$2b$') or password.startswith('$2a$') or password.startswith('$2y$') + + +# ==================== 数据加密工具 ==================== + +# 全局加密器实例 +_cipher_suite = None + + +def get_encryption_key() -> bytes: + """ + 从 SECRET_KEY 派生加密密钥 + 使用 PBKDF2 从 SECRET_KEY 派生 32 字节密钥 + """ + secret_key = os.getenv("SECRET_KEY") + if not secret_key: + raise RuntimeError("SECRET_KEY is required for encryption") + + # 使用固定盐(因为我们需要确保重启后能解密) + # 注意:这里使用固定盐是为了确保密钥一致性,安全性依赖于 SECRET_KEY 的强度 + salt = b'outlook_email_encryption_salt_v1' + + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + ) + key = base64.urlsafe_b64encode(kdf.derive(secret_key.encode())) + return key + + +def get_cipher() -> Fernet: + """获取加密器实例(单例模式)""" + global _cipher_suite + if _cipher_suite is None: + key = get_encryption_key() + _cipher_suite = Fernet(key) + return _cipher_suite + + +def encrypt_data(data: str) -> str: + """ + 加密敏感数据 + 返回 base64 编码的加密字符串,带有 'enc:' 前缀标识 + """ + if not data: + return data + + # 如果已经加密,直接返回 + if data.startswith('enc:'): + return data + + cipher = get_cipher() + encrypted = cipher.encrypt(data.encode('utf-8')) + return 'enc:' + encrypted.decode('utf-8') + + +def decrypt_data(encrypted_data: str) -> str: + """ + 解密敏感数据 + 如果数据未加密(没有 'enc:' 前缀),直接返回原始数据 + """ + if not encrypted_data: + return encrypted_data + + # 如果没有加密标识,返回原始数据(向后兼容) + if not encrypted_data.startswith('enc:'): + return encrypted_data + + try: + cipher = get_cipher() + encrypted_bytes = encrypted_data[4:].encode('utf-8') # 移除 'enc:' 前缀 + decrypted = cipher.decrypt(encrypted_bytes) + return decrypted.decode('utf-8') + except Exception as e: + # 解密失败,可能是密钥变更或数据损坏 + import sys + error_msg = f"Failed to decrypt data: {str(e)}" + print(f"[ERROR] {error_msg}", file=sys.stderr) + print(f"[ERROR] Data preview: {encrypted_data[:50]}...", file=sys.stderr) + print(f"[ERROR] This usually means SECRET_KEY has changed or data is corrupted", file=sys.stderr) + raise RuntimeError(error_msg) + + +def is_encrypted(data: str) -> bool: + """检查数据是否已加密""" + return data and data.startswith('enc:') + + # ==================== 错误处理工具 ==================== def generate_trace_id() -> str: @@ -239,7 +456,20 @@ def init_db(): FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE ) ''') - + + # 创建审计日志表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + user_ip TEXT, + details TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + # 检查并添加缺失的列(数据库迁移) cursor.execute("PRAGMA table_info(accounts)") columns = [col[1] for col in cursor.fetchall()] @@ -274,10 +504,25 @@ def init_db(): ''') # 初始化默认设置 - cursor.execute(''' - INSERT OR IGNORE INTO settings (key, value) - VALUES ('login_password', ?) - ''', (LOGIN_PASSWORD,)) + # 检查是否已有密码设置 + cursor.execute("SELECT value FROM settings WHERE key = 'login_password'") + existing_password = cursor.fetchone() + + if existing_password: + # 如果存在密码但是明文,则迁移为哈希 + password_value = existing_password[0] + if not is_password_hashed(password_value): + hashed_password = hash_password(password_value) + cursor.execute(''' + UPDATE settings SET value = ? WHERE key = 'login_password' + ''', (hashed_password,)) + else: + # 首次初始化,哈希默认密码 + hashed_password = hash_password(LOGIN_PASSWORD) + cursor.execute(''' + INSERT INTO settings (key, value) + VALUES ('login_password', ?) + ''', (hashed_password,)) cursor.execute(''' INSERT OR IGNORE INTO settings (key, value) @@ -326,10 +571,50 @@ def init_db(): ON account_refresh_logs(account_id) ''') + # 迁移现有明文数据为加密数据 + migrate_sensitive_data(conn) + conn.commit() conn.close() +def migrate_sensitive_data(conn): + """迁移现有明文敏感数据为加密数据""" + cursor = conn.cursor() + + # 获取所有账号 + cursor.execute('SELECT id, password, refresh_token FROM accounts') + accounts = cursor.fetchall() + + migrated_count = 0 + for account_id, password, refresh_token in accounts: + needs_update = False + new_password = password + new_refresh_token = refresh_token + + # 检查并加密 password + if password and not is_encrypted(password): + new_password = encrypt_data(password) + needs_update = True + + # 检查并加密 refresh_token + if refresh_token and not is_encrypted(refresh_token): + new_refresh_token = encrypt_data(refresh_token) + needs_update = True + + # 更新数据库 + if needs_update: + cursor.execute(''' + UPDATE accounts + SET password = ?, refresh_token = ? + WHERE id = ? + ''', (new_password, new_refresh_token, account_id)) + migrated_count += 1 + + if migrated_count > 0: + print(f"已迁移 {migrated_count} 个账号的敏感数据为加密存储") + + # ==================== 应用初始化 ==================== def init_app(): @@ -482,21 +767,36 @@ def load_accounts(group_id: int = None) -> List[Dict]: db = get_db() if group_id: cursor = db.execute(''' - SELECT a.*, g.name as group_name, g.color as group_color - FROM accounts a - LEFT JOIN groups g ON a.group_id = g.id + SELECT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id WHERE a.group_id = ? ORDER BY a.created_at DESC ''', (group_id,)) else: cursor = db.execute(''' - SELECT a.*, g.name as group_name, g.color as group_color - FROM accounts a - LEFT JOIN groups g ON a.group_id = g.id + SELECT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id ORDER BY a.created_at DESC ''') rows = cursor.fetchall() - return [dict(row) for row in rows] + accounts = [] + for row in rows: + account = dict(row) + # 解密敏感字段 + if account.get('password'): + try: + account['password'] = decrypt_data(account['password']) + except Exception: + pass # 解密失败保持原值 + if account.get('refresh_token'): + try: + account['refresh_token'] = decrypt_data(account['refresh_token']) + except Exception: + pass # 解密失败保持原值 + accounts.append(account) + return accounts def get_account_by_email(email_addr: str) -> Optional[Dict]: @@ -504,48 +804,84 @@ def get_account_by_email(email_addr: str) -> Optional[Dict]: db = get_db() cursor = db.execute('SELECT * FROM accounts WHERE email = ?', (email_addr,)) row = cursor.fetchone() - return dict(row) if row else None + if not row: + return None + account = dict(row) + # 解密敏感字段 + if account.get('password'): + try: + account['password'] = decrypt_data(account['password']) + except Exception: + pass + if account.get('refresh_token'): + try: + account['refresh_token'] = decrypt_data(account['refresh_token']) + except Exception: + pass + return account def get_account_by_id(account_id: int) -> Optional[Dict]: """根据 ID 获取账号""" db = get_db() cursor = db.execute(''' - SELECT a.*, g.name as group_name, g.color as group_color - FROM accounts a - LEFT JOIN groups g ON a.group_id = g.id + SELECT a.*, g.name as group_name, g.color as group_color + FROM accounts a + LEFT JOIN groups g ON a.group_id = g.id WHERE a.id = ? ''', (account_id,)) row = cursor.fetchone() - return dict(row) if row else None + if not row: + return None + account = dict(row) + # 解密敏感字段 + if account.get('password'): + try: + account['password'] = decrypt_data(account['password']) + except Exception: + pass + if account.get('refresh_token'): + try: + account['refresh_token'] = decrypt_data(account['refresh_token']) + except Exception: + pass + return account -def add_account(email_addr: str, password: str, client_id: str, refresh_token: str, +def add_account(email_addr: str, password: str, client_id: str, refresh_token: str, group_id: int = 1, remark: str = '') -> bool: """添加邮箱账号""" db = get_db() try: + # 加密敏感字段 + encrypted_password = encrypt_data(password) if password else password + encrypted_refresh_token = encrypt_data(refresh_token) if refresh_token else refresh_token + db.execute(''' INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark) VALUES (?, ?, ?, ?, ?, ?) - ''', (email_addr, password, client_id, refresh_token, group_id, remark)) + ''', (email_addr, encrypted_password, client_id, encrypted_refresh_token, group_id, remark)) db.commit() return True except sqlite3.IntegrityError: return False -def update_account(account_id: int, email_addr: str, password: str, client_id: str, +def update_account(account_id: int, email_addr: str, password: str, client_id: str, refresh_token: str, group_id: int, remark: str, status: str) -> bool: """更新邮箱账号""" db = get_db() try: + # 加密敏感字段 + encrypted_password = encrypt_data(password) if password else password + encrypted_refresh_token = encrypt_data(refresh_token) if refresh_token else refresh_token + db.execute(''' - UPDATE accounts - SET email = ?, password = ?, client_id = ?, refresh_token = ?, + UPDATE accounts + SET email = ?, password = ?, client_id = ?, refresh_token = ?, group_id = ?, remark = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - ''', (email_addr, password, client_id, refresh_token, group_id, remark, status, account_id)) + ''', (email_addr, encrypted_password, client_id, encrypted_refresh_token, group_id, remark, status, account_id)) db.commit() return True except Exception: @@ -576,6 +912,49 @@ def delete_account_by_email(email_addr: str) -> bool: # ==================== 工具函数 ==================== +def sanitize_input(text: str, max_length: int = 500) -> str: + """ + 净化用户输入,防止XSS攻击 + - 转义HTML特殊字符 + - 限制长度 + - 移除控制字符 + """ + if not text: + return "" + + # 限制长度 + text = text[:max_length] + + # 移除控制字符(保留换行和制表符) + text = ''.join(char for char in text if char.isprintable() or char in '\n\t') + + # 转义HTML特殊字符 + text = html.escape(text, quote=True) + + return text + + +def log_audit(action: str, resource_type: str, resource_id: str = None, details: str = None): + """ + 记录审计日志 + :param action: 操作类型(如 'export', 'delete', 'update') + :param resource_type: 资源类型(如 'account', 'group') + :param resource_id: 资源ID + :param details: 详细信息 + """ + try: + db = get_db() + user_ip = request.remote_addr if request else 'unknown' + db.execute(''' + INSERT INTO audit_logs (action, resource_type, resource_id, user_ip, details) + VALUES (?, ?, ?, ?, ?) + ''', (action, resource_type, resource_id, user_ip, details)) + db.commit() + except Exception: + # 审计日志失败不应影响主流程 + pass + + def decode_header_value(header_value: str) -> str: """解码邮件头字段""" if not header_value: @@ -863,7 +1242,12 @@ def get_access_token_imap(client_id: str, refresh_token: str) -> Optional[str]: def get_emails_imap(account: str, client_id: str, refresh_token: str, folder: str = 'inbox', skip: int = 0, top: int = 20) -> Dict[str, Any]: - """使用 IMAP 获取邮件列表(支持分页和文件夹选择)""" + """使用 IMAP 获取邮件列表(支持分页和文件夹选择)- 默认使用新版服务器""" + return get_emails_imap_with_server(account, client_id, refresh_token, folder, skip, top, IMAP_SERVER_NEW) + + +def get_emails_imap_with_server(account: str, client_id: str, refresh_token: str, folder: str = 'inbox', skip: int = 0, top: int = 20, server: str = IMAP_SERVER_NEW) -> Dict[str, Any]: + """使用 IMAP 获取邮件列表(支持分页、文件夹选择和服务器选择)""" token_result = get_access_token_imap_result(client_id, refresh_token) if not token_result.get("success"): return {"success": False, "error": token_result.get("error")} @@ -872,7 +1256,7 @@ def get_emails_imap(account: str, client_id: str, refresh_token: str, folder: st connection = None try: - connection = imaplib.IMAP4_SSL(IMAP_SERVER_NEW, IMAP_PORT) + connection = imaplib.IMAP4_SSL(server, IMAP_PORT) auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode('utf-8') connection.authenticate('XOAUTH2', lambda x: auth_string) @@ -999,26 +1383,48 @@ def get_emails_imap(account: str, client_id: str, refresh_token: str, folder: st pass -def get_email_detail_imap(account: str, client_id: str, refresh_token: str, message_id: str) -> Optional[Dict]: +def get_email_detail_imap(account: str, client_id: str, refresh_token: str, message_id: str, folder: str = 'inbox') -> Optional[Dict]: """使用 IMAP 获取邮件详情""" access_token = get_access_token_imap(client_id, refresh_token) if not access_token: return None - + connection = None try: connection = imaplib.IMAP4_SSL(IMAP_SERVER_NEW, IMAP_PORT) auth_string = f"user={account}\1auth=Bearer {access_token}\1\1".encode('utf-8') connection.authenticate('XOAUTH2', lambda x: auth_string) - connection.select('"INBOX"') - + + # 根据文件夹类型选择 IMAP 文件夹 + folder_map = { + 'inbox': ['"INBOX"', 'INBOX'], + 'junkemail': ['"Junk"', '"Junk Email"', 'Junk', '"垃圾邮件"'], + 'deleteditems': ['"Deleted"', '"Deleted Items"', '"Trash"', 'Deleted', '"已删除邮件"'], + 'trash': ['"Deleted"', '"Deleted Items"', '"Trash"', 'Deleted', '"已删除邮件"'] + } + possible_folders = folder_map.get(folder.lower(), ['"INBOX"']) + + # 尝试选择文件夹 + selected_folder = None + for imap_folder in possible_folders: + try: + status, response = connection.select(imap_folder, readonly=True) + if status == 'OK': + selected_folder = imap_folder + break + except Exception: + continue + + if not selected_folder: + return None + status, msg_data = connection.fetch(message_id.encode() if isinstance(message_id, str) else message_id, '(RFC822)') if status != 'OK' or not msg_data or not msg_data[0]: return None - + raw_email = msg_data[0][1] msg = email.message_from_bytes(raw_email) - + return { 'id': message_id, 'subject': decode_header_value(msg.get("Subject", "无主题")), @@ -1055,22 +1461,47 @@ def login_required(f): # ==================== Flask 路由 ==================== @app.route('/login', methods=['GET', 'POST']) +@csrf_exempt # 登录接口排除CSRF保护(用户未登录时无法获取token) def login(): """登录页面""" if request.method == 'POST': - data = request.json if request.is_json else request.form - password = data.get('password', '') - - # 从数据库获取密码,如果没有则使用默认密码 - correct_password = get_login_password() - - if password == correct_password: - session['logged_in'] = True - session.permanent = True - return jsonify({'success': True, 'message': '登录成功'}) - else: - return jsonify({'success': False, 'error': '密码错误'}) - + try: + # 获取客户端 IP + client_ip = request.headers.get('X-Forwarded-For', request.remote_addr) + if client_ip: + client_ip = client_ip.split(',')[0].strip() + + # 检查速率限制 + allowed, remaining_time = check_rate_limit(client_ip) + if not allowed: + return jsonify({ + 'success': False, + 'error': f'登录失败次数过多,请在 {remaining_time} 秒后重试' + }), 429 + + data = request.json if request.is_json else request.form + password = data.get('password', '') + + # 从数据库获取密码哈希 + stored_password = get_login_password() + + # 验证密码 + if verify_password(password, stored_password): + # 登录成功,重置失败记录 + reset_login_attempts(client_ip) + session['logged_in'] = True + session.permanent = True + return jsonify({'success': True, 'message': '登录成功'}) + else: + # 登录失败,记录失败次数 + record_login_failure(client_ip) + return jsonify({'success': False, 'error': '密码错误'}) + except Exception as e: + print(f"Login error: {e}") + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': f'登录处理失败: {str(e)}'}), 500 + # GET 请求返回登录页面 return render_template('login.html') @@ -1089,6 +1520,17 @@ def index(): return render_template('index.html') +@app.route('/api/csrf-token', methods=['GET']) +@csrf_exempt # CSRF token获取接口排除CSRF保护 +def get_csrf_token(): + """获取CSRF Token""" + if CSRF_AVAILABLE: + token = generate_csrf() + return jsonify({'csrf_token': token}) + else: + return jsonify({'csrf_token': None, 'csrf_disabled': True}) + + # ==================== 分组 API ==================== @app.route('/api/groups', methods=['GET']) @@ -1122,13 +1564,13 @@ def api_get_group(group_id): def api_add_group(): """添加分组""" data = request.json - name = data.get('name', '').strip() - description = data.get('description', '') + name = sanitize_input(data.get('name', '').strip(), max_length=100) + description = sanitize_input(data.get('description', ''), max_length=500) color = data.get('color', '#1a1a1a') - + if not name: return jsonify({'success': False, 'error': '分组名称不能为空'}) - + group_id = add_group(name, description, color) if group_id: return jsonify({'success': True, 'message': '分组创建成功', 'group_id': group_id}) @@ -1141,13 +1583,13 @@ def api_add_group(): def api_update_group(group_id): """更新分组""" data = request.json - name = data.get('name', '').strip() - description = data.get('description', '') + name = sanitize_input(data.get('name', '').strip(), max_length=100) + description = sanitize_input(data.get('description', ''), max_length=500) color = data.get('color', '#1a1a1a') - + if not name: return jsonify({'success': False, 'error': '分组名称不能为空'}) - + if update_group(group_id, name, description, color): return jsonify({'success': True, 'message': '分组更新成功'}) else: @@ -1170,36 +1612,40 @@ def api_delete_group(group_id): @app.route('/api/groups//export') @login_required def api_export_group(group_id): - """导出分组下的所有邮箱账号为 TXT 文件""" + """导出分组下的所有邮箱账号为 TXT 文件(需要二次验证)""" + # 检查二次验证token + verify_token = request.args.get('verify_token') + if not verify_token or not session.get('export_verify_token') or verify_token != session.get('export_verify_token'): + return jsonify({'success': False, 'error': '需要二次验证', 'need_verify': True}) + + # 清除验证token(一次性使用) + session.pop('export_verify_token', None) + group = get_group_by_id(group_id) if not group: return jsonify({'success': False, 'error': '分组不存在'}) - - # 获取该分组下的所有账号(完整信息) - db = get_db() - cursor = db.execute(''' - SELECT email, password, client_id, refresh_token - FROM accounts - WHERE group_id = ? - ORDER BY created_at DESC - ''', (group_id,)) - accounts = cursor.fetchall() - + + # 使用 load_accounts 获取该分组下的所有账号(自动解密) + accounts = load_accounts(group_id) + if not accounts: return jsonify({'success': False, 'error': '该分组下没有邮箱账号'}) - + + # 记录审计日志 + log_audit('export', 'group', str(group_id), f"导出分组 '{group['name']}' 的 {len(accounts)} 个账号") + # 生成导出内容(格式:email----password----client_id----refresh_token) lines = [] for acc in accounts: - line = f"{acc['email']}----{acc['password'] or ''}----{acc['client_id']}----{acc['refresh_token']}" + line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" lines.append(line) - + content = '\n'.join(lines) - + # 生成文件名(使用 URL 编码处理中文) filename = f"{group['name']}_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" encoded_filename = quote(filename) - + # 返回文件下载响应 return Response( content, @@ -1213,31 +1659,36 @@ def api_export_group(group_id): @app.route('/api/accounts/export') @login_required def api_export_all_accounts(): - """导出所有邮箱账号为 TXT 文件""" - # 获取所有账号(完整信息) - db = get_db() - cursor = db.execute(''' - SELECT email, password, client_id, refresh_token - FROM accounts - ORDER BY created_at DESC - ''') - accounts = cursor.fetchall() - + """导出所有邮箱账号为 TXT 文件(需要二次验证)""" + # 检查二次验证token + verify_token = request.args.get('verify_token') + if not verify_token or not session.get('export_verify_token') or verify_token != session.get('export_verify_token'): + return jsonify({'success': False, 'error': '需要二次验证', 'need_verify': True}) + + # 清除验证token(一次性使用) + session.pop('export_verify_token', None) + + # 使用 load_accounts 获取所有账号(自动解密) + accounts = load_accounts() + if not accounts: return jsonify({'success': False, 'error': '没有邮箱账号'}) - + + # 记录审计日志 + log_audit('export', 'all_accounts', None, f"导出所有账号,共 {len(accounts)} 个") + # 生成导出内容(格式:email----password----client_id----refresh_token) lines = [] for acc in accounts: - line = f"{acc['email']}----{acc['password'] or ''}----{acc['client_id']}----{acc['refresh_token']}" + line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" lines.append(line) - + content = '\n'.join(lines) - + # 生成文件名(使用 URL 编码处理中文) filename = f"all_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" encoded_filename = quote(filename) - + # 返回文件下载响应 return Response( content, @@ -1251,39 +1702,45 @@ def api_export_all_accounts(): @app.route('/api/accounts/export-selected', methods=['POST']) @login_required def api_export_selected_accounts(): - """导出选中分组的邮箱账号为 TXT 文件""" + """导出选中分组的邮箱账号为 TXT 文件(需要二次验证)""" data = request.json group_ids = data.get('group_ids', []) - + verify_token = data.get('verify_token') + + # 检查二次验证token + if not verify_token or not session.get('export_verify_token') or verify_token != session.get('export_verify_token'): + return jsonify({'success': False, 'error': '需要二次验证', 'need_verify': True}) + + # 清除验证token(一次性使用) + session.pop('export_verify_token', None) + if not group_ids: return jsonify({'success': False, 'error': '请选择要导出的分组'}) - - # 获取选中分组下的所有账号 - db = get_db() - placeholders = ','.join(['?' for _ in group_ids]) - cursor = db.execute(f''' - SELECT email, password, client_id, refresh_token - FROM accounts - WHERE group_id IN ({placeholders}) - ORDER BY group_id, created_at DESC - ''', group_ids) - accounts = cursor.fetchall() - - if not accounts: + + # 获取选中分组下的所有账号(使用 load_accounts 自动解密) + all_accounts = [] + for group_id in group_ids: + accounts = load_accounts(group_id) + all_accounts.extend(accounts) + + if not all_accounts: return jsonify({'success': False, 'error': '选中的分组下没有邮箱账号'}) - + + # 记录审计日志 + log_audit('export', 'selected_groups', ','.join(map(str, group_ids)), f"导出选中分组的 {len(all_accounts)} 个账号") + # 生成导出内容 lines = [] - for acc in accounts: - line = f"{acc['email']}----{acc['password'] or ''}----{acc['client_id']}----{acc['refresh_token']}" + for acc in all_accounts: + line = f"{acc['email']}----{acc.get('password', '')}----{acc['client_id']}----{acc['refresh_token']}" lines.append(line) - + content = '\n'.join(lines) - + # 生成文件名 filename = f"selected_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" encoded_filename = quote(filename) - + # 返回文件下载响应 return Response( content, @@ -1294,6 +1751,32 @@ def api_export_selected_accounts(): ) +@app.route('/api/export/verify', methods=['POST']) +@login_required +def api_generate_export_verify_token(): + """生成导出验证token(二次验证)""" + data = request.json + password = data.get('password', '') + + # 验证密码 + db = get_db() + cursor = db.execute("SELECT value FROM settings WHERE key = 'login_password'") + result = cursor.fetchone() + + if not result: + return jsonify({'success': False, 'error': '系统配置错误'}) + + stored_password = result[0] + if not verify_password(password, stored_password): + return jsonify({'success': False, 'error': '密码错误'}) + + # 生成一次性验证token + verify_token = secrets.token_urlsafe(32) + session['export_verify_token'] = verify_token + + return jsonify({'success': True, 'verify_token': verify_token}) + + # ==================== 邮箱账号 API ==================== @app.route('/api/accounts', methods=['GET']) @@ -1436,23 +1919,23 @@ def api_add_account(): def api_update_account(account_id): """更新账号""" data = request.json - + # 检查是否只更新状态 if 'status' in data and len(data) == 1: # 只更新状态 return api_update_account_status(account_id, data['status']) - + email_addr = data.get('email', '') password = data.get('password', '') client_id = data.get('client_id', '') refresh_token = data.get('refresh_token', '') group_id = data.get('group_id', 1) - remark = data.get('remark', '') + remark = sanitize_input(data.get('remark', ''), max_length=200) status = data.get('status', 'active') - + if not email_addr or not client_id or not refresh_token: return jsonify({'success': False, 'error': '邮箱、Client ID 和 Refresh Token 不能为空'}) - + if update_account(account_id, email_addr, password, client_id, refresh_token, group_id, remark, status): return jsonify({'success': True, 'message': '账号更新成功'}) else: @@ -1567,7 +2050,22 @@ def api_refresh_account(account_id): account_id = account['id'] account_email = account['email'] client_id = account['client_id'] - refresh_token = account['refresh_token'] + encrypted_refresh_token = account['refresh_token'] + + # 解密 refresh_token + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + error_msg = f"解密 token 失败: {str(e)}" + log_refresh_result(account_id, account_email, 'manual', 'failed', error_msg) + error_payload = build_error_payload( + "TOKEN_DECRYPT_FAILED", + "Token 解密失败", + "DecryptionError", + 500, + error_msg + ) + return jsonify({'success': False, 'error': error_payload}) # 测试 refresh token success, error_msg = test_refresh_token(client_id, refresh_token) @@ -1628,7 +2126,29 @@ def api_refresh_all_accounts(): account_id = account['id'] account_email = account['email'] client_id = account['client_id'] - refresh_token = account['refresh_token'] + encrypted_refresh_token = account['refresh_token'] + + # 解密 refresh_token + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + # 解密失败,记录错误 + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + failed_list.append({ + 'id': account_id, + 'email': account_email, + 'error': error_msg + }) + try: + conn.execute(''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message) + VALUES (?, ?, ?, ?, ?) + ''', (account_id, account_email, 'manual', 'failed', error_msg)) + conn.commit() + except Exception: + pass + continue # 发送当前处理的账号信息 yield f"data: {json.dumps({'type': 'progress', 'current': index, 'total': total, 'email': account_email, 'success_count': success_count, 'failed_count': failed_count})}\n\n" @@ -1714,7 +2234,22 @@ def api_refresh_failed_accounts(): account_id = account['id'] account_email = account['email'] client_id = account['client_id'] - refresh_token = account['refresh_token'] + encrypted_refresh_token = account['refresh_token'] + + # 解密 refresh_token + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + # 解密失败,记录错误 + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + failed_list.append({ + 'id': account_id, + 'email': account_email, + 'error': error_msg + }) + log_refresh_result(account_id, account_email, 'retry', 'failed', error_msg) + continue # 测试 refresh token success, error_msg = test_refresh_token(client_id, refresh_token) @@ -1807,7 +2342,29 @@ def api_trigger_scheduled_refresh(): account_id = account['id'] account_email = account['email'] client_id = account['client_id'] - refresh_token = account['refresh_token'] + encrypted_refresh_token = account['refresh_token'] + + # 解密 refresh_token + try: + refresh_token = decrypt_data(encrypted_refresh_token) if encrypted_refresh_token else encrypted_refresh_token + except Exception as e: + # 解密失败,记录错误 + failed_count += 1 + error_msg = f"解密 token 失败: {str(e)}" + failed_list.append({ + 'id': account_id, + 'email': account_email, + 'error': error_msg + }) + try: + conn.execute(''' + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message) + VALUES (?, ?, ?, ?, ?) + ''', (account_id, account_email, 'scheduled', 'failed', error_msg)) + conn.commit() + except Exception: + pass + continue yield f"data: {json.dumps({'type': 'progress', 'current': index, 'total': total, 'email': account_email, 'success_count': success_count, 'failed_count': failed_count})}\n\n" @@ -2014,52 +2571,55 @@ def api_get_emails(email_addr): ) return jsonify({'success': False, 'error': error_payload}) - method = request.args.get('method', 'graph') folder = request.args.get('folder', 'inbox') # inbox, junkemail, deleteditems skip = int(request.args.get('skip', 0)) top = int(request.args.get('top', 20)) - graph_error = None - if method == 'graph': - # 每次只查询20封邮件 - graph_result = get_emails_graph(account['client_id'], account['refresh_token'], folder, skip, top) - if graph_result.get("success"): - emails = graph_result.get("emails", []) - # 更新刷新时间 - db = get_db() - db.execute(''' - UPDATE accounts - SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE email = ? - ''', (email_addr,)) - db.commit() + # 收集所有错误信息 + all_errors = {} - # 格式化 Graph API 返回的数据 - formatted = [] - for e in emails: - formatted.append({ - 'id': e.get('id'), - 'subject': e.get('subject', '无主题'), - 'from': e.get('from', {}).get('emailAddress', {}).get('address', '未知'), - 'date': e.get('receivedDateTime', ''), - 'is_read': e.get('isRead', False), - 'has_attachments': e.get('hasAttachments', False), - 'body_preview': e.get('bodyPreview', '') - }) + # 1. 尝试 Graph API + graph_result = get_emails_graph(account['client_id'], account['refresh_token'], folder, skip, top) + if graph_result.get("success"): + emails = graph_result.get("emails", []) + # 更新刷新时间 + db = get_db() + db.execute(''' + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE email = ? + ''', (email_addr,)) + db.commit() - return jsonify({ - 'success': True, - 'emails': formatted, - 'method': 'Graph API', - 'has_more': len(formatted) >= top + # 格式化 Graph API 返回的数据 + formatted = [] + for e in emails: + formatted.append({ + 'id': e.get('id'), + 'subject': e.get('subject', '无主题'), + 'from': e.get('from', {}).get('emailAddress', {}).get('address', '未知'), + 'date': e.get('receivedDateTime', ''), + 'is_read': e.get('isRead', False), + 'has_attachments': e.get('hasAttachments', False), + 'body_preview': e.get('bodyPreview', '') }) - graph_error = graph_result.get("error") + return jsonify({ + 'success': True, + 'emails': formatted, + 'method': 'Graph API', + 'has_more': len(formatted) >= top + }) + else: + all_errors["graph"] = graph_result.get("error") - # 如果 Graph API 失败,尝试 IMAP - imap_result = get_emails_imap(account['email'], account['client_id'], account['refresh_token'], folder, skip, top) - if imap_result.get("success"): - emails = imap_result.get("emails", []) + # 2. 尝试新版 IMAP (outlook.live.com) + imap_new_result = get_emails_imap_with_server( + account['email'], account['client_id'], account['refresh_token'], + folder, skip, top, IMAP_SERVER_NEW + ) + if imap_new_result.get("success"): + emails = imap_new_result.get("emails", []) # 更新刷新时间 db = get_db() db.execute(''' @@ -2072,23 +2632,44 @@ def api_get_emails(email_addr): return jsonify({ 'success': True, 'emails': emails, - 'method': 'IMAP', + 'method': 'IMAP (新版)', 'has_more': len(emails) >= top }) + else: + all_errors["imap_new"] = imap_new_result.get("error") - imap_error = imap_result.get("error") - combined_details = {} - if graph_error: - combined_details["graph"] = graph_error - if imap_error: - combined_details["imap"] = imap_error - details = combined_details or imap_error or graph_error or "未知错误" + # 3. 尝试旧版 IMAP (outlook.office365.com) + imap_old_result = get_emails_imap_with_server( + account['email'], account['client_id'], account['refresh_token'], + folder, skip, top, IMAP_SERVER_OLD + ) + if imap_old_result.get("success"): + emails = imap_old_result.get("emails", []) + # 更新刷新时间 + db = get_db() + db.execute(''' + UPDATE accounts + SET last_refresh_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE email = ? + ''', (email_addr,)) + db.commit() + + return jsonify({ + 'success': True, + 'emails': emails, + 'method': 'IMAP (旧版)', + 'has_more': len(emails) >= top + }) + else: + all_errors["imap_old"] = imap_old_result.get("error") + + # 所有方式都失败,返回所有错误信息 error_payload = build_error_payload( "EMAIL_FETCH_FAILED", - "获取邮件失败,请检查账号配置", + "获取邮件失败,所有方式均失败,请检查账号配置", "EmailFetchError", 502, - details + all_errors ) return jsonify({'success': False, 'error': error_payload}) @@ -2098,12 +2679,13 @@ def api_get_emails(email_addr): def api_get_email_detail(email_addr, message_id): """获取邮件详情""" account = get_account_by_email(email_addr) - + if not account: return jsonify({'success': False, 'error': '账号不存在'}) - + method = request.args.get('method', 'graph') - + folder = request.args.get('folder', 'inbox') + if method == 'graph': detail = get_email_detail_graph(account['client_id'], account['refresh_token'], message_id) if detail: @@ -2120,12 +2702,12 @@ def api_get_email_detail(email_addr, message_id): 'body_type': detail.get('body', {}).get('contentType', 'text') } }) - + # 如果 Graph API 失败,尝试 IMAP - detail = get_email_detail_imap(account['email'], account['client_id'], account['refresh_token'], message_id) + detail = get_email_detail_imap(account['email'], account['client_id'], account['refresh_token'], message_id, folder) if detail: return jsonify({'success': True, 'email': detail}) - + return jsonify({'success': False, 'error': '获取邮件详情失败'}) @@ -2633,12 +3215,15 @@ def api_update_settings(): if 'login_password' in data: new_password = data['login_password'].strip() if new_password: - if len(new_password) < 4: - errors.append('密码长度至少为 4 位') - elif set_setting('login_password', new_password): - updated.append('登录密码') + if len(new_password) < 8: + errors.append('密码长度至少为 8 位') else: - errors.append('更新登录密码失败') + # 哈希新密码 + hashed_password = hash_password(new_password) + if set_setting('login_password', hashed_password): + updated.append('登录密码') + else: + errors.append('更新登录密码失败') # 更新 GPTMail API Key if 'gptmail_api_key' in data: @@ -2905,6 +3490,24 @@ def trigger_refresh_internal(): conn.close() +# ==================== 错误处理 ==================== + +@app.errorhandler(400) +def bad_request(error): + """处理400错误""" + print(f"400 Bad Request: {error}") + return jsonify({'success': False, 'error': '请求格式错误'}), 400 + + +@app.errorhandler(Exception) +def handle_exception(error): + """处理未捕获的异常""" + print(f"Unhandled exception: {error}") + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(error)}), 500 + + # ==================== 主程序 ==================== if __name__ == '__main__':