springbootAI 1.8.0__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.
- spring/__init__.py +66 -0
- spring/ai/__init__.py +78 -0
- spring/ai/advisors.py +139 -0
- spring/ai/annotations.py +74 -0
- spring/ai/autoconfig.py +481 -0
- spring/ai/core.py +391 -0
- spring/ai/etl.py +188 -0
- spring/ai/memory.py +109 -0
- spring/ai/observability.py +129 -0
- spring/ai/providers.py +789 -0
- spring/ai/resilience.py +258 -0
- spring/ai/tools.py +106 -0
- spring/ai/vectorstore.py +303 -0
- spring/annotations/__init__.py +188 -0
- spring/annotations/cache.py +126 -0
- spring/annotations/cloud.py +207 -0
- spring/annotations/conditional.py +272 -0
- spring/annotations/core.py +864 -0
- spring/annotations/messaging.py +107 -0
- spring/aop/__init__.py +4 -0
- spring/aop/cloud_aop.py +404 -0
- spring/aop/comprehensive_aop.py +1015 -0
- spring/aop/method_interceptor.py +19 -0
- spring/aop/proxy_factory.py +55 -0
- spring/cloud/__init__.py +76 -0
- spring/cloud/discovery.py +364 -0
- spring/cloud/feign.py +469 -0
- spring/cloud/gateway.py +452 -0
- spring/cloud/load_balancer.py +149 -0
- spring/cloud/seata.py +557 -0
- spring/cloud/sentinel.py +525 -0
- spring/cloud/tracer.py +337 -0
- spring/config/__init__.py +21 -0
- spring/config/binding.py +206 -0
- spring/config/config_loader.py +405 -0
- spring/context/__init__.py +13 -0
- spring/context/application_context.py +589 -0
- spring/context/bean_definition.py +70 -0
- spring/context/bean_factory.py +1052 -0
- spring/context/registry.py +58 -0
- spring/context/scanner.py +106 -0
- spring/core/__init__.py +3 -0
- spring/core/graceful_shutdown.py +196 -0
- spring/core/typing_utils.py +50 -0
- spring/csv/__init__.py +52 -0
- spring/csv/annotations.py +402 -0
- spring/csv/converters.py +69 -0
- spring/csv/easy_csv.py +95 -0
- spring/csv/exceptions.py +27 -0
- spring/csv/reader.py +195 -0
- spring/csv/writer.py +155 -0
- spring/data/__init__.py +54 -0
- spring/data/page.py +181 -0
- spring/data/repository.py +274 -0
- spring/data/specification.py +228 -0
- spring/datasource/__init__.py +66 -0
- spring/datasource/annotations.py +133 -0
- spring/datasource/context.py +69 -0
- spring/datasource/dynamic.py +148 -0
- spring/event/__init__.py +7 -0
- spring/event/publisher.py +69 -0
- spring/excel/__init__.py +51 -0
- spring/excel/annotations.py +405 -0
- spring/excel/converters.py +231 -0
- spring/excel/easy_excel.py +94 -0
- spring/excel/exceptions.py +31 -0
- spring/excel/reader.py +254 -0
- spring/excel/style.py +95 -0
- spring/excel/writer.py +197 -0
- spring/i18n/__init__.py +97 -0
- spring/i18n/accessor.py +94 -0
- spring/i18n/auto_config.py +177 -0
- spring/i18n/holder.py +106 -0
- spring/i18n/locale.py +152 -0
- spring/i18n/locale_resolver.py +367 -0
- spring/i18n/message_source.py +250 -0
- spring/i18n/middleware.py +79 -0
- spring/i18n/properties.py +168 -0
- spring/i18n/sources.py +255 -0
- spring/logging/__init__.py +1 -0
- spring/logging/loguru_logger.py +228 -0
- spring/main.py +378 -0
- spring/messaging/__init__.py +1 -0
- spring/messaging/rabbitmq.py +302 -0
- spring/monitoring/__init__.py +1 -0
- spring/monitoring/prometheus.py +199 -0
- spring/orm/__init__.py +258 -0
- spring/orm/database.py +222 -0
- spring/orm/ddl_auto.py +1217 -0
- spring/orm/migration.py +419 -0
- spring/orm/mybatis_integration.py +400 -0
- spring/orm/pymybatis/__init__.py +86 -0
- spring/orm/pymybatis/annotations/__init__.py +30 -0
- spring/orm/pymybatis/annotations/annotations.py +332 -0
- spring/orm/pymybatis/cache/__init__.py +47 -0
- spring/orm/pymybatis/cache/cache.py +371 -0
- spring/orm/pymybatis/cache/redis_cache.py +434 -0
- spring/orm/pymybatis/circuit_breaker/__init__.py +21 -0
- spring/orm/pymybatis/circuit_breaker/circuit_breaker.py +424 -0
- spring/orm/pymybatis/configuration.py +525 -0
- spring/orm/pymybatis/core/__init__.py +10 -0
- spring/orm/pymybatis/core/sql_session.py +1382 -0
- spring/orm/pymybatis/core/sql_session_factory.py +76 -0
- spring/orm/pymybatis/dialect/__init__.py +9 -0
- spring/orm/pymybatis/dialect/dialect.py +445 -0
- spring/orm/pymybatis/dynamic_sql/__init__.py +9 -0
- spring/orm/pymybatis/dynamic_sql/dynamic_sql.py +900 -0
- spring/orm/pymybatis/interceptor/__init__.py +31 -0
- spring/orm/pymybatis/interceptor/interceptor.py +427 -0
- spring/orm/pymybatis/mapper/__init__.py +9 -0
- spring/orm/pymybatis/mapper/mapper.py +540 -0
- spring/orm/pymybatis/metrics/__init__.py +41 -0
- spring/orm/pymybatis/metrics/metrics.py +595 -0
- spring/orm/pymybatis/pool/__init__.py +9 -0
- spring/orm/pymybatis/pool/connection_pool.py +711 -0
- spring/orm/pymybatis/security/__init__.py +19 -0
- spring/orm/pymybatis/security/access_control.py +415 -0
- spring/orm/pymybatis/security/password_encoder.py +293 -0
- spring/orm/pymybatis/security/sensitive_data_masker.py +326 -0
- spring/orm/pymybatis/security/sql_injection_detector.py +675 -0
- spring/orm/pymybatis/transaction/__init__.py +9 -0
- spring/orm/pymybatis/transaction/transaction.py +288 -0
- spring/orm/pymybatis/type_handler/__init__.py +37 -0
- spring/orm/pymybatis/type_handler/type_handler.py +473 -0
- spring/orm/pymybatis/version.py +9 -0
- spring/orm/pymybatis/xml_parser/__init__.py +9 -0
- spring/orm/pymybatis/xml_parser/xml_parser.py +761 -0
- spring/retry/__init__.py +12 -0
- spring/retry/retry_annotations.py +71 -0
- spring/retry/retry_decorator.py +155 -0
- spring/scheduling/__init__.py +3 -0
- spring/scheduling/scheduler.py +389 -0
- spring/security/__init__.py +39 -0
- spring/security/jwt_utils.py +281 -0
- spring/security/replay_protection.py +206 -0
- spring/security/secret_manager.py +226 -0
- spring/security/security_aop.py +248 -0
- spring/security/security_context.py +172 -0
- spring/test/__init__.py +45 -0
- spring/test/slicing.py +341 -0
- spring/tracing/__init__.py +11 -0
- spring/tracing/skywalking.py +229 -0
- spring/tx/__init__.py +52 -0
- spring/tx/events.py +172 -0
- spring/tx/synchronization.py +143 -0
- spring/utils/__init__.py +5 -0
- spring/utils/banner.py +32 -0
- spring/utils/logger.py +73 -0
- spring/utils/redis_client.py +526 -0
- spring/validation/__init__.py +55 -0
- spring/validation/aop.py +141 -0
- spring/validation/constraints.py +357 -0
- spring/validation/exceptions.py +55 -0
- spring/validation/validator.py +139 -0
- spring/web/__init__.py +12 -0
- spring/web/actuator.py +319 -0
- spring/web/exception_handler.py +61 -0
- spring/web/health.py +399 -0
- spring/web/interceptor.py +91 -0
- spring/web/result.py +44 -0
- spring/web/swagger.py +601 -0
- spring/web/web_context.py +755 -0
- spring/websocket/__init__.py +86 -0
- spring/websocket/annotations.py +169 -0
- spring/websocket/broker.py +238 -0
- spring/websocket/exceptions.py +26 -0
- spring/websocket/handler.py +243 -0
- spring/websocket/router.py +526 -0
- spring/websocket/session.py +216 -0
- springbootai-1.8.0.dist-info/METADATA +2796 -0
- springbootai-1.8.0.dist-info/RECORD +175 -0
- springbootai-1.8.0.dist-info/WHEEL +5 -0
- springbootai-1.8.0.dist-info/entry_points.txt +2 -0
- springbootai-1.8.0.dist-info/licenses/LICENSE +7 -0
- springbootai-1.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JWT 工具类
|
|
3
|
+
提供 Token 生成、验证、解析等功能
|
|
4
|
+
"""
|
|
5
|
+
import jwt
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
import functools
|
|
9
|
+
from typing import Optional, Dict, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _InstanceOrDefaultMethod:
|
|
13
|
+
"""Bind to an explicit instance, or to the configured module singleton."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, func):
|
|
16
|
+
self.func = func
|
|
17
|
+
functools.update_wrapper(self, func)
|
|
18
|
+
|
|
19
|
+
def __get__(self, instance, owner):
|
|
20
|
+
target = instance
|
|
21
|
+
if target is None:
|
|
22
|
+
target = globals().get('jwt_utils')
|
|
23
|
+
if target is None:
|
|
24
|
+
target = owner()
|
|
25
|
+
return self.func.__get__(target, owner)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class JwtUtils:
|
|
29
|
+
"""JWT工具类"""
|
|
30
|
+
|
|
31
|
+
ALLOWED_ALGORITHMS = {'HS256', 'HS384', 'HS512'}
|
|
32
|
+
|
|
33
|
+
def __init__(self, secret_key: str = None, algorithm: str = 'HS256'):
|
|
34
|
+
self.configure(secret_key=secret_key, algorithm=algorithm)
|
|
35
|
+
|
|
36
|
+
def configure(
|
|
37
|
+
self,
|
|
38
|
+
secret_key: Optional[str] = None,
|
|
39
|
+
algorithm: str = 'HS256',
|
|
40
|
+
issuer: Optional[str] = None,
|
|
41
|
+
audience: Optional[str] = None,
|
|
42
|
+
leeway: int = 0,
|
|
43
|
+
) -> None:
|
|
44
|
+
normalized_algorithm = str(algorithm).upper()
|
|
45
|
+
if normalized_algorithm not in self.ALLOWED_ALGORITHMS:
|
|
46
|
+
raise ValueError(f"不允许的 JWT 算法: {normalized_algorithm}")
|
|
47
|
+
self.secret_key = secret_key or 'spring-python-secret-key-change-in-production'
|
|
48
|
+
self.algorithm = normalized_algorithm
|
|
49
|
+
self.issuer = issuer
|
|
50
|
+
self.audience = audience
|
|
51
|
+
self.leeway = max(0, int(leeway))
|
|
52
|
+
|
|
53
|
+
@_InstanceOrDefaultMethod
|
|
54
|
+
def generate_token(self, payload: Dict[str, Any], expires_in: int = 3600) -> str:
|
|
55
|
+
"""
|
|
56
|
+
生成 JWT Token
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
payload: 载荷数据
|
|
60
|
+
expires_in: 过期时间(秒),默认1小时
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
JWT Token字符串
|
|
64
|
+
"""
|
|
65
|
+
if expires_in <= 0:
|
|
66
|
+
raise ValueError("expires_in 必须大于 0")
|
|
67
|
+
now = int(time.time())
|
|
68
|
+
token_payload = {
|
|
69
|
+
**payload,
|
|
70
|
+
'exp': now + expires_in,
|
|
71
|
+
'iat': now,
|
|
72
|
+
'jti': str(uuid.uuid4()),
|
|
73
|
+
'token_type': 'access',
|
|
74
|
+
}
|
|
75
|
+
if self.issuer:
|
|
76
|
+
token_payload['iss'] = self.issuer
|
|
77
|
+
if self.audience:
|
|
78
|
+
token_payload['aud'] = self.audience
|
|
79
|
+
|
|
80
|
+
return jwt.encode(token_payload, self.secret_key, algorithm=self.algorithm)
|
|
81
|
+
|
|
82
|
+
@_InstanceOrDefaultMethod
|
|
83
|
+
def generate_refresh_token(self, payload: Dict[str, Any], expires_in: int = 86400) -> str:
|
|
84
|
+
"""
|
|
85
|
+
生成刷新 Token
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
payload: 载荷数据
|
|
89
|
+
expires_in: 过期时间(秒),默认24小时
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
刷新 Token 字符串
|
|
93
|
+
"""
|
|
94
|
+
token = self.generate_token(payload, expires_in)
|
|
95
|
+
decoded = jwt.decode(token, options={'verify_signature': False})
|
|
96
|
+
decoded['token_type'] = 'refresh'
|
|
97
|
+
return jwt.encode(decoded, self.secret_key, algorithm=self.algorithm)
|
|
98
|
+
|
|
99
|
+
@_InstanceOrDefaultMethod
|
|
100
|
+
def validate_token(self, token: str) -> bool:
|
|
101
|
+
"""
|
|
102
|
+
验证 Token 是否有效
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
token: JWT Token
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
是否有效
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
self.decode_token(token)
|
|
112
|
+
return True
|
|
113
|
+
except (jwt.PyJWTError, ValueError, TypeError):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
@_InstanceOrDefaultMethod
|
|
117
|
+
def verify_token(self, token: str) -> Dict[str, Any]:
|
|
118
|
+
"""验证并返回 Token 载荷;无效 Token 会抛出解码异常。"""
|
|
119
|
+
return self.decode_token(token)
|
|
120
|
+
|
|
121
|
+
@_InstanceOrDefaultMethod
|
|
122
|
+
def decode_token(self, token: str) -> Dict[str, Any]:
|
|
123
|
+
"""
|
|
124
|
+
解码并验证 Token
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
token: JWT Token
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
解码后的载荷数据
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
jwt.ExpiredSignatureError: Token已过期
|
|
134
|
+
jwt.InvalidTokenError: Token无效
|
|
135
|
+
"""
|
|
136
|
+
options = {
|
|
137
|
+
'require': ['exp', 'iat', 'jti', 'token_type'],
|
|
138
|
+
'verify_aud': self.audience is not None,
|
|
139
|
+
'verify_iss': self.issuer is not None,
|
|
140
|
+
}
|
|
141
|
+
return jwt.decode(
|
|
142
|
+
token,
|
|
143
|
+
self.secret_key,
|
|
144
|
+
algorithms=[self.algorithm],
|
|
145
|
+
audience=self.audience,
|
|
146
|
+
issuer=self.issuer,
|
|
147
|
+
leeway=self.leeway,
|
|
148
|
+
options=options,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@_InstanceOrDefaultMethod
|
|
152
|
+
def get_payload(self, token: str) -> Dict[str, Any]:
|
|
153
|
+
"""
|
|
154
|
+
获取 Token 载荷(不验证签名)
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
token: JWT Token
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
载荷数据
|
|
161
|
+
"""
|
|
162
|
+
return jwt.decode(token, options={'verify_signature': False})
|
|
163
|
+
|
|
164
|
+
@_InstanceOrDefaultMethod
|
|
165
|
+
def is_expired(self, token: str) -> bool:
|
|
166
|
+
"""
|
|
167
|
+
检查 Token 是否已过期
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
token: JWT Token
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
是否已过期
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
payload = self.get_payload(token)
|
|
177
|
+
exp = payload.get('exp', 0)
|
|
178
|
+
return time.time() > exp
|
|
179
|
+
except (jwt.PyJWTError, ValueError, TypeError):
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
@_InstanceOrDefaultMethod
|
|
183
|
+
def refresh_token(self, refresh_token: str, expires_in: int = 3600) -> str:
|
|
184
|
+
"""
|
|
185
|
+
使用刷新 Token 生成新的访问 Token
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
refresh_token: 刷新 Token
|
|
189
|
+
expires_in: 新 Token 过期时间(秒)
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
新的访问 Token
|
|
193
|
+
"""
|
|
194
|
+
payload = self.decode_token(refresh_token)
|
|
195
|
+
if payload.get('token_type') != 'refresh':
|
|
196
|
+
raise jwt.InvalidTokenError("访问令牌不能用于刷新")
|
|
197
|
+
|
|
198
|
+
# 移除过期时间相关字段
|
|
199
|
+
payload.pop('exp', None)
|
|
200
|
+
payload.pop('iat', None)
|
|
201
|
+
payload.pop('jti', None)
|
|
202
|
+
payload.pop('token_type', None)
|
|
203
|
+
|
|
204
|
+
return self.generate_token(payload, expires_in)
|
|
205
|
+
|
|
206
|
+
@_InstanceOrDefaultMethod
|
|
207
|
+
def extract_user_id(self, token: str) -> Optional[str]:
|
|
208
|
+
"""
|
|
209
|
+
从 Token 中提取用户ID
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
token: JWT Token
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
用户ID
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
payload = self.decode_token(token)
|
|
219
|
+
return payload.get('user_id') or payload.get('sub')
|
|
220
|
+
except (jwt.PyJWTError, ValueError, TypeError):
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
@_InstanceOrDefaultMethod
|
|
224
|
+
def extract_roles(self, token: str) -> list:
|
|
225
|
+
"""
|
|
226
|
+
从 Token 中提取角色列表
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
token: JWT Token
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
角色列表
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
payload = self.decode_token(token)
|
|
236
|
+
roles = payload.get('roles', [])
|
|
237
|
+
if isinstance(roles, str):
|
|
238
|
+
return [roles]
|
|
239
|
+
return roles
|
|
240
|
+
except (jwt.PyJWTError, ValueError, TypeError):
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
@_InstanceOrDefaultMethod
|
|
244
|
+
def extract_permissions(self, token: str) -> list:
|
|
245
|
+
"""
|
|
246
|
+
从 Token 中提取权限列表
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
token: JWT Token
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
权限列表
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
payload = self.decode_token(token)
|
|
256
|
+
permissions = payload.get('permissions', [])
|
|
257
|
+
if isinstance(permissions, str):
|
|
258
|
+
return [permissions]
|
|
259
|
+
return permissions
|
|
260
|
+
except (jwt.PyJWTError, ValueError, TypeError):
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# 创建全局 JWT 工具实例
|
|
265
|
+
jwt_utils = JwtUtils()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def init_jwt(config: dict) -> None:
|
|
269
|
+
"""
|
|
270
|
+
初始化 JWT 配置
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
config: JWT配置字典,包含secret_key等
|
|
274
|
+
"""
|
|
275
|
+
jwt_utils.configure(
|
|
276
|
+
secret_key=config.get('secret_key', 'spring-python-secret-key-change-in-production'),
|
|
277
|
+
algorithm=config.get('algorithm', 'HS256'),
|
|
278
|
+
issuer=config.get('issuer'),
|
|
279
|
+
audience=config.get('audience'),
|
|
280
|
+
leeway=config.get('leeway', 0),
|
|
281
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
重放攻击防护 (Replay Attack Protection)
|
|
3
|
+
|
|
4
|
+
防护机制:
|
|
5
|
+
- Nonce + Timestamp 双重校验
|
|
6
|
+
- 时间戳窗口 (默认5分钟)
|
|
7
|
+
- Nonce 去重缓存 (Redis或内存)
|
|
8
|
+
- 请求签名验证
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import logging
|
|
15
|
+
import threading
|
|
16
|
+
from typing import Optional, Dict, Set, Tuple
|
|
17
|
+
from collections import OrderedDict
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("Spring.Security.Replay")
|
|
20
|
+
|
|
21
|
+
# 默认时间戳窗口(秒)
|
|
22
|
+
DEFAULT_TIMESTAMP_WINDOW = 300
|
|
23
|
+
# Nonce缓存最大容量(LRU)
|
|
24
|
+
DEFAULT_NONCE_CACHE_SIZE = 100000
|
|
25
|
+
# Nonce过期清理间隔(秒)
|
|
26
|
+
NONCE_CLEANUP_INTERVAL = 60
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NonceCache:
|
|
30
|
+
"""线程安全的LRU Nonce缓存"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, max_size: int = DEFAULT_NONCE_CACHE_SIZE, ttl: int = DEFAULT_TIMESTAMP_WINDOW):
|
|
33
|
+
self._cache: "OrderedDict[str, float]" = OrderedDict()
|
|
34
|
+
self._max_size = max_size
|
|
35
|
+
self._ttl = ttl
|
|
36
|
+
self._lock = threading.RLock()
|
|
37
|
+
self._last_cleanup = time.monotonic()
|
|
38
|
+
|
|
39
|
+
def _cleanup_expired(self):
|
|
40
|
+
"""清理过期的nonce"""
|
|
41
|
+
now = time.time()
|
|
42
|
+
if time.monotonic() - self._last_cleanup < NONCE_CLEANUP_INTERVAL:
|
|
43
|
+
# 即使到了清理间隔,也只清理部分避免阻塞
|
|
44
|
+
expired_keys = []
|
|
45
|
+
for k, ts in list(self._cache.items()):
|
|
46
|
+
if now - ts > self._ttl:
|
|
47
|
+
expired_keys.append(k)
|
|
48
|
+
else:
|
|
49
|
+
break # OrderedDict按插入顺序,前面过期后面也应该过期
|
|
50
|
+
for k in expired_keys:
|
|
51
|
+
self._cache.pop(k, None)
|
|
52
|
+
self._last_cleanup = time.monotonic()
|
|
53
|
+
|
|
54
|
+
def check_and_add(self, nonce: str) -> bool:
|
|
55
|
+
"""
|
|
56
|
+
检查nonce是否已存在,不存在则添加
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
True 如果nonce有效(未重复),False 如果重复
|
|
60
|
+
"""
|
|
61
|
+
with self._lock:
|
|
62
|
+
self._cleanup_expired()
|
|
63
|
+
|
|
64
|
+
if nonce in self._cache:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# 容量控制
|
|
68
|
+
while len(self._cache) >= self._max_size:
|
|
69
|
+
self._cache.popitem(last=False)
|
|
70
|
+
|
|
71
|
+
self._cache[nonce] = time.time()
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RedisNonceCache:
|
|
76
|
+
"""基于Redis的分布式Nonce缓存"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, redis_client, key_prefix: str = "springpy:nonce:", ttl: int = DEFAULT_TIMESTAMP_WINDOW):
|
|
79
|
+
self._redis = redis_client
|
|
80
|
+
self._prefix = key_prefix
|
|
81
|
+
self._ttl = ttl
|
|
82
|
+
|
|
83
|
+
def check_and_add(self, nonce: str) -> bool:
|
|
84
|
+
key = f"{self._prefix}{nonce}"
|
|
85
|
+
try:
|
|
86
|
+
# SET NX: 仅在key不存在时设置,成功返回True(nonce有效)
|
|
87
|
+
return bool(self._redis.set(key, '1', ex=self._ttl, nx=True))
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.warning(f"Redis nonce check failed, falling back to allow: {e}")
|
|
90
|
+
return True # Redis故障时降级为允许(由timestamp校验兜底)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ReplayProtection:
|
|
94
|
+
"""
|
|
95
|
+
重放攻击保护器
|
|
96
|
+
|
|
97
|
+
Usage:
|
|
98
|
+
protector = ReplayProtection(secret_key="your-secret")
|
|
99
|
+
is_valid, reason = protector.validate_request(
|
|
100
|
+
timestamp=request.headers.get("X-Timestamp"),
|
|
101
|
+
nonce=request.headers.get("X-Nonce"),
|
|
102
|
+
signature=request.headers.get("X-Signature"),
|
|
103
|
+
body=request.body,
|
|
104
|
+
)
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, secret_key: str,
|
|
108
|
+
timestamp_window: int = DEFAULT_TIMESTAMP_WINDOW,
|
|
109
|
+
nonce_cache=None,
|
|
110
|
+
redis_client=None):
|
|
111
|
+
self.secret_key = secret_key.encode('utf-8')
|
|
112
|
+
self.timestamp_window = timestamp_window
|
|
113
|
+
|
|
114
|
+
if nonce_cache:
|
|
115
|
+
self.nonce_cache = nonce_cache
|
|
116
|
+
elif redis_client:
|
|
117
|
+
self.nonce_cache = RedisNonceCache(redis_client, ttl=timestamp_window)
|
|
118
|
+
else:
|
|
119
|
+
self.nonce_cache = NonceCache(ttl=timestamp_window)
|
|
120
|
+
|
|
121
|
+
def generate_signature(self, timestamp: str, nonce: str, body: str = "",
|
|
122
|
+
method: str = "", path: str = "") -> str:
|
|
123
|
+
"""
|
|
124
|
+
生成请求签名
|
|
125
|
+
|
|
126
|
+
签名字符串: METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY_SHA256
|
|
127
|
+
"""
|
|
128
|
+
body_hash = hashlib.sha256(body.encode('utf-8') if isinstance(body, str) else body).hexdigest()
|
|
129
|
+
message = f"{method.upper()}\n{path}\n{timestamp}\n{nonce}\n{body_hash}"
|
|
130
|
+
return hmac.new(self.secret_key, message.encode('utf-8'), hashlib.sha256).hexdigest()
|
|
131
|
+
|
|
132
|
+
def validate_request(self, timestamp: str, nonce: str,
|
|
133
|
+
signature: str = "", body: str = "",
|
|
134
|
+
method: str = "", path: str = "") -> Tuple[bool, str]:
|
|
135
|
+
"""
|
|
136
|
+
验证请求是否为重放攻击
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
timestamp: 请求时间戳(毫秒或秒,自动检测)
|
|
140
|
+
nonce: 唯一请求标识
|
|
141
|
+
signature: 请求签名(可选)
|
|
142
|
+
body: 请求体(用于签名验证)
|
|
143
|
+
method: HTTP方法
|
|
144
|
+
path: 请求路径
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
(is_valid, reason)
|
|
148
|
+
"""
|
|
149
|
+
# 1. 验证时间戳
|
|
150
|
+
try:
|
|
151
|
+
ts = int(timestamp)
|
|
152
|
+
# 自动检测毫秒/秒
|
|
153
|
+
if ts > 1e12:
|
|
154
|
+
ts = ts / 1000
|
|
155
|
+
now = time.time()
|
|
156
|
+
if abs(now - ts) > self.timestamp_window:
|
|
157
|
+
return False, f"Timestamp expired: window={self.timestamp_window}s, diff={abs(now - ts):.1f}s"
|
|
158
|
+
except (ValueError, TypeError):
|
|
159
|
+
return False, "Invalid timestamp format"
|
|
160
|
+
|
|
161
|
+
# 2. 验证Nonce
|
|
162
|
+
if not nonce or len(nonce) < 8:
|
|
163
|
+
return False, "Invalid or missing nonce (minimum 8 characters)"
|
|
164
|
+
|
|
165
|
+
if not self.nonce_cache.check_and_add(nonce):
|
|
166
|
+
logger.warning(f"Replay attack detected: duplicate nonce {nonce[:8]}***")
|
|
167
|
+
return False, "Duplicate nonce detected (possible replay attack)"
|
|
168
|
+
|
|
169
|
+
# 3. 验证签名(如果提供)
|
|
170
|
+
if signature:
|
|
171
|
+
expected_sig = self.generate_signature(str(timestamp), nonce, body, method, path)
|
|
172
|
+
if not hmac.compare_digest(expected_sig, signature):
|
|
173
|
+
return False, "Invalid signature"
|
|
174
|
+
|
|
175
|
+
return True, "OK"
|
|
176
|
+
|
|
177
|
+
def validate_headers(self, headers: Dict[str, str], body: str = "",
|
|
178
|
+
method: str = "", path: str = "") -> Tuple[bool, str]:
|
|
179
|
+
"""从HTTP头验证请求"""
|
|
180
|
+
return self.validate_request(
|
|
181
|
+
timestamp=headers.get("x-timestamp", headers.get("X-Timestamp", "")),
|
|
182
|
+
nonce=headers.get("x-nonce", headers.get("X-Nonce", "")),
|
|
183
|
+
signature=headers.get("x-signature", headers.get("X-Signature", "")),
|
|
184
|
+
body=body,
|
|
185
|
+
method=method,
|
|
186
|
+
path=path,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def create_replay_protection(secret_key: str = None, redis_client=None,
|
|
191
|
+
**kwargs) -> Optional[ReplayProtection]:
|
|
192
|
+
"""
|
|
193
|
+
工厂方法创建重放保护器
|
|
194
|
+
|
|
195
|
+
如果没有secret_key则返回None(不启用重放防护)
|
|
196
|
+
"""
|
|
197
|
+
if not secret_key:
|
|
198
|
+
# 尝试从配置获取
|
|
199
|
+
try:
|
|
200
|
+
from spring.security.secret_manager import SecretManager
|
|
201
|
+
secret_key = SecretManager().get_jwt_secret()
|
|
202
|
+
except Exception:
|
|
203
|
+
return None
|
|
204
|
+
if not secret_key:
|
|
205
|
+
return None
|
|
206
|
+
return ReplayProtection(secret_key, redis_client=redis_client, **kwargs)
|