尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot 3.x方法级安全实战:@PreAuthorize与SpEL深度解析

SpringBoot 3.x方法级安全实战:@PreAuthorize与SpEL深度解析 先说个我自己的经历。之前接手一个老项目接口层的鉴权写得还算规整URL 拦了一大堆但内部服务层基本是“裸奔”状态。后来安全审计发现一个越权漏洞普通用户只要知道接口路径就能绕过页面按钮直接调用管理员的删除方法。那一次排查下来我意识到光靠WebSecurityConfig里那几条 AntPath 规则根本不够真正精细的权限控制必须下沉到方法级别。也就是从那时候开始PreAuthorize和 SpEL 成了我每个 Spring Boot 项目里必配的一套组合拳。这篇文章就围绕 SpringBoot 3.x 这套体系把方法级安全这块从头到尾捋一遍。不是只贴几个注解就完事我会把PreAuthorize的表达式语法、SpEL 到底怎么解析、Spring Security 6.x 里有哪些坑、以及自定义权限校验器怎么写全部拆开来讲保证你看完能直接用到自己项目里。适合谁看已经会用 Spring Security 做登录认证、但还没系统搞过方法级权限控制的后端开发以及那些被hasRole和hasAuthority搞懵、不知道参数怎么传进 SpEL 表达式的同学。1. 先想清楚什么时候才需要方法级安全1.1 接口层过滤的盲区Spring Security 最常见的配置方式是在SecurityFilterChain里写Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).hasRole(USER) .anyRequest().authenticated() ); return http.build(); }这套配置能挡住大多数“按 URL 粗粒度划分”的场景但有两个明显盲区第一URL 规则和业务方法之间不是一一对应的。同一个 Service 方法可能被多个 Controller 调用你在接口层配了权限不代表每个入口都覆盖到了。比如OrderService.cancelOrder()这个方法用户端可以调用管理后台也能调用但管理后台的取消订单可能要求更高权限。如果你只在 URL 层配了/admin/**那用户端的那个入口就漏了。第二越权场景防不住。最典型的就是 IDORInsecure Direct Object References用户传一个orderId后端只校验“是否登录”却没校验“这个订单是不是这个用户的”。这种业务规则层面的权限判断接口层的过滤器根本做不了——它只能看 URL 和方法看不到方法入参。1.2 PreAuthorize 到底解决什么问题方法级安全的核心思想很简单在调用目标方法之前先执行一段权限校验逻辑校验不过就抛异常阻止方法体执行。Spring 的实现方式是基于 AOP 的拦截器PreAuthorize注解里写的表达式会在方法被代理调用时求值。这种方式的优势非常明显权限判断和业务逻辑放在一起可读性高方法上扫一眼就知道谁能用可以直接引用方法参数做对象级别的细粒度校验校验逻辑可以复用同一个 Service 方法无论从哪个入口进来保护力度都一样所以我的经验是URL 拦截用来做粗粒度入口控制方法级权限用来做精细业务规则。两者协同而不是互相替代。2. 环境准备与开启方法安全2.1 依赖引入SpringBoot 3.x 对应的是 Spring Security 6.x用法上和 5.x 最大的区别在配置类的写法。先引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency如果你用了 Spring Boot 的父子工程或 BOM 管理不需要写版本号Boot 3.2.x 默认带的 Security 6.2.x 就支持下面这一套。2.2 EnableMethodSecurity 三个开关Spring Security 6.x 里开启方法安全用EnableMethodSecurity它替代了 5.x 里的EnableGlobalMethodSecurity。基础配置如下Configuration EnableMethodSecurity public class SecurityConfig { }就这样加上之后你的PreAuthorize就能用了。但这个注解其实还有三个重要属性需要根据场景选配属性默认值作用prePostEnabledtrue开启PreAuthorize和PostAuthorizesecuredEnabledfalse开启Secured老式注解jsr250Enabledfalse开启RolesAllowedJSR-250 标准注解如果你只是用PreAuthorize默认就够。但如果团队里有老代码用了Secured或RolesAllowed就需要分别打开开关。还有一个细节Spring Security 6.x 默认开启了代理配置但注意——EnableMethodSecurity是基于 Spring AOP 的默认只拦截 public 方法。你写private方法上注解或者同一个类内部this调用都不会生效这点后面坑的部分还会细讲。3. PreAuthorize 核心用法拆解3.1 基于角色的判断hasRole 与 hasAnyRole最简单的用法就是限制方法只能被某个角色调用PreAuthorize(hasRole(ADMIN)) public void deleteUser(Long userId) { // 业务逻辑 }hasRole(ADMIN)的功能是判断当前用户是否具备ADMIN角色。但这里有个 Spring Security 6.x 的隐藏规则要搞清楚尤其是从 5.x 升级过来的同学很容易踩坑。在 Spring Security 5.x 里hasRole(ADMIN)会自动给参数加上ROLE_前缀也就是实际比对的是ROLE_ADMIN。在 6.x 里这个行为默认依旧如此但如果你用了 OAuth2 资源服务器那一套角色的声明方式可能带SCOPE_前缀此时hasRole就会对不上。多个角色用或关系判断就用hasAnyRolePreAuthorize(hasAnyRole(ADMIN, OPERATOR)) public void batchImport(ListFile files) { // 业务逻辑 }3.2 基于权限字符串的判断hasAuthority 与 hasAnyAuthority角色和权限的区别很多新手容易搞混。我的理解是这样的角色是对用户身份的一种“画像”比如ADMIN、USER而权限是对操作的一种“描述”比如user:delete、order:export。生产系统中建议把判断粒度细化到权限字符串上因为角色会随组织结构调整而变动而权限描述相对稳定。hasAuthority的用法PreAuthorize(hasAuthority(user:delete)) public void deleteUser(Long userId) { // 业务逻辑 }注意hasAuthority不会加任何前缀它直接比对当前用户持有的权限集合。如果你在数据库里给用户分配的角色是ROLE_ADMIN那hasAuthority(ROLE_ADMIN)也能匹配上但通常不建议这么混用保持一种风格会让排查更容易。实际项目里我更喜欢把权限字符串设计成“资源:操作”的格式比如system:user:addsystem:user:editsystem:user:deleteorder:export这种设计在审计时特别直观看一眼权限代码就知道是干什么的。3.3 多条件组合与逻辑运算实际业务的权限判断几乎都不是单一条件。比如“管理员可以删运营人员可以删自己创建的”就需要组合表达式PreAuthorize(hasRole(ADMIN) or hasRole(OPERATOR) and #userId #root.principal.id) public void deleteContent(Long userId) { // 业务逻辑 }这里涉及两个 SpEL 操作符优先级的问题and的优先级高于or所以上面这条表达式的实际含义是hasRole(ADMIN) or (hasRole(OPERATOR) and #userId #root.principal.id)。如果需要更复杂的括号嵌套直接加括号就行SpEL 完全支持。另外还有取反操作PreAuthorize(!hasRole(TEMP_USER)) public void publishArticle(Article article) { // 业务逻辑 }我还见过一种很实用的组合就是“放行 细化”PreAuthorize(permitAll() and #article.authorId authentication.principal.id)permitAll()本来不过滤任何人但配合and后面的条件就能实现“所有人可以访问但只能操作自己的数据”这种灵活效果。4. SpEL 表达式深度解析4.1 表达式里能拿到什么变量PreAuthorize里的字符串本质是 SpELSpring Expression Language表达式Spring Security 在方法调用前会创建专门的求值上下文。在这个上下文里你能访问以下对象#root当前被调用的方法对象及目标对象authentication当前认证信息包含principal用户对象、credentials、authorities等principal直接从 authentication 里取出的用户实体#参数名访问当前方法入参beanName引用 Spring 容器中的其他 Bean这个就是 SpEL 最强大的地方它不是简单死板的字符串匹配而是一个完整的表达式语言。只要你能获得对象引用就可以调用对象上的任意 public 方法。比如我有这样一个方法PreAuthorize(permissionService.checkProjectMember(#projectId, authentication.principal.username)) public Project getProjectDetail(Long projectId) { // 业务逻辑 }表达式的含义是调用 Spring 容器里permissionService这个 Bean 的checkProjectMember方法传两个参数——方法的projectId入参和当前登录用户名。返回true就放行false就拦截。这种通过beanName引用自定义 Bean 的方式是实际项目中用得最多的玩法具体怎么写我放到后面自定义校验器部分详细展示。4.2 方法入参的四种引用姿势4.2.1 编译参数名直接引用最自然的写法直接通过参数名引用PreAuthorize(#username authentication.principal.username) public void updateProfile(String username) { // 业务逻辑 }但这有一个隐藏前提项目必须开启 Java 编译参数保留。如果你用 Maven 构建spring-boot-starter-parent默认会开启-parameters编译参数所以 Spring Boot 项目基本没问题。如果是非 Spring Boot 项目或者自定义父 POM需要显式配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/parameters /configuration /plugin否则表达式里写方法实际参数名是找不到的会报Property or field username cannot be found on object这类错。4.2.2 使用 P 注解绑定参数名如果编译参数没开启或者你担心参数名被混淆可以用P注解给表达式指定别名PreAuthorize(perm.check(#userId)) public void deleteUser(P(userId) Long userId) { // 业务逻辑 }不过说句实话Spring Boot 项目里P用得很少因为编译参数默认就是开启的而且P要从spring-security-core里单独引入显得有点啰嗦。我一般只在写公共组件、不确定调用方编译配置时才用它。4.2.3 通过 authentication 获取当前用户方法入参不仅仅是形参authentication是表达式里自带的引用不需要你传入。比如PreAuthorize(#order.ownerId authentication.principal.id) public Order getOrder(Order order) { // 业务逻辑 }这里authentication.principal的类型就是你在登录认证时设置的用户对象。如果你的 Principal 不是自定义对象而是字符串用户名那就要写成authentication.principal直接比较。4.2.4 使用 #root 取注解所在方法信息#root指向的是MethodSecurityExpressionOperations或类似的方法安全上下文通过它可以拿到拦截的Method对象。这个在实现比较“动态”的权限规则时很管用PreAuthorize(permissionService.checkByMethod(#root)) public void dynamicOperation() { // 业务逻辑 }自定义 BeancheckByMethod内部可以通过反射获取方法上的自定义注解再结合当前用户的权限进行精细判断。这种姿势比较适合有权限模型动态配置需求的中后台系统但反射调用有一定性能开销建议高频接口慎用低频管理操作无所谓。4.3 自定义校验 Bean 的实战这是全篇最值得细看的部分。直接用hasRole这些内置表达式业务规则稍微复杂就写不进去所以实践中一定要搭一个自己的权限校验器。定义一个普通 Spring BeanComponent(ss) public class PermissionService { /** * 判断是否有某个权限 */ public boolean hasPerm(String permission) { Authentication authentication SecurityContextHolder.getContext().getAuthentication(); if (authentication null) { return false; } return authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .anyMatch(auth - auth.equals(permission)); } /** * 判断是否项目成员 */ public boolean isProjectMember(Long projectId, String username) { ProjectMember member projectMemberMapper.selectByProjectIdAndUsername(projectId, username); return member ! null; } /** * 判断是否为数据归属人 */ public boolean isOwner(Object data, String username) { if (data instanceof BaseEntity) { BaseEntity entity (BaseEntity) data; return username.equals(entity.getCreateBy()); } return false; } }然后方法上直接引用PreAuthorize(ss.hasPerm(system:project:edit)) public void editProject(Long projectId) { // 业务逻辑 } PreAuthorize(ss.isProjectMember(#projectId, authentication.principal.username)) public ProjectDetail projectDetail(Long projectId) { // 业务逻辑 } PreAuthorize(ss.isOwner(#entity, authentication.principal.username)) public void updateEntity(BaseEntity entity) { // 业务逻辑 }这个ss校验器的好处是权限判断逻辑收拢在一个类里后续要对接规则引擎、决策表都好扩展表达式可读性极高业务方法上写的东西几乎接近自然语言测试好写直接 mockSecurityContext然后单测校验器即可5. 方法安全背后的原理与常见坑5.1 方法安全是怎么拦下来的理解原理排查问题才能有方向。Spring Security 的方法安全底层依赖三条链入口AuthorizationManagerBeforeMethodInterceptor在方法调用前执行校验。代理Spring AOP 生成代理对象老的TargetSource加Advisor组合目标方法被拦截。异常翻译校验失败会抛出AccessDeniedException由全局异常处理器转换为 403 响应。大概的拦截顺序是请求进入 Controller 层如果 Controller 或 Service 方法上有PreAuthorizeAOP 代理触发拦截器拦截器拿到注解里的 SpEL 表达式放进MethodSecurityExpressionHandler求值表达式返回false抛出AccessDeniedException全局RestControllerAdvice捕获并返回 JSON 结构有个点值得注意同一个类里的方法互相调用无法触发代理。比如Service public class OrderService { public void createOrder(OrderDTO dto) { // 业务逻辑 this.cancelOrder(dto.getId()); // 这里直接 this 调用PreAuthorize 不生效 } PreAuthorize(ss.hasPerm(order:cancel)) public void cancelOrder(Long orderId) { // 业务逻辑 } }由于this.cancelOrder()走的是 this 引用不是代理对象所以方法安全拦截器根本不会执行。解决方式有两种把cancelOrder拆到另一个 Service或者注入自身代理Lazy OrderService self通过self.cancelOrder()调用。5.2 五个高频坑第一PreAuthorize只对 public 方法生效。Spring AOP 基于动态代理private 和 protected 方法无法被代理拦截。如果确实需要保护私有方法考虑把逻辑抽到独立 Bean 中。第二SpEL 表达式里直接写字符串比较要小心引号。缺失引号会导致解析异常这个错误在注解里很难第一时间从编译期发现通常要到运行时拦截才发现。第三异步方法无法使用PreAuthorize。Async方法走的是独立的线程池调用方法安全上下文不一定能传递过去。建议在异步方法入口处手动用SecurityContextHolder校验权限。第四权限变更后SecurityContext里的Authentication不会自动刷新。如果你改动了用户的角色或权限集合需要手动更新SecurityContextHolder中的认证信息否则hasAuthority判断的还是旧数据。这种情况在做“用户列表页勾选角色”的场景里很常见。第五hasPermission是 Spring ACL 模块的底层接口默认需要额外集成 ACL 实现才能用别把它和hasAuthority混为一谈。如果没有引入spring-security-acl直接用这个表达式会报错。5.3 方法安全 接口层安全的分工一个完整的项目里我习惯这样划分安全层次工具负责内容认证层UsernamePasswordAuthenticationFilter、AuthenticationManager验证用户名密码建立登录态接口层SecurityFilterChain URL 规则粗粒度控制哪些路径需要登录哪些路径是固定角色方法层PreAuthorize 自定义校验器精细控制具体某个方法谁能调、传参是否符合业务规则数据层MyBatis-Plus 拦截器、数据权限插件行级数据过滤用户只能看到自己团队的数据层次之间相互配合但不是每条规则都要上全。接口层的 URL 规则要精简方法层的注解要精确到业务方法。如果所有逻辑都堆到SecurityFilterChain里维护成本会爆炸。6. 实战排查经验记录6.1 高频报错与排查路径写方法安全的时候最常见的报错基本集中在几个固定场景我整理了一张速查表报错现象可能原因处理方式注解完全不生效接口直接放行没有加EnableMethodSecurity或者方法不是 public或者同类型内部 this 调用检查配置类检查方法修饰符拆分逻辑到独立 Bean表达式解析报SpelEvaluationException参数名引用错误没有开启-parameters编译参数确认参数名和注解一致检查编译配置改用P绑定别名一直返回 403无法进入方法表达式判断返回false角色或权限字符串不匹配在自定义校验器中加日志确认当前用户的权限集合角色带不带ROLE_前缀总是搞混hasRole会自动加前缀hasAuthority不加统一用权限字符串避免两种混用Async方法里权限校验不生效方法安全上下文没有传递到异步线程手动在异步入口用SecurityContextHolder设置上下文再校验用了PreAuthorize但 Jar 包没有切面依赖缺少spring-boot-starter-aop引入spring-boot-starter-aop依赖排查这类问题我个人的固定套路是先确认前缀有没有加ROLE_再确认表达式里的参数到底能不能解析最后把SecurityContext的Authentication和GrantedAuthority列表打印出来。90% 的线上问题都是这三个原因。6.2 权限表达式的性能与安全建议SpEL 表达式每次方法调用都需要解析和执行虽然 Spring 有缓存机制但如果表达式特别复杂、方法调用频率又高还是会对性能有一点影响。所以我在实际项目中一般遵循这几条原则高频接口的权限校验规则不要写大段的 SpEL能精简就精简自定义校验器里避免做数据库慢查询权限判断越轻越好不要在表达式里做大量集合遍历把逻辑挪到 Java 代码里不要把敏感数据直接写进表达式防止日志泄露还有一条从安全角度出发的建议权限校验失败了必须明确抛出AccessDeniedException不要吞掉异常也不要返回 200 加个错误标志。前者会导致用户以为操作成功了后者会破坏调用方对 RESTful 语义的依赖。用全局异常处理器统一把这类异常转换成 HTTP 403前端统一处理整个系统的错误语义就很清晰。6.3 从老版本升级到 Spring Security 6.x 的适配点如果项目是从 Spring Boot 2.x 升级到 3.x方法安全这块有几点必须同步改EnableGlobalMethodSecurity替换为EnableMethodSecurityWebSecurityConfigurerAdapter写法废弃改成注册SecurityFilterChainBeanantMatchers改名为requestMatchers如果之前在表达式里用了hasIpAddress这类方法注意地址匹配库的依赖变化这些改动单独拎出来都不难但混在一起升级时容易顾此失彼。我建议升级后的第一件事就是写一个只包含PreAuthorize的测试接口验证注解是否正常拦截再进入业务回归。7. 测试方法级安全的最优姿势方法级安全不能只靠手工测一定要落到自动化测试里。Spring Security 提供了WithMockUser可以直接在测试类里模拟用户配合PreAuthorize做断言SpringBootTest AutoConfigureMockMvc class ProjectServiceTest { Autowired private ProjectService projectService; Test WithMockUser(username admin, roles {ADMIN}) void adminCanDeleteProject() { // 断言调用不抛异常 projectService.deleteProject(1L); } Test WithMockUser(username tom, roles {USER}) void userCannotDeleteProject() { assertThrows(AccessDeniedException.class, () - { projectService.deleteProject(1L); }); } }如果权限是通过authorities配置的写成WithMockUser(authorities {system:project:delete})。需要更复杂的用户对象时还可以实现WithUserDetails自定义注解来组装完整的 UserDetails。写方法安全的测试有一个原则每一个发生过生产事故的权限规则都必须有对应的自动化用例。这样下次重构或升级安全框架时权限规则是否被意外关闭跑一遍测试就知道。8. 写在最后的一点个人体会说几句实在话。方法级安全这套东西配置起来确实简单无非一个注解加一段表达式。但真正决定系统安全质量的是你在设计阶段有没有把权限模型想清楚角色和权限字段怎么设计权限字符串怎么命名用户和角色的多对多关系如何映射哪些规则的判断粒度要落到对象字段上。我用PreAuthorize这些年最大的感受是它逼着开发者把“谁能做什么”这个问题前置到编码阶段。以前写接口先写业务逻辑再想起要加权限判断现在设计方法签名时就要同步想清楚这个方法被调用的权限语义。哪怕最后权限规则有调整改的也只是一个注解表达式而不是散落的 if 判断。分享一个经验在团队项目里建议把自定义权限校验器的方法命名设计得贴近业务语义比如ss.isProjectMember(...)、ss.isOwner(...)这样非安全背景的开发同事也能一眼看明白表达式的含义避免出现“这个注解到底拦什么”的困惑。方法级安全还能配合后面的PostAuthorize、PreFilter、PostFilter扩展分别负责返回结果校验和集合过滤。等PreAuthorize玩熟之后这整套方法安全家族可以继续深入下去。
返回列表