JWT知识梳理与实战
书接上文,我们基本完成了JWT的功能,但是它还有很多不完善的地方,本章我们将它们细细优化一下
本章承接上文juejin.cn/post/744446…
在这一章中,我们从JWT的特性和限制出发,提出了双Token的优化方案,并结合数据库、定时任务等技术实现了安全可靠的Token刷新机制。
我们这里采用比较常见的accessToken与refreshToken的方式来优化
为什么要这样做?
JWT本身的特点以及带来的缺陷
- JWT一旦颁发给用户,那么用户在Token有效期间都是畅通无阻的,你无法在服务器上直接强制让某个用户下线。
- 由于token会经常在网络传输中出现,容易存在安全风险,因为服务器只认token,容易被黑客攻击,token的有效时间不宜过长。
- 如果token时间很短,也有问题,用户会经常需要登陆,这明显会严重影响用户体验。
你会发现,由于第一条的限制,如果我们想后端下线某一个用户,就必须将用户登陆状态存储在数据库中。
而第二条要求token到有效时间不能太长,太长会存在安全隐患,第三条又要求token存在时间不能太短,太短会频繁要求用户登陆,影响用户体验。
解决方案:双token的模式,accessToken and refreshToken
他们的过期时间不同,分别为:
- 过期时间短的accessToken,过期时间一般在15分钟
- 过期时间长的refreshToken,过期时间不定,一般会有7天左右
具体实现:
- 数据库中单独建表存储refreshToken,这样就能在后端主动吊销。
- 在客户端检查accessToken,遇到快过期的时候,就发送对应的refreshTokenAction,更新accessToken。
- 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代码
好了,让我们先修改一下文件结构,由于授权功能越来越复杂,我们单独建一个文件夹来处理这些功能,就像这样:
然后,将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>
);
}
运行试试,登陆之后,你可以在首页看到一个刷新按钮
试试点击刷新token
使用node-cron来做定时任务,定时清理过期的refreshToken
好的,目前看来,前端部分的功能我们已经做得差不多了,由于我们将refreshToken存储在数据库中,当refreshToken过期时,我们需要去清理数据库中的过期refreshToken
首先,我先告诉你如何清楚过期的refreshToken
const result = await prisma.refreshToken.deleteMany({
where: {
expiresAt: {
lt: new Date(), // 删除所有过期的 Refresh Token
},
},
});
这里有三种方案:
- 最简单的,在调用refreshToken的地方去删除过期的refreshToken
- 使用
node-cron开一个定时任务,每天定时去清理refreshToken - 利用数据库触发器或事件调度器,每天清理一次过期的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,如果有什么问题,也可以在评论区提出,感谢你们的反馈。