feat(console): API Key 介面錯位收斂為簡單 email+password 登入 (Arcrun#3 發現②)
背景:self-hosted 單租戶下 console 原本那格「API Key」其實只是 namespace 明碼字串,不是註冊制 key(leo 原話:理論上根本沒有 API Key 這件事)。leo 拍板: 換成簡單 email+password 登入頁(自己設一組帳密,不用 OAuth),登入成功後端發 session token 存 localStorage;後端 API 呼叫仍用固定租戶字串打 KBDB(登入系統 只擋外人看頁面,不做多租戶)。 租戶字串收斂:發現①已核實 owner_id='leo' 是 D1 中 458,357 筆資料實際使用的 租戶字串(ak_... 只有 2 筆孤兒資料)。CONSOLE_TENANT 預設 "leo",不製造第三個租戶。 新增: - cypher-executor/src/routes/console-auth.ts:/console/auth-status、 /console/setup(首次自助設定帳密,寫入 SESSIONS_KV console:credentials)、 /console/setup/reset(換帳密,需舊密碼)、/console/login、/console/session (驗 session + 回傳固定租戶字串)、/console/logout。密碼用 salt + 3 輪 SHA-256 雜湊,不存明碼。 - cypher-executor/src/types.ts:Bindings 加 CONSOLE_TENANT。 - cypher-executor/wrangler.toml:[vars] 加 CONSOLE_TENANT = "leo"。 - cypher-executor/src/routes/console.ts:① 卡片從「貼 API Key」改成登入/首次 設定表單;查詢函式改用登入後端回的固定租戶字串,使用者不再需要知道任何 namespace 字串。 驗證見 issue #3 留言。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* arcrun console 登入(Arcrun#3 發現②,2026-07-03)
|
||||
*
|
||||
* 背景:console v0 那格叫「API Key」但 self-hosted 單租戶下其實只是 namespace 明碼字串,
|
||||
* 不是註冊制 key(leo 原話:理論上根本沒有 API Key 這件事)。leo 拍板:
|
||||
* 換成簡單 email+password 登入頁(自己設一組帳密即可,不用第三方 OAuth 或高級機制),
|
||||
* 登入成功後端發 session token 存 localStorage;**後端 API 呼叫仍是用固定租戶字串打
|
||||
* KBDB**(登入系統只是擋外人看到頁面,不是要做多租戶)。
|
||||
*
|
||||
* 與 routes/auth.ts 的差異:auth.ts 是官方 SaaS 的 Google/GitHub OAuth 多租戶註冊(每個
|
||||
* 使用者各自一把 ak_... api_key,各自一個租戶)。這裡是 self-hosted console 的「單一管理員
|
||||
* 帳密」— 全站只有一組帳密,只為擋外人看頁面,不產生新租戶、不核發 API key。
|
||||
*
|
||||
* 帳密怎麼設(不是雲端工人幫 leo 決定密碼):首次造訪 /console 時若尚未設定過,前端會走
|
||||
* 「首次設定」流程(POST /console/setup)——leo 自己在瀏覽器輸入 email/password,一次性寫入
|
||||
* SESSIONS_KV `console:credentials`(已存在就 409,不能覆蓋,換帳密走 /console/setup/reset
|
||||
* 需帶舊密碼)。之後才是一般登入(POST /console/login)。
|
||||
*
|
||||
* 固定租戶字串:CONSOLE_TENANT([vars],非機密——self-hosted 架構本就是明碼 namespace)。
|
||||
* 預設 "leo"(Arcrun#3 發現①已核實:owner_id='leo' 是 D1 中 458,357 筆資料實際使用的租戶字串,
|
||||
* ak_... 只有 2 筆孤兒資料,故統一收斂到 'leo',不製造第三個租戶)。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
const CREDS_KEY = 'console:credentials';
|
||||
const SESSION_PREFIX = 'console_sess:';
|
||||
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 天
|
||||
|
||||
interface StoredCredentials {
|
||||
email: string;
|
||||
salt: string; // hex
|
||||
hash: string; // hex,sha256(salt + password) 迭代 3 次
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** 簡易迭代雜湊(非 bcrypt/argon2,但比單輪 SHA-256 好一點;self-hosted 單管理員帳密,威脅模型輕)。 */
|
||||
async function hashPassword(password: string, salt: string): Promise<string> {
|
||||
let h = `${salt}:${password}`;
|
||||
for (let i = 0; i < 3; i++) h = await sha256Hex(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
function tenantOf(c: { env: Bindings }): string {
|
||||
return c.env.CONSOLE_TENANT || 'leo';
|
||||
}
|
||||
|
||||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
return c.json({ configured: !!existing });
|
||||
});
|
||||
|
||||
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
||||
consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (existing) return c.json({ error: '已設定過帳密,請改用登入;要換帳密請用 /console/setup/reset(需舊密碼)' }, 409);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim();
|
||||
const password = body?.password ?? '';
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
|
||||
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
expirationTtl: SESSION_TTL_SECONDS,
|
||||
});
|
||||
return c.json({ success: true, session_token: token, tenant: tenantOf(c) });
|
||||
});
|
||||
|
||||
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
|
||||
consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const currentPassword = body?.current_password ?? '';
|
||||
const email = (body?.email ?? '').trim();
|
||||
const password = body?.password ?? '';
|
||||
if (!currentPassword || !email || !password) return c.json({ error: 'current_password、email、password 必填' }, 400);
|
||||
if (password.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
|
||||
|
||||
const currentHash = await hashPassword(currentPassword, existing.salt);
|
||||
if (currentHash !== existing.hash) return c.json({ error: '舊密碼不正確' }, 401);
|
||||
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /console/login — body: {email, password}。成功 → session token(localStorage 存這個,不存密碼)。
|
||||
consoleAuthRouter.post('/console/login', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定帳密,請先完成首次設定' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim().toLowerCase();
|
||||
const password = body?.password ?? '';
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
expirationTtl: SESSION_TTL_SECONDS,
|
||||
});
|
||||
return c.json({ success: true, session_token: token, tenant: tenantOf(c) });
|
||||
});
|
||||
|
||||
// GET /console/session — Authorization: Bearer <session_token>。前端載入頁面時用來確認 session 還有效
|
||||
// + 拿回固定租戶字串(不必再手貼 API Key)。
|
||||
consoleAuthRouter.get('/console/session', async (c) => {
|
||||
const auth = c.req.header('authorization') ?? '';
|
||||
const token = auth.match(/^Bearer\s+(\S+)/i)?.[1];
|
||||
if (!token) return c.json({ valid: false }, 401);
|
||||
const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
|
||||
if (!sess) return c.json({ valid: false }, 401);
|
||||
return c.json({ valid: true, tenant: tenantOf(c) });
|
||||
});
|
||||
|
||||
// POST /console/logout — Authorization: Bearer <session_token>。
|
||||
consoleAuthRouter.post('/console/logout', async (c) => {
|
||||
const auth = c.req.header('authorization') ?? '';
|
||||
const token = auth.match(/^Bearer\s+(\S+)/i)?.[1];
|
||||
if (token) await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
Reference in New Issue
Block a user