sa-token-python-core 0.1.1__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.
- sa_token/__init__.py +89 -0
- sa_token/adapter/__init__.py +24 -0
- sa_token/adapter/http.py +71 -0
- sa_token/adapter/path.py +163 -0
- sa_token/adapter/pipeline.py +97 -0
- sa_token/config.py +130 -0
- sa_token/context.py +63 -0
- sa_token/exception.py +143 -0
- sa_token/integration/__init__.py +10 -0
- sa_token/integration/django.py +131 -0
- sa_token/integration/fastapi.py +315 -0
- sa_token/integration/fastapi_oauth2.py +136 -0
- sa_token/integration/flask.py +191 -0
- sa_token/integration/starlette.py +227 -0
- sa_token/listener.py +100 -0
- sa_token/manager.py +244 -0
- sa_token/model.py +145 -0
- sa_token/oauth2/__init__.py +19 -0
- sa_token/oauth2/model.py +122 -0
- sa_token/oauth2/server.py +361 -0
- sa_token/online/__init__.py +292 -0
- sa_token/permission.py +67 -0
- sa_token/py.typed +0 -0
- sa_token/security/__init__.py +14 -0
- sa_token/security/nonce.py +93 -0
- sa_token/security/refresh.py +300 -0
- sa_token/security/temp_token.py +114 -0
- sa_token/session.py +96 -0
- sa_token/sso/__init__.py +217 -0
- sa_token/storage/__init__.py +22 -0
- sa_token/storage/base.py +66 -0
- sa_token/storage/memory.py +154 -0
- sa_token/storage/redis.py +136 -0
- sa_token/stp_interface.py +20 -0
- sa_token/stp_logic.py +911 -0
- sa_token/stp_util.py +367 -0
- sa_token/strategy/__init__.py +77 -0
- sa_token/strategy/base.py +22 -0
- sa_token/strategy/builtin.py +99 -0
- sa_token/strategy/jwt.py +72 -0
- sa_token/sync.py +268 -0
- sa_token/token_io.py +66 -0
- sa_token_python_core-0.1.1.dist-info/METADATA +756 -0
- sa_token_python_core-0.1.1.dist-info/RECORD +46 -0
- sa_token_python_core-0.1.1.dist-info/WHEEL +4 -0
- sa_token_python_core-0.1.1.dist-info/licenses/LICENSE +201 -0
sa_token/stp_util.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""StpUtil:全局静态门面。
|
|
2
|
+
|
|
3
|
+
方法签名与 :class:`~sa_token.stp_logic.StpLogic` 一一对应,只是省去了手动
|
|
4
|
+
传递 Manager。这里刻意逐个显式声明而不是用 ``__getattr__`` 转发,
|
|
5
|
+
这样 IDE 补全、类型检查和文档都能正常工作。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from .context import get_current_login_id, get_current_token
|
|
13
|
+
from .exception import SaTokenNotInitializedException
|
|
14
|
+
from .model import DEFAULT_LOGIN_TYPE, TerminalInfo, TokenInfo
|
|
15
|
+
from .permission import MatchMode
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: no cover - 仅供类型检查
|
|
18
|
+
from .manager import SaTokenManager
|
|
19
|
+
from .security import LoginTokenPair
|
|
20
|
+
from .session import SaSession
|
|
21
|
+
from .stp_logic import StpLogic
|
|
22
|
+
|
|
23
|
+
__all__ = ["StpUtil", "set_manager", "get_manager", "clear_manager"]
|
|
24
|
+
|
|
25
|
+
_manager: SaTokenManager | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_manager(manager: SaTokenManager) -> None:
|
|
29
|
+
"""设置全局 Manager,一般由 ``SaTokenBuilder.build()`` 自动调用。"""
|
|
30
|
+
global _manager
|
|
31
|
+
_manager = manager
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_manager() -> SaTokenManager:
|
|
35
|
+
if _manager is None:
|
|
36
|
+
raise SaTokenNotInitializedException()
|
|
37
|
+
return _manager
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def clear_manager() -> None:
|
|
41
|
+
"""清除全局实例,主要给测试做隔离。"""
|
|
42
|
+
global _manager
|
|
43
|
+
_manager = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StpUtil:
|
|
47
|
+
"""默认账号体系(``login``)的静态门面。"""
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def logic(login_type: str = DEFAULT_LOGIN_TYPE) -> StpLogic:
|
|
51
|
+
"""取得底层 StpLogic,用于访问门面未暴露的高级能力。"""
|
|
52
|
+
return get_manager().stp(login_type)
|
|
53
|
+
|
|
54
|
+
# 认证 -----------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
async def login(
|
|
58
|
+
login_id: Any,
|
|
59
|
+
*,
|
|
60
|
+
device: str | None = None,
|
|
61
|
+
timeout: int | None = None,
|
|
62
|
+
tag: str | None = None,
|
|
63
|
+
extra: dict[str, Any] | None = None,
|
|
64
|
+
token_value: str | None = None,
|
|
65
|
+
) -> str:
|
|
66
|
+
return await StpUtil.logic().login(
|
|
67
|
+
login_id,
|
|
68
|
+
device=device,
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
tag=tag,
|
|
71
|
+
extra=extra,
|
|
72
|
+
token_value=token_value,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
async def login_with_refresh(
|
|
77
|
+
login_id: Any,
|
|
78
|
+
*,
|
|
79
|
+
device: str | None = None,
|
|
80
|
+
timeout: int | None = None,
|
|
81
|
+
tag: str | None = None,
|
|
82
|
+
extra: dict[str, Any] | None = None,
|
|
83
|
+
) -> LoginTokenPair:
|
|
84
|
+
return await StpUtil.logic().login_with_refresh(
|
|
85
|
+
login_id,
|
|
86
|
+
device=device,
|
|
87
|
+
timeout=timeout,
|
|
88
|
+
tag=tag,
|
|
89
|
+
extra=extra,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
async def refresh_access_token(refresh_token: str) -> LoginTokenPair:
|
|
94
|
+
return await get_manager().refresh_tokens.refresh(refresh_token)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
async def logout(login_id: Any, *, device: str | None = None) -> None:
|
|
98
|
+
await StpUtil.logic().logout(login_id, device=device)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
async def logout_by_token(token: str | None = None) -> None:
|
|
102
|
+
await StpUtil.logic().logout_by_token(token)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
async def kickout(login_id: Any, *, device: str | None = None) -> None:
|
|
106
|
+
await StpUtil.logic().kickout(login_id, device=device)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
async def kickout_by_token(token: str | None = None) -> None:
|
|
110
|
+
await StpUtil.logic().kickout_by_token(token)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
async def replaced(login_id: Any, *, device: str | None = None) -> None:
|
|
114
|
+
await StpUtil.logic().replaced(login_id, device=device)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
async def is_login(token: str | None = None) -> bool:
|
|
118
|
+
return await StpUtil.logic().is_login(token)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
async def check_login(token: str | None = None) -> str:
|
|
122
|
+
return await StpUtil.logic().check_login(token)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
async def get_login_id(token: str | None = None) -> str:
|
|
126
|
+
return await StpUtil.logic().get_login_id(token)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
async def get_login_id_or_none(token: str | None = None) -> str | None:
|
|
130
|
+
return await StpUtil.logic().get_login_id_or_none(token)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def get_token_value() -> str | None:
|
|
134
|
+
"""当前调用链绑定的 token(由中间件或 ``sa_token_context`` 写入)。"""
|
|
135
|
+
return get_current_token()
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def get_login_id_from_context() -> str | None:
|
|
139
|
+
"""当前调用链已校验过的 login_id,不产生任何存储访问。"""
|
|
140
|
+
return get_current_login_id()
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
async def get_token_info(token: str | None = None) -> TokenInfo | None:
|
|
144
|
+
return await StpUtil.logic().get_token_info(token)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
async def get_offline_reason(token: str) -> dict[str, Any] | None:
|
|
148
|
+
return await StpUtil.logic().get_offline_reason(token)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
async def renew_timeout(token: str, timeout: int) -> bool:
|
|
152
|
+
return await StpUtil.logic().renew_timeout(token, timeout)
|
|
153
|
+
|
|
154
|
+
# 权限 / 角色 -----------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
async def get_permissions(login_id: Any) -> list[str]:
|
|
158
|
+
return await StpUtil.logic().get_permissions(login_id)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
async def set_permissions(login_id: Any, permissions: list[str]) -> None:
|
|
162
|
+
await StpUtil.logic().set_permissions(login_id, permissions)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
async def add_permission(login_id: Any, permission: str) -> None:
|
|
166
|
+
await StpUtil.logic().add_permission(login_id, permission)
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
async def remove_permission(login_id: Any, permission: str) -> None:
|
|
170
|
+
await StpUtil.logic().remove_permission(login_id, permission)
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
async def clear_permissions(login_id: Any) -> None:
|
|
174
|
+
await StpUtil.logic().clear_permissions(login_id)
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
async def has_permission(login_id: Any, permission: str) -> bool:
|
|
178
|
+
return await StpUtil.logic().has_permission(login_id, permission)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
async def has_permissions_and(login_id: Any, permissions: list[str]) -> bool:
|
|
182
|
+
return await StpUtil.logic().has_permissions_and(login_id, permissions)
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
async def has_permissions_or(login_id: Any, permissions: list[str]) -> bool:
|
|
186
|
+
return await StpUtil.logic().has_permissions_or(login_id, permissions)
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
async def check_permission(
|
|
190
|
+
login_id: Any,
|
|
191
|
+
permissions: str | list[str],
|
|
192
|
+
*,
|
|
193
|
+
mode: MatchMode = "OR",
|
|
194
|
+
) -> None:
|
|
195
|
+
await StpUtil.logic().check_permission(login_id, permissions, mode=mode)
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
async def get_roles(login_id: Any) -> list[str]:
|
|
199
|
+
return await StpUtil.logic().get_roles(login_id)
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
async def set_roles(login_id: Any, roles: list[str]) -> None:
|
|
203
|
+
await StpUtil.logic().set_roles(login_id, roles)
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
async def add_role(login_id: Any, role: str) -> None:
|
|
207
|
+
await StpUtil.logic().add_role(login_id, role)
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
async def remove_role(login_id: Any, role: str) -> None:
|
|
211
|
+
await StpUtil.logic().remove_role(login_id, role)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
async def has_role(login_id: Any, role: str) -> bool:
|
|
215
|
+
return await StpUtil.logic().has_role(login_id, role)
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
async def has_roles_and(login_id: Any, roles: list[str]) -> bool:
|
|
219
|
+
return await StpUtil.logic().has_roles_and(login_id, roles)
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
async def has_roles_or(login_id: Any, roles: list[str]) -> bool:
|
|
223
|
+
return await StpUtil.logic().has_roles_or(login_id, roles)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
async def check_role(
|
|
227
|
+
login_id: Any,
|
|
228
|
+
roles: str | list[str],
|
|
229
|
+
*,
|
|
230
|
+
mode: MatchMode = "OR",
|
|
231
|
+
) -> None:
|
|
232
|
+
await StpUtil.logic().check_role(login_id, roles, mode=mode)
|
|
233
|
+
|
|
234
|
+
# Session ---------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
async def get_session(login_id: Any, *, create: bool = True) -> SaSession | None:
|
|
238
|
+
return await StpUtil.logic().get_session(login_id, create=create)
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
async def get_token_session(token: str | None = None) -> SaSession | None:
|
|
242
|
+
return await StpUtil.logic().get_token_session(token)
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
async def delete_session(login_id: Any) -> None:
|
|
246
|
+
await StpUtil.logic().delete_session(login_id)
|
|
247
|
+
|
|
248
|
+
# 封禁 -----------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
async def disable(
|
|
252
|
+
login_id: Any,
|
|
253
|
+
seconds: int,
|
|
254
|
+
*,
|
|
255
|
+
service: str = "login",
|
|
256
|
+
level: int = 1,
|
|
257
|
+
) -> None:
|
|
258
|
+
await StpUtil.logic().disable(login_id, seconds, service=service, level=level)
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
async def untie(login_id: Any, *, service: str = "login") -> None:
|
|
262
|
+
await StpUtil.logic().untie(login_id, service=service)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
async def is_disable(login_id: Any, *, service: str = "login", level: int = 1) -> bool:
|
|
266
|
+
return await StpUtil.logic().is_disable(login_id, service=service, level=level)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
async def get_disable_time(login_id: Any, *, service: str = "login") -> int:
|
|
270
|
+
return await StpUtil.logic().get_disable_time(login_id, service=service)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
async def get_disable_level(login_id: Any, *, service: str = "login") -> int:
|
|
274
|
+
return await StpUtil.logic().get_disable_level(login_id, service=service)
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
async def check_disable(login_id: Any, *, service: str = "login", level: int = 1) -> None:
|
|
278
|
+
await StpUtil.logic().check_disable(login_id, service=service, level=level)
|
|
279
|
+
|
|
280
|
+
# 二级认证 --------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
async def open_safe(token: str, business: str, seconds: int) -> None:
|
|
284
|
+
await StpUtil.logic().open_safe(token, business, seconds)
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
async def is_safe(token: str | None, business: str) -> bool:
|
|
288
|
+
return await StpUtil.logic().is_safe(token, business)
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
async def check_safe(token: str | None, business: str) -> None:
|
|
292
|
+
await StpUtil.logic().check_safe(token, business)
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
async def close_safe(token: str, business: str) -> None:
|
|
296
|
+
await StpUtil.logic().close_safe(token, business)
|
|
297
|
+
|
|
298
|
+
# 查询 -----------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
async def get_terminal_list(login_id: Any, *, device: str | None = None) -> list[TerminalInfo]:
|
|
302
|
+
return await StpUtil.logic().get_terminal_list(login_id, device=device)
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
async def get_token_value_list_by_login_id(
|
|
306
|
+
login_id: Any,
|
|
307
|
+
*,
|
|
308
|
+
device: str | None = None,
|
|
309
|
+
) -> list[str]:
|
|
310
|
+
return await StpUtil.logic().get_token_value_list_by_login_id(login_id, device=device)
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
async def search_token_value(
|
|
314
|
+
keyword: str = "",
|
|
315
|
+
*,
|
|
316
|
+
start: int = 0,
|
|
317
|
+
size: int = 100,
|
|
318
|
+
) -> list[str]:
|
|
319
|
+
return await StpUtil.logic().search_token_value(keyword, start=start, size=size)
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
async def search_session(
|
|
323
|
+
keyword: str = "",
|
|
324
|
+
*,
|
|
325
|
+
start: int = 0,
|
|
326
|
+
size: int = 100,
|
|
327
|
+
) -> list[str]:
|
|
328
|
+
return await StpUtil.logic().search_session(keyword, start=start, size=size)
|
|
329
|
+
|
|
330
|
+
# 安全工具 -------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
async def issue_nonce(subject: Any, *, purpose: str = "default") -> str:
|
|
334
|
+
return await get_manager().nonces.issue(str(subject), purpose=purpose)
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
async def consume_nonce(
|
|
338
|
+
nonce: str, subject: Any, *, purpose: str = "default"
|
|
339
|
+
) -> None:
|
|
340
|
+
await get_manager().nonces.consume(nonce, str(subject), purpose=purpose)
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
async def create_temp_token(
|
|
344
|
+
value: Any,
|
|
345
|
+
timeout: int,
|
|
346
|
+
*,
|
|
347
|
+
namespace: str = "default",
|
|
348
|
+
record_index: bool = False,
|
|
349
|
+
) -> str:
|
|
350
|
+
return await get_manager().temp_tokens.create(
|
|
351
|
+
value,
|
|
352
|
+
timeout,
|
|
353
|
+
namespace=namespace,
|
|
354
|
+
record_index=record_index,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
async def parse_temp_token(token: str, *, namespace: str = "default") -> Any | None:
|
|
359
|
+
return await get_manager().temp_tokens.parse(token, namespace=namespace)
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
async def consume_temp_token(token: str, *, namespace: str = "default") -> Any | None:
|
|
363
|
+
return await get_manager().temp_tokens.consume(token, namespace=namespace)
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
async def delete_temp_token(token: str, *, namespace: str = "default") -> bool:
|
|
367
|
+
return await get_manager().temp_tokens.delete(token, namespace=namespace)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Token 生成策略。
|
|
2
|
+
|
|
3
|
+
``create_strategy`` 把配置里的字符串风格名翻译成具体实现,
|
|
4
|
+
这样用户既可以用 ``token_style="uuid"``,也能直接注入自定义策略对象。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..config import SaTokenConfig
|
|
10
|
+
from .base import TokenStrategy
|
|
11
|
+
from .builtin import (
|
|
12
|
+
HashStrategy,
|
|
13
|
+
RandomStrategy,
|
|
14
|
+
SimpleUuidStrategy,
|
|
15
|
+
TikStrategy,
|
|
16
|
+
TimestampStrategy,
|
|
17
|
+
UuidStrategy,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"TokenStrategy",
|
|
22
|
+
"UuidStrategy",
|
|
23
|
+
"SimpleUuidStrategy",
|
|
24
|
+
"RandomStrategy",
|
|
25
|
+
"HashStrategy",
|
|
26
|
+
"TimestampStrategy",
|
|
27
|
+
"TikStrategy",
|
|
28
|
+
"JwtStrategy",
|
|
29
|
+
"create_strategy",
|
|
30
|
+
"BUILTIN_STYLES",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
BUILTIN_STYLES = (
|
|
34
|
+
"uuid",
|
|
35
|
+
"simple-uuid",
|
|
36
|
+
"random32",
|
|
37
|
+
"random64",
|
|
38
|
+
"random128",
|
|
39
|
+
"hash",
|
|
40
|
+
"timestamp",
|
|
41
|
+
"tik",
|
|
42
|
+
"jwt",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_strategy(config: SaTokenConfig) -> TokenStrategy:
|
|
47
|
+
"""按 ``config.token_style`` 创建策略实例。"""
|
|
48
|
+
style = config.token_style.strip().lower()
|
|
49
|
+
if style == "uuid":
|
|
50
|
+
return UuidStrategy()
|
|
51
|
+
if style in ("simple-uuid", "simple_uuid"):
|
|
52
|
+
return SimpleUuidStrategy()
|
|
53
|
+
if style.startswith("random"):
|
|
54
|
+
suffix = style[len("random") :]
|
|
55
|
+
return RandomStrategy(int(suffix) if suffix.isdigit() else 32)
|
|
56
|
+
if style == "hash":
|
|
57
|
+
return HashStrategy()
|
|
58
|
+
if style == "timestamp":
|
|
59
|
+
return TimestampStrategy()
|
|
60
|
+
if style == "tik":
|
|
61
|
+
return TikStrategy()
|
|
62
|
+
if style == "jwt":
|
|
63
|
+
from .jwt import JwtStrategy
|
|
64
|
+
|
|
65
|
+
return JwtStrategy(
|
|
66
|
+
config.jwt_secret_key or "",
|
|
67
|
+
algorithm=config.jwt_algorithm,
|
|
68
|
+
)
|
|
69
|
+
raise ValueError(f"未知的 token_style: {config.token_style},可选:{', '.join(BUILTIN_STYLES)}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def __getattr__(name: str):
|
|
73
|
+
if name == "JwtStrategy":
|
|
74
|
+
from .jwt import JwtStrategy
|
|
75
|
+
|
|
76
|
+
return JwtStrategy
|
|
77
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Token 生成策略契约。
|
|
2
|
+
|
|
3
|
+
生成什么样的字符串是策略的事;这个字符串代表谁,永远以存储为准。
|
|
4
|
+
因此非 JWT 策略的 :meth:`parse` 返回 ``None`` 是完全正常的。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
__all__ = ["TokenStrategy"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class TokenStrategy(Protocol):
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
def generate(self, login_id: str, extra: dict[str, Any] | None = None) -> str: ...
|
|
19
|
+
|
|
20
|
+
def parse(self, token: str) -> dict[str, Any] | None:
|
|
21
|
+
"""自解释 token(如 JWT)返回载荷,其余返回 ``None``。"""
|
|
22
|
+
...
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""内置的不透明 token 生成策略。
|
|
2
|
+
|
|
3
|
+
全部使用 :mod:`secrets` 而非 :mod:`random`,保证 token 不可预测。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import secrets
|
|
10
|
+
import string
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..model import now_ms
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"UuidStrategy",
|
|
18
|
+
"SimpleUuidStrategy",
|
|
19
|
+
"RandomStrategy",
|
|
20
|
+
"HashStrategy",
|
|
21
|
+
"TimestampStrategy",
|
|
22
|
+
"TikStrategy",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_TIK_ALPHABET = string.ascii_letters + string.digits
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _OpaqueStrategy:
|
|
29
|
+
"""不透明 token 的公共基类:无法从 token 本身反推身份。"""
|
|
30
|
+
|
|
31
|
+
name = "opaque"
|
|
32
|
+
|
|
33
|
+
def parse(self, token: str) -> dict[str, Any] | None:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UuidStrategy(_OpaqueStrategy):
|
|
38
|
+
name = "uuid"
|
|
39
|
+
|
|
40
|
+
def generate(self, login_id: str, extra: dict[str, Any] | None = None) -> str:
|
|
41
|
+
return str(uuid.uuid4())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SimpleUuidStrategy(_OpaqueStrategy):
|
|
45
|
+
name = "simple-uuid"
|
|
46
|
+
|
|
47
|
+
def generate(self, login_id: str, extra: dict[str, Any] | None = None) -> str:
|
|
48
|
+
return uuid.uuid4().hex
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RandomStrategy(_OpaqueStrategy):
|
|
52
|
+
"""定长随机 hex 串,``length`` 为字符数。"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, length: int = 32) -> None:
|
|
55
|
+
if length < 8:
|
|
56
|
+
raise ValueError("随机 token 长度不得小于 8")
|
|
57
|
+
self.length = length
|
|
58
|
+
self.name = f"random{length}"
|
|
59
|
+
|
|
60
|
+
def generate(self, login_id: str, extra: dict[str, Any] | None = None) -> str:
|
|
61
|
+
return secrets.token_hex((self.length + 1) // 2)[: self.length]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HashStrategy(_OpaqueStrategy):
|
|
65
|
+
"""SHA256(login_id + 随机盐),长度固定 64。
|
|
66
|
+
|
|
67
|
+
掺入随机盐是必须的:否则同一个 login_id 永远得到同一个 token,
|
|
68
|
+
多端登录与顶号语义会直接失效。
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name = "hash"
|
|
72
|
+
|
|
73
|
+
def generate(self, login_id: str, extra: dict[str, Any] | None = None) -> str:
|
|
74
|
+
material = f"{login_id}:{now_ms()}:{secrets.token_hex(16)}"
|
|
75
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TimestampStrategy(_OpaqueStrategy):
|
|
79
|
+
"""``{毫秒时间戳}_{随机串}``,便于从 token 直接看出签发时间。"""
|
|
80
|
+
|
|
81
|
+
name = "timestamp"
|
|
82
|
+
|
|
83
|
+
def generate(self, login_id: str, extra: dict[str, Any] | None = None) -> str:
|
|
84
|
+
return f"{now_ms()}_{secrets.token_hex(8)}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TikStrategy(_OpaqueStrategy):
|
|
88
|
+
"""短 token,适合放进 URL 或口令分享。
|
|
89
|
+
|
|
90
|
+
长度短意味着熵低,仅建议用于短期 / 一次性场景。
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
name = "tik"
|
|
94
|
+
|
|
95
|
+
def __init__(self, length: int = 8) -> None:
|
|
96
|
+
self.length = length
|
|
97
|
+
|
|
98
|
+
def generate(self, login_id: str, extra: dict[str, Any] | None = None) -> str:
|
|
99
|
+
return "".join(secrets.choice(_TIK_ALPHABET) for _ in range(self.length))
|
sa_token/strategy/jwt.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""有状态 JWT 策略。
|
|
2
|
+
|
|
3
|
+
token 自带 claims,但校验时**仍然查存储**,因此踢人、顶号、封禁全部继续有效。
|
|
4
|
+
这是刻意的取舍:纯无状态 JWT 无法在服务端即时作废,与本项目的核心语义冲突。
|
|
5
|
+
|
|
6
|
+
需要额外安装:``pip install "sa-token-python-core[jwt]"``。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import secrets
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..model import now_ms
|
|
15
|
+
|
|
16
|
+
__all__ = ["JwtStrategy"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JwtStrategy:
|
|
20
|
+
name = "jwt"
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
secret_key: str,
|
|
25
|
+
*,
|
|
26
|
+
algorithm: str = "HS256",
|
|
27
|
+
issuer: str | None = None,
|
|
28
|
+
audience: str | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
if not secret_key:
|
|
31
|
+
raise ValueError("使用 JWT 风格时必须配置 jwt_secret_key")
|
|
32
|
+
try:
|
|
33
|
+
import jwt as pyjwt
|
|
34
|
+
except ImportError as exc: # pragma: no cover - 依赖缺失路径
|
|
35
|
+
raise ImportError(
|
|
36
|
+
'JwtStrategy 需要 PyJWT 依赖,请执行:pip install "sa-token-python-core[jwt]"'
|
|
37
|
+
) from exc
|
|
38
|
+
self._jwt = pyjwt
|
|
39
|
+
self.secret_key = secret_key
|
|
40
|
+
self.algorithm = algorithm
|
|
41
|
+
self.issuer = issuer
|
|
42
|
+
self.audience = audience
|
|
43
|
+
|
|
44
|
+
def generate(self, login_id: str, extra: dict[str, Any] | None = None) -> str:
|
|
45
|
+
issued_at = now_ms() // 1000
|
|
46
|
+
payload: dict[str, Any] = {
|
|
47
|
+
"loginId": login_id,
|
|
48
|
+
"iat": issued_at,
|
|
49
|
+
# jti 让相同 login_id 的多次登录得到不同 token,多端语义才成立。
|
|
50
|
+
"jti": secrets.token_hex(12),
|
|
51
|
+
}
|
|
52
|
+
if self.issuer:
|
|
53
|
+
payload["iss"] = self.issuer
|
|
54
|
+
if self.audience:
|
|
55
|
+
payload["aud"] = self.audience
|
|
56
|
+
if extra:
|
|
57
|
+
payload.update(extra)
|
|
58
|
+
return self._jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
|
59
|
+
|
|
60
|
+
def parse(self, token: str) -> dict[str, Any] | None:
|
|
61
|
+
try:
|
|
62
|
+
return self._jwt.decode(
|
|
63
|
+
token,
|
|
64
|
+
self.secret_key,
|
|
65
|
+
algorithms=[self.algorithm],
|
|
66
|
+
audience=self.audience,
|
|
67
|
+
issuer=self.issuer,
|
|
68
|
+
# 过期与否由存储层的 TTL 决定,避免两套过期时间互相打架。
|
|
69
|
+
options={"verify_exp": False},
|
|
70
|
+
)
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|