前端权限控制能做的是"让用户看不到不该看的入口",做不了"阻止用户访问不该访问的数据"。把这两件事混在一起,就会出现两种典型事故:接口被直接调用时没有任何校验,因为"前端已经拦了";以及路由守卫写得很严,但用户刷新后一闪而过地看到了内容,因为鉴权是异步的、渲染先发生了。
按"边界 → 分层 → 守卫位置 → 登录态来源 → 跳转语义"拆。
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. 自测
- 一个按钮用条件渲染隐藏了,为什么仍然需要服务端鉴权?
- 守卫放在组件
useEffect里,会出现什么可观察的现象? - 动态注册路由在刷新时为什么会 404,两种修法各是什么?
- 未登录跳登录页、已登录但无权限,为什么处理方式不同?
next回跳参数为什么必须校验,//开头有什么特殊含义?- 多个接口同时返回 401,为什么不能让它们各自去刷新会话?