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/exception.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""sa-token 异常体系。
|
|
2
|
+
|
|
3
|
+
核心层只抛这些异常,框架适配层负责把它们翻译成各自的 HTTP 响应,
|
|
4
|
+
禁止在适配层重新发明错误码。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"NotLoginType",
|
|
13
|
+
"SaTokenException",
|
|
14
|
+
"SaTokenNotInitializedException",
|
|
15
|
+
"NotLoginException",
|
|
16
|
+
"NotPermissionException",
|
|
17
|
+
"NotRoleException",
|
|
18
|
+
"DisableException",
|
|
19
|
+
"NotSafeException",
|
|
20
|
+
"SecurityException",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotLoginType(str, Enum):
|
|
25
|
+
"""未登录的细分原因,用于让调用方区分「没带 token」和「被踢下线」。"""
|
|
26
|
+
|
|
27
|
+
NOT_TOKEN = "NOT_TOKEN"
|
|
28
|
+
INVALID_TOKEN = "INVALID_TOKEN"
|
|
29
|
+
TOKEN_TIMEOUT = "TOKEN_TIMEOUT"
|
|
30
|
+
TOKEN_FREEZE = "TOKEN_FREEZE"
|
|
31
|
+
BE_REPLACED = "BE_REPLACED"
|
|
32
|
+
KICK_OUT = "KICK_OUT"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_NOT_LOGIN_MESSAGES: dict[NotLoginType, str] = {
|
|
36
|
+
NotLoginType.NOT_TOKEN: "未提供 token",
|
|
37
|
+
NotLoginType.INVALID_TOKEN: "token 无效",
|
|
38
|
+
NotLoginType.TOKEN_TIMEOUT: "token 已过期",
|
|
39
|
+
NotLoginType.TOKEN_FREEZE: "token 已被冻结(长时间未活跃)",
|
|
40
|
+
NotLoginType.BE_REPLACED: "token 已被顶下线",
|
|
41
|
+
NotLoginType.KICK_OUT: "token 已被踢下线",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SaTokenException(Exception):
|
|
46
|
+
"""所有 sa-token 异常的基类。"""
|
|
47
|
+
|
|
48
|
+
http_status = 500
|
|
49
|
+
|
|
50
|
+
def __init__(self, message: str, *, login_type: str = "login") -> None:
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.message = message
|
|
53
|
+
self.login_type = login_type
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SaTokenNotInitializedException(SaTokenException):
|
|
57
|
+
"""在调用 StpUtil 之前没有构建 Manager。"""
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
super().__init__(
|
|
61
|
+
"sa-token 尚未初始化,请先调用 SaToken.builder()...build() 或 set_manager()"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NotLoginException(SaTokenException):
|
|
66
|
+
"""未登录、token 失效、被踢、被顶、被冻结。"""
|
|
67
|
+
|
|
68
|
+
http_status = 401
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
not_login_type: NotLoginType,
|
|
73
|
+
*,
|
|
74
|
+
login_type: str = "login",
|
|
75
|
+
token: str | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
super().__init__(_NOT_LOGIN_MESSAGES[not_login_type], login_type=login_type)
|
|
78
|
+
self.type = not_login_type
|
|
79
|
+
self.token = token
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class NotPermissionException(SaTokenException):
|
|
83
|
+
"""权限不足。"""
|
|
84
|
+
|
|
85
|
+
http_status = 403
|
|
86
|
+
|
|
87
|
+
def __init__(self, permission: str, *, login_type: str = "login") -> None:
|
|
88
|
+
super().__init__(f"缺少权限:{permission}", login_type=login_type)
|
|
89
|
+
self.permission = permission
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class NotRoleException(SaTokenException):
|
|
93
|
+
"""角色不足。"""
|
|
94
|
+
|
|
95
|
+
http_status = 403
|
|
96
|
+
|
|
97
|
+
def __init__(self, role: str, *, login_type: str = "login") -> None:
|
|
98
|
+
super().__init__(f"缺少角色:{role}", login_type=login_type)
|
|
99
|
+
self.role = role
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DisableException(SaTokenException):
|
|
103
|
+
"""账号(或账号的某项服务)被封禁。"""
|
|
104
|
+
|
|
105
|
+
http_status = 403
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
login_id: str,
|
|
110
|
+
service: str,
|
|
111
|
+
level: int,
|
|
112
|
+
remaining: int,
|
|
113
|
+
*,
|
|
114
|
+
login_type: str = "login",
|
|
115
|
+
) -> None:
|
|
116
|
+
super().__init__(
|
|
117
|
+
f"账号 {login_id} 的服务 {service} 已被封禁(等级 {level},剩余 {remaining} 秒)",
|
|
118
|
+
login_type=login_type,
|
|
119
|
+
)
|
|
120
|
+
self.login_id = login_id
|
|
121
|
+
self.service = service
|
|
122
|
+
self.level = level
|
|
123
|
+
self.remaining = remaining
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class NotSafeException(SaTokenException):
|
|
127
|
+
"""二级认证未通过。"""
|
|
128
|
+
|
|
129
|
+
http_status = 403
|
|
130
|
+
|
|
131
|
+
def __init__(self, business: str, *, login_type: str = "login") -> None:
|
|
132
|
+
super().__init__(f"二级认证未通过:{business}", login_type=login_type)
|
|
133
|
+
self.business = business
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SecurityException(SaTokenException):
|
|
137
|
+
"""Nonce、Refresh Token、Temp Token 等安全流程错误。"""
|
|
138
|
+
|
|
139
|
+
http_status = 400
|
|
140
|
+
|
|
141
|
+
def __init__(self, code: str, message: str, *, login_type: str = "login") -> None:
|
|
142
|
+
super().__init__(message, login_type=login_type)
|
|
143
|
+
self.code = code
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Django 适配(同步中间件 + 装饰器)。
|
|
2
|
+
|
|
3
|
+
Django 自带一套完整的 Session/User 体系,因此这里的定位是:
|
|
4
|
+
给**纯 API 项目**(DRF / 无模板)提供与 FastAPI、Flask 一致的 Token 鉴权,
|
|
5
|
+
而不是替换 ``django.contrib.auth``。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
from ..adapter.http import HttpContext
|
|
15
|
+
from ..adapter.path import PathAuthConfig
|
|
16
|
+
from ..adapter.pipeline import build_rule, resolve_token, run_auth_flow, run_path_auth
|
|
17
|
+
from ..exception import SaTokenException
|
|
18
|
+
from ..permission import MatchMode
|
|
19
|
+
from ..stp_util import get_manager
|
|
20
|
+
from ..sync import run_sync
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DjangoHttpContext",
|
|
24
|
+
"SaTokenDjangoMiddleware",
|
|
25
|
+
"check_login",
|
|
26
|
+
"check_permission",
|
|
27
|
+
"check_role",
|
|
28
|
+
"to_json_response",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_F = TypeVar("_F", bound=Callable[..., Any])
|
|
32
|
+
|
|
33
|
+
#: 由项目在 settings 里赋值,用于开启路径鉴权。
|
|
34
|
+
PATH_AUTH: PathAuthConfig | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DjangoHttpContext(HttpContext):
|
|
38
|
+
def __init__(self, request: Any) -> None:
|
|
39
|
+
self._request = request
|
|
40
|
+
self.state: dict[str, Any] = {}
|
|
41
|
+
|
|
42
|
+
def get_header(self, name: str) -> str | None:
|
|
43
|
+
return self._request.headers.get(name)
|
|
44
|
+
|
|
45
|
+
def get_cookie(self, name: str) -> str | None:
|
|
46
|
+
return self._request.COOKIES.get(name)
|
|
47
|
+
|
|
48
|
+
def get_query(self, name: str) -> str | None:
|
|
49
|
+
return self._request.GET.get(name)
|
|
50
|
+
|
|
51
|
+
def get_path(self) -> str:
|
|
52
|
+
return self._request.path
|
|
53
|
+
|
|
54
|
+
def get_method(self) -> str:
|
|
55
|
+
return self._request.method
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_json_response(exc: SaTokenException) -> Any:
|
|
59
|
+
from django.http import JsonResponse
|
|
60
|
+
|
|
61
|
+
payload: dict[str, Any] = {
|
|
62
|
+
"code": exc.http_status,
|
|
63
|
+
"message": exc.message,
|
|
64
|
+
"error": type(exc).__name__,
|
|
65
|
+
}
|
|
66
|
+
detail_type = getattr(exc, "type", None)
|
|
67
|
+
if detail_type is not None:
|
|
68
|
+
payload["type"] = detail_type.value
|
|
69
|
+
return JsonResponse(payload, status=exc.http_status)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SaTokenDjangoMiddleware:
|
|
73
|
+
"""加入 ``MIDDLEWARE`` 即可。
|
|
74
|
+
|
|
75
|
+
默认只解析 token 并挂到 ``request.sa_token`` / ``request.sa_login_id``;
|
|
76
|
+
把 :data:`PATH_AUTH` 设为规则表后才执行强制鉴权。
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, get_response: Callable[[Any], Any]) -> None:
|
|
80
|
+
self._get_response = get_response
|
|
81
|
+
|
|
82
|
+
def __call__(self, request: Any) -> Any:
|
|
83
|
+
ctx = DjangoHttpContext(request)
|
|
84
|
+
manager = get_manager()
|
|
85
|
+
try:
|
|
86
|
+
if PATH_AUTH is None:
|
|
87
|
+
resolve_token(ctx, manager)
|
|
88
|
+
else:
|
|
89
|
+
run_sync(run_path_auth(ctx, manager, PATH_AUTH))
|
|
90
|
+
except SaTokenException as exc:
|
|
91
|
+
return to_json_response(exc)
|
|
92
|
+
|
|
93
|
+
request.sa_token = ctx.state.get("stp_token")
|
|
94
|
+
request.sa_login_id = ctx.state.get("stp_login_id")
|
|
95
|
+
return self._get_response(request)
|
|
96
|
+
|
|
97
|
+
def process_exception(self, request: Any, exception: Exception) -> Any:
|
|
98
|
+
if isinstance(exception, SaTokenException):
|
|
99
|
+
return to_json_response(exception)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _guard(rule_factory: Callable[[], Any]) -> Callable[[_F], _F]:
|
|
104
|
+
def decorator(view: _F) -> _F:
|
|
105
|
+
@functools.wraps(view)
|
|
106
|
+
def wrapper(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
107
|
+
ctx = DjangoHttpContext(request)
|
|
108
|
+
try:
|
|
109
|
+
result = run_sync(run_auth_flow(ctx, get_manager(), rule_factory()))
|
|
110
|
+
except SaTokenException as exc:
|
|
111
|
+
return to_json_response(exc)
|
|
112
|
+
request.sa_login_id = result.login_id
|
|
113
|
+
request.sa_token = result.token
|
|
114
|
+
return view(request, *args, **kwargs)
|
|
115
|
+
|
|
116
|
+
return wrapper # type: ignore[return-value]
|
|
117
|
+
|
|
118
|
+
return decorator
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check_login(view: _F) -> _F:
|
|
122
|
+
"""标准注解:``@check_login``。"""
|
|
123
|
+
return _guard(build_rule)(view)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_permission(*permissions: str, mode: MatchMode = "OR") -> Callable[[_F], _F]:
|
|
127
|
+
return _guard(lambda: build_rule(permissions=list(permissions), mode=mode))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_role(*roles: str, mode: MatchMode = "OR") -> Callable[[_F], _F]:
|
|
131
|
+
return _guard(lambda: build_rule(roles=list(roles), mode=mode))
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""FastAPI 适配。
|
|
2
|
+
|
|
3
|
+
各框架的标准鉴权写法都是注解:``@sa.check_login`` / ``@sa.check_permission``。
|
|
4
|
+
:class:`SaTokenFastAPI` 提供这一套,与 Flask、Django 同一套 ``StpLogic``。
|
|
5
|
+
|
|
6
|
+
FastAPI 作为主要适配框架,额外提供 ``Depends`` / ``Annotated``(``LoginId``、
|
|
7
|
+
``BearerLoginId`` 等),方便按 FastAPI 习惯写依赖注入。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import inspect
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Annotated, Any, TypeVar
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from fastapi import Depends, FastAPI, Request, Response, WebSocket
|
|
19
|
+
from fastapi.security import ( # pyright: ignore[reportMissingImports]
|
|
20
|
+
HTTPAuthorizationCredentials,
|
|
21
|
+
HTTPBearer,
|
|
22
|
+
)
|
|
23
|
+
except ImportError as exc: # pragma: no cover - 依赖缺失路径
|
|
24
|
+
raise ImportError(
|
|
25
|
+
'该模块需要 fastapi,请执行:pip install "sa-token-python-core[fastapi]"'
|
|
26
|
+
) from exc
|
|
27
|
+
|
|
28
|
+
from ..adapter.path import PathAuthConfig
|
|
29
|
+
from ..adapter.pipeline import build_rule, run_auth_flow
|
|
30
|
+
from ..context import get_current_login_id, get_current_token
|
|
31
|
+
from ..exception import NotLoginException, NotLoginType
|
|
32
|
+
from ..permission import MatchMode
|
|
33
|
+
from ..stp_util import get_manager
|
|
34
|
+
from .starlette import (
|
|
35
|
+
SaTokenMiddleware,
|
|
36
|
+
StarletteHttpContext,
|
|
37
|
+
check_disable,
|
|
38
|
+
check_login,
|
|
39
|
+
check_permission,
|
|
40
|
+
check_role,
|
|
41
|
+
check_safe,
|
|
42
|
+
current_login_id,
|
|
43
|
+
current_login_id_or_none,
|
|
44
|
+
current_token,
|
|
45
|
+
install_exception_handlers,
|
|
46
|
+
sa_token_exception_handler,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_F = TypeVar("_F", bound=Callable[..., Any])
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"SaTokenFastAPI",
|
|
53
|
+
"SaTokenMiddleware",
|
|
54
|
+
"StarletteHttpContext",
|
|
55
|
+
"install_exception_handlers",
|
|
56
|
+
"sa_token_exception_handler",
|
|
57
|
+
"check_login",
|
|
58
|
+
"check_permission",
|
|
59
|
+
"check_role",
|
|
60
|
+
"check_disable",
|
|
61
|
+
"check_safe",
|
|
62
|
+
"current_login_id",
|
|
63
|
+
"current_login_id_or_none",
|
|
64
|
+
"current_token",
|
|
65
|
+
"LoginId",
|
|
66
|
+
"OptionalLoginId",
|
|
67
|
+
"TokenValue",
|
|
68
|
+
"BearerLoginId",
|
|
69
|
+
"set_token_cookie",
|
|
70
|
+
"delete_token_cookie",
|
|
71
|
+
"FastAPIWebSocketContext",
|
|
72
|
+
"authenticate_websocket",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _extract_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request:
|
|
77
|
+
request = kwargs.get("request")
|
|
78
|
+
if isinstance(request, Request):
|
|
79
|
+
return request
|
|
80
|
+
for value in args:
|
|
81
|
+
if isinstance(value, Request):
|
|
82
|
+
return value
|
|
83
|
+
for value in kwargs.values():
|
|
84
|
+
if isinstance(value, Request):
|
|
85
|
+
return value
|
|
86
|
+
raise RuntimeError("未找到 Request")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _endpoint_signature(view: Callable[..., Any]) -> inspect.Signature:
|
|
90
|
+
signature = inspect.signature(view)
|
|
91
|
+
if "request" in signature.parameters:
|
|
92
|
+
return signature
|
|
93
|
+
request_param = inspect.Parameter(
|
|
94
|
+
"request",
|
|
95
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
96
|
+
annotation=Request,
|
|
97
|
+
)
|
|
98
|
+
return signature.replace(parameters=[*signature.parameters.values(), request_param])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SaTokenFastAPI:
|
|
102
|
+
"""FastAPI 的标准注解鉴权,与 Flask / Django 同一套语义。
|
|
103
|
+
|
|
104
|
+
用法::
|
|
105
|
+
|
|
106
|
+
app = FastAPI()
|
|
107
|
+
sa = SaTokenFastAPI(app)
|
|
108
|
+
|
|
109
|
+
@app.get("/user")
|
|
110
|
+
@sa.check_login
|
|
111
|
+
async def user_info():
|
|
112
|
+
return {"id": sa.login_id()}
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
app: FastAPI | None = None,
|
|
118
|
+
*,
|
|
119
|
+
path_auth: PathAuthConfig | None = None,
|
|
120
|
+
login_type: str = "login",
|
|
121
|
+
) -> None:
|
|
122
|
+
self.path_auth = path_auth
|
|
123
|
+
self.login_type = login_type
|
|
124
|
+
if app is not None:
|
|
125
|
+
self.init_app(app)
|
|
126
|
+
|
|
127
|
+
def init_app(self, app: FastAPI) -> None:
|
|
128
|
+
app.add_middleware(
|
|
129
|
+
SaTokenMiddleware,
|
|
130
|
+
path_auth=self.path_auth,
|
|
131
|
+
login_type=self.login_type,
|
|
132
|
+
)
|
|
133
|
+
install_exception_handlers(app)
|
|
134
|
+
|
|
135
|
+
def login_id(self) -> str:
|
|
136
|
+
login_id = get_current_login_id()
|
|
137
|
+
if login_id:
|
|
138
|
+
return login_id
|
|
139
|
+
raise NotLoginException(NotLoginType.NOT_TOKEN, login_type=self.login_type)
|
|
140
|
+
|
|
141
|
+
def token(self) -> str | None:
|
|
142
|
+
return get_current_token()
|
|
143
|
+
|
|
144
|
+
def _guard(
|
|
145
|
+
self,
|
|
146
|
+
rule_factory: Callable[[], Any],
|
|
147
|
+
extra: Callable[[Any], Any] | None = None,
|
|
148
|
+
) -> Callable[[_F], _F]:
|
|
149
|
+
def decorator(view: _F) -> _F:
|
|
150
|
+
had_request = "request" in inspect.signature(view).parameters
|
|
151
|
+
|
|
152
|
+
@functools.wraps(view)
|
|
153
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
154
|
+
request = _extract_request(args, kwargs)
|
|
155
|
+
ctx = StarletteHttpContext(request)
|
|
156
|
+
result = await run_auth_flow(
|
|
157
|
+
ctx,
|
|
158
|
+
get_manager(),
|
|
159
|
+
rule_factory(),
|
|
160
|
+
login_type=self.login_type,
|
|
161
|
+
)
|
|
162
|
+
request.state.sa_login_id = result.login_id
|
|
163
|
+
request.state.sa_token = result.token
|
|
164
|
+
if extra is not None:
|
|
165
|
+
extra_result = extra(result)
|
|
166
|
+
if inspect.isawaitable(extra_result):
|
|
167
|
+
await extra_result
|
|
168
|
+
call_kwargs = kwargs
|
|
169
|
+
call_args = args
|
|
170
|
+
if not had_request:
|
|
171
|
+
call_kwargs = dict(kwargs)
|
|
172
|
+
call_kwargs.pop("request", None)
|
|
173
|
+
call_args = tuple(item for item in args if not isinstance(item, Request))
|
|
174
|
+
outcome = view(*call_args, **call_kwargs)
|
|
175
|
+
if inspect.isawaitable(outcome):
|
|
176
|
+
return await outcome
|
|
177
|
+
return outcome
|
|
178
|
+
|
|
179
|
+
wrapper.__signature__ = _endpoint_signature(view)
|
|
180
|
+
return wrapper # type: ignore[return-value]
|
|
181
|
+
|
|
182
|
+
return decorator
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def check_login(self) -> Callable[[_F], _F]:
|
|
186
|
+
"""标准注解:``@sa.check_login``。"""
|
|
187
|
+
return self._guard(build_rule)
|
|
188
|
+
|
|
189
|
+
def check_permission(self, *permissions: str, mode: MatchMode = "OR") -> Callable[[_F], _F]:
|
|
190
|
+
"""标准注解:``@sa.check_permission("order:delete")``。"""
|
|
191
|
+
return self._guard(lambda: build_rule(permissions=list(permissions), mode=mode))
|
|
192
|
+
|
|
193
|
+
def check_role(self, *roles: str, mode: MatchMode = "OR") -> Callable[[_F], _F]:
|
|
194
|
+
return self._guard(lambda: build_rule(roles=list(roles), mode=mode))
|
|
195
|
+
|
|
196
|
+
def check_safe(self, business: str) -> Callable[[_F], _F]:
|
|
197
|
+
async def extra(result: Any) -> None:
|
|
198
|
+
await get_manager().stp(self.login_type).check_safe(result.token, business)
|
|
199
|
+
|
|
200
|
+
return self._guard(build_rule, extra=extra)
|
|
201
|
+
|
|
202
|
+
def check_disable(self, service: str = "login", level: int = 1) -> Callable[[_F], _F]:
|
|
203
|
+
async def extra(result: Any) -> None:
|
|
204
|
+
await get_manager().stp(self.login_type).check_disable(
|
|
205
|
+
result.login_id,
|
|
206
|
+
service=service,
|
|
207
|
+
level=level,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return self._guard(build_rule, extra=extra)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# FastAPI 惯用扩展:Depends / Annotated。
|
|
214
|
+
|
|
215
|
+
LoginId = Annotated[str, Depends(current_login_id)]
|
|
216
|
+
OptionalLoginId = Annotated[str | None, Depends(current_login_id_or_none)]
|
|
217
|
+
TokenValue = Annotated[str | None, Depends(current_token)]
|
|
218
|
+
|
|
219
|
+
_bearer_scheme = HTTPBearer(auto_error=False)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def _bearer_login_id(
|
|
223
|
+
request: Request,
|
|
224
|
+
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
|
|
225
|
+
) -> str:
|
|
226
|
+
"""Depends 扩展:把 Bearer 写进 OpenAPI,读取逻辑与注解鉴权相同。"""
|
|
227
|
+
del credentials
|
|
228
|
+
return await current_login_id(request)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
BearerLoginId = Annotated[str, Depends(_bearer_login_id)]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def set_token_cookie(
|
|
235
|
+
response: Response,
|
|
236
|
+
token: str,
|
|
237
|
+
*,
|
|
238
|
+
max_age: int | None = None,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""按全局 Cookie 配置把登录 token 写入响应。
|
|
241
|
+
|
|
242
|
+
这是显式函数而不是自动拦截 ``StpUtil.login``:核心层没有 Response,
|
|
243
|
+
自动写入会破坏框架无关边界。
|
|
244
|
+
"""
|
|
245
|
+
from ..stp_util import get_manager
|
|
246
|
+
|
|
247
|
+
config = get_manager().config
|
|
248
|
+
if not config.is_write_cookie:
|
|
249
|
+
return
|
|
250
|
+
resolved_max_age = config.timeout if max_age is None else max_age
|
|
251
|
+
response.set_cookie(
|
|
252
|
+
key=config.token_name,
|
|
253
|
+
value=token,
|
|
254
|
+
max_age=None if resolved_max_age < 0 else resolved_max_age,
|
|
255
|
+
path=config.cookie_path,
|
|
256
|
+
domain=config.cookie_domain,
|
|
257
|
+
secure=config.cookie_secure,
|
|
258
|
+
httponly=config.cookie_http_only,
|
|
259
|
+
samesite=config.cookie_same_site,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def delete_token_cookie(response: Response) -> None:
|
|
264
|
+
"""删除登录 Cookie,参数与写入时保持一致。"""
|
|
265
|
+
from ..stp_util import get_manager
|
|
266
|
+
|
|
267
|
+
config = get_manager().config
|
|
268
|
+
response.delete_cookie(
|
|
269
|
+
key=config.token_name,
|
|
270
|
+
path=config.cookie_path,
|
|
271
|
+
domain=config.cookie_domain,
|
|
272
|
+
secure=config.cookie_secure,
|
|
273
|
+
httponly=config.cookie_http_only,
|
|
274
|
+
samesite=config.cookie_same_site,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class FastAPIWebSocketContext:
|
|
279
|
+
"""把 FastAPI / Starlette WebSocket 包装成核心 HttpContext。"""
|
|
280
|
+
|
|
281
|
+
def __init__(self, websocket: WebSocket) -> None:
|
|
282
|
+
self.websocket = websocket
|
|
283
|
+
self.state: dict[str, Any] = {}
|
|
284
|
+
|
|
285
|
+
def get_header(self, name: str) -> str | None:
|
|
286
|
+
return self.websocket.headers.get(name)
|
|
287
|
+
|
|
288
|
+
def get_cookie(self, name: str) -> str | None:
|
|
289
|
+
return self.websocket.cookies.get(name)
|
|
290
|
+
|
|
291
|
+
def get_query(self, name: str) -> str | None:
|
|
292
|
+
return self.websocket.query_params.get(name)
|
|
293
|
+
|
|
294
|
+
def get_path(self) -> str:
|
|
295
|
+
return self.websocket.url.path
|
|
296
|
+
|
|
297
|
+
def get_method(self) -> str:
|
|
298
|
+
return "WEBSOCKET"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def authenticate_websocket(
|
|
302
|
+
websocket: WebSocket,
|
|
303
|
+
*,
|
|
304
|
+
login_type: str = "login",
|
|
305
|
+
) -> tuple[str, str]:
|
|
306
|
+
"""在 ``accept`` 之前校验 WebSocket,返回 ``(login_id, token)``。"""
|
|
307
|
+
from ..online import WebSocketAuthenticator
|
|
308
|
+
from ..stp_util import get_manager
|
|
309
|
+
|
|
310
|
+
context = FastAPIWebSocketContext(websocket)
|
|
311
|
+
login_id = await WebSocketAuthenticator(
|
|
312
|
+
get_manager(), login_type=login_type
|
|
313
|
+
).authenticate(context)
|
|
314
|
+
token = context.state["stp_token"]
|
|
315
|
+
return login_id, token
|