如何在 Next.js 中實現身份驗證

理解身份驗證對於保護應用程式資料至關重要。本頁將引導您了解如何使用 React 和 Next.js 功能來實現身份驗證。

開始之前,可以將流程分解為三個概念:

  1. 身份驗證 (Authentication):驗證使用者是否為其所聲稱的身份。要求使用者通過他們擁有的憑證(如用戶名和密碼)來證明身份。
  2. 會話管理 (Session Management):跨請求追蹤使用者的驗證狀態。
  3. 授權 (Authorization):決定使用者可以存取哪些路由和資料。

以下圖表展示了使用 React 和 Next.js 功能的身份驗證流程:

展示使用 React 和 Next.js 功能的身份驗證流程圖

本頁示例為教學目的展示了基本的用戶名和密碼驗證。雖然您可以實現自訂的身份驗證方案,但為了提高安全性和簡化流程,我們建議使用身份驗證函式庫。這些函式庫提供了內建的身份驗證、會話管理和授權解決方案,以及額外功能如社交登入、多因素驗證和基於角色的存取控制。您可以在身份驗證函式庫部分找到相關列表。

身份驗證

註冊和登入功能

您可以使用 <form> 元素搭配 React 的伺服器動作 (Server Actions)useActionState 來獲取使用者憑證、驗證表單欄位,並呼叫您的身份驗證提供者 API 或資料庫。

由於伺服器動作總是在伺服器端執行,因此提供了處理身份驗證邏輯的安全環境。

以下是實現註冊/登入功能的步驟:

1. 獲取使用者憑證

要獲取使用者憑證,建立一個在提交時調用伺服器動作的表單。例如,一個接受使用者名稱、電子郵件和密碼的註冊表單:

import { signup } from '@/app/actions/auth'

export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Sign Up</button>
    </form>
  )
}
import { signup } from '@/app/actions/auth'

export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Sign Up</button>
    </form>
  )
}
export async function signup(formData: FormData) {}
export async function signup(formData) {}

2. 在伺服器端驗證表單欄位

使用伺服器動作在伺服器端驗證表單欄位。如果您的身份驗證提供者不提供表單驗證,可以使用如 ZodYup 這樣的模式驗證函式庫。

以 Zod 為例,您可以定義一個帶有適當錯誤訊息的表單模式:

import { z } from 'zod'

export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { message: 'Name must be at least 2 characters long.' })
    .trim(),
  email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
  password: z
    .string()
    .min(8, { message: 'Be at least 8 characters long' })
    .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
    .regex(/[0-9]/, { message: 'Contain at least one number.' })
    .regex(/[^a-zA-Z0-9]/, {
      message: 'Contain at least one special character.',
    })
    .trim(),
})

export type FormState =
  | {
      errors?: {
        name?: string[]
        email?: string[]
        password?: string[]
      }
      message?: string
    }
  | undefined
import { z } from 'zod'

export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { message: 'Name must be at least 2 characters long.' })
    .trim(),
  email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
  password: z
    .string()
    .min(8, { message: 'Be at least 8 characters long' })
    .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
    .regex(/[0-9]/, { message: 'Contain at least one number.' })
    .regex(/[^a-zA-Z0-9]/, {
      message: 'Contain at least one special character.',
    })
    .trim(),
})

為了避免不必要的身份驗證提供者 API 或資料庫呼叫,如果任何表單欄位不符合定義的模式,您可以在伺服器動作中提前 return

import { SignupFormSchema, FormState } from '@/app/lib/definitions'

export async function signup(state: FormState, formData: FormData) {
  // Validate form fields
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })

  // If any form fields are invalid, return early
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Call the provider or db to create a user...
}
import { SignupFormSchema } from '@/app/lib/definitions'

export async function signup(state, formData) {
  // Validate form fields
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })

  // If any form fields are invalid, return early
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Call the provider or db to create a user...
}

回到您的 <SignupForm />,可以使用 React 的 useActionState 鉤子在表單提交時顯示驗證錯誤:

'use client'

import { signup } from '@/app/actions/auth'
import { useActionState } from 'react'

export default function SignupForm() {
  const [state, action, pending] = useActionState(signup, undefined)

  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <p>Password must:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <button disabled={pending} type="submit">
        Sign Up
      </button>
    </form>
  )
}
'use client'

import { signup } from '@/app/actions/auth'
import { useActionState } from 'react'

export default function SignupForm() {
  const [state, action, pending] = useActionState(signup, undefined)

  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <p>Password must:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <button disabled={pending} type="submit">
        Sign Up
      </button>
    </form>
  )
}

須知事項:

  • 在 React 19 中,useFormStatus 包含返回物件上的其他鍵,如 data、method 和 action。如果您不使用 React 19,則只有 pending 鍵可用。
  • 在變更資料之前,您應該始終確保使用者也有權執行該操作。請參閱身份驗證與授權

3. 建立使用者或驗證使用者憑證

在驗證表單欄位後,您可以建立新使用者帳戶或透過呼叫您的驗證提供者 API 或資料庫來檢查使用者是否存在。

延續前面的範例:

export async function signup(state: FormState, formData: FormData) {
  // 1. 驗證表單欄位
  // ...

  // 2. 準備要插入資料庫的資料
  const { name, email, password } = validatedFields.data
  // 例如:在儲存前雜湊使用者的密碼
  const hashedPassword = await bcrypt.hash(password, 10)

  // 3. 將使用者插入資料庫或呼叫驗證函式庫的 API
  const data = await db
    .insert(users)
    .values({
      name,
      email,
      password: hashedPassword,
    })
    .returning({ id: users.id })

  const user = data[0]

  if (!user) {
    return {
      message: '建立帳戶時發生錯誤。',
    }
  }

  // 待辦事項:
  // 4. 建立使用者工作階段
  // 5. 重新導向使用者
}
export async function signup(state, formData) {
  // 1. 驗證表單欄位
  // ...

  // 2. 準備要插入資料庫的資料
  const { name, email, password } = validatedFields.data
  // 例如:在儲存前雜湊使用者的密碼
  const hashedPassword = await bcrypt.hash(password, 10)

  // 3. 將使用者插入資料庫或呼叫函式庫 API
  const data = await db
    .insert(users)
    .values({
      name,
      email,
      password: hashedPassword,
    })
    .returning({ id: users.id })

  const user = data[0]

  if (!user) {
    return {
      message: '建立帳戶時發生錯誤。',
    }
  }

  // 待辦事項:
  // 4. 建立使用者工作階段
  // 5. 重新導向使用者
}

成功建立使用者帳戶或驗證使用者憑證後,您可以建立工作階段來管理使用者的驗證狀態。根據您的工作階段管理策略,工作階段可以儲存在 cookie 或資料庫中,或兩者皆存。繼續閱讀工作階段管理章節以了解更多。

提示:

  • 上面的範例較為詳細,因為它為了教學目的分解了驗證步驟。這突顯出實作自己的安全解決方案可能很快變得複雜。考慮使用驗證函式庫來簡化流程。
  • 為了改善使用者體驗,您可能希望在註冊流程中更早檢查重複的電子郵件或使用者名稱。例如,當使用者輸入使用者名稱或輸入欄位失去焦點時。這可以幫助避免不必要的表單提交,並立即向使用者提供回饋。您可以使用像 use-debounce 這樣的函式庫來管理這些檢查的頻率。

工作階段管理

工作階段管理確保使用者的驗證狀態在請求之間保持。它涉及建立、儲存、刷新和刪除工作階段或令牌。

有兩種類型的工作階段:

  1. 無狀態 (Stateless):工作階段資料(或令牌)儲存在瀏覽器的 cookie 中。cookie 隨每個請求發送,允許在伺服器上驗證工作階段。這種方法較簡單,但如果未正確實作,安全性較低。
  2. 資料庫 (Database):工作階段資料儲存在資料庫中,使用者的瀏覽器僅接收加密的工作階段 ID。這種方法更安全,但可能更複雜且使用更多伺服器資源。

須知: 雖然您可以使用任一方法或兩者,但我們建議使用工作階段管理函式庫,例如 iron-sessionJose

無狀態工作階段

要建立和管理無狀態工作階段,您需要遵循以下步驟:

  1. 產生一個密鑰,用於簽署您的工作階段,並將其儲存為環境變數
  2. 使用工作階段管理函式庫編寫加密/解密工作階段資料的邏輯。
  3. 使用 Next.js cookies API 管理 cookie。

除了上述內容,考慮新增功能在使用者返回應用程式時更新(或刷新)工作階段,並在使用者登出時刪除工作階段。

須知: 檢查您的驗證函式庫是否包含工作階段管理。

1. 產生密鑰

有幾種方法可以產生用於簽署工作階段的密鑰。例如,您可以選擇在終端機中使用 openssl 命令:

terminal
openssl rand -base64 32

此命令產生一個 32 字元的隨機字串,您可以將其用作密鑰並儲存在您的環境變數檔案中:

.env
SESSION_SECRET=your_secret_key

然後,您可以在工作階段管理邏輯中引用此密鑰:

app/lib/session.js
const secretKey = process.env.SESSION_SECRET

2. 加密和解密工作階段

接下來,您可以使用您偏好的工作階段管理函式庫來加密和解密工作階段。延續前面的範例,我們將使用 Jose(與 Edge Runtime 相容)和 React 的 server-only 套件,以確保您的工作階段管理邏輯僅在伺服器上執行。

import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { SessionPayload } from '@/app/lib/definitions'

const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)

export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}

export async function decrypt(session: string | undefined = '') {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('驗證工作階段失敗')
  }
}
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'

const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)

export async function encrypt(payload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}

export async function decrypt(session) {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('驗證工作階段失敗')
  }
}

提示:

  • 負載應包含在後續請求中使用的最少、唯一的使用者資料,例如使用者的 ID、角色等。它不應包含個人識別資訊,如電話號碼、電子郵件地址、信用卡資訊等,或敏感資料如密碼。

3. 設定 cookie(推薦選項)

要將工作階段儲存在 cookie 中,請使用 Next.js cookies API。cookie 應在伺服器上設定,並包含推薦的選項:

  • HttpOnly:防止客戶端 JavaScript 存取 cookie。
  • Secure:使用 https 發送 cookie。
  • SameSite:指定 cookie 是否可以與跨站請求一起發送。
  • Max-Age 或 Expires:在一定時間後刪除 cookie。
  • Path:定義 cookie 的 URL 路徑。

請參考 MDN 以獲取有關這些選項的更多資訊。

import 'server-only'
import { cookies } from 'next/headers'

export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()

  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}
import 'server-only'
import { cookies } from 'next/headers'

export async function createSession(userId) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, expiresAt })
  const cookieStore = await cookies()

  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

回到您的伺服器動作中,您可以呼叫 createSession() 函式,並使用 redirect() API 將使用者重新導向到適當的頁面:

import { createSession } from '@/app/lib/session'

export async function signup(state: FormState, formData: FormData) {
  // 先前的步驟:
  // 1. 驗證表單欄位
  // 2. 準備要插入資料庫的資料
  // 3. 將使用者插入資料庫或呼叫函式庫 API

  // 目前的步驟:
  // 4. 建立使用者工作階段
  await createSession(user.id)
  // 5. 重新導向使用者
  redirect('/profile')
}
import { createSession } from '@/app/lib/session'

export async function signup(state, formData) {
  // 先前的步驟:
  // 1. 驗證表單欄位
  // 2. 準備要插入資料庫的資料
  // 3. 將使用者插入資料庫或呼叫函式庫 API

  // 目前的步驟:
  // 4. 建立使用者工作階段
  await createSession(user.id)
  // 5. 重新導向使用者
  redirect('/profile')
}

提示:

  • Cookie 應在伺服器上設定以防止客戶端篡改。
  • 🎥 觀看:了解更多關於無狀態工作階段和 Next.js 驗證 → YouTube (11 分鐘)

更新(或刷新)工作階段

您也可以延長工作階段的過期時間。這對於在使用者再次存取應用程式時保持登入狀態很有用。例如:

import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)

  if (!session || !payload) {
    return null
  }

  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: 'lax',
    path: '/',
  })
}
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)

  if (!session || !payload) {
    return null
  }

  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)(
    await cookies()
  ).set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: 'lax',
    path: '/',
  })
}

提示: 檢查您的驗證函式庫是否支援刷新令牌,可用於延長使用者的工作階段。

刪除工作階段 (Session)

要刪除工作階段,您可以刪除 cookie:

import 'server-only'
import { cookies } from 'next/headers'

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}
import 'server-only'
import { cookies } from 'next/headers'

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}

然後您可以在應用程式中重複使用 deleteSession() 函式,例如在登出時:

import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'

export async function logout() {
  await deleteSession()
  redirect('/login')
}
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'

export async function logout() {
  await deleteSession()
  redirect('/login')
}

資料庫工作階段 (Database Sessions)

要建立和管理資料庫工作階段,您需要遵循以下步驟:

  1. 在資料庫中建立一個表來儲存工作階段和資料(或檢查您的驗證函式庫是否處理此功能)
  2. 實作插入、更新和刪除工作階段的功能
  3. 在將工作階段 ID 儲存到使用者瀏覽器之前對其進行加密,並確保資料庫和 cookie 保持同步(這是可選的,但建議用於 中介軟體 (Middleware) 中的樂觀驗證檢查)

例如:

import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'

export async function createSession(id: number) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  // 1. 在資料庫中建立工作階段
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // 返回工作階段 ID
    .returning({ id: sessions.id })

  const sessionId = data[0].id

  // 2. 加密工作階段 ID
  const session = await encrypt({ sessionId, expiresAt })

  // 3. 將工作階段儲存在 cookie 中以進行樂觀驗證檢查
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}
import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'

export async function createSession(id) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)

  // 1. 在資料庫中建立工作階段
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // 返回工作階段 ID
    .returning({ id: sessions.id })

  const sessionId = data[0].id

  // 2. 加密工作階段 ID
  const session = await encrypt({ sessionId, expiresAt })

  // 3. 將工作階段儲存在 cookie 中以進行樂觀驗證檢查
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

提示

  • 為了更快存取,您可以考慮為工作階段的生命週期添加伺服器快取。您也可以將工作階段資料保留在主資料庫中,並合併資料請求以減少查詢次數
  • 您可以選擇使用資料庫工作階段來處理更高級的使用案例,例如追蹤使用者上次登入時間、活躍裝置數量,或讓使用者能夠登出所有裝置

實作工作階段管理後,您需要添加授權邏輯來控制使用者在應用程式中的存取權限和操作。繼續閱讀 授權 (Authorization) 章節以了解更多資訊

授權 (Authorization)

當使用者通過驗證並建立工作階段後,您可以實作授權來控制使用者在應用程式中的存取權限和操作

主要有兩種授權檢查類型:

  1. 樂觀檢查 (Optimistic):使用儲存在 cookie 中的工作階段資料檢查使用者是否有權存取路由或執行操作。這些檢查適用於快速操作,例如顯示/隱藏 UI 元素或根據權限或角色重定向使用者
  2. 安全檢查 (Secure):使用儲存在資料庫中的工作階段資料檢查使用者是否有權存取路由或執行操作。這些檢查更安全,適用於需要存取敏感資料或操作的情況

對於這兩種情況,我們建議:

使用中介軟體進行樂觀檢查(可選)

在某些情況下,您可能希望使用 中介軟體 (Middleware) 並根據權限重定向使用者:

  • 執行樂觀檢查。由於中介軟體在每個路由上執行,這是集中重定向邏輯和預先過濾未授權使用者的好方法
  • 保護在使用者之間共享資料的靜態路由(例如付費牆後的內容)

然而,由於中介軟體在每個路由上執行,包括 預取 (prefetched) 路由,重要的是僅從 cookie 讀取工作階段(樂觀檢查),並避免資料庫檢查以防止效能問題

例如:

import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'

// 1. 指定受保護和公開路由
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']

export default async function middleware(req: NextRequest) {
  // 2. 檢查目前路由是受保護還是公開
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)

  // 3. 從 cookie 解密工作階段
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  // 4. 如果使用者未驗證,重定向到 /login
  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }

  // 5. 如果使用者已驗證,重定向到 /dashboard
  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith('/dashboard')
  ) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }

  return NextResponse.next()
}

// 中介軟體不應執行的路由
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
import { NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'

// 1. 指定受保護和公開路由
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']

export default async function middleware(req) {
  // 2. 檢查目前路由是受保護還是公開
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)

  // 3. 從 cookie 解密工作階段
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  // 5. 如果使用者未驗證,重定向到 /login
  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }

  // 6. 如果使用者已驗證,重定向到 /dashboard
  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith('/dashboard')
  ) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }

  return NextResponse.next()
}

// 中介軟體不應執行的路由
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}

雖然中介軟體對於初始檢查很有用,但它不應該是保護資料的唯一防線。大多數安全檢查應盡可能靠近資料源執行,請參閱 資料存取層 (Data Access Layer) 了解更多資訊

提示

  • 在中介軟體中,您也可以使用 req.cookies.get('session').value 讀取 cookies
  • 中介軟體使用 Edge Runtime,請檢查您的驗證函式庫和工作階段管理函式庫是否相容
  • 您可以使用中介軟體中的 matcher 屬性來指定中介軟體應執行的路由。儘管如此,對於驗證,建議中介軟體在所有路由上執行

建立資料存取層 (Data Access Layer, DAL)

我們建議建立一個 DAL 來集中您的資料請求和授權邏輯

DAL 應包含一個函式,在使用者與應用程式互動時驗證其工作階段。至少,該函式應檢查工作階段是否有效,然後重定向或返回進行進一步請求所需的用戶資訊

例如,為您的 DAL 建立一個單獨的檔案,其中包含 verifySession() 函式。然後使用 React 的 cache API 在 React 渲染過程中記憶化函式的返回值:

import 'server-only'

import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  if (!session?.userId) {
    redirect('/login')
  }

  return { isAuth: true, userId: session.userId }
})
import 'server-only'

import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'

export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)

  if (!session.userId) {
    redirect('/login')
  }

  return { isAuth: true, userId: session.userId }
})

然後您可以在資料請求、伺服器操作 (Server Actions)、路由處理程式 (Route Handlers) 中調用 verifySession() 函式:

export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  try {
    const data = await db.query.users.findMany({
      where: eq(users.id, session.userId),
      // 明確返回您需要的欄位,而不是整個使用者物件
      columns: {
        id: true,
        name: true,
        email: true,
      },
    })

    const user = data[0]

    return user
  } catch (error) {
    console.log('獲取使用者失敗')
    return null
  }
})
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  try {
    const data = await db.query.users.findMany({
      where: eq(users.id, session.userId),
      // 明確返回您需要的欄位,而不是整個使用者物件
      columns: {
        id: true,
        name: true,
        email: true,
      },
    })

    const user = data[0]

    return user
  } catch (error) {
    console.log('獲取使用者失敗')
    return null
  }
})

提示

  • DAL 可用於保護在請求時獲取的資料。然而,對於在使用者之間共享資料的靜態路由,資料將在建置時獲取,而不是在請求時。使用 中介軟體 (Middleware) 來保護靜態路由
  • 對於安全檢查,您可以通過將工作階段 ID 與資料庫進行比較來檢查工作階段是否有效。使用 React 的 cache 函式來避免在渲染過程中對資料庫進行不必要的重複請求
  • 您可能希望將相關的資料請求合併到一個 JavaScript 類別中,該類別在任何方法之前運行 verifySession()

使用資料傳輸物件 (DTO)

當檢索資料時,建議只回傳應用程式中會用到的必要資料,而非整個物件。例如,當你獲取使用者資料時,可能只需要回傳使用者的 ID 和名稱,而不是包含密碼、電話號碼等敏感資訊的整個使用者物件。

然而,如果你無法控制回傳的資料結構,或是在團隊協作中想避免整個物件被傳遞到客戶端,你可以使用一些策略,例如指定哪些欄位可以安全地暴露給客戶端。

import 'server-only'
import { getUser } from '@/app/lib/dal'

function canSeeUsername(viewer: User) {
  return true
}

function canSeePhoneNumber(viewer: User, team: string) {
  return viewer.isAdmin || team === viewer.team
}

export async function getProfileDTO(slug: string) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // 在這裡回傳特定欄位
  })
  const user = data[0]

  const currentUser = await getUser(user.id)

  // 或是在這裡只回傳查詢特定的資料
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}
import 'server-only'
import { getUser } from '@/app/lib/dal'

function canSeeUsername(viewer) {
  return true
}

function canSeePhoneNumber(viewer, team) {
  return viewer.isAdmin || team === viewer.team
}

export async function getProfileDTO(slug) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // 在這裡回傳特定欄位
  })
  const user = data[0]

  const currentUser = await getUser(user.id)

  // 或是在這裡只回傳查詢特定的資料
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}

透過將資料請求和授權邏輯集中到資料存取層 (DAL) 並使用 DTO,你可以確保所有資料請求都是安全且一致的,這使得應用程式在擴展時更容易維護、審查和除錯。

小知識

  • 有幾種不同的方式可以定義 DTO,從使用 toJSON()、像上面範例中的獨立函式,到 JS 類別。由於這些是 JavaScript 模式而非 React 或 Next.js 的功能,我們建議進行一些研究,找到最適合你應用程式的模式。
  • 在我們的 Next.js 安全性文章 中了解更多安全性最佳實踐。

伺服器元件 (Server Components)

伺服器元件 中進行授權檢查對於基於角色的存取控制非常有用。例如,根據使用者角色條件式渲染元件:

import { verifySession } from '@/app/lib/dal'

export default function Dashboard() {
  const session = await verifySession()
  const userRole = session?.user?.role // 假設 'role' 是 session 物件的一部分

  if (userRole === 'admin') {
    return <AdminDashboard />
  } else if (userRole === 'user') {
    return <UserDashboard />
  } else {
    redirect('/login')
  }
}
import { verifySession } from '@/app/lib/dal'

export default function Dashboard() {
  const session = await verifySession()
  const userRole = session.role // 假設 'role' 是 session 物件的一部分

  if (userRole === 'admin') {
    return <AdminDashboard />
  } else if (userRole === 'user') {
    return <UserDashboard />
  } else {
    redirect('/login')
  }
}

在這個範例中,我們使用來自 DAL 的 verifySession() 函式來檢查 'admin'、'user' 和未授權的角色。這種模式確保每位使用者只能與其角色相符的元件互動。

佈局 (Layouts) 與授權檢查

由於 部分渲染 (Partial Rendering),在 佈局 中進行檢查時要小心,因為這些檢查不會在導航時重新渲染,這意味著使用者 session 不會在每次路由變更時被檢查。

相反地,你應該在資料來源附近或將被條件式渲染的元件中進行檢查。

例如,考慮一個共享佈局,它獲取使用者資料並在導航中顯示使用者圖片。與其在佈局中進行授權檢查,你應該在佈局中獲取使用者資料 (getUser()),並在 DAL 中進行授權檢查。

這確保了無論在應用程式的哪個地方呼叫 getUser(),都會執行授權檢查,並防止開發者忘記檢查使用者是否有權存取資料。

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser();

  return (
    // ...
  )
}
export default async function Layout({ children }) {
  const user = await getUser();

  return (
    // ...
  )
}
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  // 從 session 中獲取使用者 ID 並取得資料
})
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null

  // 從 session 中獲取使用者 ID 並取得資料
})

小知識:

  • 在 SPA 中,常見的模式是在佈局或頂層元件中 return null 如果使用者未獲授權。這種模式 不推薦 使用,因為 Next.js 應用程式有多個入口點,這不會阻止巢狀路由區段和伺服器動作 (Server Actions) 被存取。

伺服器動作 (Server Actions)

對待 伺服器動作 時,應採用與對外公開的 API 端點相同的安全性考量,並驗證使用者是否被允許執行變更。

在下面的範例中,我們在允許動作繼續之前檢查使用者的角色:

'use server'
import { verifySession } from '@/app/lib/dal'

export async function serverAction(formData: FormData) {
  const session = await verifySession()
  const userRole = session?.user?.role

  // 如果使用者未被授權執行該動作,則提前回傳
  if (userRole !== 'admin') {
    return null
  }

  // 為授權的使用者繼續執行動作
}
'use server'
import { verifySession } from '@/app/lib/dal'

export async function serverAction() {
  const session = await verifySession()
  const userRole = session.user.role

  // 如果使用者未被授權執行該動作,則提前回傳
  if (userRole !== 'admin') {
    return null
  }

  // 為授權的使用者繼續執行動作
}

路由處理器 (Route Handlers)

對待 路由處理器 時,應採用與對外公開的 API 端點相同的安全性考量,並驗證使用者是否被允許存取路由處理器。

例如:

import { verifySession } from '@/app/lib/dal'

export async function GET() {
  // 使用者驗證和角色檢查
  const session = await verifySession()

  // 檢查使用者是否已驗證
  if (!session) {
    // 使用者未驗證
    return new Response(null, { status: 401 })
  }

  // 檢查使用者是否具有 'admin' 角色
  if (session.user.role !== 'admin') {
    // 使用者已驗證但不具備正確權限
    return new Response(null, { status: 403 })
  }

  // 為授權的使用者繼續執行
}
import { verifySession } from '@/app/lib/dal'

export async function GET() {
  // 使用者驗證和角色檢查
  const session = await verifySession()

  // 檢查使用者是否已驗證
  if (!session) {
    // 使用者未驗證
    return new Response(null, { status: 401 })
  }

  // 檢查使用者是否具有 'admin' 角色
  if (session.user.role !== 'admin') {
    // 使用者已驗證但不具備正確權限
    return new Response(null, { status: 403 })
  }

  // 為授權的使用者繼續執行
}

上面的範例展示了一個具有雙層安全性檢查的路由處理器。它首先檢查有效的 session,然後驗證登入的使用者是否為 'admin'。

上下文提供者 (Context Providers)

由於 交錯渲染 (interleaving),使用上下文提供者進行驗證是可行的。然而,React context 在伺服器元件中不受支援,因此僅適用於客戶端元件。

這種方式可行,但任何子伺服器元件會先在伺服器端渲染,且無法存取上下文提供者的 session 資料:

import { ContextProvider } from 'auth-lib'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <ContextProvider>{children}</ContextProvider>
      </body>
    </html>
  )
}
'use client';

import { useSession } from "auth-lib";

export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)

  return (
    // ...
  );
}
'use client';

import { useSession } from "auth-lib";

export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)

  return (
    // ...
  );
}

如果客戶端元件需要 session 資料(例如用於客戶端資料獲取),可以使用 React 的 taintUniqueValue API 來防止敏感的 session 資料暴露給客戶端。

資源

現在你已經了解了 Next.js 中的驗證機制,以下是一些與 Next.js 相容的函式庫和資源,可幫助你實現安全的驗證和 session 管理:

驗證函式庫

Session 管理函式庫

延伸閱讀

要繼續學習有關驗證和安全性的知識,請查看以下資源: