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:
@@ -219,12 +219,42 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">登入</button>
|
||||
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
|
||||
<div style="font-size:13px;color:rgba(var(--ink-rgb),.4);line-height:1.7">你自己就是管理員?<a href="/console/" style="color:var(--amber)">用管理主控台密碼救援自己</a></div>
|
||||
<!-- 🔴 D50/D62:這裡原本寫「忘記密碼請聯絡管理員重設」+一條「用管理主控台密碼救援自己」
|
||||
的 console 連結。**兩條都是已否決的做法**,不准寫回來:
|
||||
① leo:「我要是記得密碼,還來找忘記密碼幹嘛?」——console 登入頁一樣要密碼
|
||||
② console 與 portal 是安裝時**同一組帳密**寫進兩個地方(D50 補刀)
|
||||
⇒ console 不是第二道閘,是同一把鎖的第二個鑰匙孔,往它補洞不會多出任何一條路
|
||||
③ 唯一管理員忘記密碼時「聯絡管理員」=叫他聯絡他自己
|
||||
leo 2026-08-10:「**是對 portal 不是對 console**,這樣 youlin 雖然忘記,
|
||||
我還是可以去 portal 忘記密碼。」⇒ 出口就在這裡。 -->
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">
|
||||
忘記密碼了?<a href="#" id="login-forgot" style="color:var(--amber)">寄一條「修改密碼」連結給我</a>
|
||||
</div>
|
||||
<div id="forgot-box" class="hide" style="display:flex;flex-direction:column;gap:10px;margin-top:4px">
|
||||
<input type="email" id="forgot-email" class="txt" placeholder="你註冊時用的 Email" autocomplete="username">
|
||||
<button class="btn" id="forgot-submit">寄出連結</button>
|
||||
<div id="forgot-status" style="font-size:13.5px;min-height:1.2em;line-height:1.6"></div>
|
||||
</div>
|
||||
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改密碼(D62):忘記密碼點信裡的連結進來的落點。
|
||||
🔴 這個殼**刻意沒有自己的密碼欄位**——真正的表單是設定頁那一份 #pw-form,
|
||||
進到這個模式時被 JS 原封不動搬進 #reset-slot。D62:「同一個畫面、同一條寫入路徑,
|
||||
差別只有一格:現有密碼是要填、還是被連結豁免。」複製一份長得像的就會漂移。 -->
|
||||
<div class="authwrap view" id="v-reset">
|
||||
<div class="authbox">
|
||||
<div>
|
||||
<div style="font-size:22px;font-weight:600;margin-bottom:6px">設定新密碼</div>
|
||||
<div id="reset-who" style="font-size:14px;color:rgba(var(--ink-rgb),.55);line-height:1.7;margin-bottom:14px"></div>
|
||||
<div id="reset-slot"></div>
|
||||
<div id="reset-error" class="err" style="font-size:14px;line-height:1.7"></div>
|
||||
<div style="margin-top:16px;font-size:13.5px"><a href="#/" id="reset-back" style="color:var(--amber)">回登入頁</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首次設定(t49,leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
|
||||
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
|
||||
<div class="authwrap view" id="v-firstsetup">
|
||||
@@ -375,10 +405,14 @@ if (!window.ARCRUN_API_BASE) {
|
||||
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">更改密碼</div>
|
||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<!-- D62(leo 2026-08-10):「改密碼與忘記密碼**這兩個機制其實是一個機制,可以簡化**。」
|
||||
⇒ 這個表單是**整個站唯一一份**改密碼的表單。忘記密碼點連結進來時,
|
||||
它會被原封不動搬到 #v-reset 那個殼裡(不是複製一份長得像的)——
|
||||
差別只有「現有密碼」那一格顯不顯示。同一個畫面、同一條寫入路徑。 -->
|
||||
<div class="panel" id="st-pw-panel">
|
||||
<div style="font-size:17px;font-weight:600" id="pw-title">更改密碼</div>
|
||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)" id="pw-sub">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||||
<div id="pw-form" style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
|
||||
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
|
||||
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
|
||||
@@ -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 {
|
||||
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
|
||||
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
|
||||
}
|
||||
$('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 = '你正在為 <strong>' + esc(x.d.email) + '</strong> 設定新密碼。<br>這條連結只能用一次,<strong>不需要輸入現在的密碼</strong>。';
|
||||
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();
|
||||
|
||||
@@ -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