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
@@ -0,0 +1,361 @@
1
+ """OAuth2 授权码流程。
2
+
3
+ 只依赖核心的存储抽象,不依赖任何 Web 框架,因此协议逻辑可以在单测里
4
+ 纯函数式地跑通;HTTP 端点由使用方按自己的框架挂载。
5
+
6
+ 签发出来的 access_token 是**有状态**的:存储里删掉即刻失效,
7
+ 与本项目「服务端必须能即时作废凭证」的整体语义一致。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import secrets
15
+ from typing import TYPE_CHECKING
16
+
17
+ from ..exception import SaTokenException
18
+ from .model import AccessTokenInfo, AuthorizationCode, OAuth2Client, TokenResponse
19
+
20
+ if TYPE_CHECKING: # pragma: no cover - 仅供类型检查
21
+ from ..manager import SaTokenManager
22
+
23
+ __all__ = ["OAuth2Error", "OAuth2Server", "generate_pkce_pair"]
24
+
25
+
26
+ class OAuth2Error(SaTokenException):
27
+ """OAuth2 协议错误,``error`` 为规范定义的错误码。"""
28
+
29
+ http_status = 400
30
+
31
+ def __init__(self, error: str, description: str) -> None:
32
+ super().__init__(f"{error}: {description}")
33
+ self.error = error
34
+ self.description = description
35
+
36
+
37
+ def _s256(verifier: str) -> str:
38
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
39
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
40
+
41
+
42
+ class OAuth2Server:
43
+ """授权服务端。
44
+
45
+ 支持 ``authorization_code``(含 PKCE)与 ``refresh_token`` 两种授权类型。
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ manager: SaTokenManager,
51
+ *,
52
+ code_timeout: int = 300,
53
+ access_token_timeout: int = 7200,
54
+ refresh_token_timeout: int = 2592000,
55
+ ) -> None:
56
+ self._manager = manager
57
+ self.code_timeout = code_timeout
58
+ self.access_token_timeout = access_token_timeout
59
+ self.refresh_token_timeout = refresh_token_timeout
60
+
61
+ # 存储键 ---------------------------------------------------------------
62
+
63
+ def _key(self, suffix: str, identifier: str) -> str:
64
+ return self._manager.config.make_key("oauth2", suffix, identifier)
65
+
66
+ @property
67
+ def _storage(self):
68
+ return self._manager.storage
69
+
70
+ # 客户端 ---------------------------------------------------------------
71
+
72
+ async def register_client(self, client: OAuth2Client) -> None:
73
+ await self._storage.set(self._key("client", client.client_id), client.to_json())
74
+
75
+ async def get_client(self, client_id: str) -> OAuth2Client | None:
76
+ raw = await self._storage.get(self._key("client", client_id))
77
+ return OAuth2Client.from_json(raw) if raw else None
78
+
79
+ async def remove_client(self, client_id: str) -> None:
80
+ await self._storage.delete(self._key("client", client_id))
81
+
82
+ async def _require_client(self, client_id: str) -> OAuth2Client:
83
+ client = await self.get_client(client_id)
84
+ if client is None:
85
+ raise OAuth2Error("invalid_client", f"未注册的 client_id: {client_id}")
86
+ return client
87
+
88
+ # 授权码 ---------------------------------------------------------------
89
+
90
+ async def create_authorization_code(
91
+ self,
92
+ *,
93
+ client_id: str,
94
+ login_id: str,
95
+ redirect_uri: str,
96
+ scopes: list[str] | None = None,
97
+ code_challenge: str | None = None,
98
+ code_challenge_method: str = "S256",
99
+ state: str | None = None,
100
+ ) -> AuthorizationCode:
101
+ """用户在授权页点击「同意」之后调用。"""
102
+ client = await self._require_client(client_id)
103
+ if not client.allows_redirect(redirect_uri):
104
+ raise OAuth2Error("invalid_request", "redirect_uri 未在客户端注册列表中")
105
+ if "authorization_code" not in client.grant_types:
106
+ raise OAuth2Error("unauthorized_client", "客户端不支持 authorization_code")
107
+ if client.is_public and not code_challenge:
108
+ raise OAuth2Error("invalid_request", "公开客户端必须使用 PKCE")
109
+
110
+ requested = list(scopes or client.scopes)
111
+ invalid = [scope for scope in requested if scope not in client.scopes]
112
+ if invalid:
113
+ raise OAuth2Error("invalid_scope", f"未授权的 scope: {', '.join(invalid)}")
114
+
115
+ code = AuthorizationCode(
116
+ code=secrets.token_urlsafe(32),
117
+ client_id=client_id,
118
+ login_id=login_id,
119
+ redirect_uri=redirect_uri,
120
+ scopes=requested,
121
+ code_challenge=code_challenge,
122
+ code_challenge_method=code_challenge_method,
123
+ state=state,
124
+ )
125
+ await self._storage.set(self._key("code", code.code), code.to_json(), self.code_timeout)
126
+ return code
127
+
128
+ async def _consume_code(self, code: str) -> AuthorizationCode:
129
+ """原子消费授权码:读取即删除,防止重放。"""
130
+ key = self._key("code", code)
131
+ raw = await self._storage.get(key)
132
+ if raw is None:
133
+ raise OAuth2Error("invalid_grant", "授权码无效或已过期")
134
+ if not await self._storage.compare_and_delete(key, raw):
135
+ # 删除失败说明已被并发请求取走,同样按重放处理。
136
+ raise OAuth2Error("invalid_grant", "授权码已被使用")
137
+ parsed = AuthorizationCode.from_json(raw)
138
+ if parsed is None:
139
+ raise OAuth2Error("invalid_grant", "授权码数据损坏")
140
+ return parsed
141
+
142
+ # 令牌 -----------------------------------------------------------------
143
+
144
+ async def exchange_code_for_token(
145
+ self,
146
+ *,
147
+ code: str,
148
+ client_id: str,
149
+ client_secret: str | None = None,
150
+ redirect_uri: str | None = None,
151
+ code_verifier: str | None = None,
152
+ ) -> TokenResponse:
153
+ client = await self._require_client(client_id)
154
+ self._verify_client_secret(client, client_secret)
155
+
156
+ authorization_code = await self._consume_code(code)
157
+ if authorization_code.client_id != client_id:
158
+ raise OAuth2Error("invalid_grant", "授权码不属于该客户端")
159
+ if redirect_uri is not None and authorization_code.redirect_uri != redirect_uri:
160
+ raise OAuth2Error("invalid_grant", "redirect_uri 与申请时不一致")
161
+ self._verify_pkce(authorization_code, code_verifier)
162
+
163
+ return await self._issue_tokens(
164
+ client_id=client_id,
165
+ login_id=authorization_code.login_id,
166
+ scopes=authorization_code.scopes,
167
+ )
168
+
169
+ async def refresh_access_token(
170
+ self,
171
+ *,
172
+ refresh_token: str,
173
+ client_id: str,
174
+ client_secret: str | None = None,
175
+ ) -> TokenResponse:
176
+ client = await self._require_client(client_id)
177
+ self._verify_client_secret(client, client_secret)
178
+
179
+ key = self._key("refresh", refresh_token)
180
+ raw = await self._storage.get(key)
181
+ if raw is None:
182
+ raise OAuth2Error("invalid_grant", "refresh_token 无效或已过期")
183
+ info = AccessTokenInfo.from_json(raw)
184
+ if info is None or info.client_id != client_id:
185
+ raise OAuth2Error("invalid_grant", "refresh_token 不属于该客户端")
186
+
187
+ # 原子消费并轮转:并发刷新时只能有一个请求成功。
188
+ if not await self._storage.compare_and_delete(key, raw):
189
+ raise OAuth2Error("invalid_grant", "refresh_token 已被使用")
190
+ await self._storage.delete(self._key("access", info.access_token))
191
+ return await self._issue_tokens(
192
+ client_id=client_id, login_id=info.login_id, scopes=info.scopes
193
+ )
194
+
195
+ async def client_credentials_token(
196
+ self,
197
+ *,
198
+ client_id: str,
199
+ client_secret: str,
200
+ scopes: list[str] | None = None,
201
+ ) -> TokenResponse:
202
+ """机器到机器的 ``client_credentials`` 授权。
203
+
204
+ 公开客户端不能使用该模式;返回的 access token 不签发 refresh token,
205
+ 因为客户端可随时用自己的凭证重新申请。
206
+ """
207
+ client = await self._require_client(client_id)
208
+ if client.is_public:
209
+ raise OAuth2Error("unauthorized_client", "公开客户端不能使用 client_credentials")
210
+ self._verify_client_secret(client, client_secret)
211
+ if "client_credentials" not in client.grant_types:
212
+ raise OAuth2Error("unauthorized_client", "客户端未启用 client_credentials")
213
+ requested = list(scopes or client.scopes)
214
+ invalid = [scope for scope in requested if scope not in client.scopes]
215
+ if invalid:
216
+ raise OAuth2Error("invalid_scope", f"未授权的 scope: {', '.join(invalid)}")
217
+
218
+ access_token = secrets.token_urlsafe(32)
219
+ info = AccessTokenInfo(
220
+ access_token=access_token,
221
+ client_id=client_id,
222
+ login_id=f"client:{client_id}",
223
+ scopes=requested,
224
+ expires_in=self.access_token_timeout,
225
+ )
226
+ await self._storage.set(
227
+ self._key("access", access_token),
228
+ info.to_json(),
229
+ self.access_token_timeout,
230
+ )
231
+ return TokenResponse(
232
+ access_token=access_token,
233
+ expires_in=self.access_token_timeout,
234
+ scope=" ".join(requested),
235
+ )
236
+
237
+ async def introspect(self, access_token: str) -> dict[str, object]:
238
+ """返回适合 RFC 7662 introspection 端点的结果。"""
239
+ raw = await self._storage.get(self._key("access", access_token))
240
+ info = AccessTokenInfo.from_json(raw) if raw else None
241
+ if info is None:
242
+ return {"active": False}
243
+ return {
244
+ "active": True,
245
+ "client_id": info.client_id,
246
+ "sub": info.login_id,
247
+ "scope": " ".join(info.scopes),
248
+ "token_type": "Bearer",
249
+ }
250
+
251
+ async def _issue_tokens(
252
+ self,
253
+ *,
254
+ client_id: str,
255
+ login_id: str,
256
+ scopes: list[str],
257
+ ) -> TokenResponse:
258
+ access_token = secrets.token_urlsafe(32)
259
+ refresh_token = secrets.token_urlsafe(32)
260
+ info = AccessTokenInfo(
261
+ access_token=access_token,
262
+ client_id=client_id,
263
+ login_id=login_id,
264
+ scopes=scopes,
265
+ expires_in=self.access_token_timeout,
266
+ )
267
+ await self._storage.set(
268
+ self._key("access", access_token), info.to_json(), self.access_token_timeout
269
+ )
270
+ await self._storage.set(
271
+ self._key("refresh", refresh_token), info.to_json(), self.refresh_token_timeout
272
+ )
273
+ return TokenResponse(
274
+ access_token=access_token,
275
+ refresh_token=refresh_token,
276
+ expires_in=self.access_token_timeout,
277
+ scope=" ".join(scopes),
278
+ )
279
+
280
+ async def verify_access_token(self, access_token: str) -> AccessTokenInfo:
281
+ raw = await self._storage.get(self._key("access", access_token))
282
+ if raw is None:
283
+ raise OAuth2Error("invalid_token", "access_token 无效或已过期")
284
+ info = AccessTokenInfo.from_json(raw)
285
+ if info is None:
286
+ raise OAuth2Error("invalid_token", "access_token 数据损坏")
287
+ return info
288
+
289
+ async def check_scope(self, access_token: str, scope: str) -> AccessTokenInfo:
290
+ info = await self.verify_access_token(access_token)
291
+ if scope not in info.scopes:
292
+ raise OAuth2Error("insufficient_scope", f"缺少 scope: {scope}")
293
+ return info
294
+
295
+ async def revoke_token(self, token: str) -> bool:
296
+ """吊销 access_token 或 refresh_token,两者都尝试。"""
297
+ revoked = False
298
+ for suffix in ("access", "refresh"):
299
+ key = self._key(suffix, token)
300
+ if await self._storage.exists(key):
301
+ await self._storage.delete(key)
302
+ revoked = True
303
+ return revoked
304
+
305
+ def build_authorize_url(
306
+ self,
307
+ *,
308
+ authorize_endpoint: str,
309
+ client_id: str,
310
+ redirect_uri: str,
311
+ scopes: list[str] | None = None,
312
+ state: str | None = None,
313
+ code_challenge: str | None = None,
314
+ code_challenge_method: str = "S256",
315
+ ) -> str:
316
+ """给客户端生成跳转到授权页的 URL。"""
317
+ from urllib.parse import urlencode
318
+
319
+ params = {
320
+ "response_type": "code",
321
+ "client_id": client_id,
322
+ "redirect_uri": redirect_uri,
323
+ }
324
+ if scopes:
325
+ params["scope"] = " ".join(scopes)
326
+ if state:
327
+ params["state"] = state
328
+ if code_challenge:
329
+ params["code_challenge"] = code_challenge
330
+ params["code_challenge_method"] = code_challenge_method
331
+ separator = "&" if "?" in authorize_endpoint else "?"
332
+ return f"{authorize_endpoint}{separator}{urlencode(params)}"
333
+
334
+ # 校验 -----------------------------------------------------------------
335
+
336
+ @staticmethod
337
+ def _verify_client_secret(client: OAuth2Client, client_secret: str | None) -> None:
338
+ if client.is_public:
339
+ return
340
+ if not client_secret or not secrets.compare_digest(
341
+ client.client_secret or "", client_secret
342
+ ):
343
+ raise OAuth2Error("invalid_client", "client_secret 不正确")
344
+
345
+ @staticmethod
346
+ def _verify_pkce(code: AuthorizationCode, code_verifier: str | None) -> None:
347
+ if not code.code_challenge:
348
+ return
349
+ if not code_verifier:
350
+ raise OAuth2Error("invalid_grant", "缺少 code_verifier")
351
+ expected = (
352
+ code_verifier if code.code_challenge_method == "plain" else _s256(code_verifier)
353
+ )
354
+ if not secrets.compare_digest(expected, code.code_challenge):
355
+ raise OAuth2Error("invalid_grant", "code_verifier 校验失败")
356
+
357
+
358
+ def generate_pkce_pair() -> tuple[str, str]:
359
+ """生成 ``(code_verifier, code_challenge)``,供客户端使用。"""
360
+ verifier = secrets.token_urlsafe(48)
361
+ return verifier, _s256(verifier)
@@ -0,0 +1,292 @@
1
+ """在线用户管理与 WebSocket 鉴权。
2
+
3
+ 连接鉴权复用 ``token_io`` + ``StpLogic.check_login``,不另起一套校验;
4
+ 在线状态分两层:
5
+
6
+ - **存储层**:跨进程可见的在线记录,用于「谁在线」这类查询
7
+ - **进程内**:真实的连接对象,用于推送与断开
8
+
9
+ 之所以拆两层:连接对象本身没法序列化进 Redis,跨进程踢人只能靠存储层
10
+ 标记 + 各进程自己断开本地连接。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ from collections.abc import Awaitable, Callable
18
+ from dataclasses import asdict, dataclass, field
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from ..adapter.http import HttpContext
22
+ from ..exception import NotLoginException, NotLoginType
23
+ from ..listener import Event, EventData
24
+ from ..model import now_ms
25
+ from ..token_io import read_token
26
+
27
+ if TYPE_CHECKING: # pragma: no cover - 仅供类型检查
28
+ from ..manager import SaTokenManager
29
+
30
+ __all__ = ["OnlineUser", "OnlineManager", "WebSocketAuthenticator"]
31
+
32
+ #: 推送回调:接收 (连接对象, 消息文本)。
33
+ Sender = Callable[[Any, str], Awaitable[None]]
34
+
35
+
36
+ @dataclass
37
+ class OnlineUser:
38
+ login_id: str
39
+ device: str = "default"
40
+ connection_id: str = ""
41
+ connect_time: int = field(default_factory=now_ms)
42
+ last_heartbeat: int = field(default_factory=now_ms)
43
+
44
+ def to_json(self) -> str:
45
+ return json.dumps(asdict(self), ensure_ascii=False, separators=(",", ":"))
46
+
47
+ @classmethod
48
+ def from_json(cls, raw: str) -> OnlineUser | None:
49
+ try:
50
+ payload = json.loads(raw)
51
+ except ValueError:
52
+ return None
53
+ if not isinstance(payload, dict) or "login_id" not in payload:
54
+ return None
55
+ allowed = set(cls.__dataclass_fields__)
56
+ return cls(**{k: v for k, v in payload.items() if k in allowed})
57
+
58
+
59
+ class OnlineManager:
60
+ """在线用户注册表。"""
61
+
62
+ def __init__(
63
+ self,
64
+ manager: SaTokenManager,
65
+ *,
66
+ heartbeat_timeout: int = 300,
67
+ sender: Sender | None = None,
68
+ closer: Callable[[Any], Awaitable[None]] | None = None,
69
+ ) -> None:
70
+ self._manager = manager
71
+ self.heartbeat_timeout = heartbeat_timeout
72
+ self._sender = sender
73
+ self._closer = closer
74
+ # login_id -> {connection_id: 连接对象}
75
+ self._connections: dict[str, dict[str, Any]] = {}
76
+ self._lock = asyncio.Lock()
77
+ manager.on(Event.KICKOUT, self._on_offline_event)
78
+ manager.on(Event.REPLACED, self._on_offline_event)
79
+
80
+ def _key(self, login_id: str, connection_id: str) -> str:
81
+ return self._manager.config.make_key("online", "user", f"{login_id}:{connection_id}")
82
+
83
+ @property
84
+ def _storage(self):
85
+ return self._manager.storage
86
+
87
+ async def register(
88
+ self,
89
+ login_id: str,
90
+ connection: Any,
91
+ *,
92
+ connection_id: str | None = None,
93
+ device: str = "default",
94
+ ) -> OnlineUser:
95
+ """登记一条在线连接。"""
96
+ resolved_id = connection_id or f"{id(connection):x}"
97
+ user = OnlineUser(
98
+ login_id=login_id,
99
+ device=device,
100
+ connection_id=resolved_id,
101
+ )
102
+ await self._storage.set(
103
+ self._key(login_id, resolved_id), user.to_json(), self.heartbeat_timeout
104
+ )
105
+ async with self._lock:
106
+ self._connections.setdefault(login_id, {})[resolved_id] = connection
107
+ return user
108
+
109
+ async def unregister(self, login_id: str, connection_id: str) -> None:
110
+ await self._storage.delete(self._key(login_id, connection_id))
111
+ async with self._lock:
112
+ bucket = self._connections.get(login_id)
113
+ if bucket is not None:
114
+ bucket.pop(connection_id, None)
115
+ if not bucket:
116
+ self._connections.pop(login_id, None)
117
+
118
+ async def heartbeat(self, login_id: str, connection_id: str) -> bool:
119
+ """刷新心跳,连接记录已过期时返回 False。"""
120
+ key = self._key(login_id, connection_id)
121
+ raw = await self._storage.get(key)
122
+ if raw is None:
123
+ return False
124
+ user = OnlineUser.from_json(raw)
125
+ if user is None:
126
+ return False
127
+ user.last_heartbeat = now_ms()
128
+ await self._storage.set(key, user.to_json(), self.heartbeat_timeout)
129
+ return True
130
+
131
+ async def is_online(self, login_id: str) -> bool:
132
+ cursor: str | None = None
133
+ pattern = self._key(login_id, "*")
134
+ while True:
135
+ cursor, keys = await self._storage.scan(pattern, cursor, 50)
136
+ if keys:
137
+ return True
138
+ if cursor is None:
139
+ return False
140
+
141
+ async def get_online_users(self, login_id: str) -> list[OnlineUser]:
142
+ users: list[OnlineUser] = []
143
+ cursor: str | None = None
144
+ pattern = self._key(login_id, "*")
145
+ while True:
146
+ cursor, keys = await self._storage.scan(pattern, cursor, 100)
147
+ for key in keys:
148
+ raw = await self._storage.get(key)
149
+ user = OnlineUser.from_json(raw) if raw else None
150
+ if user is not None:
151
+ users.append(user)
152
+ if cursor is None:
153
+ return users
154
+
155
+ def local_connections(self, login_id: str) -> list[Any]:
156
+ """本进程持有的连接对象。"""
157
+ return list(self._connections.get(login_id, {}).values())
158
+
159
+ async def send_to_user(self, login_id: str, message: str) -> int:
160
+ """向该用户在本进程上的所有连接推送,返回成功条数。"""
161
+ if self._sender is None:
162
+ raise RuntimeError("未配置 sender,无法推送消息")
163
+ sent = 0
164
+ for connection in self.local_connections(login_id):
165
+ try:
166
+ await self._sender(connection, message)
167
+ sent += 1
168
+ except Exception:
169
+ # 推送失败通常意味着连接已断,交给连接自身的清理流程处理。
170
+ continue
171
+ return sent
172
+
173
+ async def send_to_device(self, login_id: str, device: str, message: str) -> int:
174
+ """只向指定设备类型推送。"""
175
+ if self._sender is None:
176
+ raise RuntimeError("未配置 sender,无法推送消息")
177
+ online_users = await self.get_online_users(login_id)
178
+ allowed_ids = {
179
+ user.connection_id for user in online_users if user.device == device
180
+ }
181
+ sent = 0
182
+ local = self._connections.get(login_id, {})
183
+ for connection_id, connection in local.items():
184
+ if connection_id not in allowed_ids:
185
+ continue
186
+ try:
187
+ await self._sender(connection, message)
188
+ sent += 1
189
+ except Exception:
190
+ continue
191
+ return sent
192
+
193
+ async def kickout(self, login_id: str) -> None:
194
+ """踢人并断开该用户的连接。
195
+
196
+ 先走核心的 ``kickout`` 让 token 失效,再断开连接:顺序反了的话,
197
+ 客户端可能在断线重连的瞬间用旧 token 重新连上。
198
+ """
199
+ await self._manager.stp().kickout(login_id)
200
+
201
+ async def disconnect_user(self, login_id: str) -> None:
202
+ connections = self.local_connections(login_id)
203
+ async with self._lock:
204
+ connection_ids = list(self._connections.get(login_id, {}).keys())
205
+ self._connections.pop(login_id, None)
206
+ for connection_id in connection_ids:
207
+ await self._storage.delete(self._key(login_id, connection_id))
208
+ if self._closer is None:
209
+ return
210
+ for connection in connections:
211
+ try:
212
+ await self._closer(connection)
213
+ except Exception:
214
+ continue
215
+
216
+ async def disconnect_device(self, login_id: str, device: str) -> None:
217
+ """断开本进程中该用户指定设备的连接。"""
218
+ online_users = await self.get_online_users(login_id)
219
+ connection_ids = {
220
+ user.connection_id for user in online_users if user.device == device
221
+ }
222
+ async with self._lock:
223
+ bucket = self._connections.get(login_id, {})
224
+ connections = [
225
+ bucket.pop(connection_id)
226
+ for connection_id in list(connection_ids)
227
+ if connection_id in bucket
228
+ ]
229
+ if not bucket:
230
+ self._connections.pop(login_id, None)
231
+ for connection_id in connection_ids:
232
+ await self._storage.delete(self._key(login_id, connection_id))
233
+ if self._closer is not None:
234
+ for connection in connections:
235
+ try:
236
+ await self._closer(connection)
237
+ except Exception:
238
+ continue
239
+
240
+ async def cleanup_stale_connections(self) -> int:
241
+ """清理 Storage TTL 已过期、但本进程仍残留的连接对象。"""
242
+ stale: list[tuple[str, str, Any]] = []
243
+ async with self._lock:
244
+ for login_id, bucket in list(self._connections.items()):
245
+ for connection_id, connection in list(bucket.items()):
246
+ if not await self._storage.exists(self._key(login_id, connection_id)):
247
+ stale.append((login_id, connection_id, connection))
248
+ bucket.pop(connection_id, None)
249
+ if not bucket:
250
+ self._connections.pop(login_id, None)
251
+ if self._closer is not None:
252
+ for _, _, connection in stale:
253
+ try:
254
+ await self._closer(connection)
255
+ except Exception:
256
+ continue
257
+ return len(stale)
258
+
259
+ async def _on_offline_event(self, data: EventData) -> None:
260
+ """StpUtil.kickout/replaced 也能自动断开本进程连接。"""
261
+ if data.login_id is None:
262
+ return
263
+ if data.device:
264
+ await self.disconnect_device(data.login_id, data.device)
265
+ else:
266
+ await self.disconnect_user(data.login_id)
267
+
268
+
269
+ class WebSocketAuthenticator:
270
+ """WebSocket 连接鉴权。
271
+
272
+ token 来源与 HTTP 完全一致(Header → Cookie → Query),因为浏览器
273
+ WebSocket API 不能自定义 Header,Query 往往是唯一可用的通道。
274
+ """
275
+
276
+ def __init__(self, manager: SaTokenManager, *, login_type: str = "login") -> None:
277
+ self._manager = manager
278
+ self.login_type = login_type
279
+
280
+ async def authenticate(self, ctx: HttpContext) -> str:
281
+ """握手阶段鉴权,返回 ``login_id``,失败抛 :class:`NotLoginException`。"""
282
+ token = read_token(ctx, self._manager.config)
283
+ login_id = await self._manager.stp(self.login_type).check_login(token)
284
+ ctx.state["stp_token"] = token
285
+ ctx.state["stp_login_id"] = login_id
286
+ return login_id
287
+
288
+ async def authenticate_token(self, token: str | None) -> str:
289
+ """已经自行取到 token(例如从首条消息里读)时使用。"""
290
+ if not token:
291
+ raise NotLoginException(NotLoginType.NOT_TOKEN, login_type=self.login_type)
292
+ return await self._manager.stp(self.login_type).check_login(token)