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/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
|
+
]
|
sa_token/oauth2/model.py
ADDED
|
@@ -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
|