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

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

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

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

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

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

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

身份驗證

以下是實作註冊和/或登入表單的步驟:

  1. 使用者透過表單提交他們的憑證。
  2. 表單發送一個由 API 路由處理的請求。
  3. 成功驗證後,流程完成,表示使用者已成功驗證。
  4. 如果驗證失敗,則顯示錯誤訊息。

考慮一個登入表單,使用者可以輸入他們的憑證:

import { FormEvent } from 'react'
import { useRouter } from 'next/router'

export default function LoginPage() {
  const router = useRouter()

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()

    const formData = new FormData(event.currentTarget)
    const email = formData.get('email')
    const password = formData.get('password')

    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })

    if (response.ok) {
      router.push('/profile')
    } else {
      // 處理錯誤
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" name="email" placeholder="Email" required />
      <input type="password" name="password" placeholder="Password" required />
      <button type="submit">登入</button>
    </form>
  )
}
import { FormEvent } from 'react'
import { useRouter } from 'next/router'

export default function LoginPage() {
  const router = useRouter()

  async function handleSubmit(event) {
    event.preventDefault()

    const formData = new FormData(event.currentTarget)
    const email = formData.get('email')
    const password = formData.get('password')

    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })

    if (response.ok) {
      router.push('/profile')
    } else {
      // 處理錯誤
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" name="email" placeholder="Email" required />
      <input type="password" name="password" placeholder="Password" required />
      <button type="submit">登入</button>
    </form>
  )
}

上面的表單有兩個輸入欄位,用於捕獲使用者的電子郵件和密碼。提交時,它會觸發一個函式,該函式向 API 路由 (/api/auth/login) 發送 POST 請求。

然後,您可以在 API 路由中呼叫您的驗證提供者的 API 來處理驗證:

import type { NextApiRequest, NextApiResponse } from 'next'
import { signIn } from '@/auth'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    const { email, password } = req.body
    await signIn('credentials', { email, password })

    res.status(200).json({ success: true })
  } catch (error) {
    if (error.type === 'CredentialsSignin') {
      res.status(401).json({ error: '無效的憑證。' })
    } else {
      res.status(500).json({ error: '發生錯誤。' })
    }
  }
}
import { signIn } from '@/auth'

export default async function handler(req, res) {
  try {
    const { email, password } = req.body
    await signIn('credentials', { email, password })

    res.status(200).json({ success: true })
  } catch (error) {
    if (error.type === 'CredentialsSignin') {
      res.status(401).json({ error: '無效的憑證。' })
    } else {
      res.status(500).json({ error: '發生錯誤。' })
    }
  }
}

工作階段管理

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

有兩種類型的工作階段:

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

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

無狀態工作階段

設定與刪除 cookies

您可以使用 API 路由 (API Routes) 在伺服器上將工作階段設定為 cookie:

import { serialize } from 'cookie'
import type { NextApiRequest, NextApiResponse } from 'next'
import { encrypt } from '@/app/lib/session'

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const sessionData = req.body
  const encryptedSessionData = encrypt(sessionData)

  const cookie = serialize('session', encryptedSessionData, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 7, // 一週
    path: '/',
  })
  res.setHeader('Set-Cookie', cookie)
  res.status(200).json({ message: '成功設定 cookie!' })
}
import { serialize } from 'cookie'
import { encrypt } from '@/app/lib/session'

export default function handler(req, res) {
  const sessionData = req.body
  const encryptedSessionData = encrypt(sessionData)

  const cookie = serialize('session', encryptedSessionData, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 7, // 一週
    path: '/',
  })
  res.setHeader('Set-Cookie', cookie)
  res.status(200).json({ message: '成功設定 cookie!' })
}

資料庫工作階段 (Database Sessions)

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

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

在伺服器上建立工作階段

import db from '../../lib/db'
import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    const user = req.body
    const sessionId = generateSessionId()
    await db.insertSession({
      sessionId,
      userId: user.id,
      createdAt: new Date(),
    })

    res.status(200).json({ sessionId })
  } catch (error) {
    res.status(500).json({ error: '內部伺服器錯誤' })
  }
}
import db from '../../lib/db'

export default async function handler(req, res) {
  try {
    const user = req.body
    const sessionId = generateSessionId()
    await db.insertSession({
      sessionId,
      userId: user.id,
      createdAt: new Date(),
    })

    res.status(200).json({ sessionId })
  } catch (error) {
    res.status(500).json({ error: '內部伺服器錯誤' })
  }
}

授權 (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 屬性來指定中介軟體應執行的路由。儘管如此,對於驗證,建議中介軟體在所有路由上執行

建立資料存取層 (DAL)

保護 API 路由

Next.js 中的 API 路由對於處理伺服器端邏輯和資料管理至關重要。保護這些路由以確保只有授權使用者可以存取特定功能是必要的。這通常涉及驗證使用者的驗證狀態和其基於角色的權限。

以下是一個保護 API 路由的範例:

import { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const session = await getSession(req)

  // 檢查使用者是否已驗證
  if (!session) {
    res.status(401).json({
      error: '使用者未驗證',
    })
    return
  }

  // 檢查使用者是否具有 'admin' 角色
  if (session.user.role !== 'admin') {
    res.status(401).json({
      error: '未授權存取:使用者不具備管理員權限。',
    })
    return
  }

  // 為授權的使用者繼續執行路由
  // ... API 路由的實作
}
export default async function handler(req, res) {
  const session = await getSession(req)

  // 檢查使用者是否已驗證
  if (!session) {
    res.status(401).json({
      error: '使用者未驗證',
    })
    return
  }

  // 檢查使用者是否具有 'admin' 角色
  if (session.user.role !== 'admin') {
    res.status(401).json({
      error: '未授權存取:使用者不具備管理員權限。',
    })
    return
  }

  // 為授權的使用者繼續執行路由
  // ... API 路由的實作
}

這個範例展示了一個具有雙層安全性檢查的 API 路由,用於驗證和授權。它首先檢查有效的 session,然後驗證登入的使用者是否為 'admin'。這種方法確保了安全的存取,僅限於已驗證和授權的使用者,保持了請求處理的穩健安全性。

資源

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

驗證函式庫

Session 管理函式庫

延伸閱讀

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