Skip to content

Latest commit

 

History

History
576 lines (431 loc) · 22.9 KB

File metadata and controls

576 lines (431 loc) · 22.9 KB

← 返回首页

第六章 - JWT 认证:双 Token 机制与自动刷新

前几章的页面没有任何身份控制——任何人都能访问所有数据。这一章引入真实的登录认证,使用 JWT 实现"登录 → 持久会话 → 自动续期 → 退出"的完整流程。后端换成独立的 Fastify 服务,前端 Next.js 通过 rewrite 代理与之通信。

示例代码:codes

运行方式:

# 终端 1:启动后端
cd codes/backend
npm install
npm run dev

# 终端 2:启动前端
cd codes/frontend
npm install
npm run dev

后端跑在 http://localhost:4000,可以在 http://localhost:4000/docs 打开 Scalar API 文档自行测试接口。
前端跑在 http://localhost:3000,直接访问登录页面。

测试账号:admin / passwordjack / cisco

目录

  1. JWT 认证的原理
  2. 双 Token 机制:为什么需要两个 Token?
  3. 信息放在哪里:Cookie vs localStorage
  4. 后端:Fastify 项目结构
  5. 后端:app.ts 插件注册
  6. 后端:四个认证接口
  7. 后端:Scalar API 文档
  8. 前端:next.config.ts 的 rewrite 代理
  9. 前端:auth-fetch.ts 的核心逻辑
  10. 前端:登录页面
  11. 前端:受保护页面与自动刷新

1. JWT 认证的原理

传统的 Session 认证里,服务端记录谁已登录(存在内存或数据库里);客户端只拿到一个不透明的 session id,每次请求服务端都要去查。JWT 翻转了这个逻辑:登录信息编码在 token 本身,服务端不存任何状态,只需要用密钥验签。

一个 JWT 由三段组成,用 . 分隔:

eyJhbGciOiJIUzI1NiJ9   ← Header(算法信息,Base64)
.eyJ1c2VySWQiOjF9       ← Payload(用户数据,Base64)
.SflKxwRJSMeKKF2QT...  ← Signature(用密钥对前两段签名)

Payload 是明文 Base64,任何人都可以解码看内容,但不知道密钥就无法伪造 Signature。服务端验证 token 时,只需重新计算签名并比较——不需要查数据库。

认证流程

用户输入账号密码
  → 发送 POST /auth/login
  → 后端验证,生成 access_token 和 refresh_token,写入 Cookie
  → 后续请求自动携带 Cookie(浏览器行为)
  → 后端从 Cookie 里取 access_token,验签,返回数据

前后端需要交换的信息:

方向 内容 方式
前端 → 后端(登录时) username + password JSON body
后端 → 前端(登录成功) access_token + refresh_token Set-Cookie
前端 → 后端(后续请求) Cookie(浏览器自动附带) Cookie header
后端 → 前端(校验结果) 用户信息 or 401 错误 JSON body

2. 双 Token 机制:为什么需要两个 Token?

只用一个 token 有两难困境:

  • 设置很长的有效期:token 一旦泄露,攻击者可以长时间冒用,没有办法撤销(服务端无状态)
  • 设置很短的有效期:用户频繁被踢出,体验极差

双 Token 是这个矛盾的工程解:

Access Token Refresh Token
用途 访问受保护接口 换取新的 access token
有效期 很短(本章 30 秒,生产通常 15 分钟) 较长(本章 180 秒,生产通常 7 天)
发出频率 每次请求都带上 仅在 access token 过期时用
泄露风险 较高(高频使用) 较低(低频使用)

自动刷新流程

请求 GET /auth/me
  → 后端返回 401(access token 过期)
  → 前端自动调用 POST /auth/refresh(携带 refresh_token cookie)
  → 后端验证 refresh token,重新签发两个 token,写入新 Cookie
  → 前端重试原始请求
  → 成功返回用户信息

对用户完全透明——token 在后台默默续期,不需要重新登录。只有当 refresh token 也过期时,才需要用户重新登录。


3. 信息放在哪里:Cookie vs localStorage

token 存哪里,是安全性的关键决策。

localStorage 的问题:任何 JavaScript 代码都可以读取 localStorage,XSS 攻击(注入恶意脚本)可以直接偷走 token。

httpOnly Cookie:设置了 httpOnly 标志的 Cookie,JavaScript 完全无法读取(包括 document.cookie),只有浏览器本身能操作它。XSS 攻击无法窃取 token。

本章后端在设置 Cookie 时统一用 httpOnly: true

reply.setCookie("access_token", accessToken, {
  httpOnly: true, // JS 不可读
  sameSite: "lax", // 跨站请求限制,防 CSRF
  maxAge: 30, // 秒
});

代价是:前端代码也看不到 token 的值,无法手动解析 Payload。在本案例中,登录以后的用户信息通过 /auth/me 接口返回,不需要前端通过解析token来获取。


4. 后端:Fastify 项目结构

codes/backend/
├── src/
│   ├── app.ts              # 入口:注册插件和路由,启动服务器
│   ├── data/
│   │   └── users.ts        # 模拟用户数据(生产环境换成数据库)
│   └── routes/
│       ├── index.ts        # 统一 re-export
│       ├── home.ts         # GET / 重定向到文档
│       └── auth.ts         # 认证路由:login / refresh / logout / me
└── package.json

核心依赖:

作用
fastify Web 框架
@fastify/cookie 读写 Cookie
@fastify/cors 跨域配置
@fastify/swagger 生成 OpenAPI 规范
@scalar/fastify-api-reference Scalar API 文档 UI
jsonwebtoken JWT 签发与验证
pino-pretty 格式化日志输出

5. 后端:app.ts 插件注册

// src/app.ts
await fastify.register(fastifyCors, {
  origin: "http://localhost:3000",
  credentials: true, // 允许跨域请求携带 Cookie
});
await fastify.register(fastifyCookie);
await fastify.register(fastifySwagger, {
  openapi: {
    info: { title: "JWT Tutorial API", version: "1.0.0" },
    tags: [{ name: "Auth", description: "Authentication routes" }],
  },
});
await fastify.register(scalarApiReference, { routePrefix: "/docs" });

几个注意点:

CORS 的 credentials: true:浏览器默认不允许跨域请求携带 Cookie。必须同时在后端设置 credentials: true,在前端 fetch 设置 credentials: "include",Cookie 才能正常传递。两边缺一不可。

Swagger + Scalar 的关系@fastify/swagger 负责解析路由上的 schema 定义,生成标准的 OpenAPI JSON 描述文件(/docs/json)。Scalar 是一个现代风格的 API 文档 UI,它读取这份 JSON 渲染出好看的交互界面,挂载在 /docs。两者分工明确:一个生成描述,一个渲染界面。

日志配置:Fastify 内置 pino 日志。pino-pretty 让日志输出更易读——带颜色、时间戳、去掉不必要的 pidhostname


6. 后端:四个认证接口

所有路由定义在 src/routes/auth.ts,由两个辅助函数支撑。

issueTokens:统一签发 token 对

// src/routes/auth.ts
const ACCESS_SECRET = "access-secret-change-in-prod";
const REFRESH_SECRET = "refresh-secret-change-in-prod";

function issueTokens(reply: FastifyReply, userId: number) {
  const accessToken = jwt.sign({ userId }, ACCESS_SECRET, { expiresIn: "30s" });
  const refreshToken = jwt.sign({ userId }, REFRESH_SECRET, { expiresIn: "180s" });

  reply.log.debug({ userId, access_token: accessToken, refresh_token: refreshToken }, "[cookie] setting auth cookies");

  reply
    .setCookie("access_token", accessToken, { httpOnly: true, sameSite: "lax", maxAge: 30 })
    .setCookie("refresh_token", refreshToken, { httpOnly: true, sameSite: "lax", maxAge: 180 });
}

issueTokensloginrefresh 两个接口里都会调用,两者都需要签发新的 token 对。提取成函数避免重复,也确保两处的配置完全一致。

jwt.sign 的第一个参数是 Payload,这里只放了 userId,用于在后续请求里查出用户信息,不要放密码或其他敏感字段(Payload 是明文 Base64)。

有效期故意设得很短(30 秒 / 180 秒)是为了演示:在本章里,登录后等 30 秒再访问 dashboard,就能亲眼看到 access token 过期、自动刷新、重试的完整链路。生产环境通常是 15 分钟 / 7 天。

POST /auth/login

fastify.post("/auth/login", {
  schema: {
    tags: ["Auth"],
    summary: "Login with username and password",
    body: LoginBody, // { username: string; password: string }
    response: {
      200: MessageResponse,
      401: ErrorResponse,
    },
  },
  handler: async (request, reply) => {
    const { username, password } = request.body as { username: string; password: string };
    request.log.debug({ username }, "[login] attempt");

    const user = USERS.find((u) => u.username === username && u.password === password);
    if (!user) {
      request.log.debug({ username }, "[login] failed — invalid credentials");
      return reply.status(401).send({ error: "Invalid credentials" });
    }

    issueTokens(reply, user.id);
    request.log.debug({ userId: user.id, username }, "[login] success");
    return { message: "Login successful" };
  },
});

路由定义里的 schema 字段有双重作用:提供给 Swagger 生成 API 文档,同时被 Fastify 用于自动校验请求体和序列化响应。如果前端发来的 body 缺少 usernamepassword,Fastify 会直接返回 400,不会进入 handler。

POST /auth/refresh

handler: async (request, reply) => {
  logRequestCookies(request); // 打印当前请求携带的所有 Cookie(调试用)

  const token = request.cookies.refresh_token;
  if (!token) {
    return reply.status(401).send({ error: "No refresh token" });
  }

  try {
    const { userId } = jwt.verify(token, REFRESH_SECRET) as { userId: number };
    request.log.debug({ userId }, "[refresh] token valid, issuing new pair");
    issueTokens(reply, userId);
    return { message: "Tokens refreshed" };
  } catch {
    return reply.status(401).send({ error: "Invalid refresh token" });
  }
},

jwt.verify 验签,同时检查有效期。token 过期或签名不对都会抛异常,统一在 catch 里返回 401。注意这里用的是 REFRESH_SECRET,和 access token 用的 ACCESS_SECRET 不同,生产环境中,两个 token 应使用不同的密钥签发,其中一个泄露不会影响另一个。

POST /auth/logout

handler: async (request, reply) => {
  logRequestCookies(request);
  reply.clearCookie("access_token").clearCookie("refresh_token");
  request.log.debug("[logout] auth cookies cleared");
  return { message: "Logged out" };
},

退出登录就是清除两个 Cookie。clearCookie 实际上是向浏览器发送一个 Set-Cookie 头,把对应 Cookie 的 maxAge 设为 0,浏览器收到后立即删除。

GET /auth/me(受保护接口)

handler: async (request, reply) => {
  logRequestCookies(request);

  const token = request.cookies.access_token;
  if (!token) {
    return reply.status(401).send({ error: "Unauthorized" });
  }

  try {
    const result = jwt.verify(token, ACCESS_SECRET) as { userId: number; iat: number; exp: number };
    request.log.debug(
      { userId: result.userId, exp: new Date(result.exp * 1000).toISOString() },
      "[me] token valid"
    );
    const user = USERS.find((u) => u.id === result.userId);
    return { id: user!.id, username: user!.username };
  } catch {
    return reply.status(401).send({ error: "Access token expired or invalid" });
  }
},

这是本章唯一的受保护接口,实现了 JWT 鉴权的核心逻辑:

  1. 从 Cookie 取 access token
  2. jwt.verify 验签并校验有效期,任何篡改或过期都会抛异常
  3. 从 Payload 里取出 userId,查用户信息返回
  4. 出错统一返回 401

调试日志里专门打印了 exp(过期时间),可以在终端里看到 token 的剩余有效期。


7. 后端:Scalar API 文档

启动后端后,访问 http://localhost:4000/docs,可以看到 Scalar 渲染的交互式文档。每个接口都有:

  • 请求体结构(自动从 schema.body 生成)
  • 响应结构(自动从 schema.response 生成)
  • "Try it out" 功能,可以直接在浏览器里发请求

用 Scalar 测试 Cookie 认证的步骤

  1. 打开 POST /auth/login,填入 {"username": "admin", "password": "password"},点击发送
  2. 后端返回 {"message": "Login successful"},同时 Cookie 已写入浏览器
  3. 打开 GET /auth/me,直接点发送(Cookie 自动附带)——可以看到返回的用户信息
  4. 等待 30 秒后再请求 /auth/me,会得到 401(access token 过期)
  5. 调用 POST /auth/refresh,然后再试 /auth/me——会再次成功

8. 前端:next.config.ts 的 rewrite 代理

// codes/frontend/next.config.ts
const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: "/auth/:path*",
        destination: "http://localhost:4000/auth/:path*",
      },
    ];
  },
};

source 是匹配规则,:path* 是通配符,匹配 /auth/ 后面的任意路径。destination 是转发目标。

rewrite 发生在 Next.js 服务端,不是浏览器。 请求链路是这样的:

浏览器 fetch("/auth/login")
  → 到达 Next.js 服务(localhost:3000)
  → Next.js 匹配 rewrites 规则
  → Next.js 服务端转发到 http://localhost:4000/auth/login
  → Fastify 处理,返回响应(含 Set-Cookie)
  → Next.js 把响应原样传回浏览器

浏览器全程只看到 localhost:3000,不知道 localhost:4000 的存在。转发发生在 Node.js 进程里,不是浏览器行为。

这样做有两个好处:

解决跨域:浏览器的跨域限制只针对浏览器发出的请求。服务端之间互相调用没有跨域问题。通过 Next.js 代理,浏览器发的是同源请求(localhost:3000 → localhost:3000),完全不触发 CORS 机制——Fastify 那边的 CORS 配置实际上用不上,保留它只是为了防止有人绕过 Next.js 直接从浏览器访问 localhost:4000

统一地址:前端代码里所有 fetch 都写 /auth/login/auth/me 这样的相对路径,不需要硬编码后端地址。部署时只改 rewrite 的 destination,前端代码不用动。


9. 前端:auth-fetch.ts 的核心逻辑

这是本章前端最重要的文件,封装了所有与认证相关的 fetch 逻辑。

// codes/frontend/lib/auth-fetch.ts

// 防止并发刷新:全局单例 Promise
let refreshPromise: Promise<Response> | null = null;

async function refreshToken(): Promise<Response> {
  if (refreshPromise) {
    console.log("[auth] refresh already in flight, reusing existing promise");
    return refreshPromise;
  }

  console.log("[auth] calling POST /auth/refresh to get new token pair");
  refreshPromise = fetch("/auth/refresh", {
    method: "POST",
    credentials: "include",
  }).finally(() => {
    refreshPromise = null;
  });

  return refreshPromise;
}

refreshPromise 是模块级别的单例变量。考虑这个场景:页面同时发出三个并发请求,全部遇到 401,如果各自独立去刷 token,就会有三个并发的 /auth/refresh 请求——这是纯粹的浪费,一次刷新就够了。

单例 Promise 解决这个问题:第一个到达的请求创建 refresh Promise;后续请求发现 refreshPromise 不为 null,直接复用同一个 Promise。三个请求等待同一次刷新完成,刷新成功后各自重试,全部成功。

在生产环境中,这个单例还有更关键的作用。许多后端会实现 refresh token rotation:refresh token 用一次就作废,同时签发新的 refresh token。在这种机制下,三个并发 refresh 请求会互相干扰——第一个请求消费了 refresh token,第二、三个请求拿着同一个已作废的 token 去刷,后端会返回 401,导致用户被强制退出。单例 Promise 确保无论多少并发请求,refresh 操作永远只发出一次。

// 简单封装:带 credentials 的 fetch,用于 login / logout / refresh
export async function fetchWithCookies(url: string, options: RequestInit = {}): Promise<Response> {
  console.log(`[auth] fetchWithCookies ${options.method ?? "GET"} ${url}`);
  const res = await fetch(url, { ...options, credentials: "include" });
  console.log(`[auth] fetchWithCookies response: ${res.status}`);
  return res;
}

// 带自动刷新的 fetch,用于需要认证的业务请求
export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
  const doFetch = () => fetch(url, { ...options, credentials: "include" });

  console.log(`[auth] fetchWithAuth ${options.method ?? "GET"} ${url}`);
  let res = await doFetch();
  console.log(`[auth] first attempt: ${res.status}`);

  if (res.status === 401) {
    console.log("[auth] got 401 — access token likely expired, attempting refresh");
    const refreshRes = await refreshToken();

    if (refreshRes.ok) {
      console.log("[auth] refresh succeeded, retrying original request");
      res = await doFetch();
      console.log(`[auth] retry result: ${res.status}`);
    } else {
      console.log(`[auth] refresh failed (${refreshRes.status}), user needs to login again`);
    }
  }

  return res;
}

fetchWithAuth 实现了"先试一次,失败了刷 token,然后重试"的模式:

  1. credentials: "include" 发请求(携带 Cookie)
  2. 收到 401:access token 可能过期,调用 refreshToken()
  3. 刷新成功:新 Cookie 已写入浏览器,用 doFetch() 重试原始请求
  4. 刷新失败:refresh token 也过期了,返回最终的 401 给调用方处理

doFetch 被定义为闭包而不是直接内联,是为了在步骤 3 里复用完全相同的 fetch 调用,避免重复书写 urloptions

两个导出函数用途不同:

函数 用途 自动刷新
fetchWithCookies login、logout、refresh 本身
fetchWithAuth 受保护的业务接口(如 /auth/me

login 和 logout 不需要自动刷新,用 fetchWithCookies 即可。


10. 前端:登录页面

// codes/frontend/app/page.tsx
"use client";

export default function LoginPage() {
  const router = useRouter();
  const [form, setForm] = useState({ username: "admin", password: "password" });
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setLoading(true);

    try {
      const res = await fetchWithCookies("/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(form),
      });

      if (res.ok) {
        router.replace("/dashboard");
      } else {
        const data = await res.json();
        setError(data.error ?? "Login failed");
      }
    } catch {
      setError("Network error");
    } finally {
      setLoading(false);
    }
  }

  return <main className="flex min-h-screen items-center justify-center">{/* 表单... */}</main>;
}

几个设计决策:

为什么用 router.replace 而不是 router.pushreplace 不会在历史记录里留下当前页面,登录后按浏览器后退键不会回到登录页(而是更早的页面或空白)。push 会留下历史,用户可以后退回登录页,这在已登录状态下没有意义。

表单默认值username: "admin", password: "password" 是预填的测试账号,方便演示时不用每次手动输入。

使用 fetchWithCookies 而非 fetchWithAuth:登录请求不需要自动刷新逻辑——如果登录本身失败了,刷新也无从谈起。


11. 前端:受保护页面与自动刷新

// codes/frontend/app/dashboard/page.tsx
"use client";

export default function DashboardPage() {
  const router = useRouter();
  const [user, setUser] = useState<User | null>(null);
  const [status, setStatus] = useState<"loading" | "ok" | "unauth">("loading");

  useEffect(() => {
    fetchMe();
  }, []);

  async function fetchMe() {
    try {
      const res = await fetchWithAuth("/auth/me"); // 自动刷新
      if (res.ok) {
        setUser(await res.json());
        setStatus("ok");
      } else {
        setStatus("unauth");
        router.replace("/"); // 无法认证,回到登录页
      }
    } catch {
      setStatus("unauth");
      router.replace("/");
    }
  }

  async function handleLogout() {
    await fetchWithCookies("/auth/logout", { method: "POST" });
    router.replace("/");
  }

  // ...渲染逻辑
}

页面加载时立即鉴权useEffect 在组件挂载后调用 fetchMe,用 fetchWithAuth 请求 /auth/me。如果 access token 过期,fetchWithAuth 内部会自动刷新并重试,整个过程对 DashboardPage 完全透明——它只看到最终的成功或失败。

三种状态"loading" 是初始状态,此时显示加载提示,防止页面先闪烁出空内容再跳转。"ok" 表示鉴权成功,显示用户信息。"unauth" 表示鉴权失败(refresh 也失败了),重定向到登录页。

观察自动刷新:打开浏览器开发者工具的 Console 面板,登录后等 30 秒再访问 dashboard 页面(或刷新)。可以看到 [auth] 开头的日志按顺序打印:

[auth] fetchWithAuth GET /auth/me
[auth] first attempt: 401
[auth] got 401 — access token likely expired, attempting refresh
[auth] calling POST /auth/refresh to get new token pair
[auth] refresh succeeded, retrying original request
[auth] retry result: 200

整个刷新和重试过程在几百毫秒内完成,用户看到的只是短暂的"Loading…",然后正常显示 dashboard。

同时观察后端终端的 pino 日志,能看到服务端的对应视角:两次 /auth/me 请求(第一次 401,第二次 200)和一次 /auth/refresh 请求,以及每次请求时 Cookie 的内容。