From d259630f59e7378ae0809d6c98c5ae37205f6675 Mon Sep 17 00:00:00 2001 From: assast Date: Mon, 26 Jan 2026 21:58:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=89=E5=85=A8=E6=BC=8F?= =?UTF-8?q?=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 23 + .gitignore | 3 +- README.md | 282 +++++++++++- img/登录失败强制间隔.png | Bin 0 -> 36965 bytes outlook_mail_reader.py | 8 +- requirements.txt | 5 +- templates/index.html | 84 +++- web_outlook_app.py | 953 ++++++++++++++++++++++++++++++++------- 8 files changed, 1146 insertions(+), 212 deletions(-) create mode 100644 .env.example create mode 100644 img/登录失败强制间隔.png 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 0000000000000000000000000000000000000000..ce1e92f1c8f73a372fb7728954a3ed211e06325a GIT binary patch literal 36965 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 literal 0 HcmV?d00001 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__':