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
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sa-token-python-core
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: 有状态 Token 认证与权限鉴权框架(轻量核心)。灵感来源于 Java 的 Sa-Token。全量依赖请安装 sa-token-python。
|
|
5
|
+
Project-URL: Homepage, https://github.com/Star-yb/sa-token-python
|
|
6
|
+
Project-URL: Documentation, https://github.com/Star-yb/sa-token-python
|
|
7
|
+
Project-URL: Source, https://github.com/Star-yb/sa-token-python
|
|
8
|
+
Project-URL: Issues, https://github.com/Star-yb/sa-token-python/issues
|
|
9
|
+
Author: sa-token-python contributors
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: auth,authentication,authorization,rbac,sa-token,token
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Session
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: fakeredis>=2.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx2>=0.1; extra == 'dev'
|
|
26
|
+
Requires-Dist: lupa>=2; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
30
|
+
Provides-Extra: django
|
|
31
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
32
|
+
Provides-Extra: fastapi
|
|
33
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
34
|
+
Provides-Extra: flask
|
|
35
|
+
Requires-Dist: flask>=3; extra == 'flask'
|
|
36
|
+
Provides-Extra: full
|
|
37
|
+
Requires-Dist: django>=4.2; extra == 'full'
|
|
38
|
+
Requires-Dist: fastapi>=0.110; extra == 'full'
|
|
39
|
+
Requires-Dist: flask>=3; extra == 'full'
|
|
40
|
+
Requires-Dist: pyjwt>=2; extra == 'full'
|
|
41
|
+
Requires-Dist: redis>=5; extra == 'full'
|
|
42
|
+
Requires-Dist: starlette>=0.37; extra == 'full'
|
|
43
|
+
Provides-Extra: jwt
|
|
44
|
+
Requires-Dist: pyjwt>=2; extra == 'jwt'
|
|
45
|
+
Provides-Extra: redis
|
|
46
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
47
|
+
Provides-Extra: starlette
|
|
48
|
+
Requires-Dist: starlette>=0.37; extra == 'starlette'
|
|
49
|
+
Description-Content-Type: text/markdown
|
|
50
|
+
|
|
51
|
+
# sa-token-python
|
|
52
|
+
|
|
53
|
+
<p align="center">
|
|
54
|
+
<img src="docs/community.png" alt="加入讨论群" width="220" />
|
|
55
|
+
</p>
|
|
56
|
+
<p align="center">扫码加入讨论群</p>
|
|
57
|
+
|
|
58
|
+
轻量级、**有状态** 的 Python 认证鉴权框架,灵感来源于 [Sa-Token](https://sa-token.cc/)(Java)。
|
|
59
|
+
|
|
60
|
+
同族实现:[sa-token-go](https://github.com/sa-tokens/sa-token-go) · [sa-token-rust](https://github.com/sa-tokens/sa-token-rust) · [xlt-token](https://github.com/xiaoLangtou/xlt-token)(Node.js)
|
|
61
|
+
|
|
62
|
+
**核心不依赖任何 Web 框架。** 脚本、定时任务、gRPC、FastAPI、Flask、Django 共用同一套登录 / 踢人 / 权限语义。
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
token = await StpUtil.login(user_id)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 特性
|
|
71
|
+
|
|
72
|
+
- 🔐 **认证** — 登录 / 登出 / 双 Token 刷新,多端登录,踢人与顶号带明确原因
|
|
73
|
+
- 🛡️ **鉴权** — 权限与角色,支持通配符(`user:*`)和 AND / OR
|
|
74
|
+
- 🛣️ **路径鉴权** — Ant 风格规则表,可按 GET / POST / PUT / DELETE 区分
|
|
75
|
+
- 🧯 **防重放** — 服务端 Nonce、Refresh family 重放检测、Temp Token 原子消费
|
|
76
|
+
- 🚫 **封禁** — 临时 / 永久,按服务、按等级
|
|
77
|
+
- 🔒 **二级认证** — 已登录仍需短时确认,适合支付、改密
|
|
78
|
+
- 💾 **Session** — 账号级与 Token 级 KV 会话
|
|
79
|
+
- 🎨 **Token 风格** — uuid / random / hash / timestamp / tik / 有状态 JWT
|
|
80
|
+
- 📦 **存储可插拔** — Memory(开发)/ Redis(生产)/ 自定义 `SaStorage`
|
|
81
|
+
- 🎧 **事件** — 登录、登出、踢人、封禁等,载荷只带 token 指纹
|
|
82
|
+
- 🌐 **框架适配** — FastAPI 优先;Starlette / Flask / Django 基础适配
|
|
83
|
+
- 🎫 **OAuth2** — 授权码 + PKCE + client_credentials + introspection + 吊销
|
|
84
|
+
- 🔑 **SSO** — 一次性 ticket、HMAC 签名与统一登出
|
|
85
|
+
- 👥 **在线用户** — FastAPI WebSocket 鉴权、按设备推送、踢人即断连
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 安装
|
|
90
|
+
|
|
91
|
+
Python 3.10+。两个发行名,导入均为 `sa_token`。
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install sa-token-python # 全量:核心 + Redis / JWT / 全部框架适配
|
|
95
|
+
pip install "sa-token-python[full]" # 与上一行相同
|
|
96
|
+
|
|
97
|
+
pip install sa-token-python-core # 轻量:核心 + 内存存储
|
|
98
|
+
pip install "sa-token-python-core[redis]" # Redis 存储
|
|
99
|
+
pip install "sa-token-python-core[jwt]" # JWT token 风格
|
|
100
|
+
pip install "sa-token-python-core[fastapi]" # FastAPI / Starlette
|
|
101
|
+
pip install "sa-token-python-core[flask]" # Flask
|
|
102
|
+
pip install "sa-token-python-core[django]" # Django
|
|
103
|
+
pip install "sa-token-python-core[full]" # 与 pip install sa-token-python 相同
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
也可按需组合 extras,例如 `"sa-token-python-core[redis,jwt,fastapi]"`。
|
|
107
|
+
|
|
108
|
+
从源码安装:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install "git+https://github.com/Star-yb/sa-token-python.git"
|
|
112
|
+
pip install "sa-token-python-core[fastapi] @ git+https://github.com/Star-yb/sa-token-python.git"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 快速开始(原生,无 Web 框架)
|
|
118
|
+
|
|
119
|
+
这是主路径。没有中间件,没有 Request,脚本里直接可用。
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
import asyncio
|
|
123
|
+
from sa_token import SaToken, StpUtil
|
|
124
|
+
from sa_token.storage import MemoryStorage
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def main() -> None:
|
|
128
|
+
SaToken.builder().storage(MemoryStorage()).timeout(7200).build()
|
|
129
|
+
|
|
130
|
+
token = await StpUtil.login("10001", device="cli")
|
|
131
|
+
await StpUtil.set_permissions("10001", ["user:read", "order:*"])
|
|
132
|
+
await StpUtil.set_roles("10001", ["admin"])
|
|
133
|
+
|
|
134
|
+
assert await StpUtil.is_login(token)
|
|
135
|
+
assert await StpUtil.has_permission("10001", "order:delete") # order:* 命中
|
|
136
|
+
|
|
137
|
+
await StpUtil.kickout("10001")
|
|
138
|
+
assert not await StpUtil.is_login(token)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
asyncio.run(main())
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from sa_token.storage import MemoryStorage, RedisStorage
|
|
146
|
+
|
|
147
|
+
SaToken.builder().storage(MemoryStorage()).timeout(7200).build()
|
|
148
|
+
SaToken.builder().storage(RedisStorage.from_url("redis://127.0.0.1:6379/0")).timeout(7200).build()
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
被踢之后拿到的是明确原因,而不是笼统的「未登录」:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from sa_token import NotLoginException, NotLoginType
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
await StpUtil.check_login(token)
|
|
158
|
+
except NotLoginException as exc:
|
|
159
|
+
assert exc.type is NotLoginType.KICK_OUT # 还可以是 BE_REPLACED / TOKEN_TIMEOUT / ...
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 异步与同步
|
|
165
|
+
|
|
166
|
+
**内核默认是异步。** 所有核心方法都是 `async`:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
token = await StpUtil.login(10001)
|
|
170
|
+
login_id = await StpUtil.check_login(token)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
FastAPI / Starlette 直接 `await`。Flask、Django 同步视图、没有事件循环的脚本,用同步桥接:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from sa_token.sync import StpUtilSync
|
|
177
|
+
|
|
178
|
+
token = StpUtilSync.login(10001) # 没有 await
|
|
179
|
+
login_id = StpUtilSync.check_login(token)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`StpUtilSync` **不是第二份鉴权实现**,只是把同一套 `StpLogic` 放到后台事件循环里跑。
|
|
183
|
+
|
|
184
|
+
命名约定(避免和常见缩写搞混):
|
|
185
|
+
|
|
186
|
+
| 名字 | 含义 | 使用场景 |
|
|
187
|
+
| --- | --- | --- |
|
|
188
|
+
| `StpUtil` | 异步 API(`async` / `await`) | FastAPI、脚本 `asyncio.run`、任何已有事件循环的环境 |
|
|
189
|
+
| `StpUtilSync` | 同步 API。`Sync` = English **synchronous**(同步) | Flask、Django 同步视图、普通函数 |
|
|
190
|
+
|
|
191
|
+
已经在事件循环里时不要调用 `StpUtilSync`,会明确报错,请直接 `await StpUtil.xxx()`。
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## 核心 API
|
|
196
|
+
|
|
197
|
+
### 认证
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
token = await StpUtil.login(1000)
|
|
201
|
+
token = await StpUtil.login("user123", device="mobile")
|
|
202
|
+
token = await StpUtil.login(1000, token_value="legacy-token") # 迁移旧系统
|
|
203
|
+
|
|
204
|
+
tokens = await StpUtil.login_with_refresh(1000, device="web")
|
|
205
|
+
new_tokens = await StpUtil.refresh_access_token(tokens.refresh_token)
|
|
206
|
+
|
|
207
|
+
await StpUtil.is_login(token)
|
|
208
|
+
await StpUtil.check_login(token) # 失败抛 NotLoginException
|
|
209
|
+
await StpUtil.get_login_id(token)
|
|
210
|
+
|
|
211
|
+
await StpUtil.logout(1000)
|
|
212
|
+
await StpUtil.logout_by_token(token)
|
|
213
|
+
await StpUtil.kickout(1000) # 下次请求 type=KICK_OUT
|
|
214
|
+
await StpUtil.kickout_by_token(token) # 只踢指定终端,保留原因
|
|
215
|
+
await StpUtil.replaced(1000) # 下次请求 type=BE_REPLACED
|
|
216
|
+
await StpUtil.get_offline_reason(token)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Nonce / Temp Token
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
# 服务端签发;绑定用户与业务,消费时原子删除
|
|
223
|
+
nonce = await StpUtil.issue_nonce("10001", purpose="change-password")
|
|
224
|
+
await StpUtil.consume_nonce(nonce, "10001", purpose="change-password")
|
|
225
|
+
|
|
226
|
+
# 邀请、重置密码、邮箱验证等一次性链接
|
|
227
|
+
temp_token = await StpUtil.create_temp_token(
|
|
228
|
+
{"user_id": "10001"}, 300, namespace="reset-password"
|
|
229
|
+
)
|
|
230
|
+
payload = await StpUtil.consume_temp_token(
|
|
231
|
+
temp_token, namespace="reset-password"
|
|
232
|
+
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`consume_nonce` 与 `consume_temp_token` 在并发请求中只允许一次成功。Nonce 不是
|
|
236
|
+
验证码,也不能代替登录校验;它用于阻止有效请求被重复提交。
|
|
237
|
+
|
|
238
|
+
### 权限 / 角色
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
await StpUtil.set_permissions(1000, ["user:read", "user:write", "admin:*"])
|
|
242
|
+
await StpUtil.has_permission(1000, "admin:delete") # 通配符命中
|
|
243
|
+
await StpUtil.has_permissions_and(1000, ["user:read", "user:write"])
|
|
244
|
+
await StpUtil.has_permissions_or(1000, ["admin", "super"])
|
|
245
|
+
await StpUtil.check_permission(1000, "order:delete") # 失败抛 NotPermissionException
|
|
246
|
+
|
|
247
|
+
await StpUtil.set_roles(1000, ["admin", "manager"])
|
|
248
|
+
await StpUtil.has_role(1000, "admin")
|
|
249
|
+
await StpUtil.check_role(1000, "admin")
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
已有 RBAC 表时,实现 `StpInterface`(`get_permission_list` / `get_role_list`),不要把权限再抄一份进 Redis。
|
|
253
|
+
|
|
254
|
+
### Session / 封禁 / 二级认证
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
session = await StpUtil.get_session(1000)
|
|
258
|
+
await session.set("nickname", "alice")
|
|
259
|
+
|
|
260
|
+
await StpUtil.disable(1000, seconds=3600) # 临时
|
|
261
|
+
await StpUtil.disable(1000, seconds=-1) # 永久
|
|
262
|
+
await StpUtil.disable(1000, 3600, service="comment", level=2)
|
|
263
|
+
await StpUtil.untie(1000)
|
|
264
|
+
|
|
265
|
+
await StpUtil.open_safe(token, "pay", 300)
|
|
266
|
+
await StpUtil.check_safe(token, "pay")
|
|
267
|
+
await StpUtil.close_safe(token, "pay")
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### 多端查询
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
await StpUtil.get_terminal_list(1000)
|
|
274
|
+
await StpUtil.get_token_value_list_by_login_id(1000, device="web")
|
|
275
|
+
await StpUtil.search_token_value("abc") # 管理端扫描,不要放进热路径
|
|
276
|
+
await StpUtil.search_session("user") # 扫描 Account-Session ID
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## 配置
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
SaToken.builder() \
|
|
285
|
+
.storage(MemoryStorage()) \
|
|
286
|
+
.token_name("Authorization") \
|
|
287
|
+
.timeout(86400) \
|
|
288
|
+
.token_style("uuid") \
|
|
289
|
+
.is_concurrent(True) \
|
|
290
|
+
.is_share(False) \
|
|
291
|
+
.auto_renew(True) \
|
|
292
|
+
.build()
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
| 配置 | 默认 | 说明 |
|
|
296
|
+
| --- | --- | --- |
|
|
297
|
+
| `token_name` | `Authorization` | Header / Cookie / Query 键名 |
|
|
298
|
+
| `timeout` | `2592000` | token 有效秒数,`-1` 永久 |
|
|
299
|
+
| `active_timeout` | `-1` | 活跃超时,超时未访问则冻结 |
|
|
300
|
+
| `auto_renew` | `True` | 校验成功后续期 |
|
|
301
|
+
| `token_style` | `uuid` | 生成风格 |
|
|
302
|
+
| `token_prefix` | `Bearer ` | 读取时剥离,不带前缀也接受 |
|
|
303
|
+
| `is_concurrent` | `True` | 是否允许多端同时在线 |
|
|
304
|
+
| `is_share` | `True` | 多端是否共享同一 token |
|
|
305
|
+
| `max_login_count` | `12` | 最大同时在线数,`-1` 不限制 |
|
|
306
|
+
| `refresh_token_timeout` | `2592000` | 登录态 Refresh Token 有效期 |
|
|
307
|
+
| `refresh_token_rotate` | `True` | 刷新时轮转 Refresh Token |
|
|
308
|
+
| `refresh_token_reuse_detection` | `True` | 旧值重放时吊销整个 family |
|
|
309
|
+
| `nonce_timeout` | `60` | 服务端 Nonce 有效秒数 |
|
|
310
|
+
| `is_write_cookie` | `False` | FastAPI 显式调用写 Cookie 助手时是否生效 |
|
|
311
|
+
| `storage_key_prefix` | `satoken:` | 所有存储键前缀 |
|
|
312
|
+
| `jwt_secret_key` | `None` | JWT 风格时必填 |
|
|
313
|
+
|
|
314
|
+
Token 读取顺序全局唯一:**Header → Authorization 兜底 → Cookie → Query**。各框架禁止自己再解析一遍。
|
|
315
|
+
|
|
316
|
+
### 多端登录语义
|
|
317
|
+
|
|
318
|
+
| `is_concurrent` | `is_share` | 行为 |
|
|
319
|
+
| --- | --- | --- |
|
|
320
|
+
| `False` | 忽略 | 新登录顶掉旧登录(银行 App) |
|
|
321
|
+
| `True` | `True` | 同设备类型复用同一 token |
|
|
322
|
+
| `True` | `False` | 每端独立 token,互不影响 |
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## 路径鉴权(按 HTTP 方法区分)
|
|
327
|
+
|
|
328
|
+
Java 里可以 `SaRouter.match(SaHttpMethod.POST, "/user/**")`。本项目同样支持:规则表里用 `methods=`,匹配时同时看路径和请求方法。
|
|
329
|
+
|
|
330
|
+
`methods` 省略或空列表 = 匹配该路径的**所有方法**。
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
from sa_token.adapter import PathAuthConfig
|
|
334
|
+
from sa_token.integration.fastapi import SaTokenMiddleware
|
|
335
|
+
|
|
336
|
+
path_auth = (
|
|
337
|
+
PathAuthConfig()
|
|
338
|
+
.ignore("/login", "/docs", "/openapi.json")
|
|
339
|
+
.ignore("/article", methods=["GET"]) # 公开阅读
|
|
340
|
+
.permission("/article", "article:write", methods=["POST", "PUT", "DELETE"])
|
|
341
|
+
.permission("/order/**", "order:*", methods=["DELETE"])
|
|
342
|
+
.role("/admin/**", "admin")
|
|
343
|
+
.login("/**") # 其余全部要登录
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
app.add_middleware(SaTokenMiddleware, path_auth=path_auth)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
对应关系:
|
|
350
|
+
|
|
351
|
+
| Java | 本项目 |
|
|
352
|
+
| --- | --- |
|
|
353
|
+
| `SaRouter.match("/user/**").check(r -> StpUtil.checkLogin())` | `.login("/user/**")` |
|
|
354
|
+
| `SaRouter.match(SaHttpMethod.GET, "/article")` | `.ignore("/article", methods=["GET"])` 或 `.login(..., methods=["GET"])` |
|
|
355
|
+
| `SaRouter.match(SaHttpMethod.POST, "/article").check(perm)` | `.permission("/article", "article:write", methods=["POST"])` |
|
|
356
|
+
|
|
357
|
+
注意:`ignore` 优先级最高。如果写成 `.ignore("/article")`(不限方法),后面的 `.permission("/article", ..., methods=["POST"])` 对 POST 也不会生效。要「GET 公开、POST 鉴权」,必须给 ignore 也加上 `methods=["GET"]`。
|
|
358
|
+
|
|
359
|
+
路径匹配是 Ant 风格:`*` 单层,`**` 任意层。
|
|
360
|
+
|
|
361
|
+
装饰器 / Depends 场景不需要规则表——路由本身已经绑定了方法(`@app.delete` 只对 DELETE 生效)。规则表适合网关、不想给每个路由挂注解的情况。
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## FastAPI(主要适配框架)
|
|
366
|
+
|
|
367
|
+
各框架统一用注解鉴权。FastAPI 的标准写法与 Flask / Django 相同:
|
|
368
|
+
|
|
369
|
+
```python
|
|
370
|
+
from fastapi import FastAPI, Response
|
|
371
|
+
from sa_token import SaToken, StpUtil
|
|
372
|
+
from sa_token.storage import MemoryStorage, RedisStorage
|
|
373
|
+
from sa_token.integration.fastapi import SaTokenFastAPI, set_token_cookie
|
|
374
|
+
|
|
375
|
+
SaToken.builder().storage(MemoryStorage()).set_option(is_write_cookie=True).build()
|
|
376
|
+
SaToken.builder().storage(RedisStorage.from_url("redis://127.0.0.1:6379/0")).set_option(is_write_cookie=True).build()
|
|
377
|
+
|
|
378
|
+
app = FastAPI()
|
|
379
|
+
sa = SaTokenFastAPI(app)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@app.post("/login")
|
|
383
|
+
async def login(user_id: str, response: Response) -> dict:
|
|
384
|
+
tokens = await StpUtil.login_with_refresh(user_id, device="web")
|
|
385
|
+
set_token_cookie(response, tokens.access_token)
|
|
386
|
+
return tokens.to_dict()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.get("/user")
|
|
390
|
+
@sa.check_login
|
|
391
|
+
async def user_info() -> dict:
|
|
392
|
+
return {"id": sa.login_id()}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@app.delete("/order/{order_id}")
|
|
396
|
+
@sa.check_permission("order:delete")
|
|
397
|
+
async def delete_order(order_id: str) -> dict:
|
|
398
|
+
return {"deleted": order_id}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
作为主要适配框架,FastAPI 额外提供 `Depends` / `Annotated`(`LoginId`、`BearerLoginId`),语义与注解相同:
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
from fastapi import Depends
|
|
405
|
+
from sa_token.integration.fastapi import LoginId, BearerLoginId, check_permission
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@app.get("/profile")
|
|
409
|
+
async def profile(login_id: LoginId) -> dict:
|
|
410
|
+
return {"id": login_id}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@app.get("/me")
|
|
414
|
+
async def me(login_id: BearerLoginId) -> dict:
|
|
415
|
+
return {"id": login_id}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@app.get("/order/{order_id}")
|
|
419
|
+
async def get_order(order_id: str, _: str = Depends(check_permission("order:read"))) -> dict:
|
|
420
|
+
return {"id": order_id}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### FastAPI WebSocket
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
from fastapi import WebSocket
|
|
427
|
+
from sa_token.integration.fastapi import authenticate_websocket
|
|
428
|
+
|
|
429
|
+
@app.websocket("/ws")
|
|
430
|
+
async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
431
|
+
login_id, token = await authenticate_websocket(websocket)
|
|
432
|
+
await websocket.accept()
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
浏览器 WebSocket 不能自定义 Header 时可使用
|
|
436
|
+
`/ws?Authorization=<token>`。生产代码应再用 `OnlineManager.register` 登记连接,
|
|
437
|
+
并在心跳、断开时调用 `heartbeat` / `unregister`。
|
|
438
|
+
|
|
439
|
+
完整的登录刷新、Cookie、权限、Nonce、WebSocket 示例见
|
|
440
|
+
[`examples/fastapi/main.py`](examples/fastapi/main.py)。
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## Flask
|
|
445
|
+
|
|
446
|
+
Flask 没有事件循环,用 `StpUtilSync`。鉴权与 FastAPI、Django 一样使用注解。
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
from flask import Flask
|
|
450
|
+
from sa_token import SaToken
|
|
451
|
+
from sa_token.storage import MemoryStorage, RedisStorage
|
|
452
|
+
from sa_token.integration.flask import SaTokenFlask
|
|
453
|
+
from sa_token.sync import StpUtilSync
|
|
454
|
+
|
|
455
|
+
SaToken.builder().storage(MemoryStorage()).build()
|
|
456
|
+
SaToken.builder().storage(RedisStorage.from_url("redis://127.0.0.1:6379/0")).build()
|
|
457
|
+
|
|
458
|
+
app = Flask(__name__)
|
|
459
|
+
sa = SaTokenFlask(app)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@app.post("/login")
|
|
463
|
+
def login():
|
|
464
|
+
return {"token": StpUtilSync.login("10001")}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@app.get("/user")
|
|
468
|
+
@sa.check_login
|
|
469
|
+
def user_info():
|
|
470
|
+
return {"id": sa.login_id()}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@app.delete("/order/<order_id>")
|
|
474
|
+
@sa.check_permission("order:delete")
|
|
475
|
+
def delete_order(order_id):
|
|
476
|
+
return {"deleted": order_id}
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
完整示例见 [`examples/flask/main.py`](examples/flask/main.py)。
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## Django
|
|
484
|
+
|
|
485
|
+
Django 自带 `django.contrib.auth`(Session + User 模型)。本库**不是**去替换它,而是给 **纯 API / DRF** 项目提供与 FastAPI、Flask 一致的 Token 鉴权。模板站点继续用 Django Auth 即可。
|
|
486
|
+
|
|
487
|
+
安装:`pip install "sa-token-python-core[django]"` 或 `pip install sa-token-python`。Django 是同步 WSGI,一律走 `StpUtilSync`。
|
|
488
|
+
|
|
489
|
+
### 1. 启动时初始化
|
|
490
|
+
|
|
491
|
+
在某个 App 的 `AppConfig.ready()` 里构建一次 Manager(只做一次,不要在每个请求里 `build()`):
|
|
492
|
+
|
|
493
|
+
```python
|
|
494
|
+
# myapp/apps.py
|
|
495
|
+
from django.apps import AppConfig
|
|
496
|
+
|
|
497
|
+
class MyappConfig(AppConfig):
|
|
498
|
+
name = "myapp"
|
|
499
|
+
|
|
500
|
+
def ready(self) -> None:
|
|
501
|
+
from sa_token import SaToken
|
|
502
|
+
from sa_token.storage import MemoryStorage, RedisStorage
|
|
503
|
+
|
|
504
|
+
SaToken.builder().storage(MemoryStorage()).timeout(86400).print_banner(False).build()
|
|
505
|
+
SaToken.builder().storage(RedisStorage.from_url("redis://127.0.0.1:6379/0")).timeout(86400).print_banner(False).build()
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### 2. 注册中间件
|
|
509
|
+
|
|
510
|
+
```python
|
|
511
|
+
# settings.py
|
|
512
|
+
MIDDLEWARE = [
|
|
513
|
+
# ... Django 自带中间件 ...
|
|
514
|
+
"sa_token.integration.django.SaTokenDjangoMiddleware",
|
|
515
|
+
]
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
默认行为与 FastAPI 中间件相同:**只解析 token**,挂到 `request.sa_token` / `request.sa_login_id`,不强制登录。未登录访问公开接口不会 401。
|
|
519
|
+
|
|
520
|
+
鉴权异常会被中间件翻成 JSON:`{"code": 401, "message": "...", "error": "NotLoginException", "type": "NOT_TOKEN"}`。
|
|
521
|
+
|
|
522
|
+
### 3. 登录 / 登出视图
|
|
523
|
+
|
|
524
|
+
```python
|
|
525
|
+
from django.http import JsonResponse
|
|
526
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
527
|
+
from sa_token.sync import StpUtilSync
|
|
528
|
+
|
|
529
|
+
@csrf_exempt
|
|
530
|
+
def login(request):
|
|
531
|
+
# 这里自己查数据库校验账号密码,通过后:
|
|
532
|
+
user_id = "10001"
|
|
533
|
+
token = StpUtilSync.login(user_id, device="web")
|
|
534
|
+
StpUtilSync.set_permissions(user_id, ["user:read", "order:*"])
|
|
535
|
+
StpUtilSync.set_roles(user_id, ["admin"])
|
|
536
|
+
return JsonResponse({"token": token})
|
|
537
|
+
|
|
538
|
+
def logout(request):
|
|
539
|
+
StpUtilSync.logout_by_token(request.sa_token)
|
|
540
|
+
return JsonResponse({"ok": True})
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
客户端之后在 Header 带 `Authorization: Bearer <token>`。Cookie / Query 同样能读到(由核心 `token_io` 统一处理)。
|
|
544
|
+
|
|
545
|
+
### 4. 视图装饰器
|
|
546
|
+
|
|
547
|
+
与 FastAPI、Flask 相同,使用注解:
|
|
548
|
+
|
|
549
|
+
```python
|
|
550
|
+
from django.http import JsonResponse
|
|
551
|
+
from sa_token.integration.django import check_login, check_permission, check_role
|
|
552
|
+
|
|
553
|
+
@check_login
|
|
554
|
+
def me(request):
|
|
555
|
+
return JsonResponse({"login_id": request.sa_login_id})
|
|
556
|
+
|
|
557
|
+
@check_permission("order:delete")
|
|
558
|
+
def delete_order(request, order_id):
|
|
559
|
+
return JsonResponse({"deleted": order_id})
|
|
560
|
+
|
|
561
|
+
@check_role("admin")
|
|
562
|
+
def admin_panel(request):
|
|
563
|
+
return JsonResponse({"ok": True})
|
|
564
|
+
|
|
565
|
+
@check_permission("user:read", "user:write", mode="AND")
|
|
566
|
+
def update_user(request):
|
|
567
|
+
return JsonResponse({"ok": True})
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
装饰器失败时直接返回 401 / 403 JSON,不会进到视图函数。
|
|
571
|
+
|
|
572
|
+
### 5. 路径规则表(可选)
|
|
573
|
+
|
|
574
|
+
不想给每个 view 挂装饰器时,在初始化之后给模块级 `PATH_AUTH` 赋值,中间件就会按规则表强制鉴权:
|
|
575
|
+
|
|
576
|
+
```python
|
|
577
|
+
# 建议放在 AppConfig.ready() 里,build() 之后
|
|
578
|
+
from sa_token.adapter import PathAuthConfig
|
|
579
|
+
from sa_token.integration import django as sa_django
|
|
580
|
+
|
|
581
|
+
sa_django.PATH_AUTH = (
|
|
582
|
+
PathAuthConfig()
|
|
583
|
+
.ignore("/api/login", "/api/health")
|
|
584
|
+
.ignore("/api/article", methods=["GET"])
|
|
585
|
+
.permission("/api/article", "article:write", methods=["POST", "PUT", "DELETE"])
|
|
586
|
+
.role("/api/admin/**", "admin")
|
|
587
|
+
.login("/api/**")
|
|
588
|
+
)
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### 6. 与 DRF 一起用
|
|
592
|
+
|
|
593
|
+
中间件仍然生效。DRF ViewSet 上继续套同一个装饰器,或在 `initial()` / permission 类里调用 `StpUtilSync.check_login(request.sa_token)`。不要在 DRF 里再写一套 Header 解析。
|
|
594
|
+
|
|
595
|
+
取当前用户:
|
|
596
|
+
|
|
597
|
+
```python
|
|
598
|
+
login_id = request.sa_login_id # 中间件或装饰器已经校验过时
|
|
599
|
+
# 或
|
|
600
|
+
login_id = StpUtilSync.get_login_id(request.sa_token)
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### 7. 不要和 Django Auth 混用同一套会话
|
|
604
|
+
|
|
605
|
+
| | Django Auth | sa-token-python |
|
|
606
|
+
| --- | --- | --- |
|
|
607
|
+
| 凭证 | `sessionid` Cookie | Token(Header / Cookie / Query) |
|
|
608
|
+
| 用户模型 | `AUTH_USER_MODEL` | 只认 `login_id` 字符串 |
|
|
609
|
+
| 踢人 | 清 session / 改密码未必立刻失效 | `kickout` 立刻生效 |
|
|
610
|
+
| 适用 | 模板站点、Admin | 纯 API、多端、需要踢人封禁 |
|
|
611
|
+
|
|
612
|
+
两套可以并存(Admin 继续用 Django Auth,API 用本库),但不要让同一个接口两套都校验。
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
## 存储
|
|
617
|
+
|
|
618
|
+
```python
|
|
619
|
+
from sa_token import SaToken
|
|
620
|
+
from sa_token.storage import MemoryStorage, RedisStorage
|
|
621
|
+
|
|
622
|
+
SaToken.builder().storage(MemoryStorage()).timeout(7200).build()
|
|
623
|
+
SaToken.builder().storage(RedisStorage.from_url("redis://127.0.0.1:6379/0")).timeout(7200).build()
|
|
624
|
+
SaToken.builder().storage(RedisStorage.from_url("redis://:your_password@127.0.0.1:6379/0")).timeout(7200).build()
|
|
625
|
+
SaToken.builder().storage(RedisStorage.from_url("redis://user:your_password@127.0.0.1:6379/1")).timeout(7200).build()
|
|
626
|
+
SaToken.builder().storage(RedisStorage.from_url("rediss://:your_password@redis.example.com:6379/0")).timeout(7200).build()
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
```python
|
|
630
|
+
from redis.asyncio import Redis
|
|
631
|
+
from sa_token.storage import RedisStorage
|
|
632
|
+
|
|
633
|
+
client = Redis(host="127.0.0.1", port=6379, db=0, password="your_password", decode_responses=True)
|
|
634
|
+
SaToken.builder().storage(RedisStorage(client)).build()
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
自定义存储实现 `SaStorage`:`get` / `set` / `delete` / `expire` / `ttl` / `set_if_absent` / `compare_and_set` / `scan`。
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
## Token 风格
|
|
642
|
+
|
|
643
|
+
`uuid`(默认)、`simple-uuid`、`random32` / `random64` / `random128`、`hash`、`timestamp`、`tik`、`jwt`。
|
|
644
|
+
|
|
645
|
+
JWT 是**有状态 JWT**:token 自带 claims,校验仍查存储。踢人、顶号、封禁继续有效。不要把它理解成「签发后服务端不管」。
|
|
646
|
+
|
|
647
|
+
```python
|
|
648
|
+
SaToken.builder().storage(MemoryStorage()).token_style("jwt").jwt_secret_key("至少32字节的密钥").build()
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
---
|
|
652
|
+
|
|
653
|
+
## 事件
|
|
654
|
+
|
|
655
|
+
```python
|
|
656
|
+
from sa_token import Event, EventData, SaToken
|
|
657
|
+
from sa_token.storage import MemoryStorage
|
|
658
|
+
|
|
659
|
+
def on_event(data: EventData) -> None:
|
|
660
|
+
print(data.event, data.login_id, data.token_fingerprint)
|
|
661
|
+
|
|
662
|
+
SaToken.builder().storage(MemoryStorage()).on(Event.ALL, on_event).build()
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
事件包括 `LOGIN` / `LOGOUT` / `KICKOUT` / `REPLACED` / `DISABLE` / `UNTIE` / `RENEW` 等。载荷只有 token 指纹,不会把原始 token 打进日志。监听器异常不影响主流程。
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## 扩展模块
|
|
670
|
+
|
|
671
|
+
全部框架无关,可在单测里不起 HTTP 服务跑通。
|
|
672
|
+
|
|
673
|
+
**OAuth2**(授权码 + PKCE + 原子 refresh 轮转 + client_credentials):
|
|
674
|
+
|
|
675
|
+
```python
|
|
676
|
+
from sa_token.oauth2 import OAuth2Client, OAuth2Server, generate_pkce_pair
|
|
677
|
+
|
|
678
|
+
server = OAuth2Server(manager)
|
|
679
|
+
await server.register_client(OAuth2Client(
|
|
680
|
+
client_id="web-app",
|
|
681
|
+
client_secret="secret",
|
|
682
|
+
redirect_uris=["https://app.example/callback"],
|
|
683
|
+
scopes=["read"],
|
|
684
|
+
))
|
|
685
|
+
code = await server.create_authorization_code(
|
|
686
|
+
client_id="web-app", login_id="10001", redirect_uri="https://app.example/callback",
|
|
687
|
+
)
|
|
688
|
+
tokens = await server.exchange_code_for_token(
|
|
689
|
+
code=code.code, client_id="web-app", client_secret="secret",
|
|
690
|
+
)
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
FastAPI 可直接挂载标准 `/token`、`/introspect`、`/revoke`:
|
|
694
|
+
|
|
695
|
+
```python
|
|
696
|
+
from sa_token.integration.fastapi_oauth2 import create_oauth2_router
|
|
697
|
+
|
|
698
|
+
app.include_router(create_oauth2_router(server))
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
授权页是否同意属于业务决策,不会由库自动放行;用户确认后调用
|
|
702
|
+
`authorization_redirect`。
|
|
703
|
+
|
|
704
|
+
**SSO**:配置 `SsoConfig(secret_key="...")` 后使用
|
|
705
|
+
`create_signed_ticket` / `validate_ticket(..., signature=...)`;ticket 一次性且绑定
|
|
706
|
+
service。`SsoServer.logout` 返回待通知客户端列表,HTTP 或消息队列由应用选择。
|
|
707
|
+
|
|
708
|
+
**在线用户 / WebSocket**:`OnlineManager` 支持心跳、按用户或设备推送、按设备
|
|
709
|
+
断开和过期连接清理。通过 `StpUtil.kickout` / `replaced` 触发核心事件时,本进程
|
|
710
|
+
连接会自动关闭。跨 worker 的连接对象不能放进 Redis,跨进程广播需由部署层接入
|
|
711
|
+
Redis Pub/Sub 或消息总线。
|
|
712
|
+
|
|
713
|
+
---
|
|
714
|
+
|
|
715
|
+
## 异常
|
|
716
|
+
|
|
717
|
+
| 异常 | HTTP | 含义 |
|
|
718
|
+
| --- | --- | --- |
|
|
719
|
+
| `NotLoginException` | 401 | 未登录 / 无效 / 超时 / 被踢 / 被顶 / 冻结,看 `exc.type` |
|
|
720
|
+
| `NotPermissionException` | 403 | 权限不足 |
|
|
721
|
+
| `NotRoleException` | 403 | 角色不足 |
|
|
722
|
+
| `DisableException` | 403 | 账号封禁 |
|
|
723
|
+
| `NotSafeException` | 403 | 二级认证未通过 |
|
|
724
|
+
| `SecurityException` | 400 | Nonce / Refresh / Temp Token 安全流程失败,看 `exc.code` |
|
|
725
|
+
| `SaTokenException` | 500 | 其它内部错误 |
|
|
726
|
+
|
|
727
|
+
`NotLoginException.type`:`NOT_TOKEN` / `INVALID_TOKEN` / `TOKEN_TIMEOUT` / `TOKEN_FREEZE` / `BE_REPLACED` / `KICK_OUT`。
|
|
728
|
+
|
|
729
|
+
框架适配只做异常 → HTTP 的翻译,不在适配层重新发明错误码。
|
|
730
|
+
|
|
731
|
+
---
|
|
732
|
+
|
|
733
|
+
## 项目结构
|
|
734
|
+
|
|
735
|
+
```
|
|
736
|
+
src/sa_token/
|
|
737
|
+
├── stp_logic.py # 认证鉴权的唯一实现
|
|
738
|
+
├── stp_util.py # 异步静态门面
|
|
739
|
+
├── sync.py # 同步桥接 StpUtilSync
|
|
740
|
+
├── adapter/ # HttpContext + 路径规则 + 统一管道
|
|
741
|
+
├── integration/ # FastAPI / Flask / Django / Starlette(只做适配)
|
|
742
|
+
├── storage/ # Memory / Redis
|
|
743
|
+
├── strategy/ # Token 生成风格
|
|
744
|
+
├── security/ # Nonce / 登录 Refresh / Temp Token
|
|
745
|
+
├── oauth2/ sso/ online/
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
依赖方向只能从上到下:`integration` 可以依赖 core,core 绝不能 import FastAPI / Flask / Django。
|
|
749
|
+
|
|
750
|
+
示例见 [examples/](examples/)。
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
## License
|
|
755
|
+
|
|
756
|
+
Apache-2.0
|