建置白標 SaaS 最困難的部分,不是 AI、也不是自訂網域或計費,而是單一個登入表單。
我開發了 VoiceDash,一個白標平台,讓代理商能以自己的品牌將 AI 語音代理轉售給自己的客戶。理論上這是一款語音產品,但實際上讓我夜不能寐的其實是驗證機制,因為白標產品存在一個大多數應用程式從未遇過的結構性問題:同一個登入表單必須同時服務兩種截然不同的使用者,而且這兩種使用者永遠不允許彼此轉換。
這是我如何透過單一驗證供應商以及一個小小的判別欄位解決這個問題的故事,以及差點毀掉整個專案的 edge-runtime 陷阱。
兩種永遠不得重疊的使用者
層級結構如下:代理商註冊後成為一個 Workspace。Workspace 內的代理商員工是 WorkspaceMembers。代理商接著建立 Clients,每個 Client 又有各自的 ClientMembers,也就是實際登入並看到代理商品牌的入口網站的使用者。
因此登入畫面會面對兩種人:
- 代理商使用者:我的付費客戶。他們以電子郵件和密碼登入,管理所有事物:客戶、代理、計費、品牌。
- 客戶成員:我客戶的客戶。他們登入的是代理商品牌的入口網站,只能看到自己所屬客戶的資料,完全不知道 VoiceDash 的存在。
最重要的不變條件是:客戶成員絕對不能存取代理商路由,也絕對不能看到其他客戶的資料。一旦失效,一家代理商的客戶就能讀取另一家代理商的客戶資料。那不是 bug,那是公司經營的結束。
最誘人的做法是建立兩套驗證系統:兩個供應商、兩種 session 形狀、兩套中介軟體,全部都要重複一次。我不想維護兩套,因此我只建立了一套。
單一供應商,一個判別欄位
VoiceDash 使用 NextAuth 搭配單一 CredentialsProvider。關鍵在於 credentials 上的 type 欄位,值為 "agency" 或 "client",而 authorize 函式會依此分流:
async authorize(credentials) {
if (!credentials) return null;
const type = (credentials.type as string) || "agency";
if (type === "client") {
// client members log in by loginId OR email, password optional
const loginId = credentials.loginId as string;
if (!loginId) return null;
let clientMember = await prisma.clientMember.findUnique({
where: { loginId },
include: { user: true, client: { include: { workspace: true } }, role: true },
});
// ...fall back to email lookup, verify password only if one is set...
return {
id: clientMember.user.id,
clientId: clientMember.clientId,
workspaceId: clientMember.client.workspaceId,
role: clientMember.role?.name || "Member",
type: "client",
};
}
// agency users log in by email + password
const user = await prisma.user.findUnique({ /* ... */ });
// ...bcrypt compare...
return {
id: user.id,
workspaceId: member?.workspaceId || null,
role: member?.role || "MEMBER",
type: "agency",
};
}
Enter fullscreen mode Exit fullscreen mode
兩條分支確實不同。代理商登入是單純的電子郵件加密碼;客戶登入接受 loginId 或電子郵件,且密碼為選填,因為有些代理商會讓客戶以無密碼的方式,僅用 login ID 登入。兩種流程、兩種查詢、兩種回傳的使用者形狀,但使用同一個供應商,且都回傳帶有 type 的物件。
這個 type 就是整個安全模型的核心。
寫入 token,到處讀取
只回傳使用者物件還不夠,它必須存在於每個後續請求中。VoiceDash 使用 JWT session,因此判別欄位會在登入時寫入 token,並在每次請求時讀回:
// jwt callback: user -> token, on sign in
token.type = (user as any).type;
token.workspaceId = (user as any).workspaceId;
token.clientId = (user as any).clientId;
// session callback: token -> session, on every request
(session as any).type = token.type;
(session as any).workspaceId = token.workspaceId;
(session as any).clientId = token.clientId;
Enter fullscreen mode Exit fullscreen mode
現在應用程式的每個部分都能詢問同一個問題:session.type,就能確切知道目前對話對象的身分。資料查詢會依據 token 中的 workspaceId 或 clientId 進行範圍限定,而非來自用戶端在請求中傳送的任何內容。這最後一點就是整個關鍵:租戶邊界來自已簽章的 token,而非來自 URL 參數或請求主體(用戶端可竄改的內容)。
執行強制規範的閘道
若沒有真正能把人拒於門外的機制,以上都只是理論。在這個版本的 Next.js 中,中介軟體檔案是 proxy.ts,它是每個請求都必須通過的單一閘道:
// agency routes require an agency session
if (pathname.startsWith("/agency")) {
if (!session) return NextResponse.redirect(new URL("/login", req.url));
if ((session as any).type !== "agency") {
return NextResponse.redirect(new URL("/client/agents", req.url));
}
return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode
type !== "agency" 檢查是關鍵所在。已登入的客戶成員若在網址列輸入 /agency/clients,不會得到 500 錯誤或空白頁面,而是會被導回自己的入口網站。同一個檔案也會分割根路徑:在 app 子網域下,根路徑會導向儀表板或登入頁;而在行銷網域下,根路徑則會顯示公開的 landing page。一個檔案、四個決策,全都由同一個 token 驅動。
花了我一整個下午才搞懂的陷阱:edge 無法存取你的資料庫
以下是我希望有人事先告訴我的部分。中介軟體在 edge runtime 上執行,而 edge runtime 無法引入 bcrypt,也無法引入 Prisma。若嘗試這麼做,建置不會優雅地失敗,而會在中介軟體嘗試載入的瞬間崩潰,讓人感覺驗證機制本身壞掉了。
解決方法是將設定拆成兩部分。有一個 edge-safe 的 auth.config.ts,只存放 callbacks、pages、session strategy,以及一個空的 providers: [] 陣列。它不引入任何 Node 相關模組。另一個獨立的 auth.ts 則展開該設定,並加入真正的 CredentialsProvider(包含 bcrypt 和 Prisma),這個檔案只會在 Node runtime 中執行,那些引入才合法。
// auth.config.ts, edge safe, imported by proxy.ts
export const authConfig: NextAuthConfig = {
session: { strategy: "jwt" },
providers: [], // real providers added in auth.ts
callbacks: { /* jwt + session, no Node imports */ },
};
Enter fullscreen mode Exit fullscreen mode
中介軟體引入 edge-safe 的設定,因此它可以在不碰觸 bcrypt 或資料庫的情況下讀取並驗證 token。API 路由則引入完整的 auth.ts。初次看到時會覺得像是重複,但其實不是。這是有意劃分的「可在 edge 執行的程式碼」與「需要資料庫的程式碼」之間的界線。
假如我能回到開始之前
判別欄位是從單一驗證系統服務兩種受眾的最低成本方式,同時也是程式碼庫中最危險的一行程式碼。少一個 type 檢查,就不是小問題,而是權限提升。因此我將它視為任何不變條件來處理:我假設它在某處會被遺忘,而 proxy.ts 中的閘道,正是為了確保在頁面元件中遺漏它不會演變成資安漏洞。
如果你正在建置任何「你的客戶還有客戶」的產品,這就是你應該採用的模式:單一供應商、寫入 token 的單一判別欄位、執行強制規範的單一閘道,以及乾淨的 edge 與 Node 分離,讓你的中介軟體能在不把資料庫拉到 edge 的情況下驗證身分。
若想查看這個模型實際運作的成品,請前往 voice-dash.com。但我真正感到驕傲的,正是上述的驗證模型,也是花費最長時間才弄對的部分。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.