c4cee35adb
leo 2026-08-10 下令:「登入認證資料要分離⋯⋯就算只有我一個人存在單獨的 json 檔也好,
它不能被改資料庫的連結導致無法登入。」「昨天不能登入 portal,今天不能登入 mcp,
這根本就是一個問題。」
病:portal 帳號住 KBDB(owner_id = {CONSOLE_TENANT}::portal),console 管理員帳密住
SESSIONS_KV。兩者都靠 binding 指過去,重裝/遷移一定會被重新指一次 ⇒ 保險箱的鑰匙
放在保險箱裡。2026-08-09 leo 資料一個位元組都沒動,卻被鎖在門外。
修:認證搬到 CF Workers per-script Secrets(掛在 script 上,與 bindings 兩套資源,
重部不會洗掉;journeys/gemini-key-lost-on-reinstall.md 與 installer worker.js:1148 皆有實證)。
- 新增 lib/portal-auth-store.ts:自足的 JSON,讀取零網路呼叫,>4.6KB 自動溢位分片
- portal.ts 的帳號讀寫全部改走它;KBDB 只留為舊實例的回退讀路徑,登入成功順手搬過去
- console-auth.ts 的第二份認證資料同樣搬離 KV
- 不牴觸 D38:KBDB 三張核心表不增不減,本案是把東西搬出去
- 沿用 credentials.ts 既有的 putWorkerSecret/deleteWorkerSecret,不另造第二套寫入路徑(D36)
明顯失敗(把 #10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
- 「這台實例讀不到任何登入資料」回 503 + code=auth_store_empty,且**不計入 5 次鎖定**
(08-09 leo 就是被系統自己的誤判鎖了 15 分鐘)
- /console/setup 遇既有帳號改說「你剛才輸入的密碼沒有被採用」,不再只說「已設定過」
- /health 與 /console/auth-status 吐 auth_store 狀態(住哪、寫不寫得進去)
stage 實測撞到並修掉的坑:改 secret 會產生 worker 新版本,既有 isolate 讀到的還是舊 env
⇒ 「建好帳號立刻登入」有 15 秒以上 401,還被算進鎖定。加一層短 TTL 的 KV 加速器
(非真相源,只在 secret 查不到/密碼對不上時問一次),換 KV 不影響不變量。
驗收:stage(youlin)把知識資料庫換成另一顆空的 + 換租戶代號 + SESSIONS_KV 換成空的,
三樣一起換之後 portal / MCP /authorize / console 三條登入路徑仍全綠(複跑 3 次)。
對照組(舊版程式碼同樣換庫):登入回「email 或密碼錯誤」,5 次後鎖 15 分鐘。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
102 lines
4.4 KiB
TypeScript
102 lines
4.4 KiB
TypeScript
/**
|
|
* console-auth.ts —— D61 舊實例相容(帳密只在舊 SESSIONS_KV,尚未搬遷過)
|
|
*
|
|
* 拆成獨立檔案的理由:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
|
* 一旦某個測試讓 console 帳密的認證儲存寫入成功,overlay.console 就會在**同一支測試檔案**
|
|
* 剩下的測試裡持續存在(不同檔案=不同 worker 執行個體,互不污染,已用小型探針驗證過)。
|
|
* tests/console-auth.test.ts 一開始就會走一次「首次設定成功」,之後整支檔案都是「已設定」
|
|
* 的世界;「認證儲存還是空的、帳密只活在舊 KV」這個起始狀態只有在全新檔案才測得出來。
|
|
*/
|
|
import { SELF, env, fetchMock } from 'cloudflare:test';
|
|
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
|
|
|
const CF_API = 'https://api.cloudflare.com';
|
|
const CREDS_KEY = 'console:credentials';
|
|
|
|
beforeAll(() => {
|
|
fetchMock.activate();
|
|
fetchMock.disableNetConnect();
|
|
});
|
|
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
|
|
|
function json(method: string, path: string, body?: unknown) {
|
|
return SELF.fetch(`http://localhost${path}`, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function mockAuthStoreWrite(times = 1): { puts: () => Array<{ name: string; text: string }> } {
|
|
const captured: Array<{ name: string; text: string }> = [];
|
|
fetchMock
|
|
.get(CF_API)
|
|
.intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
|
|
.reply(200, (opts) => {
|
|
const body = JSON.parse(String(opts.body)) as { name: string; text: string };
|
|
captured.push(body);
|
|
return { success: true };
|
|
})
|
|
.times(times);
|
|
return { puts: () => captured };
|
|
}
|
|
|
|
/** 複刻 console-auth.ts 內未 export 的私有迭代雜湊(sha256(salt+password) 迭代 3 次),
|
|
* 單純為了在測試端準備一筆能通過驗證的 legacy fixture,不是重新實作生產邏輯。 */
|
|
async function legacyHash(password: string, salt: string): Promise<string> {
|
|
async function sha256Hex(input: string): Promise<string> {
|
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
|
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
let h = `${salt}:${password}`;
|
|
for (let i = 0; i < 3; i++) h = await sha256Hex(h);
|
|
return h;
|
|
}
|
|
|
|
const EMAIL = 'legacy-owner@example.com';
|
|
const PASSWORD = 'legacy-owner-pw-1';
|
|
const SALT = 'deadbeef00112233';
|
|
|
|
describe('D61 舊實例相容:console 帳密只在舊 KV(尚未搬遷)', () => {
|
|
it('GET /console/auth-status:讀到舊 KV 這筆、順手搬進認證儲存', async () => {
|
|
const hash = await legacyHash(PASSWORD, SALT);
|
|
await env.SESSIONS_KV.put(
|
|
CREDS_KEY,
|
|
JSON.stringify({ email: EMAIL, salt: SALT, hash, created_at: '2026-01-01T00:00:00.000Z' }),
|
|
);
|
|
const { puts } = mockAuthStoreWrite();
|
|
|
|
const res = await json('GET', '/console/auth-status');
|
|
expect(res.status).toBe(200);
|
|
const data = (await res.json()) as {
|
|
configured: boolean;
|
|
credentials_source: string;
|
|
auth_store: { console_configured: boolean };
|
|
};
|
|
expect(data.configured).toBe(true);
|
|
expect(data.credentials_source).toBe('legacy-kv'); // 這次是靠回退讀到的
|
|
// loadCredentials 內的 best-effort 搬遷在回應組出來之前就已 await 完成,
|
|
// 故 authStoreStatus 已經反映搬遷後的狀態
|
|
expect(data.auth_store.console_configured).toBe(true);
|
|
|
|
const shards = puts();
|
|
expect(shards.length).toBe(1);
|
|
const shard = JSON.parse(shards[0].text) as { console: { email: string; hash: string } };
|
|
expect(shard.console.email).toBe(EMAIL);
|
|
expect(shard.console.hash).toBe(hash); // 原樣搬過去,不重新雜湊
|
|
});
|
|
|
|
it('搬遷後再打一次:新家已經有了,直接命中新家(不用再查舊 KV)', async () => {
|
|
const res = await json('GET', '/console/auth-status');
|
|
const data = (await res.json()) as { credentials_source: string };
|
|
expect(data.credentials_source).toBe('secrets');
|
|
});
|
|
|
|
it('用搬遷過去的帳密登入 → 200(搬遷沒有讓帳密變得登不進去)', async () => {
|
|
const res = await json('POST', '/console/login', { email: EMAIL, password: PASSWORD });
|
|
expect(res.status).toBe(200);
|
|
const data = (await res.json()) as { success: boolean };
|
|
expect(data.success).toBe(true);
|
|
});
|
|
});
|