diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index b3571c5..e0a6bec 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -219,12 +219,42 @@ if (!window.ARCRUN_API_BASE) {
-
帳號由管理員發放。忘記密碼請聯絡管理員重設。
-
你自己就是管理員?用管理主控台密碼救援自己
+ +
+ 忘記密碼了?寄一條「修改密碼」連結給我 +
+
+ + +
+
+ +
+
+
+
設定新密碼
+
+
+
+
回登入頁
+
+
+
+
@@ -375,10 +405,14 @@ if (!window.ARCRUN_API_BASE) {
-
-
更改密碼
-
需輸入舊密碼驗證身分;新密碼至少 8 碼
-
+ +
+
更改密碼
+
需輸入舊密碼驗證身分;新密碼至少 8 碼
+
@@ -817,6 +851,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { } catch (e) { /* 登入殼還沒渲染就算了 */ } } function boot() { + // D62:信裡的「修改密碼」連結最優先——這個人**就是進不去才收到這封信的**, + // 不可以因為瀏覽器裡剛好還留著一個舊 token 就把他丟去別的畫面。 + var rt = (typeof resetTokenFromUrl === 'function') ? resetTokenFromUrl() : ''; + if (rt) { showReset(rt); return; } if (!S.token) { showAuth(); return; } fetch(API_BASE + '/portal/session', { headers: authHeaders() }) .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) @@ -2041,33 +2079,147 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { '
角色' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '
' + '
可查庫' + libHtml + '
'; } - $('st-pw-save').addEventListener('click', function () { + // ══════════ D62:改密碼與忘記密碼=同一個機制 ══════════ + // + // leo 2026-08-10:「這兩個機制其實是一個機制,可以簡化。」 + // 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋 + // 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋 + // ⇒ 底下只有**一個表單元素**(#pw-form)與**一支送出函式**,差別只有「現有密碼」那一格 + // 顯不顯示、以及帶 current 還是帶 reset_token。兩份長得像的東西必然漂移 + // (arcrun-rag#40「同一個事實兩份」)。 + // 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。 + + var S_RESET_TOKEN = ''; // 非空=現在是「忘記密碼」模式 + + /** 把那一份唯一的表單搬到指定容器,並決定「現有密碼」那一格出不出現。 */ + function mountPasswordForm(host, needCurrent) { + var form = $('pw-form'); + if (form && form.parentNode !== host) host.appendChild(form); + $('st-pw-old').classList.toggle('hide', !needCurrent); + $('st-pw-save').textContent = needCurrent ? '更新密碼' : '設定新密碼'; + $('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = ''; + $('st-pw-status').textContent = ''; + } + + /** 送出:兩種模式共用。帶 reset_token 就走連結那一格,否則走現有密碼那一格。 */ + function submitPasswordChange() { + var needCurrent = !S_RESET_TOKEN; var oldPw = $('st-pw-old').value; var newPw = $('st-pw-new').value; var newPw2 = $('st-pw-new2').value; var st = $('st-pw-status'); st.className = 'err'; - if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; } + if (needCurrent && !oldPw) { st.textContent = '請填現在的密碼'; return; } + if (!newPw) { st.textContent = '請填新密碼'; return; } if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; } if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; } + var body = { 'new': newPw }; + if (S_RESET_TOKEN) body.reset_token = S_RESET_TOKEN; else body.current = oldPw; $('st-pw-save').disabled = true; - st.textContent = ''; - fetch(API_BASE + '/portal/me/password', { + st.textContent = '處理中…'; + fetch(API_BASE + '/portal/password/change', { method: 'POST', - headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), - body: JSON.stringify({ current: oldPw, 'new': newPw }) + headers: Object.assign({ 'Content-Type': 'application/json' }, needCurrent ? authHeaders() : {}), + body: JSON.stringify(body) }) .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { $('st-pw-save').disabled = false; - if (guard401(x.status)) return; - if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; } + // 忘記密碼模式沒有 session,401 不代表「你被登出」——別誤踢 + if (needCurrent && guard401(x.status)) return; + if (!x.ok) { st.textContent = (x.d && x.d.error) || '更新失敗'; return; } st.className = 'ok'; - st.textContent = '密碼已更新'; $('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = ''; - toast('密碼已更新'); + if (S_RESET_TOKEN) { + st.textContent = '密碼已設定好了,正在帶你回登入頁…'; + var who = (x.d && x.d.email) || ''; + S_RESET_TOKEN = ''; + setTimeout(function () { + history.replaceState(null, '', location.pathname); + location.hash = ''; + $('v-reset').classList.remove('on'); + mountPasswordForm($('st-pw-panel'), true); + showAuth(); + if (who) $('login-email').value = who; + $('login-status').className = 'ok'; + $('login-status').textContent = '新密碼已生效,請用它登入。'; + }, 1200); + } else { + st.textContent = '密碼已更新(你不會被登出,可以繼續操作)'; + toast('密碼已更新'); + } }) .catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); }); + } + $('st-pw-save').addEventListener('click', submitPasswordChange); + + // ── 登入頁的「忘記密碼」入口(**在 portal,不是 console**,leo 2026-08-10)── + $('login-forgot').addEventListener('click', function (ev) { + ev.preventDefault(); + $('forgot-box').classList.remove('hide'); + $('forgot-email').value = $('login-email').value.trim(); + $('forgot-email').focus(); + }); + $('forgot-submit').addEventListener('click', function () { + var email = $('forgot-email').value.trim(); + var st = $('forgot-status'); + st.className = 'err'; + if (!email) { st.textContent = '請填 Email'; return; } + $('forgot-submit').disabled = true; + st.textContent = '寄送中…'; + fetch(API_BASE + '/portal/password/forgot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email }) + }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (x) { + $('forgot-submit').disabled = false; + if (!x.ok) { st.textContent = (x.d && x.d.error) || '寄送失敗'; return; } + st.className = 'ok'; + st.textContent = (x.d && x.d.message) || '如果這個 email 有帳號,連結已經寄出了。'; + }) + .catch(function (e) { $('forgot-submit').disabled = false; st.textContent = friendlyErr(e); }); + }); + + /** 點信裡的連結進來:`…/portal/#/reset?token=…`(cypher 的 /portal/password/reset-link 導過來的)。 */ + function resetTokenFromUrl() { + var m = String(location.hash || '').match(/[?&]token=([^&]+)/); + if (m) return decodeURIComponent(m[1]); + m = String(location.search || '').match(/[?&]token=([^&]+)/); + return m ? decodeURIComponent(m[1]) : ''; + } + function showReset(token) { + $('v-login').classList.remove('on'); + $('v-firstsetup').classList.remove('on'); + $('shell').classList.remove('on'); + $('tabbar').classList.remove('on'); + $('v-reset').classList.add('on'); + $('reset-error').textContent = ''; + // 先問一次「這張票還有效嗎」——連結是一次有效、30 分鐘過期的, + // 過期就要當場說清楚,不要讓人填完密碼才被打回票(#49:別把系統狀態講成用戶的錯)。 + fetch(API_BASE + '/portal/password/reset?token=' + encodeURIComponent(token)) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (x) { + if (!x.ok || !x.d || x.d.valid !== true) { + $('reset-who').textContent = ''; + $('reset-error').textContent = (x.d && x.d.error) || '這條連結已經失效了。請回登入頁重新按一次「忘記密碼」。'; + return; + } + S_RESET_TOKEN = token; + $('reset-who').innerHTML = '你正在為 ' + esc(x.d.email) + ' 設定新密碼。
這條連結只能用一次,不需要輸入現在的密碼。'; + mountPasswordForm($('reset-slot'), false); // ← 差別只有這一格 + }) + .catch(function (e) { $('reset-error').textContent = friendlyErr(e); }); + } + $('reset-back').addEventListener('click', function (ev) { + ev.preventDefault(); + S_RESET_TOKEN = ''; + history.replaceState(null, '', location.pathname); + location.hash = ''; + $('v-reset').classList.remove('on'); + mountPasswordForm($('st-pw-panel'), true); + showAuth(); }); boot(); diff --git a/cypher-executor/src/lib/portal-auth.ts b/cypher-executor/src/lib/portal-auth.ts index d0c6371..12b650f 100644 --- a/cypher-executor/src/lib/portal-auth.ts +++ b/cypher-executor/src/lib/portal-auth.ts @@ -98,6 +98,20 @@ export function randomHex(bytes: number): string { .join(''); } +/** + * SHA-256 → hex。**用途只有一個**:把「修改密碼連結」的 token 換成查詢用的 KV key(D62)。 + * + * 為什麼不直接拿 token 當 key:連結裡的 token 是**能改密碼的憑據**,直接當 key 等於 + * 把它明碼存在 KV 裡;改存雜湊後,看得到 KV 的人也拿不到可用的連結。 + * (這裡只做 digest——不是 rule 2.2 禁的 `crypto.subtle.decrypt` / RSASSA 簽章。) + */ +export async function sha256Hex(input: string): Promise { + 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(''); +} + /** * 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。 * 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。 diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index b19332b..a14cc6c 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -23,7 +23,7 @@ import type { Context } from 'hono'; import type { Bindings } from '../types'; import { kbdbBase } from './kbdb-proxy'; import { validateConsoleSession } from './console-auth'; -import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth'; +import { hashPassword, verifyPassword, randomHex, generatePassword, sha256Hex } from '../lib/portal-auth'; import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds'; // arcrun-rag#10:/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑, // 不在 portal 這層另造第二套儲存(D36:值進 Workers Secret,D1 只留 ref)。 @@ -715,29 +715,300 @@ portalRouter.get('/portal/session', (c) => }), ); -// POST /portal/me/password — body {current, new}。驗舊密改新密。 -portalRouter.post('/portal/me/password', (c) => - run(c, async () => { - const auth = await requirePortalUser(c); - if (!auth.ok) return auth.res; - const body = await c.req.json().catch(() => null); - const current = String(body?.current ?? ''); - const next = String(body?.new ?? ''); - if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400); - if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400); +// ═════════════════ D62:改密碼與忘記密碼是**同一個機制** ═══════════════════════ +// +// leo 2026-08-10 拍板(頂層 decisions-summary D62): +// 「這兩個機制其實是一個機制,可以簡化。」 +// - 修改密碼:到「修改密碼」→ 輸入**現有的** → 輸入新的 → 覆蓋現有的 +// - 忘記密碼:寄給你**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋 +// ⇒ **同一個畫面、同一條寫入路徑,差別只有一格**:「現有密碼」是要填、還是被連結豁免。 +// +// 🔴 **不做一次性密碼**(leo:「不要發一次性密碼太麻煩」)——這修正了 D50 的「一次性驗證碼」, +// 形態改成連結;D50 其餘部分(console 退場、不准沿用註冊辨識碼)不變。 +// 🔴 連結的安全性(承 D50 的理由,不可退讓): +// ① **一次有效**——用掉就從 KV 刪除(本檔 consumeResetToken) +// ② **會過期**——KV expirationTtl 30 分鐘,過了就是不存在 +// ③ **與註冊辨識碼不同源**——現場 crypto 亂數產生、只活在這台實例的 SESSIONS_KV, +// 跟 landing `SIGNUPS` 那組安裝辨識碼沒有任何關係。 +// D50 否決固定辨識碼的理由正是「綁定不會變 ⇒ 等於不會過期的鑰匙」。 +// 🔴 已否決、不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。 +// 🔴 **入口在 portal,不是 console**(leo 2026-08-10:「是對 portal 不是對 console, +// 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」)——console 與 portal 是 +// 安裝時同一組帳密寫進兩個地方(D50 補刀),往 console 補洞不會多出任何一條路。 - const ok = await verifyPassword(current, auth.user.values.password_hash ?? ''); - if (!ok) return c.json({ error: '舊密碼不正確' }, 401); +/** 「修改密碼」連結 token 的 KV key 前綴(存的是 token 的 sha256,不是 token 本身)。 */ +const PWRESET_PREFIX = 'portal_pwreset:'; +/** 連結有效期:30 分鐘(安全要求②「會過期」)。 */ +const PWRESET_TTL_SECONDS = 30 * 60; +/** 同一個 email 的請求節流 key 前綴+冷卻秒數(避免被拿來灌信)。 */ +const PWRESET_THROTTLE_PREFIX = 'portal_pwreset_req:'; +const PWRESET_THROTTLE_SECONDS = 120; +/** 代寄回呼票(讓郵差可以回頭問「這封真的是你要我寄的嗎」)的 key 前綴與存活秒數。 */ +const RELAY_TICKET_PREFIX = 'portal_relay_ticket:'; +const RELAY_TICKET_TTL_SECONDS = 120; - const newHash = await hashPassword(next); - await patchRecordValues(c.env, auth.user.recordId, { - password_hash: newHash, - updated_at: new Date().toISOString(), +interface ResetTokenPayload { + record_id: string; + email: string; + created_at: string; +} + +/** + * portal 前端(使用者會看到的那個網址)的 origin。 + * 與 index.ts 的 CORS 白名單**同一套推導**:UI_ORIGINS 優先,否則用 workers.dev 兄弟位址。 + * (2026-08-08 事故的教訓:能推導出來的東西就不要再多一個「必須被注入、漏了看不出來」的變數。) + */ +function portalUiOrigin(env: Bindings): string | null { + const declared = String(env.UI_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean); + if (declared.length > 0) return declared[0]; + const sub = String(env.WORKER_SUBDOMAIN ?? '').trim(); + return sub ? `https://arcrun-rag-ui.${sub}.workers.dev` : null; +} + +/** 發一張「修改密碼」連結票,回傳要放進連結的 token(明碼只在這一刻存在)。 */ +async function issueResetToken(env: Bindings, recordId: string, email: string): Promise { + const token = randomHex(32); + const payload: ResetTokenPayload = { record_id: recordId, email, created_at: new Date().toISOString() }; + await env.SESSIONS_KV.put(`${PWRESET_PREFIX}${await sha256Hex(token)}`, JSON.stringify(payload), { + expirationTtl: PWRESET_TTL_SECONDS, + }); + return token; +} + +/** 看一眼票是否有效(**不消耗**)——給「點進連結先渲染畫面」用。 */ +async function peekResetToken(env: Bindings, token: string): Promise { + if (!token || !/^[0-9a-f]{16,128}$/i.test(token)) return null; + const raw = await env.SESSIONS_KV.get(`${PWRESET_PREFIX}${await sha256Hex(token)}`); + if (!raw) return null; + try { + return JSON.parse(raw) as ResetTokenPayload; + } catch { + return null; + } +} + +/** + * 用掉一張票(安全要求①「一次有效」):**先刪再回傳**。 + * 順序是刻意的——先刪掉才回,兩個人同時點同一條連結時最多只有一個拿得到。 + */ +async function consumeResetToken(env: Bindings, token: string): Promise { + const payload = await peekResetToken(env, token); + if (!payload) return null; + await env.SESSIONS_KV.delete(`${PWRESET_PREFIX}${await sha256Hex(token)}`); + return payload; +} + +/** + * **唯一的密碼寫入路徑**(D62「同一條寫入路徑」的落地點)。 + * 修改密碼與忘記密碼都只能從這裡覆蓋密碼——不再有第二支自己 hash 自己 patch 的路。 + */ +async function writeNewPassword(env: Bindings, recordId: string, newPassword: string): Promise { + const newHash = await hashPassword(newPassword); + await patchRecordValues(env, recordId, { + password_hash: newHash, + updated_at: new Date().toISOString(), + }); +} + +/** + * 請中央 landing 代寄「修改密碼」連結。 + * + * 🔴 **為什麼要代寄**(leo 2026-08-10 給的職責切法): + * 「其實這應該是 youlin 的實例告訴 arcrun.dev 說『我實例的重設密碼網址是 abc.recover, + * 你幫我寄信給用戶讓他來修改密碼』,由 arcrun.dev 幫它寄出這封信。」 + * ⇒ **實例**產生連結、管一次有效/過期/作廢;**arcrun.dev 只是郵差**,不碰任何認證邏輯。 + * 必須這樣切的硬理由:用戶自己的實例**根本沒有寄信能力**——安裝器部署 cypher 的 binding + * 只有 ai/d1/kv_namespace/plain_text/secret_text/service/vectorize,**沒有 send_email**。 + * ⚠️ 「由我們中央代寄」是依 leo「寄給你」推導的**假設**,他尚未正式表態(D62 明列為未裁前置)。 + * + * 🔴 **絕不把整條 URL 交給郵差**(總管 2026-08-10 紅線):寄件網域 `arcrun.dev` 掛在 uncle6、 + * 帶 DKIM。郵差若肯收「任意 URL + 任意 email」就寄,任何人裝一台實例就能用 `arcrun.dev` + * 的名義、**通過 DKIM 驗證**把任意連結寄給任意人 ⇒ 一台開放的釣魚中繼, + * 燒的是整個網域的信譽、波及所有用戶、**不可逆**。 + * + * 做法:我們只交出**這台實例自己的 origin + 一張回呼票**。連結由郵差自己組,而且郵差會 + * **回頭打這個 origin** 問「這張票是你發的嗎」(見 /portal/password/relay-verify)。 + * 冒用別人的網域會被那台實例自己否認 ⇒ **主機屬於呼叫方這件事由郵差親自確認, + * 不是相信呼叫方的宣稱**。 + * + * `apiOrigin` 取自**這次請求真正抵達的位址**(`new URL(c.req.url).origin`),不新增任何 + * 需要被注入的變數——2026-08-08 兩次事故的教訓:能推導的就不要再多一個會被漏掉的設定。 + * + * **不假綠**(mindset §7):寄不出去就回 'failed' / 'not_configured'。 + */ +async function relayResetLink( + env: Bindings, + apiOrigin: string, + email: string, + ticket: string, +): Promise<'sent' | 'not_configured' | 'failed'> { + const base = String(env.PORTAL_MAIL_RELAY_BASE ?? '').trim().replace(/\/$/, ''); + if (!base) return 'not_configured'; + const headers: Record = { 'Content-Type': 'application/json' }; + if (env.PORTAL_MAIL_RELAY_KEY) headers['X-Arcrun-Relay-Key'] = env.PORTAL_MAIL_RELAY_KEY; + try { + const res = await fetch(`${base}/api/send-password-reset`, { + method: 'POST', + headers, + body: JSON.stringify({ email, api_origin: apiOrigin, ticket }), }); - return c.json({ success: true }); + return res.ok ? 'sent' : 'failed'; + } catch { + return 'failed'; + } +} + +/** + * POST /portal/password/relay-verify — **郵差回頭確認用**(body `{ticket}`)。 + * + * 這支存在的唯一理由是上面那條紅線:郵差不可以相信「呼叫方說這台是我的」。 + * 它會回頭打**連結裡的那個主機**問這張票在不在—— + * - 真的是這台發的 → 這裡答得出來 → 寄 + * - 有人冒用別人的網域 → 被冒用的那台**根本沒有這張票** → 答不出來 → 郵差拒寄 + * 回應**不含 email 明碼、不含 reset token**,只回收件人 email 的 sha256 讓郵差核對它手上那個, + * 以及要放進信裡的完整連結(由本台自己組,主機必然是自己)。 + * 票 TTL 兩分鐘、看過即焚——它不是憑據,只是一次握手。 + */ +portalRouter.post('/portal/password/relay-verify', (c) => + run(c, async () => { + const body = await c.req.json().catch(() => null); + const ticket = String(body?.ticket ?? '').trim(); + if (!ticket || !/^[0-9a-f]{8,64}$/i.test(ticket)) return c.json({ ok: false }, 400); + const key = `${RELAY_TICKET_PREFIX}${ticket}`; + const raw = await c.env.SESSIONS_KV.get(key); + if (!raw) return c.json({ ok: false }, 404); + await c.env.SESSIONS_KV.delete(key); // 一次握手,看過即焚 + let parsed: { email: string; link: string }; + try { + parsed = JSON.parse(raw) as { email: string; link: string }; + } catch { + return c.json({ ok: false }, 404); + } + return c.json({ ok: true, email_sha256: await sha256Hex(parsed.email), link: parsed.link }); }), ); +// GET /portal/password/reset-link?token=… — 信裡連結的落點。 +// **連結的主機刻意是 cypher 自己**(=郵差回呼確認的那個主機,兩者必須是同一個, +// 否則「郵差確認過的主機」與「信裡的主機」就不是同一件事,紅線等於沒守)。 +// 這裡只做一件事:把人帶去 portal 前端的修改密碼畫面。 +portalRouter.get('/portal/password/reset-link', (c) => { + const token = c.req.query('token') ?? ''; + const ui = portalUiOrigin(c.env); + if (!ui) return c.text('這台實例沒有設定 portal 前端網址,無法導向修改密碼畫面。', 500); + return c.redirect(`${ui}/portal/#/reset?token=${encodeURIComponent(token)}`, 302); +}); + +// POST /portal/password/forgot — body {email}。**公開端點**(忘記密碼的人當然沒登入)。 +// +// 不洩漏帳號存在性:帳號在不在,回的都是同一句話、同一個 200。 +// 唯一會回錯的是「這台實例根本沒設代寄服務」——那與「有沒有這個帳號」無關,講出來不洩漏任何事, +// 而不講就會讓人對著一封永遠不會到的信等下去(#49「把故障講成用戶的問題」的反面)。 +portalRouter.post('/portal/password/forgot', (c) => + run(c, async () => { + const body = await c.req.json().catch(() => null); + const email = String(body?.email ?? '').trim().toLowerCase(); + if (!email || !isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400); + + if (!String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()) { + return c.json( + { + error: + '這台實例還沒有設定寄信服務,「忘記密碼」的連結寄不出去。' + + '請重新執行安裝/更新讓它就緒,或請管理員直接幫你改密碼。', + code: 'mail_relay_not_configured', + }, + 503, + ); + } + + const generic = { + success: true, + message: '如果這個 email 在這台實例上有帳號,我們已經把「修改密碼」的連結寄過去了(連結 30 分鐘內有效、只能用一次)。', + }; + + // 節流:同一個 email 兩分鐘內只寄一次(擋灌信,也擋拿這支當帳號存在性探針的節奏) + const throttleKey = `${PWRESET_THROTTLE_PREFIX}${email}`; + if (await c.env.SESSIONS_KV.get(throttleKey)) return c.json(generic); + await c.env.SESSIONS_KV.put(throttleKey, '1', { expirationTtl: PWRESET_THROTTLE_SECONDS }); + + const recordId = await findUserRecordId(c.env, email).catch(() => null); + if (!recordId) return c.json(generic); // 沒有這個帳號 → 一樣的回應,什麼都不寄 + + const token = await issueResetToken(c.env, recordId, email); + // 連結的主機=這次請求真正抵達的位址(郵差待會兒就是回頭打這裡確認的) + const apiOrigin = new URL(c.req.url).origin; + const link = `${apiOrigin}/portal/password/reset-link?token=${encodeURIComponent(token)}`; + const ticket = randomHex(16); + await c.env.SESSIONS_KV.put(`${RELAY_TICKET_PREFIX}${ticket}`, JSON.stringify({ email, link }), { + expirationTtl: RELAY_TICKET_TTL_SECONDS, + }); + await relayResetLink(c.env, apiOrigin, email, ticket); // 寄不出去也回同一句(存在性不可由回應推得) + return c.json(generic); + }), +); + +// GET /portal/password/reset?token=… — 點進連結時先問「這張票還有效嗎」(**不消耗**)。 +// 回 email 讓畫面顯示「你正在為 xxx@yyy 設定新密碼」——票本身就證明持有人控制那個信箱。 +portalRouter.get('/portal/password/reset', (c) => + run(c, async () => { + const payload = await peekResetToken(c.env, c.req.query('token') ?? ''); + if (!payload) { + return c.json( + { valid: false, error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。' }, + 400, + ); + } + return c.json({ valid: true, email: payload.email }); + }), +); + +/** + * POST /portal/password/change — **D62 的那一支**:修改密碼與忘記密碼共用。 + * + * body:`{ new, current? , reset_token? }` + * - 帶 `reset_token`(從信裡的連結來)→ **忽略 current**,票就是憑據 + * - 沒帶 → 必須是登入狀態 + 提供正確的 `current` + * 兩條路在這一行之後**完全相同**(writeNewPassword)——這就是「差別只有一格」的實體。 + */ +async function handlePasswordChange(c: Context<{ Bindings: Bindings }>): Promise { + const body = await c.req.json().catch(() => null); + const next = String(body?.new ?? ''); + const resetToken = String(body?.reset_token ?? '').trim(); + if (!next) return c.json({ error: 'new(新密碼)必填' }, 400); + if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400); + + // ── 忘記密碼那一格:憑連結,不問現有密碼 ── + if (resetToken) { + const payload = await consumeResetToken(c.env, resetToken); + if (!payload) { + return c.json( + { error: '這條連結已經失效了(只能用一次、30 分鐘內有效)。請回登入頁重新按一次「忘記密碼」。', code: 'reset_token_invalid' }, + 400, + ); + } + await writeNewPassword(c.env, payload.record_id, next); + return c.json({ success: true, email: payload.email, via: 'reset_link' }); + } + + // ── 修改密碼那一格:要登入、要現有密碼 ── + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const current = String(body?.current ?? ''); + if (!current) return c.json({ error: 'current(現有密碼)必填' }, 400); + const ok = await verifyPassword(current, auth.user.values.password_hash ?? ''); + if (!ok) return c.json({ error: '舊密碼不正確' }, 401); + + await writeNewPassword(c.env, auth.user.recordId, next); + return c.json({ success: true, via: 'current_password' }); +} + +portalRouter.post('/portal/password/change', (c) => run(c, () => handlePasswordChange(c))); + +// POST /portal/me/password — 舊名,**轉呼同一支**(現有前端與 CLI 還在用這個路徑)。 +// 保留別名而不是留第二份實作:兩份必然漂移(D39/arcrun-rag#40「同一個事實兩份」)。 +portalRouter.post('/portal/me/password', (c) => run(c, () => handlePasswordChange(c))); + // ═══════════════════════════════ admin 端點 ══════════════════════════════════ // POST /portal/admin/bootstrap — 需 **console owner session**(design D-7:owner secret 是 diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index a930ae0..fc6ea36 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -115,6 +115,19 @@ export type Bindings = { // expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。 // 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。 PORTAL_SESSION_TTL?: string; + // Portal / console 前端站的 origin 白名單(逗號分隔,非機密)。index.ts 的 CORS 讀它; + // D62 的「修改密碼」連結也用它當「使用者會看到的那個網址」(未設 → 用 workers.dev 兄弟位址推導)。 + UI_ORIGINS?: string; + // ── D62「忘記密碼」=寄一條「修改密碼」連結(非機密)─────────────────────────── + // 中央代寄服務的 base URL(landing worker)。**用戶自己的實例沒有寄信能力**——安裝器 + // 部署 cypher 的 binding 只有 ai/d1/kv/plain_text/secret_text/service/vectorize, + // **沒有 send_email**;能寄信的是我們 landing 的 CF Email Service(寄件網域 arcrun.dev)。 + // 未設 → /portal/password/forgot 誠實回 503 `mail_relay_not_configured`,不假裝寄出去了。 + // ⚠️ 「由中央代寄」是依 leo「寄給你」推導的**假設**,尚待他正式表態(D62 未裁前置)。 + PORTAL_MAIL_RELAY_BASE?: string; + // 代寄服務的共享秘密(可選)。設了就在代寄請求帶 X-Arcrun-Relay-Key,讓 landing 端 + // 分辨「這是我們自己的實例」。未設=不帶(landing 端仍有速率限制與固定樣板)。 + PORTAL_MAIL_RELAY_KEY?: string; // Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。 // 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session // 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。