sa-token-python-core 0.1.2__tar.gz → 0.1.4__tar.gz

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 (66) hide show
  1. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/PKG-INFO +4 -3
  2. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/README.md +3 -2
  3. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/examples/native/quick_start.py +1 -1
  4. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/pyproject.toml +1 -1
  5. sa_token_python_core-0.1.4/src/sa_token/banner.py +76 -0
  6. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/config.py +7 -1
  7. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/integration/django.py +2 -0
  8. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/integration/flask.py +4 -0
  9. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/integration/starlette.py +2 -0
  10. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/manager.py +6 -22
  11. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/security/refresh.py +21 -5
  12. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/stp_logic.py +3 -1
  13. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/stp_util.py +3 -0
  14. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/conftest.py +9 -0
  15. sa_token_python_core-0.1.4/tests/test_banner.py +106 -0
  16. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_security.py +34 -0
  17. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/.github/workflows/ci.yml +0 -0
  18. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/.gitignore +0 -0
  19. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/LICENSE +0 -0
  20. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/examples/fastapi/main.py +0 -0
  21. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/examples/flask/main.py +0 -0
  22. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/__init__.py +0 -0
  23. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/adapter/__init__.py +0 -0
  24. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/adapter/http.py +0 -0
  25. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/adapter/path.py +0 -0
  26. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/adapter/pipeline.py +0 -0
  27. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/context.py +0 -0
  28. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/exception.py +0 -0
  29. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/integration/__init__.py +0 -0
  30. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/integration/fastapi.py +0 -0
  31. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/integration/fastapi_oauth2.py +0 -0
  32. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/listener.py +0 -0
  33. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/model.py +0 -0
  34. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/oauth2/__init__.py +0 -0
  35. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/oauth2/model.py +0 -0
  36. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/oauth2/server.py +0 -0
  37. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/online/__init__.py +0 -0
  38. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/permission.py +0 -0
  39. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/py.typed +0 -0
  40. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/security/__init__.py +0 -0
  41. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/security/nonce.py +0 -0
  42. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/security/temp_token.py +0 -0
  43. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/session.py +0 -0
  44. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/sso/__init__.py +0 -0
  45. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/storage/__init__.py +0 -0
  46. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/storage/base.py +0 -0
  47. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/storage/memory.py +0 -0
  48. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/storage/redis.py +0 -0
  49. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/stp_interface.py +0 -0
  50. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/strategy/__init__.py +0 -0
  51. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/strategy/base.py +0 -0
  52. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/strategy/builtin.py +0 -0
  53. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/strategy/jwt.py +0 -0
  54. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/sync.py +0 -0
  55. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/src/sa_token/token_io.py +0 -0
  56. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_adapter.py +0 -0
  57. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_auth.py +0 -0
  58. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_authorization.py +0 -0
  59. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_core_isolation.py +0 -0
  60. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_extensions.py +0 -0
  61. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_integration_fastapi.py +0 -0
  62. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_integration_fastapi_oauth2.py +0 -0
  63. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_integration_flask.py +0 -0
  64. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_optional_backends.py +0 -0
  65. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_permission.py +0 -0
  66. {sa_token_python_core-0.1.2 → sa_token_python_core-0.1.4}/tests/test_storage_and_token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sa-token-python-core
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: 有状态 Token 认证与权限鉴权框架(轻量核心)。灵感来源于 Java 的 Sa-Token。常用依赖请安装 sa-token-python;Web 框架按 extras 安装。
5
5
  Project-URL: Homepage, https://github.com/Star-yb/sa-token-python
6
6
  Project-URL: Documentation, https://github.com/Star-yb/sa-token-python
@@ -313,6 +313,7 @@ SaToken.builder() \
313
313
  | `is_write_cookie` | `False` | FastAPI 显式调用写 Cookie 助手时是否生效 |
314
314
  | `storage_key_prefix` | `satoken:` | 所有存储键前缀 |
315
315
  | `jwt_secret_key` | `None` | JWT 风格时必填 |
316
+ | `is_print_banner` | `True` | 启动时打印 Sa-Token 标志,`.print_banner(False)` 关闭 |
316
317
 
317
318
  Token 读取顺序全局唯一:**Header → Authorization 兜底 → Cookie → Query**。各框架禁止自己再解析一遍。
318
319
 
@@ -504,8 +505,8 @@ class MyappConfig(AppConfig):
504
505
  from sa_token import SaToken
505
506
  from sa_token.storage import MemoryStorage, RedisStorage
506
507
 
507
- SaToken.builder().storage(MemoryStorage()).timeout(86400).print_banner(False).build()
508
- SaToken.builder().storage(RedisStorage.from_url("redis://127.0.0.1:6379/0")).timeout(86400).print_banner(False).build()
508
+ SaToken.builder().storage(MemoryStorage()).timeout(86400).build()
509
+ SaToken.builder().storage(RedisStorage.from_url("redis://127.0.0.1:6379/0")).timeout(86400).build()
509
510
  ```
510
511
 
511
512
  ### 2. 注册中间件
@@ -263,6 +263,7 @@ SaToken.builder() \
263
263
  | `is_write_cookie` | `False` | FastAPI 显式调用写 Cookie 助手时是否生效 |
264
264
  | `storage_key_prefix` | `satoken:` | 所有存储键前缀 |
265
265
  | `jwt_secret_key` | `None` | JWT 风格时必填 |
266
+ | `is_print_banner` | `True` | 启动时打印 Sa-Token 标志,`.print_banner(False)` 关闭 |
266
267
 
267
268
  Token 读取顺序全局唯一:**Header → Authorization 兜底 → Cookie → Query**。各框架禁止自己再解析一遍。
268
269
 
@@ -454,8 +455,8 @@ class MyappConfig(AppConfig):
454
455
  from sa_token import SaToken
455
456
  from sa_token.storage import MemoryStorage, RedisStorage
456
457
 
457
- SaToken.builder().storage(MemoryStorage()).timeout(86400).print_banner(False).build()
458
- SaToken.builder().storage(RedisStorage.from_url("redis://127.0.0.1:6379/0")).timeout(86400).print_banner(False).build()
458
+ SaToken.builder().storage(MemoryStorage()).timeout(86400).build()
459
+ SaToken.builder().storage(RedisStorage.from_url("redis://127.0.0.1:6379/0")).timeout(86400).build()
459
460
  ```
460
461
 
461
462
  ### 2. 注册中间件
@@ -24,7 +24,7 @@ def on_auth_event(data: EventData) -> None:
24
24
 
25
25
 
26
26
  async def main() -> None:
27
- SaToken.builder().storage(MemoryStorage()).timeout(7200).print_banner(False).on(
27
+ SaToken.builder().storage(MemoryStorage()).timeout(7200).on(
28
28
  Event.ALL, on_auth_event
29
29
  ).build()
30
30
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sa-token-python-core"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "有状态 Token 认证与权限鉴权框架(轻量核心)。灵感来源于 Java 的 Sa-Token。常用依赖请安装 sa-token-python;Web 框架按 extras 安装。"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,76 @@
1
+ """启动横幅。
2
+
3
+ 与 Java / Go 版一致:初始化时默认打印一次 ASCII 标志。
4
+ Web 框架适配会在服务启动时再尝试打印;进程内只输出一次。
5
+ ``is_print_banner=False`` 或 ``.print_banner(False)`` 可关闭。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING: # pragma: no cover - 仅供类型检查
14
+ from .manager import SaTokenManager
15
+
16
+ __all__ = ["BANNER", "emit_banner", "emit_configured_banner", "reset_banner_state"]
17
+
18
+ BANNER = r"""
19
+ _____ ______ __
20
+ / ___/____ _ /_ __/___ / /_____ ____
21
+ \__ \/ __ `/ / / / __ \/ //_/ _ \/ __ \
22
+ ___/ / /_/ / / / / /_/ / ,< / __/ / / /
23
+ /____/\__,_/ /_/ \____/_/|_|\___/_/ /_/
24
+ """
25
+
26
+ _printed = False
27
+
28
+
29
+ def reset_banner_state() -> None:
30
+ """测试隔离:允许下一个用例再次打印。"""
31
+ global _printed
32
+ _printed = False
33
+
34
+
35
+ def render_banner(manager: SaTokenManager) -> str:
36
+ from .manager import __version__
37
+
38
+ timeout = manager.config.timeout
39
+ timeout_text = "永久" if timeout < 0 else f"{timeout} 秒"
40
+ lines = [
41
+ BANNER.rstrip("\n"),
42
+ f":: sa-token-python :: (v{__version__})",
43
+ f":: Python :: {sys.version.split()[0]}",
44
+ f":: Token Style :: {manager.config.token_style}",
45
+ f":: Token Timeout :: {timeout_text}",
46
+ f":: Storage :: {type(manager.storage).__name__}",
47
+ "",
48
+ ]
49
+ return "\n".join(lines)
50
+
51
+
52
+ def write_banner(manager: SaTokenManager) -> None:
53
+ """无条件写出横幅,供手动调用。"""
54
+ sys.stdout.write(render_banner(manager) + "\n")
55
+ sys.stdout.flush()
56
+
57
+
58
+ def emit_banner(manager: SaTokenManager) -> None:
59
+ """按配置打印横幅,同一进程只打印一次。"""
60
+ global _printed
61
+ if _printed or not manager.config.is_print_banner:
62
+ return
63
+ _printed = True
64
+ write_banner(manager)
65
+
66
+
67
+ def emit_configured_banner() -> None:
68
+ """框架启动钩子:全局 Manager 已就绪则打印。"""
69
+ from .exception import SaTokenNotInitializedException
70
+ from .stp_util import get_manager
71
+
72
+ try:
73
+ manager = get_manager()
74
+ except SaTokenNotInitializedException:
75
+ return
76
+ emit_banner(manager)
@@ -105,7 +105,13 @@ class SaTokenConfig:
105
105
  jwt_algorithm: str = "HS256"
106
106
 
107
107
  is_print_banner: bool = True
108
- """初始化时是否打印启动横幅。"""
108
+ """是否打印启动横幅。默认 True,与 Java / Go 版一致。
109
+
110
+ 原生 ``build()``、FastAPI / Starlette 服务启动、Flask ``init_app``、
111
+ Django 中间件加载时都会尝试打印,同一进程只输出一次。
112
+ 关闭:``SaToken.builder().print_banner(False)`` 或
113
+ ``set_option(is_print_banner=False)``。
114
+ """
109
115
 
110
116
  extra: dict[str, Any] = field(default_factory=dict)
111
117
  """给扩展模块(OAuth2 / SSO / 在线用户)放自定义配置。"""
@@ -14,6 +14,7 @@ from typing import Any, TypeVar
14
14
  from ..adapter.http import HttpContext
15
15
  from ..adapter.path import PathAuthConfig
16
16
  from ..adapter.pipeline import build_rule, resolve_token, run_auth_flow, run_path_auth
17
+ from ..banner import emit_configured_banner
17
18
  from ..exception import SaTokenException
18
19
  from ..permission import MatchMode
19
20
  from ..stp_util import get_manager
@@ -78,6 +79,7 @@ class SaTokenDjangoMiddleware:
78
79
 
79
80
  def __init__(self, get_response: Callable[[Any], Any]) -> None:
80
81
  self._get_response = get_response
82
+ emit_configured_banner()
81
83
 
82
84
  def __call__(self, request: Any) -> Any:
83
85
  ctx = DjangoHttpContext(request)
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any, TypeVar
13
13
  from ..adapter.http import HttpContext
14
14
  from ..adapter.path import PathAuthConfig
15
15
  from ..adapter.pipeline import build_rule, resolve_token, run_auth_flow, run_path_auth
16
+ from ..banner import emit_configured_banner
16
17
  from ..exception import SaTokenException
17
18
  from ..permission import MatchMode
18
19
  from ..stp_util import get_manager
@@ -77,6 +78,9 @@ class SaTokenFlask:
77
78
  def init_app(self, app: Flask) -> None:
78
79
  app.before_request(self._before_request)
79
80
  app.register_error_handler(SaTokenException, self._handle_exception)
81
+ if hasattr(app, "before_serving"):
82
+ app.before_serving(emit_configured_banner)
83
+ emit_configured_banner()
80
84
 
81
85
  # 请求钩子 --------------------------------------------------------------
82
86
 
@@ -26,6 +26,7 @@ except ImportError as exc: # pragma: no cover - 依赖缺失路径
26
26
  from ..adapter.http import HttpContext
27
27
  from ..adapter.path import PathAuthConfig
28
28
  from ..adapter.pipeline import build_rule, resolve_token, run_auth_flow, run_path_auth
29
+ from ..banner import emit_configured_banner
29
30
  from ..exception import SaTokenException
30
31
  from ..permission import MatchMode
31
32
  from ..stp_util import get_manager
@@ -99,6 +100,7 @@ class SaTokenMiddleware(BaseHTTPMiddleware):
99
100
  super().__init__(app)
100
101
  self.path_auth = path_auth
101
102
  self.login_type = login_type
103
+ emit_configured_banner()
102
104
 
103
105
  async def dispatch(
104
106
  self,
@@ -6,9 +6,9 @@ Manager 负责组装:配置 + 存储 + Token 策略 + 事件总线 + 权限数
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import sys
10
9
  from typing import TYPE_CHECKING, Any
11
10
 
11
+ from .banner import emit_banner, write_banner
12
12
  from .config import SaTokenConfig
13
13
  from .listener import Event, EventBus, Listener
14
14
  from .model import DEFAULT_LOGIN_TYPE
@@ -22,15 +22,7 @@ if TYPE_CHECKING: # pragma: no cover - 仅供类型检查
22
22
 
23
23
  __all__ = ["SaTokenManager", "SaTokenBuilder", "SaToken", "__version__"]
24
24
 
25
- __version__ = "0.1.2"
26
-
27
- _BANNER = r"""
28
- _____ ______ __
29
- / ___/____ _ /_ __/___ / /_____ ____
30
- \__ \/ __ `/ / / / __ \/ //_/ _ \/ __ \
31
- ___/ / /_/ / / / / /_/ / ,< / __/ / / /
32
- /____/\__,_/ /_/ \____/_/|_|\___/_/ /_/
33
- """
25
+ __version__ = "0.1.4"
34
26
 
35
27
 
36
28
  class SaTokenManager:
@@ -54,6 +46,7 @@ class SaTokenManager:
54
46
  self._nonce_manager: NonceManager | None = None
55
47
  self._refresh_token_manager: RefreshTokenManager | None = None
56
48
  self._temp_token_manager: TempTokenManager | None = None
49
+ emit_banner(self)
57
50
 
58
51
  def stp(self, login_type: str = DEFAULT_LOGIN_TYPE) -> StpLogic:
59
52
  """取得指定账号体系的逻辑实例,不同 ``login_type`` 之间存储完全隔离。"""
@@ -100,16 +93,8 @@ class SaTokenManager:
100
93
  return self._temp_token_manager
101
94
 
102
95
  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))
96
+ """手动打印启动横幅,不受「只打印一次」限制。"""
97
+ write_banner(self)
113
98
 
114
99
 
115
100
  class SaTokenBuilder:
@@ -190,6 +175,7 @@ class SaTokenBuilder:
190
175
  return self
191
176
 
192
177
  def print_banner(self, value: bool) -> SaTokenBuilder:
178
+ """是否打印启动横幅,默认 True。传入 False 关闭。"""
193
179
  self._config.is_print_banner = value
194
180
  return self
195
181
 
@@ -227,8 +213,6 @@ class SaTokenBuilder:
227
213
  stp_interface=self._stp_interface,
228
214
  events=self._events,
229
215
  )
230
- if self._config.is_print_banner:
231
- manager.print_banner()
232
216
  if self._set_global:
233
217
  from .stp_util import set_manager
234
218
 
@@ -228,15 +228,27 @@ class RefreshTokenManager:
228
228
  if cursor is None:
229
229
  return
230
230
 
231
- async def revoke_all_for_login(self, login_type: str, login_id: str) -> None:
231
+ async def revoke_all_for_login(
232
+ self,
233
+ login_type: str,
234
+ login_id: str,
235
+ *,
236
+ logout_access: bool = True,
237
+ ) -> None:
232
238
  raw = await self._storage.get(self._user_index_key(login_type, login_id))
233
239
  families = json.loads(raw) if raw else []
234
240
  if isinstance(families, list):
235
241
  for family_id in families:
236
- await self.revoke_family(str(family_id))
242
+ await self.revoke_family(str(family_id), logout_access=logout_access)
237
243
  await self._storage.delete(self._user_index_key(login_type, login_id))
238
244
 
239
- async def revoke_family(self, family_id: str) -> None:
245
+ async def revoke_family(self, family_id: str, *, logout_access: bool = True) -> None:
246
+ """吊销整个 token family。
247
+
248
+ ``logout_access=False`` 时只作废 refresh 侧记录。账号级 kickout/replaced
249
+ 已经把 access token 标成下线原因,若这里再 ``logout_by_token`` 会把刚写入
250
+ 的离线记录删掉,客户端就会看到 ``INVALID_TOKEN`` 而不是 ``KICK_OUT``。
251
+ """
240
252
  key = self._family_key(family_id)
241
253
  raw = await self._storage.get(key)
242
254
  family = _FamilyRecord.from_json(raw) if raw else None
@@ -245,8 +257,12 @@ class RefreshTokenManager:
245
257
  family.revoked = True
246
258
  await self._storage.set(key, family.to_json(), self._manager.config.refresh_token_timeout)
247
259
  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)
260
+ if logout_access:
261
+ for access_token in set(family.access_tokens):
262
+ info = await logic.get_token_info(access_token)
263
+ if info is not None and info.is_offline:
264
+ continue
265
+ await logic.logout_by_token(access_token, revoke_refresh=False)
250
266
  for token in set(family.refresh_tokens):
251
267
  await self._storage.delete(self._refresh_key(token))
252
268
 
@@ -370,8 +370,10 @@ class StpLogic:
370
370
  offline_count = await self._offline_terminals(session, normalized_id, device, state)
371
371
  await self._save_or_drop_session(session, normalized_id)
372
372
  if device is None:
373
+ # 离线原因已经写进 access token;family 吊销只作废 refresh,不能再
374
+ # logout 把记录删掉,否则客户端会看到 INVALID_TOKEN。
373
375
  await self._manager.refresh_tokens.revoke_all_for_login(
374
- self.login_type, normalized_id
376
+ self.login_type, normalized_id, logout_access=False
375
377
  )
376
378
  if offline_count:
377
379
  await self._emit(event, login_id=normalized_id, device=device)
@@ -29,6 +29,9 @@ def set_manager(manager: SaTokenManager) -> None:
29
29
  """设置全局 Manager,一般由 ``SaTokenBuilder.build()`` 自动调用。"""
30
30
  global _manager
31
31
  _manager = manager
32
+ from .banner import emit_banner
33
+
34
+ emit_banner(manager)
32
35
 
33
36
 
34
37
  def get_manager() -> SaTokenManager:
@@ -3,10 +3,18 @@ from __future__ import annotations
3
3
  import pytest
4
4
 
5
5
  from sa_token import SaToken, SaTokenManager
6
+ from sa_token.banner import reset_banner_state
6
7
  from sa_token.storage import MemoryStorage
7
8
  from sa_token.stp_util import clear_manager
8
9
 
9
10
 
11
+ @pytest.fixture(autouse=True)
12
+ def _isolate_banner():
13
+ reset_banner_state()
14
+ yield
15
+ reset_banner_state()
16
+
17
+
10
18
  @pytest.fixture
11
19
  def build_manager():
12
20
  """返回一个构建 Manager 的工厂,测试之间互不干扰。"""
@@ -22,6 +30,7 @@ def build_manager():
22
30
 
23
31
  yield _build
24
32
  clear_manager()
33
+ reset_banner_state()
25
34
 
26
35
 
27
36
  @pytest.fixture
@@ -0,0 +1,106 @@
1
+ """启动横幅:默认打印一次,可通过配置关闭。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from sa_token import SaToken, SaTokenConfig, SaTokenManager, __version__, set_manager
8
+ from sa_token.banner import emit_configured_banner, reset_banner_state
9
+ from sa_token.storage import MemoryStorage
10
+ from sa_token.stp_util import clear_manager
11
+
12
+
13
+ def test_build_prints_banner_by_default(capsys) -> None:
14
+ SaToken.builder().storage(MemoryStorage()).as_global(False).build()
15
+
16
+ output = capsys.readouterr().out
17
+ assert "sa-token-python" in output
18
+ assert f"(v{__version__})" in output
19
+ assert "MemoryStorage" in output
20
+ assert r"/ ___/____ _" in output
21
+
22
+
23
+ def test_print_banner_false_silences_output(capsys) -> None:
24
+ SaToken.builder().storage(MemoryStorage()).print_banner(False).as_global(False).build()
25
+
26
+ assert capsys.readouterr().out == ""
27
+
28
+
29
+ def test_set_option_can_disable_banner(capsys) -> None:
30
+ SaToken.builder().storage(MemoryStorage()).set_option(is_print_banner=False).as_global(
31
+ False
32
+ ).build()
33
+
34
+ assert capsys.readouterr().out == ""
35
+
36
+
37
+ def test_banner_prints_only_once(capsys) -> None:
38
+ manager = SaToken.builder().storage(MemoryStorage()).as_global(False).build()
39
+ capsys.readouterr()
40
+
41
+ manager.print_banner()
42
+ assert "sa-token-python" in capsys.readouterr().out
43
+
44
+ set_manager(manager)
45
+ emit_configured_banner()
46
+ assert capsys.readouterr().out == ""
47
+
48
+
49
+ def test_direct_manager_construction_prints(capsys) -> None:
50
+ SaTokenManager(SaTokenConfig(), MemoryStorage())
51
+
52
+ output = capsys.readouterr().out
53
+ assert "sa-token-python" in output
54
+
55
+
56
+ def test_emit_configured_banner_without_manager_is_silent(capsys) -> None:
57
+ clear_manager()
58
+ emit_configured_banner()
59
+ assert capsys.readouterr().out == ""
60
+
61
+
62
+ def test_reset_allows_reprint(capsys) -> None:
63
+ SaToken.builder().storage(MemoryStorage()).as_global(False).build()
64
+ assert "sa-token-python" in capsys.readouterr().out
65
+
66
+ reset_banner_state()
67
+ SaToken.builder().storage(MemoryStorage()).as_global(False).build()
68
+ assert "sa-token-python" in capsys.readouterr().out
69
+
70
+
71
+ def test_fastapi_startup_prints_banner(capsys) -> None:
72
+ pytest.importorskip("fastapi")
73
+ from fastapi import FastAPI
74
+ from fastapi.testclient import TestClient
75
+
76
+ from sa_token.integration.fastapi import SaTokenMiddleware
77
+
78
+ manager = SaToken.builder().storage(MemoryStorage()).print_banner(False).build()
79
+ manager.config.is_print_banner = True
80
+ reset_banner_state()
81
+
82
+ app = FastAPI()
83
+ app.add_middleware(SaTokenMiddleware)
84
+ with TestClient(app):
85
+ pass
86
+
87
+ output = capsys.readouterr().out
88
+ assert "sa-token-python" in output
89
+ assert "MemoryStorage" in output
90
+
91
+
92
+ def test_flask_init_prints_banner(capsys) -> None:
93
+ pytest.importorskip("flask")
94
+ from flask import Flask
95
+
96
+ from sa_token.integration.flask import SaTokenFlask
97
+
98
+ SaToken.builder().storage(MemoryStorage()).build()
99
+ capsys.readouterr()
100
+ reset_banner_state()
101
+
102
+ app = Flask(__name__)
103
+ SaTokenFlask(app)
104
+
105
+ output = capsys.readouterr().out
106
+ assert "sa-token-python" in output
@@ -112,3 +112,37 @@ async def test_kickout_revokes_refresh_family(stp, manager) -> None:
112
112
  await stp.kickout("10001")
113
113
  with pytest.raises(SecurityException):
114
114
  await manager.refresh_tokens.refresh(pair.refresh_token)
115
+
116
+
117
+ async def test_kickout_after_refresh_login_keeps_kick_out_reason(stp, manager) -> None:
118
+ pair = await stp.login_with_refresh("10001")
119
+ await stp.kickout("10001")
120
+
121
+ with pytest.raises(NotLoginException) as error:
122
+ await stp.check_login(pair.access_token)
123
+ assert error.value.type is NotLoginType.KICK_OUT
124
+
125
+ reason = await stp.get_offline_reason(pair.access_token)
126
+ assert reason is not None
127
+ assert reason["reason"] == "KICK_OUT"
128
+
129
+ with pytest.raises(SecurityException):
130
+ await manager.refresh_tokens.refresh(pair.refresh_token)
131
+
132
+
133
+ async def test_replaced_after_refresh_login_keeps_replaced_reason(stp) -> None:
134
+ pair = await stp.login_with_refresh("10001")
135
+ await stp.replaced("10001")
136
+
137
+ with pytest.raises(NotLoginException) as error:
138
+ await stp.check_login(pair.access_token)
139
+ assert error.value.type is NotLoginType.BE_REPLACED
140
+
141
+
142
+ async def test_kickout_by_token_after_refresh_login_keeps_kick_out_reason(stp) -> None:
143
+ pair = await stp.login_with_refresh("10001")
144
+ await stp.kickout_by_token(pair.access_token)
145
+
146
+ with pytest.raises(NotLoginException) as error:
147
+ await stp.check_login(pair.access_token)
148
+ assert error.value.type is NotLoginType.KICK_OUT