「JWT 没法撤销」几乎是个共识,绕法通常有两种:要么把 token 做得很短,拿体验换安全;要么引入一张黑名单,每个请求多查一次 Redis。
我们最后选的是第三种,而且它几乎不要钱:把会话状态的检查,塞进每个已认证请求本来就要做的那次数据库读里。
前提是——这个前提其实很多系统都成立——你的鉴权本来就不是「解开 JWT 就放行」。只要你还要查一次「这个人现在还是不是这个工作空间的成员」,那么在同一个数据库连接、同一次往返里多做一次主键查询,代价接近于零,而换来的是撤销在下一个请求就生效。
这篇把这个设计完整讲一遍:token 里多带的那一个 claim、每个请求实际发生的五到六次读、升级时怎么做到不把所有人踢下线,以及刷新令牌轮换在实现上和文档上差了哪一截。
一个自托管部署里,「登录态多久过期」不是一个数字,是两个可调旋钮 + 六个写死的期限 + 一条撤销路径。先把它们摆在一起:
什么东西 | 多久 | 可配吗 | 到期之后会发生什么 |
|---|---|---|---|
access token | 30 分钟 | ✅ | 前端静默换一张,用户无感 |
会话(refresh token) | 14 天 | ✅ | 必须重新输密码 |
二次验证挑战 | 5 分钟 | ❌ 写死 | 重新走一遍密码 |
密码重置链接 | 30 分钟 | ❌ 写死 | 重新申请一封 |
邮箱验证链接 | 24 小时 | ❌ 写死 | 重新申请一封 |
工作空间邀请 | 14 天 | ❌ 写死 | 邀请人重发 |
API key | 1–365 天,创建时必填 | 每把 key 各自指定 | 调用直接 401 |
撤销一台设备 | —— | —— | 那台设备的下一个请求就 401 |
两个可调的里,只有第二个是「会话长度」。第一个不是——尽管我们自己的文档到今天还在按「会话长度」的口径解释它(第十三节 ③)。
2026-08-06,提交 69be399,标题是 fix(deploy): default the access token lifetime to one workday。提交信息写得很直白:
Access tokens expired after a fixed 30 minutes and there is no refresh
flow, so self-hosted users were silently logged out mid-session.没有刷新流程的时候,access token 就是登录态。它过期,人就掉线;而且掉得很难看——不是弹一个「登录已过期」,是下一次点击突然 401。于是这个提交把默认值改成 480 分钟,一个工作日,并把它透传进两个 compose profile。
这个改法在当时是对的,但代价在提交信息里也写了:每一次登录都变成一张 8 小时之内谁也撤不回的通行证。 一张已经签发的 JWT,服务端既不知道它存在、也没有地方能把它作废——你唯一能做的是等它过期,或者换掉签名密钥把所有人一起踢掉。
对一个「自托管的企业内部平台」来说,这个代价不小:员工离职、设备丢失、某个人在客户现场的笔记本上登了一次——这些场景里你想做的都是「把那一个登录态掐掉」,而当时的系统对此只有一个回答:等 8 小时。
2026-08-30,提交 09dbf7e,标题是 feat(identity): make a sign-out mean something, and stop logging people out,38 个文件、+1439/-56。它做的事按提交信息分成四段,我按重要性重排一下:
sid claim 指向它;第 1、2 条一落地,ACCESS_TOKEN_EXPIRE_MINUTES 的含义就变了。它不再决定「用户多久被踢一次」——那件事现在由 REFRESH_TOKEN_EXPIRE_DAYS 和刷新流程决定;它现在只决定一件事:一个已经被撤销的登录态还能苟多久。所以它回到了 30 分钟,提交信息里写得很清楚:
Access tokens drop to 30 minutes because they no longer bound the session --
they bound how long a revoked session keeps working.同一个数字,两次改动,两个含义。 这是我写这篇的原因:如果你在自己的部署里看见 ACCESS_TOKEN_EXPIRE_MINUTES,先搞清楚你的系统属于哪一种,再决定调大还是调小。
(这句提交信息本身现在也需要打个折——见第十三节 ③,撤销在带 sid 的 token 上其实是立刻生效的,这句话描述的是没有 sid 的那部分旧 token。)
「JWT 不能撤销」这个说法,准确的版本是「只验签的 JWT 不能撤销」。一旦你在请求路径上引入任何一次服务端查询,撤销就回来了——代价是那次查询。
我们的做法是不新增这次查询,而是搭车。看这段注释(server/app/modules/identity/infra/workspace_access.py:30–37):
When the caller's token names a session, that session must still be
live. It is checked here rather than in a separate lookup because this
is already the one database read every authenticated request makes, and
checking it at refresh time alone would leave a signed-out token working
until it expired.关键前提是:我们的鉴权本来就不是「解开 JWT 就放行」。 每个已认证请求都要回答「这个人现在还是不是这个租户/工作空间的成员、他的限额是多少」,这件事必须读库。既然连接已经开了、事务已经在了,那么多做一次主键查询的边际成本接近于零。
实际发生的读,按 DatabaseWorkspaceAccessResolver.resolve 的顺序数(workspace_access.py:23–97):
第几次 | 读什么 | 拿它做什么 |
|---|---|---|
1 |
| 会话还活着吗(只在 token 带 |
2 | 租户成员关系 | 还是这个租户的人吗 |
3 | 工作空间成员关系 | 还是这个空间的人吗 |
4 | 租户 | 取租户级限额 |
5 | 工作空间 | 取空间级限额(覆盖租户级) |
6 | 二次验证登记(可选) | 空间要求 MFA 时,此人登记过吗 |
会话检查是这串里的第一次,而且是主键等值查询。换来的是:撤销一台设备之后,那台设备的下一个请求直接 401——不用等 30 分钟,也不用维护一张黑名单。
顺带一提第 6 条那个分支很值得抄:工作空间开了「必须二次验证」而此人没登记时,它抛的不是「你不是成员」,而是一个带 reason: mfa_required 的 403(workspace_access.py:63–72)。这两件事长得像,但前端能做的事完全不同——前者只能显示「无权访问」,后者可以直接把人引到登记页面。
改鉴权最怕的就是上线那一刻全员掉线。这里的处理很简单,值得记一笔:sid 是可选 claim,没有 sid 的 token 一路放行,直到它自己过期。
create_access_token 的文档字符串里就写着这条(server/app/kernel/identity/auth.py:53–58):
session_id: Session this token belongs to. Naming it lets a
sign-out end the access it granted; a token without one is
accepted until it expires, so upgrading does not sign
everybody out._require_live_session 那一侧也只在 session_id 非空时才被调用(workspace_access.py:45–46、:100–107)。于是升级窗口是自然收敛的:旧 token 最多再活 ACCESS_TOKEN_EXPIRE_MINUTES,之后客户端拿 refresh token 换一张新的——新的那张带 sid,从此进入新世界。没有人被踢,也没有人永远停在旧规则里。
这个设计有个前提:升级那一刻用户手上得有 refresh token。8-30 之前没有刷新流程,所以实际情况是所有人在 8 小时内会各自重新登录一次,然后自然获得会话。代价可控。
这是最容易想当然的一条。会话的 expires_at 在签发时算一次(service.py:405–429):
expires_at=utc_now() + timedelta(days=self._refresh_token_days()),然后我把整个 identity 模块里写 expires_at 的地方都数了一遍:会话这条路上 只有 _issue_session 这一处在写,其余全是读。刷新不延长它。 refresh_session 在轮换 token 的同时更新的是 last_seen_at、workspace_id、user_agent、ip_address 四个字段(service.py:490–498),唯独不碰 expires_at。
所以 REFRESH_TOKEN_EXPIRE_DAYS=14 的准确含义是:从这次输密码算起 14 天,无论期间多活跃,到点必须重新输密码。 不是「14 天不活动才过期」。
这是一个取舍,而且我认为选得对:滑动窗口意味着一台一直开着的机器可以永远不再证明「你还是你」,而绝对上限给了整个系统一个上界——任何一个登录态,最长活 14 天。 代价是活跃用户也会被打断,而打断的时机完全不可预测(取决于两周前的某个下午几点登的)。
想要更短的上界就调小它;想要「每周一重新登录一次」这种节奏,现在没有直接的表达方式(第十三节 ⑥)。
会话行里存的不是 refresh token,是它的 SHA-256(service.py:400–402、:424),和 API key 用的是同一套规矩。明文只在签发的那一次交给客户端,之后服务端再也拿不出来——库被拖走也不能拿来当登录用,这是模型注释里写明的目的(models.py:365–367)。
每次刷新都会换一个新的(service.py:490–491):旧的哈希被覆盖,于是旧 token 立刻失配。这意味着一条很有用的性质——同一个 refresh token 只能用一次。
不过关于「用第二次会发生什么」,文档、注释、提交信息和测试名说的是一回事,代码做的是另一回事。这条留到第十三节 ④ 说,因为它是我这次查出来最值钱的一处。
服务端做完一半,另一半在浏览器里。web/app/utils/request.ts 里有三段东西,合起来就是「静默续期」:
① 401 之后重放一次(:206–230):
async function retryWithRefreshedToken(error: any): Promise<any | null> {
const config = error?.config as (...)
if (!config || error?.response?.status !== 401) return null
if (config.skipAuthRefresh || config._retriedAfterRefresh) return null
if (!storedRefreshToken()) return null
const token = await refreshAccessToken()
if (!token) return null
config._retriedAfterRefresh = true
config.headers = { ...(config.headers || {}), Authorization: `Bearer ${token}` }
return request.request(config)
}两道防回环:刷新请求自己带 skipAuthRefresh,重放过的请求带 _retriedAfterRefresh。
② 同一时刻只刷新一次(:145–183)。这段的注释写清了为什么必须这么做:
A page loads a dozen requests at once, and an expired token fails all of
them together. Without this, each failure would spend the same refresh
token, and every attempt after the first would look like a replay一个页面同时发十几个请求,token 过期会让它们一起失败。如果每个失败都各自去刷新,它们会拿着同一个 refresh token 去换十几次,而轮换的语义决定了只有第一次能成。所以这里用一个模块级的 refreshInFlight 把它们收敛成一次。
③ 实在换不回来就跳登录页(:186–203)。AUTO_REDIRECT_UNAUTHORIZED 常量写死为 true(:20),401 之后延时一秒清掉本地存储并跳 /sign-in,且把当前路由塞进 redirect 参数。
把这三段和第三节的服务端检查接起来,就能还原「在控制台里点掉一台设备」之后那台设备上发生的事:下一个请求 401(会话已撤销)→ 拦截器去刷新 → 刷新也被拒(refresh_session 里 _session_is_live 不通过)→ 返回 null → 401 冒出来 →清本地存储、跳登录页。整个过程一个请求周期,不需要等任何 token 过期。
顺带把取舍摆明:两个 token 都存在 localStorage 里(auth-session.ts:1–30)。好处是刷新页面不掉线、实现简单;代价是一次 XSS 等于拿走一个最长 14 天的会话。换成 HttpOnly Cookie 会挡住这条,但要引入 CSRF 防护和跨站部署的一堆麻烦事。我们选了前者,这是个应该被明写出来的选择,而不是一个默认。
这一点对 Agent 运行时特别重要,所以单开一节。
控制台跑一次 agent、跟一条工作流,走的都是 SSE(@microsoft/fetch-event-source)。而这条路不经过上面那个 axios 拦截器。看 request.ts:430–470:连接建立时调用 buildAuthHeaders() 取一次 Authorization,之后这个头就固定在这条连接上;onerror 里的处理是记下错误然后 throw(:479–487),而在 fetch-event-source 里,onerror 抛异常等于放弃重试。
三个后果,方向不一样,都值得知道:
第三条我认为是个真问题。绕法很直白:开流之前先发一个便宜的普通请求把 token 续上。
按威胁模型给一张表,比给一个「推荐值」有用:
你担心的事 | 调哪个 | 调成什么 | 代价 |
|---|---|---|---|
设备丢了要立刻断 | 不用调 | 在控制台点掉那台设备 | 无,下一个请求就断 |
没人来点那个按钮 |
| 5–15 分钟 | 刷新变密,每次刷新写一次库 |
合规要求「N 天必须重新认证」 |
| N | 到点打断,且时机不可预测 |
共享设备 / 公共终端 |
| 1 | 每天都要重新登录 |
服务器很忙,想少查库 | 调大 | —— | ⚠ 没用:会话检查是每个请求做的,不是每次刷新做的 |
最后一行是最容易搞错的:把 access token 调长不会减少数据库读,因为每个请求都要读成员关系,会话检查是搭在那次读上的。调长它唯一的效果是让旧 token(升级前签发、不带 sid 的那些)活得更久——那是你不想要的方向。
另外两条实务:
SECRET_KEY 必须换,而且生产会拦你。 长度 < 32 或等于两个已知占位值时,validate_runtime_requirements() 在启动时就失败(settings.py:550–555)。这是启动即失败,不是运行时告警。SYSTEM_MAIL_ENABLED=false)。这条和会话长度直接相关:14 天一到必须重新输密码,而密码忘了的唯一出路是重置邮件。邮件没开时,bootstrap_admin.py 遇到已存在的邮箱只会打印 User already exists. Skipping bootstrap. 然后退出(:30–33)——产品内没有第二条路。如果你要长期自托管,要么开邮件,要么接受「忘密码 = 改库」。这些全部写死在 service.py:91–103 那一段常量里,和部署无关:
MFA_CHALLENGE_PURPOSE = "mfa_challenge"
MFA_CHALLENGE_MINUTES = 5
PASSWORD_RESET_MINUTES = 30
EMAIL_VERIFICATION_MINUTES = 60 * 24
INVITATION_DAYS = 14其中二次验证挑战那个设计值得说一句。密码验过、还差第二因子的那个中间状态,用的是一个无状态 JWT,带一个 purpose: mfa_challenge claim(service.py:1131–1143)。它的意义在于:这张票不能当 access token 用——鉴权入口处显式拒绝任何带 purpose 的 token(context_resolver.py:88–92):
# A token minted for one step of sign-in authorizes nothing. Without
# this, presenting the challenge token as a bearer would make the
# second factor optional for anyone who noticed.
if payload.get("purpose"):
raise UnauthorizedError("Token cannot be used to authorize a request")「登录中间态的凭据不能当登录凭据用」这条,是自己搓 MFA 时很容易漏掉的一格。它的代价是这张票不可撤销(无状态,没地方标记用过),5 分钟内一直有效——这条我放在第十三节 ⑨。
API key 是另一条完全独立的路:它不走会话,鉴权时单独查 key 行、单独判过期、单独取 scope(context_resolver.py:145–175)。而且期限是创建时必填的,范围 1–365 天(schemas.py:408–412),没有「永不过期」这个选项。对自动化调用方来说这多少有点烦,但「长期凭据必须被重新签发」是个我认可的默认。
SECRET_KEY 已经不再等于全员登出这条是运维口径,有会话表之前和之后完全不一样,写给要做密钥轮换的人。
以前:所有 access token 都用 SECRET_KEY 签名,换掉它 = 所有 token 验签失败 = 全员掉线。这是当时唯一的「全员登出」手段。
现在:换掉它之后,所有 access token 确实一起失效;但客户端拿到 401 会去刷新,而 refresh_session 根本不看 access token——它只拿 refresh token 查库(service.py:460–468)。refresh token 是随机串、存的是哈希,跟签名密钥毫无关系。于是客户端立刻换回一张用新密钥签的 access token,用户什么都感觉不到。
好消息是密钥轮换不再是一次全员打断;坏消息是你不能再拿它当紧急踢人的手段。而真正的「把所有人踢下线」这个动作,系统里现在没有——revoke_all_sessions 只作用于调用者自己的会话(service.py:536–551),路由也只有 /me/sessions 那三个(router.py:257–277)。管理员想把某个离职员工踢下线,目前得走注销账号(execute_account_deletion 会结束他的全部会话,service.py:640–660),或者把他从租户里移除(成员关系是每个请求都查的,移除之后下一个请求就 403),再或者直接改库。
会话表落地之后,控制台的安全面板能列出「当前登录的设备」:每一行有 user agent、IP、最后活动时间,可以单独结束;当前这一台不给结束按钮(提交信息原话:signing yourself out of the page you are on is a trap)。「退出其它所有设备」默认保留当前这台(settings.tsx:309–317 调的是 revokeAllSessions(true))。
成员列表里的「最近活跃」也是从这里来的:last_seen_for_users 对每个用户取所有会话 last_seen_at 的最大值(repository.py:468–483)。注意它的精度——last_seen_at 只在刷新时更新,也就是说它的分辨率大约是一个 access token 的寿命(30 分钟),不是「上一次点击」。
看不见的东西也列一下,免得误会:已经结束的会话不在列表里(list_by_user 默认 include_ended=False,而 include_ended=True 全仓库没有一个调用点),所以这个面板是「当前在线设备」,不是「登录历史」。登录本身也没有审计事件——审计表里有 identity.session.revoked,没有任何对应「有人登录了」的事件类型。
按惯例,这一节讲我们自己的问题。
① 控制台里那个「Session timeout」下拉是假的。 web/app/console/routes/settings.tsx:1105–1116 有一个 12 小时 / 24 小时 / 7 天的下拉框,defaultValue="12 hours",没有 onChange、没有保存、不读后端。旁边的注释是诚实的:
BACKEND-PENDING: session lifetime is an instance setting
(ACCESS_TOKEN_EXPIRE_MINUTES), not a workspace one. Not built
rather than withheld: it needs a per-workspace override the
token issuer would have to read.但更糟的是三个选项跟真实的两个旋钮一个都对不上:真实值是 30 分钟和 14 天,下拉里是 12 小时、24 小时、7 天。一个管理员看到这个界面,会合理地以为自己的会话是 12 小时。影响:口径误导。绕法:改 .env。这条我打算提一个 issue,写这篇时还没提,所以正文里不挂链接。
② 账号菜单里的「Log out」只清浏览器,不撤销会话。 两个入口(web/app/components/common/nav-user.tsx:31–36、web/app/console/shell/icon-rail.tsx:81–86)做的事完全一样:清查询缓存、清 user store、清 localStorage、跳 /sign-in。没有任何一处调用 revokeSession 或 /me/sessions/revoke-all。
于是:你点了退出,服务端那条会话仍然是 active,仍然会出现在安全面板的设备列表里,它的 refresh token 仍然有效直到 14 天后——只不过那串 token 刚被从这台浏览器的 localStorage 里删掉了。影响:「退出」这个词在两个地方意思不一样——安全面板里的 End 是真撤销,账号菜单里的 Log out 不是。绕法:用安全面板那个按钮。这条也打算提 issue,而且我认为改起来是几行的事(退出时先发一次 revoke,失败也照样往下走)。
③ 四处文档/注释仍在按旧语义解释撤销延迟。 这四处写的是同一句话:撤销之后,已签发的 access token 会继续工作到过期。
server/app/settings/settings.py:73–78.env.example:15–18server/.env.example:33–36docs/deployment/production-profile.md:46–50(原话:an access token already issued keeps working until it expires, even after the session behind it was ended)而第三节那段代码就是为了让这句话不再成立而写的。 带 sid 的 token 在会话被撤销后,下一个请求就 401。这四处说明写于会话表落地之前或与之同时,没有跟着改。影响:读文档的人会为了缩短「撤销延迟」去把 access token 调到很短,而那个延迟其实已经是零了。绕法:以代码为准。这条也打算提 issue。
④ 「用过的 refresh token 会终结会话」没有实现,而且测试名字就叫这个。 这是我这次查出来最值钱的一处,拆开说。
文档、注释、提交信息三处都承诺了同一件事:
service.py:454–457:presenting a rotated-out token ends the sessiondocs/deployment/production-profile.md:52–55:presenting a spent one ends the session, so a stolen token is usable at most until the real client next renews09dbf7e 的信息:A spent one is a replay, and the answer to a replay is to end the session实际代码(service.py:462–468)是这样的:
session = self.session_repo.get_by_refresh_hash(
self._hash_refresh_token(refresh_token)
)
if session is None:
raise UnauthorizedError("Invalid refresh token")轮换之后,旧 token 的哈希已经被覆盖掉了,库里根本没有一行认识它。所以拿旧 token 来换,得到的是「查不到 → 401」,会话完好无损,也没有任何地方记下「有人重放了一次」。
测试文件把这件事写得更直白(server/tests/unit/test_user_sessions.py:65–77):函数名叫 test_replaying_a_rotated_token_ends_the_session,断言却是 assert len(service.session_repo.list_by_user(user.id)) == 1,配一条注释「the live session is untouched by a failed replay of the old one」。测试名字和测试内容互相矛盾,而测试内容是对的。
影响:被偷走一个 refresh token 时,先用的一方赢。攻击者先刷新,就拿到新 token、会话继续归他;真正的客户端下一次刷新失败,看到的是一次 401,然后跳登录页重新登录——他会以为是自己「掉线了」。系统不会告诉任何人发生过重放。绕法:真要检测重放,得另存一份「上一代哈希」,或者按会话记一个单调计数器。这条也打算提 issue,而且我认为它比 ① ② 都严重,因为已经写进文档的安全承诺是会被别人当作事实引用的。
⑤ expired 这个状态从来没有被写进去过,而且会话行只增不减。 模型里写着 status 的三个取值是 active / revoked / expired(models.py:385–386),但全仓库没有任何一处把它设成 "expired"——过期完全是读时判定(_session_is_live、_require_live_session 各判一次)。
与之配套的是 ix_user_sessions_expiry 这个 (status, expires_at) 复合索引(models.py:373):这是给一个清扫任务准备的索引,而那个清扫任务不存在。 没有任何 worker 或脚本删除、归档过期会话行。影响:user_sessions 表随登录次数单调增长,一个用了一年的实例会积一堆 14 天前就死掉的行。绕法:自己写一条定时 SQL 删掉早已过期的行,语义上是安全的(那些行没有任何读者)。
⑥ 没有空闲超时,也没有「每周一重新登录」这种表达。 last_seen_at 被记下来了(刷新时更新),但不参与任何判定——它只用于排序和成员列表的「最近活跃」。所以「30 天没用过的登录态自动失效」这条常见合规要求,现在只能靠把 REFRESH_TOKEN_EXPIRE_DAYS 调小来近似,而那是绝对上限、不是空闲窗口,两者对活跃用户的影响完全不同。影响:有这类要求的部署没法精确表达。绕法:调小绝对上限,或者定时把 last_seen_at 过旧的行改成 revoked。
⑦ 改密码不结束其它会话,重置密码会。 两条路的处理不一样:
complete_password_reset(service.py:808–821):改完密码遍历该用户的所有会话全部结束,注释写了理由——重置是「我怀疑账号被入侵」时做的动作。change_password(service.py:1517–1528):校验旧密码、写新哈希、结束。一条会话都不动。影响:一个人因为「我怀疑有人看到了我的密码」而在设置里改密码,改完之后那个人的登录态还活着——而他大概率以为改密码把别人踢掉了。绕法:改完密码顺手点一次「退出其它所有设备」。
⑧ 登录没有审计事件,登出有。 审计表里有 identity.session.revoked(service.py:553–575)、identity.mfa.changed、identity.account.closed 等,没有任何一个事件类型对应「有人登录了」或「开了一条会话」。对一个把治理当卖点的平台,这条不对称挺显眼:你能查到谁把谁踢了,查不到谁在什么时候从哪个 IP 登进来过。会话表里其实有这些信息(created_at / ip_address / user_agent),只是它不进审计流。影响:登录行为进不了审计检索与导出。绕法:直接查 user_sessions 表。
⑨ /login 和 /refresh 没有任何速率限制。 应用里注册的中间件只有四个(tracing / 错误处理 / 响应封装 / request id)加一个 CORS(main.py:274–292),没有限流中间件;生产用的 Caddyfile 一共 28 行,里面没有 limit_req。系统里叫 rate_limit 的那些配置全部是 LLM 与工具调用的配额,和登录无关。
同一条里还有一个相关事实:二次验证的挑战票是 5 分钟的无状态 JWT,没有「用过即废」标记;TOTP 码本身也不防重放(totp.py:80–86 只比对 ±drift 步内的候选值,不记录用过的步数)。影响:暴力破解与验证码重放的防护,在我们这套默认栈里 完全落在部署方的反向代理上,而文档没有说这件事。绕法:在网关上对 /api/v1/login、/api/v1/refresh、/api/v1/login/mfa 加限流。
⑩ 多标签页刷新竞争可能把人踢到登录页。这条是推论,不是观测。 第七节那个单飞锁是模块级变量(request.ts:153),作用域是一个标签页。开两个控制台标签页、都放到 token 过期、然后同时操作,两边会各自拿 localStorage 里同一个 refresh token 去换:先到的换成功并写回新 token,后到的拿到 401,refreshAccessToken 返回 null,于是 401 冒出去,AUTO_REDIRECT_UNAUTHORIZED 把那个标签页送去登录页。
我没有构造出这个场景,所以它是读代码得到的推论。 之所以还是写进来,是因为它正好是 ④ 那条「用过的 token 会终结会话」如果被真的实现出来会放大的问题——那时输掉竞争的就不只是一个标签页,而是整条会话。绕法(如果它真的会发生):把刷新结果通过 storage 事件或 BroadcastChannel 在标签页之间共享。
fb46f20。我没有起一套环境去验证「撤销之后下一个请求就 401」这件事,尽管我认为代码路径是清楚的。第十三节 ⑩ 更是明确标注为推论。在有服务端会话记录之前, ACCESS_TOKEN_EXPIRE_MINUTES 是你的会话长度,调大调小都疼;有了之后,它只是撤销延迟的上限,越小越好,而会话长度交给另一个数字。 决定它属于哪一种的不是这个配置项本身,而是你的鉴权路径上有没有那一次查询——如果你的系统本来就要读一次成员关系,那这次查询你其实已经付过钱了。
仓库在 github.com/soit-ai/soit。这篇里的每一条你都能自己核:
git show 69be399 和 git show 09dbf7e 是那两次改动,提交信息比我这篇短且准;server/app/modules/identity/infra/workspace_access.py,连同下面的 resolve 一起读;server/tests/unit/test_user_sessions.py,函数名和断言就隔三行。如果你发现我哪一条说错了——尤其是第十三节那十条里,有没有哪条是我读漏了实现——欢迎直接开 issue 打脸。比起被夸「设计得挺清楚」,我更需要知道哪里对不上。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。