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.
- 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 +163 -0
- sa_token/adapter/pipeline.py +97 -0
- sa_token/config.py +130 -0
- sa_token/context.py +63 -0
- sa_token/exception.py +143 -0
- sa_token/integration/__init__.py +10 -0
- sa_token/integration/django.py +131 -0
- sa_token/integration/fastapi.py +315 -0
- sa_token/integration/fastapi_oauth2.py +136 -0
- sa_token/integration/flask.py +191 -0
- sa_token/integration/starlette.py +227 -0
- sa_token/listener.py +100 -0
- sa_token/manager.py +244 -0
- sa_token/model.py +145 -0
- sa_token/oauth2/__init__.py +19 -0
- sa_token/oauth2/model.py +122 -0
- sa_token/oauth2/server.py +361 -0
- sa_token/online/__init__.py +292 -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 +93 -0
- sa_token/security/refresh.py +300 -0
- sa_token/security/temp_token.py +114 -0
- sa_token/session.py +96 -0
- sa_token/sso/__init__.py +217 -0
- sa_token/storage/__init__.py +22 -0
- sa_token/storage/base.py +66 -0
- sa_token/storage/memory.py +154 -0
- sa_token/storage/redis.py +136 -0
- sa_token/stp_interface.py +20 -0
- sa_token/stp_logic.py +911 -0
- sa_token/stp_util.py +367 -0
- sa_token/strategy/__init__.py +77 -0
- sa_token/strategy/base.py +22 -0
- sa_token/strategy/builtin.py +99 -0
- sa_token/strategy/jwt.py +72 -0
- sa_token/sync.py +268 -0
- sa_token/token_io.py +66 -0
- sa_token_python_core-0.1.1.dist-info/METADATA +756 -0
- sa_token_python_core-0.1.1.dist-info/RECORD +46 -0
- sa_token_python_core-0.1.1.dist-info/WHEEL +4 -0
- 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
|
+
]
|
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,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)
|