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.
- 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 +220 -0
- sa_token/adapter/pipeline.py +104 -0
- sa_token/config.py +145 -0
- sa_token/context.py +68 -0
- sa_token/exception.py +147 -0
- sa_token/integration/__init__.py +10 -0
- sa_token/integration/django.py +175 -0
- sa_token/integration/fastapi.py +355 -0
- sa_token/integration/fastapi_oauth2.py +200 -0
- sa_token/integration/flask.py +227 -0
- sa_token/integration/starlette.py +268 -0
- sa_token/listener.py +115 -0
- sa_token/manager.py +247 -0
- sa_token/model.py +167 -0
- sa_token/oauth2/__init__.py +19 -0
- sa_token/oauth2/model.py +141 -0
- sa_token/oauth2/server.py +412 -0
- sa_token/online/__init__.py +313 -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 +101 -0
- sa_token/security/refresh.py +444 -0
- sa_token/security/temp_token.py +128 -0
- sa_token/session.py +188 -0
- sa_token/sso/__init__.py +220 -0
- sa_token/storage/__init__.py +26 -0
- sa_token/storage/base.py +86 -0
- sa_token/storage/memory.py +161 -0
- sa_token/storage/redis.py +150 -0
- sa_token/stp_interface.py +20 -0
- sa_token/stp_logic.py +952 -0
- sa_token/stp_util.py +375 -0
- sa_token/strategy/__init__.py +74 -0
- sa_token/strategy/base.py +22 -0
- sa_token/strategy/builtin.py +101 -0
- sa_token/strategy/jwt.py +75 -0
- sa_token/sync.py +294 -0
- sa_token/token_io.py +66 -0
- sa_token_python-0.1.6.dist-info/METADATA +735 -0
- sa_token_python-0.1.6.dist-info/RECORD +46 -0
- {sa_token_python-0.1.5.dist-info → sa_token_python-0.1.6.dist-info}/WHEEL +1 -1
- sa_token_python-0.1.6.dist-info/licenses/LICENSE +201 -0
- sa_token_python-0.1.5.dist-info/METADATA +0 -45
- 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
|
+
]
|
sa_token/adapter/http.py
ADDED
|
@@ -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
|
sa_token/adapter/path.py
ADDED
|
@@ -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}"
|