放弃 JWT 后,我给 FastAPI 游戏服务端手撸了一套 token session

分类:后端技术文 | 系列文第 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 和服务端权威存档串起来,聊聊从登录到存档落库的完整信任链。

相关推荐
用户842981424181044 分钟前
从 JavaScript 到可运行的字节码 - ByteJS 使用指南
javascript
学以智用2 小时前
wangEditor V5 精简完整参考手册
javascript
用户298698530142 小时前
React 前端实现 Excel 与 CSV 互转:基于 WebAssembly 的浏览器端方案
javascript·react.js·excel
1-1=018 小时前
Vue 3 模板语
前端·javascript·vue.js
风卿18 小时前
看着AI写代码太孤独了,来欣赏星云吧:一个DSH 装饰插件
前端·javascript
牧艺18 小时前
弹层不用再手算坐标了:CSS Anchor Positioning 从原理到可上线
前端·javascript·css
愚坤20 小时前
前端真有意思,JS还能独立做项目?
前端·javascript·github
李纲明21 小时前
微信小程序 getSystemInfo 提示已废弃、屏幕高度算错胶囊还对不齐?getWindowInfo 和 getDeviceInfo 怎么接才稳
javascript·微信小程序·小程序·小程序开发·devdiy
FungLeo1 天前
成为全栈·Vue 管理后台篇·用 fetch 建一层可控的请求内核
前端·javascript·vue.js·http·fetch·formdata·接口封装