1. 背景与动机
在微服务架构中,我们经常需要在方法层面进行细粒度权限校验,比如“家庭管理员才能添加成员”、“家长只能查看自己家庭的宝宝数据”。Spring Security 提供了 @PreAuthorize 注解,允许通过 SpEL 表达式声明权限规则。
当我们尝试扩展 SpEL 表达式,添加类似 familyRoleGe('MANAGER') 的自定义函数时,最容易想到的是继承 MethodSecurityExpressionRoot 并替换默认的 MethodSecurityExpressionHandler。然而这种方式在 Spring Security 6.x 中容易因 Bean 冲突导致自定义根不生效,且涉及内部机制,维护成本高。
本文提出一种更简单、更可靠、更符合 Spring Security 官方推荐的最佳实践:将自定义权限逻辑抽入 Spring Bean,通过 @beanName.method() 在 SpEL 中直接调用。
2. 方案对比
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 自定义表达式根 | 继承 MethodSecurityExpressionRoot,注册自定义 MethodSecurityExpressionHandler | 自定义函数与内置函数写法一致,如 familyRoleGe('ADMIN') | 容易与 Spring Security 自动配置产生 Bean 冲突;需实现多个内部接口;升级/维护风险高 |
| SpEL Bean 注入(推荐) | 将权限方法放入 @Component Bean,通过 @bean.method() 调用 | 零侵入,无 Bean 冲突;完全解耦;便于单元测试;Spring 官方文档推荐 | 表达式写法稍长,需加 @ 引用 Bean |
显然,第二种方案在稳定性、可维护性和可测试性上全面占优。
3. 核心思想
我们不改变 Spring Security 内部的表达式求值流程,而是利用 SpEL 本身就支持访问 Spring 容器中任何 Bean 的特性。只需:
- 创建一个普通的 Spring Bean(例如命名为
sec),提供用于权限判断的公共方法。 - 在
@PreAuthorize注解中通过@sec.method(args)调用这些方法。 - 方法内部从
SecurityContextHolder获取当前用户信息,执行业务校验。
这样,我们就完全避开了对 MethodSecurityExpressionHandler 的定制,与 Spring Security 的默认行为无缝集成。
4. 实现步骤
4.1 定义权限评估 Bean
package cn.net.yunlou.common.security;
import cn.net.yunlou.common.IBaseEnum;
import cn.net.yunlou.common.context.UserContext;
import cn.net.yunlou.common.enums.FamilyRoleEnum;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Component("sec")
public class SecurityEvaluator {
/**
* 从 SecurityContext 中提取当前用户上下文
*/
private UserContext getCtx() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getDetails() instanceof UserContext ctx) {
return ctx;
}
return null;
}
/** 用户等级 >= 指定值 */
public boolean userLevelGe(int requiredLevel) {
UserContext ctx = getCtx();
return ctx != null && ctx.getUserLevel() >= requiredLevel;
}
/** 是否属于指定家庭 */
public boolean belongsToFamily(Long familyId) {
UserContext ctx = getCtx();
return ctx != null && familyId != null && familyId.equals(ctx.getFamilyId());
}
/** 家庭角色数值是否 >= 指定角色(字符串枚举名) */
public boolean familyRoleGe(String requiredRole) {
FamilyRoleEnum roleEnum = IBaseEnum.valueOf(requiredRole, FamilyRoleEnum.class);
if (roleEnum == null) return false;
int requiredValue = roleEnum.getValue();
UserContext ctx = getCtx();
return ctx != null && ctx.getFamilyRole() != null && ctx.getFamilyRole() >= requiredValue;
}
/** 是否属于某家庭且角色满足 */
public boolean hasFamilyAccess(Long familyId, String requiredRole) {
return belongsToFamily(familyId) && familyRoleGe(requiredRole);
}
/** 是否拥有管理员角色(可混合内置权限) */
public boolean isAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") ||
a.getAuthority().equals("ROLE_ROOT"));
}
/** 管理员或指定家庭角色(常用组合) */
public boolean isAdminOrFamilyRole(Long familyId, String requiredRole) {
return isAdmin() || hasFamilyAccess(familyId, requiredRole);
}
}
4.2 保留 Spring Security 默认配置(无需自定义表达式处理器)
@Configuration
@EnableMethodSecurity // 启用方法安全,使用默认的表达式处理器
public class InternalSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, HandlerExceptionResolver resolver) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> resolver.resolveException(req, res, null, e))
.accessDeniedHandler((req, res, e) -> resolver.resolveException(req, res, null, e))
);
return http.build();
}
// 确保 InternalAuthFilter 将 UserContext 设置到 Authentication.details 中
}
关键点:不再声明
MethodSecurityExpressionHandlerBean,完全使用 Spring Boot 自动配置的默认处理器。
4.3 在 Controller 中使用
@RestController
@RequestMapping("/api/family")
public class FamilyController {
@PostMapping("/{familyId}/member")
@PreAuthorize("@sec.hasFamilyAccess(#familyId, 'MANAGER')")
public R<Void> addMember(@PathVariable Long familyId, @RequestBody AddMemberRequest request) {
// 仅家庭管理员及以上可调用
return R.ok();
}
@GetMapping("/{familyId}/data")
@PreAuthorize("hasAnyRole('ROOT','ADMIN') or @sec.hasFamilyAccess(#familyId, 'MEMBER')")
public R<List<BabyData>> getBabyData(@PathVariable Long familyId) {
// 后台管理员或该家庭任何角色(MEMBER 以上)都能访问
return R.ok(babyService.listByFamily(familyId));
}
@PutMapping("/{familyId}/settings")
@PreAuthorize("@sec.isAdminOrFamilyRole(#familyId, 'ADMIN')")
public R<Void> updateSettings(@PathVariable Long familyId, @RequestBody SettingsRequest request) {
// 管理员或家庭创建者/管理员
return R.ok();
}
}
5. 优势总结
- 无冲突:不使用自定义
ExpressionRoot,不会与 Spring Security 内部自动配置打架。 - 易维护:所有权限逻辑集中在一个 Bean 中,修改或新增方法只需添加一个
public方法,不影响其他代码。 - 可测试:Bean 方法可单独进行单元测试,不依赖 Web 上下文。
- 灵活性高:可在 SpEL 中自由组合内置权限检查(
hasRole、hasAuthority)与自定义业务检查。 - 官方推荐:Spring Security 文档明确指出“使用 Bean 引用是扩展表达式的首选方式”。
6. 迁移指南(从自定义 ExpressionRoot 迁移)
- 删除自定义
CustomSecurityExpressionRoot和CustomMethodSecurityExpressionHandler类。 - 删除
@Bean MethodSecurityExpressionHandler的声明。 - 将原表达式根中的方法复制到
SecurityEvaluatorBean 中,并确保方法能从SecurityContextHolder获取用户上下文。 - 全局替换注解表达式:
familyRoleGe('ADMIN')→@sec.familyRoleGe('ADMIN')hasFamilyAccess(#id, 'MEMBER')→@sec.hasFamilyAccess(#id, 'MEMBER')
- 重启服务,所有安全检查将无缝切换,零风险。
7. 常见问题
Q:@sec Bean 是如何被表达式引擎发现的? A:SpEL 的 @ 语法直接委托给 BeanFactory 查找同名 Bean,只要你的 Bean 被 Spring 管理(@Component 或 @Bean),就能自动发现。
Q:方法必须为 public 吗? A:是的,SpEL 反射调用要求方法是 public,参数类型需与表达式传入一致。
Q:如何在表达式中传递参数? A:使用 #paramName 引用方法参数名,Spring Security 默认开启 -parameters 编译选项(Spring Boot 项目通常无需额外配置)。若使用 @Param 注解,也可通过 @Param 的 value 引用。
Q:可以同时使用 hasRole 和 @sec 吗? A:完全支持,组合使用没有任何限制,如 hasRole('ADMIN') or @sec.hasFamilyAccess(#id, 'MEMBER')。
8. 结语
通过 Spring Bean 注入实现方法安全扩展,我们可以在零侵入的前提下获得强大的自定义权限判断能力。该模式已在 Spring Security 官方文档中推荐,并经大量项目验证,是现代化微服务权限控制的最佳实践。建议在所有需要方法级授权的模块中推广使用。