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.
Files changed (46) hide show
  1. sa_token/__init__.py +89 -0
  2. sa_token/adapter/__init__.py +24 -0
  3. sa_token/adapter/http.py +71 -0
  4. sa_token/adapter/path.py +163 -0
  5. sa_token/adapter/pipeline.py +97 -0
  6. sa_token/config.py +130 -0
  7. sa_token/context.py +63 -0
  8. sa_token/exception.py +143 -0
  9. sa_token/integration/__init__.py +10 -0
  10. sa_token/integration/django.py +131 -0
  11. sa_token/integration/fastapi.py +315 -0
  12. sa_token/integration/fastapi_oauth2.py +136 -0
  13. sa_token/integration/flask.py +191 -0
  14. sa_token/integration/starlette.py +227 -0
  15. sa_token/listener.py +100 -0
  16. sa_token/manager.py +244 -0
  17. sa_token/model.py +145 -0
  18. sa_token/oauth2/__init__.py +19 -0
  19. sa_token/oauth2/model.py +122 -0
  20. sa_token/oauth2/server.py +361 -0
  21. sa_token/online/__init__.py +292 -0
  22. sa_token/permission.py +67 -0
  23. sa_token/py.typed +0 -0
  24. sa_token/security/__init__.py +14 -0
  25. sa_token/security/nonce.py +93 -0
  26. sa_token/security/refresh.py +300 -0
  27. sa_token/security/temp_token.py +114 -0
  28. sa_token/session.py +96 -0
  29. sa_token/sso/__init__.py +217 -0
  30. sa_token/storage/__init__.py +22 -0
  31. sa_token/storage/base.py +66 -0
  32. sa_token/storage/memory.py +154 -0
  33. sa_token/storage/redis.py +136 -0
  34. sa_token/stp_interface.py +20 -0
  35. sa_token/stp_logic.py +911 -0
  36. sa_token/stp_util.py +367 -0
  37. sa_token/strategy/__init__.py +77 -0
  38. sa_token/strategy/base.py +22 -0
  39. sa_token/strategy/builtin.py +99 -0
  40. sa_token/strategy/jwt.py +72 -0
  41. sa_token/sync.py +268 -0
  42. sa_token/token_io.py +66 -0
  43. sa_token_python_core-0.1.1.dist-info/METADATA +756 -0
  44. sa_token_python_core-0.1.1.dist-info/RECORD +46 -0
  45. sa_token_python_core-0.1.1.dist-info/WHEEL +4 -0
  46. 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))
@@ -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