V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
jixiaopeng
V2EX  ›  程序员

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

  •  
  •   jixiaopeng ·
    huanghanzhilian · 288 天前 · 2142 次点击
    这是一个创建于 288 天前的主题,其中的信息可能已经有所发展或是发生改变。

    介绍

    在这篇文章中,我们将学习如何在 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方法(例如GETPOSTPUTDELETE等),再到route文件导出该API,这样就既简单又高效的做好了基础的编码设计。

    通过apiHandler包装器函数,再扩展了jwtMiddlewareidentityMiddlewarevalidateMiddlewareerrorHandler,来更好的设计优化代码:

    • 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 请求的核心逻辑,解析bodyqueryparams,查询数据,最后通过统一的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 的一些设计,也欢迎大家一起共同探讨。

    18 条回复    2024-02-13 05:12:28 +08:00
    Dragonphy
        1
    Dragonphy  
       288 天前
    Next 最近人人喊打啊,都在推 Remix
    jixiaopeng
        2
    jixiaopeng  
    OP
       288 天前
    @Dragonphy 我觉得能解决问题就是好的框架,就和谈对象一样,要看双方适不适合
    lstz
        3
    lstz  
       288 天前 via Android
    怎么不上 ts
    jixiaopeng
        4
    jixiaopeng  
    OP
       288 天前
    @lstz 后续会持续迭代,肯定要上 TS
    wu00
        5
    wu00  
       288 天前
    asp.net core 一模一样
    jixiaopeng
        6
    jixiaopeng  
    OP
       288 天前
    @wu00 不太了解 asp.net core ,要去学习学习了
    dream4ever
        7
    dream4ever  
       288 天前
    写系列文章的话,可以在每一篇文章中把之前的各篇文章链接也附上,阅读体验会更好。
    jixiaopeng
        8
    jixiaopeng  
    OP
       288 天前
    @dream4ever 谢谢老师的建议,我一定会改正,再次感激提醒
    horizon
        9
    horizon  
       288 天前
    很好,我用 blitzjs
    jixiaopeng
        10
    jixiaopeng  
    OP
       288 天前
    @horizon 感激很好的样子
    xiaojun996
        11
    xiaojun996  
       288 天前
    不错不错,虽然我现在是用 nextjs + nestjs 开发了 2 个项目,没有用 nextjs 写 api ,不过你这个文章让我收获不少,谢谢 upup
    jixiaopeng
        12
    jixiaopeng  
    OP
       288 天前 via iPhone
    @xiaojun996 哈哈,一起共同进步
    lilei2023
        13
    lilei2023  
       288 天前
    页面路由权限怎么搞? 没找一个最佳的办法,尤其是在服务端发送请求判断的时候,
    jixiaopeng
        14
    jixiaopeng  
    OP
       288 天前 via iPhone
    @lilei2023 您可以看看我开源项目源码,我是用 layout 组件来处理,后续我会更新这类文章。
    rizon
        15
    rizon  
       286 天前 via iPhone
    文章挺好的。

    顺便随手感慨一句:js 这种语言果然可读性很差啊,ts 也就好个些许。python 这种缩进型的读起来也是痛苦。
    单说阅读,在各种语法糖的加持下,需要更多的理解消耗。有时候不能做到一眼扫一下就看完的效果。还需要脑内加工一下。
    就随便感慨一句。
    jixiaopeng
        16
    jixiaopeng  
    OP
       286 天前 via iPhone
    @rizon 确实是,我在想我要尽快改成 ts
    cheunghy
        17
    cheunghy  
       283 天前
    next 新出的 app 路由,坑太多了
    jixiaopeng
        18
    jixiaopeng  
    OP
       282 天前 via iPhone
    @cheunghy 确实是,需要去踩踩,不过理解了感觉还行
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1076 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 34ms · UTC 19:00 · PVG 03:00 · LAX 11:00 · JFK 14:00
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.