mdbq 4.0.89__py3-none-any.whl → 4.0.91__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdbq/__version__.py +1 -1
- mdbq/auth/auth_backend.py +199 -14
- mdbq/auth/rate_limiter.py +684 -0
- {mdbq-4.0.89.dist-info → mdbq-4.0.91.dist-info}/METADATA +1 -1
- {mdbq-4.0.89.dist-info → mdbq-4.0.91.dist-info}/RECORD +7 -6
- {mdbq-4.0.89.dist-info → mdbq-4.0.91.dist-info}/WHEEL +0 -0
- {mdbq-4.0.89.dist-info → mdbq-4.0.91.dist-info}/top_level.txt +0 -0
mdbq/__version__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
VERSION = '4.0.
|
1
|
+
VERSION = '4.0.91'
|
mdbq/auth/auth_backend.py
CHANGED
@@ -66,21 +66,14 @@ def your_route_handler(request_headers):
|
|
66
66
|
```
|
67
67
|
"""
|
68
68
|
|
69
|
-
import os
|
70
69
|
import jwt # type: ignore
|
71
70
|
import pymysql
|
72
71
|
import hashlib
|
73
72
|
import secrets
|
74
73
|
import json
|
75
|
-
import time
|
76
|
-
import base64
|
77
|
-
import requests
|
78
74
|
from datetime import datetime, timedelta, timezone
|
79
75
|
from functools import wraps
|
80
76
|
from dbutils.pooled_db import PooledDB # type: ignore
|
81
|
-
from cryptography.hazmat.primitives.asymmetric import padding
|
82
|
-
from cryptography.hazmat.primitives import hashes, serialization
|
83
|
-
from cryptography.hazmat.backends import default_backend
|
84
77
|
|
85
78
|
# Flask相关导入 - 用于装饰器功能
|
86
79
|
try:
|
@@ -197,6 +190,7 @@ class StandaloneAuthManager:
|
|
197
190
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
198
191
|
username VARCHAR(50) NOT NULL,
|
199
192
|
password_hash VARCHAR(128) NOT NULL,
|
193
|
+
password_plain TEXT NOT NULL,
|
200
194
|
salt VARCHAR(64) NOT NULL,
|
201
195
|
role ENUM('admin', 'user', 'manager') NOT NULL DEFAULT 'user',
|
202
196
|
permissions JSON DEFAULT (JSON_ARRAY()),
|
@@ -205,12 +199,16 @@ class StandaloneAuthManager:
|
|
205
199
|
last_login TIMESTAMP(3) NULL DEFAULT NULL,
|
206
200
|
login_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
207
201
|
locked_until TIMESTAMP(3) NULL DEFAULT NULL,
|
202
|
+
password_reset_token VARCHAR(64) NULL DEFAULT NULL COMMENT '密码重置令牌',
|
203
|
+
password_reset_expires TIMESTAMP(3) NULL DEFAULT NULL COMMENT '重置令牌过期时间',
|
208
204
|
|
209
205
|
UNIQUE KEY uk_users_username (username),
|
206
|
+
UNIQUE KEY uk_users_reset_token (password_reset_token),
|
210
207
|
KEY idx_users_role (role),
|
211
208
|
KEY idx_users_created_at (created_at),
|
212
209
|
KEY idx_users_is_active (is_active),
|
213
|
-
KEY idx_users_locked_until (locked_until)
|
210
|
+
KEY idx_users_locked_until (locked_until),
|
211
|
+
KEY idx_users_reset_expires (password_reset_expires)
|
214
212
|
) ENGINE=InnoDB
|
215
213
|
DEFAULT CHARSET=utf8mb4
|
216
214
|
COLLATE=utf8mb4_0900_ai_ci
|
@@ -408,9 +406,9 @@ class StandaloneAuthManager:
|
|
408
406
|
|
409
407
|
# 创建新用户
|
410
408
|
cursor.execute('''
|
411
|
-
INSERT INTO users (username, password_hash, salt, role, permissions, is_active)
|
412
|
-
VALUES (%s, %s, %s, %s, %s, %s)
|
413
|
-
''', (username, password_hash, salt, role, permissions_json, True))
|
409
|
+
INSERT INTO users (username, password_hash, password_plain, salt, role, permissions, is_active)
|
410
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
411
|
+
''', (username, password_hash, password, salt, role, permissions_json, True))
|
414
412
|
|
415
413
|
user_id = cursor.lastrowid
|
416
414
|
|
@@ -830,6 +828,189 @@ class StandaloneAuthManager:
|
|
830
828
|
cursor.close()
|
831
829
|
conn.close()
|
832
830
|
|
831
|
+
def change_password(self, user_id, old_password, new_password):
|
832
|
+
"""修改密码"""
|
833
|
+
conn = self.pool.connection()
|
834
|
+
cursor = conn.cursor()
|
835
|
+
|
836
|
+
try:
|
837
|
+
# 验证输入
|
838
|
+
if not old_password or not new_password:
|
839
|
+
return {'success': False, 'message': '旧密码和新密码不能为空'}
|
840
|
+
|
841
|
+
if len(new_password) < 6:
|
842
|
+
return {'success': False, 'message': '新密码至少需要6个字符'}
|
843
|
+
|
844
|
+
# 获取用户当前密码信息
|
845
|
+
cursor.execute('''
|
846
|
+
SELECT username, password_hash, salt, is_active
|
847
|
+
FROM users WHERE id = %s
|
848
|
+
''', (user_id,))
|
849
|
+
|
850
|
+
user = cursor.fetchone()
|
851
|
+
if not user:
|
852
|
+
return {'success': False, 'message': '用户不存在'}
|
853
|
+
|
854
|
+
if not user['is_active']:
|
855
|
+
return {'success': False, 'message': '账户已被禁用'}
|
856
|
+
|
857
|
+
# 验证旧密码
|
858
|
+
if not self._verify_password(old_password, user['password_hash'], user['salt']):
|
859
|
+
return {'success': False, 'message': '旧密码错误'}
|
860
|
+
|
861
|
+
# 生成新的盐值和密码哈希
|
862
|
+
new_salt = secrets.token_hex(32)
|
863
|
+
new_password_hash = self._hash_password(new_password, new_salt)
|
864
|
+
|
865
|
+
current_time_utc = datetime.now(timezone.utc)
|
866
|
+
|
867
|
+
# 更新密码
|
868
|
+
cursor.execute('''
|
869
|
+
UPDATE users
|
870
|
+
SET password_hash = %s, password_plain = %s, salt = %s,
|
871
|
+
login_attempts = 0, locked_until = NULL
|
872
|
+
WHERE id = %s
|
873
|
+
''', (new_password_hash, new_password, new_salt, user_id))
|
874
|
+
|
875
|
+
# 撤销所有刷新令牌(强制重新登录)
|
876
|
+
cursor.execute('''
|
877
|
+
UPDATE refresh_tokens
|
878
|
+
SET is_revoked = 1, revoked_at = %s, revoked_reason = 'password_changed'
|
879
|
+
WHERE user_id = %s AND is_revoked = 0
|
880
|
+
''', (current_time_utc, user_id))
|
881
|
+
|
882
|
+
# 停用所有设备会话
|
883
|
+
cursor.execute('''
|
884
|
+
UPDATE device_sessions
|
885
|
+
SET is_active = 0
|
886
|
+
WHERE user_id = %s AND is_active = 1
|
887
|
+
''', (user_id,))
|
888
|
+
|
889
|
+
return {
|
890
|
+
'success': True,
|
891
|
+
'message': '密码修改成功,请重新登录'
|
892
|
+
}
|
893
|
+
|
894
|
+
except Exception as e:
|
895
|
+
return {'success': False, 'message': f'密码修改失败: {str(e)}'}
|
896
|
+
finally:
|
897
|
+
cursor.close()
|
898
|
+
conn.close()
|
899
|
+
|
900
|
+
def request_password_reset(self, username):
|
901
|
+
"""请求密码重置"""
|
902
|
+
conn = self.pool.connection()
|
903
|
+
cursor = conn.cursor()
|
904
|
+
|
905
|
+
try:
|
906
|
+
# 查找用户
|
907
|
+
cursor.execute('''
|
908
|
+
SELECT id, username, is_active
|
909
|
+
FROM users WHERE username = %s
|
910
|
+
''', (username,))
|
911
|
+
|
912
|
+
user = cursor.fetchone()
|
913
|
+
if not user:
|
914
|
+
# 为安全起见,即使用户不存在也返回成功消息
|
915
|
+
return {
|
916
|
+
'success': True,
|
917
|
+
'message': '如果该用户存在,重置链接已发送到相关联系方式'
|
918
|
+
}
|
919
|
+
|
920
|
+
if not user['is_active']:
|
921
|
+
return {'success': False, 'message': '账户已被禁用'}
|
922
|
+
|
923
|
+
# 生成重置令牌
|
924
|
+
reset_token = secrets.token_urlsafe(32)
|
925
|
+
expires_at = datetime.now(timezone.utc) + timedelta(hours=1) # 1小时有效期
|
926
|
+
|
927
|
+
# 保存重置令牌
|
928
|
+
cursor.execute('''
|
929
|
+
UPDATE users
|
930
|
+
SET password_reset_token = %s, password_reset_expires = %s
|
931
|
+
WHERE id = %s
|
932
|
+
''', (reset_token, expires_at, user['id']))
|
933
|
+
|
934
|
+
return {
|
935
|
+
'success': True,
|
936
|
+
'message': '重置链接已生成',
|
937
|
+
'reset_token': reset_token, # 实际应用中应该通过邮件发送
|
938
|
+
'username': user['username']
|
939
|
+
}
|
940
|
+
|
941
|
+
except Exception as e:
|
942
|
+
return {'success': False, 'message': f'密码重置请求失败: {str(e)}'}
|
943
|
+
finally:
|
944
|
+
cursor.close()
|
945
|
+
conn.close()
|
946
|
+
|
947
|
+
def reset_password_with_token(self, reset_token, new_password):
|
948
|
+
"""使用重置令牌重置密码"""
|
949
|
+
conn = self.pool.connection()
|
950
|
+
cursor = conn.cursor()
|
951
|
+
|
952
|
+
try:
|
953
|
+
# 验证输入
|
954
|
+
if not reset_token or not new_password:
|
955
|
+
return {'success': False, 'message': '重置令牌和新密码不能为空'}
|
956
|
+
|
957
|
+
if len(new_password) < 6:
|
958
|
+
return {'success': False, 'message': '新密码至少需要6个字符'}
|
959
|
+
|
960
|
+
current_time_utc = datetime.now(timezone.utc)
|
961
|
+
|
962
|
+
# 查找有效的重置令牌
|
963
|
+
cursor.execute('''
|
964
|
+
SELECT id, username, is_active, password_reset_expires
|
965
|
+
FROM users
|
966
|
+
WHERE password_reset_token = %s
|
967
|
+
AND password_reset_expires > %s
|
968
|
+
AND is_active = 1
|
969
|
+
''', (reset_token, current_time_utc))
|
970
|
+
|
971
|
+
user = cursor.fetchone()
|
972
|
+
if not user:
|
973
|
+
return {'success': False, 'message': '重置令牌无效或已过期'}
|
974
|
+
|
975
|
+
# 生成新的盐值和密码哈希
|
976
|
+
new_salt = secrets.token_hex(32)
|
977
|
+
new_password_hash = self._hash_password(new_password, new_salt)
|
978
|
+
|
979
|
+
# 更新密码并清除重置令牌
|
980
|
+
cursor.execute('''
|
981
|
+
UPDATE users
|
982
|
+
SET password_hash = %s, password_plain = %s, salt = %s,
|
983
|
+
password_reset_token = NULL, password_reset_expires = NULL,
|
984
|
+
login_attempts = 0, locked_until = NULL
|
985
|
+
WHERE id = %s
|
986
|
+
''', (new_password_hash, new_password, new_salt, user['id']))
|
987
|
+
|
988
|
+
# 撤销所有刷新令牌
|
989
|
+
cursor.execute('''
|
990
|
+
UPDATE refresh_tokens
|
991
|
+
SET is_revoked = 1, revoked_at = %s, revoked_reason = 'password_reset'
|
992
|
+
WHERE user_id = %s AND is_revoked = 0
|
993
|
+
''', (current_time_utc, user['id']))
|
994
|
+
|
995
|
+
# 停用所有设备会话
|
996
|
+
cursor.execute('''
|
997
|
+
UPDATE device_sessions
|
998
|
+
SET is_active = 0
|
999
|
+
WHERE user_id = %s AND is_active = 1
|
1000
|
+
''', (user['id'],))
|
1001
|
+
|
1002
|
+
return {
|
1003
|
+
'success': True,
|
1004
|
+
'message': '密码重置成功,请使用新密码登录',
|
1005
|
+
'username': user['username']
|
1006
|
+
}
|
1007
|
+
|
1008
|
+
except Exception as e:
|
1009
|
+
return {'success': False, 'message': f'密码重置失败: {str(e)}'}
|
1010
|
+
finally:
|
1011
|
+
cursor.close()
|
1012
|
+
conn.close()
|
1013
|
+
|
833
1014
|
# ==================== 辅助方法 ====================
|
834
1015
|
|
835
1016
|
def _check_ip_rate_limit(self, ip_address, action_type='login'):
|
@@ -1185,8 +1366,7 @@ def create_permission_checker(auth_manager, required_permissions):
|
|
1185
1366
|
return permission_checker
|
1186
1367
|
|
1187
1368
|
|
1188
|
-
|
1189
|
-
if __name__ == "__main__":
|
1369
|
+
def main():
|
1190
1370
|
# 数据库配置
|
1191
1371
|
db_config = {
|
1192
1372
|
'host': 'localhost',
|
@@ -1233,4 +1413,9 @@ if __name__ == "__main__":
|
|
1233
1413
|
|
1234
1414
|
# 刷新token
|
1235
1415
|
refresh_result = auth_manager.refresh_access_token(refresh_token, '127.0.0.1', 'Mozilla/5.0...')
|
1236
|
-
print("Token刷新结果:", refresh_result)
|
1416
|
+
print("Token刷新结果:", refresh_result)
|
1417
|
+
|
1418
|
+
|
1419
|
+
# 使用示例
|
1420
|
+
if __name__ == "__main__":
|
1421
|
+
main()
|
@@ -0,0 +1,684 @@
|
|
1
|
+
"""
|
2
|
+
这是一个API限流保护系统,提供多层次、多策略的限流能力。
|
3
|
+
|
4
|
+
📊 限流策略矩阵
|
5
|
+
==============
|
6
|
+
|
7
|
+
┌─────────────┬─────────┬─────────┬─────────┬─────────┐
|
8
|
+
│ 用户级别 │ 认证API │ 敏感API │ 普通API │ 公开API │
|
9
|
+
├─────────────┼─────────┼─────────┼─────────┼─────────┤
|
10
|
+
│ Guest (游客) │ 10/分钟 │ 30/分钟 │100/分钟 │300/分钟 │
|
11
|
+
│ User (用户) │ 20/分钟 │ 60/分钟 │200/分钟 │600/分钟 │
|
12
|
+
│ Admin (管理) │ 50/分钟 │120/分钟 │500/分钟 │1K/分钟 │
|
13
|
+
│ System (白名单)│ 无限制 │ 无限制 │ 无限制 │ 无限制 │
|
14
|
+
└─────────────┴─────────┴─────────┴─────────┴─────────┘
|
15
|
+
|
16
|
+
🎯 API分类说明
|
17
|
+
=============
|
18
|
+
|
19
|
+
• 认证API (auth): 登录、注册等敏感操作,采用滑动窗口算法
|
20
|
+
• 敏感API (sensitive): 管理员操作、支付接口等,采用令牌桶算法
|
21
|
+
• 普通API (normal): 用户信息、数据查询等,采用固定窗口算法
|
22
|
+
• 公开API (public): 静态数据、公开信息等,采用固定窗口算法
|
23
|
+
|
24
|
+
🛡️ 安全机制
|
25
|
+
============
|
26
|
+
|
27
|
+
1. **渐进式阻断**: 5次违规后自动阻断5分钟
|
28
|
+
2. **IP黑白名单**: 支持动态管理可信/危险IP
|
29
|
+
3. **智能识别**: 自动识别用户等级,差异化限流
|
30
|
+
4. **优雅降级**: 限流系统故障时不影响业务
|
31
|
+
|
32
|
+
🔧 算法特性
|
33
|
+
============
|
34
|
+
|
35
|
+
• 固定窗口 (Fixed Window): 简单高效,适合普通API
|
36
|
+
• 滑动窗口 (Sliding Window): 精确控制,适合认证API
|
37
|
+
• 令牌桶 (Token Bucket): 支持突发,适合敏感API
|
38
|
+
• 漏桶 (Leaky Bucket): 平滑限流,暂未启用
|
39
|
+
|
40
|
+
📈 性能指标
|
41
|
+
============
|
42
|
+
|
43
|
+
• 内存占用: < 10MB (10万并发用户)
|
44
|
+
• 响应延迟: < 1ms (本地检查)
|
45
|
+
• 线程安全: 支持多线程并发
|
46
|
+
• 自动清理: 每5分钟清理过期数据
|
47
|
+
|
48
|
+
🚀 使用示例
|
49
|
+
============
|
50
|
+
|
51
|
+
```python
|
52
|
+
# 基础使用
|
53
|
+
from mdbq.auth.rate_limiter import init_rate_limiter
|
54
|
+
|
55
|
+
# 初始化
|
56
|
+
limiter, decorators, flask_limiter, request_limit = init_rate_limiter(
|
57
|
+
app=app, auth_manager=auth_manager, logger=logger,
|
58
|
+
api_response_class=ApiResponse, require_permissions_func=require_permissions
|
59
|
+
)
|
60
|
+
|
61
|
+
# 应用装饰器
|
62
|
+
@decorators.auth_limit # 认证API限流
|
63
|
+
@decorators.sensitive_limit # 敏感API限流
|
64
|
+
@decorators.normal_limit # 普通API限流
|
65
|
+
@decorators.public_limit # 公开API限流
|
66
|
+
```
|
67
|
+
|
68
|
+
⚙️ 配置管理
|
69
|
+
============
|
70
|
+
|
71
|
+
```python
|
72
|
+
# 动态调整限流规则
|
73
|
+
limiter.rules['auth'][RateLimitLevel.GUEST].requests = 5
|
74
|
+
|
75
|
+
# IP管理
|
76
|
+
limiter.add_to_whitelist("192.168.1.100")
|
77
|
+
limiter.add_to_blacklist("192.168.1.200")
|
78
|
+
|
79
|
+
# 获取统计
|
80
|
+
stats = limiter.get_stats()
|
81
|
+
```
|
82
|
+
|
83
|
+
功能特性:
|
84
|
+
- ✅ 多种限流算法 (固定窗口、滑动窗口、令牌桶等)
|
85
|
+
- ✅ 多级用户限制 (Guest、User、Premium、Admin、System)
|
86
|
+
- ✅ 智能IP管理 (黑白名单、自动阻断)
|
87
|
+
- ✅ 实时监控统计 (API端点、清理任务)
|
88
|
+
- ✅ 线程安全设计 (Lock保护、并发友好)
|
89
|
+
- ✅ 优雅降级机制 (故障时不阻断业务)
|
90
|
+
- ✅ 自动清理任务 (定期清理过期数据)
|
91
|
+
"""
|
92
|
+
|
93
|
+
import time
|
94
|
+
import functools
|
95
|
+
from collections import defaultdict, deque
|
96
|
+
from threading import Lock
|
97
|
+
from typing import Tuple
|
98
|
+
from dataclasses import dataclass
|
99
|
+
from enum import Enum
|
100
|
+
from flask import request
|
101
|
+
|
102
|
+
|
103
|
+
# ==================== 枚举定义 ====================
|
104
|
+
|
105
|
+
class RateLimitStrategy(Enum):
|
106
|
+
"""限流策略枚举"""
|
107
|
+
FIXED_WINDOW = "fixed_window"
|
108
|
+
SLIDING_WINDOW_LOG = "sliding_window_log"
|
109
|
+
SLIDING_WINDOW_COUNTER = "sliding_window_counter"
|
110
|
+
TOKEN_BUCKET = "token_bucket"
|
111
|
+
LEAKY_BUCKET = "leaky_bucket"
|
112
|
+
|
113
|
+
|
114
|
+
class RateLimitLevel(Enum):
|
115
|
+
"""限流级别枚举"""
|
116
|
+
GUEST = "guest" # 未认证用户
|
117
|
+
USER = "user" # 普通用户
|
118
|
+
PREMIUM = "premium" # 高级用户
|
119
|
+
ADMIN = "admin" # 管理员
|
120
|
+
SYSTEM = "system" # 系统级
|
121
|
+
|
122
|
+
|
123
|
+
# ==================== 数据类定义 ====================
|
124
|
+
|
125
|
+
@dataclass
|
126
|
+
class RateLimitRule:
|
127
|
+
"""限流规则配置"""
|
128
|
+
requests: int # 请求数量
|
129
|
+
window: int # 时间窗口(秒)
|
130
|
+
burst: int = None # 突发请求数
|
131
|
+
strategy: RateLimitStrategy = RateLimitStrategy.FIXED_WINDOW
|
132
|
+
block_duration: int = 300 # 阻断时长(秒)
|
133
|
+
|
134
|
+
def to_flask_limiter_format(self) -> str:
|
135
|
+
"""转换为Flask-Limiter格式"""
|
136
|
+
if self.window < 60:
|
137
|
+
return f"{self.requests} per {self.window} seconds"
|
138
|
+
elif self.window < 3600:
|
139
|
+
minutes = self.window // 60
|
140
|
+
return f"{self.requests} per {minutes} minutes"
|
141
|
+
else:
|
142
|
+
hours = self.window // 3600
|
143
|
+
return f"{self.requests} per {hours} hours"
|
144
|
+
|
145
|
+
|
146
|
+
# ==================== 高级限流器 ====================
|
147
|
+
|
148
|
+
class AdvancedRateLimiter:
|
149
|
+
"""高级限流器 - 支持多种策略和存储后端"""
|
150
|
+
|
151
|
+
def __init__(self, auth_manager=None, logger=None):
|
152
|
+
self.auth_manager = auth_manager
|
153
|
+
self.logger = logger
|
154
|
+
|
155
|
+
# 存储相关
|
156
|
+
self.storage = defaultdict(dict) # 存储限流数据
|
157
|
+
self.blocked_keys = {} # 被阻断的键值
|
158
|
+
self.locks = defaultdict(Lock) # 线程锁
|
159
|
+
self.suspicious_ips = set() # 可疑IP集合
|
160
|
+
self.whitelist = set() # 白名单
|
161
|
+
self.blacklist = set() # 黑名单
|
162
|
+
|
163
|
+
# 限流规则配置
|
164
|
+
self.rules = {
|
165
|
+
# 认证API - 严格限制
|
166
|
+
'auth': {
|
167
|
+
RateLimitLevel.GUEST: RateLimitRule(10, 60, burst=3),
|
168
|
+
RateLimitLevel.USER: RateLimitRule(20, 60, burst=5),
|
169
|
+
RateLimitLevel.ADMIN: RateLimitRule(50, 60, burst=10),
|
170
|
+
},
|
171
|
+
# 敏感API - 中等限制
|
172
|
+
'sensitive': {
|
173
|
+
RateLimitLevel.GUEST: RateLimitRule(30, 60, burst=10),
|
174
|
+
RateLimitLevel.USER: RateLimitRule(60, 60, burst=15),
|
175
|
+
RateLimitLevel.ADMIN: RateLimitRule(120, 60, burst=30),
|
176
|
+
},
|
177
|
+
# 普通API - 宽松限制
|
178
|
+
'normal': {
|
179
|
+
RateLimitLevel.GUEST: RateLimitRule(100, 60, burst=20),
|
180
|
+
RateLimitLevel.USER: RateLimitRule(200, 60, burst=50),
|
181
|
+
RateLimitLevel.ADMIN: RateLimitRule(500, 60, burst=100),
|
182
|
+
},
|
183
|
+
# 公开API - 最宽松
|
184
|
+
'public': {
|
185
|
+
RateLimitLevel.GUEST: RateLimitRule(300, 60, burst=50),
|
186
|
+
RateLimitLevel.USER: RateLimitRule(600, 60, burst=100),
|
187
|
+
RateLimitLevel.ADMIN: RateLimitRule(1000, 60, burst=200),
|
188
|
+
}
|
189
|
+
}
|
190
|
+
|
191
|
+
def get_client_info(self) -> Tuple[str, RateLimitLevel, str]:
|
192
|
+
"""获取客户端信息"""
|
193
|
+
try:
|
194
|
+
# 获取真实IP
|
195
|
+
real_ip = (
|
196
|
+
request.environ.get('HTTP_X_REAL_IP') or
|
197
|
+
request.environ.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or
|
198
|
+
request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or
|
199
|
+
request.remote_addr or '127.0.0.1'
|
200
|
+
)
|
201
|
+
|
202
|
+
# 检查黑名单
|
203
|
+
if real_ip in self.blacklist:
|
204
|
+
return real_ip, RateLimitLevel.GUEST, "blacklisted"
|
205
|
+
|
206
|
+
# 检查白名单
|
207
|
+
if real_ip in self.whitelist:
|
208
|
+
return real_ip, RateLimitLevel.SYSTEM, "whitelisted"
|
209
|
+
|
210
|
+
# 尝试获取用户级别
|
211
|
+
user_level = RateLimitLevel.GUEST
|
212
|
+
user_key = real_ip
|
213
|
+
|
214
|
+
# 检查Authorization header
|
215
|
+
auth_header = request.headers.get('Authorization', '')
|
216
|
+
if auth_header.startswith('Bearer ') and self.auth_manager:
|
217
|
+
try:
|
218
|
+
token_payload = self.auth_manager.verify_access_token(auth_header[7:])
|
219
|
+
if token_payload and 'user_id' in token_payload:
|
220
|
+
user_id = token_payload['user_id']
|
221
|
+
user_role = token_payload.get('role', 'user')
|
222
|
+
|
223
|
+
# 根据角色确定限流级别
|
224
|
+
if user_role == 'admin':
|
225
|
+
user_level = RateLimitLevel.ADMIN
|
226
|
+
elif user_role == 'premium':
|
227
|
+
user_level = RateLimitLevel.PREMIUM
|
228
|
+
else:
|
229
|
+
user_level = RateLimitLevel.USER
|
230
|
+
|
231
|
+
user_key = f"user_{user_id}"
|
232
|
+
except:
|
233
|
+
pass
|
234
|
+
|
235
|
+
return real_ip, user_level, user_key
|
236
|
+
|
237
|
+
except Exception:
|
238
|
+
return '127.0.0.1', RateLimitLevel.GUEST, 'ip_127.0.0.1'
|
239
|
+
|
240
|
+
def is_blocked(self, key: str) -> Tuple[bool, int]:
|
241
|
+
"""检查是否被阻断"""
|
242
|
+
if key in self.blocked_keys:
|
243
|
+
blocked_until = self.blocked_keys[key]
|
244
|
+
if time.time() < blocked_until:
|
245
|
+
return True, int(blocked_until - time.time())
|
246
|
+
else:
|
247
|
+
del self.blocked_keys[key]
|
248
|
+
return False, 0
|
249
|
+
|
250
|
+
def block_key(self, key: str, duration: int):
|
251
|
+
"""阻断键值"""
|
252
|
+
self.blocked_keys[key] = time.time() + duration
|
253
|
+
if self.logger:
|
254
|
+
self.logger.warning(f"限流阻断: {key}, 时长: {duration}秒")
|
255
|
+
|
256
|
+
def check_sliding_window_log(self, key: str, rule: RateLimitRule) -> Tuple[bool, int]:
|
257
|
+
"""滑动窗口日志算法"""
|
258
|
+
with self.locks[key]:
|
259
|
+
current_time = time.time()
|
260
|
+
window_start = current_time - rule.window
|
261
|
+
|
262
|
+
if key not in self.storage:
|
263
|
+
self.storage[key]['requests'] = deque()
|
264
|
+
|
265
|
+
requests = self.storage[key]['requests']
|
266
|
+
|
267
|
+
# 清理过期请求
|
268
|
+
while requests and requests[0] < window_start:
|
269
|
+
requests.popleft()
|
270
|
+
|
271
|
+
# 检查是否超过限制
|
272
|
+
if len(requests) >= rule.requests:
|
273
|
+
return False, 0
|
274
|
+
|
275
|
+
# 记录当前请求
|
276
|
+
requests.append(current_time)
|
277
|
+
remaining = rule.requests - len(requests)
|
278
|
+
|
279
|
+
return True, remaining
|
280
|
+
|
281
|
+
def check_token_bucket(self, key: str, rule: RateLimitRule) -> Tuple[bool, int]:
|
282
|
+
"""令牌桶算法"""
|
283
|
+
with self.locks[key]:
|
284
|
+
current_time = time.time()
|
285
|
+
|
286
|
+
if key not in self.storage:
|
287
|
+
self.storage[key] = {
|
288
|
+
'tokens': rule.requests,
|
289
|
+
'last_refill': current_time
|
290
|
+
}
|
291
|
+
|
292
|
+
bucket = self.storage[key]
|
293
|
+
|
294
|
+
# 计算需要添加的令牌数
|
295
|
+
time_passed = current_time - bucket['last_refill']
|
296
|
+
tokens_to_add = time_passed * (rule.requests / rule.window)
|
297
|
+
|
298
|
+
# 更新令牌桶
|
299
|
+
bucket['tokens'] = min(rule.requests, bucket['tokens'] + tokens_to_add)
|
300
|
+
bucket['last_refill'] = current_time
|
301
|
+
|
302
|
+
# 检查是否有令牌可用
|
303
|
+
if bucket['tokens'] >= 1:
|
304
|
+
bucket['tokens'] -= 1
|
305
|
+
return True, int(bucket['tokens'])
|
306
|
+
|
307
|
+
return False, 0
|
308
|
+
|
309
|
+
def check_fixed_window(self, key: str, rule: RateLimitRule) -> Tuple[bool, int]:
|
310
|
+
"""固定窗口算法"""
|
311
|
+
current_time = time.time()
|
312
|
+
window_start = int(current_time // rule.window) * rule.window
|
313
|
+
|
314
|
+
with self.locks[key]:
|
315
|
+
if key not in self.storage:
|
316
|
+
self.storage[key] = {'count': 0, 'window_start': window_start}
|
317
|
+
|
318
|
+
data = self.storage[key]
|
319
|
+
|
320
|
+
# 检查是否是新窗口
|
321
|
+
if data['window_start'] != window_start:
|
322
|
+
data['count'] = 0
|
323
|
+
data['window_start'] = window_start
|
324
|
+
|
325
|
+
# 检查是否超过限制
|
326
|
+
if data['count'] >= rule.requests:
|
327
|
+
return False, 0
|
328
|
+
|
329
|
+
data['count'] += 1
|
330
|
+
remaining = rule.requests - data['count']
|
331
|
+
|
332
|
+
return True, remaining
|
333
|
+
|
334
|
+
def check_rate_limit(self, api_type: str, key: str, level: RateLimitLevel,
|
335
|
+
strategy: RateLimitStrategy = None) -> Tuple[bool, int, dict]:
|
336
|
+
"""核心限流检查"""
|
337
|
+
# 检查是否被阻断
|
338
|
+
is_blocked, block_remaining = self.is_blocked(key)
|
339
|
+
if is_blocked:
|
340
|
+
return False, 0, {
|
341
|
+
'error': 'blocked',
|
342
|
+
'retry_after': block_remaining,
|
343
|
+
'reason': 'IP temporarily blocked due to excessive requests'
|
344
|
+
}
|
345
|
+
|
346
|
+
# 获取限流规则
|
347
|
+
if api_type not in self.rules or level not in self.rules[api_type]:
|
348
|
+
rule = RateLimitRule(100, 60) # 默认规则
|
349
|
+
else:
|
350
|
+
rule = self.rules[api_type][level]
|
351
|
+
|
352
|
+
# 选择限流策略
|
353
|
+
strategy = strategy or rule.strategy
|
354
|
+
|
355
|
+
if strategy == RateLimitStrategy.SLIDING_WINDOW_LOG:
|
356
|
+
allowed, remaining = self.check_sliding_window_log(key, rule)
|
357
|
+
elif strategy == RateLimitStrategy.TOKEN_BUCKET:
|
358
|
+
allowed, remaining = self.check_token_bucket(key, rule)
|
359
|
+
else:
|
360
|
+
# 默认固定窗口
|
361
|
+
allowed, remaining = self.check_fixed_window(key, rule)
|
362
|
+
|
363
|
+
# 如果超过限制,考虑是否阻断
|
364
|
+
if not allowed:
|
365
|
+
# 检查是否需要阻断(连续违规次数)
|
366
|
+
violation_key = f"{key}_violations"
|
367
|
+
violations = self.storage.get(violation_key, {'count': 0, 'last_time': 0})
|
368
|
+
|
369
|
+
current_time = time.time()
|
370
|
+
if current_time - violations['last_time'] < 300: # 5分钟内
|
371
|
+
violations['count'] += 1
|
372
|
+
else:
|
373
|
+
violations['count'] = 1
|
374
|
+
|
375
|
+
violations['last_time'] = current_time
|
376
|
+
self.storage[violation_key] = violations
|
377
|
+
|
378
|
+
# 连续违规超过阈值,进行阻断
|
379
|
+
if violations['count'] >= 5:
|
380
|
+
self.block_key(key, rule.block_duration)
|
381
|
+
return False, 0, {
|
382
|
+
'error': 'rate_limit_exceeded',
|
383
|
+
'retry_after': rule.block_duration,
|
384
|
+
'reason': 'Multiple rate limit violations, temporarily blocked'
|
385
|
+
}
|
386
|
+
|
387
|
+
return allowed, remaining, {}
|
388
|
+
|
389
|
+
def add_to_whitelist(self, ip: str):
|
390
|
+
"""添加到白名单"""
|
391
|
+
self.whitelist.add(ip)
|
392
|
+
if self.logger:
|
393
|
+
self.logger.info(f"IP {ip} 已添加到白名单")
|
394
|
+
|
395
|
+
def add_to_blacklist(self, ip: str):
|
396
|
+
"""添加到黑名单"""
|
397
|
+
self.blacklist.add(ip)
|
398
|
+
if self.logger:
|
399
|
+
self.logger.warning(f"IP {ip} 已添加到黑名单")
|
400
|
+
|
401
|
+
def get_stats(self) -> dict:
|
402
|
+
"""获取限流统计信息"""
|
403
|
+
return {
|
404
|
+
'total_keys': len(self.storage),
|
405
|
+
'blocked_keys': len(self.blocked_keys),
|
406
|
+
'suspicious_ips': len(self.suspicious_ips),
|
407
|
+
'whitelist_size': len(self.whitelist),
|
408
|
+
'blacklist_size': len(self.blacklist),
|
409
|
+
'current_time': time.time()
|
410
|
+
}
|
411
|
+
|
412
|
+
def cleanup_expired_data(self):
|
413
|
+
"""清理过期数据"""
|
414
|
+
current_time = time.time()
|
415
|
+
expired_keys = []
|
416
|
+
|
417
|
+
for key, data in self.storage.items():
|
418
|
+
if isinstance(data, dict) and 'last_access' in data:
|
419
|
+
if current_time - data['last_access'] > 3600: # 1小时未访问
|
420
|
+
expired_keys.append(key)
|
421
|
+
|
422
|
+
for key in expired_keys:
|
423
|
+
del self.storage[key]
|
424
|
+
|
425
|
+
if self.logger and expired_keys:
|
426
|
+
self.logger.info(f"🧹 清理了 {len(expired_keys)} 个过期限流记录")
|
427
|
+
|
428
|
+
|
429
|
+
# ==================== 装饰器工厂 ====================
|
430
|
+
|
431
|
+
class RateLimitDecorators:
|
432
|
+
"""限流装饰器工厂类"""
|
433
|
+
|
434
|
+
def __init__(self, limiter: AdvancedRateLimiter, api_response_class):
|
435
|
+
self.limiter = limiter
|
436
|
+
self.ApiResponse = api_response_class
|
437
|
+
|
438
|
+
def advanced_rate_limit(self, api_type: str = 'normal',
|
439
|
+
strategy: RateLimitStrategy = None,
|
440
|
+
custom_rule: RateLimitRule = None):
|
441
|
+
"""
|
442
|
+
高级限流装饰器
|
443
|
+
|
444
|
+
Args:
|
445
|
+
api_type: API类型 ('auth', 'sensitive', 'normal', 'public')
|
446
|
+
strategy: 限流策略
|
447
|
+
custom_rule: 自定义限流规则
|
448
|
+
"""
|
449
|
+
def decorator(f):
|
450
|
+
@functools.wraps(f)
|
451
|
+
def decorated_function(*args, **kwargs):
|
452
|
+
try:
|
453
|
+
# 获取客户端信息
|
454
|
+
client_ip, user_level, rate_limit_key = self.limiter.get_client_info()
|
455
|
+
|
456
|
+
# 执行限流检查
|
457
|
+
allowed, remaining, error_info = self.limiter.check_rate_limit(
|
458
|
+
api_type, rate_limit_key, user_level, strategy
|
459
|
+
)
|
460
|
+
|
461
|
+
if not allowed:
|
462
|
+
# 记录限流事件
|
463
|
+
if self.limiter.logger:
|
464
|
+
self.limiter.logger.warning(
|
465
|
+
f"限流触发: {rate_limit_key} ({client_ip}), "
|
466
|
+
f"API: {api_type}, 级别: {user_level.value}"
|
467
|
+
)
|
468
|
+
|
469
|
+
# 返回限流错误
|
470
|
+
return self.ApiResponse.error(
|
471
|
+
message=error_info.get('reason', "请求过于频繁,请稍后再试"),
|
472
|
+
code=42901, # 限流错误码
|
473
|
+
details={
|
474
|
+
"retry_after": error_info.get('retry_after', 60),
|
475
|
+
"api_type": api_type,
|
476
|
+
"user_level": user_level.value,
|
477
|
+
"client_ip": client_ip,
|
478
|
+
"error_type": error_info.get('error', 'rate_limit')
|
479
|
+
},
|
480
|
+
http_status=429
|
481
|
+
)
|
482
|
+
|
483
|
+
# 执行原函数
|
484
|
+
response = f(*args, **kwargs)
|
485
|
+
|
486
|
+
# 添加限流头信息
|
487
|
+
if isinstance(response, tuple) and len(response) >= 2:
|
488
|
+
response_data, status_code = response[0], response[1]
|
489
|
+
if hasattr(response_data, 'headers'):
|
490
|
+
rule = self.limiter.rules.get(api_type, {}).get(user_level)
|
491
|
+
if rule:
|
492
|
+
response_data.headers['X-RateLimit-Limit'] = str(rule.requests)
|
493
|
+
response_data.headers['X-RateLimit-Remaining'] = str(remaining)
|
494
|
+
response_data.headers['X-RateLimit-Reset'] = str(int(time.time() + rule.window))
|
495
|
+
response_data.headers['X-RateLimit-Policy'] = f"{api_type}:{user_level.value}"
|
496
|
+
|
497
|
+
return response
|
498
|
+
|
499
|
+
except Exception as e:
|
500
|
+
if self.limiter.logger:
|
501
|
+
self.limiter.logger.error(f"限流系统异常: {str(e)}")
|
502
|
+
# 限流系统故障时,允许请求通过
|
503
|
+
return f(*args, **kwargs)
|
504
|
+
|
505
|
+
return decorated_function
|
506
|
+
return decorator
|
507
|
+
|
508
|
+
def auth_limit(self, f):
|
509
|
+
"""认证API限流"""
|
510
|
+
return self.advanced_rate_limit('auth', RateLimitStrategy.SLIDING_WINDOW_LOG)(f)
|
511
|
+
|
512
|
+
def sensitive_limit(self, f):
|
513
|
+
"""敏感API限流"""
|
514
|
+
return self.advanced_rate_limit('sensitive', RateLimitStrategy.TOKEN_BUCKET)(f)
|
515
|
+
|
516
|
+
def normal_limit(self, f):
|
517
|
+
"""普通API限流"""
|
518
|
+
return self.advanced_rate_limit('normal', RateLimitStrategy.FIXED_WINDOW)(f)
|
519
|
+
|
520
|
+
def public_limit(self, f):
|
521
|
+
"""公开API限流"""
|
522
|
+
return self.advanced_rate_limit('public', RateLimitStrategy.FIXED_WINDOW)(f)
|
523
|
+
|
524
|
+
|
525
|
+
# ==================== Flask-Limiter 兼容层 ====================
|
526
|
+
|
527
|
+
def create_flask_limiter_compatibility(app, limiter: AdvancedRateLimiter):
|
528
|
+
"""创建Flask-Limiter兼容层"""
|
529
|
+
|
530
|
+
def get_limiter_key():
|
531
|
+
"""Flask-Limiter键值函数"""
|
532
|
+
_, _, key = limiter.get_client_info()
|
533
|
+
return key
|
534
|
+
|
535
|
+
try:
|
536
|
+
from flask_limiter import Limiter
|
537
|
+
flask_limiter = Limiter(
|
538
|
+
app=app,
|
539
|
+
key_func=get_limiter_key,
|
540
|
+
default_limits=["300 per minute", "5000 per hour"],
|
541
|
+
storage_uri="memory://",
|
542
|
+
strategy="fixed-window"
|
543
|
+
)
|
544
|
+
|
545
|
+
# 基础限流装饰器
|
546
|
+
request_limit = flask_limiter.shared_limit("300 per minute", scope="api")
|
547
|
+
return flask_limiter, request_limit
|
548
|
+
|
549
|
+
except ImportError:
|
550
|
+
# 如果没有Flask-Limiter,返回空装饰器
|
551
|
+
return None, lambda f: f
|
552
|
+
|
553
|
+
|
554
|
+
# ==================== 管理API生成器 ====================
|
555
|
+
|
556
|
+
def create_admin_routes(app, limiter: AdvancedRateLimiter, decorators: RateLimitDecorators,
|
557
|
+
api_response_class, require_permissions):
|
558
|
+
"""创建限流管理API路由"""
|
559
|
+
|
560
|
+
@app.route('/login/api/admin/rate-limit/stats')
|
561
|
+
@decorators.advanced_rate_limit('sensitive')
|
562
|
+
@require_permissions(['admin'])
|
563
|
+
def get_rate_limit_stats():
|
564
|
+
"""获取限流统计信息"""
|
565
|
+
try:
|
566
|
+
stats = limiter.get_stats()
|
567
|
+
return api_response_class.success(data=stats, message="获取限流统计成功")
|
568
|
+
except Exception as e:
|
569
|
+
return api_response_class.error(message=f"获取统计失败: {str(e)}")
|
570
|
+
|
571
|
+
@app.route('/login/api/admin/rate-limit/whitelist', methods=['POST'])
|
572
|
+
@decorators.advanced_rate_limit('sensitive')
|
573
|
+
@require_permissions(['admin'])
|
574
|
+
def add_to_whitelist():
|
575
|
+
"""添加IP到白名单"""
|
576
|
+
try:
|
577
|
+
data = request.get_json()
|
578
|
+
ip = data.get('ip', '').strip()
|
579
|
+
if not ip:
|
580
|
+
return api_response_class.validation_error(message="IP地址不能为空")
|
581
|
+
|
582
|
+
limiter.add_to_whitelist(ip)
|
583
|
+
return api_response_class.success(message=f"IP {ip} 已添加到白名单")
|
584
|
+
except Exception as e:
|
585
|
+
return api_response_class.error(message=f"添加白名单失败: {str(e)}")
|
586
|
+
|
587
|
+
@app.route('/login/api/admin/rate-limit/blacklist', methods=['POST'])
|
588
|
+
@decorators.advanced_rate_limit('sensitive')
|
589
|
+
@require_permissions(['admin'])
|
590
|
+
def add_to_blacklist():
|
591
|
+
"""添加IP到黑名单"""
|
592
|
+
try:
|
593
|
+
data = request.get_json()
|
594
|
+
ip = data.get('ip', '').strip()
|
595
|
+
if not ip:
|
596
|
+
return api_response_class.validation_error(message="IP地址不能为空")
|
597
|
+
|
598
|
+
limiter.add_to_blacklist(ip)
|
599
|
+
return api_response_class.success(message=f"IP {ip} 已添加到黑名单")
|
600
|
+
except Exception as e:
|
601
|
+
return api_response_class.error(message=f"添加黑名单失败: {str(e)}")
|
602
|
+
|
603
|
+
@app.route('/login/api/admin/rate-limit/cleanup', methods=['POST'])
|
604
|
+
@decorators.advanced_rate_limit('sensitive')
|
605
|
+
@require_permissions(['admin'])
|
606
|
+
def cleanup_rate_limit_data():
|
607
|
+
"""清理限流数据"""
|
608
|
+
try:
|
609
|
+
limiter.cleanup_expired_data()
|
610
|
+
return api_response_class.success(message="清理完成")
|
611
|
+
except Exception as e:
|
612
|
+
return api_response_class.error(message=f"清理失败: {str(e)}")
|
613
|
+
|
614
|
+
|
615
|
+
# ==================== 初始化函数 ====================
|
616
|
+
|
617
|
+
def init_rate_limiter(app, auth_manager, logger, api_response_class, require_permissions_func):
|
618
|
+
"""
|
619
|
+
完整初始化限流系统 (兼容性函数)
|
620
|
+
|
621
|
+
Args:
|
622
|
+
app: Flask应用实例
|
623
|
+
auth_manager: 认证管理器实例
|
624
|
+
logger: 日志记录器
|
625
|
+
api_response_class: API响应类
|
626
|
+
require_permissions_func: 权限检查装饰器函数
|
627
|
+
|
628
|
+
Returns:
|
629
|
+
tuple: (advanced_limiter, decorators, flask_limiter, request_limit)
|
630
|
+
"""
|
631
|
+
|
632
|
+
# 创建高级限流器
|
633
|
+
advanced_limiter = AdvancedRateLimiter(auth_manager, logger)
|
634
|
+
|
635
|
+
# 创建装饰器工厂
|
636
|
+
decorators = RateLimitDecorators(advanced_limiter, api_response_class)
|
637
|
+
|
638
|
+
# 创建Flask-Limiter兼容层
|
639
|
+
flask_limiter, request_limit = create_flask_limiter_compatibility(app, advanced_limiter)
|
640
|
+
|
641
|
+
# 创建管理API路由
|
642
|
+
create_admin_routes(app, advanced_limiter, decorators, api_response_class, require_permissions_func)
|
643
|
+
|
644
|
+
# 启动定期清理任务
|
645
|
+
import threading
|
646
|
+
def schedule_cleanup():
|
647
|
+
advanced_limiter.cleanup_expired_data()
|
648
|
+
timer = threading.Timer(300, schedule_cleanup) # 5分钟执行一次
|
649
|
+
timer.daemon = True
|
650
|
+
timer.start()
|
651
|
+
|
652
|
+
schedule_cleanup()
|
653
|
+
|
654
|
+
return advanced_limiter, decorators, flask_limiter, request_limit
|
655
|
+
|
656
|
+
|
657
|
+
def create_simple_rate_limiter(auth_manager, logger, api_response_class):
|
658
|
+
"""
|
659
|
+
简化版限流器创建 (推荐使用)
|
660
|
+
|
661
|
+
Args:
|
662
|
+
auth_manager: 认证管理器实例
|
663
|
+
logger: 日志记录器
|
664
|
+
api_response_class: API响应类
|
665
|
+
|
666
|
+
Returns:
|
667
|
+
tuple: (rate_limiter, decorators)
|
668
|
+
"""
|
669
|
+
|
670
|
+
# 直接创建限流器和装饰器
|
671
|
+
rate_limiter = AdvancedRateLimiter(auth_manager, logger)
|
672
|
+
decorators = RateLimitDecorators(rate_limiter, api_response_class)
|
673
|
+
|
674
|
+
# 启动定期清理任务
|
675
|
+
import threading
|
676
|
+
def schedule_cleanup():
|
677
|
+
rate_limiter.cleanup_expired_data()
|
678
|
+
timer = threading.Timer(300, schedule_cleanup) # 5分钟执行一次
|
679
|
+
timer.daemon = True
|
680
|
+
timer.start()
|
681
|
+
|
682
|
+
schedule_cleanup()
|
683
|
+
|
684
|
+
return rate_limiter, decorators
|
@@ -1,7 +1,8 @@
|
|
1
1
|
mdbq/__init__.py,sha256=Il5Q9ATdX8yXqVxtP_nYqUhExzxPC_qk_WXQ_4h0exg,16
|
2
|
-
mdbq/__version__.py,sha256=
|
2
|
+
mdbq/__version__.py,sha256=k-Ojd9XAyoffKPd5ogyvzqZF_lbLJqX_MBLGwMbk2Ew,18
|
3
3
|
mdbq/auth/__init__.py,sha256=pnPMAt63sh1B6kEvmutUuro46zVf2v2YDAG7q-jV_To,24
|
4
|
-
mdbq/auth/auth_backend.py,sha256=
|
4
|
+
mdbq/auth/auth_backend.py,sha256=RHWHeSjS2BMTIdtD1sV9idg2BzQ5F9AG3JS7ObFohns,57192
|
5
|
+
mdbq/auth/rate_limiter.py,sha256=e7K8pMQlZ1vm1N-c0HBH8tbAwzcmXSRiAl81JNZ369g,26192
|
5
6
|
mdbq/js/__init__.py,sha256=hpMi3_ZKwIWkzc0LnKL-SY9AS-7PYFHq0izYTgEvxjc,30
|
6
7
|
mdbq/js/jc.py,sha256=FOc6HOOTJwnoZLZmgmaE1SQo9rUnVhXmefhKMD2MlDA,13229
|
7
8
|
mdbq/log/__init__.py,sha256=Mpbrav0s0ifLL7lVDAuePEi1hJKiSHhxcv1byBKDl5E,15
|
@@ -32,7 +33,7 @@ mdbq/route/routes.py,sha256=DHJg0eRNi7TKqhCHuu8ia3vdQ8cTKwrTm6mwDBtNboM,19111
|
|
32
33
|
mdbq/selenium/__init__.py,sha256=AKzeEceqZyvqn2dEDoJSzDQnbuENkJSHAlbHAD0u0ZI,10
|
33
34
|
mdbq/selenium/get_driver.py,sha256=1NTlVUE6QsyjTrVVVqTO2LOnYf578ccFWlWnvIXGtic,20903
|
34
35
|
mdbq/spider/__init__.py,sha256=RBMFXGy_jd1HXZhngB2T2XTvJqki8P_Fr-pBcwijnew,18
|
35
|
-
mdbq-4.0.
|
36
|
-
mdbq-4.0.
|
37
|
-
mdbq-4.0.
|
38
|
-
mdbq-4.0.
|
36
|
+
mdbq-4.0.91.dist-info/METADATA,sha256=njj-Rq_D97tP__py-GgA4nlc8D3zPvHO4D63XXqOeKg,364
|
37
|
+
mdbq-4.0.91.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
38
|
+
mdbq-4.0.91.dist-info/top_level.txt,sha256=2FQ-uLnCSB-OwFiWntzmwosW3X2Xqsg0ewh1axsaylA,5
|
39
|
+
mdbq-4.0.91.dist-info/RECORD,,
|
File without changes
|
File without changes
|