sa-token-python 0.1.5__py3-none-any.whl → 0.1.6__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 (48) 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 +220 -0
  5. sa_token/adapter/pipeline.py +104 -0
  6. sa_token/config.py +145 -0
  7. sa_token/context.py +68 -0
  8. sa_token/exception.py +147 -0
  9. sa_token/integration/__init__.py +10 -0
  10. sa_token/integration/django.py +175 -0
  11. sa_token/integration/fastapi.py +355 -0
  12. sa_token/integration/fastapi_oauth2.py +200 -0
  13. sa_token/integration/flask.py +227 -0
  14. sa_token/integration/starlette.py +268 -0
  15. sa_token/listener.py +115 -0
  16. sa_token/manager.py +247 -0
  17. sa_token/model.py +167 -0
  18. sa_token/oauth2/__init__.py +19 -0
  19. sa_token/oauth2/model.py +141 -0
  20. sa_token/oauth2/server.py +412 -0
  21. sa_token/online/__init__.py +313 -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 +101 -0
  26. sa_token/security/refresh.py +444 -0
  27. sa_token/security/temp_token.py +128 -0
  28. sa_token/session.py +188 -0
  29. sa_token/sso/__init__.py +220 -0
  30. sa_token/storage/__init__.py +26 -0
  31. sa_token/storage/base.py +86 -0
  32. sa_token/storage/memory.py +161 -0
  33. sa_token/storage/redis.py +150 -0
  34. sa_token/stp_interface.py +20 -0
  35. sa_token/stp_logic.py +952 -0
  36. sa_token/stp_util.py +375 -0
  37. sa_token/strategy/__init__.py +74 -0
  38. sa_token/strategy/base.py +22 -0
  39. sa_token/strategy/builtin.py +101 -0
  40. sa_token/strategy/jwt.py +75 -0
  41. sa_token/sync.py +294 -0
  42. sa_token/token_io.py +66 -0
  43. sa_token_python-0.1.6.dist-info/METADATA +735 -0
  44. sa_token_python-0.1.6.dist-info/RECORD +46 -0
  45. {sa_token_python-0.1.5.dist-info → sa_token_python-0.1.6.dist-info}/WHEEL +1 -1
  46. sa_token_python-0.1.6.dist-info/licenses/LICENSE +201 -0
  47. sa_token_python-0.1.5.dist-info/METADATA +0 -45
  48. sa_token_python-0.1.5.dist-info/RECORD +0 -3
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,220 @@
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
+ 请求路径里的 ``.`` 会去掉,``..`` 会向上退一层。退到根之外时,具体路径
23
+ 模式不命中,避免 ``/public/../admin`` 命中 ``/public/**``。整段都是 ``**``
24
+ 的模式(例如 ``/**``)仍然命中,这样登录兜底规则不会把这些路径放行。
25
+ """
26
+ pattern_parts = [part for part in pattern.strip("/").split("/") if part != ""]
27
+ path_parts = _normalize_segments(path)
28
+ if path_parts is None:
29
+ return bool(pattern_parts) and all(part == "**" for part in pattern_parts)
30
+ if pattern_parts == path_parts:
31
+ return True
32
+ return _match_parts(pattern_parts, 0, path_parts, 0, set())
33
+
34
+
35
+ def _normalize_segments(path: str) -> list[str] | None:
36
+ parts: list[str] = []
37
+ for part in path.strip("/").split("/"):
38
+ if part == "" or part == ".":
39
+ continue
40
+ if part == "..":
41
+ if not parts:
42
+ return None
43
+ parts.pop()
44
+ continue
45
+ parts.append(part)
46
+ return parts
47
+
48
+
49
+ def _match_parts(
50
+ pattern_parts: list[str],
51
+ pattern_index: int,
52
+ path_parts: list[str],
53
+ path_index: int,
54
+ seen: set[tuple[int, int]],
55
+ ) -> bool:
56
+ while pattern_index < len(pattern_parts):
57
+ part = pattern_parts[pattern_index]
58
+ if part == "**":
59
+ # ** 可以吞掉 0..n 层。记下已尝试的位置,避免多段 ** 组合爆炸。
60
+ if pattern_index == len(pattern_parts) - 1:
61
+ return True
62
+ for skip in range(path_index, len(path_parts) + 1):
63
+ state = (pattern_index + 1, skip)
64
+ if state in seen:
65
+ continue
66
+ seen.add(state)
67
+ if _match_parts(pattern_parts, pattern_index + 1, path_parts, skip, seen):
68
+ return True
69
+ return False
70
+ if path_index >= len(path_parts):
71
+ return False
72
+ if part != "*" and part != path_parts[path_index]:
73
+ return False
74
+ pattern_index += 1
75
+ path_index += 1
76
+ return path_index == len(path_parts)
77
+
78
+
79
+ @dataclass
80
+ class CheckGroup:
81
+ """一组需要按同一模式校验的权限或角色。
82
+
83
+ 多条规则命中同一路径时,各组独立判断,不能把 AND 粘到其它 OR 组上。
84
+ """
85
+
86
+ values: list[str]
87
+ mode: MatchMode = "OR"
88
+
89
+
90
+ @dataclass
91
+ class PathRule:
92
+ """一条路径规则。
93
+
94
+ ``ignore=True`` 的规则优先级最高,用于放行登录页、健康检查等公开接口。
95
+ ``permission_groups`` / ``role_groups`` 按来源规则分开保存匹配模式。
96
+ ``permissions`` / ``roles`` 仍是全部取值的并集,便于查看命中了哪些项。
97
+ """
98
+
99
+ pattern: str
100
+ ignore: bool = False
101
+ require_login: bool = False
102
+ permissions: list[str] = field(default_factory=list)
103
+ roles: list[str] = field(default_factory=list)
104
+ mode: MatchMode = "OR"
105
+ methods: list[str] = field(default_factory=list)
106
+ permission_groups: list[CheckGroup] = field(default_factory=list)
107
+ role_groups: list[CheckGroup] = field(default_factory=list)
108
+ _methods: frozenset[str] = field(init=False, repr=False, compare=False)
109
+
110
+ def __post_init__(self) -> None:
111
+ self._methods = frozenset(method.upper() for method in self.methods)
112
+
113
+ def matches(self, path: str, method: str) -> bool:
114
+ if self._methods and method.upper() not in self._methods:
115
+ return False
116
+ return ant_match(self.pattern, path)
117
+
118
+
119
+ class PathAuthConfig:
120
+ """路径规则表。
121
+
122
+ 匹配策略:先看是否命中任一 ``ignore`` 规则;否则把全部命中的规则合并。
123
+ 权限与角色取值取并集,但每一条规则的 AND/OR 单独成组,互不改写。
124
+
125
+ 没有命中任何规则时默认放行,不要求登录。需要「没写到的路径也要登录」时,
126
+ 在表末尾加上 ``.login("/**")``。
127
+ """
128
+
129
+ def __init__(self) -> None:
130
+ self._rules: list[PathRule] = []
131
+
132
+ @property
133
+ def rules(self) -> list[PathRule]:
134
+ return list(self._rules)
135
+
136
+ def add(self, rule: PathRule) -> PathAuthConfig:
137
+ self._rules.append(rule)
138
+ return self
139
+
140
+ def ignore(self, *patterns: str, methods: list[str] | None = None) -> PathAuthConfig:
141
+ """放行指定路径。``methods`` 为空表示所有 HTTP 方法都放行。
142
+
143
+ 同一路径要对不同方法区别对待时,必须给 ignore 也加上 ``methods``:
144
+ ignore 优先级最高,不限方法的 ``.ignore("/article")`` 会把
145
+ ``POST /article`` 一并放行。
146
+ """
147
+ for pattern in patterns:
148
+ self._rules.append(
149
+ PathRule(pattern, ignore=True, methods=list(methods or []))
150
+ )
151
+ return self
152
+
153
+ def login(self, *patterns: str, methods: list[str] | None = None) -> PathAuthConfig:
154
+ for pattern in patterns:
155
+ self._rules.append(
156
+ PathRule(pattern, require_login=True, methods=list(methods or []))
157
+ )
158
+ return self
159
+
160
+ def permission(
161
+ self,
162
+ pattern: str,
163
+ *permissions: str,
164
+ mode: MatchMode = "OR",
165
+ methods: list[str] | None = None,
166
+ ) -> PathAuthConfig:
167
+ self._rules.append(
168
+ PathRule(
169
+ pattern,
170
+ require_login=True,
171
+ permissions=list(permissions),
172
+ mode=mode,
173
+ methods=list(methods or []),
174
+ )
175
+ )
176
+ return self
177
+
178
+ def role(
179
+ self,
180
+ pattern: str,
181
+ *roles: str,
182
+ mode: MatchMode = "OR",
183
+ methods: list[str] | None = None,
184
+ ) -> PathAuthConfig:
185
+ self._rules.append(
186
+ PathRule(
187
+ pattern,
188
+ require_login=True,
189
+ roles=list(roles),
190
+ mode=mode,
191
+ methods=list(methods or []),
192
+ )
193
+ )
194
+ return self
195
+
196
+ def resolve(self, path: str, method: str) -> PathRule:
197
+ """把命中的规则合并成一条待执行规则。"""
198
+ matched = [rule for rule in self._rules if rule.matches(path, method)]
199
+ if not matched:
200
+ return PathRule(path)
201
+ if any(rule.ignore for rule in matched):
202
+ return PathRule(path, ignore=True)
203
+
204
+ merged = PathRule(path, require_login=any(rule.require_login for rule in matched))
205
+ for rule in matched:
206
+ if rule.permission_groups:
207
+ merged.permission_groups.extend(rule.permission_groups)
208
+ merged.permissions.extend(
209
+ value for group in rule.permission_groups for value in group.values
210
+ )
211
+ elif rule.permissions:
212
+ merged.permission_groups.append(CheckGroup(list(rule.permissions), rule.mode))
213
+ merged.permissions.extend(rule.permissions)
214
+ if rule.role_groups:
215
+ merged.role_groups.extend(rule.role_groups)
216
+ merged.roles.extend(value for group in rule.role_groups for value in group.values)
217
+ elif rule.roles:
218
+ merged.role_groups.append(CheckGroup(list(rule.roles), rule.mode))
219
+ merged.roles.extend(rule.roles)
220
+ return merged
@@ -0,0 +1,104 @@
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 CheckGroup, PathAuthConfig, PathRule
15
+
16
+ if TYPE_CHECKING: # pragma: no cover - 仅供类型检查
17
+ from ..manager import SaTokenManager
18
+ from .http import HttpContext
19
+
20
+ __all__ = ["AuthResult", "build_rule", "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
+ permission_groups = list(rule.permission_groups)
61
+ if not permission_groups and rule.permissions:
62
+ permission_groups = [CheckGroup(list(rule.permissions), rule.mode)]
63
+ for group in permission_groups:
64
+ await logic.check_permission(login_id, group.values, mode=group.mode)
65
+
66
+ role_groups = list(rule.role_groups)
67
+ if not role_groups and rule.roles:
68
+ role_groups = [CheckGroup(list(rule.roles), rule.mode)]
69
+ for group in role_groups:
70
+ await logic.check_role(login_id, group.values, mode=group.mode)
71
+
72
+ ctx.state["stp_login_id"] = login_id
73
+ ctx.state["stp_token"] = token
74
+ set_current(token, login_id)
75
+ return AuthResult(token=token, login_id=login_id)
76
+
77
+
78
+ async def run_path_auth(
79
+ ctx: HttpContext,
80
+ manager: SaTokenManager,
81
+ path_auth: PathAuthConfig,
82
+ *,
83
+ login_type: str = "login",
84
+ ) -> AuthResult:
85
+ """按路径规则表鉴权。"""
86
+ rule = path_auth.resolve(ctx.get_path(), ctx.get_method())
87
+ return await run_auth_flow(ctx, manager, rule, login_type=login_type)
88
+
89
+
90
+ def build_rule(
91
+ *,
92
+ require_login: bool = True,
93
+ permissions: list[str] | None = None,
94
+ roles: list[str] | None = None,
95
+ mode: MatchMode = "OR",
96
+ ) -> PathRule:
97
+ """给装饰器 / Depends 用的临时规则。"""
98
+ return PathRule(
99
+ pattern="",
100
+ require_login=require_login,
101
+ permissions=list(permissions or []),
102
+ roles=list(roles or []),
103
+ mode=mode,
104
+ )
sa_token/config.py ADDED
@@ -0,0 +1,145 @@
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, get_args
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 or not self.token_name.strip():
115
+ raise ValueError("token_name 不能为空")
116
+ if self.storage_key_prefix and not self.storage_key_prefix.endswith(":"):
117
+ self.storage_key_prefix += ":"
118
+ if self.overflow_logout_mode not in get_args(OverflowLogoutMode):
119
+ raise ValueError(f"overflow_logout_mode 非法:{self.overflow_logout_mode!r}")
120
+ if self.replaced_range not in get_args(ReplacedRange):
121
+ raise ValueError(f"replaced_range 非法:{self.replaced_range!r}")
122
+ for name in ("timeout", "active_timeout", "offline_record_timeout", "perm_cache_timeout"):
123
+ value = getattr(self, name)
124
+ if not isinstance(value, int) or isinstance(value, bool) or value < NEVER_EXPIRE:
125
+ raise ValueError(f"{name} 非法:{value!r}")
126
+
127
+ @classmethod
128
+ def from_dict(cls, values: dict[str, Any], *, strict: bool = False) -> SaTokenConfig:
129
+ """从字典构造配置。
130
+
131
+ 默认忽略未知字段,便于对接外部配置中心。``strict=True`` 时未知字段抛出
132
+ ``ValueError``,避免拼错的键被静默丢掉后仍使用默认值。
133
+ """
134
+ known = {f.name for f in fields(cls)}
135
+ unknown = [key for key in values if key not in known]
136
+ if strict and unknown:
137
+ raise ValueError(f"未知配置项:{', '.join(unknown)}")
138
+ return cls(**{key: value for key, value in values.items() if key in known})
139
+
140
+ def key_prefix(self, login_type: str) -> str:
141
+ return f"{self.storage_key_prefix}{login_type}:"
142
+
143
+ def make_key(self, login_type: str, suffix: str, identifier: str) -> str:
144
+ """拼接存储键:``satoken:login:token:abc``。"""
145
+ return f"{self.key_prefix(login_type)}{suffix}:{identifier}"