三層模型確實有效。本系列第 1 部分是那個證明它的發票事件。第 2 部分是 proxy.ts 的 matcher 漏洞,會靜默失敗。第 3 部分是 mutation 層的驗證檢查,即使其他一切看起來都很穩固,它還是會被跳過。我的套件是把這套做在 Route Handlers 而不是 Server Actions,但原理完全相同:每個變更端點在碰資料之前,都會先驗證呼叫者。

我把這三層都正確地做完了。接著我想在上面做出一整套完整的驗證套件。

電子郵件與密碼登入。Google 和 GitHub OAuth。電子郵件驗證。密碼重設。依角色分流的路由。一個帶即時資料庫統計的管理員面板。以及一個真正的人會放心輸入真實密碼的 UI。

就在那時,整合 bug 出現了。不是架構 bug,而是接線 bug。這種 bug 只有在每個零件都接起來、真實流程真的跑完整條路徑時才會出現。

一共有五個。下面就是實際發生的情況。

Bug 1:Cookie 寫入顯示成功,然後就消失了

登入回傳 200。回應裡有 token。客戶端寫入 cookie。下一個請求立刻又被導回登入頁。

我先從 JWT 下手。JWT 沒問題。proxy 也沒問題。只是下一次請求根本沒有 cookie。

實際發生的是這樣:成功登入之後,客戶端用 document.cookie = ... 寫 cookie。在 localhost 的某些瀏覽器與時序組合下,這個寫入會回報成功。document.cookie.includes('auth_tokens') 在賦值後立刻回傳 true。然後幾秒鐘後,就不見了。沒有錯誤,沒有警告。

問題不在時序,而是應該把寫入移到它本來就屬於的位置。

// app/api/auth/login/route.ts
const COOKIE_NAME = process.env.NEXT_PUBLIC_AUTH_COOKIE_NAME ?? "auth_tokens";

// ... after building responseBody and cookieTokens ...

const response = NextResponse.json(responseBody, { status: 200 });

const isSecure =
  request.headers.get("x-forwarded-proto") === "https" ||
  request.nextUrl.protocol === "https:";

const maxAge = rememberMe === true ? 60 * 60 * 24 * 30 : 60 * 60 * 24 * 7;

response.cookies.set(COOKIE_NAME, JSON.stringify(cookieTokens), {
  httpOnly: true,
  sameSite: "lax",
  secure: isSecure,
  path: "/",
  maxAge,
});

return response;

httpOnly: true 的意思是頁面上的 JavaScript 完全無法讀取這個 cookie。就算有 XSS 漏洞注入的腳本,也無法竊取 session token,因為 token 對所有腳本都不可見。瀏覽器會從 Set-Cookie 回應標頭自動儲存它,不會再有那種悄悄消失的情況。

如同第 1 部分所說,proxy 讀的是 cookie 裡的 session,而不是 localStoragehttpOnly 一次解決兩個問題:proxy 看得到 cookie,客戶端腳本也碰不到它。

如果客戶端在寫一個本來就應該由伺服器來寫的東西,就別再除錯那段客戶端寫入了。把它移走。

Bug 2:導航根本沒有走到 Proxy

Cookie 修好了,伺服器設的,httpOnly 也有。還是被導回登入頁。

登入 API 回傳 200 之後,表單原本是呼叫 window.location.href。但在找到這個修正之前,我先試過 router.push(redirectTo)。第二個 bug 就藏在那裡。

router.push 是客戶端導航,不會真的發出 HTTP 請求。瀏覽器沒有理由把剛剛設好的 cookie 附加到任何 outgoing 請求上,因為根本沒有經過網路。proxy.ts 只能看到真實的 HTTP 請求。留在 React router 裡的路由切換,對它來說是不可見的。這和第 2 部分解釋 matcher 漏洞為什麼在 proxy 層看不見是同一條邊界:proxy 無法檢查它從來沒看見的東西。

登入表單正是因為這個原因才使用 window.location.href

// app/login/login-form.tsx
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
  e.preventDefault();
  setError(null);
  setIsSubmitting(true);

  try {
    await login(email.trim(), password, rememberMe);
    window.location.href = redirectTo; // real HTTP GET, proxy sees the cookie
  } catch (err) {
    // ... error handling
    setIsSubmitting(false);
  }
}

window.location.href 會強制整頁重新載入,也就是一次真正的 HTTP GET。瀏覽器會附帶它持有的所有 cookie。proxy 看得到,因為這個請求真的有跨過網路。

Bug 3:JWT 的 sub 宣告裡放進了資料庫整數

Cookie 正常送達。proxy 仍然拒絕 token。

除錯 log 顯示 JWT 解碼完全正常。每個 claim 都在。extractPayload 裡的型別守衛一直在 sub 上失敗。typeof sub !== 'string' 一直回傳 true,即使那個值看起來就是一個 user ID。

資料庫的 id 欄位是 SERIAL,也就是 Postgres 整數。當這個整數在沒有明確轉型的情況下傳給 jose.setSubject() 時,它會被序列化成 token 裡的數字。extractPayload 函式會檢查 typeof sub !== "string",如果不符合就丟錯。它做得完全正確。問題是上游對整數主鍵的假設從來就不成立。

修正在 lib/auth/jwt.tssignAccessTokensignRefreshToken 都要改:

// lib/auth/jwt.ts
export async function signAccessToken(
  payload: SignablePayload,
): Promise<string> {
  return (
    new SignJWT({
      role: payload.role,
      email: payload.email,
      permissions: payload.permissions,
    })
      .setProtectedHeader({ alg: "HS256" })
      // Always cast to string here. Postgres ids that come through as
      // numbers will otherwise get signed as numbers, and proxy.ts expects
      // sub to always be a string when it verifies the token.
      .setSubject(String(payload.sub))
      .setIssuedAt()
      .setExpirationTime(ACCESS_TOKEN_TTL)
      .sign(ACCESS_SECRET)
  );
}

signAccessTokensignRefreshToken 裡都加上 String(payload.sub)。就一個字。抓出這個問題的 extractPayload 型別守衛是對的。錯的是那個 claim 格式不正確,只是因為數字和數字字串在 console log 裡看起來一模一樣,所以不容易看出來。

如果測試資料用的是字串 ID,這個 bug 在開發環境裡不會出現。只有當真實的 SERIAL 欄位遇上會嚴格驗證 claim 型別的 JWT 函式庫時,它才會浮現。

Bug 4:同一個 Email 已經有密碼帳號時,又用 OAuth 登入

電子郵件與密碼驗證已經端到端正常運作。現在可以加 Google 和 GitHub 了。

OAuth 流程本身文件寫得很清楚。真正要回答的問題是:如果某個原本已有密碼帳號的人,之後又用同一個 email 地址點了「Continue with Google」,會發生什麼事?

callback 路由處理三種不同情況:

// app/api/auth/google/callback/route.ts

// Case 1: they've signed in with Google before, google_id already set
let user = await db.queryOne<UserRow>(
  `SELECT id, email, name, role, is_active, google_id
   FROM authkit_test_users
   WHERE google_id = $1`,
  [googleUser.sub],
);

if (!user) {
  // Case 2: no google_id match, but email matches an existing row
  const existingByEmail = await db.queryOne<UserRow>(
    `SELECT id, email, name, role, is_active, google_id
     FROM authkit_test_users
     WHERE email = $1`,
    [normalizedEmail],
  );

  if (existingByEmail) {
    // Linking is safe here because Google has already verified this
    // email belongs to whoever is sitting at the browser right now.
    // email_verified gets set true at the same time, in case they
    // registered with a password earlier and never finished clicking
    // their own verification link.
    await db.query(
      `UPDATE authkit_test_users
       SET google_id = $1, email_verified = true
       WHERE id = $2`,
      [googleUser.sub, existingByEmail.id],
    );
    user = existingByEmail;
  } else {
    // Case 3: brand new person, create the row
    // password_hash stays null, role always defaults to 'user'
    const rows = await db.query<UserRow>(
      `INSERT INTO authkit_test_users
         (email, password_hash, name, role, is_active, email_verified, google_id)
       VALUES ($1, NULL, $2, 'user', true, true, $3)
       RETURNING id, email, name, role, is_active, google_id`,
      [normalizedEmail, googleUser.name ?? null, googleUser.sub],
    );
    user = rows[0] ?? null;
  }
}

第 2 種情況之所以安全,是因為 email 已經被對方驗證過了。Google 已經確認這個 email 屬於現在坐在瀏覽器前的人。這個保證比多數應用程式自己的驗證流程還強。這種模式不安全的版本,是只要有人在欄位裡輸入 email 就直接把帳號綁起來,完全沒有第三方驗證。

新帳號的角色一律預設為 'user'。沒有任何訪客能透過登入按鈕走到管理員權限。

GitHub 比 Google 多需要一步。Google 在基本 profile request 裡總會回傳已驗證 email,但 GitHub 不會。很多使用者會把 email 設成私人,所以 profile 回應裡會是 email: null。解法是再打一次 GitHub 的 emails endpoint,篩出同時標記為 primary 與 verified 的地址。沒有已驗證的 primary email,這個帳號就不能用這種方式登入。

登入頁面在電子郵件與密碼表單上方顯示 Google 和 GitHub 登入按鈕,包含記住我勾選框與忘記密碼連結

同一個登入畫面上的兩個提供者。Bug 4 裡的三種帳號查詢方式,讓同一個 email 已經存在於密碼註冊時仍然安全。

Bug 5:對於從未設定過密碼的帳號按了忘記密碼

這個問題只有在你同時做了 OAuth 和電子郵件/密碼登入,並把它們放進同一個產品時才會冒出來。

使用者先用 Google 註冊。幾個月後,他到忘記密碼頁面輸入 email,因為他真的忘了自己一直都是用 Google 按鈕進來,從來沒有設定過密碼。

如果沒有處理這種情況,對方就會收到一封針對「從未存在過的密碼」的密碼重設信。連結技術上能用,但整體體驗完全說不通,使用者只會比之前更困惑。

password_hash 欄位對每個純 OAuth 帳號都維持 null。判斷就是這個:

// app/api/auth/forgot-password/route.ts

if (!user.password_hash) {
  // This account signed in through an OAuth provider and never set a
  // password. Send a message pointing at whichever one they actually
  // used rather than sending a reset link for a password that doesn't exist.
  const provider = user.google_id
    ? "Google"
    : user.github_id
      ? "GitHub"
      : "Google or GitHub";

  try {
    await resend.emails.send({
      from: process.env.RESEND_FROM_EMAIL ?? "[email protected]",
      to: normalizedEmail,
      subject: `Sign in with ${provider}`,
      html: buildOAuthOnlyEmail({ name: user.name, provider }),
    });
  } catch {
    // Email failed to send, the generic response goes out either way
  }
  return NextResponse.json(genericSuccessBody, { status: 200 });
}

buildOAuthOnlyEmail 函式也在同一個檔案裡。它會產生一封簡單的 HTML 電子郵件,告訴對方這個帳號是用那個提供者建立的,並把他們連回登入頁。沒有密碼重設連結,也沒有混亂。

不管走哪個分支,瀏覽器收到的回應都一樣:

const genericSuccessBody = {
  message:
    "If an account exists for this email, we've sent you instructions.",
};

同樣的訊息,同樣的 200 狀態,無論使用者有沒有密碼、是否使用 OAuth、或帳號根本不存在。這個端點絕對不該確認或否認你的系統裡有哪些 email 地址。

忘記密碼頁面,深色背景上只有單一 email 欄位與送出重設連結按鈕

同一個頁面,對瀏覽器來說回應也一樣,無論提交的是哪個 email。對 password_hash 的分支判斷完全在伺服器端進行。

這五個 bug 清掉之後實際上推出了什麼

使用 bcrypt 雜湊的註冊、即時密碼強度條,以及條款勾選框。透過 Resend 的電子郵件驗證與密碼重設,並且各自有不同的 token 存活時間:驗證 24 小時、重設 1 小時。Google 和 GitHub OAuth,外加上面那套帳號綁定邏輯。依角色分流的路由。一個從資料庫拉即時統計數字的管理員面板。以及當有人直接打到自己角色不該看的路由時,顯示 403 頁面而不是直接崩潰。

第 1 部分提到的資料層擁有權模式,會跑在每一個查詢上。SQL 內直接寫 WHERE user_id = $1,而不是在結果外層包一層。第 3 部分提到的驗證檢查模式,會跑在每一個 mutation 端點上。先驗證呼叫者,再碰資料。這兩者都已經原封不動地放進這個套件,和本系列前面幾篇文章寫的一模一樣。

管理員面板顯示四個統計卡片:總使用者數、啟用使用者數、已驗證 email、管理員帳號,深色背景

管理員面板中的即時資料庫統計。沒有 admin 角色的人如果直接打這個網址,會進到 403 頁面。

還有一個值得提的 Tailwind v4 問題,因為它完全不會報錯。Next.js 16 搭配 Turbopack 時,有個已知的檔案監看問題,會讓 Tailwind 的 class 偵測完全漏掉 .tsx 檔。建置正常、沒有警告,但 HTML 完全沒套樣式。修正方式如下:

/* app/globals.css — add after @import "tailwindcss" */
@source "../app/**/*.{ts,tsx}";
@source "../components/**/*.{ts,tsx}";
@source "../lib/**/*.{ts,tsx}";

三行,畫面就會正確渲染。

這次完成後,驗證清單還要再加的三件事

本系列第 1 到第 3 部分整理出了一份清單:那些在本機可用、到了正式環境就壞掉的東西。現在還要再加三項。

客戶端的 cookie 寫入可能會沒有任何錯誤訊息就直接消失。 把寫入移到伺服器端,用 response.cookies.set()。不管時序問題如何,這才是它該在的位置。

登入後使用 router.push 不會觸發 proxy。 如果 proxy 需要在下一個請求讀到新設定的 cookie,那個請求就必須是一次真正的 HTTP GET。請用 window.location.href

在簽 JWT 前,把整數主鍵包成 String().setSubject(String(payload.sub)),不要用 .setSubject(payload.sub)SERIAL 欄位是整數。抓出這個問題的 JWT 型別守衛是正確的,錯的是 claim 格式不正確,只是不容易直接看出來。

取得這個 Auth Kit

本系列做出來的所有東西都整合成一個套件:三層安全、電子郵件與密碼、Google 和 GitHub OAuth、電子郵件驗證、密碼重設、依角色存取、管理員面板,以及部署指南。Next.js 16 Auth Kit at shubhra.dev

完整實作教學,包含所有程式碼檔案、資料庫 schema 與部署說明,請見 I Built a Next.js 16 Auth Kit. Every Edge Case I Found Is in Here

還有人在上線前碰過 OAuth 忘記密碼的邊界案例嗎?我是在這次實作之後才開始特別注意這件事。比我想像中常見。用 Google 註冊的人,真的會在之後跑去忘記密碼頁面,因為他們忘了自己最初是怎麼登入的。很好奇其他人是提早抓到,還是等使用者回報才發現。

註:圖片編修使用了 AI。


原文出處:https://dev.to/shubhradev/my-nextjs-16-auth-passed-every-test-five-bugs-that-only-showed-up-when-i-wired-it-together-bgh


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝17   💬3   ❤️3
678
🥈
我愛JS
📝2   💬3   ❤️3
129
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登