前后端权限管理的学习

引言

本文记录了我在学习前后端权限管理过程中的关键知识点和实现示例。权限管理是 Web 应用中确保用户仅访问授权资源的核心机制,包括菜单权限(控制可见导航项)和功能权限(控制操作按钮或 API 访问)。前端实现侧重于用户体验优化(如隐藏无权限元素),而后端则负责严格的安全校验(如数据过滤和访问控制)。以下内容基于实际代码示例,突出核心步骤,并适当补充说明以提升可读性。

一、前端权限管理实现

1.1 菜单权限示例

需求:根据用户角色动态渲染菜单,避免硬编码,确保不同用户看到不同的菜单选项。

后端返回的用户对象中包含 menuPermissionList,其中一级菜单提供 name 和 icon,二级菜单提供 name、icon 和 url。示例 JSON 数据如下:

[
  {
       "id": 1,
       "name": "市场活动",
       "code": null,
       "url": null,
       "type": "menu",
       "parentId": 0,
       "orderNo": 1,
       "icon": "OfficeBuilding",
       "subPermissionList": [
          {
               "id": 2,
               "name": "市场活动",
               "code": null,
               "url": "/dashboard/activity",
               "type": null,
               "parentId": null,
               "orderNo": null,
               "icon": "CreditCard",
               "subPermissionList": null
          }
      ]
  },
  {
       "id": 10,
       "name": "线索管理",
       "code": null,
       "url": null,
       "type": "menu",
       "parentId": 0,
       "orderNo": 2,
       "icon": "Magnet",
       "subPermissionList": [
          {
               "id": 12,
               "name": "线索管理",
               "code": null,
               "url": "/dashboard/clue",
               "type": null,
               "parentId": null,
               "orderNo": null,
               "icon": "Paperclip",
               "subPermissionList": null
          }
      ]
  },
  {
       "id": 19,
       "name": "客户管理",
       "code": null,
       "url": null,
       "type": "menu",
       "parentId": 0,
       "orderNo": 3,
       "icon": "User",
       "subPermissionList": [
          {
               "id": 20,
               "name": "客户管理",
               "code": null,
               "url": "/dashboard/customer",
               "type": null,
               "parentId": null,
               "orderNo": null,
               "icon": "UserFilled",
               "subPermissionList": null
          }
      ]
  },
  {
       "id": 24,
       "name": "交易管理",
       "code": null,
       "url": null,
       "type": "menu",
       "parentId": 0,
       "orderNo": 4,
       "icon": "Wallet",
       "subPermissionList": [
          {
               "id": 25,
               "name": "交易管理",
               "code": null,
               "url": "/dashboard/tran",
               "type": null,
               "parentId": null,
               "orderNo": null,
               "icon": "Coin",
               "subPermissionList": null
          }
      ]
  },
  {
       "id": 28,
       "name": "产品管理",
       "code": null,
       "url": null,
       "type": "menu",
       "parentId": 0,
       "orderNo": 5,
       "icon": "Memo",
       "subPermissionList": [
          {
               "id": 29,
               "name": "产品管理",
               "code": null,
               "url": "/dashboard/product",
               "type": null,
               "parentId": null,
               "orderNo": null,
               "icon": "SetUp",
               "subPermissionList": null
          }
      ]
  },
  {
       "id": 35,
       "name": "字典管理",
       "code": null,
       "url": null,
       "type": "menu",
       "parentId": 0,
       "orderNo": 6,
       "icon": "Grid",
       "subPermissionList": [
          {
               "id": 36,
               "name": "字典类型",
               "code": null,
               "url": "/dashboard/dictype",
               "type": null,
               "parentId": null,
               "orderNo": null,
               "icon": "Postcard",
               "subPermissionList": null
          },
          {
               "id": 42,
               "name": "字典数据",
               "code": null,
               "url": "/dashboard/dicvalue",
               "type": null,
               "parentId": null,
               "orderNo": null,
               "icon": "DataAnalysis",
               "subPermissionList": null
          }
      ]
  },
  {
       "id": 48,
       "name": "用户管理",
       "code": null,
       "url": null,
       "type": "menu",
       "parentId": 0,
       "orderNo": 7,
       "icon": "Stamp",
       "subPermissionList": [
          {
               "id": 49,
               "name": "用户管理",
               "code": null,
               "url": "/dashboard/user",
               "type": null,
               "parentId": null,
               "orderNo": null,
               "icon": "User",
               "subPermissionList": null
          }
      ]
  },
  {
       "id": 55,
       "name": "系统管理",
       "code": null,
       "url": null,
       "type": "menu",
       "parentId": 0,
       "orderNo": 8,
       "icon": "Setting",
       "subPermissionList": [
          {
               "id": 56,
               "name": "系统管理",
               "code": null,
               "url": "/dashboard/system",
               "type": null,
               "parentId": null,
               "orderNo": null,
               "icon": "Tools",
               "subPermissionList": null
          }
      ]
  }
]

修改前,菜单代码采用硬编码方式,重复性高,不易维护:

<el-menu
   :default-active="currentRouterPath"
   class="el-menu-vertical-demo"
   :unique-opened="true"
   :collapse="isCollapse"
   :collapse-transition="false"
   :router="true">
 <!--市场活动开始-->
 <el-sub-menu index="1">
   <template #title>
     <el-icon>
       <DataLine/>
     </el-icon>
     <span>市场活动</span>
   </template>
   <el-menu-item index="/dashboard/activity">
     <el-icon>
       <Management/>
     </el-icon>
    市场管理
   </el-menu-item>
 </el-sub-menu>
 <!--市场活动结束-->
 <!--线索管理开始-->
 <el-sub-menu index="2">
   <template #title>
     <el-icon>
       <Key/>
     </el-icon>
     <span>线索管理</span>
   </template>
   <el-menu-item index="/dashboard/clue">
     <el-icon>
       <Management/>
     </el-icon>
    线索管理
   </el-menu-item>
 </el-sub-menu>
 <!--线索管理结束-->
 <!--客户管理开始-->
 <el-sub-menu index="3">
   <template #title>
     <el-icon>
       <User/>
     </el-icon>
     <span>客户管理</span>
   </template>
   <el-menu-item index="/dashboard/customer">
     <el-icon>
       <Management/>
     </el-icon>
    客户管理
   </el-menu-item>
 </el-sub-menu>
 <!--客户管理结束-->
 <!--交易管理开始-->
 <el-sub-menu index="4">
   <template #title>
     <el-icon>
       <Tickets/>
     </el-icon>
     <span>交易管理</span>
   </template>
   <el-menu-item index="4-1">
     <el-icon>
       <Management/>
     </el-icon>
    交易管理
   </el-menu-item>
 </el-sub-menu>
 <!--交易管理结束-->
 <!--产品管理开始-->
 <el-sub-menu index="5">
   <template #title>
     <el-icon>
       <Box/>
     </el-icon>
     <span>产品管理</span>
   </template>
   <el-menu-item index="5-1">
     <el-icon>
       <Management/>
     </el-icon>
    产品管理
   </el-menu-item>
 </el-sub-menu>
 <!--产品管理结束-->
 <!--字典管理开始-->
 <el-sub-menu index="6">
   <template #title>
     <el-icon>
       <Reading/>
     </el-icon>
     <span>字典管理</span>
   </template>
   <el-menu-item index="6-1">
     <el-icon>
       <Management/>
     </el-icon>
    字典管理
   </el-menu-item>
 </el-sub-menu>
 <!--字典管理结束-->
 <!--用户管理开始-->
 <el-sub-menu index="7">
   <template #title>
     <el-icon>
       <UserFilled/>
     </el-icon>
     <span>用户管理</span>
   </template>
   <el-menu-item index="/dashboard/user">
     <el-icon>
       <Management/>
     </el-icon>
    用户管理
   </el-menu-item>
 </el-sub-menu>
 <!--用户管理结束-->
 <!--系统管理开始-->
 <el-sub-menu index="8">
   <template #title>
     <el-icon>
       <Setting/>
     </el-icon>
     <span>系统管理</span>
   </template>
   <el-menu-item index="8-1">
     <el-icon>
       <Management/>
     </el-icon>
    系统管理
   </el-menu-item>
 </el-sub-menu>
 <!--系统管理结束-->
</el-menu>

修改后,使用 Vue 的 v-for 动态渲染菜单,基于 user.menuPermissionList 数据,代码更简洁、可维护性强:

<el-menu
   :default-active="currentRouterPath"
   class="el-menu-vertical-demo"
   :unique-opened="true"
   :collapse="isCollapse"
   :collapse-transition="false"
   :router="true">
 <el-sub-menu :index="menuPermission.id" v-for="menuPermission in user.menuPermissionList"
              :key="menuPermission.id">
   <template #title>
     <!--使用 component 来写入 icon -->
     <el-icon><component :is="menuPermission.icon"/></el-icon>

     <span>{{ menuPermission.name }}</span>
   </template>
   <el-menu-item :index="subMenuPermission.url"
                 v-for="subMenuPermission in menuPermission.subPermissionList"
                 :key="subMenuPermission.id">
     <el-icon><component :is="subMenuPermission.icon"/></el-icon>
    {{ subMenuPermission.name }}
   </el-menu-item>
 </el-sub-menu>
</el-menu>

同时,调整数据模型中的 user 结构,以匹配动态渲染需求:

user: {
 name: "",
 menuPermissionList: [
  {
     id: 0,
     name: "",
     icon:"",
     subPermissionList: [
      {
         id: 0,
         name: "",
         url: "",
         icon: "",
      }
    ],
  }
],
},

此实现确保菜单根据用户权限动态生成,提升了前端的灵活性和用户体验。

1.2 功能权限示例

需求:根据用户角色隐藏无权限的操作按钮(如删除按钮),类似于菜单权限,但针对具体功能点。

以隐藏删除按钮为例,使用 Vue 自定义指令实现:

  1. 创建自定义指令 在 main.js 的 app.mount 前添加指令 hasPermission。该指令检查用户权限列表,若无匹配权限,则移除元素(注:移除比隐藏更彻底,避免通过开发者工具绕过)。

    // el:指令所绑定到的页面 DOM 元素。这可以用于直接操作 DOM。
    // binding:是一个对象,里面包含很多属性,重点看 value 属性:传递给指令的值。我们传的是 clue:delete 这个值
    app.directive("hasPermission", (el, binding) => {
       // 这会在 `mounted` 和 `updated` 时都调用
       doGet("/api/login/info", {}).then(resp => {
           let user = resp.data.data;
           let permissionList = user.permissionList;

           let flag = false;

           for (let key in permissionList) {
               if (permissionList[key] === binding.value) {
                   flag = true;
                   break;
              }
          }
           if (!flag) {
               // 没有权限,把页面元素隐藏掉
               // el.style.display = 'none';
               // 把没有权限的按钮 DOM 元素删除
               el.parentNode && el.parentNode.removeChild(el)
          }
      })
    })
  2. 使用自定义指令 在按钮元素上应用指令,值对应数据库中的权限标识(如clue:delete)。

    <el-button type="danger" @click="del(scope.row.id,scope.row.fullName)" v-has-permission="'clue:delete'">删除</el-button>

此前端实现仅防“君子”(正常用户),无法阻止恶意绕过(如直接调用API)。因此,必须结合后端严格校验,以实现完整权限控制。

二、后端权限管理实现

2.1 数据范围权限示例(基于AOP)

需求:管理员可查看所有用户数据,而普通用户仅限查看自身数据。通过Spring AOP(Aspect-Oriented Programming,面向切面编程)动态注入SQL过滤条件,实现数据隔离。

使用 AspectJ 框架在 Mapper 方法上应用切面,针对非管理员用户追加 WHERE 子句。

步骤1:定义切面类

package com.sangui.aspect;


import com.sangui.common.DataScope;
import com.sangui.constant.Constants;
import com.sangui.model.TUser;
import com.sangui.util.JwtUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.util.List;

/**
* @Author: sangui
* @CreateTime: 2025-09-08
* @Description:
* @Version: 1.0
*/
@Aspect
@Component
public class DataScopeAspect {

   // aspectJ 实现 aop

   // 切入点,切在注解上
   @Pointcut(value = "@annotation(com.sangui.common.DataScope)")
   private void pointCut() {

  }

   @Around(value = "pointCut()")
   public Object process(ProceedingJoinPoint joinPoint) throws Throwable {
       MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();

       // 拿到方法上的注解
       DataScope dataScope = methodSignature.getMethod().getDeclaredAnnotation(DataScope.class);

       String tableAlias = dataScope.tableAlias();
       String tableField = dataScope.tableField();

       // 在 spring web 容器中,可以拿到当前请求的 request 对象
       HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

       String token = request.getHeader(Constants.TOKEN_NAME);
       // 从 token 中解析出该用户是管理员还是普通用户
       TUser tUser = JwtUtils.parseUserFromJwt(token);

       // 拿到用户的角色
       List<String> roleList = tUser.getRoleList();
       // System.out.println("roleList:" + roleList);
       Object[] args = joinPoint.getArgs();

       // 不包含 admin 角色,只查当前用户自己的数据,否则查所有数据
       if (!roleList.contains("admin")) {
           // 拿方法的第一个参数
           if (args.length > 0 && args[0] instanceof String) {
               String filterSql = (String) args[0];
               // 假设原 filterSql 可能已有条件,因此追加而非覆盖
               // 如果原设计是覆盖,可改为直接赋值;但根据描述,建议追加以支持其他过滤
               filterSql += " and " + tableAlias + "." + tableField + " = " + tUser.getId();
               args[0] = filterSql;
               // System.out.println("filterSql:" + filterSql);
          }
      }

       // System.out.println("目标方法执行之前....");
       Object result = joinPoint.proceed(args);
       // System.out.println("目标方法执行之后....");
       return result;
  }
}

步骤2:应用切面注解

在Mapper接口方法上添加 @DataScope 注解,并传入参数(如表别名和字段)。同时,调整 Service 方法以支持过滤参数。

package com.sangui.mapper;

import com.sangui.common.DataScope;
import com.sangui.model.TUser;
import org.springframework.security.core.Authentication;

import java.util.List;

/**
* @author sangui
*/
public interface TUserMapper {
   int deleteByPrimaryKey(Integer id);

   int insert(TUser record);

   int insertSelective(TUser record);

   TUser selectByPrimaryKey(Integer id);

   int updateByPrimaryKeySelective(TUser record);

   int updateByPrimaryKey(TUser record);

   TUser selectByLoginAct(String loginAct);

   // 加入切面,并在方法中加入参数
   @DataScope(tableAlias = "tu",tableField = "id")
   List<TUser> selectUserByPage(String filterSql);

   TUser selectByIdWithCreateAndEditUserName(Integer id);

   int deleteByIds(String ids);
}

步骤3:调整Mapper XML

在 XML 映射文件中使用表别名,并通过动态 WHERE 注入过滤条件:

<select id="selectUserByPage" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"/>
from t_user tu
<where>
${filterSql}
</where>
</select>

此 AOP 实现确保数据查询在源头过滤,管理员无限制,普通用户仅见自身数据,提升了安全性。

2.2 功能权限示例(基于注解)

需求:根据数据库中的用户权限,控制菜单和功能的可见性及可操作性。不同于数据范围权限,此处直接从权限源头阻断无授权访问,包括隐藏菜单和禁用功能(如删除、修改)。

此实现结合 Spring Security 的注解方式,确保后端严格校验。

步骤1:启用注解权限

在 Security 配置类中添加 @EnableMethodSecurity,开启方法级权限验证。

// 开启注解方式的权限验证
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
// ....代码略
}

步骤2:在Controller方法上添加权限注解

使用 @PreAuthorize 指定所需权限,值对应数据库权限标识。

package com.sangui.web;


import com.github.pagehelper.PageInfo;
import com.sangui.model.TClue;
import com.sangui.query.ClueQuery;
import com.sangui.result.R;
import com.sangui.service.ClueService;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

/**
* @Author: sangui
* @CreateTime: 2025-09-12
* @Description:
* @Version: 1.0
*/
@RestController
public class ClueController {
@Resource
ClueService clueService;

@PreAuthorize("hasAuthority('clue:list')")
@GetMapping("/api/clues")
public R getClues(@RequestParam(value = "current", required = false) Integer current) {
if (current == null) {
current = 1;
}
PageInfo<TClue> pageInfo = clueService.getCluesByPage(current);

return R.ok(pageInfo);
}

@PreAuthorize("hasAuthority('clue:view')")
@GetMapping("/api/clue/{id}")
public R getUserDetail(@PathVariable("id") Integer id) {
TClue tClue = clueService.getClueDetailById(id);
return R.ok(tClue);
}

@PreAuthorize("hasAuthority('clue:delete')")
@DeleteMapping("/api/clue/{id}")
public R delClue(@PathVariable("id") Integer id) {
int count = clueService.delClueById(id);
return count >= 1 ? R.ok() : R.fail();
}

@PreAuthorize("hasAuthority('clue:delete')")
@DeleteMapping("/api/clues")
public R batchDelClues(@RequestParam(value = "ids", required = false) String ids) {
int count = clueService.delCluesByIds(ids);
// System.out.println(ids);
int len = ids.split(",").length;
return count >= len ? R.ok() : R.fail();
}

/**
* 上传 Excel 的 控制器
*
* @param file 前端上传的文件,注意命名和前端一致
* @param authentication 自动注入,用于显示上传人信息
* @return 给前端提示上传状态
* @throws IOException IO 异常
*/
@PreAuthorize("hasAuthority('clue:import')")
@PostMapping("/api/import-excel")
public R importExcel(MultipartFile file, Authentication authentication) throws IOException {
clueService.uploadFile(file.getInputStream(), authentication);
return R.ok();
}

@PreAuthorize("hasAuthority('clue:add')")
@PostMapping("/api/clue")
public R addClue(ClueQuery clueQuery, Authentication authentication) {
//System.out.println("啊啊啊啊啊啊啊啊啊啊啊啊啊啊:" + clueQuery);
int count = clueService.addClue(clueQuery,authentication);
return count >= 1 ? R.ok() : R.fail();
}


@PreAuthorize("hasAuthority('clue:view')")
@GetMapping("/api/clue/full/{id}")
public R getClueFullDetail(@PathVariable("id")Integer id){
TClue tClue = clueService.getClueFullDetail(id);
return R.ok(tClue);
}

@PreAuthorize("hasAuthority('clue:edit')")
@PutMapping("/api/clue")
public R editClue(ClueQuery clueQuery,Authentication authentication) {
int count = clueService.editClue(clueQuery,authentication);
return count >= 1 ? R.ok() : R.fail();
}
}

步骤3:配置权限异常处理器

在 Security 配置的 securityFilterChain 构建前添加访问拒绝处理器。

.exceptionHandling((t)->{
t.accessDeniedHandler(myAccessDeniedHandler);
})

Spring Security 将根据登录时加载的权限信息自动校验方法调用。若无权限,后端拒绝请求。前端需配合隐藏按钮,形成“防君子”(前端隐藏)+“防小人”(后端校验)的完整体系。

结语

通过以上前后端权限管理示例,我加深了对动态菜单渲染、自定义指令、AOP 数据过滤和注解权限的理解。这些机制确保应用安全性和用户体验的平衡。实际开发中,可进一步集成RBAC(Role-Based Access Control,基于角色的访问控制)模型扩展功能。

  • 微信
  • 赶快加我聊天吧
  • QQ
  • 赶快加我聊天吧
  • weinxin
三桂

发表评论 取消回复 您未登录,登录后才能评论,前往登录