认证与登录门控
这一课你将能做到
- 画出完整的登录链路:提交凭证 → 后端校验签发 token → Cookie 存储 → 请求携带 → 服务端校验 → 门控 UI
- 用 JWT + HttpOnly Cookie + middleware 实现一个可运行的登录门控(含登出与未登录跳转)
- 说出 session 管理两种模式(JWT / 服务端 Session)的取舍,答好「token 放哪、middleware 做什么」两道高频面试题
为什么要学
真实产品一定有「未登录不能看」的内容——本站平台就是这样:免费课时随便看,后面课时锁着,练习记录、个人中心必须登录。这套「登录门控」是前后端协作的综合题,最能暴露一个人的全栈功底,所以也是面试常客:「token 存 localStorage 还是 Cookie?为什么?」「middleware 层做什么鉴权?」「JWT 和 Session 怎么选?」
本课的链路设计遵循本站真实架构:凭证校验由 Spring Boot 后端负责(数据库、密码哈希都在 Java 侧),Next.js 负责「持有凭证、控制访问、渲染门控 UI」。这个分工在企业里最常见——前端框架不做用户体系,只做鉴权协议的客户端。
先立全链路总图,本课所有代码都在填这张图的格子:
① 登录页提交账号密码
↓
② Next.js BFF 转发 → Spring Boot 校验 → 签发 JWT
↓
③ Set-Cookie: token=xxx (HttpOnly) 存入浏览器
↓
④ 之后每个请求自动携带 Cookie
├─ middleware.ts:路由级门控(未登录 → 跳 /login)
├─ Server 组件:读 Cookie 调后端取个人数据
└─ Client 组件:只管展示,不碰 token
↓
⑤ 登出:清除 Cookie + 后端吊销(可选)
先建立图像
JWT 的结构——三段式,用点分隔:
eyJhbGciOiJIUzI1NiJ9 . eyJ1c2VySWQiOjQyfQ . SflKxwRJ...
头部(header) 载荷(payload) 签名(signature)
算法:HS256 {userId: 42, exp:...} HMAC(header+payload, secret)
三个关键认知:
- payload 只是 Base64 编码不是加密,任何人都能解开看——所以里面只放 userId、过期时间这类非敏感信息,绝不能放密码、手机号
- 签名保证「没被篡改」:改一个字,服务端验签就失败
- 服务端无状态:不用存 token,验签即信任——这是它和 Session 的本质区别
两种 session 管理模式对照:
| JWT(无状态) | 服务端 Session(有状态) |
|---|
| 凭证存哪 | 客户端自持 | 服务端存(Redis/内存),客户端只拿 sessionId |
| 校验方式 | 验签(不算库) | 查库/Redis |
| 吊销 | 难(等过期,或配黑名单) | 删记录即生效 |
| 典型选型 | 前后端分离、微服务 | 传统单体、强安全要求 |
跟着做
步骤 1:登录页(Client 组件收凭证)
// src/app/login/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
setLoading(false);
if (res.ok) {
router.push("/me"); // 登录成功进个人页
router.refresh(); // 刷新服务端渲染数据
} else {
setError("用户名或密码错误");
}
}
return (
<form onSubmit={handleSubmit} className="mx-auto max-w-sm space-y-3 p-6">
<h1 className="text-2xl font-bold">登录</h1>
<input
className="w-full rounded border p-2"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className="w-full rounded border p-2"
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
className="w-full rounded bg-blue-600 py-2 text-white disabled:opacity-50"
disabled={loading}
>
{loading ? "登录中…" : "登录"}
</button>
</form>
);
}
步骤 2:BFF 登录接口(转发 + 签发 Cookie)
jose 是标准 JWT 库(Serverless 环境可用)。接口代码:
// src/app/api/login/route.ts
import { NextRequest, NextResponse } from "next/server";
const BACKEND = process.env.API_INTERNAL_URL ?? "http://localhost:8080";
export async function POST(request: NextRequest) {
const { username, password } = await request.json();
// 1. 凭证校验交给 Spring Boot(真实架构)
const backendRes = await fetch(`${BACKEND}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!backendRes.ok) {
return NextResponse.json({ message: "用户名或密码错误" }, { status: 401 });
}
const { token } = await backendRes.json(); // 后端签发的 JWT
// 2. 写入 HttpOnly Cookie
const res = NextResponse.json({ ok: true, username });
res.cookies.set("token", token, {
httpOnly: true, // JS 不可读,防 XSS
sameSite: "lax", // 基础 CSRF 防护
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 7, // 7 天
path: "/",
});
return res;
}
强调这个设计:token 永远不出现在前端 JS 的手里。登录响应体里不返回 token 给页面,直接进 HttpOnly Cookie——XSS 偷不走。
步骤 3:middleware 路由级门控
// src/middleware.ts(或 src/proxy.ts,视版本约定)
import { NextRequest, NextResponse } from "next/server";
const PROTECTED = ["/me", "/practice", "/progress"]; // 需登录的前缀
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtected = PROTECTED.some((p) => pathname.startsWith(p));
const token = request.cookies.get("token")?.value;
if (isProtected && !token) {
// 未登录访问受保护页:跳登录页并记住来源
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", pathname);
return NextResponse.redirect(loginUrl);
}
// 已登录访问 /login:送回个人页,避免重复登录
if (pathname === "/login" && token) {
return NextResponse.redirect(new URL("/me", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/me/:path*", "/practice/:path*", "/progress/:path*", "/login"],
};
middleware 跑在边缘层、每次请求最先执行,用户根本看不到受保护页的闪烁——这是体验最好的门控位置。注意 middleware 里只判断「token 存不存在」这种轻量逻辑,验签和用户信息查询仍在后端/Server 组件里(middleware 运行环境受限,也不该每次请求都做重活)。
const from = new URLSearchParams(window.location.search).get("from") ?? "/me";
// router.push(from) 替代固定跳 /me
步骤 4:Server 组件读登录态渲染个人数据
// src/lib/serverAuth.ts —— 本站同款封装
import { cookies } from "next/headers";
export async function getServerToken(): Promise<string | undefined> {
const store = await cookies();
return store.get("token")?.value;
}
// src/app/me/page.tsx
import { getServerToken } from "@/lib/serverAuth";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic"; // 个人页必须动态渲染
export default async function MePage() {
const token = await getServerToken();
if (!token) redirect("/login?from=/me"); // 组件层二道门(middleware 之外兜底)
const res = await fetch(`${process.env.API_INTERNAL_URL}/api/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (res.status === 401) redirect("/login"); // token 过期/无效
const user = await res.json();
return (
<main className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-bold">你好,{user.username}</h1>
<p className="mt-2 text-gray-600">已学 {user.lessonCount} 课时</p>
</main>
);
}
注意 dynamic = "force-dynamic"——上一课的知识点在这里落地:读 Cookie 的页面天生动态,显式声明以防万一。
步骤 5:登出
// src/app/api/logout/route.ts
import { NextResponse } from "next/server";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set("token", "", { maxAge: 0, path: "/" }); // 置空 + 立即过期
return res;
}
// 放在个人页里的登出按钮(Client 组件)
"use client";
import { useRouter } from "next/navigation";
export default function LogoutButton() {
const router = useRouter();
return (
<button
className="rounded border px-3 py-1 text-sm"
onClick={async () => {
await fetch("/api/logout", { method: "POST" });
router.push("/login");
router.refresh();
}}
>
退出登录
</button>
);
}
讲清楚要点
- 三层门控纵深防御:middleware(路由级,最早拦截)→ Server 组件(数据级,兜底验签)→ 后端 API(最终裁决,所有写操作必须鉴权)。跳过前两层或许能用,但后端校验永远不能省——直接 curl 你的 API 可绕过一切前端门控。
- token 存储的定论:HttpOnly Cookie 优先。localStorage 能被 XSS 一锅端;Cookie 的
httpOnly 让脚本读不到,sameSite 挡基础 CSRF,secure 保证只走 HTTPS。面试答这句就是满分线。
- Next.js 侧的职责边界:Next.js 持有与转发凭证、做 UI 门控与重定向,不做密码校验、不存用户表——那是 Spring Boot 的事。前端框架里的认证代码只应该有「搬运」和「判断」,不该有「验证」。
- router.refresh() 是被低估的一步:登录后若不刷新,Server 组件缓存的服务端渲染结果还是「未登录态」。
router.refresh() 让当前路由的服务端重新渲染,导航栏上的头像才会出现。
- 登录态过期要有优雅路径:后端 401 → Server 组件
redirect("/login");Client 请求 401 → 统一在 fetch 封装里处理跳转。用户感知应该是「请你重新登录」而不是白屏或报错堆栈。
你可能会懵的地方
-
middleware 里为什么不验 JWT 签名?
三个原因:middleware 运行在边缘环境,功能受限且应极快;验签需要密钥,把 secret 暴露到更广的执行面增加风险面;门控的目的是「体验层拦截」,真正的安全裁决在 API 层。middleware 判有无 token、Server 组件验证有效性,分工明确。
-
JWT 无法主动失效,用户改密码后旧 token 还能用怎么办?
纯 JWT 确实如此。工程解法:① 短有效期(如 2h)+ Refresh Token 轮换;② 关键操作(改密码)后维护一个 token 黑名单或把用户版本号编进 payload 验签时比对;③ 对安全要求高的系统改用服务端 Session(Redis 存储,删了即失效)。这是 JWT vs Session 面试题的进阶答案。
-
CSRF 和 XSS,Cookie 方案防了哪个?
sameSite 防 CSRF(跨站请求不自动带 Cookie),httpOnly 防 XSS 窃取。但 XSS 能偷不到却「能用」——脚本可以在受害浏览器里直接发请求。所以 Cookie 方案也要配合输出转义、CSP 等防 XSS 手段,不是一劳永逸。
-
NextAuth(Auth.js)什么时候用?
需要「第三方登录」(GitHub/Google/微信)或多租户用户体系时,它的 OAuth 流程、账号关联、session 适配是现成的。本场景(自研 Spring Boot 用户体系 + JWT)用 NextAuth 反而要写自定义 provider 绕远路——自研后端 + BFF 转发是更短路径。面试说清这个选型逻辑比报菜名重要。
-
为什么登录成功要 router.refresh(),而不只是 push?
push 只做客户端导航,服务端组件的渲染数据(如导航栏的用户信息)可能来自旧缓存或根本没取过;refresh 触发当前路由服务端重渲染,整个页面状态与登录态对齐。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 登录后把 token 存 localStorage | XSS 可窃取 | HttpOnly Cookie |
middleware 里 jwt.verify 全量验签 | 边缘层过重、密钥扩散 | middleware 只判存在性,验签留后端 |
| 只做 middleware 门控,后端接口裸奔 | curl 直连绕过一切门控 | 后端 API 独立鉴权(Security 注解/过滤器) |
| 受保护页面没配动态渲染 | 读 Cookie 的页面可能被静态缓存串号 | export const dynamic = "force-dynamic" |
| 登出只删前端状态不清 Cookie | 下次请求仍带旧 token | 服务端 Set-Cookie maxAge=0 清除 |
卡点急救
- 登录成功但刷新后又是未登录态:Cookie 没写上。检查接口响应头有没有
Set-Cookie;dev 环境 secure: true 会导致 HTTP 下 Cookie 被丢弃(本课代码已按 NODE_ENV 区分)。
- middleware 报错或完全不执行:确认文件位置(src/middleware.ts 与 src/app 同级)与导出名(middleware + config);matcher 路径写错会导致永不过滤;Next 16 起文件名约定改为 proxy.ts 的版本注意对照官方文档。
- Server 组件拿到的 token 是 undefined:
cookies() 在 Next 15 是异步的,必须 await cookies();另外确认请求确实带了 Cookie(同域、未开隐身、Cookie 未过期)。
面试常问
-
JWT 和 Session 的区别与选型?
JWT 无状态:凭证自含签名,服务端验签不查库,适合分布式与前后端分离,但吊销困难、payload 可解码不可放敏感数据;Session 有状态:服务端存储会话(Redis),客户端只持 sessionId,吊销即时、控制力强,代价是存储与查询开销。选型:自研平台级产品倾向 Session 或「短 JWT + Refresh」,微服务与跨域场景倾向 JWT。
-
token 为什么放 HttpOnly Cookie 而不是 localStorage?
localStorage 可被同源 JS 读取,一旦存在 XSS 漏洞(UGC 内容、三方脚本)token 即泄露;HttpOnly Cookie 对 JS 不可见,切断了窃取路径,配合 sameSite 缓解 CSRF、secure 限定 HTTPS。代价是需要 BFF 层处理 Cookie 读写与 CSRF 防护——本站登录接口就是 Next.js BFF 负责把后端签发的 token 写入 Cookie。
-
描述你们的登录门控链路。
参考回答:登录页(Client 组件)提交凭证 → Next.js BFF 转发 Spring Boot 校验 → 后端签发 JWT → BFF 写入 HttpOnly Cookie;之后每个请求自动携带,middleware 对 /me /practice 等前缀做路由级门控(未登录重定向并记录来源),Server 组件读 Cookie 后携带 Authorization 调后端取个人数据(no-store),后端对每个 API 独立鉴权;登出在服务端清除 Cookie。三层门控 + 凭证不过前端 JS 是安全设计核心。
自测三问
- 从点「登录」到看到个人页,完整请求链路经过哪几层?每层各做什么?
- middleware、Server 组件、后端 API 三层门控,哪层绝对不能省?为什么?
- JWT 的 payload 能放手机号吗?为什么?
小结一句话
认证的本质是「凭证的签发、携带、校验、吊销」四件事:后端签发 JWT、HttpOnly Cookie 持有、middleware 做路由门控、Server 组件带凭证取数,后端永远保留最终裁决权。
延伸