
1. 从认证到授权为什么说授权才是安全框架的真正分水岭做过Java后端的人都有一个共识认证和授权听起来像是一对孪生兄弟实际干起来完全是两码事。认证解决的是“你是谁”授权解决的是“你能干什么”。很多项目在认证环节做得滴水不漏JWT校验、Session管理、验证码、多因素登录一应俱全结果一到授权就拉胯——所有登录用户权限一刀切或者硬编码几个角色判断散落在各个Controller里改一个权限规则要翻遍整个代码库。Spring Security的Authorization模块就是专门收拾这个烂摊子的。它提供了一套从URL级别到方法级别、从角色判断到权限表达式、从静态配置到动态决策的完整授权体系。但问题在于这套体系的抽象层次比较高初学者容易在GrantedAuthority、AccessDecisionManager、AuthorizationManager、SecurityFilterChain这些概念之间迷路。我自己带团队的时候见过不少开发者认证部分写得漂漂亮亮一到授权就开始在Controller里写if (user.getRole().equals(ADMIN))等于把Spring Security最核心的价值给扔了。这篇内容适合已经用过Spring Security做认证、但对授权机制还是一知半解的后端开发者。我会从授权模型的设计思路讲起把Spring Security 6.x版本下的授权体系拆开揉碎配上可以直接跑的配置示例和踩坑记录。不管你是刚接触Spring Security的新手还是想从旧版本迁移到新版本的老手应该都能找到对自己有用的东西。2. 授权体系的核心设计Spring Security到底怎么想的2.1 授权模型的三个基本问题在聊具体实现之前先把Spring Security授权的底层逻辑理清楚。任何授权系统本质上都在回答三个问题第一谁在请求第二请求的是什么资源第三这个主体有没有权限访问这个资源Spring Security把这三个问题分别映射到了Authentication对象、RequestMatcher或方法签名和AuthorizationManager上。Authentication对象在认证成功后会被放入SecurityContextHolder里面包含了用户身份信息和权限列表Collection? extends GrantedAuthority。这个权限列表就是授权的判断依据。注意这里的GrantedAuthority是一个很纯粹的东西——它就是一个字符串Spring Security不关心你存的是ROLE_ADMIN还是user:read还是ORDER_DELETE它只负责在需要的时候拿出来比对。RequestMatcher负责匹配请求。在Web场景下它匹配的是HTTP方法和URL路径在方法级别它匹配的是方法签名和注解。Spring Security 6.x之后AuthorizationFilter取代了旧的FilterSecurityInterceptor配合AuthorizationManager来完成最终的决策。AuthorizationManager是Spring Security 5.5之后引入的新抽象用来替代旧的AccessDecisionManager和AccessDecisionVoter体系。它的核心方法就一个AuthorizationDecision check(SupplierAuthentication authentication, Object object)。返回一个AuthorizationDecision里面就一个布尔值表示通过还是不通过。这个设计比旧版简洁太多了旧版的投票器机制虽然灵活但配置起来极其繁琐一个简单的角色判断要写一堆RoleVoter、AuthenticatedVoter的配置。2.2 为什么从AccessDecisionManager迁移到AuthorizationManager我经历过从旧版迁移到新版的过程说实话旧版的AccessDecisionManager体系在复杂场景下确实强大但它的心智负担太重了。你需要理解AccessDecisionVoter的投票机制、AccessDecisionManager的决策策略AffirmativeBased、ConsensusBased、UnanimousBased还要搞清楚ConfigAttribute的解析过程。对于大多数项目来说这些抽象层次完全是过度设计。AuthorizationManager的设计哲学是“组合优于继承”。它提供了AuthorityAuthorizationManager、AuthenticatedAuthorizationManager、RequestMatcherDelegatingAuthorizationManager等实现类你可以通过RequestMatcher来组合不同的授权规则。比如http.authorizeHttpRequests(auth - auth .requestMatchers(/public/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(HttpMethod.POST, /api/orders/**).hasAuthority(order:write) .anyRequest().authenticated() );这段代码在Spring Security 6.x里是标准写法底层就是通过RequestMatcherDelegatingAuthorizationManager把每个RequestMatcher映射到一个AuthorizationManager实例上。permitAll()对应PermitAllAuthorizationManagerhasRole()对应AuthorityAuthorizationManagerauthenticated()对应AuthenticatedAuthorizationManager。每个AuthorizationManager只负责自己的判断逻辑职责非常清晰。2.3 角色与权限的区分ROLE_前缀到底怎么回事这是新手最容易混淆的地方。Spring Security里hasRole(ADMIN)和hasAuthority(ROLE_ADMIN)在效果上是等价的因为hasRole()会自动给参数加上ROLE_前缀。但hasAuthority(ADMIN)和hasRole(ADMIN)就不一样了前者要求用户的权限列表里有一个字符串恰好是ADMIN后者要求有一个字符串是ROLE_ADMIN。我个人的建议是如果你的系统角色体系比较简单直接用hasRole()让Spring Security帮你处理前缀。如果权限粒度比较细比如需要区分user:read、user:write、order:approve这种操作级权限那就统一用hasAuthority()把角色也当作一种权限来管理比如ROLE_ADMIN、ROLE_USER。这样整个权限模型是一致的不会出现角色和权限两套体系混用的情况。注意在Spring Security 6.x中hasRole()默认前缀仍然是ROLE_但可以通过GrantedAuthorityDefaults来修改。不过我不建议改这个默认值因为很多第三方库和示例代码都假设了这个前缀改了之后容易出一些莫名其妙的问题。3. 从配置到代码授权规则的落地方式3.1 基于URL的授权配置实战URL级别的授权是最常见的场景适合做粗粒度的访问控制。在Spring Security 6.x中配置方式从原来的WebSecurityConfigurerAdapter链式调用改成了SecurityFilterChainBean的定义方式。这个变化不仅仅是API的调整更重要的是它让配置变成了纯粹的Bean定义更容易理解和测试。Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/css/**, /js/**, /images/**).permitAll() .requestMatchers(/login, /register).permitAll() .requestMatchers(/api/public/**).permitAll() .requestMatchers(/api/user/**).hasAnyRole(USER, ADMIN) .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(HttpMethod.DELETE, /api/**).hasAuthority(data:delete) .anyRequest().authenticated() ) .formLogin(form - form.loginPage(/login).permitAll()) .logout(logout - logout.logoutUrl(/logout).permitAll()); return http.build(); } }这段配置里有一个关键点规则的顺序非常重要。Spring Security会按照声明的顺序依次匹配第一个匹配上的规则就会生效。所以permitAll()的规则一定要放在前面anyRequest()一定要放在最后。我见过有项目把anyRequest().authenticated()写在中间结果后面的规则全部失效排查了半天才发现是顺序问题。另外requestMatchers()的参数是可变长的可以一次传多个路径模式。路径匹配支持Ant风格/api/**和MVC风格/api/{id}在Spring Security 6.x中默认使用MvcRequestMatcher需要确保项目中引入了Spring MVC。如果是WebFlux项目则使用PathPatternRequestMatcher。3.2 方法级授权PreAuthorize的正确打开方式URL级别的授权有个天然缺陷它只能控制到接口层面无法控制到方法参数层面。比如一个/api/orders/{orderId}接口URL规则只能判断用户有没有访问订单接口的权限但没法判断用户有没有访问这个特定订单的权限。这时候就需要方法级授权出场了。要使用方法级授权首先要在配置类上加上EnableMethodSecurity注解。Spring Security 6.x中这个注解取代了旧的EnableGlobalMethodSecurity。默认情况下PreAuthorize、PostAuthorize、PreFilter、PostFilter都是启用的。Service public class OrderService { PreAuthorize(hasRole(ADMIN) or #userId authentication.principal.id) public ListOrder getOrdersByUser(Long userId) { return orderRepository.findByUserId(userId); } PreAuthorize(hasAuthority(order:approve)) public void approveOrder(Long orderId) { // 审批逻辑 } PostAuthorize(returnObject.userId authentication.principal.id or hasRole(ADMIN)) public Order getOrderDetail(Long orderId) { return orderRepository.findById(orderId).orElseThrow(); } }PreAuthorize里写的是SpEL表达式可以访问方法参数通过#参数名、authentication对象、principal对象等。PostAuthorize则是在方法执行之后再做权限判断适合那种需要先查出数据才能判断权限的场景。这里有个性能上的坑要注意PostAuthorize会导致方法先执行、再判断权限如果方法里有数据库写操作权限判断失败时数据已经写进去了。所以PostAuthorize只适合用在查询方法上写操作一律用PreAuthorize。3.3 自定义AuthorizationManager处理复杂业务规则当授权规则复杂到一定程度比如需要查数据库、调用外部服务、或者根据多个条件组合判断时SpEL表达式就不够用了。这时候就需要自定义AuthorizationManager。Component public class OrderAuthorizationManager implements AuthorizationManagerRequestAuthorizationContext { private final OrderRepository orderRepository; public OrderAuthorizationManager(OrderRepository orderRepository) { this.orderRepository orderRepository; } Override public AuthorizationDecision check(SupplierAuthentication authentication, RequestAuthorizationContext context) { Authentication auth authentication.get(); if (!auth.isAuthenticated()) { return new AuthorizationDecision(false); } String orderId context.getVariables().get(orderId); Order order orderRepository.findById(Long.valueOf(orderId)).orElse(null); if (order null) { return new AuthorizationDecision(false); } boolean isOwner order.getUserId().equals(auth.getName()); boolean isAdmin auth.getAuthorities().stream() .anyMatch(a - a.getAuthority().equals(ROLE_ADMIN)); return new AuthorizationDecision(isOwner || isAdmin); } }然后在配置中这样使用http.authorizeHttpRequests(auth - auth .requestMatchers(/api/orders/{orderId}) .access(new OrderAuthorizationManager(orderRepository)) .anyRequest().authenticated() );这个OrderAuthorizationManager会从请求路径中提取orderId变量然后查数据库判断当前用户是不是订单的所有者。这种细粒度的授权逻辑用SpEL表达式很难优雅地实现自定义AuthorizationManager是更合适的选择。实操心得自定义AuthorizationManager时一定要考虑缓存问题。如果每次请求都要查数据库在高并发场景下会成为性能瓶颈。我通常会在AuthorizationManager里加一层本地缓存比如Caffeine缓存key用userId resourceId过期时间设置短一些比如30秒既能减少数据库压力又不会导致权限变更后长时间不生效。4. 授权数据从哪来UserDetails与权限加载4.1 UserDetailsService的权限装配授权判断依赖Authentication对象里的权限列表而这个列表通常是在认证阶段由UserDetailsService加载的。UserDetails接口有一个getAuthorities()方法返回的就是权限集合。Service public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; private final PermissionRepository permissionRepository; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userRepository.findByUsername(username) .orElseThrow(() - new UsernameNotFoundException(用户不存在)); ListGrantedAuthority authorities new ArrayList(); // 加载角色 user.getRoles().forEach(role - authorities.add(new SimpleGrantedAuthority(ROLE_ role.getName())) ); // 加载细粒度权限 permissionRepository.findByUserId(user.getId()).forEach(perm - authorities.add(new SimpleGrantedAuthority(perm.getCode())) ); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.isEnabled(), true, true, true, authorities ); } }这段代码里角色和权限被统一放进了authorities集合。角色加上ROLE_前缀权限直接用权限码。这样在授权判断时hasRole(ADMIN)会匹配到ROLE_ADMINhasAuthority(user:read)会匹配到user:read。4.2 权限缓存与动态刷新每次请求都查数据库加载权限显然不现实。通常的做法是在UserDetailsService里加缓存或者用UserCache。Spring Security提供了UserCache接口和CachingUserDetailsService实现但更常见的做法是用Spring Cache注解。Cacheable(value userDetails, key #username) Override public UserDetails loadUserByUsername(String username) { // 加载逻辑 }但这里有个问题权限变更后缓存不会自动失效。如果管理员修改了某个用户的权限用户需要重新登录才能生效。对于权限变更不频繁的系统这通常可以接受。如果要求实时生效就需要在权限变更时主动清除缓存。我一般会在权限管理服务里注入CacheManager在权限变更后调用cacheManager.getCache(userDetails).evict(username)。另外如果用了JWT这种无状态认证方式权限信息是编码在Token里的修改权限后需要等Token过期或者强制用户重新登录。这是无状态认证的固有代价选择JWT的时候就要想清楚这个权衡。4.3 权限模型设计的实践经验权限模型的设计直接决定了授权代码的复杂度。我见过两种极端一种是权限粒度太粗只有ROLE_ADMIN和ROLE_USER两个角色结果所有业务逻辑都靠角色判断代码里到处是if (isAdmin())另一种是权限粒度太细每个操作都定义一个权限码结果权限表里几百条记录管理起来极其痛苦。我的经验是采用RBAC基于角色的访问控制模型角色和权限分离。角色是权限的集合用户关联角色。权限码的命名遵循资源:操作的格式比如order:read、order:write、order:delete。这样既保持了灵活性又不会让权限数量失控。对于数据级别的权限比如只能看自己的订单不要试图用权限码来表达而是用PreAuthorize里的SpEL表达式或者自定义AuthorizationManager来处理。权限码负责“能不能访问这个功能”数据权限负责“能访问哪些数据”两者分开管理逻辑会清晰很多。5. 常见问题与排查技巧实录5.1 授权不生效的典型原因授权配置写了但不起作用这是最常见的问题。根据我的排查经验原因通常集中在以下几个方面问题现象可能原因排查方法所有请求都放行permitAll()规则放在了最前面且匹配了所有路径检查requestMatchers的顺序和路径模式角色判断始终为false权限列表里没有ROLE_前缀打印Authentication.getAuthorities()确认方法级注解不生效缺少EnableMethodSecurity检查配置类注解方法级注解不生效方法被同类内部调用确认调用是否经过代理自定义AuthorizationManager不执行规则被前面的规则拦截调整规则顺序把自定义规则放前面方法级注解因为内部调用不生效这个问题特别隐蔽。比如一个Service类里方法A调用了方法B方法B上有PreAuthorize但通过方法A调用时权限判断不会执行因为调用没有经过Spring代理。解决办法是把方法B移到另一个Bean里或者通过AopContext.currentProxy()来调用。5.2 SpEL表达式中的常见错误PreAuthorize里的SpEL表达式写错了通常不会报编译错误而是在运行时抛异常或者静默失败。几个容易踩的坑#userId和#user.id的区别前者取方法参数名后者取参数对象的属性。如果参数名在编译时被擦除了没有加-parameters编译参数#userId会取不到值。解决办法是在Maven编译器插件里加上parameterstrue/parameters。hasRole(ADMIN)和hasAuthority(ROLE_ADMIN)混用效果一样但不要在一个表达式里混着写容易搞混。authentication.principal的类型如果是自定义的UserDetails实现需要强转或者用P注解指定类型。PreAuthorize(hasRole(ADMIN) or #user.id authentication.principal.id) public void updateUser(P(user) User user) { // P注解给参数起别名避免依赖编译参数 }5.3 授权失败的响应处理授权失败时Spring Security默认返回403页面或者JSON错误。但在前后端分离的项目里通常需要返回统一的JSON格式。这就需要自定义AccessDeniedHandler。Component public class CustomAccessDeniedHandler implements AccessDeniedHandler { Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\message\:\权限不足\}); } }然后在配置中注册http.exceptionHandling(ex - ex .accessDeniedHandler(customAccessDeniedHandler) );注意区分AccessDeniedHandler和AuthenticationEntryPoint前者处理已认证但权限不足的情况403后者处理未认证的情况401。两个都要配置否则前后端分离项目里会出现返回HTML登录页而不是JSON错误的情况。踩坑记录有一次项目里配置了AccessDeniedHandler但测试时发现权限不足仍然返回了默认的403页面。排查后发现是因为异常被全局异常处理器拦截了AccessDeniedException在到达AccessDeniedHandler之前就被ControllerAdvice处理掉了。解决办法是在全局异常处理器里把AccessDeniedException重新抛出去或者直接在全局异常处理器里处理权限不足的响应不依赖AccessDeniedHandler。6. 版本迁移与进阶方向6.1 从Spring Security 5.x迁移到6.x的注意事项Spring Security 6.x的变化不小迁移时几个关键点需要留意。首先是配置方式WebSecurityConfigurerAdapter被移除了必须改成SecurityFilterChainBean的方式。其次是authorizeRequests()改成了authorizeHttpRequests()虽然名字只差一个单词但底层的AuthorizationManager体系完全不同。还有antMatchers()改成了requestMatchers()mvcMatchers()也被合并到了requestMatchers()里。方法级安全注解方面EnableGlobalMethodSecurity被EnableMethodSecurity取代默认启用了PreAuthorize等注解不需要再显式指定prePostEnabled true。另外AuthenticationPrincipal的解析逻辑也有调整如果之前依赖了一些隐式行为迁移后可能需要显式配置。我建议迁移时不要一次性全改而是先升级依赖版本让项目跑起来然后逐步替换废弃的API。Spring Security 6.x在大部分场景下对5.x的配置是兼容的只是会打印废弃警告。利用这个过渡期一边跑测试一边改配置风险可控。6.2 动态权限与数据权限的扩展思路静态配置的授权规则适合大多数场景但如果系统需要支持管理员在界面上动态配置权限就需要把授权规则从代码里抽出来存到数据库。实现思路是自定义一个AuthorizationManager从数据库加载URL与权限的映射关系然后在check()方法里做匹配。数据权限的扩展则更复杂一些。除了前面提到的自定义AuthorizationManager方案还可以用Hibernate Filter或者MyBatis拦截器在SQL层面自动拼接数据权限条件。这种方案对业务代码侵入最小但实现难度较高需要仔细处理各种边界情况。我个人倾向于把数据权限逻辑放在Service层用PreAuthorize配合SpEL表达式来处理。虽然每个方法都要写注解但逻辑清晰、易于调试出了问题也容易定位。用拦截器自动拼接SQL虽然优雅但一旦出问题排查起来非常痛苦特别是涉及多表关联查询的时候。6.3 授权体系的测试策略授权逻辑的测试往往被忽视但它的重要性不亚于业务逻辑测试。我通常会用WithMockUser和WithUserDetails来模拟不同角色的用户配合MockMvc做集成测试。Test WithMockUser(roles ADMIN) void adminCanAccessAdminEndpoint() throws Exception { mockMvc.perform(get(/api/admin/users)) .andExpect(status().isOk()); } Test WithMockUser(authorities order:read) void userWithOrderReadCanAccess() throws Exception { mockMvc.perform(get(/api/orders)) .andExpect(status().isOk()); } Test WithMockUser void normalUserCannotAccessAdminEndpoint() throws Exception { mockMvc.perform(get(/api/admin/users)) .andExpect(status().isForbidden()); }对于自定义AuthorizationManager可以单独写单元测试直接构造Authentication对象和RequestAuthorizationContext调用check()方法验证返回值。这样测试不依赖Spring上下文跑起来很快。测试覆盖率方面我建议至少覆盖每个角色的正常访问、越权访问被拒绝、未认证访问被拒绝、权限变更后的行为。特别是越权访问的测试很多项目只测了正常流程结果上线后发现普通用户能访问管理员接口这种问题在测试阶段发现成本最低。授权体系的建设不是一蹴而就的它需要随着业务的发展不断调整。一开始可能只有简单的角色判断随着业务复杂度的提升逐步引入细粒度权限、数据权限、动态权限。关键是保持权限模型的清晰和一致不要今天用角色判断、明天用权限码、后天又在Controller里写硬编码。统一用Spring Security提供的抽象来表达授权规则代码的可维护性会好很多。