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/web/auth_state.py
T
snowy-yang 6288a98ecc refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留
大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述,
会误导后来者按不复存在的架构去改代码。

拆分(对外导入路径不变,行为零变更):
- music_downloader.py  -> music_downloader/(models/downloader/cover/_http/_log/_concurrency)
- auth_system.py       -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...)
- music_api.py         -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors)
- services/runtime.py  -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/
  streaming/tasks);services/runtime.py 保留为兼容门面

兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点
(auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性
动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。

修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字
等价」校验逐一定位,静态检查只会报出其中 4 处):
- core/config.py  ).strip() or '*'
- core/state.py   threading.Lock() x2
- qr_login.py     datetime.now() / .strip()
- music_downloader/downloader.py  .lower()
- services/podcast.py  set() / time.time() x2

清理与删除:
- 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt
- 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等
  失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明)
- _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含
- pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败

验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed;
真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
2026-09-26 12:37:48 +08:00

173 lines
6.1 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 -*-
"""认证的内存态与校验辅助(模块级状态,框架无关)。
包含:登录失败锁定、管理员 2FA 票据、发码 IP 限流、用户名/保留名校验。
这些状态**进程内共享**——登录/注册/2FA 各端点都读写同一份,避免「换个端点绕过锁定」。
"""
import secrets
import time
import auth_system as auth
# ---- 登录失败锁定(按用户名维度,10 分钟窗口内 >=5 次临时锁定)----
_LOGIN_FAILS: dict = {}
_LOGIN_FAIL_SWEEP_TS = 0.0
# 本锁定周期已落过审计日志的用户名(解锁或成功登录即重置)
_LOGIN_FAIL_LOGGED: set = set()
# ---- 管理员两步验证票据(内存态)----
_ADMIN_2FA: dict = {}
_ADMIN2FA_LAST_SEND: dict = {}
_LOGIN_FAIL_LIMIT = 5
_LOGIN_FAIL_WINDOW = 600
_LOGIN_FAIL_SWEEP_TS = 0.0
_ADMIN_2FA_TTL = 300 # 票据 5 分钟有效
_ADMIN_2FA_MAX_ATTEMPTS = 5 # 邮箱比对 + 验证码合计尝试上限
_ADMIN_2FA_MAX_TICKETS = 200 # 全局活票上限(防内存膨胀,挤掉最旧)
_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环
def _admin2fa_purge() -> None:
now = time.time()
for t in [t for t, v in _ADMIN_2FA.items() if now - v['ts'] > _ADMIN_2FA_TTL]:
_ADMIN_2FA.pop(t, None)
def _admin2fa_mask(email: str) -> str:
"""邮箱掩码(审计日志与前端提示用):3*****@qq.com 样式"""
try:
local, dom = str(email).split('@', 1)
return (local[0] if local else '*') + '*****@' + dom
except Exception:
return '*****'
def _admin2fa_issue(user) -> str:
_admin2fa_purge()
# 每管理员仅 1 张活票——新票签发即销毁同账号旧票
for t in [t for t, v in list(_ADMIN_2FA.items()) if v['username'] == user['username']]:
_ADMIN_2FA.pop(t, None)
if len(_ADMIN_2FA) >= _ADMIN_2FA_MAX_TICKETS:
items = list(_ADMIN_2FA.items()) # 快照后再 min,防并发 pop 使迭代失效
if items:
oldest = min(items, key=lambda kv: kv[1]['ts'])[0]
_ADMIN_2FA.pop(oldest, None)
ticket = secrets.token_urlsafe(16)
_ADMIN_2FA[ticket] = {'user_id': user['id'], 'username': user['username'],
'email': user['email'], 'ts': int(time.time()), 'attempts': 0}
return ticket
def _admin2fa_take(ticket: str):
"""取活票(不销毁):无票/过期返回 (None, None);命中返回 (ticket, 数据)。"""
_admin2fa_purge()
v = _ADMIN_2FA.get(ticket or '')
if not v:
return None, None
return ticket, v
def _login_fail_check(username: str) -> bool:
"""返回 True 表示允许尝试登录;锁定窗口内返回 False"""
global _LOGIN_FAIL_SWEEP_TS
now = time.time()
fails = [t for t in _LOGIN_FAILS.get(username, []) if now - t < _LOGIN_FAIL_WINDOW]
_LOGIN_FAILS[username] = fails
if len(fails) < _LOGIN_FAIL_LIMIT:
_LOGIN_FAIL_LOGGED.discard(username)
if len(_LOGIN_FAILS) > 10000 and now - _LOGIN_FAIL_SWEEP_TS > 60: # 容量上限(用户名为键,防无界增长)
_LOGIN_FAIL_SWEEP_TS = now
stale_users = [u for u, ts in _LOGIN_FAILS.items()
if not ts or now - max(ts) > _LOGIN_FAIL_WINDOW]
for u in stale_users[:5000]:
_LOGIN_FAILS.pop(u, None)
if len(_LOGIN_FAILS) > 20000:
for u in list(_LOGIN_FAILS.keys())[:len(_LOGIN_FAILS) // 2]:
_LOGIN_FAILS.pop(u, None)
return len(fails) < _LOGIN_FAIL_LIMIT
def _login_fail_record(username: str) -> None:
_LOGIN_FAILS.setdefault(username, []).append(time.time())
def _login_fail_clear(username: str) -> None:
_LOGIN_FAILS.pop(username, None)
_LOGIN_FAIL_LOGGED.discard(username)
def _login_fail_locked_log_once(username: str) -> bool:
"""锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表;
解锁(窗口过期或成功登录)后进入新周期,才允许再记一条。"""
if username in _LOGIN_FAIL_LOGGED:
return False
_LOGIN_FAIL_LOGGED.add(username)
return True
def _safe_register_credits() -> int:
"""注册赠送次数(settings.register_credits,默认 3)。
防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。"""
try:
credits = int(auth.get_setting('register_credits', str(auth.DEFAULT_REGISTER_CREDITS)) or 0)
except (TypeError, ValueError):
return auth.DEFAULT_REGISTER_CREDITS
return max(0, credits)
_SEND_CODE_IP = {}
_SEND_CODE_IP_LIMIT = 10
_SEND_CODE_IP_WINDOW = 3600
def _send_code_ip_check(ip: str) -> bool:
"""返回 True 表示允许发送;超限返回 False(表超限时顺手清理过期项,防无界增长)"""
now = time.time()
if len(_SEND_CODE_IP) > 10000:
stale = [k for k, (c, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW]
for k in stale:
_SEND_CODE_IP.pop(k, None)
cnt, win = _SEND_CODE_IP.get(ip, (0, now))
if now - win > _SEND_CODE_IP_WINDOW:
cnt, win = 0, now
if cnt >= _SEND_CODE_IP_LIMIT:
return False
_SEND_CODE_IP[ip] = (cnt + 1, win)
return True
_RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'moderator', '管理员', '客服'}
def re_username_ok(username: str) -> bool:
import re as _re
# 禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险)
# 白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符
#("аdmin" 冒充钓鱼);保留字(admin/qfbsh 等)一并拒绝,防冒充管理入口
return bool(_re.fullmatch(r'[A-Za-z0-9_\u4e00-\u9fa5]{3,20}', username)) and \
not _re.match(r'^_+$', username) and not username.startswith('_') and not username.endswith('_') \
and username.lower() not in _RESERVED_USERNAMES
def secrets_rand() -> int:
import secrets as _s
return _s.randbelow(900000) + 100000