分类:后端技术文 | 系列文第 4 篇(《纵横四海》服务端系列) 前置阅读:《SQLite WAL 在小服务里的正确打开方式》《服务端权威架构:客户端永远不可信》
一、我先把 JWT 写了出来,又亲手拆了它
《纵横四海》是我业余在维护的多人 WAP 文字游戏,H5 前端 + FastAPI + SQLite(WAL)。做登录态的时候,网上教程一搜全是"FastAPI + JWT 三步搞定",官方文档的示例也是 OAuth2 + JWT 摆在最前面。
一开始我真按教程搭了个 JWT demo,签发、验签、payload 塞 player_id,半小时就跑通了,当时还挺得意。
重点:真正让我停手的不是"JWT 不好",是一个具体需求,GM 封禁要能立刻踢人下线。
《纵横四海》有 GM 封禁需求。玩家开挂、刷金币,GM 要能把他当场踢下线。我照着教程去实现"JWT 撤销",思路是加黑名单:Redis 里维护一张 revoked_jti 表,签发时记 jti,每个请求进来先查一遍。
写到一半停下来想了想,我在干嘛?
为了"无状态"选了 JWT,为了撤销能力又加一层服务端状态。绕了一圈,等于拿有状态的补丁,去模拟我一开始就能免费拿到的东西。
demo 删了。然后把我的约束逐条过一遍:
- 单进程部署,没有多实例,JWT 的无状态优势用不上;
- H5 同源 API,不存在跨服务共享身份;
- WAP 游戏玩家爱挂机,我要的恰恰是能强制下线;
- 存档和交易全在服务端,本来就该服务端说了算,JWT 反而把信任下放给了客户端。
四条里没有一条站在 JWT 那边。本来还想给它找补一句"JWT 也不是不能用",写到这里发现找补不动,四个约束全站在对面。
结论很干脆:服务端 session,token 只当一张取件码。
至于 Starlette 自带的 SessionMiddleware,看过它的默认行为就放弃了。它把整个 session 序列化后塞进一个签名 Cookie,等于玩家的会话数据在浏览器和服务器之间来回飞。我后面要做的 GM 封禁、多设备管理、会话审计,都得跟它的序列化格式搏斗。说白了核心逻辑就百来行,不如自己撸,每一行都知道在干嘛。
二、动手前定下的三件事
1. token 用随机串就行
secrets.token_urlsafe(32) 生成,43 个字符,URL 安全,本身不含任何信息。玩家拿到的就是一张取件码,会话数据全在服务端表里。
两条路线的核心差异列一下:
| 维度 | JWT | Opaque Token + 服务端 session |
|---|---|---|
| 撤销 | 签发即生效,撤销要额外机制 | 改一行状态码,立即失效 |
| 内容泄露 | payload 是 Base64,解开就能看 | 服务端之外没有任何信息 |
| 每请求开销 | 本地验签,快 | 一次主键查询,单机 SQLite 微秒级 |
| 密钥管理 | 密钥泄露 = 全线失守 | 无密钥,随机数够大就行 |
唯一的"牺牲"是每次都要查库。
但对单机 SQLite,这根本不算牺牲:
- 校验走的是
token_hash的唯一索引,按索引查一行; - 实测微秒级,具体数据放第五节。
重点:查库听着吓人,落到单机就是一次索引查询的功夫。
2. 库里存哈希,别存原文
这条我最想单独拎出来说。
直觉写法是会话表直接存 token 原文,校验时字符串比对。
但先想一下最坏情况,SQLite 文件一旦泄露,通常就这几种姿势:
- 备份盘流出去;
- 误把 db 文件提交进仓库;
- 服务器被打穿。
三种情况里,攻击者拿到的是什么?所有玩家的有效登录态。连破解都不用,直接拿去请求接口,等于已经登录了。
所以入库的必须是 sha256(token) 的十六进制串,规矩就两条:
- token 原文只在签发那一刻返回给玩家一次,服务端不落盘;
- 校验时对请求带来的 token 算同样的哈希,再拿哈希查表。
这个习惯可以推广成一句话:
凡是能拿来登录的东西,落库一律哈希。
密码如此,token 如此,API key 也一样。成本几乎为零,收益是把"数据库泄露"从灾难降级成虚惊,攻击者手里只剩一堆不可逆的哈希。
肯定有人要问:存哈希为什么不直接用 bcrypt?密码不都这么存吗?
这里有个容易忽略的判断。密码是低熵的,用户就爱用 123456,攻击者拿字典能跑出结果,所以密码需要 bcrypt 这种慢哈希拖住他。token 不一样,token_urlsafe(32) 有 256 位熵,没有字典可跑,穷举在数学上就不成立,sha256 足够。
用 bcrypt 反而会引入新问题:它一次校验要几十毫秒,我的每个请求都带 token 校验,等于把 CPU 整天泡在哈希里。一句话记住这个对应关系:高熵随机串配快哈希,低熵密码配慢哈希。
3. 过期要两道闸门
WAP 文字游戏的典型行为:玩家开着页面挂机,偶尔操作一下。你别笑,这游戏真能挂一晚上。
如果只做"30 分钟无操作过期",会有个漏洞:理论上一个一直活跃的会话可以永远续命。token 要是泄露了,就是永远有效。
所以过期做两道闸门,各管各的:
- 滑动过期:30 分钟没有任何请求,会话失效。覆盖"挂机忘关页面"的场景;
- 绝对过期:签发后 7 天,不管多活跃一律重登。兜底"会话被长期盗用"的场景。
这两个数不是拍的。30 分钟来自玩家行为观察:这个游戏的正常请求间隔基本在一分钟以内,挂机脚本也带心跳,30 分钟没动静基本可以断定人不在了。7 天参考的是常见会话安全基线,再考虑这个游戏账号本身的资产价值,盗号损失可控,放得再长就是给盗号者续命。
落到实现上就两个字段,分工明确:
| 字段 | 谁在动它 | 规矩 |
|---|---|---|
last_seen_at |
每次请求刷新 | 支撑滑动过期 |
expires_at |
只在签发时定死 | 之后谁也不碰,绝对兜底 |
三、完整代码
3.1 建表
sql
CREATE TABLE IF NOT EXISTS session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id INTEGER NOT NULL REFERENCES player(id),
token_hash TEXT NOT NULL UNIQUE, -- sha256(token),唯一索引
created_at INTEGER NOT NULL, -- 签发时间戳(秒)
last_seen_at INTEGER NOT NULL, -- 滑动过期依据
expires_at INTEGER NOT NULL, -- 绝对过期兜底
device TEXT NOT NULL DEFAULT '', -- UA 摘要,多设备管理用
status TEXT NOT NULL DEFAULT 'active' -- active / revoked
);
CREATE INDEX IF NOT EXISTS idx_session_player ON session(player_id);
两个细节。token_hash 建了 UNIQUE,既是业务约束也是查询主路径,校验走的就是这个索引。吊销不删行,只把 status 改成 revoked,留着做封禁审计。想知道某玩家被封过几次,一条 SELECT 的事。
3.2 签发:登录成功后调用
python
import hashlib
import secrets
import time
from fastapi import Depends, Header, HTTPException
TOKEN_TTL = 7 * 24 * 3600 # 绝对过期:7 天
IDLE_TTL = 30 * 60 # 滑动过期:30 分钟无操作
def _hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def _device_fingerprint(user_agent: str) -> str:
"""UA 摘要即可,不存完整 UA(省空间,也避免存指纹级敏感信息)"""
return hashlib.md5(user_agent.encode()).hexdigest()[:16]
async def create_session(conn, player_id: int, user_agent: str) -> str:
"""登录成功后调用。返回 token 原文------只此一次,服务端不再保存。"""
token = secrets.token_urlsafe(32)
now = int(time.time())
await conn.execute(
"""INSERT INTO session
(player_id, token_hash, created_at, last_seen_at,
expires_at, device, status)
VALUES (?, ?, ?, ?, ?, ?, 'active')""",
(player_id, _hash(token), now, now,
now + TOKEN_TTL, _device_fingerprint(user_agent)),
)
await conn.commit()
return token
3.3 校验:一个 Depends 搞定所有受保护路由
python
async def get_current_player(
authorization: str = Header(default=""),
conn=Depends(get_db),
) -> int:
"""每个需要登录的路由 Depends 这个函数,直接拿到 player_id。"""
if not authorization.startswith("Bearer "):
raise HTTPException(401, "missing token")
token = authorization.removeprefix("Bearer ").strip()
th = _hash(token)
now = int(time.time())
row = await conn.execute_fetchall(
"""SELECT player_id, last_seen_at, expires_at, status
FROM session WHERE token_hash = ?""",
(th,),
)
if not row:
raise HTTPException(401, "invalid token")
player_id, last_seen, expires_at, status = row[0]
if status != "active":
raise HTTPException(401, "session revoked")
if now > expires_at or now - last_seen > IDLE_TTL:
# 过期即吊销(留行做审计),而不是 DELETE
await conn.execute(
"UPDATE session SET status='revoked' WHERE token_hash=?", (th,))
await conn.commit()
raise HTTPException(401, "session expired")
# 滑动续期:只推 last_seen_at,绝对过期时间不动
await conn.execute(
"UPDATE session SET last_seen_at=? WHERE token_hash=?", (now, th))
await conn.commit()
return player_id
挂到路由上就一行:
python
@app.post("/api/market/sell")
async def sell_item(
item_id: int,
price: int,
player_id: int = Depends(get_current_player),
):
# player_id 是服务端查表得来的,不是客户端传的------这就够了
...
重点:player_id 必须从服务端会话反查,客户端传来的只是"主张"。
这里有个坑值得多说一句。player_id 必须从服务端会话里查出来,不能从请求参数收。客户端传来的 player_id 谁都能改;Depends(get_current_player) 返回的是从 token_hash 反查出来的,信任链直接接在会话表上。整个"服务端权威"设计里最小的那块基石,就是这里。
3.4 前端怎么带 token
H5 端用 sessionStorage 存,关标签页即失效,比 localStorage 保守。fetch 封装统一带 header:
javascript
async function api(path, body) {
const token = sessionStorage.getItem("token");
const resp = await fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (resp.status === 401) {
sessionStorage.removeItem("token");
location.href = "/login.html"; // 会话失效,回登录页
throw new Error("unauthorized");
}
return resp.json();
}
传输层顺手定死三条规矩:token 只走 Authorization header,绝不放 URL 参数,URL 会进 access log 也会进 Referer,放 URL 等于到处留底;全站 HTTPS;收到 401 无条件清本地 token 回登录页。
3.5 GM 踢人:一条 UPDATE 的事
封号,连历史会话一起作废:
python
async def ban_player(conn, player_id: int):
await conn.execute(
"UPDATE session SET status='revoked' "
"WHERE player_id=? AND status='active'",
(player_id,))
await conn.commit()
配合玩家表的 banned 标记,登录路由签发前检查一次,被踢玩家手里的 token 立刻变废纸。当初放弃 JWT 图的就是这个能力,一条 SQL。
四、两个踩过的坑
这套方案上线前踩过两个坑,都跟"自作聪明"有关,单独拎出来记一笔。
坑一:日志里打了完整 token。
开发期为了排查方便,我把 Authorization header 原样打进日志。有天翻日志找 bug,满屏都是完整 token,看着看着忽然反应过来:这些日志要保留 30 天,等于躺在磁盘上的一份额外登录态备份,磁盘泄露的爆炸半径直接翻倍。
改法:日志里只打 token 前 6 位,够对上号就行。
教训和第二节其实是同一条的延伸:凭证进了日志,性质就等同于落库存了原文,脱敏这条规矩一处都不能豁免。
坑二:前端给 401 加了"静默重试"。
最早的 fetch 封装里我做过一个"贴心"设计:收到 401 不直接跳登录页,先调一次刷新接口换新 token,重试原请求,想着帮玩家省一次跳转。
结果滑动过期形同虚设:会话永远不会真正失效,总能被续上。这个逻辑要是被脚本利用,等于给挂机发永久通行证。
改法:401 无条件清本地 token,回登录页,前端不做任何会话层的"聪明"补偿。
教训:过期机制的完整性,取决于所有环节都按规矩拒绝。任何一处试图"帮你绕过过期"的补偿逻辑,都是在给滥用者开门。
五、一点实测数据
单机环境(Python 3.12 + aiosqlite,WAL 模式),对带 token 校验的接口压了一轮:
| 指标 | 数值 |
|---|---|
| 单次 token 校验(查表 + 续期) | 平均 ~0.35ms,P99 < 1ms |
| 200 并发下认证开销占总耗时 | < 3% |
| 每晚定时任务吊销的过期会话 | 约几百行/天 |
多说一句,每次校验会 UPDATE 一次 last_seen_at,听着像双倍写放大。但 WAL 模式下这种小事务提交非常便宜,前面 WAL 那篇聊过,实测完全无感。真正要记的是定期清 revoked 的历史行,不然审计数据一直膨胀。我丢了个每晚跑的 DELETE FROM session WHERE status='revoked' AND expires_at < ?,够了。
六、写完回看
1. 选型先问撤销,再问性能。
网上默认推荐 JWT,是因为大多数教程面向多实例、跨服务的架构。你的服务要是单进程小活,JWT 的优势一个都用不上,不可撤销的缺陷倒是实打实落你头上。反过来 opaque token 的"每次查库",实测下来就第五节那几个数,无所谓。先列自己的约束,再看教程的推荐,顺序别反。
2. 凡是能拿来登录的,落库一律哈希。
成本接近零,收益是把最坏情况从灾难变虚惊。日志脱敏也算这条的延伸,坑一里已经交过学费。
3. session 是服务端权威的起点。
客户端持有的只是凭证。会话有没有效、玩家是谁、能干什么,全由服务端说了算。player_id 从会话表反查而不是从请求参数收,这个习惯后来一路贯彻到存档写入和 C12 交易模块。客户端给的任何东西都只是主张,验过才算数。
最后用一句话把这篇收住:选型不是选最先进的方案,是选跟你约束最匹配的方案。
下一篇打算把这套 session 和服务端权威存档串起来,聊聊从登录到存档落库的完整信任链。