iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
佛心分享-SideProject30

Road To Full-Stack:前端轉全端的 Instagram 挑戰系列 第 5

Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 5

  • 分享至 

  • xImage
  •  

本日完成內容

導入 Auth.js(NextAuth v5)並以 Drizzle Adapter 連接 PostgreSQL,採用「資料庫 Session」策略,透過 Middleware 保護路由;同步擴充資料庫 Schema、整理程式分層、調整首頁與測試。

心得

漸漸得我開始習慣在 ChatGPT、Codex、Cursor 之間來回切換,試圖利用他們各自的能力最大化我在時間內能完成的內容,但結果還是常常不盡人意,Cursor 看似最多功能,可即便引入了官方文件他還是會寫出過時的代碼,Codex CLI跟我說他無法上網導致他無法看到最新版本的文件,Codex in IDE 大概介在中間,是我用起來最順的工具,ChatGPT 則是目前用來前期討論,畢竟他能把對話打包成專案,讓他跟我溝通的時候比較有延續性。

另外開始感受到任務切分的重要性,今天一次性要求太多內容,雖然已經跟 ChatGPT 討論過並且讓他產生完整的 prompt 給 Codex 使用,但還一次過多內容還是讓 Codex 寫出不符合我需求的內容,之後應該要改變方式明確任務拆分跟參考資料,測試看看是否能改善工作範圍變大時的產出正確性。

功能概覽

  • 身分驗證:整合 Auth.js + Drizzle Adapter,使用 Credentials Provider(bcrypt 驗證)
  • Session:採「資料庫 Session」(非 JWT),新增 sessions 表並由 Adapter 管理
  • Middleware:以 callbacks.authorized 控制放行;預設保護站內路由
  • Schema:擴充 users 欄位(emailVerified, image),新增 NextAuth 所需表
  • API:新增 app/api/auth/[...nextauth] 路由,導出 Auth.js handlers
  • 型別:擴充 Session.user.id(型別安全地取得使用者 ID)
  • UI:移除 Day 4 的 UsersList 測試頁,首頁改為靜態訊息
  • 測試:更新欄位期望,通過型別與 Lint 檢查

套件與腳本

新增依賴:

{
  "dependencies": {
    "next-auth": "^5.0.0-beta.29",
    "@auth/drizzle-adapter": "^1.9.2"
  }
}

保留 Drizzle 指令:db:generate / db:migrate / db:seed / db:studio / db:push

新增環境變數(.env / .env.example):

AUTH_SECRET=...
AUTH_URL=http://localhost:3000
AUTH_TRUST_HOST=false

資料庫 Schema 與 Migrations

擴充 users 與新增 NextAuth adapter 所需表:

src/lib/db/schema.ts

export const users = pgTable(
  'users',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    email: varchar('email', { length: 255 }).notNull().unique(),
    emailVerified: timestamp('email_verified', { mode: 'date', withTimezone: true }),
    password: varchar('password', { length: 255 }).notNull(),
    name: varchar('name', { length: 255 }).notNull(),
    image: varchar('image', { length: 255 }),
    createdAt: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
    updatedAt: timestamp('updated_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [index('users_created_at_id_idx').on(table.createdAt, table.id)]
);

// NextAuth adapter: 儲存第三方登入帳號資訊
export const accounts = pgTable('accounts', {
  /* provider/composite PK 等欄位 */
});

// NextAuth adapter: 儲存登入 session
export const sessions = pgTable('sessions', {
  /* sessionToken / userId / expires */
});

// NextAuth adapter: 信箱登入/驗證流程用的 token
export const verificationTokens = pgTable('verification_tokens', {
  /* identifier/token/expiry */
});

// NextAuth adapter: WebAuthn 等實體驗證器
export const authenticators = pgTable('authenticators', {
  /* credentialID / userId 等欄位 */
});

產生的 migration(節錄):

scripts/db/migrations/0003_mean_vanisher.sql

ALTER TABLE "users" ADD COLUMN "email_verified" timestamptz;
ALTER TABLE "users" ADD COLUMN "image" varchar(255);
CREATE TABLE "accounts" (...);
CREATE TABLE "sessions" (...);
CREATE TABLE "verification_tokens" (...);
CREATE TABLE "authenticators" (...);

執行:

npm run db:generate
npm run db:migrate

Auth.js 設定與 Middleware

Auth.js 設定採用 Drizzle Adapter、Credentials Provider、資料庫 Session,並於 authorized 控制放行:

src/server/auth/index.ts

export const authConfig = {
  adapter: DrizzleAdapter(db, {
    usersTable: users,
    accountsTable: accounts,
    sessionsTable: sessions,
    verificationTokensTable: verificationTokens,
    authenticatorsTable: authenticators,
  }),
  session: { strategy: 'database' },
  providers: [
    Credentials({
      async authorize(input) {
        const { email, password } = input ?? {};
        if (typeof email !== 'string' || typeof password !== 'string') return null;
        const user = await findByEmail(email);
        if (!user?.password) return null;
        const ok = await bcrypt.compare(password, user.password);
        if (!ok) return null;
        const { password: _pw, ...safe } = user;
        return safe;
      },
    }),
  ],
  pages: { signIn: '/login' },
  callbacks: {
    authorized({ request, auth }) {
      const { pathname } = request.nextUrl;
      if (pathname.startsWith('/login') || pathname.startsWith('/signup')) return true;
      if (pathname.startsWith('/api/auth')) return true;
      if (pathname.startsWith('/_next')) return true;
      if (pathname === '/favicon.ico' || pathname.includes('.')) return true;
      return !!auth?.user;
    },
    async session({ session, user }) {
      if (session.user && user) session.user.id = user.id;
      return session;
    },
  },
  secret: process.env.AUTH_SECRET,
} satisfies NextAuthConfig;

export const { handlers, auth } = NextAuth(authConfig);

Middleware 導出與篩選:

middleware.ts

export { auth as middleware } from '@/server/auth';
export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] };

Auth.js 路由:

src/app/api/auth/[...nextauth]/route.ts

import { handlers } from '@/server/auth';
export const { GET, POST } = handlers;

服務層與 Repository

  • 維持分層:Auth 驗證改用 repository 方法 findByEmail,而非直接 db.query.users
    • src/server/auth/index.tssrc/server/repos/usersRepo.ts:11
  • 分頁查詢小幅重構(避免 query 重新指派產生的型別雜訊):
    • src/server/repos/usersRepo.ts:21

首頁與元件

  • 刪除 Day 4 測試頁 UsersListsrc/app/_components/users-list.tsx
  • 首頁簡化為靜態訊息:src/app/page.tsx

型別與測試

  • 擴充 Session 型別:types/next-auth.d.ts(提供 session.user.id
  • 調整測試資料結構以符合擴充後的 users 欄位:tests/api.users.get.test.ts
  • 驗證:npx tsc --noEmitnpm run lint

附註

失敗的 prompt:

技術棧與原則
Next.js App Router、Auth.js v5(NextAuth 新版)、Drizzle(Postgres)、shadcn/ui、Tailwind。
路由與資料夾結構遵循現有專案規範(src/app、src/server、src/lib/db 等)。
Session 採用 Auth.js 預設 JWT(session.strategy = 'jwt')。
資料庫與 Adapter
保留既有 users 資料表結構(password 欄位存雜湊值,非明碼)。
導入 @auth/drizzle-adapter 以持久化使用者與必要表;新增 Drizzle schema 與遷移建立:accounts、sessions、verificationTokens(JWT session 不用 sessions,但建立不衝突)。
若需,對 Adapter 進行自訂 schema 對應,以沿用現有 users 表(至少需 id、email、name)。
環境變數(Auth.js)
AUTH_SECRET:簽名/加密 JWT 與 token 的金鑰(強隨機、正式環境必填)。
AUTH_URL:對外 Base URL(例如 http://localhost:3000、生產網域),用於產生回呼與重導 URL。
AUTH_TRUST_HOST:在反向代理情境允許信任 X-Forwarded-* 推斷 Host/協定(設定 true 可解代理問題;若已正確設 AUTH_URL 通常可不開)。
Auth.js 初始化與 Middleware(Edge 兼容)
新增 auth.config.ts:共用設定(Provider、callbacks 等),不含 adapter。
新增根層 auth.ts:NextAuth({ adapter: DrizzleAdapter(db), session: { strategy: 'jwt' }, ...authConfig }) 並輸出 { handlers, auth, signIn, signOut }。
新增 app/api/auth/[...nextauth]/route.ts:re-export handlers 的 GET/POST。
新增 middleware.ts:以 NextAuth(authConfig) 的 auth 實例在中介層使用(不含 adapter),自訂 gating 邏輯(見下方),而非單純 export { auth as middleware }。
路由保護規則(middleware)
放行:/login、/api/auth/、/_next/、/favicon.ico、其他帶副檔名的靜態資源。
其他路徑:無有效 JWT session → redirect /login。
已登入造訪 /login → redirect /。
注意:middleware 僅做 gating 與 keep-alive,不查 DB(沿用不含 adapter 的 authConfig)。
Credentials Provider(Email/Password 登入)
在 auth.ts 設定 Credentials provider。
authorize():以 email 查詢使用者,使用 bcryptjs.compare() 驗證密碼;正確回傳使用者(至少 id、email、name),錯誤回傳 null(語意等同舊 CredentialsSignin)。
登入僅驗證帳密正確,不檢查密碼複雜度。
註冊 API:/api/auth/signup
使用 zod 驗證:
長度 8–72
至少 1 數字 [0-9]、1 小寫 [a-z]、1 大寫 [A-Z]、1 特殊符號 [^A-Za-z0-9]
確認密碼一致
流程:檢查 email 重複 → bcryptjs.hash() 儲存 → 建立使用者(不自動登入)。
回應:成功 201;重複 409;驗證錯誤 400(各錯誤依下述 error 模型回傳)。
成功後前端導回 /login。
錯誤模型(域錯誤碼 + HTTP 狀態)與工具
更新 src/server/errors.ts 為可延展模型:
ErrorCode:如 VALIDATION_ERROR、EMAIL_TAKEN、INVALID_CREDENTIALS、INTERNAL_ERROR。
ErrorDescriptor[code] = { httpStatus, message }。
errorResponse(code, { details, issues }) 與 ok(data, status?) 工具,統一 API 回應格式。
目標回應(前端穩定結構):
{ code: 'EMAIL_TAKEN', message: 'Email 已被註冊', details?: any, issues?: { path: string[], message: string }[] }。
/login UI(shadcn/ui,仿 IG)
src/app/login/page.tsx 為 RSC;_components/AuthForm.tsx 為 client("use client")。
結構:Tabs(登入 | 註冊)
登入:Email、密碼、登入按鈕
註冊:名稱、Email、密碼、確認密碼
密碼規則清單即時打勾顯示、未通過禁送出
欄位錯誤訊息 + 全域 Alert;提交時按鈕 loading
RWD:
Mobile:卡片寬 92%,置中
Tablet:卡片固定 420–480px
Desktop:置中顯示,首頁留白即可
元件:Tabs、Form(react-hook-form + zod resolver)、Input、Label、Button、Card、Alert。
使用 shadcn CLI 新增所需元件;遵循專案命名與匯入規則(@/ alias)。
清理舊範例
刪除 users 查詢的頁面與 API、相關 server/repo 與測試:
src/app/api/users/route.ts
src/app/_components/users-list.tsx
src/server/users.ts、src/server/repos/usersRepo.ts(若已不再使用)
tests/api.users.get.test.ts、tests/api.users.post.test.ts、tests/service.users.test.ts
測試(Vitest + Playwright)
Vitest(整合/單元):
/api/auth/signup:成功 201、重複 email 409、密碼規則不符 400、二次密碼不一致 400
Credentials authorize():正確帳密成功;錯誤密碼回 null(語意 = Invalid Credentials)
密碼 schema:缺數字/小寫/大寫/特殊/長度不足/超 72 → 失敗;全部符合 → 成功
middleware:未登入訪問 / → 302 /login;未登入訪問 /login → 放行;已登入訪問 /login → /
Playwright(E2E):
註冊:填表 → 成功 → 導回登入
登入:新帳號登入 → 首頁顯示登入狀態 → 刷新仍保持登入
錯誤登入:顯示錯誤訊息
登出:點登出 → 回到登入卡
middleware:未登入直訪 / → 導到 /login;已登入直訪 /login → 導到 /
RWD:手機/平板/桌機下排版不破版
測試環境建議使用 SQLite 或測試專用 DB,.env.test 與 .env.local 區隔。
相依與 README 調整
依賴:
正式:next-auth、@auth/drizzle-adapter、zod、bcryptjs、react-hook-form、@hookform/resolvers、lucide-react(如需 icon)
已有:drizzle-orm、pg、dayjs、tailwindcss 等
README 修正:
使用 bcryptjs(非 bcrypt),新增三個 AUTH 變數說明與設定範例
加入 shadcn CLI 使用方式與所需元件清單
DB 工作流(db:generate → review SQL → db:migrate → db:seed)
測試指令與環境注意事項
開發與交付項目(產出清單)
新增:auth.config.ts、auth.ts、app/api/auth/[...nextauth]/route.ts、app/api/auth/signup/route.ts、middleware.ts、app/login/page.tsx、app/login/_components/AuthForm.tsx、src/server/validation/auth.ts(zod)
更新:src/server/errors.ts(域錯誤碼模型與 helper)、README.md(依賴/ENV/CLI)
DB:新增 adapter 所需表的 Drizzle schema 與遷移;保留 users(password 雜湊)
移除:舊 users 範例頁/API/測試與未使用的 server/repo 模組
測試:Vitest 覆蓋 API/authorize/schema/middleware、Playwright 覆蓋 e2e 與 RWD
驗收標準(關鍵檢核)
/api/auth/signup 依規格回 201/409/400 並使用統一錯誤格式
Credentials 登入以 bcryptjs.compare() 驗證成功/失敗
middleware 符合放行/阻擋/重導規則
/login UI 行為與 RWD 符合
移除舊 users 範例後,測試全綠(Vitest + Playwright)
README 與依賴一致(含 bcryptjs); .env.local / .env.test 設定齊全
備註
密碼複雜度限制作用於「使用者輸入明碼」(8–72),雜湊字串(約 60)存入 users.password,varchar(255) 足夠。
之後若加入節流/速率限制、i18n 化錯誤訊息、OAuth provider,可在此基礎擴充。


上一篇
Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 4
系列文
Road To Full-Stack:前端轉全端的 Instagram 挑戰5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言