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/permission.py ADDED
@@ -0,0 +1,67 @@
1
+ """权限匹配引擎。
2
+
3
+ 规则刻意保持简单、可预测:只有 ``*`` 一种通配符,按 ``:`` 分段匹配。
4
+ 复杂策略(ABAC / ReBAC)请通过 :class:`~sa_token.stp_interface.StpInterface`
5
+ 对接 Casbin 等专门的策略引擎,不要把策略语言塞进核心。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable, Sequence
11
+ from typing import Literal
12
+
13
+ __all__ = ["MatchMode", "vague_match", "has_element", "match_all", "match_any"]
14
+
15
+ MatchMode = Literal["AND", "OR"]
16
+
17
+
18
+ def vague_match(pattern: str, target: str) -> bool:
19
+ """判断单条已授权的 ``pattern`` 是否覆盖所需的 ``target``。
20
+
21
+ - 完全相同:``user:read`` 命中 ``user:read``
22
+ - 末尾通配:``user:*`` 命中 ``user:read``、``user:read:self``
23
+ - 中间通配:``user:*:view`` 命中 ``user:admin:view``
24
+ - 全局通配:``*`` 命中任意权限
25
+ """
26
+ if not pattern or not target:
27
+ return False
28
+ if pattern == target:
29
+ return True
30
+ if "*" not in pattern:
31
+ return False
32
+
33
+ pattern_parts = pattern.split(":")
34
+ target_parts = target.split(":")
35
+
36
+ for index, pattern_part in enumerate(pattern_parts):
37
+ is_last_pattern_part = index == len(pattern_parts) - 1
38
+ if pattern_part == "*" and is_last_pattern_part:
39
+ # 末尾的 * 吃掉剩余全部层级,但至少要有一层可吃。
40
+ return len(target_parts) > index
41
+ if index >= len(target_parts):
42
+ return False
43
+ if pattern_part != "*" and pattern_part != target_parts[index]:
44
+ return False
45
+
46
+ # 模式已用尽:只有层级数完全相同才算命中,避免 a:b 命中 a:b:c。
47
+ return len(pattern_parts) == len(target_parts)
48
+
49
+
50
+ def has_element(granted: Iterable[str], required: str) -> bool:
51
+ """已授权集合中是否存在覆盖 ``required`` 的条目。"""
52
+ return any(vague_match(item, required) for item in granted)
53
+
54
+
55
+ def match_all(granted: Iterable[str], required: Sequence[str]) -> str | None:
56
+ """AND 语义:返回第一个未命中的条目,全部命中时返回 ``None``。"""
57
+ granted_list = list(granted)
58
+ for item in required:
59
+ if not has_element(granted_list, item):
60
+ return item
61
+ return None
62
+
63
+
64
+ def match_any(granted: Iterable[str], required: Sequence[str]) -> bool:
65
+ """OR 语义:命中任意一个即可。"""
66
+ granted_list = list(granted)
67
+ return any(has_element(granted_list, item) for item in required)
sa_token/py.typed ADDED
File without changes
@@ -0,0 +1,14 @@
1
+ """框架无关的安全与登录生命周期扩展。"""
2
+
3
+ from .nonce import NonceManager, NonceRecord
4
+ from .refresh import LoginTokenPair, RefreshTokenManager
5
+ from .temp_token import TempTokenManager, TempTokenRecord
6
+
7
+ __all__ = [
8
+ "NonceManager",
9
+ "NonceRecord",
10
+ "LoginTokenPair",
11
+ "RefreshTokenManager",
12
+ "TempTokenManager",
13
+ "TempTokenRecord",
14
+ ]
@@ -0,0 +1,93 @@
1
+ """一次性 Nonce:防止登录、支付等请求被重放。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import secrets
7
+ from dataclasses import asdict, dataclass
8
+ from typing import TYPE_CHECKING
9
+
10
+ from ..exception import SecurityException
11
+ from ..model import now_ms
12
+
13
+ if TYPE_CHECKING:
14
+ from ..storage.base import SaStorage
15
+
16
+ __all__ = ["NonceManager", "NonceRecord"]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class NonceRecord:
21
+ nonce: str
22
+ subject: str
23
+ purpose: str
24
+ created_at: int
25
+ state: str = "issued"
26
+
27
+ def to_json(self) -> str:
28
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
29
+
30
+ @classmethod
31
+ def from_json(cls, raw: str) -> NonceRecord | None:
32
+ try:
33
+ payload = json.loads(raw)
34
+ return cls(**payload) if isinstance(payload, dict) else None
35
+ except (TypeError, ValueError):
36
+ return None
37
+
38
+
39
+ class NonceManager:
40
+ """签发并原子消费服务端 Nonce。
41
+
42
+ 与「客户端随便给一个从未见过的字符串」不同,``consume`` 只接受本服务
43
+ 通过 ``issue`` 签发的值,并校验 subject 与 purpose,防止跨用户、跨业务复用。
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ storage: SaStorage,
49
+ *,
50
+ key_prefix: str = "satoken:",
51
+ timeout: int = 60,
52
+ ) -> None:
53
+ if timeout <= 0:
54
+ raise ValueError("nonce timeout 必须大于 0")
55
+ self._storage = storage
56
+ self._key_prefix = key_prefix
57
+ self.timeout = timeout
58
+
59
+ def _key(self, nonce: str) -> str:
60
+ return f"{self._key_prefix}security:nonce:{nonce}"
61
+
62
+ @staticmethod
63
+ def generate() -> str:
64
+ return f"nonce_{now_ms()}_{secrets.token_urlsafe(24)}"
65
+
66
+ async def issue(self, subject: str, *, purpose: str = "default") -> str:
67
+ """签发短时 Nonce;极小概率随机冲突时自动重试。"""
68
+ normalized_subject = str(subject).strip()
69
+ if not normalized_subject:
70
+ raise SecurityException("INVALID_NONCE_SUBJECT", "nonce subject 不能为空")
71
+ for _ in range(12):
72
+ nonce = self.generate()
73
+ record = NonceRecord(nonce, normalized_subject, purpose, now_ms())
74
+ if await self._storage.set_if_absent(self._key(nonce), record.to_json(), self.timeout):
75
+ return nonce
76
+ raise SecurityException("NONCE_ALLOCATION_FAILED", "无法分配唯一 nonce")
77
+
78
+ async def consume(self, nonce: str, subject: str, *, purpose: str = "default") -> None:
79
+ """原子消费 Nonce;并发请求中恰好一次成功。"""
80
+ key = self._key(nonce)
81
+ raw = await self._storage.get(key)
82
+ if raw is None:
83
+ raise SecurityException("INVALID_NONCE", "nonce 无效、已使用或已过期")
84
+ record = NonceRecord.from_json(raw)
85
+ if record is None:
86
+ raise SecurityException("INVALID_NONCE", "nonce 数据损坏")
87
+ if record.subject != str(subject) or record.purpose != purpose:
88
+ raise SecurityException("NONCE_MISMATCH", "nonce 与用户或业务不匹配")
89
+ if not await self._storage.compare_and_delete(key, raw):
90
+ raise SecurityException("NONCE_REPLAYED", "nonce 已被其它请求使用")
91
+
92
+ async def exists(self, nonce: str) -> bool:
93
+ return await self._storage.exists(self._key(nonce))
@@ -0,0 +1,300 @@
1
+ """登录态 Refresh Token:与 OAuth2 Refresh Token 相互独立。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import secrets
7
+ from dataclasses import asdict, dataclass, field
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from ..exception import SecurityException
11
+ from ..model import DEFAULT_DEVICE, now_ms
12
+
13
+ if TYPE_CHECKING:
14
+ from ..manager import SaTokenManager
15
+
16
+ __all__ = ["LoginTokenPair", "RefreshTokenManager"]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class LoginTokenPair:
21
+ access_token: str
22
+ refresh_token: str
23
+ token_type: str = "Bearer"
24
+ expires_in: int = 0
25
+ refresh_expires_in: int = 0
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ return asdict(self)
29
+
30
+
31
+ @dataclass
32
+ class _RefreshRecord:
33
+ refresh_token: str
34
+ access_token: str
35
+ login_id: str
36
+ login_type: str
37
+ device: str
38
+ family_id: str
39
+ generation: int
40
+ created_at: int = field(default_factory=now_ms)
41
+ state: str = "active"
42
+
43
+ def to_json(self) -> str:
44
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
45
+
46
+ @classmethod
47
+ def from_json(cls, raw: str) -> _RefreshRecord | None:
48
+ try:
49
+ payload = json.loads(raw)
50
+ return cls(**payload) if isinstance(payload, dict) else None
51
+ except (TypeError, ValueError):
52
+ return None
53
+
54
+
55
+ @dataclass
56
+ class _FamilyRecord:
57
+ login_id: str
58
+ login_type: str
59
+ access_tokens: list[str] = field(default_factory=list)
60
+ refresh_tokens: list[str] = field(default_factory=list)
61
+ revoked: bool = False
62
+
63
+ def to_json(self) -> str:
64
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
65
+
66
+ @classmethod
67
+ def from_json(cls, raw: str) -> _FamilyRecord | None:
68
+ try:
69
+ payload = json.loads(raw)
70
+ return cls(**payload) if isinstance(payload, dict) else None
71
+ except (TypeError, ValueError):
72
+ return None
73
+
74
+
75
+ class RefreshTokenManager:
76
+ """登录态 access/refresh 生命周期管理。
77
+
78
+ 旧 refresh token 会保留为 ``used`` 墓碑直到有效期结束。再次使用旧值时,
79
+ 视为泄漏并吊销同一 family 的全部 access/refresh token。
80
+ """
81
+
82
+ def __init__(self, manager: SaTokenManager) -> None:
83
+ self._manager = manager
84
+
85
+ @property
86
+ def _storage(self):
87
+ return self._manager.storage
88
+
89
+ def _refresh_key(self, token: str) -> str:
90
+ return self._manager.config.make_key("security", "refresh", token)
91
+
92
+ def _family_key(self, family_id: str) -> str:
93
+ return self._manager.config.make_key("security", "refresh-family", family_id)
94
+
95
+ def _user_index_key(self, login_type: str, login_id: str) -> str:
96
+ return self._manager.config.make_key(
97
+ "security", "refresh-user", f"{login_type}:{login_id}"
98
+ )
99
+
100
+ async def issue(
101
+ self,
102
+ access_token: str,
103
+ login_id: str,
104
+ *,
105
+ login_type: str = "login",
106
+ device: str = DEFAULT_DEVICE,
107
+ family_id: str | None = None,
108
+ generation: int = 0,
109
+ ) -> LoginTokenPair:
110
+ resolved_family = family_id or secrets.token_urlsafe(24)
111
+ for _ in range(12):
112
+ refresh_token = f"refresh_{secrets.token_urlsafe(36)}"
113
+ record = _RefreshRecord(
114
+ refresh_token=refresh_token,
115
+ access_token=access_token,
116
+ login_id=login_id,
117
+ login_type=login_type,
118
+ device=device,
119
+ family_id=resolved_family,
120
+ generation=generation,
121
+ )
122
+ if await self._storage.set_if_absent(
123
+ self._refresh_key(refresh_token),
124
+ record.to_json(),
125
+ self._manager.config.refresh_token_timeout,
126
+ ):
127
+ break
128
+ else:
129
+ raise SecurityException("REFRESH_ALLOCATION_FAILED", "无法分配 refresh token")
130
+
131
+ await self._update_family(resolved_family, record)
132
+ await self._add_user_family(login_type, login_id, resolved_family)
133
+ access_timeout = self._manager.config.timeout
134
+ return LoginTokenPair(
135
+ access_token=access_token,
136
+ refresh_token=refresh_token,
137
+ expires_in=access_timeout,
138
+ refresh_expires_in=self._manager.config.refresh_token_timeout,
139
+ )
140
+
141
+ async def refresh(self, refresh_token: str) -> LoginTokenPair:
142
+ key = self._refresh_key(refresh_token)
143
+ raw = await self._storage.get(key)
144
+ record = _RefreshRecord.from_json(raw) if raw else None
145
+ if record is None:
146
+ raise SecurityException("INVALID_REFRESH_TOKEN", "refresh token 无效或已过期")
147
+ if record.state != "active":
148
+ if self._manager.config.refresh_token_reuse_detection:
149
+ await self.revoke_family(record.family_id)
150
+ raise SecurityException(
151
+ "REFRESH_TOKEN_REUSED",
152
+ "检测到旧 refresh token 重放,整个 token family 已吊销",
153
+ login_type=record.login_type,
154
+ )
155
+
156
+ used_record = _RefreshRecord(**{**asdict(record), "state": "used"})
157
+ if not await self._storage.compare_and_set(
158
+ key,
159
+ raw,
160
+ used_record.to_json(),
161
+ self._manager.config.refresh_token_timeout,
162
+ ):
163
+ if self._manager.config.refresh_token_reuse_detection:
164
+ await self.revoke_family(record.family_id)
165
+ raise SecurityException("REFRESH_TOKEN_REUSED", "refresh token 已被并发使用")
166
+
167
+ logic = self._manager.stp(record.login_type)
168
+ await logic.logout_by_token(record.access_token, revoke_refresh=False)
169
+ new_access = await logic.login(
170
+ record.login_id,
171
+ device=record.device,
172
+ timeout=self._manager.config.timeout,
173
+ )
174
+
175
+ if not self._manager.config.refresh_token_rotate:
176
+ active_record = _RefreshRecord(
177
+ **{
178
+ **asdict(record),
179
+ "access_token": new_access,
180
+ "created_at": now_ms(),
181
+ "state": "active",
182
+ }
183
+ )
184
+ await self._storage.set(
185
+ key,
186
+ active_record.to_json(),
187
+ self._manager.config.refresh_token_timeout,
188
+ )
189
+ await self._update_family(record.family_id, active_record)
190
+ return LoginTokenPair(
191
+ access_token=new_access,
192
+ refresh_token=refresh_token,
193
+ expires_in=self._manager.config.timeout,
194
+ refresh_expires_in=self._manager.config.refresh_token_timeout,
195
+ )
196
+
197
+ return await self.issue(
198
+ new_access,
199
+ record.login_id,
200
+ login_type=record.login_type,
201
+ device=record.device,
202
+ family_id=record.family_id,
203
+ generation=record.generation + 1,
204
+ )
205
+
206
+ async def revoke(self, refresh_token: str) -> bool:
207
+ key = self._refresh_key(refresh_token)
208
+ raw = await self._storage.get(key)
209
+ record = _RefreshRecord.from_json(raw) if raw else None
210
+ if record is None:
211
+ return False
212
+ await self._storage.delete(key)
213
+ await self._manager.stp(record.login_type).logout_by_token(
214
+ record.access_token, revoke_refresh=False
215
+ )
216
+ return True
217
+
218
+ async def revoke_for_access(self, access_token: str) -> None:
219
+ prefix = self._manager.config.key_prefix("security")
220
+ cursor: str | None = None
221
+ while True:
222
+ cursor, keys = await self._storage.scan(f"{prefix}refresh:*", cursor, 200)
223
+ for key in keys:
224
+ raw = await self._storage.get(key)
225
+ record = _RefreshRecord.from_json(raw) if raw else None
226
+ if record is not None and record.access_token == access_token:
227
+ await self._storage.delete(key)
228
+ if cursor is None:
229
+ return
230
+
231
+ async def revoke_all_for_login(self, login_type: str, login_id: str) -> None:
232
+ raw = await self._storage.get(self._user_index_key(login_type, login_id))
233
+ families = json.loads(raw) if raw else []
234
+ if isinstance(families, list):
235
+ for family_id in families:
236
+ await self.revoke_family(str(family_id))
237
+ await self._storage.delete(self._user_index_key(login_type, login_id))
238
+
239
+ async def revoke_family(self, family_id: str) -> None:
240
+ key = self._family_key(family_id)
241
+ raw = await self._storage.get(key)
242
+ family = _FamilyRecord.from_json(raw) if raw else None
243
+ if family is None:
244
+ return
245
+ family.revoked = True
246
+ await self._storage.set(key, family.to_json(), self._manager.config.refresh_token_timeout)
247
+ logic = self._manager.stp(family.login_type)
248
+ for access_token in set(family.access_tokens):
249
+ await logic.logout_by_token(access_token, revoke_refresh=False)
250
+ for token in set(family.refresh_tokens):
251
+ await self._storage.delete(self._refresh_key(token))
252
+
253
+ async def _update_family(self, family_id: str, record: _RefreshRecord) -> None:
254
+ key = self._family_key(family_id)
255
+ for _ in range(12):
256
+ raw = await self._storage.get(key)
257
+ if raw is None:
258
+ family = _FamilyRecord(record.login_id, record.login_type)
259
+ family.access_tokens.append(record.access_token)
260
+ family.refresh_tokens.append(record.refresh_token)
261
+ if await self._storage.set_if_absent(
262
+ key, family.to_json(), self._manager.config.refresh_token_timeout
263
+ ):
264
+ return
265
+ continue
266
+ family = _FamilyRecord.from_json(raw)
267
+ if family is None or family.revoked:
268
+ raise SecurityException("REFRESH_FAMILY_REVOKED", "token family 已被吊销")
269
+ if record.access_token not in family.access_tokens:
270
+ family.access_tokens.append(record.access_token)
271
+ if record.refresh_token not in family.refresh_tokens:
272
+ family.refresh_tokens.append(record.refresh_token)
273
+ if await self._storage.compare_and_set(
274
+ key,
275
+ raw,
276
+ family.to_json(),
277
+ self._manager.config.refresh_token_timeout,
278
+ ):
279
+ return
280
+ raise SecurityException("REFRESH_CONFLICT", "更新 token family 时发生并发冲突")
281
+
282
+ async def _add_user_family(self, login_type: str, login_id: str, family_id: str) -> None:
283
+ key = self._user_index_key(login_type, login_id)
284
+ for _ in range(12):
285
+ raw = await self._storage.get(key)
286
+ families = json.loads(raw) if raw else []
287
+ if family_id in families:
288
+ return
289
+ families.append(family_id)
290
+ new_raw = json.dumps(families, separators=(",", ":"))
291
+ if raw is None:
292
+ if await self._storage.set_if_absent(
293
+ key, new_raw, self._manager.config.refresh_token_timeout
294
+ ):
295
+ return
296
+ elif await self._storage.compare_and_set(
297
+ key, raw, new_raw, self._manager.config.refresh_token_timeout
298
+ ):
299
+ return
300
+ raise SecurityException("REFRESH_CONFLICT", "更新用户 refresh 索引时发生并发冲突")
@@ -0,0 +1,114 @@
1
+ """短时业务 Token:邀请、重置密码、邮箱验证等一次性动作。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import secrets
8
+ from dataclasses import asdict, dataclass
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from ..exception import SecurityException
12
+ from ..model import now_ms
13
+
14
+ if TYPE_CHECKING:
15
+ from ..storage.base import SaStorage
16
+
17
+ __all__ = ["TempTokenManager", "TempTokenRecord"]
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class TempTokenRecord:
22
+ value: Any
23
+ namespace: str
24
+ created_at: int
25
+
26
+ def to_json(self) -> str:
27
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
28
+
29
+ @classmethod
30
+ def from_json(cls, raw: str) -> TempTokenRecord | None:
31
+ try:
32
+ payload = json.loads(raw)
33
+ return cls(**payload) if isinstance(payload, dict) else None
34
+ except (TypeError, ValueError):
35
+ return None
36
+
37
+
38
+ class TempTokenManager:
39
+ """命名空间隔离的临时 Token 管理器。"""
40
+
41
+ def __init__(self, storage: SaStorage, *, key_prefix: str = "satoken:") -> None:
42
+ self._storage = storage
43
+ self._key_prefix = key_prefix
44
+
45
+ def _key(self, namespace: str, token: str) -> str:
46
+ return f"{self._key_prefix}security:temp:{namespace}:{token}"
47
+
48
+ def _index_key(self, namespace: str, value: str) -> str:
49
+ digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
50
+ return f"{self._key_prefix}security:temp-index:{namespace}:{digest}"
51
+
52
+ @staticmethod
53
+ def _validate(namespace: str, timeout: int) -> None:
54
+ if not namespace or ":" in namespace:
55
+ raise SecurityException("INVALID_TEMP_NAMESPACE", "namespace 不能为空或包含冒号")
56
+ if timeout == 0 or timeout < -1:
57
+ raise SecurityException("INVALID_TEMP_TIMEOUT", "timeout 必须为正数或 -1")
58
+
59
+ async def create(
60
+ self,
61
+ value: Any,
62
+ timeout: int,
63
+ *,
64
+ namespace: str = "default",
65
+ record_index: bool = False,
66
+ ) -> str:
67
+ """创建临时 Token;``record_index`` 可按字符串业务值反查最新 Token。"""
68
+ self._validate(namespace, timeout)
69
+ record = TempTokenRecord(value=value, namespace=namespace, created_at=now_ms())
70
+ ttl = None if timeout == -1 else timeout
71
+ for _ in range(12):
72
+ token = secrets.token_urlsafe(32)
73
+ if await self._storage.set_if_absent(
74
+ self._key(namespace, token),
75
+ record.to_json(),
76
+ ttl,
77
+ ):
78
+ if record_index and isinstance(value, str):
79
+ await self._storage.set(self._index_key(namespace, value), token, ttl)
80
+ return token
81
+ raise SecurityException("TEMP_TOKEN_ALLOCATION_FAILED", "无法分配唯一临时 token")
82
+
83
+ async def parse(self, token: str, *, namespace: str = "default") -> Any | None:
84
+ raw = await self._storage.get(self._key(namespace, token))
85
+ record = TempTokenRecord.from_json(raw) if raw else None
86
+ return record.value if record is not None else None
87
+
88
+ async def consume(self, token: str, *, namespace: str = "default") -> Any | None:
89
+ """原子读取并销毁;并发重复提交时恰好一个调用取得业务值。"""
90
+ key = self._key(namespace, token)
91
+ raw = await self._storage.get(key)
92
+ if raw is None or not await self._storage.compare_and_delete(key, raw):
93
+ return None
94
+ record = TempTokenRecord.from_json(raw)
95
+ if record is None:
96
+ return None
97
+ if isinstance(record.value, str):
98
+ index_key = self._index_key(namespace, record.value)
99
+ await self._storage.compare_and_delete(index_key, token)
100
+ return record.value
101
+
102
+ async def delete(self, token: str, *, namespace: str = "default") -> bool:
103
+ key = self._key(namespace, token)
104
+ raw = await self._storage.get(key)
105
+ if raw is None:
106
+ return False
107
+ record = TempTokenRecord.from_json(raw)
108
+ deleted = await self._storage.compare_and_delete(key, raw)
109
+ if deleted and record is not None and isinstance(record.value, str):
110
+ await self._storage.compare_and_delete(self._index_key(namespace, record.value), token)
111
+ return deleted
112
+
113
+ async def find_token(self, value: str, *, namespace: str = "default") -> str | None:
114
+ return await self._storage.get(self._index_key(namespace, value))
sa_token/session.py ADDED
@@ -0,0 +1,96 @@
1
+ """Session:挂在账号或单个 token 上的 KV 数据。
2
+
3
+ Session 对象本身是「远端数据的把手」,每次写操作都会落存储,
4
+ 这样多进程部署下不会出现各自持有过期副本的问题。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any, TypeVar
10
+
11
+ from .model import SessionData, TerminalInfo
12
+
13
+ if TYPE_CHECKING: # pragma: no cover - 仅供类型检查
14
+ from .storage.base import SaStorage
15
+
16
+ __all__ = ["SaSession"]
17
+
18
+ _T = TypeVar("_T")
19
+ _MISSING = object()
20
+
21
+
22
+ class SaSession:
23
+ """会话对象。
24
+
25
+ Account-Session(``get_session``)同时承担在线终端索引;
26
+ Token-Session(``get_token_session``)只放本次登录相关的数据。
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ storage: SaStorage,
32
+ key: str,
33
+ data: SessionData,
34
+ timeout: int | None,
35
+ ) -> None:
36
+ self._storage = storage
37
+ self._key = key
38
+ self._data = data
39
+ self._timeout = timeout
40
+
41
+ @property
42
+ def id(self) -> str:
43
+ return self._data.id
44
+
45
+ @property
46
+ def create_time(self) -> int:
47
+ return self._data.create_time
48
+
49
+ @property
50
+ def raw(self) -> SessionData:
51
+ """底层数据,供核心内部(如终端列表)使用。"""
52
+ return self._data
53
+
54
+ async def get(self, name: str, default: Any = None) -> Any:
55
+ return self._data.data.get(name, default)
56
+
57
+ async def get_typed(
58
+ self,
59
+ name: str,
60
+ expected: type[_T],
61
+ default: _T | None = None,
62
+ ) -> _T | None:
63
+ """按类型取值,类型不符时回落到 ``default``,避免把脏数据带进业务。"""
64
+ value = self._data.data.get(name, _MISSING)
65
+ if value is _MISSING or not isinstance(value, expected):
66
+ return default
67
+ return value
68
+
69
+ async def set(self, name: str, value: Any) -> None:
70
+ self._data.data[name] = value
71
+ await self.save()
72
+
73
+ async def update(self, values: dict[str, Any]) -> None:
74
+ self._data.data.update(values)
75
+ await self.save()
76
+
77
+ async def delete(self, name: str) -> None:
78
+ if self._data.data.pop(name, _MISSING) is not _MISSING:
79
+ await self.save()
80
+
81
+ async def has(self, name: str) -> bool:
82
+ return name in self._data.data
83
+
84
+ async def keys(self) -> list[str]:
85
+ return list(self._data.data.keys())
86
+
87
+ async def clear(self) -> None:
88
+ self._data.data.clear()
89
+ await self.save()
90
+
91
+ @property
92
+ def terminal_list(self) -> list[TerminalInfo]:
93
+ return self._data.terminal_list
94
+
95
+ async def save(self) -> None:
96
+ await self._storage.set(self._key, self._data.to_json(), self._timeout)