第 2.5 章 2.5.6:前端登录、Token 无感续签与路由守卫

第 2.5 章 2.5.6:前端登录、Token 无感续签与路由守卫

课程专题 :《AI Agents 开发实践》

章节 :第 2.5 章 RBAC 用户权限系统

小节 :2.5.6 前端登录与请求层

作者 :一个从 Java 转型 AI 全栈的开发者

适合人群:Java 开发者,零基础 React/Next.js 请求层


1. 前端要解决的三件事

后端接口已经能拦人了(401 / 403)。前端要配合做三件事:

  1. 登录:拿账密换 Token,再把用户信息和权限"存起来"
  2. 带上 Token :之后每个请求都自动带上 Bearer accessToken
  3. 无感续签: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. 验证:无感续签怎么测

怎么验证"无感续签"真的生效了? 有个很朴素的办法:

  1. 正常登录进后台
  2. 打开浏览器 DevTools → Application → Local Storage → autix-admin-auth
  3. 手动把 accessToken 改成一串乱码(模拟过期)
  4. 在后台点任意一个菜单(触发一次接口请求)

预期结果:请求先因 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 价值的部分:

菜单和按钮怎么按权限动态显隐。


系列文章导航

相关推荐
m4Rk_1 小时前
【论文阅读】Agent 记忆机制(102):UMA——用多个未来问题端到端训练一份共享记忆
论文阅读·人工智能·学习·开源·github
众趣科技1 小时前
实景数字孪生的 Palantir 时代:把现场搬进电脑之后,数字孪生还应该做什么?
人工智能·ai·数字孪生·空间计算·palantir
johnsong1 小时前
AI 前沿日报 2026-10-16:透明化革命
人工智能
devilnumber1 小时前
Java 代理模式(简单理解回顾)
java·开发语言·代理模式
用户83134859306981 小时前
Vue3 事件:常用用法与开发高频坑点
前端·javascript·vue.js
AI风控技术指南1 小时前
Agent安全工程实践:从指令到执行,拆解五类风险与关键控制点
人工智能·大模型安全围栏·数美科技·ai安全围栏·agent安全评测·agent安全围栏·ai安全评测
weixin_446260851 小时前
ReAct‑Evolve:基于轨迹反馈的ReAct智能体自主提示演化系统
前端·react.js·前端框架
一只落魄的蜂鸟1 小时前
【2026年-13期】 Becoming an Upgradable Professional in the Era of Intelligence
人工智能
其实防守也摸鱼1 小时前
存储管理:页面置换算法学习任务(测验题要求给出置换页面顺序,缺页次数,缺页率)
android·前端·数据库·学习·oracle·自动化