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/manager.py ADDED
@@ -0,0 +1,244 @@
1
+ """SaTokenManager 与 Builder。
2
+
3
+ Manager 负责组装:配置 + 存储 + Token 策略 + 事件总线 + 权限数据源,
4
+ 并按 ``login_type`` 缓存 :class:`StpLogic` 实例。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from .config import SaTokenConfig
13
+ from .listener import Event, EventBus, Listener
14
+ from .model import DEFAULT_LOGIN_TYPE
15
+ from .stp_logic import StpLogic
16
+ from .strategy import TokenStrategy, create_strategy
17
+
18
+ if TYPE_CHECKING: # pragma: no cover - 仅供类型检查
19
+ from .security import NonceManager, RefreshTokenManager, TempTokenManager
20
+ from .storage.base import SaStorage
21
+ from .stp_interface import StpInterface
22
+
23
+ __all__ = ["SaTokenManager", "SaTokenBuilder", "SaToken", "__version__"]
24
+
25
+ __version__ = "0.1.1"
26
+
27
+ _BANNER = r"""
28
+ _____ ______ __
29
+ / ___/____ _ /_ __/___ / /_____ ____
30
+ \__ \/ __ `/ / / / __ \/ //_/ _ \/ __ \
31
+ ___/ / /_/ / / / / /_/ / ,< / __/ / / /
32
+ /____/\__,_/ /_/ \____/_/|_|\___/_/ /_/
33
+ """
34
+
35
+
36
+ class SaTokenManager:
37
+ """框架无关的运行时核心。"""
38
+
39
+ def __init__(
40
+ self,
41
+ config: SaTokenConfig,
42
+ storage: SaStorage,
43
+ *,
44
+ strategy: TokenStrategy | None = None,
45
+ stp_interface: StpInterface | None = None,
46
+ events: EventBus | None = None,
47
+ ) -> None:
48
+ self.config = config
49
+ self.storage = storage
50
+ self.strategy = strategy or create_strategy(config)
51
+ self.stp_interface = stp_interface
52
+ self.events = events or EventBus()
53
+ self._logics: dict[str, StpLogic] = {}
54
+ self._nonce_manager: NonceManager | None = None
55
+ self._refresh_token_manager: RefreshTokenManager | None = None
56
+ self._temp_token_manager: TempTokenManager | None = None
57
+
58
+ def stp(self, login_type: str = DEFAULT_LOGIN_TYPE) -> StpLogic:
59
+ """取得指定账号体系的逻辑实例,不同 ``login_type`` 之间存储完全隔离。"""
60
+ logic = self._logics.get(login_type)
61
+ if logic is None:
62
+ logic = StpLogic(self, login_type)
63
+ self._logics[login_type] = logic
64
+ return logic
65
+
66
+ def on(self, event: Event, listener: Listener, *, priority: int = 0) -> None:
67
+ self.events.on(event, listener, priority=priority)
68
+
69
+ @property
70
+ def nonces(self) -> NonceManager:
71
+ """服务端 Nonce 管理器,按需创建且复用当前 Storage。"""
72
+ if self._nonce_manager is None:
73
+ from .security import NonceManager
74
+
75
+ self._nonce_manager = NonceManager(
76
+ self.storage,
77
+ key_prefix=self.config.storage_key_prefix,
78
+ timeout=self.config.nonce_timeout,
79
+ )
80
+ return self._nonce_manager
81
+
82
+ @property
83
+ def refresh_tokens(self) -> RefreshTokenManager:
84
+ """登录态 Refresh Token 管理器(不是 OAuth2 Refresh Token)。"""
85
+ if self._refresh_token_manager is None:
86
+ from .security import RefreshTokenManager
87
+
88
+ self._refresh_token_manager = RefreshTokenManager(self)
89
+ return self._refresh_token_manager
90
+
91
+ @property
92
+ def temp_tokens(self) -> TempTokenManager:
93
+ """邀请、重置密码等短时业务 Token 管理器。"""
94
+ if self._temp_token_manager is None:
95
+ from .security import TempTokenManager
96
+
97
+ self._temp_token_manager = TempTokenManager(
98
+ self.storage, key_prefix=self.config.storage_key_prefix
99
+ )
100
+ return self._temp_token_manager
101
+
102
+ def print_banner(self) -> None:
103
+ lines = [
104
+ _BANNER,
105
+ f":: sa-token-python :: (v{__version__})",
106
+ f":: Python :: {sys.version.split()[0]}",
107
+ f":: Token Style :: {self.config.token_style}",
108
+ f":: Token Timeout :: {self.config.timeout} 秒",
109
+ f":: Storage :: {type(self.storage).__name__}",
110
+ "",
111
+ ]
112
+ print("\n".join(lines))
113
+
114
+
115
+ class SaTokenBuilder:
116
+ """链式配置构建器。
117
+
118
+ 每个方法只改配置,``build()` 之前不会产生任何副作用,便于测试。
119
+ """
120
+
121
+ def __init__(self) -> None:
122
+ self._config = SaTokenConfig()
123
+ self._storage: SaStorage | None = None
124
+ self._strategy: TokenStrategy | None = None
125
+ self._stp_interface: StpInterface | None = None
126
+ self._events = EventBus()
127
+ self._set_global = True
128
+
129
+ # 配置项 ---------------------------------------------------------------
130
+
131
+ def config(self, config: SaTokenConfig) -> SaTokenBuilder:
132
+ """整体替换配置,之后仍可用其它方法微调。"""
133
+ self._config = config
134
+ return self
135
+
136
+ def storage(self, storage: SaStorage) -> SaTokenBuilder:
137
+ self._storage = storage
138
+ return self
139
+
140
+ def strategy(self, strategy: TokenStrategy) -> SaTokenBuilder:
141
+ self._strategy = strategy
142
+ return self
143
+
144
+ def stp_interface(self, stp_interface: StpInterface) -> SaTokenBuilder:
145
+ self._stp_interface = stp_interface
146
+ return self
147
+
148
+ def token_name(self, name: str) -> SaTokenBuilder:
149
+ self._config.token_name = name
150
+ return self
151
+
152
+ def timeout(self, seconds: int) -> SaTokenBuilder:
153
+ self._config.timeout = seconds
154
+ return self
155
+
156
+ def active_timeout(self, seconds: int) -> SaTokenBuilder:
157
+ self._config.active_timeout = seconds
158
+ return self
159
+
160
+ def token_style(self, style: str) -> SaTokenBuilder:
161
+ self._config.token_style = style
162
+ return self
163
+
164
+ def token_prefix(self, prefix: str) -> SaTokenBuilder:
165
+ self._config.token_prefix = prefix
166
+ return self
167
+
168
+ def is_concurrent(self, value: bool) -> SaTokenBuilder:
169
+ self._config.is_concurrent = value
170
+ return self
171
+
172
+ def is_share(self, value: bool) -> SaTokenBuilder:
173
+ self._config.is_share = value
174
+ return self
175
+
176
+ def max_login_count(self, count: int) -> SaTokenBuilder:
177
+ self._config.max_login_count = count
178
+ return self
179
+
180
+ def auto_renew(self, value: bool) -> SaTokenBuilder:
181
+ self._config.auto_renew = value
182
+ return self
183
+
184
+ def storage_key_prefix(self, prefix: str) -> SaTokenBuilder:
185
+ self._config.storage_key_prefix = prefix
186
+ return self
187
+
188
+ def jwt_secret_key(self, secret: str) -> SaTokenBuilder:
189
+ self._config.jwt_secret_key = secret
190
+ return self
191
+
192
+ def print_banner(self, value: bool) -> SaTokenBuilder:
193
+ self._config.is_print_banner = value
194
+ return self
195
+
196
+ def set_option(self, **options: Any) -> SaTokenBuilder:
197
+ """批量设置任意配置项,便于从配置文件加载。"""
198
+ for key, value in options.items():
199
+ if not hasattr(self._config, key):
200
+ raise ValueError(f"未知配置项:{key}")
201
+ setattr(self._config, key, value)
202
+ return self
203
+
204
+ def on(self, event: Event, listener: Listener, *, priority: int = 0) -> SaTokenBuilder:
205
+ self._events.on(event, listener, priority=priority)
206
+ return self
207
+
208
+ def as_global(self, value: bool) -> SaTokenBuilder:
209
+ """是否把构建结果设为 ``StpUtil`` 使用的全局实例,默认是。"""
210
+ self._set_global = value
211
+ return self
212
+
213
+ # 构建 -----------------------------------------------------------------
214
+
215
+ def build(self) -> SaTokenManager:
216
+ storage = self._storage
217
+ if storage is None:
218
+ # 不静默降级到内存:多进程部署下会出现「登录了但下个请求说没登录」。
219
+ from .storage.memory import MemoryStorage
220
+
221
+ storage = MemoryStorage()
222
+ self._config.__post_init__()
223
+ manager = SaTokenManager(
224
+ self._config,
225
+ storage,
226
+ strategy=self._strategy,
227
+ stp_interface=self._stp_interface,
228
+ events=self._events,
229
+ )
230
+ if self._config.is_print_banner:
231
+ manager.print_banner()
232
+ if self._set_global:
233
+ from .stp_util import set_manager
234
+
235
+ set_manager(manager)
236
+ return manager
237
+
238
+
239
+ class SaToken:
240
+ """入口门面:``SaToken.builder()...build()``。"""
241
+
242
+ @staticmethod
243
+ def builder() -> SaTokenBuilder:
244
+ return SaTokenBuilder()
sa_token/model.py ADDED
@@ -0,0 +1,145 @@
1
+ """核心数据模型。
2
+
3
+ 所有模型都能无损地 JSON 序列化:存储层只接受字符串,这样才能跨进程、
4
+ 跨语言共享同一份 Redis 数据,也避免 pickle 带来的反序列化风险。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from dataclasses import asdict, dataclass, field
12
+ from typing import Any
13
+
14
+ from .exception import NotLoginType
15
+
16
+ __all__ = [
17
+ "DEFAULT_DEVICE",
18
+ "DEFAULT_LOGIN_TYPE",
19
+ "TokenInfo",
20
+ "TerminalInfo",
21
+ "SessionData",
22
+ "now_ms",
23
+ ]
24
+
25
+ DEFAULT_DEVICE = "default"
26
+ DEFAULT_LOGIN_TYPE = "login"
27
+
28
+ #: token 记录里允许出现的下线状态,与 NotLoginType 中的子集一一对应。
29
+ _OFFLINE_STATES = {
30
+ NotLoginType.KICK_OUT.value,
31
+ NotLoginType.BE_REPLACED.value,
32
+ NotLoginType.TOKEN_FREEZE.value,
33
+ }
34
+
35
+
36
+ def now_ms() -> int:
37
+ return int(time.time() * 1000)
38
+
39
+
40
+ @dataclass
41
+ class TokenInfo:
42
+ """``token -> 身份`` 的存储记录。
43
+
44
+ 被踢 / 被顶时不删除该记录,而是把 ``state`` 置为下线原因并缩短 TTL,
45
+ 这样下一次请求能返回明确原因,而不是笼统的「未登录」。
46
+ """
47
+
48
+ login_id: str
49
+ device: str = DEFAULT_DEVICE
50
+ login_type: str = DEFAULT_LOGIN_TYPE
51
+ create_time: int = field(default_factory=now_ms)
52
+ active_time: int = field(default_factory=now_ms)
53
+ timeout: int | None = None
54
+ active_timeout: int | None = None
55
+ tag: str | None = None
56
+ state: str | None = None
57
+ offline_time: int | None = None
58
+
59
+ @property
60
+ def is_offline(self) -> bool:
61
+ return self.state in _OFFLINE_STATES
62
+
63
+ @property
64
+ def offline_type(self) -> NotLoginType | None:
65
+ if not self.is_offline:
66
+ return None
67
+ return NotLoginType(self.state)
68
+
69
+ def to_json(self) -> str:
70
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
71
+
72
+ @classmethod
73
+ def from_json(cls, raw: str) -> TokenInfo | None:
74
+ try:
75
+ payload = json.loads(raw)
76
+ except (TypeError, ValueError):
77
+ return None
78
+ if not isinstance(payload, dict) or "login_id" not in payload:
79
+ return None
80
+ allowed = {f for f in cls.__dataclass_fields__}
81
+ return cls(**{k: v for k, v in payload.items() if k in allowed})
82
+
83
+
84
+ @dataclass
85
+ class TerminalInfo:
86
+ """Account-Session 中的一条在线终端记录。"""
87
+
88
+ token: str
89
+ device: str = DEFAULT_DEVICE
90
+ login_time: int = field(default_factory=now_ms)
91
+ index: int = 0
92
+
93
+ def to_dict(self) -> dict[str, Any]:
94
+ return asdict(self)
95
+
96
+ @classmethod
97
+ def from_dict(cls, payload: dict[str, Any]) -> TerminalInfo:
98
+ allowed = {f for f in cls.__dataclass_fields__}
99
+ return cls(**{k: v for k, v in payload.items() if k in allowed})
100
+
101
+
102
+ @dataclass
103
+ class SessionData:
104
+ """Session 的可序列化载荷。
105
+
106
+ Account-Session 额外承担「该账号当前有哪些终端在线」的索引职责,
107
+ 避免为多端登录再维护一份容易和 Session 失配的索引键。
108
+ """
109
+
110
+ id: str
111
+ create_time: int = field(default_factory=now_ms)
112
+ data: dict[str, Any] = field(default_factory=dict)
113
+ terminal_list: list[TerminalInfo] = field(default_factory=list)
114
+ history_terminal_count: int = 0
115
+
116
+ def to_json(self) -> str:
117
+ payload = {
118
+ "id": self.id,
119
+ "create_time": self.create_time,
120
+ "data": self.data,
121
+ "terminal_list": [terminal.to_dict() for terminal in self.terminal_list],
122
+ "history_terminal_count": self.history_terminal_count,
123
+ }
124
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
125
+
126
+ @classmethod
127
+ def from_json(cls, raw: str) -> SessionData | None:
128
+ try:
129
+ payload = json.loads(raw)
130
+ except (TypeError, ValueError):
131
+ return None
132
+ if not isinstance(payload, dict) or "id" not in payload:
133
+ return None
134
+ terminals = [
135
+ TerminalInfo.from_dict(item)
136
+ for item in payload.get("terminal_list", [])
137
+ if isinstance(item, dict)
138
+ ]
139
+ return cls(
140
+ id=payload["id"],
141
+ create_time=payload.get("create_time", now_ms()),
142
+ data=payload.get("data", {}),
143
+ terminal_list=terminals,
144
+ history_terminal_count=payload.get("history_terminal_count", len(terminals)),
145
+ )
@@ -0,0 +1,19 @@
1
+ """OAuth2 授权码流程(含 PKCE)。
2
+
3
+ 框架无关:协议引擎只依赖核心存储,HTTP 端点由使用方挂载。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .model import AccessTokenInfo, AuthorizationCode, OAuth2Client, TokenResponse
9
+ from .server import OAuth2Error, OAuth2Server, generate_pkce_pair
10
+
11
+ __all__ = [
12
+ "OAuth2Client",
13
+ "AuthorizationCode",
14
+ "AccessTokenInfo",
15
+ "TokenResponse",
16
+ "OAuth2Server",
17
+ "OAuth2Error",
18
+ "generate_pkce_pair",
19
+ ]
@@ -0,0 +1,122 @@
1
+ """OAuth2 数据模型。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+ from typing import Any
8
+
9
+ from ..model import now_ms
10
+
11
+ __all__ = ["OAuth2Client", "AuthorizationCode", "AccessTokenInfo", "TokenResponse"]
12
+
13
+
14
+ @dataclass
15
+ class OAuth2Client:
16
+ """已注册的客户端。
17
+
18
+ ``client_secret`` 为空表示公开客户端(SPA / 移动端),此类客户端
19
+ **必须**使用 PKCE,否则授权码可能被同设备上的恶意应用截获。
20
+ """
21
+
22
+ client_id: str
23
+ client_secret: str | None = None
24
+ redirect_uris: list[str] = field(default_factory=list)
25
+ grant_types: list[str] = field(default_factory=lambda: ["authorization_code"])
26
+ scopes: list[str] = field(default_factory=list)
27
+ name: str | None = None
28
+
29
+ @property
30
+ def is_public(self) -> bool:
31
+ return not self.client_secret
32
+
33
+ def allows_redirect(self, redirect_uri: str) -> bool:
34
+ # 精确匹配,不做前缀匹配:前缀匹配是经典的开放重定向漏洞来源。
35
+ return redirect_uri in self.redirect_uris
36
+
37
+ def to_json(self) -> str:
38
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
39
+
40
+ @classmethod
41
+ def from_json(cls, raw: str) -> OAuth2Client | None:
42
+ try:
43
+ payload = json.loads(raw)
44
+ except ValueError:
45
+ return None
46
+ if not isinstance(payload, dict) or "client_id" not in payload:
47
+ return None
48
+ allowed = set(cls.__dataclass_fields__)
49
+ return cls(**{k: v for k, v in payload.items() if k in allowed})
50
+
51
+
52
+ @dataclass
53
+ class AuthorizationCode:
54
+ code: str
55
+ client_id: str
56
+ login_id: str
57
+ redirect_uri: str
58
+ scopes: list[str] = field(default_factory=list)
59
+ create_time: int = field(default_factory=now_ms)
60
+ code_challenge: str | None = None
61
+ code_challenge_method: str = "S256"
62
+ state: str | None = None
63
+
64
+ def to_json(self) -> str:
65
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
66
+
67
+ @classmethod
68
+ def from_json(cls, raw: str) -> AuthorizationCode | None:
69
+ try:
70
+ payload = json.loads(raw)
71
+ except ValueError:
72
+ return None
73
+ if not isinstance(payload, dict) or "code" not in payload:
74
+ return None
75
+ allowed = set(cls.__dataclass_fields__)
76
+ return cls(**{k: v for k, v in payload.items() if k in allowed})
77
+
78
+
79
+ @dataclass
80
+ class AccessTokenInfo:
81
+ access_token: str
82
+ client_id: str
83
+ login_id: str
84
+ scopes: list[str] = field(default_factory=list)
85
+ create_time: int = field(default_factory=now_ms)
86
+ expires_in: int = 7200
87
+
88
+ def to_json(self) -> str:
89
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
90
+
91
+ @classmethod
92
+ def from_json(cls, raw: str) -> AccessTokenInfo | None:
93
+ try:
94
+ payload = json.loads(raw)
95
+ except ValueError:
96
+ return None
97
+ if not isinstance(payload, dict) or "access_token" not in payload:
98
+ return None
99
+ allowed = set(cls.__dataclass_fields__)
100
+ return cls(**{k: v for k, v in payload.items() if k in allowed})
101
+
102
+
103
+ @dataclass
104
+ class TokenResponse:
105
+ """``/oauth2/token`` 端点的标准响应体。"""
106
+
107
+ access_token: str
108
+ refresh_token: str | None = None
109
+ token_type: str = "Bearer"
110
+ expires_in: int = 7200
111
+ scope: str = ""
112
+
113
+ def to_dict(self) -> dict[str, Any]:
114
+ payload: dict[str, Any] = {
115
+ "access_token": self.access_token,
116
+ "token_type": self.token_type,
117
+ "expires_in": self.expires_in,
118
+ "scope": self.scope,
119
+ }
120
+ if self.refresh_token:
121
+ payload["refresh_token"] = self.refresh_token
122
+ return payload