二、nextjs API 路由如何做好 JWT 登录鉴权、身份鉴权, joi 字段校验,全局处理异常等

查看 20|回复 0
作者:hlibaibai1   
介绍
在这篇文章中,我们将学习如何在 C-Shopping 电商开源项目中,基于 Next.js 14 ,处理所有 API 路由中添加身份验证和错误处理中间件的思路与实现。
这篇文章中的代码片段取自我最近开源项目 C-Shopping
完整的项目和文档可在https://github.com/huanghanzhilian/c-shopping 地址查看。
项目在线演示地址:
docker 部署地址:http://shop.huanghanlian.com/
vercel 部署地址:https://c-shopping-three.vercel.app/
Next.js 中的 API 路由
在 Next.js14 中,/app/api 文件夹包含所有基于文件名路由的 api 接口
例如文件 /app/api/user/route.js 会自动映射到路由 /api/user。API 路由处理程序导出一个默认函数,该函数传递给 HTTP 请求处理程序。
有关 Next.js API 路由的更多信息,请参阅 https://nextjs.org/docs/app/building-your-application/routing/route-handlers。
官方示例 Next.js API 路由处理程序
下面是一个 API 路由处理程序的基本示例,它将用户列表返回给 HTTP GET 请求。
只需要导出一个支持 HTTP 协议名称,再返回一个 Response ,就完成了一个 API
export async function GET() {
  const res = await fetch('https://data.mongodb-api.com/...', {
    headers: {
      'Content-Type': 'application/json',
      'API-Key': process.env.DATA_API_KEY,
    },
  })
  const data = await res.json()

  return Response.json({ data })
}
Next.js 自定义编码设计 API 处理器
我们会发现,如果按照官方的文档来写 API ,虽然简单,但是毫无设计感,当面对复杂项目时候很多引用会重复出现,我们需要设计一些中间间,来帮助我们更好的扩展 API 编码。
为了增加对中间件的支持,我创建了apiHandler包装器函数,该包装器接受一个 API 处理程序对象,并返回一个HTTP方法(例如GET, POST, PUT, DELETE等),再到route文件导出该API,这样就既简单又高效的做好了基础的编码设计。
通过apiHandler包装器函数,再扩展了jwtMiddleware、identityMiddleware、validateMiddleware、errorHandler,来更好的设计优化代码:
  • jwtMiddleware (处理 JWT 校验);
  • identityMiddleware (处理身份校验);
  • validateMiddleware (处理 joi ,字段校验);
  • errorHandler (全局处理异常)。

    项目中的路径 /helpers/api/api-handler.js
    import { NextRequest, NextResponse } from 'next/server'
    import { errorHandler, jwtMiddleware, validateMiddleware, identityMiddleware } from '.'
    export { apiHandler }
    function isPublicPath(req) {
      // public routes that don't require authentication
      const publicPaths = ['POST:/api/auth/login', 'POST:/api/auth/logout', 'POST:/api/auth/register']
      return publicPaths.includes(`${req.method}:${req.nextUrl.pathname}`)
    }
    function apiHandler(handler, { identity, schema, isJwt } = {}) {
      return async (req, ...args) => {
        try {
          if (!isPublicPath(req)) {
            // global middleware
            await jwtMiddleware(req, isJwt)
            await identityMiddleware(req, identity, isJwt)
            await validateMiddleware(req, schema)
          }
          // route handler
          const responseBody = await handler(req, ...args)
          return NextResponse.json(responseBody || {})
        } catch (err) {
          console.log('global error handler', err)
          // global error handler
          return errorHandler(err)
        }
      }
    }
    users [id] API 路由处理程序
    下面代码我们可以看到,使用了apiHandler包装器
  • 第一个参数是当前 HTTP 请求的核心逻辑,解析body、query、params,查询数据,最后通过统一的setJson返回数据结构
  • 第二个参数是一个对象,里面包含了一些中间层扩展参数逻辑,isJwt是否需要 JWT 校验、schema需要校验的字段和类型、identity操作的用户是否符合权限等。

    项目中的路径 /app/api/user/[id]/route.js
    import joi from 'joi'
    import { usersRepo, apiHandler, setJson } from '@helpers'
    const updateRole = apiHandler(
      async (req, { params }) => {
        const { id } = params
        const body = await req.json()
        await usersRepo.update(id, body)
        return setJson({
          message: '更新成功',
        })
      },
      {
        isJwt: true,
        schema: joi.object({
          role: joi.string().required().valid('user', 'admin'),
        }),
        identity: 'root',
      }
    )
    const deleteUser = apiHandler(
      async (req, { params }) => {
        const { id } = params
        await usersRepo.delete(id)
        return setJson({
          message: '用户信息已经删除',
        })
      },
      {
        isJwt: true,
        identity: 'root',
      }
    )
    export const PATCH = updateRole
    export const DELETE = deleteUser
    export const dynamic = 'force-dynamic'
    Next.js jwtMiddleware 授权中间件
    项目中JWT身份验证中间件是使用jsonwebtoken库来验证发送到受保护 API 路由的请求中的 JWT 令牌,如果令牌无效,则抛出错误,导致全局错误处理程序返回 401 Unauthorized 响应。JWT 中间件被添加到 API 处理程序包装函数中的 Next.js 请求管道中。
    项目中的路径:/api/jwt-middleware.js
    import { auth } from '../'
    async function jwtMiddleware(req, isJwt = false) {
      const id = await auth.verifyToken(req, isJwt)
      req.headers.set('userId', id)
    }
    export { jwtMiddleware }
    项目中的路径:/helpers/auth.js
    import jwt from 'jsonwebtoken'
    const verifyToken = async (req, isJwt) => {
      try {
        const token = req.headers.get('authorization')
        const decoded = jwt.verify(token, process.env.NEXT_PUBLIC_ACCESS_TOKEN_SECRET)
        const id = decoded.id
        return new Promise(resolve => resolve(id))
      } catch (error) {
        if (isJwt) {
          throw error
        }
      }
    }
    const createAccessToken = payload => {
      return jwt.sign(payload, process.env.NEXT_PUBLIC_ACCESS_TOKEN_SECRET, {
        expiresIn: '1d',
      })
    }
    export const auth = {
      verifyToken,
      createAccessToken,
    }
    Next.js identityMiddleware 身份校验中间件
    在项目设计中,暂时只设计了user普通用户、admin管理员用户,以及一个超级管理员权限root字段,在apiHandler()包装器函数调用时,可以来控制该接口的权限以及身份。
    如果权限不匹配,将抛出全局错误,进入 Next.js 请求管道中,交给全局错误处理程序,从而做到接口异常处理。
    项目中的路径:/helpers/api/identity-middleware.js
    import { usersRepo } from '../db-repo'
    async function identityMiddleware(req, identity = 'user', isJwt = false) {
      if (identity === 'user' && isJwt === false) return
      const userId = req.headers.get('userId')
      const user = await usersRepo.getOne({ _id: userId })
      req.headers.set('userRole', user.role)
      req.headers.set('userRoot', user.root)
      if (identity === 'admin' && user.role !== 'admin') {
        throw '无权操作'
      }
      if (identity === 'root' && !user.root) {
        throw '无权操作,仅超级管理可操作'
      }
    }
    export { identityMiddleware }
    Next.js validateMiddleware 请求参数校验中间件
    在apiHandler()包装器函数调用时,通过joi工具,schema 参数,来指定需要接收和校验的参数,从而避免一些冗余的字段传递,减少异常的发生。
    项目中的路径:/helpers/api/validate-middleware.js
    import joi from 'joi'
    export { validateMiddleware }
    async function validateMiddleware(req, schema) {
      if (!schema) return
      const options = {
        abortEarly: false, // include all errors
        allowUnknown: true, // ignore unknown props
        stripUnknown: true, // remove unknown props
      }
      const body = await req.json()
      const { error, value } = schema.validate(body, options)
      if (error) {
        throw `Validation error: ${error.details.map(x => x.message).join(', ')}`
      }
      // update req.json() to return sanitized req body
      req.json = () => value
    }
    Next.js 全局错误处理程序
    使用全局错误处理程序捕获所有错误,并消除了在整个 Next.js API 中重复错误处理代码的需要。
    通常按照惯例,'string'类型的错误被视为自定义(特定于应用程序)错误,这简化了抛出自定义错误的代码,因为只需要抛出一个字符串(例如抛出'Username 或 password is incorrect'),如果自定义错误以'not found'结尾,则返回 404 响应代码,否则返回标准的 400 错误响应。
    如果错误是一个名为“UnauthorizedError”的对象,则意味着 JWT 令牌验证失败,因此 HTTP 401 未经授权的响应代码将返回消息“无效令牌”。
    所有其他(未处理的)异常都被记录到控制台,并返回一个 500 服务器错误响应代码。
    项目中的路径:/helpers/api/error-handler.js
    import { NextResponse } from 'next/server'
    import { setJson } from './set-json'
    export { errorHandler }
    function errorHandler(err) {
      if (typeof err === 'string') {
        // custom application error
        const is404 = err.toLowerCase().endsWith('not found')
        const status = is404 ? 404 : 400
        return NextResponse.json(
          setJson({
            message: err,
            code: status,
          }),
          { status }
        )
      }
      if (err.name === 'JsonWebTokenError') {
        // jwt error - delete cookie to auto logout
        return NextResponse.json(
          setJson({
            message: 'Unauthorized',
            code: '401',
          }),
          { status: 401 }
        )
      }
      if (err.name === 'UserExistsError') {
        return NextResponse.json(
          setJson({
            message: err.message,
            code: '422',
          }),
          { status: 422 }
        )
      }
      // default to 500 server error
      console.error(err)
      return NextResponse.json(
        setJson({
          message: err.message,
          code: '500',
        }),
        { status: 500 }
      )
    }
    Next.js 统一处理 NextResponse ,灵活统一使用 setJson
    为什么要这样设计?我们不想在每个route中,来回的去引用NextResponse,这会使得代码可读性很差,所以在apiHandler包装器函数中,调用了 HTTP handler ,拿到了路由管道中想要的数据,最后统一输出。
    项目中的路径:/helpers/api/set-json.js
    const setJson = ({ code, message, data } = {}) => {
      return {
        code: code || 0,
        message: message || 'ok',
        data: data || null,
      }
    }
    export { setJson }
    至此,我们已经完成了API的设计,这将会给后期的开发带来效率,但同时也带来了代码的难以理解度,只能说设计程序需要有取舍,合适就好。这是我自己基于Next.js Route 的一些设计,也欢迎大家一起共同探讨。
  • 您需要登录后才可以回帖 登录 | 立即注册

    返回顶部