3047 字
约 10 分钟
18
S14-X18 · 前端路由与权限控制

前端权限控制能做的是"让用户看不到不该看的入口",做不了"阻止用户访问不该访问的数据"。把这两件事混在一起,就会出现两种典型事故:接口被直接调用时没有任何校验,因为"前端已经拦了";以及路由守卫写得很严,但用户刷新后一闪而过地看到了内容,因为鉴权是异步的、渲染先发生了。

按"边界 → 分层 → 守卫位置 → 登录态来源 → 跳转语义"拆。


1. 先认清边界:前端拦不住的,必须服务端拦

层 能挡住什么 挡不住什么
前端路由守卫 误点进入、界面混乱、无效请求 直接构造请求、改本地存储、绕过 UI
前端组件隐藏 误操作、降低误用成本 抓包重放、脚本调用接口
服务端鉴权 越权读取与写入 前端体验问题

结论很直接:前端守卫解决的是体验与误操作,服务端鉴权解决的是安全。两边都要做,但职责不能互相替代。一个按钮的 v-if 或条件渲染不构成任何保护。


2. 权限的三层落点

层级 控制对象 判据 失效表现
路由级 某页面能否进入 角色 / 路由权限码 直接输 URL 看到空白或报错页
组件级 某区块 / 按钮是否渲染 操作权限码 点了没反应,或提示无权限
数据级 某条记录能否读写 资源归属(owner、租户) 列表里出现他人的数据

前两层可以只在前端判断,第三层必须在服务端按数据做校验——同一角色对不同记录也有不同权限,这类判断前端拿不到完整信息。


3. 守卫放在哪:四种位置的取舍

位置 时机 优点 缺点
路由 loader(React Router) 进入前,可 await 数据与权限一起解析,页面拿到就是已授权 与数据加载耦合,改守卫要动 loader
路由配置 element 包一层 渲染时 集中、易读 首帧已渲染,异步鉴权会闪
布局组件内校验 子路由渲染时 一次管住整个分区 深链接进来仍先渲染子组件
全局后置钩子(导航完成后) 已跳转 可统一记录来源 目标页已挂载,可能已发请求
// React Router v6:loader 里 await 会话,未授权直接抛 redirect
export async function requireAuth({ request }: LoaderFunctionArgs) {
  const session = await getSession();            // 服务端确认过的会话
  if (!session) {
    const url = new URL(request.url);
    throw redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`);
  }
  return session;
}

// 路由表
{ path: '/orders', loader: requireAuth, element: <OrdersPage /> }

loader 抛 redirect 时页面不会渲染,这是防止"闪一下"的关键;如果鉴权放在组件 useEffect 里,首帧已经渲染出去了。


4. 登录态从哪来:本地 token 不可信

判断"是否登录"必须来自服务端的确认,不能只看 localStorage 有没有值。

来源 可信度 说明
服务端会话接口(/api/me) 高 每次启动查一次,返回角色与权限码
HttpOnly Cookie 中的会话 高(前端读不到内容,但请求自动带) 前端只能判断请求是否 401
localStorage 的 token / user 对象 低 可被改写,只能当初次渲染的占位
URL 参数里的角色 无 只用于展示层降级提示
// 启动时用会话接口确认,而不是直接信本地缓存
const { data: me, isPending } = useQuery({
  queryKey: ['me'],
  queryFn: () => fetch('/api/me').then(r => (r.ok ? r.json() : null)),
  staleTime: 60_000,
  retry: false,
});
if (isPending) return <ShellSkeleton />;      // 别在这一帧渲染受保护内容

isPending 期间渲染骨架屏而不是内容,是避免"未授权内容闪一下"的唯一办法。把 me 存进 localStorage 只为加速首帧,读到后仍要用会话接口核对。


5. 跳转语义:带返回地址,还是直接 403

情形 处理 理由
未登录访问受保护页 跳登录页,带 next 回跳参数 登录后回到原处,减少重走路径
已登录但角色不足 停留在 403 页,不跳登录 再登录也没用,避免死循环
接口返回 401 清会话后跳登录,带当前路径 会话过期
接口返回 403 提示无权限,不跳转 是权限问题不是身份问题
回跳地址来自外部域名 只接受站内相对路径 否则成为开放重定向
// 回跳参数必须校验:只允许站内相对路径
function safeNext(next: string | null, fallback = '/'): string {
  if (!next) return fallback;
  if (!next.startsWith('/') || next.startsWith('//')) return fallback;
  return next;
}

//evil.com 这种以双斜杠开头的值在浏览器里会被当作协议相对 URL,startsWith('/') 单独判断是不够的。


6. 权限码:别在前端写死角色

写法 问题
if (role === 'admin') 角色一多就散落各处,改权限要全局搜
if (perms.includes('order:refund')) 推荐:权限码由服务端下发
if (user.id === order.ownerId) 数据级判断,前端只能做展示,服务端仍需校验
页面里硬编码按钮白名单 与后端权限模型必然漂移
const { has } = usePermissions();   // 内部读会话下发的权限码数组
return (
  <>
    {has('order:refund') && <RefundButton id={order.id} />}
    {has('order:export') && <ExportButton />}
  </>
);

权限码的命名建议 资源:动作,与后端接口的鉴权项一一对应。前端只在"渲染与否"上用它,接口调用时不做任何假设。


7. 路由表:静态定义 + 过滤,还是动态注册

方案 做法 优点 风险
静态路由表 + 运行时过滤 全量路由写死,按权限过滤菜单 刷新不丢、类型安全、易调试 未授权路由仍存在于产物中
动态 addRoute 登录后按权限注册 产物里不含未授权页面 刷新时路由未注册 → 白屏或 404
服务端下发路由配置 后端给菜单与路径 运营可配 需要前端与后端约定组件映射

动态注册的经典故障是:刷新页面时权限数据还没回来,路由表是空的,直接命中 404。修法只有两个——持久化已注册标记并在注册完成后再 mount,或改回静态表 + 守卫。

// 静态表 + 守卫的组合,刷新不会 404
const routes = [
  { path: '/orders', element: <OrdersPage />, guard: 'order:view' },
  { path: '/orders/:id', element: <OrderDetail />, guard: 'order:view' },
];
// 渲染前过滤:只影响菜单展示,进入仍靠 loader/守卫判断
const visible = routes.filter(r => !r.guard || has(r.guard));

8. 懒加载与鉴权的顺序

顺序 现象
先下载 chunk,再判权限 用户多等一次网络往返才被拒绝
先判权限(loader / 守卫),再加载 未授权用户不下载任何代码
二者并行 速度快,但未授权用户仍下载了 chunk
// 权限在 loader 里判断,chunk 在其后才请求
const OrderDetail = lazy(() => import('./pages/OrderDetail'));
{ path: '/orders/:id', loader: requireAuth, element: <Suspense fallback={<Spinner />}><OrderDetail /></Suspense> }

顺序对安全没有影响(chunk 可被猜路径下载,但那只是代码不是数据),对体验影响明显:未授权用户不该为一个打不开的页面付下载成本。


9. 会话过期:先静默续期,再跳登录

token 或会话过期时,如果直接跳登录,用户正在填的表单会丢失。合理顺序是:接口返回 401 → 尝试一次静默续期(refresh)→ 成功则重放原请求 → 失败才清会话并跳登录。

续期请求要有并发保护:同一时刻多个接口同时 401,不能触发多次 refresh,否则旧 refresh token 被轮换掉、新会话又失效,形成"一直登录不上"的死循环。做法是让刷新请求单飞(共享同一个 Promise),其余请求等它结果。


10. 常见坑

坑 表现 根因 改法
只在前端拦 抓包直接调接口成功 缺服务端鉴权 接口层补校验,前端只管体验
鉴权在 useEffect 里 受保护内容闪一下 首帧已渲染 移到 loader 或路由包装层
动态注册路由刷新 404 刷新即白屏 路由表在权限回来前是空的 静态表 + 守卫
回跳参数未校验 可被跳到外部站点 开放重定向 只接受站内相对路径
角色写死在组件里 权限调整漏改 无权限码模型 服务端下发权限码
403 也跳登录 登录后仍 403,循环 把权限问题当身份问题 区分 401 与 403

11. 排错表

现象 优先核对 处理
刷新后白屏 路由是否动态注册、注册时机 静态表或注册后再挂载
进入页面先看到内容再被踢出 守卫位置在渲染之后 前移到 loader / 包装层
菜单对了但直接输 URL 能进 只做了菜单过滤、没做路由守卫 守卫与菜单用同一份权限判据
登录后回到首页而非原页面 next 参数没传或没校验 登录页读取并校验 next
多人同时操作后频繁掉登录 refresh 并发触发轮换 刷新单飞,共享 Promise

12. 边界:什么时候不需要前端权限体系

场景 更合适的选择 理由
内部工具、全部用户同权限 只做登录校验 多一层权限模型没有收益
权限极简(两种角色) 路由守卫 + 条件渲染 无需权限码体系
数据强隔离(多租户) 服务端按租户过滤 + 前端只做展示 前端拿不到完整归属信息
页面数量少 静态表 + 守卫 动态注册的复杂度不划算
需要审计谁改了什么 服务端操作日志 前端无法提供可信审计

一句话收束:前端守卫决定"看不看得到",服务端鉴权决定"拿不拿得到"。两者判据要来自同一份权限数据,否则界面与接口迟早对不上。


13. 自测

  1. 一个按钮用条件渲染隐藏了,为什么仍然需要服务端鉴权?
  2. 守卫放在组件 useEffect 里,会出现什么可观察的现象?
  3. 动态注册路由在刷新时为什么会 404,两种修法各是什么?
  4. 未登录跳登录页、已登录但无权限,为什么处理方式不同?
  5. next 回跳参数为什么必须校验,// 开头有什么特殊含义?
  6. 多个接口同时返回 401,为什么不能让它们各自去刷新会话?
S14-X18 · 前端路由与权限控制
https://www.clxhxhhr.top/posts/5917/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。