This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/auth_system/mail.py
T
snowy-yang 96af56cc27 refactor: auth_system 全面异步化(aiosqlite),拆掉 web/services 层的 to_thread 桥接
拆掉两类垫片的最后一处:auth_system 原为同步 sqlite3 栈,web 层与 services 的
worker 只能靠 asyncio.to_thread 逐个桥接才不阻塞事件循环——每次 DB 访问都白付一次
线程池调度,且「同步调异步」的边界散落在 100+ 个调用点。B1 已把 worker 变成协程,
本批把访问层本身也迁到原生异步,两条线在此合拢。

改动(按子系统):
- auth_system:db.py 照 core/db.py 现有范式重建为原生异步访问层(aiosqlite + WAL)
  ——读路径 _q 每次独立连接、无锁、可并发;写路径 _x / write_transaction 用
  asyncio.Lock 串行化,写者不阻塞读者。users / cards / credits / ledger / logs /
  settings / mail / geo / backup 九模块随之全部 async/await;security.py 是纯计算
  (pbkdf2/normalize)不动。__init__ 去掉 _DB_LOCK 与 get_conn 导出。
- web:app / deps / billing / tasks_registry 与 routers/{admin_site,auth,music,
  register,user} 直接 await,拆掉包裹的 to_thread。deps.secret_key 改 async,
  连带 _parse_cookie 也 async(会话解析要读密钥)。
- services:_taskcore / tasks / downloads / podcast 的账本调用与查询直接 await。
  4 处 start_task 调用点(download.py)确认已 await。

设计取舍(为什么这么做,而不是直接包一层):
- 锁按**事件循环**隔离(_LOOP_LOCKS 以 id(loop) 为键):asyncio.Lock 绑定创建它的
  loop,应用主 loop 与测试各自的 loop 并存,跨 loop 复用会抛 "bound to a different
  event loop"。
- WAL 切换用**独立锁** _wal_lock 而非 _write_lock:execute() 已持写锁,再在锁内
  connect→_configure 会自锁死(asyncio.Lock 非重入)。切换 + 标记在专用锁内串行并
  二次复查,否则并发首连会同时执行 PRAGMA journal_mode=WAL 争排他锁报
  database is locked(core/db.py 同款教训,实测复现)。
- force_wal 保留:journal_mode 持久化在库文件头,库文件被删重建时新文件不继承,
  必须重设。exists() 判定须在 aiosqlite.connect **之前**(connect 会创建空文件)。
- connect() 用 DB_PATH.parent 兜底建目录,优于 DATA_DIR:生产两者相同,但测试
  monkeypatch 只换 DB_PATH 时兜底仍能命中正确目录。

留在 to_thread 上的仅剩**正当的阻塞卸载**,不是同步门面:PBKDF2 的 10 万次迭代
(登录链路每次都要跑)、smtplib 发信、备份文件的 copy2 / os.replace、secret.key
的文件 IO。原有 aria2 / downloader / zip 侧的 to_thread 未动。

不变量逐条保持(迁移只改执行模型,不改语义):
- ledger_settle 幂等:账本非 open 直接 return 0,重启后重放不重复退款;
- 终态先到先得:_mark_task_terminal 对已 done/error/canceled 的任务直接 return,
  堵住 canceled→error 的非法迁移边;
- settle 必须在任务锁**外**调用(ledger_settle 要抢 SQLite 写锁,busy 最长 15s,
  持任务锁会让全站进度/提交/取消随库停摆);
- reserve_and_open 的预扣与开账同事务,消除原两步之间的崩溃丢账窗口。

路径真源红线:DATA_DIR / DB_PATH / BACKUP_DIR / SECRET_KEY_PATH 仍是 auth_system.db
的**模块全局**(用与 core.db 相同的表达式,但**不是**它的别名)。测试只 patch
auth_system.db.*,若改成 core.db 的别名会静默把测试写到生产库。已核 data/site.db
读写前后 sha256 完全一致。
顺带清掉 get_conn:迁移后它只剩「转调 connect()」一层空壳、全仓零消费者(测试
直接开裸连接、备份用 db.connect()),保留等于留一条「拿裸连接自己写」的口子。

测试:19 个文件同步迁移——10 个文件的 web.deps.secret_key 替身改 async;
test_db_conn.py 的多线程首连用例按异步语义拆成 2 条(单 loop gather(16) 覆盖批量
并发首连、WAL 就绪后 8 线程各自 loop 建连覆盖跨 loop);test_billing_scope.py 去掉
多余的 @pytest.mark.anyio;被 await 且**正常返回**的 monkeypatch 替身一律改 async
(仅 raise 的同步替身仍可用)。

门禁:ruff 全过 / pyright 0-0-0(standard 模式含 tests 与顶层模块)/ pytest
384 项全绿(0 失败 0 error 0 skipped;383→384 因 test_db_conn 拆用例)。
data/site.db 读写前后 sha256 一致(a19f5db…6487),无 -wal/-shm 边车残留。

下一步 B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py 及
 tests/test_async_bridge.py——三者在生产侧已零消费者(http_errors 仅 sync_http
 自用,requests 已非声明依赖)。
2026-09-28 20:13:19 +08:00

270 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""SMTP 配置、发信与邮箱验证码。
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步,
阻塞的 smtplib 发信经 asyncio.to_thread 卸载。)
"""
import asyncio
import base64
import re
import secrets
import smtplib
import sqlite3
import ssl
import time
from email.header import Header
from email.mime.text import MIMEText
from email.utils import formataddr
from typing import Any
from .db import (
_q,
_x,
get_secret_key,
write_transaction,
)
from .security import (
_normalize_email,
)
# ============ 邮箱验证码 / SMTP ============
# 验证码猜错计数(内存态,重启清零可接受)——同邮箱累计错 5 次作废验证码
# 键='purpose:任意邮箱' 无上限可被慢速灌大——超限清最旧
_CODE_FAILS: dict = {}
_CODE_FAILS_MAX = 5000
def _code_fails_trim() -> None:
"""超上限时按写入序丢弃最旧的一半(仅防内存无界,不承担安全语义)"""
if len(_CODE_FAILS) > _CODE_FAILS_MAX:
for k in list(_CODE_FAILS.keys())[:len(_CODE_FAILS) // 2]:
_CODE_FAILS.pop(k, None)
_SMTP_ENC_PREFIX = 'enc1:' # 信封加密标记前缀(存量明文读取时透明升级)
async def _smtp_password_encrypt(plaintext: str) -> str:
"""用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码。
原实现明文落库、并随数据库备份一起导出,等于扩大泄露半径。"""
import hashlib as _hl
from cryptography.fernet import Fernet
key = _hl.sha256((await get_secret_key()).encode('utf-8')).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return _SMTP_ENC_PREFIX + f.encrypt(plaintext.encode('utf-8')).decode('utf-8')
async def _smtp_password_decrypt(ciphertext: str) -> str:
if not ciphertext.startswith(_SMTP_ENC_PREFIX):
return ciphertext # 存量明文:透明兼容,下次保存时升级为密文
import hashlib as _hl
from cryptography.fernet import Fernet
key = _hl.sha256((await get_secret_key()).encode('utf-8')).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return f.decrypt(ciphertext[len(_SMTP_ENC_PREFIX):].encode('utf-8')).decode('utf-8')
async def get_smtp_config() -> dict[str, Any]:
rows = await _q('SELECT * FROM smtp_config WHERE id = 1')
cfg = dict(rows[0]) if rows else {'host': '', 'port': 587, 'sender': '', 'password': ''}
# 读取时解密密码——调用方(SMTP 连接/后台展示)拿到的始终是明文
if cfg.get('password'):
try:
cfg['password'] = await _smtp_password_decrypt(cfg['password'])
except Exception:
cfg['password'] = ''
return cfg
async def save_smtp_config(host: str, port: int, sender: str, password: str) -> None:
enc_pwd = await _smtp_password_encrypt(password) if password else ''
await _x('UPDATE smtp_config SET host = ?, port = ?, sender = ?, password = ?, updated_at = ? WHERE id = 1',
(host, int(port or 587), sender, enc_pwd, int(time.time())))
async def smtp_configured() -> bool:
cfg = await get_smtp_config()
return bool(cfg['host'] and cfg['sender'] and cfg['password'])
def _smtp_connect(cfg: dict[str, Any]) -> smtplib.SMTP:
"""连接并登录 SMTP(**阻塞网络 IO**):只应由 asyncio.to_thread 调用。"""
port = int(cfg['port'] or 587)
# 显式校验证书与主机名的 TLS 上下文(不依赖各运行时默认值)
ctx = ssl.create_default_context()
if port == 465:
server = smtplib.SMTP_SSL(cfg['host'], port, timeout=15, context=ctx)
else:
server = smtplib.SMTP(cfg['host'], port, timeout=15)
server.starttls(context=ctx)
server.login(cfg['sender'], cfg['password'])
return server
def _smtp_send(cfg: dict[str, Any], sender: str, to: str, raw: str) -> None:
"""同步发信(阻塞网络 IO):只应由 asyncio.to_thread 调用。"""
with _smtp_connect(cfg) as server:
server.sendmail(sender, [to], raw)
async def send_email(to: str, subject: str, body: str) -> None:
"""收件人格式校验下沉到本入口——未来任何新调用方漏校验也不再成为
CRLF/信封注入点(现有调用点的校验仅作 UX 提示)。"""
to = _normalize_email(to)
if not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', to, re.ASCII):
raise ValueError('收件邮箱格式不正确')
cfg = await get_smtp_config()
msg = MIMEText(body, 'plain', 'utf-8')
# email 库推荐写法:Header 对象由 Generator._fold 的 Header 分支做 RFC 2047 编码
# (typeshed 把 Message.__setitem__ 的值参数收窄成 str,属桩过窄;运行时已实测正确)
msg['Subject'] = Header(subject, 'utf-8') # type: ignore[assignment]
msg['From'] = formataddr(('云村小助手', cfg['sender']))
msg['To'] = to
# smtplib 同步发信是阻塞网络 IO,卸载到线程(正当的阻塞网络 IO 卸载,非同步门面)
await asyncio.to_thread(_smtp_send, cfg, cfg['sender'], to, msg.as_string())
_EMAIL_SENT_24H: dict = {} # 同邮箱 24h 发送计数(内存态,重启清零可接受)
# 验证码用途隔离——注册码不能用于找回密码,反之亦然(库行带 purpose,
# 消费/作废/还原均按用途匹配)。邮件主题与正文按用途区分,用户一眼可辨。
# 新增 admin2fa(管理员两步验证)——发码前置条件是密码已验证正确,
# 故豁免 24h 5 封与 60s 冷却(否则管理员每天最多登录 5 次会被自己锁死);
# 滥用面由"票据仅密码正确时签发 + 票据尝试上限 + 登录限流"约束
_CODE_PURPOSES = ('register', 'reset', 'admin2fa')
_CODE_EMAIL_TEMPLATES = {
'register': ('云村小助手 注册验证码',
'您正在注册云村小助手账号。\n\n'
'验证码:{code}\n\n'
'验证码 10 分钟内有效,请勿泄露给他人。\n'
'如非本人操作,请忽略本邮件。'),
'reset': ('云村小助手 找回密码验证码',
'您正在找回云村小助手账号的登录密码。\n\n'
'验证码:{code}\n\n'
'验证码 10 分钟内有效,仅用于重置密码,请勿泄露给他人。\n'
'如非本人操作,请忽略本邮件(您的密码不会被修改)。'),
'admin2fa': ('云村小助手 管理员登录验证码',
'您正在登录云村小助手管理员账号(两步验证第二步)。\n\n'
'验证码:{code}\n\n'
'验证码 10 分钟内有效,请勿泄露给任何人。\n'
'如非本人操作,请立即修改管理员密码并检查邮箱安全。'),
}
async def send_verify_code(email: str, purpose: str = 'register') -> None:
"""生成并发送邮箱验证码(10 分钟有效)(先发信成功再落库;同用途旧码即时作废)。
① 同邮箱 24h 硬上限 5 封(防邮件轰炸代理/发信配额消耗);
② email 先归一化(Gmail +tag 别名剥离)——别名刷号被邮箱查重堵死。
purpose 区分注册/找回(文案不同 + 库行用途隔离);24h 计数移到
发信成功之后(原实现 SMTP 失败也烧掉额度)。"""
if purpose not in _CODE_PURPOSES:
raise ValueError('验证码用途不正确')
email = _normalize_email(email or '')
# re.ASCII——\w 的 Unicode 语义会放行"中文邮箱@例子.com"这类
# 非 ASCII 地址(SMTP 阶段才失败,路由兜底成 500);加 254 长度上限(RFC 口径,
# 原实现 5000 字符邮箱可入库)
if len(email) > 254 or not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', email, re.ASCII):
raise ValueError('邮箱格式不正确')
if not await smtp_configured():
raise RuntimeError('邮箱服务未配置,请联系管理员')
# 同邮箱 24h ≤5 封(内存计数,不再依赖会被消费 DELETE 清零的 DB 行数);
# admin2fa 豁免 24h 上限与 60s 冷却(见 _CODE_PURPOSES 处注释)
now_ts = time.time()
sent = [t for t in _EMAIL_SENT_24H.get(email, []) if now_ts - t < 86400]
if purpose != 'admin2fa' and len(sent) >= 5:
raise RuntimeError('该邮箱验证码发送已达上限(24 小时 5 封),请明日再试')
# 60 秒内限制重发(按同邮箱同用途的最近一封计)
if purpose != 'admin2fa':
rows = await _q('SELECT created_at FROM email_codes WHERE email = ? AND purpose = ? ORDER BY id DESC LIMIT 1',
(email, purpose))
if rows and time.time() - rows[0]['created_at'] < 60:
raise RuntimeError('发送过于频繁,请 1 分钟后再试')
code = f'{secrets.randbelow(1000000):06d}'
subject, body_tpl = _CODE_EMAIL_TEMPLATES[purpose]
# 先发送:SMTP 失败不留任何有效验证码与冷却记录;成功后才计入 24h 额度(admin2fa 不计数)
await send_email(email, subject, body_tpl.format(code=code))
if purpose != 'admin2fa':
sent.append(now_ts)
_EMAIL_SENT_24H[email] = sent
# 键只增不减(旧时间戳被过滤但空键留存)——顺手剔除过期
# 空键并做容量上限,防慢速灌任意格式邮箱造成无界增长
if len(_EMAIL_SENT_24H) > 5000:
for k in [k for k, v in _EMAIL_SENT_24H.items() if not v][:2500]:
_EMAIL_SENT_24H.pop(k, None)
if len(_EMAIL_SENT_24H) > 5000:
for k in list(_EMAIL_SENT_24H.keys())[:len(_EMAIL_SENT_24H) // 2]:
_EMAIL_SENT_24H.pop(k, None)
async with write_transaction() as conn:
# 新码生效同时作废同邮箱同用途旧码;顺手清理全表过期码
await conn.execute('DELETE FROM email_codes WHERE (email = ? AND purpose = ?) OR expires_at <= ?',
(email, purpose, int(time.time())))
await conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)',
(email, code, int(time.time() + 600), int(time.time()), purpose))
async def check_verify_code(email: str, code: str, purpose: str = 'register') -> int:
"""校验验证码(单条 DELETE 原子消费,rowcount=1 才算通过,杜绝并发双读)。
成功返回剩余有效秒数(真值),失败返回 0。
同一邮箱累计错 5 次→该用途验证码作废(防 10 分钟窗口内挂机猜码)。
WHERE 带 purpose——注册码与找回码互不可用。"""
if purpose not in _CODE_PURPOSES:
return 0
email = _normalize_email(email or '')
fail_key = purpose + ':' + email # 失败计数按"用途:邮箱"隔离,互不连带
row = None
try:
async with write_transaction() as conn:
cur = await conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ? '
'RETURNING expires_at',
(email, (code or '').strip(), purpose, int(time.time())))
row = await cur.fetchone()
except sqlite3.OperationalError as _oe:
# 老版本 SQLite 无 RETURNING:回退为删除后判 rowcount。
# 仅"不支持 RETURNING"走回退——库忙(database is locked)原样
# 上抛,否则第一次等满 busy 15s、回退再等 15s,全程持锁冻结全站写路径
if 'RETURNING' not in str(_oe) and 'syntax' not in str(_oe).lower():
raise
async with write_transaction() as conn:
cur = await conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ?',
(email, (code or '').strip(), purpose, int(time.time())))
if cur.rowcount == 1:
_CODE_FAILS.pop(fail_key, None)
return 60
row = None
if row:
_CODE_FAILS.pop(fail_key, None)
return max(0, int(row[0]) - int(time.time()))
# 猜错:失败计数 +1,累计 5 次直接作废该用途的验证码
n = _CODE_FAILS.get(fail_key, 0) + 1
_CODE_FAILS[fail_key] = n
_code_fails_trim()
if n >= 5:
_CODE_FAILS.pop(fail_key, None)
try:
await _x('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose))
except Exception:
pass
return 0
async def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str = 'register') -> None:
"""注册流程后续步骤失败时还原已消费的验证码(第二轮:建号失败不应吞掉验证码)"""
if ttl_seconds <= 0:
return
if purpose not in _CODE_PURPOSES:
return
email = _normalize_email(email or '')
code = (code or '').strip()
if not email or not code:
return
await _x('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)',
(email, code, int(time.time() + ttl_seconds), int(time.time()), purpose))