Next.js中的JWT知识梳理与实战

365 阅读7分钟

JWT知识梳理与实战

书接上文,我们基本完成了JWT的功能,但是它还有很多不完善的地方,本章我们将它们细细优化一下

本章承接上文juejin.cn/post/744446…

在这一章中,我们从JWT的特性和限制出发,提出了双Token的优化方案,并结合数据库、定时任务等技术实现了安全可靠的Token刷新机制。

我们这里采用比较常见的accessToken与refreshToken的方式来优化

为什么要这样做?

JWT本身的特点以及带来的缺陷

  1. JWT一旦颁发给用户,那么用户在Token有效期间都是畅通无阻的,你无法在服务器上直接强制让某个用户下线。
  2. 由于token会经常在网络传输中出现,容易存在安全风险,因为服务器只认token,容易被黑客攻击,token的有效时间不宜过长。
  3. 如果token时间很短,也有问题,用户会经常需要登陆,这明显会严重影响用户体验。

你会发现,由于第一条的限制,如果我们想后端下线某一个用户,就必须将用户登陆状态存储在数据库中。

而第二条要求token到有效时间不能太长,太长会存在安全隐患,第三条又要求token存在时间不能太短,太短会频繁要求用户登陆,影响用户体验。

解决方案:双token的模式,accessToken and refreshToken

他们的过期时间不同,分别为:

  1. 过期时间短的accessToken,过期时间一般在15分钟
  2. 过期时间长的refreshToken,过期时间不定,一般会有7天左右

具体实现:

  1. 数据库中单独建表存储refreshToken,这样就能在后端主动吊销。
  2. 在客户端检查accessToken,遇到快过期的时候,就发送对应的refreshTokenAction,更新accessToken。
  3. accessToken短期有效,减少被盗取后的风险。

聪明的你肯定发现了,refreshToken这么牛逼,那我一直刷新不就行了,当然可以,所以为了防止恶意攻击,一般来说,refreshToken的请求都是限流的

在prisma中创建RefreshToken对象

OK,让我们先从建表开始,打开我们的prisma/schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id       String @id @default(uuid())
  username String @unique
  password String
  email    String @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  RefreshToken RefreshToken[] // 相关外键
}

// 新的数据结构
model RefreshToken {
  id        Int      @id @default(autoincrement())
  token     String   @unique
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  createdAt DateTime @default(now())
  expiresAt DateTime
}

然后执行更新数据库的命令:

npx prisma migrate dev --name add_refresh_tokens
npx prisma generate

抽象auth逻辑,重构之前代码的action代码

好了,让我们先修改一下文件结构,由于授权功能越来越复杂,我们单独建一个文件夹来处理这些功能,就像这样:

image.png

然后,将login/action.ts中的代码复制到auth中,之后我们对其进行功能上的添加,新的auth/actions.ts的逻辑如下:

"use server";

import z from "zod";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/db/prisma";
import bcrypt from "bcryptjs";
import { JwtUser } from "@/domain/jwt";

const loginSchema = z.object({
  username: z.string().min(1),
  password: z.string().min(1),
});

function generateAccessToken(user: JwtUser) {
    return jwt.sign(user, process.env.JWT_SECRET as string, {
        expiresIn: "15m",
    });
}

function generateRefreshToken(user: JwtUser) {
    return jwt.sign(user, process.env.JWT_SECRET as string, {
        expiresIn: "7d",
    });
}

export async function loginAction(formData: FormData) {
  const username = formData.get("username");
  const password = formData.get("password");

  const parsed = loginSchema.safeParse({ username, password });

  if (!parsed.success) {
    return { error: "Invalid username or password" };
  }

  const user = await prisma.user.findUnique({
    where: { username: parsed.data.username },
  });

  if (!user) {
    return { error: "User not found" };
  }

  // 这里修改成 bcrypt.compareSync 来比较密码
  if (!bcrypt.compareSync(parsed.data.password, user.password)) {
    return { error: "Invalid password" };
  }

  const userPayload: JwtUser = { id: user.id };

  const accessToken = generateAccessToken(userPayload);
  const refreshToken = generateRefreshToken(userPayload);

  await prisma.refreshToken.create({
    data: {
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
    },
  });

  const cookieStore = await cookies();

  cookieStore.set("accessToken", accessToken, {
    httpOnly: true, // 防止xss攻击
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    expires: new Date(Date.now() + 1000 * 60 * 15), // 15 minutes   
  });

  cookieStore.set("refreshToken", refreshToken, {
    httpOnly: true, // 防止xss攻击
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
    expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
  });

  redirect("/");
}

export async function logoutAction() {
  const cookieStore = await cookies();

  const refreshToken = cookieStore.get("refreshToken")?.value;

  if (refreshToken) {
    // 从数据库中移除 Refresh Token
    await prisma.refreshToken.deleteMany({
      where: { token: refreshToken },
    });
  }

  cookieStore.delete("accessToken");
  cookieStore.delete("refreshToken");
  redirect("/login");
}

export async function refreshTokenAction(refreshToken: string) {
    try {
        // 验证 Refresh Token
        const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string) as JwtUser;
    
        // 查询数据库确保 Refresh Token 有效
        const storedToken = await prisma.refreshToken.findUnique({
          where: { token: refreshToken },
        });
    
        if (!storedToken) {
          throw new Error("Invalid refresh token");
        }
    
        // 生成新的 Access Token
        const newAccessToken = generateAccessToken({ id: decoded.id });
    
        // 设置新的 Access Token 到 Cookies
        const cookieStore = await cookies();

        cookieStore.set("accessToken", newAccessToken, {
          httpOnly: true,
          secure: process.env.NODE_ENV === "production",
          sameSite: "strict",
          maxAge: 60 * 15, // 15分钟
        });
    
        return { accessToken: newAccessToken };
      } catch (error) {
        console.log(error);
        return { error: "Invalid or expired refresh token" };
      }
}

const registerSchema = z.object({
    username: z.string().min(1),
    password: z.string().min(1),
    email: z.string().email(),
  });

export const registerAction = async (formData: FormData) => {
    const username = formData.get('username')?.toString() ?? '';
    const password = formData.get('password')?.toString() ?? '';
    const email = formData.get('email')?.toString() ?? '';

    if(!registerSchema.safeParse({ username, password, email }).success) {
        return { error: "Invalid username or password" };
    }

    const existingUser = await prisma.user.findFirst({
        where: {
            OR: [
                { username },
                { email }
            ]
        }
    });

    if(existingUser) {
        return { 
            error: existingUser.username === username 
                ? "username already exists" 
                : "email already exists"
        };
    }
    
    // 嘿,这地方需要加密密码哦, 这里的环境变量 process.env.BCRYPT_SALT = 10
    try {
        await prisma.user.create({
            data: { username, email, password: bcrypt.hashSync(password, Number(process.env.BCRYPT_SALT)) },
        })
    } catch (error) {
        return { error: (error as Error).message };
    }

    return { success: true };
}

OK,我们继续编写我们客户端刷新的组件,在auth文件夹创建auth/interval-refresh-token.client.tsx

"use client";

import { useEffect } from "react";
import { refreshTokenAction } from "./actions";

const REFRESH_TOKEN_INTERVAL = 1000 * 60 * 1;
const TIME_OFFSET = 1000 * 60 * 3;

export default function IntervalRefreshToken({
  accessTokenExp,
  refreshToken,
}: {
  accessTokenExp: number;
  refreshToken: string;
}) {
  useEffect(() => {
    // 检查并刷新 accessToken
    const refresh = async () => {
      if (Date.now() > (accessTokenExp * 1000 - TIME_OFFSET)) {
        await refreshTokenAction(refreshToken);
      }
    };

    // 每1分钟检查一次,确保在token过期前刷新
    const interval = setInterval(refresh, REFRESH_TOKEN_INTERVAL);

    // 组件卸载时清理定时器
    return () => clearInterval(interval);
  }, []);

  return (
    <button
      onClick={() => {
        refreshTokenAction(refreshToken);
      }}
    >
      刷新token
    </button>
  );
}

创建相关的服务器组件auth/interval-refresh-token.tsx

import { cookies } from "next/headers";
import IntervalRefreshTokenClient from "./interval-refresh-token.client";
import jwt from "jsonwebtoken";

export default async function IntervalRefreshToken() {
  const cookieStore = await cookies();
  const accessToken = cookieStore.get("accessToken")?.value;
  const refreshToken = cookieStore.get("refreshToken")?.value;

  if (!accessToken || !refreshToken) {
    return null;
  }

  const accessTokenExp = jwt.decode(accessToken);

  if (
    !accessTokenExp ||
    typeof accessTokenExp == "string" ||
    !accessTokenExp.exp
  ) {
    return null;
  }

  return (
    <IntervalRefreshTokenClient
      key={accessToken}
      accessTokenExp={accessTokenExp.exp}
      refreshToken={refreshToken}
    />
  );
}


OK,让我们将定时刷新组件放在layout.tsx

import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { ErrorBoundary } from "react-error-boundary";
import { Suspense } from "react";
import IntervalRefreshToken from "@/auth/interval-refresh-token";

const geistSans = localFont({
  src: "./fonts/GeistVF.woff",
  variable: "--font-geist-sans",
  weight: "100 900",
});
const geistMono = localFont({
  src: "./fonts/GeistMonoVF.woff",
  variable: "--font-geist-mono",
  weight: "100 900",
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}

        <ErrorBoundary fallback={null}>
            <Suspense fallback={null}>
                <IntervalRefreshToken />
            </Suspense>
        </ErrorBoundary>
      </body>
    </html>
  );
}

运行试试,登陆之后,你可以在首页看到一个刷新按钮

image 1.png 试试点击刷新token

使用node-cron来做定时任务,定时清理过期的refreshToken

好的,目前看来,前端部分的功能我们已经做得差不多了,由于我们将refreshToken存储在数据库中,当refreshToken过期时,我们需要去清理数据库中的过期refreshToken

首先,我先告诉你如何清楚过期的refreshToken

    const result = await prisma.refreshToken.deleteMany({
        where: {
          expiresAt: {
            lt: new Date(), // 删除所有过期的 Refresh Token
          },
        },
      });

这里有三种方案:

  1. 最简单的,在调用refreshToken的地方去删除过期的refreshToken
  2. 使用node-cron 开一个定时任务,每天定时去清理refreshToken
  3. 利用数据库触发器或事件调度器,每天清理一次过期的refreshToken

这里我们采用第二种方案,让我们下载node-cron

pnpm add node-cron
pnpm add @types/node-cron -D

创建一个文件夹叫task,并且创建文件 task/clear-refresh-token.ts

import cron from "node-cron";
import { prisma } from "@/db/prisma";

// 每天凌晨 1 点运行清理任务
cron.schedule("0 1 * * *", async () => {
  try {
    console.log("Starting token cleanup job...");

    const result = await prisma.refreshToken.deleteMany({
      where: {
        expiresAt: {
          lt: new Date(), // 删除所有过期的 Refresh Token
        },
      },
    });

    console.log(`Cleanup job completed. Deleted ${result.count} expired tokens.`);
  } catch (error) {
    console.error("Error during token cleanup job:", error);
  }
});

app/layout.tsx 中引入定时任务

import '@/task/clear-refresh-token'

OK,到这里,基本上我们的鉴权系统就有了雏形了,好了,我们下篇文章再见

相关代码请访问github.com/tenghuan123… 如果对你有帮助,请给我点一个star,如果有什么问题,也可以在评论区提出,感谢你们的反馈。