为了账号安全,请及时绑定邮箱和手机立即绑定

Next.js 实现JWT身份验证:SSR与CSR的简单教程

一张随机的漂亮图片,用来吸引你的注意

Random pretty picture to get your attention

最近,我遇到了一个有趣的关于技术挑战的问题。如何在 NextJs 中使用存储于 HTTP-only cookie 中的 JWT 进行服务器端渲染,而不借助任何外部库?

在我的GitHub上,可以找到同时包含了后端和前端的单代码库,地址是我的GitHub

方法

我采取了一种非常简单的方法——生成JWT令牌的小后端和前端则将生成的令牌存储在cookie中。对于客户端的请求,我使用了rewrite指令将基础URL替换为后端的URL。对于服务端的请求,我们可以直接使用URL进行获取而无需任何处理(因为没有跨源资源共享(CORS)的问题)。服务端的数据获取则得益于从next/headers中获取的cookies包。

我们的后台

我做了一个非常简单的NodeJs Express后端应用,它只有3个接口。为了简单起见,我使用了一个基于文件的数据库插件叫做file-system-db插件。

注册端

此接口允许用户使用用户名和密码注册账户。我没有处理加盐和哈希这些细节,但在实际应用中,这些步骤是非常重要的。

    app.post("/sign-up", (req, res) => {  
      console.log(req.body);  
      if (!req.body || !req.body.username || !req.body.password) {  
        res.status(400).send({ message: "请输入用户名和密码" });  
        return;  
      }  

      const { username, password } = req.body;  
      if (db.has(username)) {  
        res.status(400).send({ message: "该用户名已被注册" });  
        return;  
      }  

      db.set(username, password);  
      res.send({ message: "注册成功" });  
    });
登录端点接口

登录端点返回一个 JWT 令牌,该令牌会被设置为前端的 cookie,允许已经登录的用户发起请求。我用 jsonwebtoken 这个库生成了令牌。

    app.post("/sign-in", (req, res) => {  
      if (!req.body || !req.body.username || !req.body.password) {  
        res.status(400).json({ message: "用户名和密码必填" });  
        return;  
      }  

      const { username, password } = req.body;  
      if (!db.has(username) || db.get(username) !== password) {  
        res.status(403).json({ message: "无效的凭据" });  
        return;  
      }  

      const token = jwt.sign({ username }, jwtSigningSecret, {  
        algorithm: "HS256",  
      });  
      res.cookie("jwt", token);  
      res.send({ message: "登录成功" });  
    });
已就绪的端点

此端点唯一的作用是验证与请求一同发送的JWT令牌。我使用了jsonwebtoken来验证令牌。

    app.get("/ready", (req, res) => {  
      const token = req.cookies["jwt"];  
      console.log(token);  
      if (!token) {  
        res.status(403).send({ message: "未通过认证" });  
        return;  
      }  

      let decoded;  
      try {  
        decoded = jwt.verify(token, jwtSigningSecret);  
      } catch (error) {  
        res.status(403).send({ message: "请重新登录" });  
        return;  
      }  

      if (!db.has(decoded.username)) {  
        res.status(403).send({ message: "登录已过期" });  
        return;  
      }  

      res.send({ message: "搞定", username: decoded.username });  
    });
前端(指网站或应用的用户界面)
next.config.ts

这是 Next.js 应用程序的配置文件。

首先我需要更改的是 rewrites 指令项。为了让客户端请求能够正常加载,我们将路径以 /api/ 开头的请求重写为后端的基础 URL(在本地运行的情况下为 http://localhost:8080)。

    const API_URL = process.env.BACKEND_BASE_URL;  // API 的基础 URL

    /** 类型为 {import('next').NextConfig} */  // 用于配置 Next.js 应用程序的行为
    const nextConfig = {  
      async rewrites() {  // 重写API路径
        return [  
          {  
            source: "/api/:path*",  
            destination: `${API_URL}/:path*`,  
          },  
        ];  
      },  
    };  

    export default nextConfig;
client-side-fetching.ts

// 客户端数据获取.ts (client-side data fetching.ts)

对于客户端数据获取,我通常使用axios。如果遇到unauthenticated响应,我可以利用axios拦截器引导用户回到登录页面。

    "use client";  

    import axios from "axios";  

    const clientFetch = axios.create();  

    clientFetch.interceptors.response.use(  
      (response) => {  
        if (response.status >= 400 && response.status <= 403) {  
          window.location.pathname = "/sign-in";  
          return Promise.reject("未授权");  
        }  
        return response;  
      },  
      (error) => {  
        return Promise.reject(error);  
      }  
    );  

    export default clientFetch;

你可以在这里看看它是怎么用的,点击此处:here

const 调用登录 = async (data: { username: string; password: string }) => {  
  try {  
    await clientFetch.post("/api/sign-in", data);  
    router.push("/home");  
  } catch (错误) {  
    const err = 错误 as AxiosError;  
    const 数据 = err.response?.data as { message: string };  
    设置错误(数据.message);  
  }  
};
服务器端数据获取.ts // 此 TypeScript 文件用于处理服务器端数据获取

我也写了一个帮助函数来在服务器端获取数据。幸运的是,我们能通过 next/headers 中的 cookies 包从 cookie 中提取 JWT。

这个辅助函数返回一个Promise,包含一个数组,其中包含O或null,以及Error或null。

    import "server-only";
    import { cookies } from "next/headers";
    import { redirect } from "next/navigation";
    import { env } from "@/env";

    /**

*  each parameter description and return value description
     */
    export async function ssrFetch<O, I = undefined>(
      pathname: string,
      options: {
        method: string;
        body?: I;
        next?: NextFetchRequestConfig;
      }
    ): Promise<[O | null, Error | null]> {
      let response;
      try {
        response = await fetch(`${env.BACKEND_BASE_URL}${pathname}`, {
          method: options.method,
          body: options.body ? JSON.stringify(options.body) : undefined,
          headers: {
            Cookie: `jwt=${(await cookies()).get("jwt")?.value || ""}`,
            "Content-Type": "application/x-www-form-urlencoded",
          },
          next: {
            revalidate: 0,
            ...options.next,
          },
        });
      } catch (error) {
        return [null, error as Error];
      }

      // 如果响应状态码为403(禁止访问)
      if (response.status === 403) {
        redirect("/sign-in");
        // 指向登录页面
      }

      // 将响应内容解析为JSON
      response = await response.json();

      // 返回一个包含数据和错误的Promise
      return [response, null];
    }

你可以在这里点击看看它是怎么用的

    async function 从JWT获取用户名(): Promise<string> {  
      const [result, error] = await ssrFetch<{  
        message: string;  
        username: string;  
      }>("/ready", { method: "GET" });  
      if (error !== null || result?.username === undefined) {  
        throw new Error("无法获取/ready");  
      }  

      return result.username;  
    }
middleware.ts

在中间件中,我们可以检查用户在每一页上的认证情况。这样可以避免用户在JWT令牌过期后看到缓存页面,比如。

    import axios, { AxiosError } from "axios";  
    import { NextRequest, NextResponse } from "next/server";  

    const PUBLIC_PATHS = ["/sign-in", "/sign-up"];  
    const ENCODED_CHAR_REGEX = /%[0-9A-Fa-f]{2}/;  

    async function validateJWT(jwt: string | undefined) {  
      if (!jwt) {  
        return false;  
      }  

      try {  
        await axios(`${process.env.BACKEND_BASE_URL}/ready`, {  
          method: "GET",  
          headers: {  
            Cookie: `jwt=${jwt}`,  
          },  
        });  
        return true;  
      } catch (error) {  
        return false;  
      }  
    }  

    export default async function middleware(req: NextRequest) {  
      const { pathname } = req.nextUrl;  

      // 处理包含编码字符的URL路径  
      if (ENCODED_CHAR_REGEX.test(pathname)) {  
        console.log("middleware: before rewrite", pathname);  
        const decodedPathname = decodeURIComponent(pathname);  
        return NextResponse.rewrite(new URL(`/app${decodedPathname}`, req.url));  
      }  

      // 跳过 Next.js 内部路由  
      if (pathname.includes("/_next/")) {  
        return NextResponse.next();  
      }  

      const jwt = req.cookies.get("jwt");  
      const isPublicPath = PUBLIC_PATHS.some((path) => pathname.startsWith(path));  
      console.log("middleware path:", pathname, "isPublicPath", isPublicPath);  

      // 处理没有 JWT 的非公共路径  
      if (!jwt && !isPublicPath) {  
        console.log("running this");  
        return NextResponse.redirect(new URL("/sign-in", req.url));  
      }  

      // 对于没有JWT的公共路径,直接跳过JWT验证  
      if (!jwt && isPublicPath) {  
        return NextResponse.next();  
      }  

      const isValidJWT = await validateJWT(jwt?.value);  

      // 如果 JWT 无效,重定向到登录页面  
      if (!isValidJWT) {  
        return NextResponse.redirect(new URL("/sign-in", req.url));  
      }  

      // 将已认证的用户从公共路径重定向到首页  
      if (isPublicPath) {  
        return NextResponse.redirect(new URL("/home", req.url));  
      }  
      console.log("middleware PASS");  

      return NextResponse.next();  
    }  

    export const config = {  
      matcher: [  
        /*  

* 匹配所有请求路径,但排除以下路径开头的请求:  

* - web/api (API 路由)  

* - web/_next/static (静态文件)  

* - web/_next/image (图片优化文件)  

* 排除所有以 .png 结尾的路径,例如web/somefile.png  
         */  
        "/((?!api|_next/image|favicon.ico|.*\\.png$).*)",  
      ],  
    };
最后的总结

这种方法受到了Max Schmitt在其文章的启发,并进行了调整和适应,针对NextJs的新版本,利用了middleware.ts并简化了流程,使之更易于操作,通过移除代理API路由,使其更加简洁,同时提供了SSR和CSR数据获取的辅助函数,以简化开发过程。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消