前端权限系统
前端权限系统设计与实战
从模型到落地:RBAC / ABAC 权限模型、路由守卫、按钮级权限、动态菜单、数据权限与多租户的完整方案
目录
- 导读:前端权限的边界与职责
- 权限模型:RBAC 与 ABAC
- 后端契约:权限接口设计
- 权限 Store 设计与初始化
- 路由权限:静态路由与动态路由
- 导航守卫:全局、路由级与组件级
- 动态菜单:递归渲染与权限过滤
- 按钮级权限:指令与组件方案
- 数据权限:行级与列级控制
- 多租户权限隔离
- 权限缓存与实时更新
- 权限与微前端
- 安全加固:前端权限的不可信本质
- 测试策略
- 踩坑清单与最佳实践
一、导读:前端权限的边界与职责
1.1 前端权限做什么、不做什么
┌─────────────────────────────────────────────────────────────┐
│ 权限控制的分层 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 网关层 │ │ 后端服务层 │ │ 前端展示层 │ │
│ │ (Nginx/ │ │ (接口鉴权 │ │ (UI 控制) │ │
│ │ Gateway) │ │ 数据过滤) │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ IP / 限流 角色/权限校验 菜单/按钮/路由 │
│ 粗粒度拦截 数据行/列过滤 体验优化、防误操作 │
│ │
│ ★ 核心原则:前端权限 = 体验层,后端权限 = 安全层 │
│ ★ 前端权限永远不能替代后端校验 │
└─────────────────────────────────────────────────────────────┘
前端权限的核心职责:
| 职责 | 说明 | 示例 |
|---|---|---|
| 路由守卫 | 无权限路由不可访问 | 普通用户访问 /admin 被拦截 |
| 菜单过滤 | 只展示有权限的菜单项 | 运营看不到「系统设置」 |
| 按钮控制 | 无权限操作按钮隐藏/禁用 | 无删除权限则隐藏删除按钮 |
| 数据脱敏 | 前端对敏感字段做遮罩 | 身份证号显示 330***1234 |
| 体验优化 | 避免用户操作后才发现无权限 | 提前告知而非提交后报错 |
前端权限不做的事:
- ❌ 不做接口安全校验(后端必须兜底)
- ❌ 不做数据行级过滤(后端 SQL 层控制)
- ❌ 不信任前端传来的权限标识(后端必须重新校验)
1.2 权限系统全景图
用户登录
│
▼
获取 Token ──→ 存储 Token(内存优先 / Storage 加密)
│
▼
拉取权限数据(角色 + 权限码 + 菜单树)
│
├─→ 注册动态路由(router.addRoute)
├─→ 生成菜单树(过滤 + 排序)
├─→ 存入权限 Store(按钮码集合)
│
▼
导航守卫拦截
│
├─→ 路由匹配:有无权限访问目标页?
├─→ 按钮渲染:v-permission / <Auth> 组件
└─→ 数据权限:列过滤 / 字段脱敏
二、权限模型:RBAC 与 ABAC
2.1 RBAC:基于角色的访问控制
RBAC 是最广泛使用的权限模型,核心思想:用户 → 角色 → 权限。
┌────────┐ ┌────────┐ ┌────────────┐
│ 用户 │────→│ 角色 │────→│ 权限 │
│ User │ N:M │ Role │ N:M │ Permission │
└────────┘ └────────┘ └────────────┘
示例:
张三 ──→ [管理员] ──→ [user:create, user:delete, user:export]
李四 ──→ [运营] ──→ [user:read, user:export]
王五 ──→ [审计] ──→ [user:read, log:read]
RBAC 数据结构:
// types/permission.ts
/** 权限码 —— 最小粒度的权限单元 */
type PermissionCode = string;
/** 角色 */
interface Role {
id: string;
code: string; // 如 'admin', 'operator'
name: string; // 如 '管理员', '运营'
permissions: PermissionCode[];
}
/** 用户(含角色与权限) */
interface UserInfo {
id: string;
username: string;
roles: Role[];
permissions: PermissionCode[]; // 扁平化:所有角色的权限并集
}
RBAC 的优势与局限:
| 维度 | 优势 | 局限 |
|---|---|---|
| 理解成本 | 直观,角色即权限集合 | 角色爆炸:细粒度控制导致角色数量膨胀 |
| 管理成本 | 分配角色即可 | 无法表达上下文相关权限(如"只能看自己部门") |
| 扩展性 | 适合 90% 的中后台系统 | 需要数据权限时力不从心 |
2.2 ABAC:基于属性的访问控制
ABAC 基于用户属性、资源属性、环境属性动态计算权限,适合复杂场景。
┌──────────────┐
│ 策略引擎 │
│ Policy Engine │
└──────┬───────┘
│
┌────┼────────────────────┐
│ │ │
▼ ▼ ▼
用户属性 资源属性 环境属性
──────── ──────── ────────
部门=技术 类型=合同 时间=工作日
职级=P7 密级=机密 IP=内网
区域=华东 金额>10000 终端=PC
策略示例:
当 用户.部门 == 资源.归属部门 AND 资源.密级 IN ['公开','内部'] → 允许查看
ABAC TypeScript 类型:
// types/abac.ts
/** 属性值类型 */
type AttributeValue = string | number | boolean | string[];
interface PolicyCondition {
field: string; // 属性路径,如 'user.department'
operator: 'eq' | 'ne' | 'in' | 'notIn' | 'gt' | 'lt' | 'contains';
value: AttributeValue;
}
interface Policy {
id: string;
name: string;
effect: 'allow' | 'deny';
actions: string[]; // 如 ['document:read', 'document:download']
conditions: PolicyCondition[];
priority: number; // 优先级,deny 优先原则
}
/** 策略评估上下文 */
interface PolicyContext {
user: Record<string, AttributeValue>; // 用户属性
resource: Record<string, AttributeValue>; // 资源属性
environment: Record<string, AttributeValue>; // 环境属性
}
策略评估引擎:
// utils/policyEngine.ts
const OPERATOR_MAP = {
eq: (a: AttributeValue, b: AttributeValue) => a === b,
ne: (a: AttributeValue, b: AttributeValue) => a !== b,
in: (a: AttributeValue, b: AttributeValue) =>
Array.isArray(b) && b.includes(a as string),
notIn: (a: AttributeValue, b: AttributeValue) =>
Array.isArray(b) && !b.includes(a as string),
gt: (a: AttributeValue, b: AttributeValue) =>
typeof a === 'number' && typeof b === 'number' && a > b,
lt: (a: AttributeValue, b: AttributeValue) =>
typeof a === 'number' && typeof b === 'number' && a < b,
contains: (a: AttributeValue, b: AttributeValue) =>
Array.isArray(a) && a.includes(b as string),
} as const;
/** 从上下文中按路径取值,如 'user.department' → context.user.department */
function resolveValue(context: PolicyContext, path: string): AttributeValue | undefined {
const keys = path.split('.');
let current: unknown = context;
for (const key of keys) {
if (current === null || current === undefined || typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current as AttributeValue;
}
/** 评估单条条件 */
function evaluateCondition(
condition: PolicyCondition,
context: PolicyContext
): boolean {
const actual = resolveValue(context, condition.field);
if (actual === undefined) return false;
const evaluator = OPERATOR_MAP[condition.operator];
return evaluator(actual, condition.value);
}
/** 评估策略:所有条件都满足才通过 */
function evaluatePolicy(policy: Policy, context: PolicyContext): boolean {
return policy.conditions.every((cond) => evaluateCondition(cond, context));
}
/**
* 策略决策:按优先级排序,deny 优先
* 返回是否允许执行指定 action
*/
export function evaluateAccess(
policies: Policy[],
context: PolicyContext,
action: string
): boolean {
const sorted = [...policies].sort((a, b) => b.priority - a.priority);
let allowed = false;
for (const policy of sorted) {
if (!policy.actions.includes(action)) continue;
if (!evaluatePolicy(policy, context)) continue;
if (policy.effect === 'deny') return false; // deny 优先,立即拒绝
allowed = true;
}
return allowed;
}
2.3 RBAC + ABAC 混合模型(推荐)
实际项目中,RBAC 做粗粒度、ABAC 做细粒度,两者结合:
┌─────────────────────────────────────────────────┐
│ RBAC + ABAC 混合模型 │
│ │
│ 第一层:RBAC │
│ 用户 → 角色 → 权限码 │
│ 解决"能不能访问这个功能" │
│ │
│ 第二层:ABAC │
│ 属性条件 → 策略决策 │
│ 解决"能访问哪些数据 / 在什么条件下可以" │
│ │
│ 示例: │
│ RBAC: 运营角色 → 有 order:export 权限 │
│ ABAC: 只能导出自己区域的订单 │
│ (user.region == resource.region) │
└─────────────────────────────────────────────────┘
三、后端契约:权限接口设计
3.1 接口清单
前端权限依赖后端提供三类数据:
┌──────────────────────────────────────────────────────┐
│ 权限相关接口 │
├──────────────┬───────────────────────────────────────┤
│ 接口 │ 说明 │
├──────────────┼───────────────────────────────────────┤
│ POST /login │ 登录,返回 accessToken + refreshToken │
│ GET /user │ 获取当前用户信息 + 角色列表 │
│ GET /perms │ 获取当前用户的权限码集合 │
│ GET /menus │ 获取当前用户的菜单树(已过滤) │
│ GET /routes │ 获取当前用户的动态路由配置 │
└──────────────┴───────────────────────────────────────┘
3.2 接口响应契约
// types/api.ts
/** 登录响应 */
interface LoginResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
/** 用户信息响应 */
interface UserProfileResponse {
id: string;
username: string;
nickname: string;
avatar: string;
roles: Array<{
id: string;
code: string; // 'admin' | 'editor' | 'viewer'
name: string;
}>;
}
/** 权限码响应 */
interface PermissionResponse {
codes: string[]; // ['user:create', 'user:delete', 'order:export', ...]
}
/** 菜单项 */
interface MenuItem {
id: string;
parentId: string | null;
name: string; // 路由 name,如 'UserManage'
path: string; // 路由 path,如 '/system/user'
component: string; // 组件路径,如 'system/user/index'
redirect?: string;
icon?: string;
title: string; // 显示名称
sort: number; // 排序
hidden: boolean; // 是否隐藏(在菜单中不显示,但路由可访问)
cache: boolean; // 是否 keep-alive
children?: MenuItem[];
}
/** 菜单响应 */
interface MenuResponse {
menus: MenuItem[];
}
3.3 合并方案:一次请求拿全
很多项目将用户信息、权限码、菜单树拆成三个接口,但初始化时串行请求会拖慢首屏。推荐合并为一次请求:
// types/api.ts
/** 用户信息 */
interface UserProfileResponse {
id: string;
username: string;
nickname: string;
avatar: string;
roles: Array<{
id: string;
code: string;
name: string;
}>;
}
/** 初始化数据(登录后一次性拉取) */
interface AppInitResponse {
user: UserProfileResponse;
permissions: string[];
menus: MenuItem[];
}
// api/app.ts
import { http } from '@/utils/http';
import type { AppInitResponse } from '@/types/api';
/** 应用初始化:一次性获取用户 + 权限 + 菜单 */
export function fetchAppInit() {
return http.get<AppInitResponse>('/app/init');
}
四、权限 Store 设计与初始化
4.1 Store 结构
// stores/permission.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { fetchAppInit } from '@/api/app';
import type { MenuItem, Role } from '@/types/api';
export const usePermissionStore = defineStore('permission', () => {
// ─── State ───
const token = ref('');
const roles = ref<Role[]>([]);
const permissionCodes = ref<Set<string>>(new Set());
const menuList = ref<MenuItem[]>([]);
const isInitialized = ref(false);
// ─── Getters ───
/** 是否已登录 */
const isLoggedIn = computed(() => !!token.value);
/** 角色码列表 */
const roleCodes = computed(() => roles.value.map((r) => r.code));
/** 是否超级管理员(跳过所有权限检查) */
const isSuperAdmin = computed(() => roleCodes.value.includes('super_admin'));
/** 判断是否拥有指定权限码 */
function hasPermission(code: string): boolean {
if (isSuperAdmin.value) return true;
return permissionCodes.value.has(code);
}
/** 判断是否拥有任一权限码 */
function hasAnyPermission(codes: string[]): boolean {
if (isSuperAdmin.value) return true;
return codes.some((code) => permissionCodes.value.has(code));
}
/** 判断是否拥有全部权限码 */
function hasAllPermissions(codes: string[]): boolean {
if (isSuperAdmin.value) return true;
return codes.every((code) => permissionCodes.value.has(code));
}
/** 判断是否拥有指定角色 */
function hasRole(code: string): boolean {
return roleCodes.value.includes(code);
}
// ─── Actions ───
/** 初始化:拉取用户信息 + 权限 + 菜单 */
async function initApp(force = false) {
if (isInitialized.value && !force) return;
const { user, permissions, menus } = await fetchAppInit();
roles.value = user.roles;
permissionCodes.value = new Set(permissions);
menuList.value = menus;
isInitialized.value = true;
}
/** 退出登录,清除所有状态 */
function reset() {
token.value = '';
roles.value = [];
permissionCodes.value = new Set();
menuList.value = [];
isInitialized.value = false;
}
return {
token,
roles,
permissionCodes,
menuList,
isInitialized,
isLoggedIn,
roleCodes,
isSuperAdmin,
hasPermission,
hasAnyPermission,
hasAllPermissions,
hasRole,
initApp,
reset,
};
});
4.2 初始化时序
App.vue mounted
│
▼
permissionStore.initApp()
│
├─→ 1. 请求 /app/init
├─→ 2. 存储角色 + 权限码 + 菜单
├─→ 3. 生成动态路由(router.addRoute)
├─→ 4. 标记 isInitialized = true
│
▼
导航守卫放行,页面正常渲染
关键点:isInitialized 是一个门控标志,确保权限数据就绪后才放行路由跳转。
4.3 权限码用 Set 而非 Array
// ❌ 数组查找 O(n)
const permissionCodes = ref<string[]>([]);
hasPermission: (code) => permissionCodes.value.includes(code)
// ✅ Set 查找 O(1)
const permissionCodes = ref<Set<string>>(new Set());
hasPermission: (code) => permissionCodes.value.has(code)
当权限码数量达到几百个时,Set 的性能优势明显。注意 Pinia 持久化时 Set 需要序列化处理:
// 持久化插件中处理 Set
const permissionPlugin = ({ store }) => {
if (store.$id === 'permission') {
store.$subscribe((_, state) => {
const serialized = {
...state,
permissionCodes: Array.from(state.permissionCodes), // Set → Array
};
sessionStorage.setItem('permission', JSON.stringify(serialized));
});
}
};
// 恢复时处理 Set
function hydratePermission() {
const raw = sessionStorage.getItem('permission');
if (!raw) return null;
const data = JSON.parse(raw);
return {
...data,
permissionCodes: new Set(data.permissionCodes), // Array → Set
};
}
五、路由权限:静态路由与动态路由
5.1 路由分类
┌────────────────────────────────────────────────────────┐
│ 路由分类 │
├────────────────────┬───────────────────────────────────┤
│ 静态路由 │ 动态路由 │
│ (constantRoutes) │ (asyncRoutes) │
├────────────────────┼───────────────────────────────────┤
│ 登录页、404、 │ 需要权限才能访问的业务页面 │
│ 注册页、公共页 │ 根据后端菜单数据动态注册 │
│ 不需要权限控制 │ 每个用户看到的路由可能不同 │
│ 写死在代码里 │ 运行时 addRoute │
└────────────────────┴───────────────────────────────────┘
5.2 静态路由定义
// router/constants.ts
import type { RouteRecordRaw } from 'vue-router';
/** 静态路由:不需要权限,所有人可访问 */
export const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', hidden: true },
},
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/error/403.vue'),
meta: { title: '无权限', hidden: true },
},
{
path: '/404',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: { title: '页面不存在', hidden: true },
},
{
path: '/',
name: 'Layout',
component: () => import('@/layouts/DefaultLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '工作台', icon: 'dashboard' },
},
],
},
];
5.3 动态路由生成
后端返回的菜单数据需要转换为 Vue Router 的路由配置:
// router/dynamic.ts
import type { RouteRecordRaw } from 'vue-router';
import type { MenuItem } from '@/types/api';
/**
* 组件映射表
* 后端返回 component 字符串,前端映射到实际组件
*
* 使用 Vite 的 import.meta.glob 实现批量导入
* 注意:key 必须使用绝对路径 /src/views/... 而非别名 @/views/...
* 因为 import.meta.glob 返回的 key 是基于项目根目录的绝对路径
*/
const VIEW_MODULES = import.meta.glob('/src/views/**/*.vue');
/** 将后端菜单转换为路由配置 */
function transformMenuToRoute(menu: MenuItem): RouteRecordRaw | null {
const route: RouteRecordRaw = {
path: menu.path,
name: menu.name,
component: resolveComponent(menu.component),
meta: {
title: menu.title,
icon: menu.icon,
hidden: menu.hidden,
cache: menu.cache,
},
};
if (menu.redirect) {
route.redirect = menu.redirect;
}
if (menu.children?.length) {
route.children = menu.children
.map(transformMenuToRoute)
.filter((r): r is RouteRecordRaw => r !== null);
}
return route;
}
/** 解析组件路径 */
function resolveComponent(componentPath: string) {
const path = `/src/views/${componentPath}.vue`;
const module = VIEW_MODULES[path];
if (!module) {
console.error(`[Router] 组件未找到: ${path}`);
return () => import('@/views/error/404.vue');
}
return module;
}
/** 批量转换菜单为路由 */
export function generateRoutes(menus: MenuItem[]): RouteRecordRaw[] {
return menus
.map(transformMenuToRoute)
.filter((r): r is RouteRecordRaw => r !== null);
}
5.4 注册动态路由
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { constantRoutes } from './constants';
import { generateRoutes } from './dynamic';
import { usePermissionStore } from '@/stores/permission';
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes,
});
/** 标记:动态路由是否已注册 */
let dynamicRoutesAdded = false;
/** 保存动态路由的移除回调 */
let removeRouteCallbacks: Array<() => void> = [];
/** 注册动态路由 */
export function addDynamicRoutes() {
if (dynamicRoutesAdded) return;
const permissionStore = usePermissionStore();
const routes = generateRoutes(permissionStore.menuList);
routes.forEach((route) => {
// addRoute 返回移除回调,比 router.getRoutes() 遍历更安全
const remove = router.addRoute(route);
removeRouteCallbacks.push(remove);
});
// 兜底:所有未匹配路由跳转 404(必须在动态路由之后添加)
const removeNotFound = router.addRoute({
name: 'DynamicNotFound',
path: '/:pathMatch(.*)*',
redirect: '/404',
});
removeRouteCallbacks.push(removeNotFound);
dynamicRoutesAdded = true;
}
/**
* 重置路由(退出登录时调用)
*
* 通过 addRoute 返回的回调移除,比遍历 router.getRoutes() 更安全:
* 1. 避免误删嵌套在 Layout.children 中的静态路由
* 2. 无 name 的兜底路由也能正确移除
*/
export function resetRouter() {
removeRouteCallbacks.forEach((remove) => remove());
removeRouteCallbacks = [];
dynamicRoutesAdded = false;
}
export default router;
5.5 两种动态路由方案对比
| 方案 | 说明 | 优势 | 劣势 |
|---|---|---|---|
| 后端返回路由配置 | 后端返回完整的路由 JSON | 前后端完全解耦,菜单可后台配置 | 后端需要维护路由结构,前后端耦合 |
| 前端定义 + 权限过滤 | 前端定义所有路由,后端只返回权限码 | 前端自治,路由结构清晰 | 新增页面需前端发版 |
推荐:中小项目用方案二(前端定义 + 权限过滤),大型项目或需要运营配置菜单的用方案一。
方案二的核心实现:
// router/asyncRoutes.ts
import type { RouteRecordRaw } from 'vue-router';
/** 全量异步路由(前端定义) */
export const asyncRoutes: RouteRecordRaw[] = [
{
path: '/system',
name: 'System',
component: () => import('@/layouts/DefaultLayout.vue'),
meta: { title: '系统管理', icon: 'setting' },
children: [
{
path: 'user',
name: 'UserManage',
component: () => import('@/views/system/user/index.vue'),
meta: { title: '用户管理', icon: 'user', permissions: ['system:user:list'] },
},
{
path: 'role',
name: 'RoleManage',
component: () => import('@/views/system/role/index.vue'),
meta: { title: '角色管理', icon: 'role', permissions: ['system:role:list'] },
},
],
},
{
path: '/order',
name: 'Order',
component: () => import('@/layouts/DefaultLayout.vue'),
meta: { title: '订单管理', icon: 'order' },
children: [
{
path: 'list',
name: 'OrderList',
component: () => import('@/views/order/list/index.vue'),
meta: { title: '订单列表', permissions: ['order:list'] },
},
{
path: 'detail/:id',
name: 'OrderDetail',
component: () => import('@/views/order/detail/index.vue'),
meta: { title: '订单详情', hidden: true, permissions: ['order:detail'] },
},
],
},
];
// router/filter.ts
import type { RouteRecordRaw } from 'vue-router';
import { usePermissionStore } from '@/stores/permission';
/**
* 根据权限过滤路由
* 递归过滤:如果父路由没有权限,子路由也不会显示
* 剪枝:父路由无权限要求但子路由全部被过滤后,父路由也移除(避免空目录)
*/
export function filterAsyncRoutes(
routes: RouteRecordRaw[],
permissionCodes: Set<string>
): RouteRecordRaw[] {
return routes
.map((route) => {
const requiredPerms = route.meta?.permissions as string[] | undefined;
// 有权限要求,检查是否满足
if (requiredPerms?.length) {
const hasAccess = requiredPerms.some((perm) => permissionCodes.has(perm));
if (!hasAccess) return null;
}
const cloned = { ...route };
// 递归过滤子路由
if (cloned.children) {
cloned.children = filterAsyncRoutes(cloned.children, permissionCodes);
}
// 剪枝:目录型路由(无 component)如果子路由全部被过滤,则移除
const isDirectory = !cloned.component && Array.isArray(route.children);
if (isDirectory && (!cloned.children || cloned.children.length === 0)) {
return null;
}
return cloned;
})
.filter((route): route is RouteRecordRaw => route !== null);
}
六、导航守卫:全局、路由级与组件级
6.1 全局前置守卫
// router/guard.ts
import type { Router } from 'vue-router';
import { usePermissionStore } from '@/stores/permission';
import { addDynamicRoutes, resetRouter } from '@/router/index';
const WHITE_LIST = ['/login', '/403', '/404'];
/**
* 全局前置守卫
*
* Vue Router 4 推荐使用 return 风格而非 next():
* - return true → 放行
* - return false → 取消导航
* - return 路由位置 → 重定向
*
* 官方文档明确说明:在守卫中 addRoute 后,
* 不要调用 router.replace(),而是通过返回新的 location 触发重定向。
*/
export function setupRouterGuard(router: Router) {
router.beforeEach(async (to) => {
const permissionStore = usePermissionStore();
// 1. 未登录
if (!permissionStore.isLoggedIn) {
if (WHITE_LIST.includes(to.path)) return true;
return {
path: '/login',
query: { redirect: to.fullPath },
replace: true,
};
}
// 2. 已登录但访问登录页 → 跳转首页
if (to.path === '/login') {
return { path: '/', replace: true };
}
// 3. 权限数据未初始化
if (!permissionStore.isInitialized) {
try {
await permissionStore.initApp();
addDynamicRoutes();
// addRoute 后通过返回新 location 触发重定向
return { path: to.fullPath, replace: true };
} catch {
// 初始化失败(如 token 过期),清除状态并跳转登录
permissionStore.reset();
resetRouter();
return {
path: '/login',
query: { redirect: to.fullPath },
replace: true,
};
}
}
// 4. 路由权限检查
const requiredPerms = to.meta?.permissions as string[] | undefined;
if (requiredPerms?.length && !permissionStore.hasAnyPermission(requiredPerms)) {
return { path: '/403', replace: true };
}
// 5. 权限数据已就绪,正常放行
return true;
});
router.afterEach((to) => {
// 设置页面标题
const title = to.meta?.title as string | undefined;
document.title = title ? `${title} - 管理后台` : '管理后台';
});
}
6.2 守卫流程图
beforeEach
│
├─ 未登录?
│ ├─ 白名单 → 放行
│ └─ 非白名单 → 跳转 /login?redirect=xxx
│
├─ 已登录 + 访问 /login → 跳转 /
│
├─ 已登录 + 未初始化?
│ ├─ initApp() 成功 → addRoute → next({ ...to, replace: true })
│ └─ initApp() 失败 → reset → 跳转 /login
│
├─ 已登录 + 已初始化?
│ ├─ 路由有权限要求?
│ │ ├─ 有权限 → 放行
│ │ └─ 无权限 → 跳转 /403
│ └─ 无权限要求 → 放行
│
▼
afterEach → 设置页面标题
6.3 组件级权限
某些页面内部需要根据权限展示不同内容:
<!-- views/system/user/index.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { usePermissionStore } from '@/stores/permission';
import { PERM } from '@/constants/permission-codes';
const permissionStore = usePermissionStore();
const canCreate = computed(() => permissionStore.hasPermission(PERM.USER_CREATE));
const canDelete = computed(() => permissionStore.hasPermission(PERM.USER_DELETE));
const canExport = computed(() => permissionStore.hasPermission(PERM.USER_EXPORT));
</script>
<template>
<div class="user-manage">
<div class="toolbar">
<el-button v-if="canCreate" type="primary" @click="handleCreate">
新增用户
</el-button>
<el-button v-if="canExport" @click="handleExport">
导出
</el-button>
</div>
<el-table :data="userList">
<el-table-column prop="name" label="姓名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column v-if="canDelete" label="操作" width="120">
<template #default="{ row }">
<el-button type="danger" link @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
七、动态菜单:递归渲染与权限过滤
7.1 菜单数据处理
后端返回的菜单已经是按权限过滤后的,但前端仍需做二次处理:排序、层级构建、隐藏项过滤。
// utils/menu.ts
import type { MenuItem } from '@/types/api';
/** 菜单项(前端渲染用,扩展了层级信息) */
interface RenderMenuItem extends MenuItem {
children?: RenderMenuItem[];
}
/** 将扁平菜单列表构建为树形结构 */
export function buildMenuTree(flatList: MenuItem[]): RenderMenuItem[] {
const map = new Map<string, RenderMenuItem>();
const roots: RenderMenuItem[] = [];
// 第一遍:建立映射
flatList.forEach((item) => {
map.set(item.id, { ...item, children: [] });
});
// 第二遍:构建树
flatList.forEach((item) => {
const node = map.get(item.id)!;
if (item.parentId === null || item.parentId === undefined) {
roots.push(node);
} else {
const parent = map.get(item.parentId);
parent?.children?.push(node);
}
});
// 排序
const sortNodes = (nodes: RenderMenuItem[]) => {
nodes.sort((a, b) => a.sort - b.sort);
nodes.forEach((node) => {
if (node.children?.length) {
sortNodes(node.children);
}
});
};
sortNodes(roots);
return roots;
}
/** 过滤隐藏菜单项(仅用于菜单渲染,路由不过滤) */
export function filterHiddenMenus(menus: RenderMenuItem[]): RenderMenuItem[] {
return menus
.filter((menu) => !menu.hidden)
.map((menu) => {
if (menu.children?.length) {
return {
...menu,
children: filterHiddenMenus(menu.children),
};
}
return menu;
})
.filter((menu) => {
// 过滤掉没有子项且没有组件的空目录
if (menu.children && menu.children.length === 0 && !menu.component) {
return false;
}
return true;
});
}
7.2 递归菜单组件
<!-- components/Sidebar/MenuItem.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import type { RenderMenuItem } from '@/utils/menu';
const props = defineProps<{
menu: RenderMenuItem;
}>();
const router = useRouter();
const hasChildren = computed(() => (props.menu.children?.length ?? 0) > 0);
function handleClick() {
if (hasChildren.value) return;
router.push(props.menu.path);
}
</script>
<template>
<!-- 有子菜单:展开为子菜单组 -->
<el-sub-menu v-if="hasChildren" :index="menu.path">
<template #title>
<el-icon v-if="menu.icon"><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</template>
<MenuItem
v-for="child in menu.children"
:key="child.id"
:menu="child"
/>
</el-sub-menu>
<!-- 无子菜单:菜单项 -->
<el-menu-item v-else :index="menu.path" @click="handleClick">
<el-icon v-if="menu.icon"><component :is="menu.icon" /></el-icon>
<template #title>{{ menu.title }}</template>
</el-menu-item>
</template>
<!-- components/Sidebar/index.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { usePermissionStore } from '@/stores/permission';
import { buildMenuTree, filterHiddenMenus } from '@/utils/menu';
import MenuItem from './MenuItem.vue';
const route = useRoute();
const permissionStore = usePermissionStore();
const menuTree = computed(() => {
const tree = buildMenuTree(permissionStore.menuList);
return filterHiddenMenus(tree);
});
const activeMenu = computed(() => route.path);
</script>
<template>
<el-menu
:default-active="activeMenu"
background-color="#001529"
text-color="#ffffffa6"
active-text-color="#ffffff"
router
>
<MenuItem
v-for="menu in menuTree"
:key="menu.id"
:menu="menu"
/>
</el-menu>
</template>
7.3 面包屑与标签页联动
// composables/useBreadcrumb.ts
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { usePermissionStore } from '@/stores/permission';
import { buildMenuTree } from '@/utils/menu';
interface BreadcrumbItem {
title: string;
path: string;
}
export function useBreadcrumb() {
const route = useRoute();
const permissionStore = usePermissionStore();
const breadcrumb = computed<BreadcrumbItem[]>(() => {
const menuTree = buildMenuTree(permissionStore.menuList);
const matched: BreadcrumbItem[] = [];
function findPath(menus: typeof menuTree, target: string): boolean {
for (const menu of menus) {
if (menu.path === target) {
matched.push({ title: menu.title, path: menu.path });
return true;
}
if (menu.children?.length) {
matched.push({ title: menu.title, path: menu.path });
if (findPath(menu.children, target)) return true;
matched.pop();
}
}
return false;
}
findPath(menuTree, route.path);
return matched;
});
return { breadcrumb };
}
八、按钮级权限:指令与组件方案
8.1 自定义指令 v-permission
// directives/permission.ts
import type { Directive, DirectiveBinding } from 'vue';
import { usePermissionStore } from '@/stores/permission';
/**
* v-permission="'user:delete'"
* v-permission="['user:delete', 'user:edit']" // 满足任一即可
*
* 修饰符:
* v-permission.all="['a', 'b']" // 需要全部满足
* v-permission.disable="'user:delete'" // 无权限时禁用而非移除
*
* 注意:指令方式在权限实时变更场景下有局限性:
* - mounted 只执行一次,权限变化后不会自动响应
* - 已移除的 DOM 无法恢复
*
* 推荐策略:
* - 简单按钮显隐:可用 v-permission
* - 需要响应式权限变化(切换角色/实时权限变更/多租户切换):优先用 <Auth> 组件
*/
export const vPermission: Directive<HTMLElement> = {
mounted(el, binding: DirectiveBinding<string | string[]>) {
checkPermission(el, binding);
},
updated(el, binding: DirectiveBinding<string | string[]>) {
checkPermission(el, binding);
},
};
function checkPermission(el: HTMLElement, binding: DirectiveBinding<string | string[]>) {
const permissionStore = usePermissionStore();
const { value, modifiers } = binding;
if (!value) return;
const codes = Array.isArray(value) ? value : [value];
const hasAccess = modifiers.all
? permissionStore.hasAllPermissions(codes)
: permissionStore.hasAnyPermission(codes);
if (hasAccess) {
// 有权限时恢复元素状态
el.removeAttribute('disabled');
el.style.opacity = '';
el.style.cursor = '';
el.style.pointerEvents = '';
return;
}
if (modifiers.disable) {
// 禁用模式:元素保留但不可点击
el.setAttribute('disabled', '');
el.style.opacity = '0.5';
el.style.cursor = 'not-allowed';
el.style.pointerEvents = 'none';
} else {
// 默认:直接移除元素
el.parentNode?.removeChild(el);
}
}
注册指令:
// main.ts
import { createApp } from 'vue';
import { vPermission } from '@/directives/permission';
import App from './App.vue';
const app = createApp(App);
app.directive('permission', vPermission);
app.mount('#app');
使用示例:
<template>
<!-- 有删除权限才显示按钮 -->
<el-button v-permission="'user:delete'" type="danger">删除</el-button>
<!-- 有编辑或审核权限才显示 -->
<el-button v-permission="['order:edit', 'order:audit']">操作</el-button>
<!-- 需要同时拥有两个权限 -->
<el-button v-permission.all="['report:export', 'report:download']">导出</el-button>
<!-- 无权限时禁用而非隐藏 -->
<el-button v-permission.disable="'user:edit'">编辑</el-button>
</template>
8.2 权限组件
指令方案的问题:无法作用于组件级别的条件渲染(如 v-if 场景)。组件方案更灵活:
<!-- components/Auth/index.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { usePermissionStore } from '@/stores/permission';
const props = withDefaults(
defineProps<{
/** 权限码 */
value: string | string[];
/** 模式:any=满足任一,all=满足全部 */
mode?: 'any' | 'all';
}>(),
{ mode: 'any' }
);
const permissionStore = usePermissionStore();
const hasAccess = computed(() => {
const codes = Array.isArray(props.value) ? props.value : [props.value];
return props.mode === 'all'
? permissionStore.hasAllPermissions(codes)
: permissionStore.hasAnyPermission(codes);
});
</script>
<template>
<slot v-if="hasAccess" />
<slot v-else name="fallback" />
</template>
使用示例:
<template>
<!-- 基本用法 -->
<Auth value="user:create">
<el-button type="primary">新增用户</el-button>
</Auth>
<!-- 带 fallback 插槽 -->
<Auth value="user:delete">
<el-button type="danger">删除</el-button>
<template #fallback>
<el-button type="danger" disabled>删除(无权限)</el-button>
</template>
</Auth>
<!-- 多权限码 -->
<Auth :value="['order:edit', 'order:cancel']" mode="any">
<el-button>操作订单</el-button>
</Auth>
</template>
8.3 函数式调用
在非模板场景(如逻辑判断)中,直接使用 Store 方法:
import { usePermissionStore } from '@/stores/permission';
const permissionStore = usePermissionStore();
// 控制表格操作列
const columns = computed(() => {
const base = [
{ prop: 'name', label: '姓名' },
{ prop: 'email', label: '邮箱' },
];
if (permissionStore.hasPermission('user:edit')) {
base.push({ prop: 'actions', label: '操作', slot: 'actions' });
}
return base;
});
// 控制批量操作按钮
const showBatchDelete = computed(
() => permissionStore.hasPermission('user:batchDelete') && selectedRows.value.length > 0
);
8.4 三种方案对比
| 方案 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
v-permission 指令 |
简单的按钮显隐 | 简洁,一行搞定 | 无法作用在组件级;移除后无法恢复 |
<Auth> 组件 |
需要条件渲染、fallback | 灵活,支持插槽 | 嵌套层级多 |
| Store 方法 | 逻辑判断、动态配置 | 最灵活 | 需手动引入 |
九、数据权限:行级与列级控制
9.1 行级数据权限
行级权限控制"能看到哪些数据",通常由后端 SQL 层控制,但前端也需要配合:
// types/dataPermission.ts
/** 数据范围 */
type DataScope =
| 'all' // 全部数据
| 'department' // 本部门
| 'department_and_sub' // 本部门及子部门
| 'self' // 仅本人
| 'custom'; // 自定义部门列表
interface DataPermission {
resource: string; // 资源标识,如 'order', 'contract'
scope: DataScope;
departmentIds?: string[]; // scope=custom 时指定
}
前端配合方式:
// api/order.ts
import { http } from '@/utils/http';
interface OrderQuery {
page: number;
pageSize: number;
status?: string;
}
/** 获取订单列表(后端自动根据数据权限过滤) */
export function fetchOrderList(params: OrderQuery) {
return http.get('/api/orders', { params });
// 后端会根据当前用户的数据权限自动过滤:
// - scope=all: 返回所有订单
// - scope=department: 只返回用户所属部门的订单
// - scope=self: 只返回用户自己创建的订单
}
前端提示:根据数据范围显示提示文案,管理用户预期:
<template>
<div class="order-list">
<el-alert v-if="dataScopeHint" type="info" :closable="false">
{{ dataScopeHint }}
</el-alert>
<el-table :data="orders">
<!-- ... -->
</el-table>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { usePermissionStore } from '@/stores/permission';
const permissionStore = usePermissionStore();
const DATA_SCOPE_HINTS: Record<string, string> = {
department: '当前仅展示您所属部门的订单',
department_and_sub: '当前展示您所属部门及下级部门的订单',
self: '当前仅展示您自己创建的订单',
custom: '当前展示指定部门的订单',
};
const dataScopeHint = computed(() => {
const scope = permissionStore.dataPermissions?.find(
(p) => p.resource === 'order'
)?.scope;
return scope ? DATA_SCOPE_HINTS[scope] : '';
});
</script>
9.2 列级数据权限
列级权限必须由后端决定返回哪些字段或返回何种脱敏值,前端只负责展示层控制。敏感字段(手机号、身份证、薪资、银行卡号等)的原文不应返回给无权限用户,仅靠前端隐藏列或脱敏不能算安全控制。
┌────────────────────────────────────────────────────────┐
│ 列级权限分两层 │
│ │
│ 后端字段级控制(安全层,必须): │
│ - 无权限时不返回该字段 │
│ - 或直接返回脱敏后的值(如 138****1234) │
│ - 敏感字段原文绝不返回给无权限用户 │
│ │
│ 前端展示控制(体验层,辅助): │
│ - 根据权限决定是否显示该列 │
│ - 显示脱敏提示(如 "无权查看完整信息") │
│ - 提供"申请查看原文"入口 │
└────────────────────────────────────────────────────────┘
// utils/columnPermission.ts
import { usePermissionStore } from '@/stores/permission';
interface ColumnConfig {
prop: string;
label: string;
permission?: string; // 查看该列需要的权限码
sensitive?: boolean; // 是否敏感字段
desensitizePermission?: string; // 脱敏查看权限码
width?: number | string;
[key: string]: unknown;
}
/**
* 根据权限过滤表格列
* 1. 无 permission 或有权限 → 显示
* 2. 有 permission 但无权限 → 隐藏
*/
export function filterColumns<T extends ColumnConfig>(columns: T[]): T[] {
const permissionStore = usePermissionStore();
return columns.filter((col) => {
if (!col.permission) return true;
return permissionStore.hasPermission(col.permission);
});
}
/** 脱敏处理函数 */
const DESENSITIZE_MAP: Record<string, (val: string) => string> = {
phone: (val) => val.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
idCard: (val) => val.replace(/(\d{3})\d{11}(\d{4})/, '$1***********$2'),
email: (val) => val.replace(/(.{2}).*(@.*)/, '$1***$2'),
bankCard: (val) => val.replace(/(\d{4})\d{8,12}(\d{4})/, '$1********$2'),
name: (val) =>
val.length <= 2
? val[0] + '*'
: val[0] + '*'.repeat(val.length - 2) + val[val.length - 1],
};
/** 对敏感字段做脱敏 */
export function desensitize(
value: string,
type: keyof typeof DESENSITIZE_MAP
): string {
const handler = DESENSITIZE_MAP[type];
return handler ? handler(value) : value;
}
/**
* 创建带脱敏的列渲染器
* 有脱敏权限显示原文,否则显示脱敏值
*/
export function createSensitiveColumn(
type: keyof typeof DESENSITIZE_MAP,
permissionCode: string
) {
const permissionStore = usePermissionStore();
const canViewOriginal = permissionStore.hasPermission(permissionCode);
return (value: string) =>
canViewOriginal ? value : desensitize(value, type);
}
使用示例:
<template>
<el-table :data="userList">
<el-table-column prop="name" label="姓名" />
<el-table-column prop="phone" label="手机号">
<template #default="{ row }">
{{ formatPhone(row.phone) }}
</template>
</el-table-column>
<el-table-column v-if="showSalary" prop="salary" label="薪资">
<template #default="{ row }">
{{ formatSalary(row.salary) }}
</template>
</el-table-column>
<el-table-column v-if="showIdCard" prop="idCard" label="身份证号">
<template #default="{ row }">
{{ formatIdCard(row.idCard) }}
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { usePermissionStore } from '@/stores/permission';
import { createSensitiveColumn } from '@/utils/columnPermission';
import { PERM } from '@/constants/permission-codes';
const permissionStore = usePermissionStore();
const showSalary = computed(() => permissionStore.hasPermission(PERM.USER_SALARY_VIEW));
const showIdCard = computed(() => permissionStore.hasPermission(PERM.USER_IDCARD_VIEW));
const formatPhone = createSensitiveColumn('phone', PERM.USER_PHONE_ORIGINAL);
const formatSalary = createSensitiveColumn('name', PERM.USER_SALARY_ORIGINAL);
const formatIdCard = createSensitiveColumn('idCard', PERM.USER_IDCARD_ORIGINAL);
</script>
十、多租户权限隔离
10.1 多租户架构
┌────────────────────────────────────────────────────────┐
│ 多租户架构 │
│ │
│ 租户 A ──→ 域名 a.app.com ──→ 租户配置 A │
│ 租户 B ──→ 域名 b.app.com ──→ 租户配置 B │
│ 租户 C ──→ 路径 /c/app ──→ 租户配置 C │
│ │
│ 每个租户: │
│ - 独立的角色体系 │
│ - 独立的菜单配置 │
│ - 独立的数据隔离 │
│ - 可能不同的功能模块 │
└────────────────────────────────────────────────────────┘
10.2 租户 Store
// stores/tenant.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
interface TenantConfig {
id: string;
name: string;
logo: string;
theme: {
primaryColor: string;
sidebarStyle: 'dark' | 'light';
};
features: string[]; // 启用的功能模块
maxUsers: number;
customDomain?: string;
}
export const useTenantStore = defineStore('tenant', () => {
const currentTenant = ref<TenantConfig | null>(null);
/** 判断功能模块是否启用 */
const isFeatureEnabled = computed(() => {
return (feature: string) =>
currentTenant.value?.features.includes(feature) ?? false;
});
/** 初始化租户信息 */
async function initTenant(tenantId: string) {
const config = await fetchTenantConfig(tenantId);
currentTenant.value = config;
// 应用租户主题
applyTenantTheme(config.theme);
}
return {
currentTenant,
isFeatureEnabled,
initTenant,
};
});
10.3 租户识别策略
// utils/tenant.ts
/** 从域名识别租户 */
export function resolveTenantFromDomain(): string | null {
// 子域名方案:tenant.app.com → tenant
const hostname = window.location.hostname;
const parts = hostname.split('.');
if (parts.length > 2) {
return parts[0];
}
return null;
}
/** 从路径识别租户 */
export function resolveTenantFromPath(): string | null {
// 路径方案:/t/tenantId/xxx
const match = window.location.pathname.match(/^\/t\/([^/]+)/);
return match?.[1] ?? null;
}
/** 从 localStorage 恢复租户 */
export function resolveTenantFromStorage(): string | null {
return localStorage.getItem('tenant_id');
}
/** 综合识别租户 */
export function resolveTenant(): string {
return (
resolveTenantFromDomain() ??
resolveTenantFromPath() ??
resolveTenantFromStorage() ??
'default'
);
}
10.4 租户与权限联动
// 初始化流程:先识别租户,再加载权限
async function bootstrap() {
// 1. 识别租户
const tenantId = resolveTenant();
const tenantStore = useTenantStore();
await tenantStore.initTenant(tenantId);
// 2. 加载权限(请求头带上租户标识)
const permissionStore = usePermissionStore();
await permissionStore.initApp();
// 3. 注册动态路由
addDynamicRoutes();
}
十一、权限缓存与实时更新
11.1 权限缓存策略
┌────────────────────────────────────────────────────────┐
│ 权限缓存分层 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 内存 Store │ │ HttpOnly Cookie│ │ BFF 模式 │ │
│ │ (accessToken)│ │(refreshToken) │ │ (最安全) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ★ accessToken:优先内存存储,页面刷新通过 refresh 恢复 │
│ ★ refreshToken:优先 HttpOnly + Secure + SameSite │
│ Cookie,由后端控制,前端 JavaScript 无法读取 │
│ ★ 高安全场景:采用 BFF 模式,前端不直接持有长期 Token │
│ ★ 禁止将 Token 存入 localStorage / sessionStorage, │
│ 同源下任意 JS 均可读取,存在 XSS 泄露风险 │
│ ★ 如业务限制必须使用 Storage,需配合严格的 XSS 防护、 │
│ 短有效期和刷新机制,并明确标注风险 │
└────────────────────────────────────────────────────────┘
// utils/auth.ts
/**
* Token 管理策略
*
* - accessToken:内存存储,页面刷新后通过 /auth/refresh 接口恢复
* - refreshToken:由后端通过 HttpOnly + Secure + SameSite=Strict Cookie 管理
* 前端 JavaScript 无法读取,天然防 XSS 窃取
*
* 安全提醒:
* OWASP 明确建议不要将 authentication tokens 存入 localStorage / sessionStorage,
* 因为同源下任意 JavaScript 都能访问它们。如业务限制必须使用 Storage,
* 需配合严格的 XSS 防护、短有效期和刷新机制,并明确标注风险。
*/
let memoryAccessToken = '';
/** 获取 accessToken(内存优先) */
export function getToken(): string {
return memoryAccessToken;
}
/** 设置 accessToken(仅存内存) */
export function setToken(token: string) {
memoryAccessToken = token;
}
/** 清除 accessToken */
export function removeToken() {
memoryAccessToken = '';
}
/**
* 页面刷新后恢复 accessToken
* 调用 /auth/refresh 接口,后端通过 HttpOnly Cookie 中的 refreshToken 签发新 accessToken
* 如果 refreshToken 也过期,后端返回 401,前端跳转登录页
*/
export async function refreshAccessToken(): Promise<string> {
const { token } = await http.post<{ token: string }>('/auth/refresh');
memoryAccessToken = token;
return token;
}
11.2 权限实时更新
管理员修改了某用户的权限后,该用户需要实时感知。三种方案:
┌──────────────────────────────────────────────────────────┐
│ 权限实时更新方案对比 │
├──────────┬──────────────────┬────────────────────────────┤
│ 方案 │ 实现方式 │ 适用场景 │
├──────────┼──────────────────┼────────────────────────────┤
│ 轮询 │ 定时请求权限接口 │ 简单场景,实时性要求不高 │
│ WebSocket │ 服务端推送变更 │ 实时性要求高 │
│ 路由切换 │ 每次导航重新校验 │ 最安全,但请求较多 │
└──────────┴──────────────────┴────────────────────────────┘
方案一:轮询刷新:
// composables/usePermissionRefresh.ts
import { onMounted, onUnmounted } from 'vue';
import { usePermissionStore } from '@/stores/permission';
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 分钟
export function usePermissionRefresh() {
const permissionStore = usePermissionStore();
let timer: ReturnType<typeof setInterval> | null = null;
async function refreshPermissions() {
try {
await permissionStore.initApp(true);
} catch {
console.warn('[Permission] 权限刷新失败');
}
}
onMounted(() => {
timer = setInterval(refreshPermissions, REFRESH_INTERVAL);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
}
方案二:WebSocket 推送:
// composables/usePermissionPush.ts
import { onMounted, onUnmounted } from 'vue';
import { usePermissionStore } from '@/stores/permission';
import { useWebSocket } from '@/composables/useWebSocket';
export function usePermissionPush() {
const permissionStore = usePermissionStore();
const { subscribe, disconnect } = useWebSocket();
function handlePermissionChange(data: { type: string; payload: unknown }) {
if (data.type === 'PERMISSION_CHANGED') {
permissionStore.initApp(true);
}
if (data.type === 'FORCE_LOGOUT') {
permissionStore.reset();
window.location.href = '/login';
}
}
onMounted(() => {
subscribe('permission', handlePermissionChange);
});
onUnmounted(() => {
disconnect();
});
}
方案三:路由切换时轻量校验:
// router/guard.ts(在全局守卫中增加)
router.beforeEach(async (to) => {
// ... 登录检查 ...
// 每次路由切换都重新校验权限版本号
if (permissionStore.isInitialized) {
const { version } = await http.get<{ version: number }>('/api/permission/version');
if (version !== permissionStore.permissionVersion) {
// 版本不一致,重新拉取全量权限
await permissionStore.initApp(true);
}
}
return true;
});
11.3 多标签页同步
同一浏览器多个标签页,A 标签退出登录后 B 标签应同步:
// composables/useCrossTabSync.ts
import { onMounted, onUnmounted } from 'vue';
import { usePermissionStore } from '@/stores/permission';
export function useCrossTabSync() {
const permissionStore = usePermissionStore();
function handleStorageChange(event: StorageEvent) {
if (event.key === 'access_token') {
if (!event.newValue) {
// Token 被清除(其他标签页退出登录)
permissionStore.reset();
window.location.href = '/login';
}
}
if (event.key === 'permission_version') {
// 权限版本变更,重新拉取
permissionStore.initApp(true);
}
}
onMounted(() => {
window.addEventListener('storage', handleStorageChange);
});
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
});
}
十二、权限与微前端
12.1 微前端权限共享架构
┌──────────────────────────────────────────────────────────┐
│ 微前端权限共享架构 │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 基座应用 │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 权限 Store(单一数据源) │ │ │
│ │ │ - token / roles / permissions / menus │ │ │
│ │ └──────────────────┬───────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────┼──────────┐ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │子应用A │ │子应用B │ │子应用C │ │ │
│ │ │用户管理│ │订单系统│ │报表中心│ │ │
│ │ └────────┘ └────────┘ └────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ 共享方式: │
│ 1. props 传递(qiankun) │
│ 2. CustomEvent 通信 │
│ 3. 共享 Store(Module Federation 单例共享) │
└──────────────────────────────────────────────────────────┘
12.2 基座向子应用传递权限
qiankun 方案:
// 基座:micro-apps.ts
import { registerMicroApps, start } from 'qiankun';
import { usePermissionStore } from '@/stores/permission';
/**
* qiankun 的 props 必须是对象,不能是函数
* 通过 getAuthState 函数让子应用在需要时获取最新权限状态
* (权限可能因实时推送而变更,子应用应主动拉取而非缓存)
*/
registerMicroApps([
{
name: 'user-admin',
entry: '//localhost:8001',
container: '#sub-container',
activeRule: '/user',
props: {
getAuthState: () => {
const permissionStore = usePermissionStore();
return {
token: permissionStore.token,
roles: permissionStore.roleCodes,
permissions: Array.from(permissionStore.permissionCodes),
menus: permissionStore.menuList,
};
},
},
},
{
name: 'order-system',
entry: '//localhost:8002',
container: '#sub-container',
activeRule: '/order',
props: {
getAuthState: () => {
const permissionStore = usePermissionStore();
return {
token: permissionStore.token,
roles: permissionStore.roleCodes,
permissions: Array.from(permissionStore.permissionCodes),
menus: permissionStore.menuList,
};
},
},
},
]);
start();
子应用接收权限:
// 子应用:main.ts
import { createApp } from 'vue';
import { useSubPermissionStore } from './stores/permission';
let app: ReturnType<typeof createApp>;
interface AuthState {
token: string;
roles: string[];
permissions: string[];
menus: MenuItem[];
}
export async function mount(props: {
getAuthState: () => AuthState;
}) {
// 通过 getAuthState 获取最新权限状态
const authState = props.getAuthState();
const permissionStore = useSubPermissionStore();
permissionStore.setPermissions(authState.permissions);
permissionStore.setRoles(authState.roles);
app = createApp(App);
app.mount('#sub-app');
}
export async function unmount() {
app?.unmount();
}
12.3 子应用权限独立 vs 共享
| 维度 | 共享(基座统一管理) | 独立(子应用自管) |
|---|---|---|
| 一致性 | 全局统一,体验一致 | 各子应用可能不一致 |
| 维护成本 | 基座维护,子应用零成本 | 每个子应用都要实现 |
| 灵活性 | 子应用无法自定义 | 子应用可扩展 |
| 适用场景 | 中后台、企业内部系统 | 开放平台、第三方集成 |
推荐:基座统一管理权限数据,子应用通过接口获取,不做独立权限逻辑。
十三、安全加固:前端权限的不可信本质
13.1 前端权限的攻击面
┌──────────────────────────────────────────────────────────┐
│ 前端权限的攻击面 │
│ │
│ 攻击方式 防御措施 │
│ ─────────────────────────────────────────────────────── │
│ 1. 修改 localStorage → Token 加密存储 + 签名校验 │
│ 2. 浏览器 DevTools → 后端必须校验,前端只是体验层 │
│ 3. 直接请求后端接口 → 后端接口权限校验(必须) │
│ 4. 伪造权限码 → 权限码从后端获取,不前端硬编码 │
│ 5. URL 直接访问 → 路由守卫 + 后端 403 │
│ 6. 接口参数篡改 → 后端参数校验 + 数据权限过滤 │
└──────────────────────────────────────────────────────────┘
13.2 安全加固清单
// utils/security.ts
/**
* 1. Token 安全存储
* - 优先存内存,避免 XSS 读取
* - SessionStorage 次选(关闭标签即清除)
* - 禁止存 Cookie(除非 HttpOnly)
* - 禁止存 LocalStorage(除非加密)
*/
/**
* 2. 权限码不可篡改
* - 权限码由后端下发,前端不硬编码
* - 每次敏感操作前,后端重新校验
*/
/**
* 3. 接口请求携带 Token
*/
import { getToken } from '@/utils/auth';
http.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
/**
* 4. 401 / 403 统一处理
*/
http.interceptors.response.use(
(response) => response,
(error) => {
const { status } = error.response ?? {};
if (status === 401) {
handleTokenExpired();
} else if (status === 403) {
router.push('/403');
}
return Promise.reject(error);
}
);
/**
* 5. XSS 防护:权限码不渲染到 DOM
* - v-permission 指令只做移除/禁用,不把权限码写入 DOM 属性
* - 菜单数据不暴露到全局变量
*/
/**
* 6. CSRF 防护
* - 使用 Bearer Token 而非 Cookie
* - 如必须用 Cookie,添加 CSRF Token
*/
13.3 IDOR 越权防护
IDOR(Insecure Direct Object Reference)是最常见的越权漏洞:
┌────────────────────────────────────────────────────────┐
│ IDOR 越权攻击示例 │
│ │
│ 攻击: │
│ GET /api/users/123 ← 正常访问自己的信息 │
│ GET /api/users/456 ← 篡改 ID,访问他人信息 │
│ │
│ 防御: │
│ 后端必须校验当前用户是否有权访问 ID=456 的资源 │
│ 前端无法防御,只能后端兜底 │
│ │
│ 前端配合: │
│ - 不在 URL 中暴露自增 ID,使用 UUID │
│ - 敏感操作二次确认 │
│ - 操作日志记录 │
└────────────────────────────────────────────────────────┘
13.4 后端必须做的事
┌────────────────────────────────────────────────────────┐
│ 后端权限校验(必须) │
│ │
│ 每个接口必须: │
│ 1. 校验 Token 有效性 │
│ 2. 校验用户角色是否有权访问该接口 │
│ 3. 校验数据权限(行级过滤) │
│ 4. 校验操作权限(如删除需要 delete 权限) │
│ 5. 参数校验(防止 IDOR 越权访问) │
│ │
│ ★ 核心原则:即使前端绕过了所有检查, │
│ 后端也能拦截非法请求 │
└────────────────────────────────────────────────────────┘
十四、测试策略
14.1 权限 Store 测试
// stores/__tests__/permission.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { usePermissionStore } from '../permission';
describe('PermissionStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
describe('hasPermission', () => {
it('超级管理员拥有所有权限', () => {
const store = usePermissionStore();
store.$patch({
roles: [{ id: '1', code: 'super_admin', name: '超管', permissions: [] }],
permissionCodes: new Set(),
});
expect(store.hasPermission('any:permission')).toBe(true);
});
it('普通用户检查权限码', () => {
const store = usePermissionStore();
store.$patch({
roles: [{ id: '2', code: 'editor', name: '编辑', permissions: [] }],
permissionCodes: new Set(['article:create', 'article:edit']),
});
expect(store.hasPermission('article:create')).toBe(true);
expect(store.hasPermission('article:delete')).toBe(false);
});
});
describe('hasAnyPermission', () => {
it('满足任一权限即返回 true', () => {
const store = usePermissionStore();
store.$patch({
roles: [{ id: '2', code: 'editor', name: '编辑', permissions: [] }],
permissionCodes: new Set(['article:edit']),
});
expect(store.hasAnyPermission(['article:create', 'article:edit'])).toBe(true);
expect(store.hasAnyPermission(['article:delete', 'article:export'])).toBe(false);
});
});
describe('hasAllPermissions', () => {
it('必须全部满足', () => {
const store = usePermissionStore();
store.$patch({
roles: [{ id: '2', code: 'editor', name: '编辑', permissions: [] }],
permissionCodes: new Set(['article:edit', 'article:create']),
});
expect(store.hasAllPermissions(['article:edit', 'article:create'])).toBe(true);
expect(store.hasAllPermissions(['article:edit', 'article:delete'])).toBe(false);
});
});
describe('reset', () => {
it('清除所有状态', () => {
const store = usePermissionStore();
store.$patch({
token: 'test-token',
roles: [{ id: '1', code: 'admin', name: '管理员', permissions: [] }],
permissionCodes: new Set(['test:perm']),
isInitialized: true,
});
store.reset();
expect(store.token).toBe('');
expect(store.roles).toEqual([]);
expect(store.permissionCodes.size).toBe(0);
expect(store.isInitialized).toBe(false);
});
});
});
14.2 路由守卫测试
// router/__tests__/guard.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createRouter, createMemoryHistory, type Router } from 'vue-router';
import { setupRouterGuard } from '../guard';
import { usePermissionStore } from '@/stores/permission';
describe('Router Guard', () => {
let router: Router;
beforeEach(async () => {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/login', name: 'Login', component: { render: () => null } },
{ path: '/403', name: 'Forbidden', component: { render: () => null } },
{ path: '/dashboard', name: 'Dashboard', component: { render: () => null } },
],
});
setupRouterGuard(router);
});
it('未登录访问非白名单页面 → 跳转登录', async () => {
const permissionStore = usePermissionStore();
permissionStore.reset();
await router.push('/dashboard');
expect(router.currentRoute.value.path).toBe('/login');
});
it('已登录访问 /login → 跳转首页', async () => {
const permissionStore = usePermissionStore();
permissionStore.$patch({ token: 'valid-token' });
await router.push('/login');
expect(router.currentRoute.value.path).toBe('/');
});
});
14.3 权限指令测试
// directives/__tests__/permission.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { defineComponent, h } from 'vue';
import { vPermission } from '../permission';
import { usePermissionStore } from '@/stores/permission';
describe('vPermission', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('有权限时保留元素', () => {
const store = usePermissionStore();
store.$patch({
roles: [{ id: '1', code: 'admin', name: '管理员', permissions: [] }],
permissionCodes: new Set(['user:delete']),
});
const Comp = defineComponent({
template: '<button v-permission="\'user:delete\'">删除</button>',
directives: { permission: vPermission },
});
const wrapper = mount(Comp);
expect(wrapper.find('button').exists()).toBe(true);
});
it('无权限时移除元素', () => {
const store = usePermissionStore();
store.$patch({
roles: [{ id: '1', code: 'viewer', name: '查看者', permissions: [] }],
permissionCodes: new Set(),
});
const Comp = defineComponent({
template: '<div><button v-permission="\'user:delete\'">删除</button></div>',
directives: { permission: vPermission },
});
const wrapper = mount(Comp);
expect(wrapper.find('button').exists()).toBe(false);
});
it('disable 修饰符:无权限时禁用而非移除', () => {
const store = usePermissionStore();
store.$patch({
roles: [{ id: '1', code: 'viewer', name: '查看者', permissions: [] }],
permissionCodes: new Set(),
});
const Comp = defineComponent({
template: '<button v-permission.disable="\'user:edit\'">编辑</button>',
directives: { permission: vPermission },
});
const wrapper = mount(Comp);
const btn = wrapper.find('button');
expect(btn.exists()).toBe(true);
expect(btn.attributes('disabled')).toBeDefined();
});
});
14.4 E2E 测试
// e2e/permission.spec.ts
import { test, expect } from '@playwright/test';
test.describe('权限系统', () => {
test('普通用户看不到管理员菜单', async ({ page }) => {
// 以普通用户登录
await page.goto('/login');
await page.fill('[data-test="username"]', 'viewer');
await page.fill('[data-test="password"]', 'password');
await page.click('[data-test="submit"]');
// 等待页面加载
await page.waitForURL('/dashboard');
// 检查菜单中不包含"系统管理"
const menuItems = page.locator('.el-menu-item, .el-sub-menu__title');
const texts = await menuItems.allTextContents();
expect(texts).not.toContain('系统管理');
});
test('直接访问无权限路由 → 403', async ({ page }) => {
// 以普通用户登录
await loginAs(page, 'viewer');
// 直接访问管理员页面
await page.goto('/system/user');
await page.waitForURL('/403');
expect(page.url()).toContain('/403');
});
test('管理员可以看到所有菜单', async ({ page }) => {
await loginAs(page, 'admin');
await page.waitForURL('/dashboard');
const menuItems = page.locator('.el-menu-item, .el-sub-menu__title');
const texts = await menuItems.allTextContents();
expect(texts).toContain('系统管理');
});
});
十五、踩坑清单与最佳实践
15.1 踩坑清单
| # | 坑 | 原因 | 解决方案 |
|---|---|---|---|
| 1 | 动态路由 404 | addRoute 后当前导航还没生效 |
next({ ...to, replace: true }) 重新导航 |
| 2 | 刷新页面白屏 | 动态路由丢失,路由守卫未重新注册 | 守卫中判断 isInitialized,未初始化则重新拉取 |
| 3 | 退出登录路由残留 | 动态路由未清除 | 退出时调用 resetRouter() 移除动态路由 |
| 4 | 权限码硬编码 | 前端到处写死字符串 | 集中定义 PERM 常量对象 |
| 5 | v-permission 移除后无法恢复 | 指令 mounted 只执行一次 |
切换用户时需重新渲染组件,或使用 <Auth> 组件 |
| 6 | Set 序列化丢失 | JSON.stringify(new Set()) → {} |
持久化时 Array.from() 转换 |
| 7 | 超管判断遗漏 | 只检查了 hasPermission 忘记超管跳过 |
hasPermission 内部统一处理超管 |
| 8 | 菜单与路由不一致 | 菜单过滤了但路由没过滤,或反之 | 菜单和路由使用同一份数据源 |
| 9 | Token 过期未处理 | 401 响应未清除状态 | 响应拦截器统一处理 401 |
| 10 | 多标签页不同步 | A 标签退出,B 标签仍可用 | storage 事件监听 + Token 校验 |
15.2 权限码常量管理
// constants/permission-codes.ts
/**
* 权限码常量
* 命名规则:模块:资源:操作
*
* 按模块分组,方便维护和检索
*/
export const PERM = {
// ─── 用户管理 ───
USER_LIST: 'system:user:list',
USER_CREATE: 'system:user:create',
USER_EDIT: 'system:user:edit',
USER_DELETE: 'system:user:delete',
USER_EXPORT: 'system:user:export',
USER_SALARY_VIEW: 'system:user:salary',
USER_IDCARD_VIEW: 'system:user:idcard',
USER_PHONE_ORIGINAL: 'system:user:phone:original',
USER_IDCARD_ORIGINAL: 'system:user:idcard:original',
USER_SALARY_ORIGINAL: 'system:user:salary:original',
// ─── 角色管理 ───
ROLE_LIST: 'system:role:list',
ROLE_CREATE: 'system:role:create',
ROLE_EDIT: 'system:role:edit',
ROLE_DELETE: 'system:role:delete',
// ─── 订单管理 ───
ORDER_LIST: 'order:list',
ORDER_DETAIL: 'order:detail',
ORDER_EDIT: 'order:edit',
ORDER_DELETE: 'order:delete',
ORDER_EXPORT: 'order:export',
ORDER_AUDIT: 'order:audit',
// ─── 报表中心 ───
REPORT_VIEW: 'report:view',
REPORT_EXPORT: 'report:export',
REPORT_DOWNLOAD: 'report:download',
} as const;
/** 权限码类型 */
export type PermissionCode = (typeof PERM)[keyof typeof PERM];
15.3 最佳实践总结
┌──────────────────────────────────────────────────────────┐
│ 前端权限最佳实践 │
│ │
│ 1. 后端兜底:前端权限只是体验层,后端必须校验 │
│ 2. 单一数据源:权限数据集中在一个 Store │
│ 3. 初始化门控:isInitialized 标志确保数据就绪 │
│ 4. 权限码常量化:集中定义,禁止散落硬编码 │
│ 5. Set 存储:权限码用 Set 而非 Array,O(1) 查找 │
│ 6. 超管统一处理:hasPermission 内部处理超管跳过 │
│ 7. 路由与菜单同源:使用同一份数据生成路由和菜单 │
│ 8. 退出清理:退出登录时清除路由、Store、Token │
│ 9. 多标签同步:storage 事件监听 + Token 校验 │
│ 10. 安全优先:Token 内存存储,敏感操作后端二次校验 │
└──────────────────────────────────────────────────────────┘
15.4 完整初始化流程
// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { setupRouterGuard } from './router/guard';
import { vPermission } from './directives/permission';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
// 注册权限指令
app.directive('permission', vPermission);
// 注册路由守卫
setupRouterGuard(router);
app.mount('#app');
// App.vue
<script setup lang="ts">
import { onMounted } from 'vue';
import { usePermissionStore } from '@/stores/permission';
import { useCrossTabSync } from '@/composables/useCrossTabSync';
import { usePermissionRefresh } from '@/composables/usePermissionRefresh';
const permissionStore = usePermissionStore();
// 多标签页同步
useCrossTabSync();
// 权限定时刷新
usePermissionRefresh();
onMounted(async () => {
// 如果有 Token,初始化权限数据
if (permissionStore.isLoggedIn && !permissionStore.isInitialized) {
try {
await permissionStore.initApp();
} catch {
permissionStore.reset();
}
}
});
</script>
<template>
<router-view />
</template>
总结
前端权限系统是一个横切关注点,贯穿路由、组件、指令、数据层。核心要点:
- 模型选择:RBAC 适合 90% 场景,ABAC 补充细粒度控制,混合模型最实用
- 单一数据源:权限数据集中在 Store,避免多处维护
- 分层控制:路由级 → 菜单级 → 按钮级 → 数据级,层层递进
- 安全底线:前端权限是体验优化,后端校验是安全兜底,两者缺一不可
- 工程化:权限码常量化、Store 标准化、指令/组件复用、测试覆盖
权限系统没有银弹,但遵循上述架构和原则,可以构建出安全、可维护、可扩展的前端权限体系。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)