Next.js 身份驗證與授權 (Auth.js / NextAuth)
在 Next.js 的生態系中,Auth.js (原名 NextAuth.js) 是目前最主流的解決方案。它不僅支援 100+ 個社群登入,還提供了極高的安全性與彈性。
本篇文章將基於最新的 Auth.js v5 (Beta) 版本,教你如何構建一個包含「帳號密碼登入」與「角色權限控管 (RBAC)」的完整認證系統。
Auth.js 架構與核心概念
在開始寫程式碼之前,我們先了解 Auth.js v5 是如何運作的。
運作流程 (The Flow)
Auth.js v5 的核心設計理念是 Standard Web APIs。這意味著它不再依賴特定環境 (如 Node.js),而是可以在 Edge Runtime (Vercel Edge, Cloudflare Workers) 上執行。
整個認證流程可以簡化為:
- 使用者操作:點擊登入按鈕 -> 呼叫
signIn()。 - API 請求:發送請求至 Next.js 的 Route Handler (
app/api/auth/[...nextauth]/route.ts)。 - 驗證與處理:Route Handler 根據你的設定檔 (
auth.ts) 驗證使用者身份 (OAuth 或 Credentials)。 - Session 建立:驗證成功後,Auth.js 會設定加密的 Cookie。
- 狀態獲取:
- Server 端:透過
auth()解密 Cookie 並讀取 Session。 - Client 端:透過
useSession()(需SessionProvider) 或 API 讀取 Session。
- Server 端:透過
關鍵 API (Key APIs)
v5 簡化了 API 的匯出,你只需要關注這幾個核心函式:
NextAuth(...):初始化的核心函式。你會在auth.ts中呼叫它,並解構出以下工具。handlers:包含{ GET, POST },專門給 Route Handler 使用的,用來處理所有/api/auth/*的請求。auth:最重要的一個函式。它是 Server-side 的通用工具。- 當作函式呼叫
auth():獲取目前的 Session。 - 當作 Middleware 使用:
export { auth as middleware }。
- 當作函式呼叫
signIn/signOut:用於在 Server Actions 或 Server Components 中觸發登入/登出流程。
安裝與基礎設定
Auth.js v5 針對 Next.js App Router 做了許多優化,並不依賴資料庫即可運行(若只用 OAuth)。
npm install next-auth@beta zod
設定檔結構:為什麼要拆成兩個檔案?
為了讓身份驗證邏輯也能在 Middleware (Edge Runtime) 中執行(例如:在 Request 進來時就攔截並檢查權限),我們必須將設定拆分為兩部分:
auth.config.ts:Edge 相容設定。只放「不依賴 Node.js 特定 API」的邏輯(如路徑導向、基本驗證回呼)。Middleware 會引入此檔案。auth.ts:完整設定。包含所有邏輯,包括需要 Node.js 環境的功能(如資料庫連線、bcrypt雜湊比對)。API Route 和 Server Actions 會引入此檔案。
1. Edge 相容設定 (auth.config.ts)
這個檔案主要負責定義「路由保護規則」與「登入頁面路徑」。
關鍵字解析:
pages:定義 Auth.js 的自訂頁面。預設會有一個簡易的登入頁,但通常我們會指定signIn: '/login'來使用我們自己寫的登入頁面。callbacks.authorized:這是 Middleware 唯一會執行的 Callback。每當有 Request 進來,就會觸發這個函式。- 輸入參數:包含
auth(當前的 Session 資訊) 與request(HTTP 請求物件)。 - 回傳值:
true:允許存取。false:拒絕存取,Auth.js 會自動將使用者導向至pages.signIn設定的登入頁。Response物件:可以回傳Response.redirect(...)來強制導向到特定頁面。
- 輸入參數:包含
providers:在auth.config.ts中我們通常只初始化一個空陣列[],這是為了滿足型別定義,實際的 Provider (如 Credentials) 會在auth.ts中加入。
// auth.config.ts
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login', // 指定自定義的登入頁面路徑,未登入者會被導向至此
},
callbacks: {
// 💡 authorized 是 Middleware 判斷權限的核心邏輯
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard) {
// 進入 Dashboard 區域:必須已登入,否則回傳 false (自動導向 /login)
if (isLoggedIn) return true;
return false;
} else if (isLoggedIn) {
// 已登入使用者訪問登入頁,導向後台
return Response.redirect(new URL('/dashboard', nextUrl));
}
// 其他頁面一律允許訪問 (如首頁、公開 API)
return true;
},
},
providers: [], // 這裡先留空,避免 Edge Runtime 載入不相容的模組
} satisfies NextAuthConfig;
2. 完整設定 (auth.ts)
這個檔案負責「真正的身份驗證邏輯」,它會合併 authConfig 並加入 Credentials Provider (帳號密碼登入)。
必要實作與流程解析:
NextAuth({...}):初始化的核心,我們將它回傳的auth,signIn,signOut匯出給專案使用。CredentialsProvider:處理帳號密碼登入的策略。authorize(credentials):驗證的核心函式。當使用者送出表單呼叫signIn('credentials', formData)時,這個函式會被觸發。- 你必須在這裡實作:Zod 驗證格式 -> 資料庫查詢 User -> 密碼比對。
- 回傳值:驗證成功回傳 User 物件;失敗回傳
null。
- Session Lifecycle (
jwt->session):- Auth.js 預設使用 JWT (JSON Web Token) 策略。
callbacks.jwt:寫入 Token。當authorize成功回傳 User 後,jwtcallback 會被呼叫。參數中的user只有在第一次登入時會存在。這時我們要將 Database 中的role等資訊寫入token。callbacks.session:讀取 Session。當前端或後端呼叫auth()時,sessioncallback 會被呼叫。它 無法直接讀取user物件,只能讀取token。因此我們必須把token中的role搬移到session.user中,前端才能拿到。
// auth.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
// 定義帳號密碼的驗證 Schema (使用 Zod)
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig, // 1. 合併 Edge 的設定 (包含 pages, authorized callback)
providers: [
// 2. 定義登入提供者 (Provider)
Credentials({
// authorize: 驗證帳號密碼的邏輯
async authorize(credentials) {
const parsedCredentials = LoginSchema.safeParse(credentials);
if (parsedCredentials.success) {
const { email, password } = parsedCredentials.data;
// 步驟 A: 在此呼叫資料庫找出這位使用者
// const user = await getUserFromDb(email);
// 步驟 B: 模擬資料庫回傳的 User 物件
// 假設密碼驗證通過 (真實專案請用 bcrypt.compare)
if (email === 'admin@example.com' && password === '123456') {
// 💡 注意:這裡回傳的物件會傳給下方的 jwt callback
return {
id: '1',
name: 'Admin User',
email: email,
role: 'admin', // 我們希望存在 Session 的自定義欄位
};
}
}
// 驗證失敗回傳 null (導致登入錯誤)
return null;
},
}),
],
callbacks: {
// 3. JWT Callback: 處理 Token 生成與更新
// 觸發時機:登入成功時、Token 被讀取時
async jwt({ token, user }) {
// 💡 `user` 參數只在「第一次登入成功」時有值
if (user) {
// 將 authorize 回傳的 role 存入 token
token.role = user.role;
}
return token;
},
// 4. Session Callback: 決定 auth() 或 useSession() 能拿到什麼資料
// 觸發時機:每次讀取 Session 時
async session({ session, token }) {
// 💡 這裡拿不到 user,只能拿到 token
// 將 token 中的 role 傳遞給 session.user
if (token.role) {
session.user.role = token.role;
}
return session; // 回傳最終的 session 物件
},
},
});
Route Handler (app/api/auth/[...nextauth]/route.ts)
這是 Auth.js 處理登入、登出請求的 API 入口。
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
角色權限控管 (RBAC)
要在 TypeScript 中使用自定義的 role 屬性,我們需要進行 Module Augmentation。
為什麼需要 Module Augmentation?
預設情況下,Auth.js 的 User 和 Session 型別只包含標準欄位 (name, email, image)。若要加入自定義的 role 權限欄位,必須擴充 TypeScript 的型別定義,否則編譯器會報錯。
// types/next-auth.d.ts
import NextAuth, { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface User {
role?: string;
}
interface Session {
user: {
role?: string;
} & DefaultSession['user'];
}
}
declare module '@auth/core/jwt' {
interface JWT {
role?: string;
}
}
實作登入功能 (Server Actions)
使用 Server Actions 來處理表單提交是目前最推薦的做法。
// app/lib/actions.ts
'use server';
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
export async function authenticate(prevState: string | undefined, formData: FormData) {
try {
await signIn('credentials', formData);
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';
default:
return 'Something went wrong.';
}
}
throw error; // ⚠️ 必須拋出錯誤,因為 Next.js 的 redirect() 背後是透過拋出 Error 來實作的
}
}
登入表單元件 (app/login/form.tsx):
'use client';
import { useFormState } from 'react-dom';
import { authenticate } from '@/app/lib/actions';
export default function LoginForm() {
const [errorMessage, dispatch] = useFormState(authenticate, undefined);
return (
<form action={dispatch} className="flex flex-col gap-4">
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<div>{errorMessage && <p className="text-red-500">{errorMessage}</p>}</div>
<button type="submit">Login</button>
</form>
);
}
Session 管理:Client vs Server
Server Components (推薦)
在 Server Components 中,我們使用 auth() 來獲取 Session。這完全不增加 Client Bundle。
import { auth } from '@/auth';
export default async function Dashboard() {
const session = await auth();
if (session?.user?.role !== 'admin') {
return <p>權限不足:您不是管理員</p>;
}
return (
<div>
<h1>歡迎回來, {session.user.name}</h1>
<pre>{JSON.stringify(session, null, 2)}</pre>
</div>
);
}
Client Components
若需要在客戶端(例如:根據登入狀態切換 Header 按鈕)使用 Session,需搭配 SessionProvider。
1. 建立 Provider (app/context/auth-provider.tsx):
'use client';
import { SessionProvider } from 'next-auth/react';
export default function AuthProvider({ children }) {
return <SessionProvider>{children}</SessionProvider>;
}
2. 在 Layout 中使用 (app/layout.tsx):
import AuthProvider from '@/app/context/auth-provider';
export default function RootLayout({ children }) {
return (
<html>
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
3. 在 Hook 中使用 (useSession):
'use client';
import { useSession } from 'next-auth/react';
export default function UserButton() {
const { data: session } = useSession();
if (session) {
return <p>已登入:{session.user.name}</p>;
}
return <a href="/login">登入</a>;
}
小結
- 設定分離:將
auth.config.ts與auth.ts分開,以支援 Edge Runtime (Middleware)。 - 類型安全:使用 TS Module Augmentation 擴充
Session與User介面,支援role欄位。 - 最佳實踐:能在 Server Components 做驗證就在 Server 做 (
auth()),避免不必要的 Client JS 下載。
透過 Auth.js v5,我們不僅能輕鬆整合第三方登入,更能構建出嚴謹的 RBAC 企業級權限系統。