diff --git a/app.py b/app.py index 07ffb38..e605a8b 100644 --- a/app.py +++ b/app.py @@ -31,6 +31,25 @@ from lottery import DoubleColorBallGenerator app = Flask(__name__) +# ============================================================ +# Flask-Limiter 限流(BIZ-129 commit 2) +# ============================================================ +# 架构决策:按 IP + 路径限流,内存存储(单进程;多进程需切换 Redis) +# - 默认 60 次/分钟 +# - login / reset-* 路由单独在 auth_api.py 里装饰 +try: + from flask_limiter import Limiter + from flask_limiter.util import get_remote_address + limiter = Limiter( + key_func=get_remote_address, + app=app, + default_limits=['60 per minute'], + storage_uri='memory://', + ) +except ImportError as e: + print(f'[BIZ-129] Flask-Limiter 不可用,限流禁用:{e}', file=sys.stderr) + limiter = None + # ============================================================ # 认证 API 蓝图注册(BIZ-129 方案 C) # ============================================================ @@ -38,7 +57,7 @@ app = Flask(__name__) # reset-request / reset-confirm。session 存 sha256 hash 入库(架构决策 #2)。 try: from auth_api import register_auth_api - register_auth_api(app) + register_auth_api(app, limiter=limiter) except ImportError as e: # auth_api.py 未就绪时不影响主流程(保持向后兼容) print(f'[BIZ-129] auth_api 蓝图注册跳过:{e}', file=sys.stderr) diff --git a/auth_api.py b/auth_api.py index 598f99b..483de36 100644 --- a/auth_api.py +++ b/auth_api.py @@ -52,6 +52,88 @@ from auth import ( auth_bp = Blueprint('auth_api', __name__, url_prefix='/api/auth') +# ============================================================ +# 限流配置(commit 2 整合 Flask-Limiter) +# ============================================================ +# 限流维度:按 IP + 路径 组合 +# - /login: 5 次/分钟(防爆破;5 次错就锁 15min 是账号级,这里是 IP 级双重防御) +# - /reset-request: 3 次/分钟(防滥用邮件/短信) +# - 其他:默认 60 次/分钟(防止爬虫) +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +# limiter 实例由 app.py 的 register_auth_api(limiter) 注入;这里先给默认 None 占位 +_limiter = None + + +def _get_limiter(): + return _limiter + + +def _rate_limit(rate: str, methods=None): + """装饰器:限流(手写内存计数器,避免 Flask-Limiter 在测试 client 下不生效的坑)。 + + 实现:按 (IP, view_function_name) 为 key,滚动 60 秒窗口。 + - 解析 "N per minute" 中的 N 作为上限 + - 多进程部署需切 Redis(架构选型问题,留待后续) + - limiter 仅为占位变量(保留扩展点),不影响计数逻辑 + """ + from functools import wraps + methods = methods or ['POST'] + parts = rate.split() + try: + max_count = int(parts[0]) + except (ValueError, IndexError): + max_count = 5 + + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + # request 阶段查 limiter(仅为占位验证,不阻断) + current_limiter = _limiter + + from flask import request as _req + ip = (_req.headers.get('X-Forwarded-For') or _req.remote_addr or 'unknown').split(',')[0].strip() + key = f'{ip}:{f.__name__}' + + import time + now = time.time() + bucket = _rl_state.setdefault(key, []) + # 清 60s 以前的 + cutoff = now - 60 + while bucket and bucket[0] < cutoff: + bucket.pop(0) + if len(bucket) >= max_count: + from flask import jsonify + resp = jsonify({ + 'success': False, + 'error': f'请求过于频繁,请 {60 - int(now - bucket[0])} 秒后再试', + 'code': 'rate_limited', + }) + resp.status_code = 429 + return resp + bucket.append(now) + return f(*args, **kwargs) + return wrapper + return decorator + + +# 限流内存计数(key = "IP:endpoint_name",value = timestamp list) +_rl_state: dict = {} + + +def register_auth_api(app, limiter=None): + """注册认证蓝图 + 记录 limiter 实例。 + + Args: + app: Flask app + limiter: 可选 flask_limiter.Limiter 实例。仅作为“能力标记”记录; + 实际限流由 _rate_limit 装饰器手动实现(见 _rl_state)。 + """ + global _limiter + _limiter = limiter + app.register_blueprint(auth_bp) + # ============================================================ # 工具函数 @@ -128,6 +210,7 @@ def require_session(f): # ============================================================ @auth_bp.route('/login', methods=['POST']) +@_rate_limit('5 per minute', methods=['POST']) def login(): init_db() payload = request.get_json(silent=True) or {} @@ -250,6 +333,7 @@ def change_password(): # ============================================================ @auth_bp.route('/reset-request', methods=['POST']) +@_rate_limit('3 per minute', methods=['POST']) def reset_request(): init_db() payload = request.get_json(silent=True) or {} @@ -294,6 +378,7 @@ def reset_request(): # ============================================================ @auth_bp.route('/reset-confirm', methods=['POST']) +@_rate_limit('5 per minute', methods=['POST']) def reset_confirm(): init_db() payload = request.get_json(silent=True) or {} @@ -355,10 +440,5 @@ def refresh(): return _ok({'refreshed': True, 'expires_at': new_expires}) -# ============================================================ -# 蓝图注册入口(供 app.py 调用) -# ============================================================ - -def register_auth_api(app): - """在 app.py 里 app.register_blueprint(auth_bp) 即可。""" - app.register_blueprint(auth_bp) +# 蓝图注册入口已定义在文件顶部 register_auth_api(app, limiter=None) +# 避免重复定义(早期版本残留已清理) diff --git a/requirements.txt b/requirements.txt index ab4f7f3..f8463b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ numpy>=1.24 openpyxl>=3.1 requests>=2.31 beautifulsoup4>=4.12 +flask-limiter>=4.0