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_routes.py
T
snowy-yang d209af847a chore: 基线快照 —— 纳入 git 版本控制(站点 v2.4.0 / Flask 单体)
在框架迁移与模块拆分前保存当前可运行状态:
- 后端:main.py/auth_routes.py/auth_system.py/music_api.py/music_downloader.py/podcast_api.py/qr_login.py
- 前端:templates/index.html + static/site_auth.js
- 部署:Dockerfile/docker-compose.yml/entrypoint.sh/requirements.txt
敏感数据(data/、cookie.txt)与内部文档(交接文档/新会话引导)按 .gitignore 排除。
2026-09-26 00:13:05 +08:00

1051 lines
52 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/* 与 /admin/site/*)
由 main.py 调用 register_auth_routes(app) 挂载。
"""
import hmac
import secrets
import sqlite3
import time
from typing import Optional, Tuple
from flask import request, send_file
import auth_system as auth
def _data() -> dict:
try:
if request.method == 'GET':
return dict(request.args)
json_data = request.get_json(silent=True) or {}
return {**dict(request.form), **json_data}
except Exception:
return {}
# 登录失败锁定(P3-7):按用户名维度记录失败,10 分钟窗口内 >=5 次临时锁定该用户名。
# 与既有按 IP 10/分限流互补——IP 池轮换爆破同一弱口令账号时仍被挡住
_LOGIN_FAILS: dict = {}
_LOGIN_FAIL_LIMIT = 5
_LOGIN_FAIL_WINDOW = 600
# R5-6:清理节流——超限后的全表扫描每 60 秒最多一次,不再每个请求都 O(n);
# 硬上限 20000 条(按插入序砍掉最早的一半),防止节流窗口内被随机用户名撑爆
_LOGIN_FAIL_SWEEP_TS = 0.0
# R5-4:本锁定周期已落过审计日志的用户名(解锁或成功登录即重置,下个周期允许再记一条)
_LOGIN_FAIL_LOGGED: set = set()
# 第 86 条:管理员两步验证(2FA)——票据表(内存态,单实例部署与全站任务表同口径)。
# ticket -> {user_id, username, email, ts, attempts};密码验证通过才签发,
# Step2 邮箱比对与 Step3 验证码共用 attempts(上限内错满即作废),Step3 成功即销毁。
# 第 87 条:每管理员仅 1 张活票(新票签发即销毁同账号旧票);_ADMIN2FA_LAST_SEND
# 为每账号 admin2fa 发码软冷却(60s,防持密码者借多票持续轰炸绑定邮箱)
_ADMIN_2FA: dict = {}
_ADMIN2FA_LAST_SEND: dict = {}
_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 _actor() -> str:
"""审计日志/卡密 created_by 的操作者名——跟随站点管理员的真实用户名
(2026-09-22 管理员账号由 admin 更名 qfbsh 后,日志不得再硬编码旧名;
无会话兜底用 system——admin 账号已不存在,不能再出现在新日志里)"""
cu = auth.current_user()
return cu['username'] if cu else 'system'
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)
# 验证码按 IP 限流(每 IP 每小时 10 次),防止公网环境换邮箱刷爆 SMTP
_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
def _ok(data=None, message: str = 'success'):
resp = {'status': 200, 'success': True, 'message': message}
if data is not None:
resp['data'] = data
return resp, 200
def _err(message: str, code: int = 400, error_code: str = ''):
resp = {'status': code, 'success': False, 'message': message}
if error_code:
resp['error_code'] = error_code
return resp, code
def register_auth_routes(app) -> None:
# ==================== 用户侧 ====================
@app.route('/auth/me', methods=['GET', 'POST'])
def auth_me():
user = auth.current_user()
ann = auth.get_announcement()
data = {
'user': auth._user_public(user) if user else None,
'duration_text': auth.format_credits(user['duration_seconds']) if user else '',
'announcement': {'title': ann['title'], 'content': ann['content'],
'popup_mode': ann['popup_mode'], 'updated_at': ann['updated_at']},
# 未配置 SMTP 时前端隐藏注册入口(owner 手动建号模式,不开放邮箱注册)
'smtp_configured': auth.smtp_configured(),
# F-6.1(第 79 条):注册赠送次数动态下发——设置改值后前端文案同步,不再硬编码"3 次"
'register_credits': _safe_register_credits(),
}
return _ok(data)
@app.route('/auth/send_code', methods=['POST'])
def auth_send_code():
try:
if not _send_code_ip_check(auth._client_ip()):
return _err('验证码请求过于频繁,请稍后再试')
data = _data()
email = str(data.get('email') or '').strip()
# 第 85 条:验证码按用途区分(注册/找回),邮件文案与库行用途隔离
purpose = str(data.get('purpose') or 'register').strip()
if purpose not in ('register', 'reset'):
return _err('验证码用途不正确')
auth.send_verify_code(email, purpose)
return _ok(message='验证码已发送,请查收邮箱')
except ValueError as e:
return _err(str(e))
except RuntimeError as e:
return _err(str(e))
except Exception:
return _err('验证码发送失败,请稍后重试', 500)
@app.route('/auth/register', methods=['POST'])
def auth_register():
try:
data = _data()
username = str(data.get('username') or '').strip()
password = str(data.get('password') or '')
email = auth._normalize_email(str(data.get('email') or ''))
code = str(data.get('code') or '').strip()
device_id = str(data.get('device_id') or '').strip()[:64]
# 第 81 条:注册前必须已同意《用户协议》与《隐私政策》——前端入口闸的
# 服务端兜底(同意状态本身存于用户浏览器,此处为语义性强制)
if str(data.get('agree') or '').strip() != '1':
return _err('请先阅读并同意《用户协议》与《隐私政策》')
if not (3 <= len(username) <= 20 and re_username_ok(username)):
return _err('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线')
if len(password) < 8 or len(password) > 128:
return _err('密码长度需在 8-128 位之间')
if len(email) > 254:
return _err('邮箱格式不正确')
# R5-P2:查重统一后移到验证码校验通过之后——验证码是“证明你能收这个
# 邮箱的信”的凭证,凭证在手才有资格探测注册状态(堵未认证枚举);
# 并发双提交仍由 UNIQUE 索引兜底
code_ttl = auth.check_verify_code(email, code, 'register')
if not code_ttl:
return _err('邮箱验证码错误或已过期')
if auth.get_user_by_name(username):
auth.restore_verify_code(email, code, code_ttl, 'register')
return _err('用户名已被注册')
# P3-19:邮箱查重(配合库侧唯一索引——索引防并发,这里给出友好提示)
if email and auth.get_user_by_email(email):
auth.restore_verify_code(email, code, code_ttl, 'register')
return _err('该邮箱已被注册,请更换邮箱或联系管理员')
# P1-1(第 79 条):防刷号双闸——① 同设备只送一次注册赠送;② 同 IP 24h
# 最多 3 次注册。device_id 是客户端随机串可伪造,故两层都要,叠加
# send_code 10 封/时/IP 与邮箱归一化查重后,刷号产能压到可忽略。
device_used_flag = auth.device_used(device_id) if device_id else False
reg_ip = auth._client_ip()
if reg_ip and auth.register_ip_exceeded(reg_ip):
auth.restore_verify_code(email, code, code_ttl, 'register')
auth.add_log('fail', username, reg_ip, '注册', '同 IP 24h 注册次数超限')
return _err('当前网络注册过于频繁,请 24 小时后再试或联系管理员')
# P3-21 + R5 防呆:赠送次数读取与钳制统一走 _safe_register_credits
credits = _safe_register_credits()
if credits < 0:
credits = 0 # R5 防呆:赠送次数不允许配成 -1(会造出不限次新号)
if device_used_flag:
# 同设备重复注册:账号仍可创建(家庭共用电脑场景),但不再重复赠送
credits = 0
try:
user = auth.create_user(username, password, email=email, credits=credits,
device_id=device_id,
ip=reg_ip,
ua=str(request.headers.get('User-Agent') or ''),
register_ip_limit=3,
register_device_once=True)
except sqlite3.IntegrityError:
# 并发双击提交时的兜底(前面已有重名预检);还原验证码供重试
auth.restore_verify_code(email, code, code_ttl, 'register')
return _err('用户名或邮箱已被注册,请换一个再试')
except ValueError as e:
# 锁内 IP 闸复查(register_ip_limit)等业务性拒绝:友好 400 而非 500
auth.restore_verify_code(email, code, code_ttl, 'register')
auth.add_log('fail', username, reg_ip, '注册', str(e))
return _err(str(e))
except Exception:
# 建号失败不吞验证码(F14 第二轮补证)
auth.restore_verify_code(email, code, code_ttl, 'register')
raise
auth.login_session(user)
fresh = auth.get_user_by_name(username)
# 第 100 条(对抗复审):设备闸在事务内置零时路由层 credits 不回读——
# 按入库实际次数对齐审计日志与 toast 文案(正常路径 min 无操作)
credits = min(credits, max(0, int(fresh['duration_seconds'] or 0)))
# 第 81 条:注册成功落审计日志(后台日志页可见刷号行为)
auth.add_log('success', username, reg_ip, '注册账号',
f'赠送 {credits} 次' if credits else '未赠送次数')
return _ok({'user': auth._user_public(fresh),
'duration_text': auth.format_credits(fresh['duration_seconds']),
'redirect': 'admin' if fresh['role'] == 'admin' else 'user'},
f'注册成功' + (f',已赠送 {credits} 次下载次数' if credits else ''))
except Exception:
return _err('注册失败,请稍后重试', 500)
@app.route('/auth/trial', methods=['POST'])
def auth_trial():
"""试用已并入注册:注册即送 N 次(随后台设置联动,第 98 条不再硬编码),无需领取"""
return _err(f'注册即送 {_safe_register_credits()} 次免费下载次数,请直接注册账号')
@app.route('/auth/login', methods=['POST'])
def auth_login():
try:
data = _data()
username = str(data.get('username') or '').strip()
password = str(data.get('password') or '')
if len(password) > 128:
# 超长密码不做 PBKDF2(CPU 放大器),直接按密码错误拒绝
return _err('用户名或密码错误')
# 第 85 条:邮箱登录——含 @ 按归一化邮箱解析出真实账号再验密;
# 锁定/日志/状态检查统一用解析出的真实用户名(邮箱大小写与 +tag
# 变体归一后,锁定不被变体绕过,日志也不再出现邮箱串)。
# 邮箱不存在与密码错误统一文案,不暴露账号存在性。
if '@' in username:
iuser = auth.get_user_by_email(username)
lock_name = iuser['username'] if iuser else (auth._normalize_email(username) or username)
else:
iuser = None
lock_name = username
# P3-7:用户名维度失败锁定(10 分钟内 5 次失败);R5-4:日志整个锁定周期只记一条
if not _login_fail_check(lock_name):
if _login_fail_locked_log_once(lock_name):
auth.add_log('fail', lock_name, auth._client_ip(), '登录', '失败次数过多,临时锁定')
return _err('尝试过于频繁,该账号已临时锁定,请 10 分钟后再试')
if iuser is not None:
user = auth.verify_login(iuser['username'], password)
else:
user = auth.verify_login(username, password)
if not user:
_login_fail_record(lock_name)
return _err('用户名或密码错误')
status = user['status'] if 'status' in user.keys() else 1
if status == 2:
reason = user['ban_reason'] if 'ban_reason' in user.keys() else ''
auth.add_log('fail', lock_name, auth._client_ip(), '登录', f'封禁账号尝试登录: {reason}')
return _err('账号已被封禁' + (f':{reason}' if reason else '') + ',请联系管理员')
if status == 0:
return _err('账号已被禁用,请联系管理员')
_login_fail_clear(lock_name) # 登录成功清零失败计数
if user['role'] == 'admin':
# 第 86 条:管理员不在此建立会话——一律转两步验证通道
# (密码正确才签发票据;未绑定邮箱 fail-closed)
admin_email = user['email'] if 'email' in user.keys() else ''
if not admin_email:
auth.add_log('fail', lock_name, auth._client_ip(), '管理员登录', '未绑定邮箱,两步验证不可用')
return _err('管理员账号未绑定邮箱,请联系管理员先完成绑定')
ticket = _admin2fa_issue(user)
auth.add_log('success', lock_name, auth._client_ip(), '管理员登录', '密码验证通过,待邮箱验证')
return _ok({'ticket': ticket, 'email_masked': _admin2fa_mask(admin_email),
'username': user['username'], 'admin_2fa': True},
'密码验证通过,请完成邮箱验证')
auth.login_session(user)
# IP 归属地异常检测:24h 内 >=3 个不同省份自动封禁(防倒卖)
banned, info = auth.record_login_location(user, auth._client_ip())
if banned:
auth.logout_session()
return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
fresh = auth.get_user_by_id(user['id'])
return _ok({
'user': auth._user_public(fresh),
'duration_text': auth.format_credits(fresh['duration_seconds']),
'redirect': 'admin' if fresh['role'] == 'admin' else 'user',
}, '登录成功')
except Exception:
return _err('登录失败,请稍后重试', 500)
@app.route('/auth/admin_login/email', methods=['POST'])
def auth_admin_login_email():
"""管理员两步验证 Step2:比对绑定邮箱 → 发送 admin2fa 验证码(第 86 条)"""
try:
data = _data()
ticket, v = _admin2fa_take(str(data.get('ticket') or ''))
if not v:
return _err('登录会话已过期,请重新开始登录', 401, 'admin_2fa_expired')
email_input = auth._normalize_email(str(data.get('email') or ''))
# 第 97 条(P3):第二因子的比对走常时比较,与本文件密钥/令牌口径一致
if (not email_input or not v.get('email')
or not hmac.compare_digest(email_input, v['email'])):
v['attempts'] += 1 # 第 87 条:失败路径自增(原实现计数器从未生效)
auth.add_log('fail', v['username'], auth._client_ip(), '管理员登录', '绑定邮箱不匹配')
if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS:
_ADMIN_2FA.pop(ticket, None)
return _err('错误次数过多,请重新开始登录')
return _err(f'绑定邮箱不正确,还可尝试 {_ADMIN_2FA_MAX_ATTEMPTS - v["attempts"]} 次')
# 第 87 条:同账号 60s 发码软冷却——票据一次性+单活票后仍防持续轰炸绑定邮箱
last_send = _ADMIN2FA_LAST_SEND.get(v['username'], 0)
if time.time() - last_send < _ADMIN2FA_SEND_GAP:
return _err('验证码发送过于频繁,请 1 分钟后再试')
auth.send_verify_code(v['email'], 'admin2fa')
_ADMIN2FA_LAST_SEND[v['username']] = time.time()
auth.add_log('success', v['username'], auth._client_ip(), '管理员登录',
f'绑定邮箱验证通过,验证码已发送({_admin2fa_mask(v["email"])})')
return _ok(message='验证码已发送至管理员邮箱,请查收')
except ValueError as e:
return _err(str(e))
except RuntimeError as e:
return _err(str(e))
except Exception:
return _err('验证码发送失败,请稍后重试', 500)
@app.route('/auth/admin_login/verify', methods=['POST'])
def auth_admin_login_verify():
"""管理员两步验证 Step3:验证码原子消费 → 建立管理员会话(第 86 条)"""
try:
data = _data()
ticket, v = _admin2fa_take(str(data.get('ticket') or ''))
if not v:
return _err('登录会话已过期,请重新开始登录', 401, 'admin_2fa_expired')
code = str(data.get('code') or '').strip()
if not auth.check_verify_code(v['email'], code, 'admin2fa'):
v['attempts'] += 1 # 第 87 条:失败路径自增(原实现计数器从未生效)
if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS:
_ADMIN_2FA.pop(ticket, None)
return _err('错误次数过多,请重新开始登录')
auth.add_log('fail', v['username'], auth._client_ip(), '管理员登录', '验证码错误')
return _err('验证码错误或已过期')
# Step3 成功即销毁票据(一次性);建会话前以库内最新状态复核
_ADMIN_2FA.pop(ticket, None)
user = auth.get_user_by_id(v['user_id'])
if not user or user['role'] != 'admin':
return _err('登录会话已过期,请重新开始登录', 401, 'admin_2fa_expired')
status = user['status'] if 'status' in user.keys() else 1
if status == 2:
auth.add_log('fail', v['username'], auth._client_ip(), '管理员登录', '封禁账号尝试登录')
return _err('账号已被封禁,请联系管理员')
if status == 0:
return _err('账号已被禁用,请联系管理员')
auth.login_session(user)
# IP 归属地异常检测:管理员天然豁免省份封禁(record_login_location 内部判定)
banned, info = auth.record_login_location(user, auth._client_ip())
if banned:
auth.logout_session()
return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
fresh = auth.get_user_by_id(user['id'])
auth.add_log('success', v['username'], auth._client_ip(), '管理员登录', '两步验证通过')
return _ok({
'user': auth._user_public(fresh),
'duration_text': auth.format_credits(fresh['duration_seconds']),
'redirect': 'admin',
}, '登录成功')
except Exception:
return _err('登录失败,请稍后重试', 500)
@app.route('/auth/logout', methods=['POST'])
def auth_logout():
auth.logout_session()
return _ok(message='已退出登录')
@app.route('/auth/heartbeat', methods=['POST'])
def auth_heartbeat():
user = auth.current_user()
if not user:
return _err('登录已失效', 401, 'unauthorized')
fresh = auth.get_user_by_id(user['id'])
credits = int(fresh['duration_seconds'] or 0)
if fresh['role'] != 'admin' and credits != -1 and credits <= 0:
return _err('剩余次数已用完', 401, 'expired')
return _ok({'duration_seconds': fresh['duration_seconds'],
'duration_text': auth.format_credits(fresh['duration_seconds'])})
@app.route('/auth/redeem', methods=['POST'])
def auth_redeem():
user = auth.current_user()
if not user:
return _err('请先登录', 401, 'unauthorized')
try:
code = str(_data().get('code') or '')
credits, remaining = auth.redeem_card(code, user['username'])
auth.add_log('success', user['username'], auth._client_ip(), '卡密充值',
f'{code[:4]}**** · +{credits} 次')
return _ok({'credits': credits, 'duration_seconds': remaining,
'duration_text': auth.format_credits(remaining)},
f'充值成功,+{credits} 次')
except ValueError as e:
auth.add_log('fail', user['username'], auth._client_ip(), '卡密充值', str(e))
return _err(str(e))
except Exception:
return _err('充值失败,请稍后重试', 500)
@app.route('/auth/ann_seen', methods=['POST'])
def auth_ann_seen():
user = auth.current_user()
if user:
ann = auth.get_announcement()
auth._x('UPDATE users SET ann_seen = ? WHERE id = ?', (ann['updated_at'], user['id']))
return _ok()
# -------------------- 自助找回密码(第 79 条:复用验证码通道) --------------------
@app.route('/auth/forgot_password', methods=['POST'])
def auth_forgot_password():
"""自助找回密码:注册邮箱收验证码 → 验码原子消费 → 重置密码并踢下全部会话。
安全口径:响应统一为「验证码错误或已过期」(不区分账号是否存在,防枚举)。"""
try:
data = _data()
# 第 84 条修复:此处原为裸调用 `_normalize_email(...)`——该名称从未定义/
# 导入,NameError 被下方 except 吞成 500,导致本接口自第 79 条诞生起
# 任何输入都返回"重置失败"。改用 auth_system 的实现(邮箱归一化)。
email = auth._normalize_email(str(data.get('email') or ''))
code = str(data.get('code') or '').strip()
newpwd = str(data.get('password') or '')
if len(newpwd) < 8 or len(newpwd) > 128:
return _err('新密码长度需在 8-128 位之间')
if not email:
return _err('缺少邮箱')
if not auth.check_verify_code(email, code, 'reset'):
return _err('邮箱验证码错误或已过期')
user = auth.get_user_by_email(email)
if not user:
# 验证码已对空邮箱消费掉——统一文案,不暴露账号存在性
return _err('邮箱验证码错误或已过期')
# 第 86 条:管理员不走自助找回——放行则邮箱持有者可自助改密,
# 改完的密码+邮箱验证码=两因子全握,2FA 形同虚设。统一防枚举文案
if user['role'] == 'admin':
return _err('邮箱验证码错误或已过期')
auth.reset_password(user['id'], newpwd)
auth.add_log('success', user['username'], auth._client_ip(), '自助找回密码', '邮箱验证码重置')
return _ok(message='密码已重置,请使用新密码登录(所有设备已下线)')
except Exception:
return _err('重置失败,请稍后重试', 500)
# ==================== 管理后台 ====================
@app.route('/admin/site/users', methods=['POST'])
@auth.admin_required
def admin_site_users():
return _ok({'users': auth.list_users()})
@app.route('/admin/site/user/delete', methods=['POST'])
@auth.admin_required
def admin_site_user_delete():
try:
uid = int(_data().get('id') or 0)
target = auth.get_user_by_id(uid)
if not auth.delete_user(uid):
return _err('用户不存在或为管理员')
auth.add_log('success', _actor(), auth._client_ip(), '删除用户', target['username'] if target else str(uid))
return _ok(message='用户已删除')
except Exception as e:
return _err(f'删除失败: {str(e)}', 500)
@app.route('/admin/site/user/duration', methods=['POST'])
@auth.admin_required
def admin_site_user_duration():
try:
data = _data()
uid = int(data.get('id') or 0)
try:
count = int(float(data.get('count') or data.get('minutes') or 0))
except (TypeError, ValueError, OverflowError):
return _err('次数必须为数字')
if count < -1:
return _err('次数不能小于 -1(-1 表示不限次数)')
target = auth.get_user_by_id(uid)
if not target:
return _err('用户不存在')
if target['role'] == 'admin':
return _err('管理员不限次数')
auth.set_user_credits(uid, count)
auth.add_log('success', _actor(), auth._client_ip(), '修改用户次数',
f'{target["username"]} → {count} 次')
return _ok(message=f'已修改为 {count} 次')
except Exception as e:
return _err(f'修改失败: {str(e)}', 500)
def _block_admin_target(target) -> Optional[Tuple]:
"""禁止对管理员账号执行危险操作(防自锁)"""
if target and target['role'] == 'admin':
return _err('不能对管理员账号执行该操作')
return None
@app.route('/admin/site/user/create', methods=['POST'])
@auth.admin_required
def admin_site_user_create():
"""管理员手动添加用户"""
try:
data = _data()
username = str(data.get('username') or '').strip()
password = str(data.get('password') or '')
email = auth._normalize_email(str(data.get('email') or ''))
try:
credits = int(float(data.get('count') or 0))
except (TypeError, ValueError, OverflowError):
# OverflowError:count='inf' → int(inf) 溢出(原实现漏捕 → 500)
return _err('次数必须为数字')
import math as _math
if not _math.isfinite(credits):
return _err('次数必须为有限数字')
if credits < -1:
return _err('次数不能小于 -1(-1 表示不限次数)')
if not re_username_ok(username):
# 第 100 条:管理端为受信方——保留名给明确提示(注册端保持防枚举模糊文案)
if username.lower() in _RESERVED_USERNAMES:
return _err('该用户名为系统保留名,不可使用')
return _err('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线')
if len(password) < 8 or len(password) > 128:
return _err('密码长度需在 8-128 位之间')
if auth.get_user_by_name(username):
return _err('用户名已存在')
# R5-13:与注册路径同口径的邮箱查重——否则重复邮箱触发 UNIQUE 约束
# 走兜底 except,给管理员看原始 500 报错
if email and auth.get_user_by_email(email):
return _err('该邮箱已被注册,请更换邮箱')
auth.create_user(username, password, email=email, credits=credits,
ip=auth._client_ip(), ua='admin创建')
auth.add_log('success', _actor(), auth._client_ip(), '添加用户',
f'{username} · {credits} 次')
return _ok(message=f'用户 {username} 创建成功({credits} 次)')
except Exception as e:
return _err(f'创建失败: {str(e)}', 500)
@app.route('/admin/site/user/reset_password', methods=['POST'])
@auth.admin_required
def admin_site_user_reset_password():
try:
data = _data()
uid = int(data.get('id') or 0)
newpwd = str(data.get('password') or '')
if len(newpwd) < 8 or len(newpwd) > 128:
# 第 87 条:与注册/找回/建号统一 8 位下限(原 6 位为最后一处口径不一)
return _err('新密码长度需在 8-128 位之间')
target = auth.get_user_by_id(uid)
if not target:
return _err('用户不存在')
current = auth.current_user()
if target['role'] == 'admin':
# F02:允许管理员修改自己的密码(需验证旧密码),否则文档要求的改密入口形同虚设
if not current or current['id'] != target['id']:
return _err('不能对其他管理员账号执行该操作')
oldpwd = str(data.get('old_password') or '')
ok, _ = auth._verify_password(target['password_hash'], oldpwd, target['salt'])
if not ok:
return _err('原密码错误')
auth.reset_password(uid, newpwd)
auth.add_log('success', current['username'], auth._client_ip(), '修改自己的密码', '管理员')
return _ok(message='密码已修改,下次登录请使用新密码(其他设备已全部下线)')
auth.reset_password(uid, newpwd)
auth.add_log('success', _actor(), auth._client_ip(), '重置密码', target['username'])
return _ok(message=f'已重置 {target["username"]} 的密码')
except Exception as e:
return _err(f'重置失败: {str(e)}', 500)
@app.route('/admin/site/user/toggle', methods=['POST'])
@auth.admin_required
def admin_site_user_toggle():
"""启用 / 禁用账号"""
try:
uid = int(_data().get('id') or 0)
target = auth.get_user_by_id(uid)
if not target:
return _err('用户不存在')
blocked = _block_admin_target(target)
if blocked:
return blocked
new_status = 0 if (target['status'] if 'status' in target.keys() else 1) else 1
auth.set_user_status(uid, new_status)
auth.add_log('success', _actor(), auth._client_ip(), '禁用/启用',
f'{target["username"]} → {"启用" if new_status else "禁用"}')
return _ok({'status': new_status},
message=f'已{"启用" if new_status else "禁用"} {target["username"]}')
except Exception as e:
return _err(f'操作失败: {str(e)}', 500)
@app.route('/admin/site/user/ban', methods=['POST'])
@auth.admin_required
def admin_user_ban():
"""封禁/解封用户(防倒卖:封禁后立即踢下线)"""
try:
data = _data()
uid = int(data.get('id') or 0)
action = str(data.get('action') or 'ban')
reason = str(data.get('reason') or '')
row = auth.get_user_by_id(uid)
if not row:
return _err('用户不存在')
if row['role'] == 'admin':
return _err('不能对管理员执行该操作')
admin_name = _actor()
if action == 'ban':
if not auth.ban_user(uid, reason or '管理员手动封禁'):
return _err('封禁失败')
auth.add_log('success', admin_name,
auth._client_ip(), '封禁用户', f'{row["username"]}: {reason or "手动封禁"}')
return _ok(message=f'已封禁用户「{row["username"]}」,该账号已强制下线')
else:
if not auth.unban_user(uid):
return _err('解封失败')
auth.add_log('success', admin_name,
auth._client_ip(), '解封用户', row['username'])
return _ok(message=f'已解封用户「{row["username"]}」')
except Exception as e:
return _err(f'操作失败: {str(e)}', 500)
@app.route('/admin/site/user/cookie', methods=['POST'])
@auth.admin_required
def admin_site_user_cookie():
username = str(_data().get('username') or '')
user = auth.get_user_by_name(username)
if not user:
return _err('用户不存在', 404)
return _ok({'username': username, 'cookie': user['netease_cookie'] or ''})
@app.route('/admin/site/smtp/get', methods=['POST'])
@auth.admin_required
def admin_site_smtp_get():
cfg = auth.get_smtp_config()
return _ok({'host': cfg['host'], 'port': cfg['port'],
'sender': cfg['sender'], 'configured': auth.smtp_configured()})
@app.route('/admin/site/smtp/save', methods=['POST'])
@auth.admin_required
def admin_site_smtp_save():
try:
data = _data()
host = str(data.get('host') or '').strip()
try:
port = int(data.get('port') or 587)
except (TypeError, ValueError):
return _err('端口必须为数字')
sender = str(data.get('sender') or '').strip()
password = str(data.get('password') or '')
if not host or not sender:
return _err('SMTP 服务器与发件邮箱不能为空')
if not password:
# 密码留空表示保持原有授权码不变
password = auth.get_smtp_config().get('password', '')
auth.save_smtp_config(host, port, sender, password)
auth.add_log('success', _actor(), auth._client_ip(), '保存SMTP配置', f'{host}:{port}')
return _ok(message='SMTP 配置已保存')
except Exception as e:
return _err(f'保存失败: {str(e)}', 500)
@app.route('/admin/site/smtp/test', methods=['POST'])
@auth.admin_required
def admin_site_smtp_test():
try:
to = str(_data().get('to') or '').strip()
if not to:
return _err('请填写测试收件邮箱')
# 收件人地址校验(防 CRLF 头注入;与验证码发送同规则)
import re as _re
if not _re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', to):
return _err('收件邮箱格式不正确')
auth.send_email(to, 'SMTP 测试邮件',
'这是一封来自云村小助手的测试邮件,收到即说明 SMTP 配置成功。')
auth.add_log('success', _actor(), auth._client_ip(), 'SMTP测试', to)
return _ok(message='测试邮件已发送,请查收')
except Exception as e:
auth.add_log('fail', _actor(), auth._client_ip(), 'SMTP测试', str(e))
return _err(f'发送失败: {str(e)}', 500)
@app.route('/admin/site/logs', methods=['POST'])
@auth.admin_required
def admin_site_logs():
"""日志分页查询(10 条/页)"""
data = _data()
log_type = 'fail' if str(data.get('type')) == 'fail' else 'success'
try:
page = max(1, int(data.get('page') or 1))
except (TypeError, ValueError):
page = 1
rows, total = auth.query_logs(log_type, limit=10, offset=(page - 1) * 10)
return _ok({'logs': rows, 'type': log_type, 'page': page,
'total': total, 'pages': max(1, (total + 9) // 10)})
@app.route('/admin/site/logs/clear', methods=['POST'])
@auth.admin_required
def admin_site_logs_clear():
"""清除指定类型的全部日志"""
log_type = 'fail' if str(_data().get('type')) == 'fail' else 'success'
deleted = auth.clear_logs(log_type)
auth.add_log('success', _actor(),
auth._client_ip(), '清除日志', f'{log_type} · {deleted} 条')
return _ok(message=f'已清除 {"失败" if log_type == "fail" else "成功"}日志 {deleted} 条')
@app.route('/admin/site/cards/generate', methods=['POST'])
@auth.admin_required
def admin_site_cards_generate():
try:
data = _data()
try:
count = int(data.get('count') or 1)
valid_days = int(data.get('valid_days') or 0)
except (TypeError, ValueError):
return _err('张数 / 次数 / 有效天数必须为数字')
# 第 81 条:显式传 0(JSON 体)不再被 or 链静默替换成默认 5
raw_credits = data.get('credits', data.get('minutes'))
try:
credits = int(raw_credits) if raw_credits is not None else 5
except (TypeError, ValueError):
return _err('张数 / 次数 / 有效天数必须为数字')
if credits <= 0:
return _err('卡密次数必须大于 0')
prefix = str(data.get('prefix') or 'VIP')
note = str(data.get('note') or '')
codes = auth.generate_cards(count, credits, created_by=_actor(),
prefix=prefix, note=note, valid_days=valid_days)
auth.add_log('success', _actor(), auth._client_ip(), '生成卡密',
f'{count} 张 · {credits} 次/张 · 有效{valid_days or "永久"}天')
return _ok({'codes': codes, 'credits': credits, 'count': len(codes)},
f'已生成 {len(codes)} 张卡密')
except Exception as e:
return _err(f'生成失败: {str(e)}', 500)
@app.route('/admin/site/cards/list', methods=['POST'])
@auth.admin_required
def admin_site_cards_list():
# F85:支持 limit/offset 分页与总数返回,较早卡密可翻页查看
data = _data()
try:
limit = int(data.get('limit') or 100)
except (TypeError, ValueError):
limit = 100
try:
offset = int(data.get('offset') or 0)
except (TypeError, ValueError):
offset = 0
cards, total = auth.list_cards(limit=limit, offset=offset)
return _ok({'cards': cards, 'total': total})
@app.route('/admin/site/cards/disable', methods=['POST'])
@auth.admin_required
def admin_site_cards_disable():
# 第 81 条:禁用/恢复未使用卡密(可逆操作;已使用卡密无禁用意义)
try:
data = _data()
code = str(data.get('code') or '').strip()
if not code:
return _err('缺少卡密')
disable = str(data.get('disabled') or '').strip().lower() in ('1', 'true') # 第 87 条:兼容布尔/大小写变体
auth.set_card_disabled(code, disable)
auth.add_log('success', _actor(), auth._client_ip(),
'禁用卡密' if disable else '恢复卡密', code[:4] + '****')
return _ok(message=('已禁用 ' if disable else '已恢复 ') + code[:4] + '****')
except ValueError as e:
return _err(str(e))
except Exception:
return _err('操作失败,请稍后重试', 500)
@app.route('/admin/site/cards/delete', methods=['POST'])
@auth.admin_required
def admin_site_cards_delete():
# 第 81 条:删除未使用卡密(已使用卡密保留兑换凭证,按 owner 规则不可删)
try:
data = _data()
code = str(data.get('code') or '').strip()
if not code:
return _err('缺少卡密')
auth.delete_card(code)
auth.add_log('success', _actor(), auth._client_ip(), '删除卡密', code[:4] + '****')
return _ok(message='已删除 ' + code[:4] + '****')
except ValueError as e:
return _err(str(e))
except Exception:
return _err('操作失败,请稍后重试', 500)
@app.route('/admin/site/ann/get', methods=['POST'])
@auth.admin_required
def admin_site_ann_get():
return _ok(auth.get_announcement())
@app.route('/admin/site/ann/save', methods=['POST'])
@auth.admin_required
def admin_site_ann_save():
try:
data = _data()
auth.save_announcement(str(data.get('title') or ''), str(data.get('content') or ''),
str(data.get('popup_mode') or 'off'))
auth.add_log('success', _actor(), auth._client_ip(), '保存公告', str(data.get('title') or ''))
return _ok(message='公告已保存')
except Exception as e:
return _err(f'保存失败: {str(e)}', 500)
# -------------------- 播放 API 密钥 --------------------
@app.route('/admin/site/play_key/get', methods=['POST'])
@auth.admin_required
def admin_site_play_key_get():
"""获取当前播放 API 密钥(不存在则自动生成一次,保证弹窗拼 URL 总有可用密钥)"""
key = auth.get_setting('playlist_play_key', '')
if not key:
key = secrets.token_urlsafe(16)
auth.set_setting('playlist_play_key', key)
return _ok({'key': key})
@app.route('/admin/site/play_key/rotate', methods=['POST'])
@auth.admin_required
def admin_site_play_key_rotate():
"""刷新播放 API 密钥:旧密钥立即失效(已签发的音频地址按 36 分钟自然过期(随交付保留期联动))"""
key = secrets.token_urlsafe(16)
auth.set_setting('playlist_play_key', key)
auth.add_log('success', _actor(), auth._client_ip(), '刷新播放API密钥', '旧密钥已失效')
return _ok({'key': key}, '密钥已刷新,旧密钥已全部失效')
# -------------------- 数据库管理 --------------------
@app.route('/admin/site/backup/create', methods=['POST'])
@auth.admin_required
def admin_site_backup_create():
try:
name = auth.create_backup()
auth.set_setting('last_backup_ts', str(time.time()))
auth.add_log('success', _actor(), auth._client_ip(), '数据库备份', name)
return _ok({'name': name}, f'备份完成:{name}')
except Exception as e:
return _err(f'备份失败: {str(e)}', 500)
@app.route('/admin/site/backup/list', methods=['POST'])
@auth.admin_required
def admin_site_backup_list():
return _ok({'backups': auth.list_backups(),
'interval_hours': auth.get_setting('backup_interval_hours', '0'),
'db_size': auth.DB_PATH.stat().st_size if auth.DB_PATH.exists() else 0})
@app.route('/admin/site/backup/download', methods=['GET'])
@auth.admin_required
def admin_site_backup_download():
name = request.args.get('name') or ''
p = auth.backup_path(name)
if not p:
return _err('备份文件不存在', 404)
resp = send_file(str(p), as_attachment=True, download_name=name)
# 第 97 条(P3):备份含完整用户库(邮箱+哈希),禁止共享缓存存储
resp.headers['Cache-Control'] = 'no-store'
return resp
@app.route('/admin/site/backup/upload', methods=['POST'])
@auth.admin_required
def admin_site_backup_upload():
dest = None
try:
f = request.files.get('file')
if not f or not f.filename:
return _err('请选择数据库文件')
if not f.filename.endswith('.db'):
return _err('仅支持 .db 文件')
auth.BACKUP_DIR.mkdir(parents=True, exist_ok=True)
# S6:同秒上传自动追加序号,不再互相覆盖
base = 'upload_' + time.strftime('%Y%m%d_%H%M%S')
name = base + '.db'
k = 1
while (auth.BACKUP_DIR / name).exists():
name = f'{base}-{k}.db'
k += 1
dest = auth.BACKUP_DIR / name
f.save(str(dest))
# 只读文件头 16 字节做魔数校验(原实现 read_bytes() 会把整个备份读进内存)
with dest.open('rb') as hf:
header = hf.read(16)
if not header.startswith(b'SQLite format 3'):
dest.unlink()
return _err('不是有效的 SQLite 数据库文件')
auth.add_log('success', _actor(), auth._client_ip(), '上传数据库', dest.name)
# 第 93 条(P2-3):上传备份同样纳入配额裁剪(20+5)
try:
auth.prune_backups(20, 5)
except Exception:
pass
return _ok({'name': dest.name}, '上传成功,可在下方选择该备份进行恢复')
except Exception as e:
# 第 99 条:上传中途失败(ENOSPC/IO)清理半截文件——残缺 .db 会进
# list_backups 且因 mtime 最新挤掉一份好备份(恢复前校验虽能拒收)
try:
if dest and dest.exists():
dest.unlink()
except Exception:
pass
return _err(f'上传失败: {str(e)}', 500)
@app.route('/admin/site/backup/restore', methods=['POST'])
@auth.admin_required
def admin_site_backup_restore():
try:
name = str(_data().get('name') or '')
auth.restore_backup(name)
auth.add_log('success', _actor(), auth._client_ip(), '恢复数据库', name)
return _ok(message='数据库已恢复,请刷新页面')
except ValueError as e:
# 第 95 条(P3-1):客户端可纠正错误(备份不存在/校验失败)返回 400,
# 与 delete 的 404 口径对齐——原实现一律 500
return _err(f'恢复失败: {str(e)}', 400)
except Exception as e:
auth.add_log('fail', _actor(), auth._client_ip(), '恢复数据库', str(e))
return _err(f'恢复失败: {str(e)}', 500)
@app.route('/admin/site/backup/delete', methods=['POST'])
@auth.admin_required
def admin_site_backup_delete():
try:
name = str(_data().get('name') or '')
if auth.delete_backup(name):
return _ok(message='备份已删除')
return _err('备份文件不存在', 404)
except Exception as e:
return _err(f'删除失败: {str(e)}', 500)
@app.route('/admin/site/backup/auto', methods=['POST'])
@auth.admin_required
def admin_site_backup_auto():
try:
import math as _math
try:
hours = float(_data().get('hours') or 0)
except (TypeError, ValueError):
return _err('间隔必须为数字')
# F46:NaN/Infinity 一律拒绝,不能持久化后让调度循环静默失效
if not _math.isfinite(hours):
return _err('间隔必须为有限数字')
if hours < 0:
return _err('间隔不能为负')
auth.set_setting('backup_interval_hours', str(hours))
return _ok(message='定时备份已开启,每 {} 小时自动备份'.format(hours) if hours > 0
else '定时备份已关闭')
except Exception as e:
return _err(f'设置失败: {str(e)}', 500)
# ============ 辅助 ============
_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