Spaces:
Running
Running
Upload 94 files
Browse files- app.py +6 -9
- models.py +4 -4
- router_users_auth.py +51 -56
- runtime_log_privacy.py +51 -0
- verify_code_engine.py +23 -24
app.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
| 1 |
# ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
|
|
|
|
|
|
|
|
|
|
| 2 |
from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
|
| 3 |
from rate_limit import shared_limit
|
| 4 |
from response_privacy import requires_no_store
|
|
@@ -106,7 +109,7 @@ def create_error_response(status_code: int, detail: str, error_type: str = None,
|
|
| 106 |
@app.exception_handler(StarletteHTTPException)
|
| 107 |
async def http_exception_handler(request, exc):
|
| 108 |
"""🚀 P3优化:HTTP 异常处理,返回统一格式"""
|
| 109 |
-
logger.warning(f"HTTP {exc.status_code} | {request
|
| 110 |
|
| 111 |
# 根据状态码确定错误类型
|
| 112 |
error_type_map = {
|
|
@@ -147,13 +150,7 @@ async def private_response_cache_control(request: Request, call_next):
|
|
| 147 |
@app.exception_handler(RequestValidationError)
|
| 148 |
async def validation_exception_handler(request, exc):
|
| 149 |
"""🚀 P3优化:请求参数校验失败处理"""
|
| 150 |
-
|
| 151 |
-
# Keep only field paths and machine error codes in public runtime logs.
|
| 152 |
-
error_fields = [
|
| 153 |
-
{"field": ".".join(str(loc) for loc in err.get("loc", ())[1:]), "type": err.get("type", "unknown")}
|
| 154 |
-
for err in exc.errors()
|
| 155 |
-
]
|
| 156 |
-
logger.warning(f"Validation Error | {request.url.path} | fields={error_fields}")
|
| 157 |
|
| 158 |
# 提取简洁的错误信息
|
| 159 |
errors = []
|
|
@@ -175,7 +172,7 @@ async def validation_exception_handler(request, exc):
|
|
| 175 |
async def global_exception_handler(request, exc):
|
| 176 |
"""🚀 P3优化:全局异常处理,捕获所有未预期异常"""
|
| 177 |
error_id = f"ERR_{int(time.time())}_{id(exc) % 10000}"
|
| 178 |
-
logger.error(f"Unhandled Exception [{error_id}] | {request
|
| 179 |
return JSONResponse(
|
| 180 |
status_code=500,
|
| 181 |
content=create_error_response(
|
|
|
|
| 1 |
# ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
|
| 2 |
+
from runtime_log_privacy import install_uvicorn_privacy_filters, route_label
|
| 3 |
+
install_uvicorn_privacy_filters()
|
| 4 |
+
|
| 5 |
from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
|
| 6 |
from rate_limit import shared_limit
|
| 7 |
from response_privacy import requires_no_store
|
|
|
|
| 109 |
@app.exception_handler(StarletteHTTPException)
|
| 110 |
async def http_exception_handler(request, exc):
|
| 111 |
"""🚀 P3优化:HTTP 异常处理,返回统一格式"""
|
| 112 |
+
logger.warning(f"HTTP {exc.status_code} | {route_label(request)} | detail_type={type(exc.detail).__name__}")
|
| 113 |
|
| 114 |
# 根据状态码确定错误类型
|
| 115 |
error_type_map = {
|
|
|
|
| 150 |
@app.exception_handler(RequestValidationError)
|
| 151 |
async def validation_exception_handler(request, exc):
|
| 152 |
"""🚀 P3优化:请求参数校验失败处理"""
|
| 153 |
+
logger.warning("Validation Error | %s | count=%d", route_label(request), len(exc.errors()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
# 提取简洁的错误信息
|
| 156 |
errors = []
|
|
|
|
| 172 |
async def global_exception_handler(request, exc):
|
| 173 |
"""🚀 P3优化:全局异常处理,捕获所有未预期异常"""
|
| 174 |
error_id = f"ERR_{int(time.time())}_{id(exc) % 10000}"
|
| 175 |
+
logger.error(f"Unhandled Exception [{error_id}] | {route_label(request)} | error_type={type(exc).__name__}")
|
| 176 |
return JSONResponse(
|
| 177 |
status_code=500,
|
| 178 |
content=create_error_response(
|
models.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# models.py
|
| 2 |
import re
|
| 3 |
from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
|
| 4 |
-
from typing import Optional, List
|
| 5 |
|
| 6 |
def _validate_email_format(email: str) -> str:
|
| 7 |
"""邮箱格式校验"""
|
|
@@ -18,9 +18,9 @@ def _validate_password_length(password: str) -> str:
|
|
| 18 |
return password
|
| 19 |
|
| 20 |
class SendCodeRequest(BaseModel):
|
| 21 |
-
contact: str
|
| 22 |
-
contact_type:
|
| 23 |
-
action_type:
|
| 24 |
account: Optional[str] = None
|
| 25 |
|
| 26 |
class UserRegister(BaseModel):
|
|
|
|
| 1 |
# models.py
|
| 2 |
import re
|
| 3 |
from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
|
| 4 |
+
from typing import Optional, List, Literal
|
| 5 |
|
| 6 |
def _validate_email_format(email: str) -> str:
|
| 7 |
"""邮箱格式校验"""
|
|
|
|
| 18 |
return password
|
| 19 |
|
| 20 |
class SendCodeRequest(BaseModel):
|
| 21 |
+
contact: str = Field(..., min_length=1, max_length=254)
|
| 22 |
+
contact_type: Literal["email", "phone"]
|
| 23 |
+
action_type: Literal["register", "reset", "recover", "withdraw"]
|
| 24 |
account: Optional[str] = None
|
| 25 |
|
| 26 |
class UserRegister(BaseModel):
|
router_users_auth.py
CHANGED
|
@@ -15,7 +15,7 @@ from rate_limit import shared_limit, store_verification_code, consume_verificati
|
|
| 15 |
import asyncio
|
| 16 |
import time
|
| 17 |
import re
|
| 18 |
-
import
|
| 19 |
import json
|
| 20 |
import logging
|
| 21 |
import 数据库连接 as db
|
|
@@ -36,6 +36,35 @@ logger = logging.getLogger("ComfyUI-Ranking.UsersAuth")
|
|
| 36 |
router = APIRouter()
|
| 37 |
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# ==========================================
|
| 40 |
# 📤 发送验证码接口(异步后台任务版)
|
| 41 |
# ==========================================
|
|
@@ -54,38 +83,14 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac
|
|
| 54 |
- action_type: "register" 注册 / "reset" 重置密码 / "recover" 找回账号
|
| 55 |
- account: (仅重置密码时必填) 用户账号
|
| 56 |
"""
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
users_db = db.load_data("users.json", default_data={})
|
| 60 |
-
matched = any(
|
| 61 |
-
u.get("email") == req.contact or u.get("phone") == req.contact
|
| 62 |
-
for u in users_db.values()
|
| 63 |
-
)
|
| 64 |
-
if not matched:
|
| 65 |
-
# 🛡️ 防枚举:未注册的联系方式也返回通用成功,但不实际发送验证码
|
| 66 |
-
return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
|
| 67 |
-
|
| 68 |
-
# 如果是重置密码,需要先验证账号与联系方式匹配
|
| 69 |
-
if req.action_type == "reset":
|
| 70 |
-
if not req.account:
|
| 71 |
-
raise HTTPException(status_code=400, detail="找回密码需先填写当前账号")
|
| 72 |
-
|
| 73 |
-
users_db = db.load_data("users.json", default_data={})
|
| 74 |
-
user = users_db.get(req.account)
|
| 75 |
-
if not user:
|
| 76 |
-
raise HTTPException(status_code=404, detail="该账号不存在")
|
| 77 |
-
|
| 78 |
-
# 校验联系方式是否与账号绑定的一致
|
| 79 |
-
if req.contact_type == "email" and user.get("email") != req.contact:
|
| 80 |
-
raise HTTPException(status_code=400, detail="填写的邮箱与该账号绑定的邮箱不一致")
|
| 81 |
-
if req.contact_type == "phone" and user.get("phone") != req.contact:
|
| 82 |
-
raise HTTPException(status_code=400, detail="填写的手机号与该账号绑定的手机号不一致")
|
| 83 |
|
| 84 |
# 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
|
| 85 |
check_send_cooldown(req.contact)
|
| 86 |
|
| 87 |
# 生成6位随机验证码
|
| 88 |
-
code = str(
|
| 89 |
|
| 90 |
# 🚀 P1性能优化:每次写入前触发清理过期验证码
|
| 91 |
cleanup_expired_codes()
|
|
@@ -116,32 +121,20 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac
|
|
| 116 |
@shared_limit(5) # 🔒 安全限流:与异步版对齐,每分钟最多5次
|
| 117 |
async def send_code_api(request: Request, req: SendCodeRequest):
|
| 118 |
"""发送验证码接口(同步版本,直接等待发送结果)"""
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
users_db = db.load_data("users.json", default_data={})
|
| 122 |
-
matched = any(
|
| 123 |
-
u.get("email") == req.contact or u.get("phone") == req.contact
|
| 124 |
-
for u in users_db.values()
|
| 125 |
-
)
|
| 126 |
-
if not matched:
|
| 127 |
-
# 🛡️ 防枚举:未注册的联系方式也返回通用成功,但不实际发送验证码
|
| 128 |
-
return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
|
| 129 |
|
| 130 |
# 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
|
| 131 |
check_send_cooldown(req.contact)
|
| 132 |
|
| 133 |
# 生成6位随机验证码
|
| 134 |
-
code = str(
|
| 135 |
# 🔒 P0安全加固:使用冒号分隔符防止跨操作重用验证码
|
| 136 |
key = f"{req.contact}:{req.action_type}"
|
| 137 |
|
| 138 |
# 存入缓存,有效期5分钟
|
| 139 |
await asyncio.to_thread(store_verification_code, key, code, int(time.time()) + 300)
|
| 140 |
|
| 141 |
-
# 同步发送(会阻塞等待结果)
|
| 142 |
-
# ⚡ 性能修复:send_email_code 内部走 SMTP 同步连接(最长可阻塞十秒级),
|
| 143 |
-
# 直接在 async 处理器里调用会占死唯一的事件循环,导致整个服务期间无法响应任何请求;
|
| 144 |
-
# 改由 asyncio.to_thread 丢到线程池执行,等待结果的同时不阻塞其他请求。
|
| 145 |
if req.contact_type == "email":
|
| 146 |
try:
|
| 147 |
await asyncio.to_thread(send_email_code, req.contact, code, req.action_type)
|
|
@@ -149,10 +142,15 @@ async def send_code_api(request: Request, req: SendCodeRequest):
|
|
| 149 |
except Exception as e:
|
| 150 |
# 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
|
| 151 |
logger.error(f"验证码邮件发送失败: action={req.action_type}, error_type={type(e).__name__}")
|
| 152 |
-
raise HTTPException(status_code=
|
| 153 |
|
| 154 |
elif req.contact_type == "phone":
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
else:
|
| 158 |
raise HTTPException(status_code=400, detail="不支持的验证方式")
|
|
@@ -187,10 +185,12 @@ async def recover_account(request: Request):
|
|
| 187 |
if not isinstance(data, dict):
|
| 188 |
raise HTTPException(status_code=400, detail="请求数据格式异常")
|
| 189 |
|
| 190 |
-
email =
|
| 191 |
-
code =
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
| 194 |
raise HTTPException(status_code=400, detail="缺失必要参数 (邮箱/验证码)")
|
| 195 |
|
| 196 |
# ========== 第二步:验证码校验(原子性一次性消费,并发安全) ==========
|
|
@@ -200,12 +200,7 @@ async def recover_account(request: Request):
|
|
| 200 |
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 201 |
|
| 202 |
# ========== 第三步:按邮箱反查账号 ==========
|
| 203 |
-
|
| 204 |
-
matched_account = None
|
| 205 |
-
for account, user in users_db.items():
|
| 206 |
-
if user.get("email") == email:
|
| 207 |
-
matched_account = account
|
| 208 |
-
break
|
| 209 |
|
| 210 |
if not matched_account:
|
| 211 |
# 理论上验证码已验证邮箱存在,此处为防御性分支
|
|
@@ -215,7 +210,7 @@ async def recover_account(request: Request):
|
|
| 215 |
"status": "success",
|
| 216 |
"message": "账号找回成功",
|
| 217 |
"account": matched_account,
|
| 218 |
-
"name":
|
| 219 |
}
|
| 220 |
|
| 221 |
|
|
|
|
| 15 |
import asyncio
|
| 16 |
import time
|
| 17 |
import re
|
| 18 |
+
import secrets
|
| 19 |
import json
|
| 20 |
import logging
|
| 21 |
import 数据库连接 as db
|
|
|
|
| 36 |
router = APIRouter()
|
| 37 |
|
| 38 |
|
| 39 |
+
def _find_contact_account(contact, contact_type):
|
| 40 |
+
try:
|
| 41 |
+
users = db.load_data("users.json", default_data={}, strict=True)
|
| 42 |
+
if not isinstance(users, dict):
|
| 43 |
+
raise ValueError("invalid_users")
|
| 44 |
+
for account, user in users.items():
|
| 45 |
+
if not isinstance(account, str) or not isinstance(user, dict):
|
| 46 |
+
raise ValueError("invalid_user")
|
| 47 |
+
if user.get(contact_type) == contact:
|
| 48 |
+
return account, user
|
| 49 |
+
return None, None
|
| 50 |
+
except Exception as error:
|
| 51 |
+
logger.warning("账号联系方式读取失败: error_type=%s", type(error).__name__)
|
| 52 |
+
raise HTTPException(status_code=503, detail="用户数据暂不可用") from None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def _check_code_contact(req):
|
| 56 |
+
if req.action_type == "recover":
|
| 57 |
+
account, _ = await asyncio.to_thread(_find_contact_account, req.contact, req.contact_type)
|
| 58 |
+
return account is not None
|
| 59 |
+
if req.action_type == "reset":
|
| 60 |
+
if not req.account:
|
| 61 |
+
raise HTTPException(status_code=400, detail="找回密码需先填写当前账号")
|
| 62 |
+
user = await asyncio.to_thread(read_user, req.account)
|
| 63 |
+
if user.get(req.contact_type) != req.contact:
|
| 64 |
+
raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配")
|
| 65 |
+
return True
|
| 66 |
+
|
| 67 |
+
|
| 68 |
# ==========================================
|
| 69 |
# 📤 发送验证码接口(异步后台任务版)
|
| 70 |
# ==========================================
|
|
|
|
| 83 |
- action_type: "register" 注册 / "reset" 重置密码 / "recover" 找回账号
|
| 84 |
- account: (仅重置密码时必填) 用户账号
|
| 85 |
"""
|
| 86 |
+
if not await _check_code_contact(req):
|
| 87 |
+
return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
# 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
|
| 90 |
check_send_cooldown(req.contact)
|
| 91 |
|
| 92 |
# 生成6位随机验证码
|
| 93 |
+
code = str(secrets.randbelow(900000) + 100000)
|
| 94 |
|
| 95 |
# 🚀 P1性能优化:每次写入前触发清理过期验证码
|
| 96 |
cleanup_expired_codes()
|
|
|
|
| 121 |
@shared_limit(5) # 🔒 安全限流:与异步版对齐,每分钟最多5次
|
| 122 |
async def send_code_api(request: Request, req: SendCodeRequest):
|
| 123 |
"""发送验证码接口(同步版本,直接等待发送结果)"""
|
| 124 |
+
if not await _check_code_contact(req):
|
| 125 |
+
return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
# 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
|
| 128 |
check_send_cooldown(req.contact)
|
| 129 |
|
| 130 |
# 生成6位随机验证码
|
| 131 |
+
code = str(secrets.randbelow(900000) + 100000)
|
| 132 |
# 🔒 P0安全加固:使用冒号分隔符防止跨操作重用验证码
|
| 133 |
key = f"{req.contact}:{req.action_type}"
|
| 134 |
|
| 135 |
# 存入缓存,有效期5分钟
|
| 136 |
await asyncio.to_thread(store_verification_code, key, code, int(time.time()) + 300)
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if req.contact_type == "email":
|
| 139 |
try:
|
| 140 |
await asyncio.to_thread(send_email_code, req.contact, code, req.action_type)
|
|
|
|
| 142 |
except Exception as e:
|
| 143 |
# 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
|
| 144 |
logger.error(f"验证码邮件发送失败: action={req.action_type}, error_type={type(e).__name__}")
|
| 145 |
+
raise HTTPException(status_code=503, detail="邮件发送失败,请稍后重试") from None
|
| 146 |
|
| 147 |
elif req.contact_type == "phone":
|
| 148 |
+
try:
|
| 149 |
+
await asyncio.to_thread(send_sms_code, req.contact, code, req.action_type)
|
| 150 |
+
return {"status": "success", "message": "验证码已成功发送至手机"}
|
| 151 |
+
except Exception as error:
|
| 152 |
+
logger.error("验证码短信发送失败: action=%s, error_type=%s", req.action_type, type(error).__name__)
|
| 153 |
+
raise HTTPException(status_code=503, detail="短信发送失败,请稍后重试") from None
|
| 154 |
|
| 155 |
else:
|
| 156 |
raise HTTPException(status_code=400, detail="不支持的验证方式")
|
|
|
|
| 185 |
if not isinstance(data, dict):
|
| 186 |
raise HTTPException(status_code=400, detail="请求数据格式异常")
|
| 187 |
|
| 188 |
+
email = data.get("email") or data.get("contact") or ""
|
| 189 |
+
code = data.get("code") or ""
|
| 190 |
+
if not isinstance(email, str) or not isinstance(code, str):
|
| 191 |
+
raise HTTPException(status_code=400, detail="请求参数格式不正确")
|
| 192 |
+
email, code = email.strip(), code.strip()
|
| 193 |
+
if not email or len(email) > 254 or not re.fullmatch(r"[0-9]{6}", code):
|
| 194 |
raise HTTPException(status_code=400, detail="缺失必要参数 (邮箱/验证码)")
|
| 195 |
|
| 196 |
# ========== 第二步:验证码校验(原子性一次性消费,并发安全) ==========
|
|
|
|
| 200 |
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 201 |
|
| 202 |
# ========== 第三步:按邮箱反查账号 ==========
|
| 203 |
+
matched_account, matched_user = await asyncio.to_thread(_find_contact_account, email, "email")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
if not matched_account:
|
| 206 |
# 理论上验证码已验证邮箱存在,此处为防御性分支
|
|
|
|
| 210 |
"status": "success",
|
| 211 |
"message": "账号找回成功",
|
| 212 |
"account": matched_account,
|
| 213 |
+
"name": matched_user.get("name", "")
|
| 214 |
}
|
| 215 |
|
| 216 |
|
runtime_log_privacy.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""运行服务器日志不记录请求目标、客户端标识或异常正文。"""
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def route_label(request):
|
| 6 |
+
route = request.scope.get('route')
|
| 7 |
+
label = getattr(route, 'path', None)
|
| 8 |
+
if (not isinstance(label, str) or len(label) > 256 or not label.startswith('/')
|
| 9 |
+
or any(char not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_{}.-:' for char in label)):
|
| 10 |
+
return '<unmatched>'
|
| 11 |
+
return label
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class UvicornPrivacyFilter(logging.Filter):
|
| 15 |
+
def filter(self, record):
|
| 16 |
+
if record.name == 'uvicorn.access':
|
| 17 |
+
args = record.args
|
| 18 |
+
method = version = 'unknown'
|
| 19 |
+
status = 0
|
| 20 |
+
if isinstance(args, tuple) and len(args) == 5:
|
| 21 |
+
if type(args[1]) is str and args[1] in {'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'CONNECT', 'TRACE'}:
|
| 22 |
+
method = args[1]
|
| 23 |
+
if type(args[3]) is str and args[3] in {'1.0', '1.1', '2', '2.0', '3'}:
|
| 24 |
+
version = args[3]
|
| 25 |
+
if type(args[4]) is int and 100 <= args[4] <= 599:
|
| 26 |
+
status = args[4]
|
| 27 |
+
record.msg = '%s - "%s %s HTTP/%s" %d'
|
| 28 |
+
record.args = ('[client]', method, '[request]', version, status)
|
| 29 |
+
record.exc_info = record.exc_text = record.stack_info = None
|
| 30 |
+
elif record.exc_info or record.exc_text or record.stack_info or record.levelno >= logging.ERROR:
|
| 31 |
+
error = record.exc_info[1] if isinstance(record.exc_info, tuple) and len(record.exc_info) == 3 else None
|
| 32 |
+
kind = type(error).__name__ if error is not None else 'unknown'
|
| 33 |
+
if not kind.isidentifier() or len(kind) > 80:
|
| 34 |
+
kind = 'unknown'
|
| 35 |
+
record.msg = 'SERVER_ERROR | error_type=%s'
|
| 36 |
+
record.args = (kind,)
|
| 37 |
+
record.exc_info = record.exc_text = record.stack_info = None
|
| 38 |
+
elif record.name == 'uvicorn.error' and type(record.msg) is str and record.msg in {
|
| 39 |
+
'%s - "WebSocket %s" [accepted]', '%s - "WebSocket %s" 403'
|
| 40 |
+
}:
|
| 41 |
+
record.args = ('[client]', '[request]')
|
| 42 |
+
elif record.name == 'uvicorn.error' and record.levelno < logging.DEBUG:
|
| 43 |
+
return False
|
| 44 |
+
return True
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def install_uvicorn_privacy_filters():
|
| 48 |
+
for name in ('uvicorn.access', 'uvicorn.error'):
|
| 49 |
+
logger = logging.getLogger(name)
|
| 50 |
+
if not any(isinstance(item, UvicornPrivacyFilter) for item in logger.filters):
|
| 51 |
+
logger.addFilter(UvicornPrivacyFilter())
|
verify_code_engine.py
CHANGED
|
@@ -30,8 +30,6 @@ _CLEANUP_INTERVAL = 300 # 5分钟清理一次
|
|
| 30 |
# 🔒 日志脱敏工具
|
| 31 |
# ==========================================
|
| 32 |
# 作用:HF Spaces 的运行日志对外可见,验证码与联系方式明文入日志等同于泄露账号
|
| 33 |
-
# 关联:云端/本地判据与 数据库连接.py 保持一致(HF Spaces 会注入 SPACE_ID)
|
| 34 |
-
_IS_CLOUD = bool(os.environ.get("SPACE_ID"))
|
| 35 |
|
| 36 |
|
| 37 |
def _mask_contact(contact: str) -> str:
|
|
@@ -67,12 +65,12 @@ def check_send_cooldown(contact: str):
|
|
| 67 |
|
| 68 |
|
| 69 |
def cleanup_expired_codes():
|
| 70 |
-
"""验证码已存入 SQL;清理由限流模块统一按时间间隔执行。"""
|
| 71 |
-
global _last_cleanup_time
|
| 72 |
-
now = time.time()
|
| 73 |
-
if now - _last_cleanup_time < _CLEANUP_INTERVAL:
|
| 74 |
-
return
|
| 75 |
-
_last_cleanup_time = now
|
| 76 |
|
| 77 |
def send_email_code(to_email: str, code: str, action: str):
|
| 78 |
"""
|
|
@@ -91,8 +89,7 @@ def send_email_code(to_email: str, code: str, action: str):
|
|
| 91 |
webhook_url = os.environ.get("MAKE_WEBHOOK_URL")
|
| 92 |
|
| 93 |
if not webhook_url:
|
| 94 |
-
|
| 95 |
-
return
|
| 96 |
|
| 97 |
# 根据动作类型生成不同的邮件标题
|
| 98 |
if action == "register":
|
|
@@ -133,6 +130,8 @@ def send_email_code(to_email: str, code: str, action: str):
|
|
| 133 |
)
|
| 134 |
try:
|
| 135 |
with urllib.request.urlopen(req, timeout=10) as response:
|
|
|
|
|
|
|
| 136 |
# 🔒 安全修复:不再打印验证码与邮箱明文(日志对外可见),仅留脱敏标识与动作类型
|
| 137 |
print(f"✅ 成功触发 Webhook 发送验证码邮件至 {_mask_contact(to_email)}(动作: {action})")
|
| 138 |
except Exception as e:
|
|
@@ -141,6 +140,7 @@ def send_email_code(to_email: str, code: str, action: str):
|
|
| 141 |
status = getattr(e, "code", None)
|
| 142 |
status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
|
| 143 |
print(f"❌ Webhook 触发失败: action={action}, target={_mask_contact(to_email)}, error_type={type(e).__name__}{status_text}")
|
|
|
|
| 144 |
|
| 145 |
|
| 146 |
def send_sms_code(phone: str, code: str, action: str):
|
|
@@ -175,6 +175,8 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 175 |
if twilio_sid:
|
| 176 |
token = os.environ.get("TWILIO_TOKEN")
|
| 177 |
from_phone = os.environ.get("TWILIO_FROM")
|
|
|
|
|
|
|
| 178 |
|
| 179 |
# 构建短信内容
|
| 180 |
body = f"【ComfyUI社区】您正在请求{action_str},验证码是:{code},5分钟内有效。"
|
|
@@ -188,6 +190,8 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 188 |
req.add_header("Authorization", f"Basic {auth}")
|
| 189 |
try:
|
| 190 |
with urllib.request.urlopen(req, timeout=10) as response:
|
|
|
|
|
|
|
| 191 |
print(f"✅ Twilio 短信已成功下发至 {_mask_contact(phone)}")
|
| 192 |
except Exception as e:
|
| 193 |
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
@@ -195,6 +199,7 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 195 |
status = getattr(e, "code", None)
|
| 196 |
status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
|
| 197 |
print(f"❌ Twilio 发送失败: target={_mask_contact(phone)}, error_type={type(e).__name__}{status_text}")
|
|
|
|
| 198 |
return
|
| 199 |
|
| 200 |
# ==========================================
|
|
@@ -210,14 +215,16 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 210 |
from alibabacloud_tea_openapi import models as open_api_models
|
| 211 |
from alibabacloud_dysmsapi20170525 import models as dysmsapi_models
|
| 212 |
except ImportError:
|
| 213 |
-
|
| 214 |
-
return
|
| 215 |
|
| 216 |
sk = os.environ.get("ALIYUN_SK")
|
| 217 |
sign_name = os.environ.get("ALIYUN_SIGN_NAME") # 短信签名,如 "阿里云"
|
| 218 |
# 根据动作类型选择不同的模板
|
| 219 |
tpl_code = os.environ.get("ALIYUN_TPL_REGISTER") if action == "register" else os.environ.get("ALIYUN_TPL_RESET")
|
| 220 |
|
|
|
|
|
|
|
|
|
|
| 221 |
# 初始化阿里云客户端
|
| 222 |
config = open_api_models.Config(access_key_id=aliyun_ak, access_key_secret=sk)
|
| 223 |
config.endpoint = 'dysmsapi.aliyuncs.com'
|
|
@@ -235,19 +242,11 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 235 |
if response.body.code == "OK":
|
| 236 |
print(f"✅ 阿里云短信已成功下发至 {_mask_contact(phone)}")
|
| 237 |
else:
|
| 238 |
-
|
| 239 |
-
|
| 240 |
except Exception as e:
|
| 241 |
print(f"❌ 阿里云请求异常: target={_mask_contact(phone)}, error_type={type(e).__name__}")
|
|
|
|
| 242 |
return
|
| 243 |
|
| 244 |
-
|
| 245 |
-
# 降级模式:控制台打印模拟
|
| 246 |
-
# ==========================================
|
| 247 |
-
# 作用:当没有配置任何短信服务时,仅在控制台打印验证码(仅限开发测试)
|
| 248 |
-
# 🔒 P0安全修复:云端日志对外可见,生产环境绝不能打印验证码明文;
|
| 249 |
-
# 本地开发(无 SPACE_ID)保留原行为,不影响调试便利性。
|
| 250 |
-
if _IS_CLOUD:
|
| 251 |
-
print(f"⚠️ 未配置短信密钥,验证码未实际下发(目标: {_mask_contact(phone)})")
|
| 252 |
-
else:
|
| 253 |
-
print(f"⚠️ 未配置短信密钥,模拟下发 -> 手机号: {phone}, 验证码: {code}")
|
|
|
|
| 30 |
# 🔒 日志脱敏工具
|
| 31 |
# ==========================================
|
| 32 |
# 作用:HF Spaces 的运行日志对外可见,验证码与联系方式明文入日志等同于泄露账号
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def _mask_contact(contact: str) -> str:
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def cleanup_expired_codes():
|
| 68 |
+
"""验证码已存入 SQL;清理由限流模块统一按时间间隔执行。"""
|
| 69 |
+
global _last_cleanup_time
|
| 70 |
+
now = time.time()
|
| 71 |
+
if now - _last_cleanup_time < _CLEANUP_INTERVAL:
|
| 72 |
+
return
|
| 73 |
+
_last_cleanup_time = now
|
| 74 |
|
| 75 |
def send_email_code(to_email: str, code: str, action: str):
|
| 76 |
"""
|
|
|
|
| 89 |
webhook_url = os.environ.get("MAKE_WEBHOOK_URL")
|
| 90 |
|
| 91 |
if not webhook_url:
|
| 92 |
+
raise RuntimeError("verification_delivery_unavailable")
|
|
|
|
| 93 |
|
| 94 |
# 根据动作类型生成不同的邮件标题
|
| 95 |
if action == "register":
|
|
|
|
| 130 |
)
|
| 131 |
try:
|
| 132 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 133 |
+
if not 200 <= response.status < 300:
|
| 134 |
+
raise RuntimeError("verification_delivery_unconfirmed")
|
| 135 |
# 🔒 安全修复:不再打印验证码与邮箱明文(日志对外可见),仅留脱敏标识与动作类型
|
| 136 |
print(f"✅ 成功触发 Webhook 发送验证码邮件至 {_mask_contact(to_email)}(动作: {action})")
|
| 137 |
except Exception as e:
|
|
|
|
| 140 |
status = getattr(e, "code", None)
|
| 141 |
status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
|
| 142 |
print(f"❌ Webhook 触发失败: action={action}, target={_mask_contact(to_email)}, error_type={type(e).__name__}{status_text}")
|
| 143 |
+
raise RuntimeError("verification_delivery_unconfirmed") from None
|
| 144 |
|
| 145 |
|
| 146 |
def send_sms_code(phone: str, code: str, action: str):
|
|
|
|
| 175 |
if twilio_sid:
|
| 176 |
token = os.environ.get("TWILIO_TOKEN")
|
| 177 |
from_phone = os.environ.get("TWILIO_FROM")
|
| 178 |
+
if not token or not from_phone:
|
| 179 |
+
raise RuntimeError("verification_delivery_unavailable")
|
| 180 |
|
| 181 |
# 构建短信内容
|
| 182 |
body = f"【ComfyUI社区】您正在请求{action_str},验证码是:{code},5分钟内有效。"
|
|
|
|
| 190 |
req.add_header("Authorization", f"Basic {auth}")
|
| 191 |
try:
|
| 192 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 193 |
+
if not 200 <= response.status < 300:
|
| 194 |
+
raise RuntimeError("verification_delivery_unconfirmed")
|
| 195 |
print(f"✅ Twilio 短信已成功下发至 {_mask_contact(phone)}")
|
| 196 |
except Exception as e:
|
| 197 |
status = getattr(getattr(e, "response", None), "status_code", None)
|
|
|
|
| 199 |
status = getattr(e, "code", None)
|
| 200 |
status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
|
| 201 |
print(f"❌ Twilio 发送失败: target={_mask_contact(phone)}, error_type={type(e).__name__}{status_text}")
|
| 202 |
+
raise RuntimeError("verification_delivery_unconfirmed") from None
|
| 203 |
return
|
| 204 |
|
| 205 |
# ==========================================
|
|
|
|
| 215 |
from alibabacloud_tea_openapi import models as open_api_models
|
| 216 |
from alibabacloud_dysmsapi20170525 import models as dysmsapi_models
|
| 217 |
except ImportError:
|
| 218 |
+
raise RuntimeError("verification_delivery_unavailable") from None
|
|
|
|
| 219 |
|
| 220 |
sk = os.environ.get("ALIYUN_SK")
|
| 221 |
sign_name = os.environ.get("ALIYUN_SIGN_NAME") # 短信签名,如 "阿里云"
|
| 222 |
# 根据动作类型选择不同的模板
|
| 223 |
tpl_code = os.environ.get("ALIYUN_TPL_REGISTER") if action == "register" else os.environ.get("ALIYUN_TPL_RESET")
|
| 224 |
|
| 225 |
+
if not sk or not sign_name or not tpl_code:
|
| 226 |
+
raise RuntimeError("verification_delivery_unavailable")
|
| 227 |
+
|
| 228 |
# 初始化阿里云客户端
|
| 229 |
config = open_api_models.Config(access_key_id=aliyun_ak, access_key_secret=sk)
|
| 230 |
config.endpoint = 'dysmsapi.aliyuncs.com'
|
|
|
|
| 242 |
if response.body.code == "OK":
|
| 243 |
print(f"✅ 阿里云短信已成功下发至 {_mask_contact(phone)}")
|
| 244 |
else:
|
| 245 |
+
print(f"❌ 阿里云下发失败: target={_mask_contact(phone)}")
|
| 246 |
+
raise RuntimeError("verification_delivery_unconfirmed")
|
| 247 |
except Exception as e:
|
| 248 |
print(f"❌ 阿里云请求异常: target={_mask_contact(phone)}, error_type={type(e).__name__}")
|
| 249 |
+
raise RuntimeError("verification_delivery_unconfirmed") from None
|
| 250 |
return
|
| 251 |
|
| 252 |
+
raise RuntimeError("verification_delivery_unavailable")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|