fix(portal): 傳播空窗期不再銷毀 session——讀不到 ≠ 這個人不存在(arcrun-rag#66)
leo 2026-08-10 在 youlin stage 改完密碼被登出,之後怎麼登都回不去。 病灶是「這一瞬間讀不到」被當成「這個人不存在」,而且做的是**不可逆**的動作。 認證的家是 CF Workers Secret,改它會產生 worker 新版本,既有 isolate 讀到的 還是舊 env(#55 實測 ≥15 秒)。舊的 requirePortalUser 在那個空窗裡直接把 session 從 KV 刪掉——不是擋下讓你重試,是當場銷毀,等 secret 鋪開也回不來。 #55 補的「讀不到就再問一次加速器」只加在登入路徑(findAndVerifyUser),這道門沒有。 三處修法(缺一不可): 1. requirePortalUser 先問一次加速器再判定(與登入路徑同一招、同一支函式) 2. **永不因「讀不到」刪 session**;仍讀不到且正在傳播空窗 → 回 503 `auth_store_propagating` 而不是 401(讀得到 record 的「已停用」照舊刪,那是確定的事實) 3. 前端 boot() 從「任何非 2xx 都 dropSession」收斂成**只有 401 才算被登出** ——後端不刪、前端卻自己丟掉 localStorage 的 token,症狀一模一樣 順帶修掉同一族的一個資料遺失路徑:mutateAuthStore 舊版拿「可能是舊版 env」當底稿做 read-modify-write,而 writeAuthStore 會重切分片並刪掉多出來的舊分片 ⇒ 底稿若是 「某帳號被建立之前」的版本,那個帳號會在這次寫入中被抹掉且無法還原(secret 是唯一真相源)。 改成先問加速器、再把 env 版與 overlay 版取聯集當底稿;刪除仍有效(fn() 在聯集之後才跑)。 驗證:tsc 與 baseline 同為 23 個既有錯誤(零新增);tests/portal-auth.test.ts 27/27 綠。 stage 實測見交付回報。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
This commit is contained in:
@@ -33,6 +33,7 @@ import { storeCredential, hasCredential } from './credentials';
|
||||
// KBDB 只保留為「舊實例的既有帳號」回退讀路徑,且讀到就順手搬進新家(見 promoteLegacyUser)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreRecentlyWritten,
|
||||
authStoreStatus,
|
||||
findAuthUserByEmail,
|
||||
findAuthUserById,
|
||||
@@ -413,6 +414,23 @@ export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Resp
|
||||
/**
|
||||
* portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。
|
||||
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。
|
||||
*
|
||||
* 🔴 `Leo/arcrun-rag#66`(2026-08-10,leo 本人被鎖在 stage 外面的那條):
|
||||
* **「這一瞬間讀不到」不可以觸發不可逆的動作。**
|
||||
* 認證的家是 CF Workers Secret,改它(改密碼/建帳號/停用)會產生 worker 新版本,
|
||||
* **既有 isolate 讀到的還是舊 env**(`#55` 實測 ≥15 秒)。舊版在那個空窗裡:
|
||||
* ① `getRecordById` 讀不到 → ② **直接把 session 從 KV 刪掉** → ③ 回 401
|
||||
* ⇒ 帶著一個**完全有效的 token**,登入狀態被當場銷毀,等 secret 鋪開也回不來。
|
||||
* `#55` 補的「讀不到就再問一次加速器」只加在登入路徑(`findAndVerifyUser`),這道門沒有。
|
||||
*
|
||||
* 三段修法(缺一不可):
|
||||
* 1. **先問一次加速器再判定**——與登入路徑同一招,同一支 `hydrateFromAccelerator`。
|
||||
* 2. **永不因「讀不到」刪 session**。刪是 best-effort 清潔工,而它清掉的是使用者唯一的
|
||||
* 憑據;KV 的 TTL 本來就會回收,這件事沒有非做不可的理由。
|
||||
* 3. 仍然讀不到且**正在傳播空窗**(加速器 key 還在)→ 回 **503 `auth_store_propagating`**,
|
||||
* 不是 401。理由在前端:portal 的 `guard401()` 一看到 401 就清 localStorage 踢回登入頁
|
||||
* ⇒ 就算 KV 那筆還在,使用者手上的 token 也被自己的瀏覽器丟掉了。
|
||||
* **後端不刪、前端不丟,這件事才算真的修好。**
|
||||
*/
|
||||
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
|
||||
const token = bearerToken(c);
|
||||
@@ -426,15 +444,33 @@ export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Pro
|
||||
/* fallthrough */
|
||||
}
|
||||
if (!recordId) {
|
||||
// 這一筆 session 的內容本身壞掉=確定的事實(不是讀不到),刪它是對的。
|
||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
||||
}
|
||||
const rec = await getRecordById(c.env, recordId);
|
||||
let rec = await getRecordById(c.env, recordId);
|
||||
if (!rec && (await hydrateFromAccelerator(c.env))) {
|
||||
rec = await getRecordById(c.env, recordId); // ①:與登入路徑同一招,再問一次加速器
|
||||
}
|
||||
if (!rec) {
|
||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
// ③:分辨「傳播空窗」與「帳號真的沒了」——前者不可以把人踢出去。
|
||||
if (await authStoreRecentlyWritten(c.env)) {
|
||||
return {
|
||||
ok: false,
|
||||
res: c.json(
|
||||
{
|
||||
error: '認證資料正在更新中(Cloudflare 正在鋪開新版本),請稍候幾秒再試——你並沒有被登出。',
|
||||
code: 'auth_store_propagating',
|
||||
},
|
||||
503,
|
||||
),
|
||||
};
|
||||
}
|
||||
// ②:查無此帳號(可能真的被刪了)→ 擋下即可,**不刪 session**(KV TTL 自己會回收)。
|
||||
return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
|
||||
}
|
||||
if ((rec.values.status ?? '') !== 'active') {
|
||||
// 讀得到 record = 確定的事實,停用要即時生效,刪 session 是對的。
|
||||
await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
return { ok: false, res: c.json({ error: '帳號已停用' }, 403) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user