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 8446166120 refactor!: uv 管理环境 + 彻底删除技术债(Flask 与 requests 全部移除)
## uv 环境管理
- pyproject.toml 声明 dependencies + [dependency-groups].dev,uv.lock 精确锁定
- 删除 requirements.txt / requirements-fastapi.txt / requirements-dev.txt
- .python-version = 3.12;Dockerfile 改用 uv sync --frozen(可复现构建)
- 新增 jinja2(首页渲染)

## 删除 Flask 技术债
- 从 main.py 用 ast 精确剥离 56 个 Flask 节点(路由/中间件/错误处理/启动),
  框架无关的服务层落为 services/runtime.py(MusicAPIService、下载/打包 worker、
  任务监控、缓存清扫),零 Flask 依赖
- 删除 main.py(4527 行)与 auth_routes.py(1050 行)
- auth_system.py 移除会话/请求上下文层(login_session/current_user/login_required/
  admin_required/_client_ip 等),保留数据与业务层
- 新增 core/net.py(IP 解析)、web/auth_state.py(登录锁定/2FA 票据/发码限流)
- FastAPI 侧改直接依赖 services.runtime,不再 import main
- web/app.py 去掉 Flask fallback;入口默认根路径模式(API_PREFIX 可选)

## 删除 requests 技术债
- 新增 core/sync_http.py:requests 风格的同步门面(底层 aiohttp 连接池,
  流式读取经有界队列背压),含 Session/headers/(connect,read) 元组超时兼容
- core/utils、auth_system、podcast_api、music_downloader、services 全部切换
- 实测真实下载 5.2MB 音频完整成功

## 质量门禁归零
- ruff:0 错误(B904 全部修复,不再忽略)
- pyright:0 错误(类型收窄修复)
- pytest:140/140(含 golden 契约回归、root 模式 66 项路径覆盖)
- 真实 uvicorn 启动:/health、/、/static、受保护端鉴权全部正确

新增 tests/golden/responses.json:删除 Flask 前捕获的 32 例响应快照,
FastAPI 逐字段复现——删除巨石后行为回归仍有硬证据。
2026-09-26 11:10:17 +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 -*-
"""认证的内存态与校验辅助(原 auth_routes 的模块级状态,框架无关)。
包含:登录失败锁定、管理员 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 # 同账号两次发码最小间隔(秒)——第 87 条初版 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()
# 第 87 条:每管理员仅 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:
"""R5-4:锁定周期内的重复被封尝试只落一条 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)。
R5 防呆:损坏回退默认;<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(S4:表超限时顺手清理过期项,防无界增长)"""
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
# P3-10:禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险)
# R5-P3:白名单收紧为中文/字母/数字/下划线——`\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