feat(BIZ-129): Flask-Limiter 集成 + 登录/重置限流
- requirements.txt: flask-limiter>=4.0
- app.py: 创建 Limiter 实例(memory:// 存储,60/分钟默认)
- auth_api.py:
- 简化 register_auth_api(app, limiter=None)——limiter 仅作能力标记
- _rate_limit(rate, methods) 装饰器:手写 IP+endpoint 滚动窗口限流
(Flask-Limiter 在测试 client 下不生效,故手动实现;多进程需切换 Redis)
- login / reset-request / reset-confirm 三个路由应用装饰器
限流策略(架构决策 #5/#6 配合):
- /login: 5 次/分钟(IP 级爆破防御,配合账号级 5 次锁定 15min)
- /reset-request: 3 次/分钟(防邮件/短信滥用)
- /reset-confirm: 5 次/分钟(防 token 暴力猜解)
测试结果(test_client 压测):
- login: 1-5 次业务 401,6 次起 429 ✅
- reset-request: 1-3 次业务 200,4 次起 429 ✅
依赖:commit b002e92 的 7 路由蓝图。
BIZ-127 架构决策:
✓ #5 限流 5 次/分钟(IP 级,与账号级锁定 15min 双重防御)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -31,6 +31,25 @@ from lottery import DoubleColorBallGenerator
|
|||||||
|
|
||||||
app = Flask(__name__)
|
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)
|
# 认证 API 蓝图注册(BIZ-129 方案 C)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -38,7 +57,7 @@ app = Flask(__name__)
|
|||||||
# reset-request / reset-confirm。session 存 sha256 hash 入库(架构决策 #2)。
|
# reset-request / reset-confirm。session 存 sha256 hash 入库(架构决策 #2)。
|
||||||
try:
|
try:
|
||||||
from auth_api import register_auth_api
|
from auth_api import register_auth_api
|
||||||
register_auth_api(app)
|
register_auth_api(app, limiter=limiter)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# auth_api.py 未就绪时不影响主流程(保持向后兼容)
|
# auth_api.py 未就绪时不影响主流程(保持向后兼容)
|
||||||
print(f'[BIZ-129] auth_api 蓝图注册跳过:{e}', file=sys.stderr)
|
print(f'[BIZ-129] auth_api 蓝图注册跳过:{e}', file=sys.stderr)
|
||||||
|
|||||||
+87
-7
@@ -52,6 +52,88 @@ from auth import (
|
|||||||
|
|
||||||
auth_bp = Blueprint('auth_api', __name__, url_prefix='/api/auth')
|
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'])
|
@auth_bp.route('/login', methods=['POST'])
|
||||||
|
@_rate_limit('5 per minute', methods=['POST'])
|
||||||
def login():
|
def login():
|
||||||
init_db()
|
init_db()
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
@@ -250,6 +333,7 @@ def change_password():
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
@auth_bp.route('/reset-request', methods=['POST'])
|
@auth_bp.route('/reset-request', methods=['POST'])
|
||||||
|
@_rate_limit('3 per minute', methods=['POST'])
|
||||||
def reset_request():
|
def reset_request():
|
||||||
init_db()
|
init_db()
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
@@ -294,6 +378,7 @@ def reset_request():
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
@auth_bp.route('/reset-confirm', methods=['POST'])
|
@auth_bp.route('/reset-confirm', methods=['POST'])
|
||||||
|
@_rate_limit('5 per minute', methods=['POST'])
|
||||||
def reset_confirm():
|
def reset_confirm():
|
||||||
init_db()
|
init_db()
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
@@ -355,10 +440,5 @@ def refresh():
|
|||||||
return _ok({'refreshed': True, 'expires_at': new_expires})
|
return _ok({'refreshed': True, 'expires_at': new_expires})
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# 蓝图注册入口已定义在文件顶部 register_auth_api(app, limiter=None)
|
||||||
# 蓝图注册入口(供 app.py 调用)
|
# 避免重复定义(早期版本残留已清理)
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
def register_auth_api(app):
|
|
||||||
"""在 app.py 里 app.register_blueprint(auth_bp) 即可。"""
|
|
||||||
app.register_blueprint(auth_bp)
|
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ numpy>=1.24
|
|||||||
openpyxl>=3.1
|
openpyxl>=3.1
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
beautifulsoup4>=4.12
|
beautifulsoup4>=4.12
|
||||||
|
flask-limiter>=4.0
|
||||||
|
|||||||
Reference in New Issue
Block a user