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/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)
|