前几章的页面没有任何身份控制——任何人都能访问所有数据。这一章引入真实的登录认证,使用 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 / password 或 jack / cisco
- JWT 认证的原理
- 双 Token 机制:为什么需要两个 Token?
- 信息放在哪里:Cookie vs localStorage
- 后端:Fastify 项目结构
- 后端:app.ts 插件注册
- 后端:四个认证接口
- 后端:Scalar API 文档
- 前端:next.config.ts 的 rewrite 代理
- 前端:auth-fetch.ts 的核心逻辑
- 前端:登录页面
- 前端:受保护页面与自动刷新
传统的 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 |
只用一个 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 也过期时,才需要用户重新登录。
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来获取。
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 |
格式化日志输出 |
// 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 让日志输出更易读——带颜色、时间戳、去掉不必要的 pid 和 hostname。
所有路由定义在 src/routes/auth.ts,由两个辅助函数支撑。
// 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 });
}issueTokens 在 login 和 refresh 两个接口里都会调用,两者都需要签发新的 token 对。提取成函数避免重复,也确保两处的配置完全一致。
jwt.sign 的第一个参数是 Payload,这里只放了 userId,用于在后续请求里查出用户信息,不要放密码或其他敏感字段(Payload 是明文 Base64)。
有效期故意设得很短(30 秒 / 180 秒)是为了演示:在本章里,登录后等 30 秒再访问 dashboard,就能亲眼看到 access token 过期、自动刷新、重试的完整链路。生产环境通常是 15 分钟 / 7 天。
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 缺少 username 或 password,Fastify 会直接返回 400,不会进入 handler。
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 应使用不同的密钥签发,其中一个泄露不会影响另一个。
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,浏览器收到后立即删除。
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 鉴权的核心逻辑:
- 从 Cookie 取 access token
- 用
jwt.verify验签并校验有效期,任何篡改或过期都会抛异常 - 从 Payload 里取出
userId,查用户信息返回 - 出错统一返回 401
调试日志里专门打印了 exp(过期时间),可以在终端里看到 token 的剩余有效期。
启动后端后,访问 http://localhost:4000/docs,可以看到 Scalar 渲染的交互式文档。每个接口都有:
- 请求体结构(自动从
schema.body生成) - 响应结构(自动从
schema.response生成) - "Try it out" 功能,可以直接在浏览器里发请求
用 Scalar 测试 Cookie 认证的步骤:
- 打开
POST /auth/login,填入{"username": "admin", "password": "password"},点击发送 - 后端返回
{"message": "Login successful"},同时 Cookie 已写入浏览器 - 打开
GET /auth/me,直接点发送(Cookie 自动附带)——可以看到返回的用户信息 - 等待 30 秒后再请求
/auth/me,会得到 401(access token 过期) - 调用
POST /auth/refresh,然后再试/auth/me——会再次成功
// 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,前端代码不用动。
这是本章前端最重要的文件,封装了所有与认证相关的 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,然后重试"的模式:
- 用
credentials: "include"发请求(携带 Cookie) - 收到 401:access token 可能过期,调用
refreshToken() - 刷新成功:新 Cookie 已写入浏览器,用
doFetch()重试原始请求 - 刷新失败:refresh token 也过期了,返回最终的 401 给调用方处理
doFetch 被定义为闭包而不是直接内联,是为了在步骤 3 里复用完全相同的 fetch 调用,避免重复书写 url 和 options。
两个导出函数用途不同:
| 函数 | 用途 | 自动刷新 |
|---|---|---|
fetchWithCookies |
login、logout、refresh 本身 | 无 |
fetchWithAuth |
受保护的业务接口(如 /auth/me) |
有 |
login 和 logout 不需要自动刷新,用 fetchWithCookies 即可。
// 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.push:replace 不会在历史记录里留下当前页面,登录后按浏览器后退键不会回到登录页(而是更早的页面或空白)。push 会留下历史,用户可以后退回登录页,这在已登录状态下没有意义。
表单默认值:username: "admin", password: "password" 是预填的测试账号,方便演示时不用每次手动输入。
使用 fetchWithCookies 而非 fetchWithAuth:登录请求不需要自动刷新逻辑——如果登录本身失败了,刷新也无从谈起。
// 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 的内容。