iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Modern Web

用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)系列 第 24

想幫內容站加登入和收藏,2026 的 Astro 該把驗證交給誰?

  • 分享至 

  • xImage
  •  

Day 23:middleware、locals 與 island 的狀態邊界已經把完整資料流拆開:每個 request 都由 middleware 辨認使用者,再把結果放進該次 request 的 locals.user。接下來要處理那份 user/session 怎麼產生、cookie 誰簽、資料存哪裡,再把「收藏文章」限制為只有登入者能執行的動作。

動手串接之前,得先決定要把驗證交給誰。登入幾乎一定會採用現成方案,而選哪一個,是這個系列裡保存期限最短的決定。

auth 教學和選型都會過期

我照著別人的教學做實作,已經遇過停更或棄用的套件。更常見的情況沒那麼戲劇化:套件只要改版,幾個操作步驟就可能和教學對不上。舊套件會如此,熱門的新套件也會。最近很紅的 hermes agent 就是一例,光是串 Google Chat,幾個步驟的實際做法已經和教學不同。這類版本落差本來就是開發日常。

這個系列也遇到了同樣的事。最初的策略文件把會員登入鎖定為 Auth.js,並列出 Firebase、自建 Session、Lucia 當備選。不到兩年後重新查證,結果如下(查證日期皆為 2026-07-21):

  • Auth.js 核心仍在維護,但 Astro 專用的社群橋接 auth-astro 最後一次發佈是 2024-12-03,停更超過一年半。README 寫著「只支援到 Astro 5、等官方 PR 合併後就棄用」,而且它已經從 Astro 官方的 auth 指南裡消失。
  • Lucia 在 2025-03 正式棄用,npm 上帶著 deprecation notice,官網也改成「教你怎麼實作驗證」的學習資源,不再以可安裝套件的形式維護。

四個當年看起來合理的選項,過兩年已有兩個不能照原樣使用。問題不在當初選錯,而是這類知識的保存期限很短。動手當天得重新判斷該把驗證交給誰。

選 auth 方案前,先問三題

順序別反,由外而內:

  1. 框架官方現在推薦誰? 先看框架自己的 auth 指南。列在第一位的方案通常仍在維護,也較可能與框架相容;官方不再提的方案,得先查清楚現況。
  2. 這個套件最近還在發版嗎? 去 npm 看最後發佈日期。負責「橋接你的框架」的套件若停更半年以上,就要考慮框架升版後跟不上的風險。
  3. 它跟你的 runtime 和資料層合得來嗎? 目標是跑在 Cloudflare Workers 這種 edge 環境,資料層則是 Drizzle + libSQL。方案必須支援這個組合;只在教學作者的 Node + Postgres 環境能跑還不夠。

用這三題檢查,auth-astro 不符合第 1、2 題;Lucia 更早就停在「還能不能裝」。

為什麼選 Better Auth

依前面三題,選 Better Auth 的理由有三個:

  • Astro 官方的 auth 指南現在把它列在第一位。
  • 框架無關、能跑在 Cloudflare Workers。
  • 原生支援 Drizzle + SQLite,session 直接存進上一階段已建立的 Turso,收藏表外鍵接它的 user.id,不必為了 auth 再另外維護一套儲存。

把幾個選項放在一起比較,先看「現在還能不能照抄」:

方案 現在還能照抄嗎 為什麼
Better Auth ✅ 選它 官方首選、可跑 Workers、原生 Drizzle + SQLite
Auth.js ⚠️ 不建議 核心仍在維護,但 Astro 橋接 auth-astro 停更、只到 Astro 5,已不在官方指南內
Lucia ❌ 不能 2025-03 棄用,已改成教學資源
自己刻 🟡 特殊情況才值得 後文細講

第一次接觸 Better Auth,可以先把它理解成處理登入細節的套件:cookie 簽章、與 GitHub 交換登入資訊、CSRF 防護和 session 簽發,都不必自己寫。你只要提供資料庫位置,並設定要開啟哪些登入方式。

登入到底在處理什麼

後面會反覆用到四個詞:

  • 驗證(authentication):確認「你是你」。登入就是驗證。
  • session:登入之後,伺服器記得「這個瀏覽器是誰」的那份狀態。
  • cookie:瀏覽器每次請求都會自動帶上的一小段資料,登入後那段就是你的通行證。
  • OAuth:不自己存密碼,改成「用 GitHub / Google 帳號登入」,把驗證外包給大平台,你只拿到「這個人是 GitHub 上的某某」。

session 存哪:DB session 還是 JWT

登入之後那份「你是誰」的狀態要放哪,有兩種做法:

  • JWT(無狀態):把使用者資訊直接簽進 cookie,伺服器不查資料庫就知道你是誰。快,但登出、封鎖不能即時生效,要等 token 自己過期。
  • DB session:cookie 裡只放一個 token,真正的 session 存在資料庫。每次請求多一次查詢,但可以即時撤銷。

這裡採用 Better Auth 預設的 DB session,並把 session 存進現有的 Turso。原因有三個:

  1. 收藏功能本來就需要穩定的 user.id 當外鍵,DB session 讓使用者資料和收藏留在同一個資料庫。
  2. edge 上會「多查一次 Turso」,但成本與上一篇 feedback 每次請求讀寫 Turso 是同一個量級,仍屬既有的查詢負擔。
  3. Better Auth 提供 cookie 快取來省下這次查詢;最小場景暫時不需要開。

動手:從 schema 到能登入

第一步:安裝套件(版本基準 2026-07-21:better-auth 1.6.23):

npm install better-auth

第二步:用官方 CLI 產 schema,不要手抄。 Better Auth 對四張核心表(user / session / account / verification)的欄位名稱與型別有固定規則;只要抄錯一個,套件就讀不到。先用 CLI 產生,再併進既有的 src/db/schema.ts

npx @better-auth/cli generate --config scripts/auth-schema.config.ts --output ./tmp.ts

產生後,把四張表併進 schema,再沿用上一篇的 drizzle-kit generatemigrate 流程。Better Auth 內建的 migrate 只支援 Kysely;這裡使用 Drizzle,因此 migration 仍由既有的 drizzle-kit 套用,不必另學一套。

同一份 migration 再加上「收藏」表,外鍵接 Better Auth 的 user.id

CREATE TABLE `favorites` (
	`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
	`user_id` text NOT NULL,
	`slug` text NOT NULL,
	`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
	FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `favorites_user_slug_unq` ON `favorites` (`user_id`,`slug`);

(user_id, slug) 設成唯一,所以同一個人對同一篇文章只會有一筆收藏。

第三步:建立 auth 實例。 資料庫連線仍遵守上一篇的條件:只在收到請求後建立,不放在模組頂層。

// src/lib/auth.ts
export function createAuth(baseURL: string) {
  const db = createDb(TURSO_DATABASE_URL, TURSO_AUTH_TOKEN); // 收藏查詢跟 auth 共用同一個
  return betterAuth({
    baseURL,
    secret: BETTER_AUTH_SECRET,
    database: drizzleAdapter(db, { provider: 'sqlite' }), // libSQL / Turso 用 sqlite provider
    emailAndPassword: { enabled: true },
    // GitHub 的 secret 齊了才掛(正式站主推 GitHub 登入)
  });
}

兩個 import 路徑容易寫錯:adapter 的 import 路徑是 better-auth/adapters/drizzle,不是 @better-auth/drizzle-adapter;Vue island 使用的 client 在 better-auth/vue

這和上一篇的資料庫連線限制相同:Workers 在 build 與模組載入階段拿不到 secret,因此 auth 和 db 都要等請求進入 handler 後再建立。所有 /api/auth/* 的請求交給一個 catch-all endpoint 處理:

// src/pages/api/auth/[...all].ts
export const prerender = false;
export const ALL: APIRoute = (ctx) => createAuth(ctx.url.origin).handler(ctx.request);

接上這個 catch-all endpoint 後,登入、登出、OAuth callback 與 session 讀取路徑就能運作。

middleware 讀 secret,會讓 build 失敗

middleware 需要讀取 session,再把使用者放進 locals。若用靜態 import 載入 createAuth,寫法會像這樣:

import { createAuth } from './lib/auth';   // ← 靜態 import,就是這行害的

接著執行 npm run build 會失敗:

[ERROR] EnvInvalidVariables: TURSO_DATABASE_URL is missing

原因在 astro:env secret 的讀取時機:它會在「模組載入時」讀取,不會等到實際使用。middleware 會經過每個請求,也包含 build 預先產生靜態頁時的請求。只要 middleware 靜態 import 會讀 secret 的模組,build 每產生一頁就會嘗試讀取 secret;建置環境沒有設定 secret,便會拋出上面的錯誤。

Day 22 的資料層沒有遇到這個問題,因為當時只有 action 這種「有請求才會載入」的模組會碰 secret;middleware 則會經過每個頁面。

解法是改用動態 import,只在需要查 session 時載入該模組:

// src/middleware.ts
export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = null;
  context.locals.session = null;

  // 沒有 cookie 就一定沒登入(build 時的靜態頁請求也沒有 cookie):直接跳過,不碰 DB 和 secret
  if (context.isPrerendered || !context.request.headers.get('cookie')) return next();

  const { createAuth } = await import('./lib/auth'); // 用到才載入
  const auth = createAuth(context.url.origin);
  const data = await auth.api.getSession({ headers: context.request.headers });
  context.locals.user = data?.user ?? null;
  context.locals.session = data?.session ?? null;
  return next();
});

這也會省下一次查詢:沒有 cookie 的匿名請求不會進資料庫查 session,能減少 edge 上不必要的查詢。

Day 24 的實作有一條邊界:個人化的登入狀態不能放進預先產生的靜態頁。內容站的文章頁在 build 時就產生固定的 HTML,不可能「build 時就知道現在這個訪客登入了沒」。因此分工如下:

  • 需要知道登入狀態的頁(登入面板、收藏切換所在的示範頁)要 export const prerender = false,改成收到請求時才算的動態頁。
  • 純內容文章頁可以維持靜態;但放在靜態頁裡的 island 無法在 build 時取得目前訪客,必須等 hydrate 後另查 session,並處理 loading/狀態切換。本篇的 /demos/auth 是 on-demand 頁,由 server 算好登入狀態後再傳 props;這套做法不能直接套在靜態文章頁。

收藏:用登入身分擋住一個動作

收藏 action 只允許登入者執行,因此先在伺服器端用 locals.user 檢查;未登入就直接回錯:

// src/actions/index.ts
toggleFavorite: defineAction({
  input: z.object({ slug: z.string().min(1) }),
  handler: async ({ slug }, ctx) => {
    const user = ctx.locals.user;
    if (!user) throw new ActionError({ code: 'UNAUTHORIZED', message: '請先登入才能收藏文章' });
    // 已登入:對 favorites 做 toggle(有就刪、沒有就加),回傳最新狀態
  },
}),

on-demand 的 /demos/auth 會由伺服器把 isLoggedIninitialFavorited(登入才查)當 props 傳給 FavoriteButton.vue,因此 island 不必為初始畫面再查一次 session。使用者點下按鈕呼叫 action 時會產生新的 request,middleware 仍要重新辨認使用者,這次檢查不能省。互動採樂觀更新、失敗回滾;未登入時,按鈕會顯示「登入以收藏」,點下後走 GitHub 登入。

登入前後的收藏 UX 如下:

  • 未登入:收藏鈕顯示「登入以收藏」,點了導去登入。
  • 登入後:同一顆鈕變成愛心,點一下收藏、再點取消,即時反應,不換頁。

這套組合的實測結果

auth 屬於易變的高風險主題,以下用 build 與本機資料寫入來驗證。

build 過不過? npm run build 在真實的 @astrojs/cloudflare adapter 下通過:21 個靜態頁順利產出,建置過程不會載入 auth;登入示範頁則被編成動態 function。這個結果確認「Better Auth + Cloudflare adapter + Vue island + Drizzle」的組合能通過 adapter 建置。

Better Auth 真的把資料寫進 libSQL 了嗎? 在本機跑一次註冊加登入,直接查資料庫:

→ signUpEmail:user + token ✓
→ signInEmail(同一個帳號):user + token ✓
=== Better Auth 寫進 libSQL 的資料 ===
users: 1 | sessions: 2 | accounts: 1(provider = credential)

一次註冊和一次登入各寫入一筆 session,account 表也有一筆資料。

目前的驗證缺口:上面的測試使用 Node 連本機檔案,已驗證「Better Auth ↔ Drizzle ↔ libSQL」這條 SQL 鏈。app 正式跑在 Workers 上,改用只走 HTTP 的 @libsql/client/web;目前還沒有在真實 workerd 中完成整套登入測試。要補上這層驗證,需先把本機 libSQL 服務成 HTTP,再用 wrangler 執行一次。差異位於傳輸層,不在 SQL;正式上線前仍要補測。

Cloudflare 上線前的幾個必要設定

  • nodejs_compat 是必要設定:Better Auth 用到 AsyncLocalStoragewrangler.jsonc 要有 nodejs_compatcompatibility_date 則要在 2024-09-23 之後。缺少這項設定時,Better Auth 無法在 Workers 上執行。
  • secret 走 astro:envBETTER_AUTH_SECRET(至少 32 個字元的高熵字串)、GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET,跟上一篇的 Turso secret 用同一套機制;本機放 .dev.vars,上線用 wrangler secret put
  • GitHub OAuth callback{你的網域}/api/auth/callback/github

什麼情況才值得「自己刻」?

有些課程會教你自己寫登入,但教學通常只示範最簡單的判斷條件,正式上線還有更多使用情境要處理。登入也是多數產品的固定流程,已有成熟方案可用。freeCodeCamp 一開始就提到 DRY(Don't Repeat Yourself),登入正是適合套用這個原則的地方。

自己實作完整登入系統,光這一項就可能吃掉整個開發時程一半以上。cookie 簽章、OAuth code 交換、CSRF、session 輪替都是安全細節,任何一項出錯都可能形成漏洞。採用官方指南推薦且持續維護的套件,可以把開發時間留給產品真正獨特的功能。因此,登入不建議自己刻。例外是把「親手實作一次 cookie 簽章和 OAuth 流程」當作學習主題;這時目標是理解原理,不是直接上線。

幾個容易誤會的地方

  • 四張 auth 表用 CLI 產、別手抄;但套用 migration 是走你自己的 drizzle-kit,不是 Better Auth 內建的那套。
  • account 表一張表兩用:email/password 登入時,它的 password 欄位存的是雜湊過的密碼;OAuth 登入則存 provider 的 token。
  • 本機想跑登入邏輯,用 email/password 就好,不必先去 GitHub 申請 OAuth App。這樣讀者跟著做時,第一步的門檻低很多。
  • 「選 Better Auth」這個結論本身也有保存期限。 它只代表動手當天依三項判準得到的答案。明年再看,先把上面三題重跑一遍。

選型判準與功能邊界

locals.user 現在有了驗證來源,收藏 action 也能拒絕未登入的請求。Better Auth 的安裝方式會過期,三個選型問題比較耐用:官方現在推薦誰、套件是否仍在發版、是否支援你的 runtime 與資料層。今天選出的套件,明年不保證仍然適用。

這個內容站的會員功能停在「登入 + 收藏 + feedback」:不做完整會員中心、角色權限或後台,因為這些功能不是目前內容站的需求。功能邊界由網站需求決定;系列最後還會再檢查一次「這個站真正需要多少」,而這裡的會員能力到此已經足夠。


上一篇
登入狀態要放哪?Astro 的 locals 與 Vue island 怎麼分工?
下一篇
多頁網站想要 SPA 般的轉場,不寫一堆 JS 做得到嗎?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言