feat(auth): 認證儲存搬回 D1/KV——落實方案C(leo confirm「走C」,arcrun-rag#99)
實作 pending-changes.md「認證儲存要不要搬回 D1/KV」提案(commit 8286c8a): D61 的病根「重裝時 binding 被安裝器照名字重新指到新建空資源」已被更通用的 shared/resource-rule(Arcrun#97,2026-08-13)解掉,故不再需要繞開 binding 去躲這個病——而繞開的代價正是這次要收的債:Workers Secrets 寫入需要外部 CF_SECRETS_API_TOKEN,這把 token 從安裝那天起就沒被種過,止血版只解掉 「建第一個帳號」這一格,之後的每一次寫入(換密碼/加帳號/改權限)仍卡死。 改動: - console 管理員帳密:家改回 SESSIONS_KV(binding,console-auth.ts) - portal 多人帳號:家改回 KBDB(binding,走 base HTTP API,D38 零 SQL,portal.ts) - D61 認證儲存(CF Workers Secrets)留為舊實例的唯讀回退路徑:讀取零成本、 零外部憑證需求(只有寫入才要 token);登入成功即 best-effort 自動搬進新家, 且**這次登入發出的 session 就直接指向新 record_id**(不必等下一次登入) - D61 的三項「明顯失敗」語意全部保留:auth_store_empty(讀不到不算密碼錯、 不計入鎖定)、/console/setup 遇既有帳號說清楚密碼沒被採用、/health 與 /console/auth-status 吐儲存狀態 - 移除止血版的 x-arcrun-install-token 表頭傳遞機制(installToken 參數)—— 帳號寫入從此不需要任何外部 CF token,這個結構性缺口已從根拔除 測試:cypher-executor 全套 vitest 439/453(14 個既存失敗與本改動無關,已用 git stash 對照 clean checkout 逐一比對檔名確認完全相同);tsc --noEmit 無新增錯誤(3 個既存錯誤同上核實無關)。已跑 build-worker-artifacts.mjs 重打 tier2 bundle,grep 複驗 createKbdbUserRecord/promoteToKbdb 進了成品、 promoteLegacyUser/x-arcrun-install-token 完全從成品消失。 未覆蓋:POST /credentials(一般 workflow API 金鑰儲存)仍依賴 CF_SECRETS_API_TOKEN——這是 01-tech-stack.md 既有的、獨立於 D61 之外的 credential 儲存架構(D19「擁有目錄不擁有內容物」),本提案範圍只涵蓋「認證」 (登入帳密),不涵蓋一般 credential 儲存;07-29 已知缺口仍待另案處理。 不准 merge 進 main(SDD 鐵律③,等總管審過再併);不准部署(D20 出貨閘)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,81 +1,64 @@
|
||||
/**
|
||||
* console-auth.ts 測試(D61:console 管理員帳密搬進認證儲存,ADR D61 / Leo/arcrun-rag#55)
|
||||
* console-auth.ts 測試(2026-08-14 起:console 管理員帳密改回住 SESSIONS_KV,
|
||||
* D61 補充/pending-changes.md「認證儲存要不要搬回 D1/KV」,leo confirm「走C」)
|
||||
*
|
||||
* 這組帳密(/console/setup、/console/login…)原本住 SESSIONS_KV `console:credentials`
|
||||
* (沒有 TTL)——KV 靠 binding 指過去,重裝會被指到新建的空 KV ⇒ 帳密憑空消失
|
||||
* (console-auth.ts 檔頭「KV=暫存、非長期真相源」第三次被違反,這次違反的是大門的鎖)。
|
||||
* D61 起改存進認證儲存(CF Workers Secrets),SESSIONS_KV 只留為回退讀路徑。
|
||||
* 這組帳密(/console/setup、/console/login…)曾在 D61(ADR D61 / Leo/arcrun-rag#55)搬進
|
||||
* 認證儲存(CF Workers Secrets)以躲開「重裝時 binding 被重新指到新建空資源」的病根——但
|
||||
* Workers Secrets 的寫入需要外部 `CF_SECRETS_API_TOKEN`,這把 token 從安裝那天起就沒被種過,
|
||||
* 於是每一台全新實例永遠建不出第一個帳號(arcrun-rag#99)。病根本身已被更通用的
|
||||
* `shared/resource-rule`(Arcrun#97)解掉,故改回 SESSIONS_KV(binding,永不需要外部 token)。
|
||||
* 認證儲存留為舊實例的回退讀路徑,見 tests/console-auth-legacy.test.ts。
|
||||
*
|
||||
* 覆蓋(本檔在此之前不存在,D61 交辦要求的新增覆蓋):
|
||||
* 覆蓋:
|
||||
* 1. 全新實例:auth-status 回 configured:false;login 回「讀不到認證資料」(不是密碼錯)。
|
||||
* 2. 首次設定成功:POST /console/setup 寫進認證儲存(CF Workers Secrets),不再寫 KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(D61 明顯失敗,取代舊版
|
||||
* 2. 首次設定成功:POST /console/setup 寫進 SESSIONS_KV。
|
||||
* 3. 已設定過 → 409,訊息明講「你剛才輸入的密碼沒有被採用」(明顯失敗,取代舊版
|
||||
* 只說「已設定過」卻不說清楚剛才那組密碼發生了什麼事的誤導文案)。
|
||||
* 4. 登入對錯:帳密正確 200;密碼錯 401。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進新家;換密碼後舊密碼立即失效。
|
||||
* 5. /console/setup/reset:舊密碼驗證+新密碼寫進 SESSIONS_KV;換密碼後舊密碼立即失效。
|
||||
*
|
||||
* 認證儲存寫入會呼叫 `https://api.cloudflare.com/.../secrets`(PUT),走 fetchMock 假 host
|
||||
* 攔截(同 portal-auth.test.ts 的 mockAuthStoreWrite),不外連;wrangler.test.toml 已預設
|
||||
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒。
|
||||
* 全程不需要任何 CF API mock——SESSIONS_KV 是 binding,wrangler.test.toml 已提供 mock KV。
|
||||
*
|
||||
* ⚠️ 測試順序不可打亂:portal-auth-store.ts 的 per-isolate overlay 是模組級全域變數,
|
||||
* 一旦某則測試讓 /console/setup 或 reset 真的寫成功,overlay.console 就會在**這支檔案**
|
||||
* 剩下的測試裡持續存在(同檔案不會在測試之間重置模組全域,只有 KV/D1 等 storage 才有
|
||||
* isolatedStorage 重置)。因此本檔刻意排成一條線性故事:先驗證「全新、尚未設定」的分支,
|
||||
* 再做一次成功的 /console/setup(之後永久變成「已設定」),後面的測試都建立在這個已設定
|
||||
* 的基礎上。「帳密只存在舊 KV(尚未搬遷過)」這個分支需要 overlay 是空的,因此另開一支
|
||||
* 檔案 tests/console-auth-legacy.test.ts(不同檔案=不同 worker 執行個體,狀態不互相污染)。
|
||||
* 🔴 每則測試自帶前置狀態(不依賴前一則測試的寫入殘留):`@cloudflare/vitest-pool-workers`
|
||||
* 的 `isolatedStorage`(預設開)在**每一個 `it()`** 前重置 KV/D1 等 storage bindings——
|
||||
* 這正是本檔要驗證的儲存介質(SESSIONS_KV),故「已設定過」的情境一律在該則測試內先呼叫一次
|
||||
* 真正的 POST /console/setup 自建,不能沿用其他 `it()` 建立的帳密(那套「同檔案內殘留」的
|
||||
* 假設只對 D61 認證儲存的模組級記憶體變數 `overlay` 成立,SESSIONS_KV 不是那個機制)。
|
||||
*/
|
||||
import { SELF, env, fetchMock } from 'cloudflare:test';
|
||||
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
|
||||
import { SELF, env } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const CF_API = 'https://api.cloudflare.com';
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
});
|
||||
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
||||
|
||||
function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
function json(method: string, path: string, body?: unknown) {
|
||||
return SELF.fetch(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** D61:認證儲存寫入路徑(同 portal-auth.test.ts 的同名 helper,那邊有完整說明)。 */
|
||||
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 };
|
||||
/** 每則測試自建一組帳密(POST /console/setup),回傳供後續斷言使用。 */
|
||||
async function setupOwner(email: string, password: string): Promise<void> {
|
||||
const res = await json('POST', '/console/setup', { email, password });
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
const OWNER_EMAIL = 'owner@example.com';
|
||||
const OWNER_PW = 'owner-first-pw-1';
|
||||
|
||||
// ═══════════════ 1. 全新實例(尚未設定過,必須排最前面)═══════════════
|
||||
// ═══════════════ 1. 全新實例(尚未設定過任何管理員帳密)═══════════════
|
||||
|
||||
describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||
it('GET /console/auth-status → configured:false,不洩漏 email', async () => {
|
||||
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: { present: boolean } };
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { home: string } };
|
||||
expect(data.configured).toBe(false);
|
||||
expect(data.credentials_source).toBe('none');
|
||||
expect(data.auth_store.home).toBe('sessions-kv');
|
||||
expect(JSON.stringify(data)).not.toContain('@'); // 不洩漏 email
|
||||
});
|
||||
|
||||
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(D61 明顯失敗)', async () => {
|
||||
it('POST /console/login → 400「讀不到認證資料」,不是密碼錯(明顯失敗)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
|
||||
expect(res.status).toBe(400);
|
||||
const data = (await res.json()) as { code: string; error: string };
|
||||
@@ -91,36 +74,31 @@ describe('全新實例(尚未設定過任何管理員帳密)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 2. 首次設定:成功寫進認證儲存(D61 起唯一寫入路徑)═══════════════
|
||||
// ═══════════════ 2. 首次設定:成功寫進 SESSIONS_KV ═══════════════
|
||||
|
||||
describe('POST /console/setup — 首次設定', () => {
|
||||
it('成功:寫進認證儲存(不再寫 SESSIONS_KV),回 session_token', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
it('成功:寫進 SESSIONS_KV(binding,不需要任何外部 CF token),回 session_token', async () => {
|
||||
const res = await json('POST', '/console/setup', { email: OWNER_EMAIL.toUpperCase(), password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string; tenant: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.session_token).toBe('string');
|
||||
|
||||
// 寫入認證儲存:一片、含小寫 email,明碼密碼絕不落地
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].name).toBe('ARCRUN_AUTH_STORE');
|
||||
expect(shards[0].text).not.toContain(OWNER_PW);
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string; salt: string; hash: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL); // 存小寫
|
||||
expect(typeof shard.console.salt).toBe('string');
|
||||
expect(typeof shard.console.hash).toBe('string');
|
||||
|
||||
// D61:不再寫舊 KV——這是本次變更的核心(舊版寫 SESSIONS_KV,重裝就蒸發)
|
||||
expect(await env.SESSIONS_KV.get('console:credentials')).toBeNull();
|
||||
const raw = await env.SESSIONS_KV.get('console:credentials');
|
||||
expect(raw).toBeTruthy();
|
||||
expect(raw).not.toContain(OWNER_PW); // 明碼絕不落地
|
||||
const stored = JSON.parse(raw!) as { email: string; salt: string; hash: string };
|
||||
expect(stored.email).toBe(OWNER_EMAIL); // 存小寫
|
||||
expect(typeof stored.salt).toBe('string');
|
||||
expect(typeof stored.hash).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 3. 已設定過 → 409(D61 明顯失敗:說得出「沒有被採用」)═══════════════
|
||||
// ═══════════════ 3. 已設定過 → 409(明顯失敗:說得出「沒有被採用」)═══════════════
|
||||
|
||||
describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||
it('409,訊息明講「你剛才輸入的密碼沒有被採用」,不誤導成「設定成功」', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/setup', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(409);
|
||||
const data = (await res.json()) as {
|
||||
@@ -130,22 +108,29 @@ describe('POST /console/setup — 已設定過(重複設定)', () => {
|
||||
expect(data.password_applied).toBe(false);
|
||||
expect(data.error).toContain('沒有被採用');
|
||||
expect(data.reset_path).toBe('/console/setup/reset');
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗(下一個 describe 也會正面驗證原帳密仍有效)
|
||||
|
||||
// 攻擊者填的帳密真的沒有生效:用它登入應該失敗,原帳密仍有效
|
||||
const attackerLogin = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(attackerLogin.status).toBe(401);
|
||||
const ownerLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(ownerLogin.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /console/auth-status → configured:true,credentials_source:secrets(新家優先命中)', async () => {
|
||||
it('GET /console/auth-status → configured:true,credentials_source:kv', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('GET', '/console/auth-status');
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { console_configured: boolean } };
|
||||
const data = (await res.json()) as { configured: boolean; credentials_source: string; auth_store: { writable: boolean } };
|
||||
expect(data.configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('secrets');
|
||||
expect(data.auth_store.console_configured).toBe(true);
|
||||
expect(data.credentials_source).toBe('kv');
|
||||
expect(data.auth_store.writable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 4. 登入對錯(用第 2 節設定的帳密)═══════════════
|
||||
// ═══════════════ 4. 登入對錯 ═══════════════
|
||||
|
||||
describe('POST /console/login', () => {
|
||||
it('帳密正確 → 200,發 session token', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { success: boolean; session_token: string };
|
||||
@@ -154,30 +139,27 @@ describe('POST /console/login', () => {
|
||||
});
|
||||
|
||||
it('密碼錯 → 401', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/login', { email: OWNER_EMAIL, password: 'wrong-password-x' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('攻擊者在第 3 節試圖搶注的帳密登不進來(證明真的「沒有被採用」)', async () => {
|
||||
const res = await json('POST', '/console/login', { email: 'attacker@example.com', password: 'trying-to-hijack-1' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 5. /console/setup/reset:換密碼,寫進新家 ═══════════════
|
||||
// ═══════════════ 5. /console/setup/reset:換密碼,寫進 SESSIONS_KV ═══════════════
|
||||
|
||||
describe('POST /console/setup/reset', () => {
|
||||
const NEW_PW = 'brand-new-owner-pw-1';
|
||||
|
||||
it('舊密碼錯 → 401,不寫入', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: 'still-wrong', email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('舊密碼對 → 200,新 hash 寫進新家;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||
const { puts } = mockAuthStoreWrite();
|
||||
it('舊密碼對 → 200,新 hash 寫進 SESSIONS_KV;換完後舊密碼立即失效、新密碼生效', async () => {
|
||||
await setupOwner(OWNER_EMAIL, OWNER_PW);
|
||||
const res = await json('POST', '/console/setup/reset', {
|
||||
current_password: OWNER_PW, email: OWNER_EMAIL, password: NEW_PW,
|
||||
});
|
||||
@@ -185,11 +167,10 @@ describe('POST /console/setup/reset', () => {
|
||||
const data = (await res.json()) as { success: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const shards = puts();
|
||||
expect(shards.length).toBe(1);
|
||||
expect(shards[0].text).not.toContain(NEW_PW); // 明碼不落地
|
||||
const shard = JSON.parse(shards[0].text) as { console: { email: string } };
|
||||
expect(shard.console.email).toBe(OWNER_EMAIL);
|
||||
const raw = await env.SESSIONS_KV.get('console:credentials');
|
||||
expect(raw).not.toContain(NEW_PW); // 明碼不落地
|
||||
const stored = JSON.parse(raw!) as { email: string };
|
||||
expect(stored.email).toBe(OWNER_EMAIL);
|
||||
|
||||
// 舊密碼立即失效
|
||||
const oldLogin = await json('POST', '/console/login', { email: OWNER_EMAIL, password: OWNER_PW });
|
||||
|
||||
Reference in New Issue
Block a user