sa-token-python-core 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. sa_token/__init__.py +89 -0
  2. sa_token/adapter/__init__.py +24 -0
  3. sa_token/adapter/http.py +71 -0
  4. sa_token/adapter/path.py +163 -0
  5. sa_token/adapter/pipeline.py +97 -0
  6. sa_token/config.py +130 -0
  7. sa_token/context.py +63 -0
  8. sa_token/exception.py +143 -0
  9. sa_token/integration/__init__.py +10 -0
  10. sa_token/integration/django.py +131 -0
  11. sa_token/integration/fastapi.py +315 -0
  12. sa_token/integration/fastapi_oauth2.py +136 -0
  13. sa_token/integration/flask.py +191 -0
  14. sa_token/integration/starlette.py +227 -0
  15. sa_token/listener.py +100 -0
  16. sa_token/manager.py +244 -0
  17. sa_token/model.py +145 -0
  18. sa_token/oauth2/__init__.py +19 -0
  19. sa_token/oauth2/model.py +122 -0
  20. sa_token/oauth2/server.py +361 -0
  21. sa_token/online/__init__.py +292 -0
  22. sa_token/permission.py +67 -0
  23. sa_token/py.typed +0 -0
  24. sa_token/security/__init__.py +14 -0
  25. sa_token/security/nonce.py +93 -0
  26. sa_token/security/refresh.py +300 -0
  27. sa_token/security/temp_token.py +114 -0
  28. sa_token/session.py +96 -0
  29. sa_token/sso/__init__.py +217 -0
  30. sa_token/storage/__init__.py +22 -0
  31. sa_token/storage/base.py +66 -0
  32. sa_token/storage/memory.py +154 -0
  33. sa_token/storage/redis.py +136 -0
  34. sa_token/stp_interface.py +20 -0
  35. sa_token/stp_logic.py +911 -0
  36. sa_token/stp_util.py +367 -0
  37. sa_token/strategy/__init__.py +77 -0
  38. sa_token/strategy/base.py +22 -0
  39. sa_token/strategy/builtin.py +99 -0
  40. sa_token/strategy/jwt.py +72 -0
  41. sa_token/sync.py +268 -0
  42. sa_token/token_io.py +66 -0
  43. sa_token_python_core-0.1.1.dist-info/METADATA +756 -0
  44. sa_token_python_core-0.1.1.dist-info/RECORD +46 -0
  45. sa_token_python_core-0.1.1.dist-info/WHEEL +4 -0
  46. sa_token_python_core-0.1.1.dist-info/licenses/LICENSE +201 -0
sa_token/__init__.py ADDED
@@ -0,0 +1,89 @@
1
+ """sa-token-python:有状态 Token 认证与权限鉴权框架。
2
+
3
+ 核心不依赖任何 Web 框架,脚本、定时任务、RPC 与 Web 接口共用同一套语义::
4
+
5
+ from sa_token import SaToken, StpUtil
6
+ from sa_token.storage import MemoryStorage
7
+
8
+ SaToken.builder().storage(MemoryStorage()).build()
9
+ token = await StpUtil.login(10001)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .config import NEVER_EXPIRE, SaTokenConfig
15
+ from .context import (
16
+ get_current_login_id,
17
+ get_current_token,
18
+ sa_token_context,
19
+ )
20
+ from .exception import (
21
+ DisableException,
22
+ NotLoginException,
23
+ NotLoginType,
24
+ NotPermissionException,
25
+ NotRoleException,
26
+ NotSafeException,
27
+ SaTokenException,
28
+ SaTokenNotInitializedException,
29
+ SecurityException,
30
+ )
31
+ from .listener import Event, EventBus, EventData
32
+ from .manager import SaToken, SaTokenBuilder, SaTokenManager, __version__
33
+ from .model import TerminalInfo, TokenInfo
34
+ from .permission import has_element, vague_match
35
+ from .security import (
36
+ LoginTokenPair,
37
+ NonceManager,
38
+ RefreshTokenManager,
39
+ TempTokenManager,
40
+ )
41
+ from .session import SaSession
42
+ from .stp_interface import StpInterface
43
+ from .stp_logic import StpLogic
44
+ from .stp_util import StpUtil, clear_manager, get_manager, set_manager
45
+
46
+ __all__ = [
47
+ "__version__",
48
+ # 入口
49
+ "SaToken",
50
+ "SaTokenBuilder",
51
+ "SaTokenManager",
52
+ "StpUtil",
53
+ "StpLogic",
54
+ "set_manager",
55
+ "get_manager",
56
+ "clear_manager",
57
+ # 配置与模型
58
+ "SaTokenConfig",
59
+ "NEVER_EXPIRE",
60
+ "TokenInfo",
61
+ "TerminalInfo",
62
+ "SaSession",
63
+ "StpInterface",
64
+ "LoginTokenPair",
65
+ "NonceManager",
66
+ "RefreshTokenManager",
67
+ "TempTokenManager",
68
+ # 上下文
69
+ "sa_token_context",
70
+ "get_current_token",
71
+ "get_current_login_id",
72
+ # 事件
73
+ "Event",
74
+ "EventBus",
75
+ "EventData",
76
+ # 权限工具
77
+ "vague_match",
78
+ "has_element",
79
+ # 异常
80
+ "SaTokenException",
81
+ "SaTokenNotInitializedException",
82
+ "NotLoginException",
83
+ "NotLoginType",
84
+ "NotPermissionException",
85
+ "NotRoleException",
86
+ "DisableException",
87
+ "NotSafeException",
88
+ "SecurityException",
89
+ ]
@@ -0,0 +1,24 @@
1
+ """框架无关的适配基础设施。
2
+
3
+ 这一层定义「共同写法」:HttpContext 抽象 + 统一鉴权管道 + 路径规则。
4
+ 具体框架绑定放在 :mod:`sa_token.integration`。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .http import HttpContext, SimpleHttpContext
10
+ from .path import PathAuthConfig, PathRule, ant_match
11
+ from .pipeline import AuthResult, build_rule, resolve_token, run_auth_flow, run_path_auth
12
+
13
+ __all__ = [
14
+ "HttpContext",
15
+ "SimpleHttpContext",
16
+ "PathAuthConfig",
17
+ "PathRule",
18
+ "ant_match",
19
+ "AuthResult",
20
+ "build_rule",
21
+ "resolve_token",
22
+ "run_auth_flow",
23
+ "run_path_auth",
24
+ ]
@@ -0,0 +1,71 @@
1
+ """HttpContext:对「一次请求」的最小抽象。
2
+
3
+ 核心层只认这个协议,不认 ``fastapi.Request`` 或 ``flask.request``。
4
+ 新增一个框架支持 = 实现这个协议,而不是重写鉴权。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Protocol, runtime_checkable
10
+
11
+ __all__ = ["HttpContext", "SimpleHttpContext"]
12
+
13
+
14
+ @runtime_checkable
15
+ class HttpContext(Protocol):
16
+ """请求上下文协议。
17
+
18
+ ``state`` 用于把校验结果回传给上层(``stp_login_id`` / ``stp_token``)。
19
+ """
20
+
21
+ state: dict[str, Any]
22
+
23
+ def get_header(self, name: str) -> str | None: ...
24
+
25
+ def get_cookie(self, name: str) -> str | None: ...
26
+
27
+ def get_query(self, name: str) -> str | None: ...
28
+
29
+ def get_path(self) -> str: ...
30
+
31
+ def get_method(self) -> str: ...
32
+
33
+
34
+ class SimpleHttpContext:
35
+ """字典驱动的上下文实现。
36
+
37
+ 适用于测试、非 HTTP 协议(gRPC metadata、MQ 消息头)以及任何
38
+ 还没有官方适配器的框架。
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ headers: dict[str, str] | None = None,
45
+ cookies: dict[str, str] | None = None,
46
+ query: dict[str, str] | None = None,
47
+ path: str = "/",
48
+ method: str = "GET",
49
+ ) -> None:
50
+ # Header 名大小写不敏感,统一转小写存储。
51
+ self._headers = {key.lower(): value for key, value in (headers or {}).items()}
52
+ self._cookies = dict(cookies or {})
53
+ self._query = dict(query or {})
54
+ self._path = path
55
+ self._method = method.upper()
56
+ self.state: dict[str, Any] = {}
57
+
58
+ def get_header(self, name: str) -> str | None:
59
+ return self._headers.get(name.lower())
60
+
61
+ def get_cookie(self, name: str) -> str | None:
62
+ return self._cookies.get(name)
63
+
64
+ def get_query(self, name: str) -> str | None:
65
+ return self._query.get(name)
66
+
67
+ def get_path(self) -> str:
68
+ return self._path
69
+
70
+ def get_method(self) -> str:
71
+ return self._method
@@ -0,0 +1,163 @@
1
+ """Ant 风格路径匹配与路径鉴权规则表。
2
+
3
+ 适合网关式、配置驱动的鉴权:不想给每个路由挂装饰器时,用一张规则表描述即可。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+ from ..permission import MatchMode
11
+
12
+ __all__ = ["ant_match", "PathRule", "PathAuthConfig"]
13
+
14
+
15
+ def ant_match(pattern: str, path: str) -> bool:
16
+ """Ant 风格匹配。
17
+
18
+ - ``*`` 匹配单层路径
19
+ - ``**`` 匹配任意层(含 0 层)
20
+ - 其余为字面量
21
+ """
22
+ if pattern == path:
23
+ return True
24
+ pattern_parts = [part for part in pattern.strip("/").split("/") if part != ""]
25
+ path_parts = [part for part in path.strip("/").split("/") if part != ""]
26
+ return _match_parts(pattern_parts, 0, path_parts, 0)
27
+
28
+
29
+ def _match_parts(
30
+ pattern_parts: list[str],
31
+ pattern_index: int,
32
+ path_parts: list[str],
33
+ path_index: int,
34
+ ) -> bool:
35
+ while pattern_index < len(pattern_parts):
36
+ part = pattern_parts[pattern_index]
37
+ if part == "**":
38
+ # ** 可以吞掉 0..n 层,逐个位置回溯尝试。
39
+ if pattern_index == len(pattern_parts) - 1:
40
+ return True
41
+ for skip in range(path_index, len(path_parts) + 1):
42
+ if _match_parts(pattern_parts, pattern_index + 1, path_parts, skip):
43
+ return True
44
+ return False
45
+ if path_index >= len(path_parts):
46
+ return False
47
+ if part != "*" and part != path_parts[path_index]:
48
+ return False
49
+ pattern_index += 1
50
+ path_index += 1
51
+ return path_index == len(path_parts)
52
+
53
+
54
+ @dataclass
55
+ class PathRule:
56
+ """一条路径规则。
57
+
58
+ ``ignore=True`` 的规则优先级最高,用于放行登录页、健康检查等公开接口。
59
+ """
60
+
61
+ pattern: str
62
+ ignore: bool = False
63
+ require_login: bool = False
64
+ permissions: list[str] = field(default_factory=list)
65
+ roles: list[str] = field(default_factory=list)
66
+ mode: MatchMode = "OR"
67
+ methods: list[str] = field(default_factory=list)
68
+
69
+ def matches(self, path: str, method: str) -> bool:
70
+ if self.methods and method.upper() not in {m.upper() for m in self.methods}:
71
+ return False
72
+ return ant_match(self.pattern, path)
73
+
74
+
75
+ class PathAuthConfig:
76
+ """路径规则表。
77
+
78
+ 匹配策略:先看是否命中任一 ``ignore`` 规则;否则把全部命中的规则合并,
79
+ 权限与角色取并集。这样 ``/admin/**`` 与 ``/admin/user/**`` 可以叠加约束。
80
+ """
81
+
82
+ def __init__(self) -> None:
83
+ self._rules: list[PathRule] = []
84
+
85
+ @property
86
+ def rules(self) -> list[PathRule]:
87
+ return list(self._rules)
88
+
89
+ def add(self, rule: PathRule) -> PathAuthConfig:
90
+ self._rules.append(rule)
91
+ return self
92
+
93
+ def ignore(self, *patterns: str, methods: list[str] | None = None) -> PathAuthConfig:
94
+ """放行指定路径。``methods`` 为空表示所有 HTTP 方法都放行。
95
+
96
+ 同一路径要对不同方法区别对待时,必须给 ignore 也加上 ``methods``:
97
+ ignore 优先级最高,不限方法的 ``.ignore("/article")`` 会把
98
+ ``POST /article`` 一并放行。
99
+ """
100
+ for pattern in patterns:
101
+ self._rules.append(
102
+ PathRule(pattern, ignore=True, methods=list(methods or []))
103
+ )
104
+ return self
105
+
106
+ def login(self, *patterns: str, methods: list[str] | None = None) -> PathAuthConfig:
107
+ for pattern in patterns:
108
+ self._rules.append(
109
+ PathRule(pattern, require_login=True, methods=list(methods or []))
110
+ )
111
+ return self
112
+
113
+ def permission(
114
+ self,
115
+ pattern: str,
116
+ *permissions: str,
117
+ mode: MatchMode = "OR",
118
+ methods: list[str] | None = None,
119
+ ) -> PathAuthConfig:
120
+ self._rules.append(
121
+ PathRule(
122
+ pattern,
123
+ require_login=True,
124
+ permissions=list(permissions),
125
+ mode=mode,
126
+ methods=list(methods or []),
127
+ )
128
+ )
129
+ return self
130
+
131
+ def role(
132
+ self,
133
+ pattern: str,
134
+ *roles: str,
135
+ mode: MatchMode = "OR",
136
+ methods: list[str] | None = None,
137
+ ) -> PathAuthConfig:
138
+ self._rules.append(
139
+ PathRule(
140
+ pattern,
141
+ require_login=True,
142
+ roles=list(roles),
143
+ mode=mode,
144
+ methods=list(methods or []),
145
+ )
146
+ )
147
+ return self
148
+
149
+ def resolve(self, path: str, method: str) -> PathRule:
150
+ """把命中的规则合并成一条待执行规则。"""
151
+ matched = [rule for rule in self._rules if rule.matches(path, method)]
152
+ if not matched:
153
+ return PathRule(path)
154
+ if any(rule.ignore for rule in matched):
155
+ return PathRule(path, ignore=True)
156
+
157
+ merged = PathRule(path, require_login=any(rule.require_login for rule in matched))
158
+ for rule in matched:
159
+ merged.permissions.extend(rule.permissions)
160
+ merged.roles.extend(rule.roles)
161
+ if rule.mode == "AND":
162
+ merged.mode = "AND"
163
+ return merged
@@ -0,0 +1,97 @@
1
+ """统一鉴权管道:所有框架适配层的唯一入口。
2
+
3
+ 框架适配只做三件事:Request → HttpContext、调用这里、异常翻译成响应。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ from ..context import set_current
12
+ from ..permission import MatchMode
13
+ from ..token_io import read_token
14
+ from .path import PathAuthConfig, PathRule
15
+
16
+ if TYPE_CHECKING: # pragma: no cover - 仅供类型检查
17
+ from ..manager import SaTokenManager
18
+ from .http import HttpContext
19
+
20
+ __all__ = ["AuthResult", "resolve_token", "run_auth_flow", "run_path_auth"]
21
+
22
+
23
+ @dataclass
24
+ class AuthResult:
25
+ """鉴权结果。``login_id`` 为 None 表示匿名放行。"""
26
+
27
+ token: str | None = None
28
+ login_id: str | None = None
29
+ anonymous: bool = False
30
+
31
+
32
+ def resolve_token(ctx: HttpContext, manager: SaTokenManager) -> str | None:
33
+ """读取 token 并写入上下文,但**不做登录校验**。
34
+
35
+ 对应 Go 版的 ``TokenInterceptor``:让业务能拿到 token,同时保持接口公开。
36
+ """
37
+ token = read_token(ctx, manager.config)
38
+ ctx.state["stp_token"] = token
39
+ set_current(token, None)
40
+ return token
41
+
42
+
43
+ async def run_auth_flow(
44
+ ctx: HttpContext,
45
+ manager: SaTokenManager,
46
+ rule: PathRule,
47
+ *,
48
+ login_type: str = "login",
49
+ ) -> AuthResult:
50
+ """执行一条规则的完整鉴权流程。"""
51
+ token = resolve_token(ctx, manager)
52
+ if rule.ignore:
53
+ return AuthResult(token=token, anonymous=True)
54
+ if not (rule.require_login or rule.permissions or rule.roles):
55
+ return AuthResult(token=token, anonymous=True)
56
+
57
+ logic = manager.stp(login_type)
58
+ login_id = await logic.check_login(token)
59
+
60
+ if rule.permissions:
61
+ await logic.check_permission(login_id, rule.permissions, mode=rule.mode)
62
+ if rule.roles:
63
+ await logic.check_role(login_id, rule.roles, mode=rule.mode)
64
+
65
+ ctx.state["stp_login_id"] = login_id
66
+ ctx.state["stp_token"] = token
67
+ set_current(token, login_id)
68
+ return AuthResult(token=token, login_id=login_id)
69
+
70
+
71
+ async def run_path_auth(
72
+ ctx: HttpContext,
73
+ manager: SaTokenManager,
74
+ path_auth: PathAuthConfig,
75
+ *,
76
+ login_type: str = "login",
77
+ ) -> AuthResult:
78
+ """按路径规则表鉴权。"""
79
+ rule = path_auth.resolve(ctx.get_path(), ctx.get_method())
80
+ return await run_auth_flow(ctx, manager, rule, login_type=login_type)
81
+
82
+
83
+ def build_rule(
84
+ *,
85
+ require_login: bool = True,
86
+ permissions: list[str] | None = None,
87
+ roles: list[str] | None = None,
88
+ mode: MatchMode = "OR",
89
+ ) -> PathRule:
90
+ """给装饰器 / Depends 用的临时规则。"""
91
+ return PathRule(
92
+ pattern="",
93
+ require_login=require_login,
94
+ permissions=list(permissions or []),
95
+ roles=list(roles or []),
96
+ mode=mode,
97
+ )
sa_token/config.py ADDED
@@ -0,0 +1,130 @@
1
+ """sa-token 配置。
2
+
3
+ 配置对象是纯数据,不含任何行为,方便从 YAML / 环境变量 / 配置中心加载后直接构造。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field, fields
9
+ from typing import Any, Literal
10
+
11
+ __all__ = [
12
+ "NEVER_EXPIRE",
13
+ "OverflowLogoutMode",
14
+ "ReplacedRange",
15
+ "SaTokenConfig",
16
+ ]
17
+
18
+ #: timeout 取该值时表示永不过期。
19
+ NEVER_EXPIRE = -1
20
+
21
+ OverflowLogoutMode = Literal["logout", "kickout", "replaced"]
22
+ ReplacedRange = Literal["curr_device", "all_device"]
23
+
24
+
25
+ @dataclass
26
+ class SaTokenConfig:
27
+ """全局配置项,字段命名与 Java / Go / Rust 版保持一致。"""
28
+
29
+ token_name: str = "Authorization"
30
+ """从 Header / Cookie / Query 中读取 token 时使用的键名。"""
31
+
32
+ timeout: int = 2592000
33
+ """token 绝对有效期(秒),-1 表示永不过期。"""
34
+
35
+ active_timeout: int = NEVER_EXPIRE
36
+ """活跃超时(秒):超过该时长未访问则冻结,-1 表示不启用。"""
37
+
38
+ dynamic_active_timeout: bool = False
39
+ """是否允许单个 token 覆盖全局 active_timeout。"""
40
+
41
+ auto_renew: bool = True
42
+ """校验通过后是否自动续期。"""
43
+
44
+ token_style: str = "uuid"
45
+ """token 生成风格,见 sa_token.strategy。"""
46
+
47
+ token_prefix: str = "Bearer "
48
+ """读取 Header 时需要剥离的前缀,空字符串表示不剥离。"""
49
+
50
+ is_read_header: bool = True
51
+ is_read_cookie: bool = True
52
+ is_read_query: bool = True
53
+ is_write_cookie: bool = False
54
+ """登录成功后是否把 token 写进 Cookie(需要框架适配层支持)。"""
55
+
56
+ cookie_path: str = "/"
57
+ cookie_domain: str | None = None
58
+ cookie_secure: bool = False
59
+ cookie_http_only: bool = True
60
+ cookie_same_site: str | None = "Lax"
61
+
62
+ is_concurrent: bool = True
63
+ """是否允许同一账号多端同时在线。False 表示新登录顶掉旧登录。"""
64
+
65
+ is_share: bool = True
66
+ """多端在线时是否共享同一个 token。仅在 is_concurrent=True 时生效。"""
67
+
68
+ max_login_count: int = 12
69
+ """同一账号最大同时在线数,-1 表示不限制。"""
70
+
71
+ overflow_logout_mode: OverflowLogoutMode = "logout"
72
+ """超出 max_login_count 时,对最旧登录采取的处理方式。"""
73
+
74
+ replaced_range: ReplacedRange = "curr_device"
75
+ """顶号影响范围:仅同设备类型,还是全部设备。"""
76
+
77
+ storage_key_prefix: str = "satoken:"
78
+ """所有存储键的统一前缀,便于多租户或共享 Redis。"""
79
+
80
+ is_logout_keep_session: bool = False
81
+ """登出后是否保留 Account-Session 数据。"""
82
+
83
+ offline_record_enabled: bool = True
84
+ """是否记录被踢 / 被顶的下线原因,便于前端提示。"""
85
+
86
+ offline_record_timeout: int = 3600
87
+ """下线记录保留秒数。"""
88
+
89
+ perm_cache_timeout: int = 0
90
+ """StpInterface 查询结果的缓存秒数,0 表示不缓存。"""
91
+
92
+ refresh_token_timeout: int = 2592000
93
+ """登录态 Refresh Token 有效期(秒),与 OAuth2 Refresh Token 相互独立。"""
94
+
95
+ refresh_token_rotate: bool = True
96
+ """刷新 access token 时是否同时轮转 refresh token。"""
97
+
98
+ refresh_token_reuse_detection: bool = True
99
+ """检测旧 refresh token 重放并吊销整个 token family。"""
100
+
101
+ nonce_timeout: int = 60
102
+ """服务端签发 Nonce 的默认有效期(秒)。"""
103
+
104
+ jwt_secret_key: str | None = None
105
+ jwt_algorithm: str = "HS256"
106
+
107
+ is_print_banner: bool = True
108
+ """初始化时是否打印启动横幅。"""
109
+
110
+ extra: dict[str, Any] = field(default_factory=dict)
111
+ """给扩展模块(OAuth2 / SSO / 在线用户)放自定义配置。"""
112
+
113
+ def __post_init__(self) -> None:
114
+ if not self.token_name:
115
+ raise ValueError("token_name 不能为空")
116
+ if self.storage_key_prefix and not self.storage_key_prefix.endswith(":"):
117
+ self.storage_key_prefix += ":"
118
+
119
+ @classmethod
120
+ def from_dict(cls, values: dict[str, Any]) -> SaTokenConfig:
121
+ """从字典构造配置,忽略未知字段,便于对接外部配置中心。"""
122
+ known = {f.name for f in fields(cls)}
123
+ return cls(**{key: value for key, value in values.items() if key in known})
124
+
125
+ def key_prefix(self, login_type: str) -> str:
126
+ return f"{self.storage_key_prefix}{login_type}:"
127
+
128
+ def make_key(self, login_type: str, suffix: str, identifier: str) -> str:
129
+ """拼接存储键:``satoken:login:token:abc``。"""
130
+ return f"{self.key_prefix(login_type)}{suffix}:{identifier}"
sa_token/context.py ADDED
@@ -0,0 +1,63 @@
1
+ """请求级上下文。
2
+
3
+ Java 用 ThreadLocal,Go 用 context.Context。Python 的异步场景里必须用
4
+ :mod:`contextvars`:``threading.local`` 在同一个事件循环线程上会被多个并发
5
+ 请求共用,直接导致身份串号。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from contextvars import ContextVar, Token
13
+
14
+ __all__ = [
15
+ "get_current_token",
16
+ "get_current_login_id",
17
+ "set_current",
18
+ "clear_current",
19
+ "sa_token_context",
20
+ ]
21
+
22
+ _current_token: ContextVar[str | None] = ContextVar("sa_token_current_token", default=None)
23
+ _current_login_id: ContextVar[str | None] = ContextVar("sa_token_current_login_id", default=None)
24
+
25
+
26
+ def get_current_token() -> str | None:
27
+ """当前调用链绑定的 token。"""
28
+ return _current_token.get()
29
+
30
+
31
+ def get_current_login_id() -> str | None:
32
+ """当前调用链绑定的 login_id(已通过校验)。"""
33
+ return _current_login_id.get()
34
+
35
+
36
+ def set_current(token: str | None, login_id: str | None = None) -> tuple[Token, Token]:
37
+ """绑定当前身份,返回可用于还原的 reset token。"""
38
+ return _current_token.set(token), _current_login_id.set(login_id)
39
+
40
+
41
+ def clear_current(tokens: tuple[Token, Token] | None = None) -> None:
42
+ """还原到绑定前的状态。"""
43
+ if tokens is None:
44
+ _current_token.set(None)
45
+ _current_login_id.set(None)
46
+ return
47
+ token_ref, login_id_ref = tokens
48
+ _current_token.reset(token_ref)
49
+ _current_login_id.reset(login_id_ref)
50
+
51
+
52
+ @contextmanager
53
+ def sa_token_context(token: str | None, login_id: str | None = None) -> Iterator[None]:
54
+ """在一段代码内绑定身份,退出时自动还原。
55
+
56
+ 同步与异步代码都能用(``with`` 与 ``async with`` 场景下 contextvars 的
57
+ 传播规则一致),因此不必再提供一个异步版本。
58
+ """
59
+ refs = set_current(token, login_id)
60
+ try:
61
+ yield
62
+ finally:
63
+ clear_current(refs)