第 2.5 章 2.5.6:前端登录、Token 无感续签与路由守卫
课程专题 :《AI Agents 开发实践》
章节 :第 2.5 章 RBAC 用户权限系统
小节 :2.5.6 前端登录与请求层
作者 :一个从 Java 转型 AI 全栈的开发者
适合人群:Java 开发者,零基础 React/Next.js 请求层
1. 前端要解决的三件事
后端接口已经能拦人了(401 / 403)。前端要配合做三件事:
- 登录:拿账密换 Token,再把用户信息和权限"存起来"
- 带上 Token :之后每个请求都自动带上
Bearer accessToken - 无感续签:accessToken 过期时,悄悄用 refreshToken 换新的,用户毫无察觉
第 1、2 件很直觉,第 3 件是本章前端最精彩的部分。
2. 技术栈与项目骨架
clients/admin-web 是 Next.js 16 管理后台,跑在 3001 端口:
json
// clients/admin-web/package.json
{
"name": "@autix/admin-web",
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@autix/contracts": "workspace:*",
"@heroui/react": "^2.8.0",
"@heroui/theme": "2.4.26",
"axios": "^1.7.9",
"framer-motion": "^12.0.0",
"next": "16.2.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"zustand": "^5.0.3"
}
}
三件新东西,先建立直觉:
| 库 | 作用 | Java 对照 |
|---|---|---|
| HeroUI | 组件库(按钮、表格、弹窗) | 一个更现代的 Bootstrap / Ant Design |
| zustand | 全局状态管理 | 浏览器里的 SecurityContext |
| axios | HTTP 客户端 + 拦截器 | Feign / RestTemplate |
3. 登录链路:两次请求
登录页 src/app/login/page.tsx 的核心逻辑:
typescript
// 流程:账密 → POST /auth/login 存双 Token → GET /auth/profile 存用户/菜单/权限 → 进后台
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError('');
setLoading(true);
try {
// 1. 登录拿双 Token(401 会被 apiErrorMessage 转成后端的原话)
const { data } = await api.post('/auth/login', { username, password });
useAuthStore.getState().setTokens(data);
// 2. 拿 profile(用户 + 菜单 + 权限编码)
const { data: profile } = await api.get('/auth/profile');
useAuthStore.getState().setProfile(profile);
// 3. 进后台
router.replace('/');
} catch (err) {
setError(apiErrorMessage(err, '登录失败,请检查用户名或密码'));
} finally {
setLoading(false);
}
}
为什么登录成功后还要马上再调一次 profile?
因为 login 只返回 Token,不返回"你有什么权限"。而渲染后台需要的全部原料
(用户名、昵称、是不是超管、有哪些菜单、有哪些权限码)都在 profile 里。
一次拿齐、存进 store,后续所有页面直接读 store,不用各自再查一遍。
这就是"登录 = 换两张入场券":一张证明身份(Token),一张写清权限(profile)。
表单部分用 HeroUI 组件拼,顺带做了个可访问性细节:
typescript
{/* role=alert + aria-live=assertive:登录失败时让屏幕阅读器主动播报 */}
{error && (
<p className="text-small text-danger" role="alert" aria-live="assertive">
{error}
</p>
)}
role="alert" 是给屏幕阅读器的信号:这段文字出现时请主动朗读 。
这是第 06 篇 UI 审查里补上的一批可访问性改进之一。
4. 登录态存储:zustand + persist
src/stores/auth.ts 是整个前端权限体系的"大脑":
typescript
// 登录态全局存储:zustand + persist(自动同步 localStorage)
// Java 对照:相当于浏览器版的 SecurityContext------但数据存在客户端,
// 所以后端每个接口仍然要做真实的权限校验(前端显隐只是体验优化,不是安全边界)
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
user: null,
menus: [],
permissions: [],
setTokens: (tokens) =>
set({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }),
setProfile: (data) =>
set({ user: data.user, menus: data.menus, permissions: data.permissions }),
clear: () =>
set({ accessToken: null, refreshToken: null, user: null, menus: [], permissions: [] }),
}),
{ name: 'autix-admin-auth' }, // localStorage 的 key
),
);
persist 中间件做的事情:每次 store 变化,自动同步到 localStorage 。
刷新页面时,store 会从 localStorage 恢复------不用重新登录。
Java 对照 :这就像
SecurityContextHolder------只不过 Spring 的
SecurityContext存在服务端 session 里,而我们的存在客户端localStorage里。这个差别极其重要 :存客户端的登录态,用户随时能改。
所以后端每一个接口都仍然要做真实的权限校验 。前端的
hasPermission只是"体验优化"(不让正常用户困惑),绝不是安全边界。
store 里还定义了权限判断函数(第 05 篇会详细用):
typescript
/**
* 权限判断:super_admin 恒 true,否则查权限编码列表
* 菜单显隐和按钮显隐共用这一个函数------前后端同一套 code
*/
export function hasPermission(code: string): boolean {
const { user, permissions } = useAuthStore.getState();
if (user?.isSuperAdmin) return true;
return permissions.includes(code);
}
5. axios 实例:前端的"唯一出网关口"
src/lib/api.ts 是本章前端技术的核心。它做了三件事:
typescript
// axios 实例:RBAC 前端唯一的"出网关口"
// 1. 请求拦截器:自动注入 Bearer accessToken
// 2. 响应拦截器:401 时用 refreshToken 自动续签并重放原请求
// 3. 续签也失败 → 清空登录态,跳回登录页
export const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4002/api';
export const api = axios.create({
baseURL: API_BASE,
timeout: 10_000,
});
// ── 请求拦截器:每次请求带上 accessToken ──
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
请求拦截器 :每个请求出去前,自动把 Authorization: Bearer xxx 塞进请求头。
这样业务代码里调 api.get('/users') 就完事了,不用每次都手写 Token。
Java 对照 :这就是 Feign 的
RequestInterceptor------统一给所有出站请求加头(比如加认证 Token、加 traceId)。
5.1 响应拦截器:401 单飞续签 + 请求重放
真正精彩的部分:
typescript
// 进行中的续签 Promise(null = 当前没有续签在跑)
let refreshing: Promise<string | null> | null = null;
/** 用 refreshToken 换新双 Token;成功返回新 accessToken,失败返回 null(并清空登录态) */
async function refreshTokens(): Promise<string | null> {
const { refreshToken, setTokens, clear } = useAuthStore.getState();
if (!refreshToken) return null;
try {
// 注意:用原生 axios 而不是 api 实例------续签请求自己不该走 401 重试逻辑
const res = await axios.post(`${API_BASE}/auth/refresh`, { refreshToken });
const tokens = res.data as { accessToken: string; refreshToken: string };
setTokens(tokens);
return tokens.accessToken;
} catch {
// refreshToken 也失效(过期/被禁用):登录态清空,由调用方跳登录页
clear();
return null;
}
}
// 给请求配置打个"_已重试"标记,防止续签后重放仍 401 导致死循环
interface RetryableConfig extends InternalAxiosRequestConfig {
_retried?: boolean;
}
api.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const original = error.config as RetryableConfig | undefined;
// 只处理:401 + 有原始配置 + 没重试过
if (error.response?.status !== 401 || !original || original._retried) {
return Promise.reject(error);
}
original._retried = true;
// 单飞:多个 401 共享一次续签
refreshing ??= refreshTokens().finally(() => {
refreshing = null;
});
const newToken = await refreshing;
if (!newToken) {
// 续签失败:跳登录页(浏览器环境才跳,SSR 时不执行)
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return Promise.reject(error);
}
// 用新 Token 重放原请求
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
},
);
这段代码有三个精妙的设计,逐个说:
设计一:区分"续签用的 axios"和"业务用的 api"
注意 refreshTokens 里用的是原生的 axios.post ,不是 api 实例 。
为什么?因为如果续签请求自己走 api 实例,它一旦失败被拦下来又是 401,
就又会触发"续签"------无限递归。用原生 axios,续签请求不参与重试逻辑,安全。
设计二:单飞(single-flight)------避免"刷新风暴"
refreshing 这个变量是关键。看这行:
typescript
refreshing ??= refreshTokens().finally(() => { refreshing = null; });
??= 是"如果左边是 null,就赋右边的值"。所以:
- 第一个 401 到达 →
refreshing是 null → 发起续签,把 Promise 存起来 - 后续 401 到达 →
refreshing已经不是 null → 复用同一个 Promise,不再发新请求 - 续签结束 →
finally把refreshing重置为 null
为什么需要它? 设想一个仪表盘页面同时发了 5 个请求 ,
而此刻 accessToken 刚好过期------5 个请求会同时收到 401。
如果没有单飞:5 个 401 各发一次续签 ,5 个续签请求抢着用同一个 refreshToken 。
如果后端有 refreshToken 轮换机制(用了就作废),这 5 个请求会互相把对方的 Token 作废,
用户直接被踢去重登录。这就是臭名昭著的"刷新风暴"。
单飞之后:5 个 401 只触发 1 次续签,5 个请求共享同一个新 Token。
设计三:请求重放 + _retried 防死循环
typescript
original._retried = true;
// ...
original.headers.Authorization = `Bearer ${newToken}`;
return api(original); // 用新 Token 重新发一遍刚才失败的请求
拿到新 Token 后,把刚才失败的请求原样重发一遍。用户完全不知道中间发生过什么。
而 _retried 标记是保险丝 :如果重放之后仍然 401(比如 Token 权限真的有问题),
_retried 已经为 true,拦截器不会再尝试续签------防止无限套娃。
附带工具:错误信息提取
typescript
/** 从 axios 错误中提取后端 message(NestJS 异常过滤器返回 { message, error, statusCode }) */
export function apiErrorMessage(err: unknown, fallback = '请求失败'): string {
if (axios.isAxiosError(err)) {
const data = err.response?.data as { message?: string | string[] } | undefined;
if (Array.isArray(data?.message)) return data.message.join(';');
if (data?.message) return data.message;
}
return fallback;
}
NestJS 的异常返回 { message, error, statusCode }。这个函数把它抽出来,
让前端能直接显示后端的原话(比如"用户名或密码错误"),而不是笼统的"请求失败"。
Array.isArray 分支很有用:class-validator 校验失败时,message 是个数组
(每个校验规则一条),这里用";"拼成一句话。
6. 路由组与登录守卫
6.1 路由组 (admin):括号目录不改 URL
src/app/
├── layout.tsx # 根布局(<html> / <body> / Providers)
├── providers.tsx # HeroUI 上下文
├── login/page.tsx # 登录页(公开,不套后台壳)
└── (admin)/ # ← 路由组,括号不参与 URL
├── layout.tsx # 后台壳:侧边栏 + 顶栏 + 守卫
├── dashboard/page.tsx # URL 是 /dashboard
├── users/page.tsx # URL 是 /users
└── roles/page.tsx # URL 是 /roles
(admin) 是"路由组" :括号目录不影响 URL 。
(admin)/users/page.tsx 的 URL 就是 /users,不是 /(admin)/users。
它的唯一作用是让组内所有页面共享同一个 layout.tsx ------
也就是共享后台的侧边栏和顶栏。登录页放到组外面,就不套后台壳。
Java 对照 :这有点像 Web MVC 的
HandlerInterceptor------后台路由(组内)进来前先过一遍登录检查,登录页(组外)不受影响。
6.2 后台布局:三层守卫
typescript
// clients/admin-web/src/app/(admin)/layout.tsx
// 守卫分层:
// 1. 未登录(无 accessToken)→ 踢回 /login
// 2. 已登录但权限不够 → 页面级 RequirePermission 渲染 403 提示
// 3. 接口越权 → 后端 403(真正的安全边界)
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const accessToken = useAuthStore((s) => s.accessToken);
const user = useAuthStore((s) => s.user);
const permissions = useAuthStore((s) => s.permissions);
// persist 从 localStorage 恢复发生在客户端,SSR 首帧 store 是空的------
// mounted 保证两端首帧一致,避免 hydration 警告
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// 路由守卫:未登录 → 登录页
useEffect(() => {
if (mounted && !accessToken) {
router.replace('/login');
}
}, [mounted, accessToken, router]);
// 每次进入后台/刷新页面都重新拉 profile
useEffect(() => {
if (!mounted || !accessToken) return;
api
.get('/auth/profile')
.then((res) => useAuthStore.getState().setProfile(res.data))
.catch(() => { /* 401 已由拦截器处理(跳登录);其余错误静默 */ });
}, [mounted, accessToken]);
// 动态菜单:按权限码过滤
const visibleMenus = useMemo(
() => MENUS.filter((m) => user?.isSuperAdmin || permissions.includes(m.code)),
[user, permissions],
);
function handleLogout() {
useAuthStore.getState().clear();
router.replace('/login');
}
// 未登录首帧渲染空,等守卫跳转
if (!mounted || !accessToken) return null;
// ...
}
三个要点:
① 守卫层:未登录踢回登录页
typescript
useEffect(() => {
if (mounted && !accessToken) {
router.replace('/login');
}
}, [mounted, accessToken, router]);
router.replace(不是 push)------不会在浏览器历史里留记录 ,
用户按"后退"不会又回到后台页面。
② mounted 标志:解决 hydration 警告
这是个 Next.js 的经典坑。persist 从 localStorage 恢复数据发生在客户端 ,
但 Next.js 首帧渲染还在服务端 ------服务端拿不到 localStorage,accessToken 是 null。
于是"服务端渲染的首帧"(未登录)和"客户端接管后的首帧"(已登录)不一致,
React 会报 hydration mismatch 警告。
解法:加一个 mounted 状态,只在客户端 useEffect 后才置为 true。
mounted 为 false 时渲染 null,让两端首帧一致,警告消失。
③ 每次进后台重拉 profile
typescript
useEffect(() => { /* api.get('/auth/profile').then(res => setProfile(res.data)) */ }, [...]);
为什么每次都要重拉?因为权限可能在别处被改了 ------
管理员在后台给你的角色改了权限,你重新进后台时,profile 会拿到最新权限,
菜单立刻更新。这是"权限变更即时生效"的保证。
而 profile 请求本身如果遇到 Token 过期会 401,被 axios 拦截器自动续签重放 ,
页面完全无感知------这正是第 5 节那套机制的价值所在。
7. 根布局与 HeroUI Provider
typescript
// src/app/layout.tsx
import './globals.css';
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
typescript
// src/app/providers.tsx
'use client';
// 全局 Provider:HeroUI 组件库的运行时上下文
// navigate 注入 Next.js 的路由跳转,让 HeroUI 的 Link 等组件走 App Router
import { HeroUIProvider } from '@heroui/react';
import { useRouter } from 'next/navigation';
export function Providers({ children }: { children: ReactNode }) {
const router = useRouter();
return <HeroUIProvider navigate={router.push}>{children}</HeroUIProvider>;
}
HeroUIProvider 是 HeroUI 组件库的运行时上下文。
把 Next.js 的 router.push 通过 navigate 注入进去,让 HeroUI 内部的链接组件
也能走 App Router 的客户端导航(而不是整页刷新)。
8. 验证:无感续签怎么测
怎么验证"无感续签"真的生效了? 有个很朴素的办法:
- 正常登录进后台
- 打开浏览器 DevTools → Application → Local Storage →
autix-admin-auth - 手动把
accessToken改成一串乱码(模拟过期) - 在后台点任意一个菜单(触发一次接口请求)
预期结果:请求先因 Token 无效被 401 → 拦截器自动用 refreshToken 续签 →
用新 Token 重放 → 页面正常显示数据,你完全感受不到中间发生了什么。
打开 Network 面板,能看到一次 401、一次 /auth/refresh、以及一次成功的重放请求。
这个手动改 Token 的方法,是验证续签链路最直接的方式。第 06 篇的验收清单里
第 6 条"accessToken 无感续签"就是这么验的。
9. 本篇小结
| 环节 | 关键设计 |
|---|---|
| 登录 | 两次请求:login 拿 Token、profile 拿菜单和权限 |
| 存储 | zustand + persist 自动同步 localStorage(对照客户端版 SecurityContext) |
| 注入 | 请求拦截器自动加 Bearer 头 |
| 续签 | 响应拦截器 401 → 原生 axios 续签 → 单飞防风暴 → 重放 + _retried 防死循环 |
| 守卫 | 路由组 (admin) 共享后台壳;mounted 防 hydration 警告;未登录 replace 回登录页 |
前端的"出入口"打通了。下一篇是最能体现 RBAC 价值的部分:
菜单和按钮怎么按权限动态显隐。
系列文章导航
- 上一篇:03 2.5.4-2.5.5 JWT 双 Token 与声明式权限守卫(./03-2.5.4-2.5.5 JWT 双 Token 与声明式权限守卫.md)
- 下一篇:05 2.5.7 动态菜单与按钮级权限(./05-2.5.7 动态菜单与按钮级权限.md)
- 回到索引 :第 2.5 章博客索引