feat(portal): 改密碼與忘記密碼合成同一個機制,用連結不用一次性密碼(D62)
leo 2026-08-10 拍板:「這兩個機制其實是一個機制,可以簡化。」 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋 ⇒ 同一個畫面、同一條寫入路徑,差別只有「現有密碼」那一格。 後端(cypher-executor/src/routes/portal.ts) - POST /portal/password/change =**那一支**。帶 reset_token 就走連結那一格(忽略 current), 沒帶就要登入 + 正確的 current。兩條路在 writeNewPassword 之後完全相同。 /portal/me/password 保留成**別名轉呼同一支**(不留第二份實作,兩份必然漂移)。 - POST /portal/password/forgot(公開)/GET /portal/password/reset(看票,不消耗) - 連結的安全性(承 D50,不可退讓):**一次有效**(用掉即刪,先刪再回)、 **會過期**(KV TTL 30 分鐘)、**與註冊辨識碼不同源**(現場 crypto 亂數, 只活在本實例 SESSIONS_KV,與 landing SIGNUPS 那組安裝辨識碼毫無關係)。 KV 存的是 token 的 sha256,不是 token 本身。 - 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。 - 🔴 已否決不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。 中央代寄(arcrun-rag landing 那半在該 repo) leo 給的職責切法:實例產生連結、管一次性/過期;arcrun.dev 只是郵差。 必須這樣切的硬理由:用戶自己的實例**沒有 send_email binding**,根本寄不了信。 🔴 總管紅線:**絕不把整條 URL 交給郵差**——寄件網域帶 DKIM,肯收「任意 URL+任意 email」 就是一台開放的釣魚中繼,燒的是整個網域信譽、不可逆。 故只交出本實例 origin + 一張回呼票,並新增 POST /portal/password/relay-verify: 郵差**回頭打這個 origin** 問「這張票是你發的嗎」,冒用別人網域會被那台實例自己否認 ⇒ 主機屬於呼叫方這件事由郵差親自確認,不是相信宣稱。 信裡的連結落點 GET /portal/password/reset-link(主機刻意=被確認過的那個 origin)。 前端(console-ui/public/portal/index.html) - 登入頁「忘記密碼」入口,**在 portal 不在 console**(leo:「是對 portal 不是對 console, 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」) - 拆掉登入頁原有的「用管理主控台密碼救援自己」連結與「忘記密碼請聯絡管理員」 ——兩條都是 D50 已否決的做法(console 與 portal 是同一組帳密的兩個鑰匙孔)。 - #v-reset 殼**沒有自己的密碼欄位**:真正的表單是設定頁那唯一一份 #pw-form, 進入連結模式時被原封不動搬過去,只切換「現有密碼」那一格顯不顯示。 同一個表單元素、同一支送出函式 —— D62「同一個畫面」的字面落地。 驗證:tsc 與 baseline 同為 23 個既有錯誤(零新增);27/27 既有測試綠;前端 JS node --check 過。 stage 實測見交付回報。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
This commit is contained in:
@@ -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<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('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
|
||||
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
|
||||
|
||||
@@ -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<string> {
|
||||
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<ResetTokenPayload | null> {
|
||||
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<ResetTokenPayload | null> {
|
||||
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<void> {
|
||||
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<string, string> = { '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<Response> {
|
||||
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 是
|
||||
|
||||
@@ -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 意外全開)。
|
||||
|
||||
Reference in New Issue
Block a user