乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Spring Security 基础

    • 本章定位
    • Spring Security 是什么
    • DelegatingFilterProxy
    • 安全过滤器链
    • SecurityFilterChain
    • 过滤器执行顺序
  • 第2章 认证

    • 本章定位
    • Authentication
    • 认证流程
    • AuthenticationManager
    • ProviderManager
    • DaoAuthenticationProvider
    • UserDetails
    • UserDetailsService
    • PasswordEncoder
    • BCryptPasswordEncoder
    • DelegatingPasswordEncoder
    • 表单登录
    • SecurityContext
    • SecurityContextHolder
    • UsernamePasswordAuthenticationToken
  • 第3章 授权

    • 本章定位
    • 授权模型
    • GrantedAuthority
    • AccessDecisionManager
    • AccessDecisionVoter
    • URL 级别授权
    • 方法级别安全
    • @PreAuthorize
    • @PostAuthorize
    • @PreFilter
    • @PostFilter
    • @Secured
    • RoleHierarchy
  • 第4章 过滤器链

    • 本章定位
    • FilterChainProxy
    • SecurityContextHolderFilter
    • LogoutFilter
    • BasicAuthenticationFilter
    • CsrfFilter
    • CorsFilter
    • HeaderWriterFilter
    • AnonymousAuthenticationFilter
    • RequestCacheAwareFilter
    • ExceptionTranslationFilter
    • FilterSecurityInterceptor
  • 第5章 会话管理

    • 本章定位
    • 会话管理
    • SessionFixation
    • 会话并发控制
    • SessionCreationPolicy
    • RememberMe
  • 第6章 JWT

    • 本章定位
    • JWT
    • JwtDecoder
    • JWT 认证
    • JwtAuthenticationConverter
  • 第7章 OAuth2

    • 本章定位
    • OAuth2 基础
    • OAuth2 Client
    • OAuth2 Resource Server
    • 第三方登录配置
  • 第8章 攻击防护

    • 本章定位
    • CSRF 跨站请求伪造防护
    • CORS 跨域防护
    • Clickjacking 点击劫持防护
    • 安全响应头
    • Session Fixation 会话固定防护
  • 第9章 测试

    • 本章定位
    • 安全测试
    • @WithMockUser
    • 最佳实践

AccessDecisionVoter

一句话定义:访问决策投票者,负责对单一权限检查项进行投票(ACCESS_GRANTED / ACCESS_DENIED / ACCESS_ABSTAIN),由 AccessDecisionManager 汇总多个 Voter 的投票结果形成最终授权决策。


定义与作用

AccessDecisionVoter 是 Spring Security 授权投票机制的参与者。每个 Voter 只负责自己擅长的权限检查维度,通过投票表达态度。AccessDecisionManager 将 ConfigAttribute 分发给配置的 Voter 列表,收集投票结果后按策略裁决。

public interface AccessDecisionVoter<S> {
    int ACCESS_GRANTED = 1;   // 赞成
    int ACCESS_ABSTAIN = 0;   // 弃权
    int ACCESS_DENIED = -1;    // 反对

    int vote(Authentication authentication, S object,
             Collection<ConfigAttribute> attributes);

    boolean supports(ConfigAttribute attribute);
}

三种投票结果的含义:

结果值含义
ACCESS_GRANTED1当前 Voter 认为权限满足
ACCESS_DENIED-1当前 Voter 认为权限不满足
ACCESS_ABSTAIN0当前 Voter 不负责此类型权限,弃权

核心原理

三大内置 Voter

Spring Security 5.x 提供三个核心 Voter 实现,覆盖绝大多数场景:

各 Voter 详细解析

1. RoleVoter

public class RoleVoter implements AccessDecisionVoter<Object> {
    private String rolePrefix = "ROLE_";

    @Override
    public int vote(Authentication authentication, Object object,
                    Collection<ConfigAttribute> attributes) {
        if (authentication == null) {
            return ACCESS_DENIED;
        }
        int result = ACCESS_ABSTAIN;
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

        for (ConfigAttribute attribute : attributes) {
            if (this.supports(attribute)) {
                result = ACCESS_DENIED;  // 一旦找到支持的 attribute,默认改为 DENY
                for (GrantedAuthority authority : authorities) {
                    if (attribute.getAttribute().equals(authority.getAuthority())) {
                        return ACCESS_GRANTED;  // 找到匹配权限 → 通过
                    }
                }
            }
        }
        return result;
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        // 只处理 ROLE_ 前缀的 attribute
        return attribute.getAttribute() != null 
            && attribute.getAttribute().startsWith(rolePrefix);
    }
}

RoleVoter 的投票逻辑:

  • 遍历 ConfigAttribute,找到以 ROLE_ 开头的属性
  • 在 authentication.getAuthorities() 中查找是否有匹配的 GrantedAuthority
  • 找到匹配 → ACCESS_GRANTED
  • 找不到匹配 → ACCESS_DENIED
  • 没有 ROLE_ 前缀的属性 → ACCESS_ABSTAIN

2. AuthenticatedVoter

public class AuthenticatedVoter implements AccessDecisionVoter<Object> {
    public static final String IS_AUTHENTICATED_FULLY = "IS_AUTHENTICATED_FULLY";
    public static final String IS_AUTHENTICATED_REMEMBERED = "IS_AUTHENTICATED_REMEMBERED";
    public static final String IS_AUTHENTICATED_ANONYMOUSLY = "IS_AUTHENTICATED_ANONYMOUSLY";

    @Override
    public int vote(Authentication authentication, Object object,
                    Collection<ConfigAttribute> attributes) {
        int result = ACCESS_ABSTAIN;
        for (ConfigAttribute attribute : attributes) {
            if (IS_AUTHENTICATED_FULLY.equals(attribute.getAttribute())) {
                result = ACCESS_DENIED;
                if (authentication.isAuthenticated() && 
                    !(authentication instanceof RememberMeAuthenticationToken) &&
                    !(authentication instanceof AnonymousAuthenticationToken)) {
                    return ACCESS_GRANTED;
                }
            }
            // IS_AUTHENTICATED_REMEMBERED / IS_AUTHENTICATED_ANONYMOUSLY 类似...
        }
        return result;
    }
}

认证状态层级(从高到低):

IS_AUTHENTICATED_FULLY
    └── 排除 RememberMe + Anonymous
    └── 通过表单/ Basic / JWT 等完整认证

IS_AUTHENTICATED_REMEMBERED
    └── 包含 FULLY + RememberMe
    └── 不包含 Anonymous

IS_AUTHENTICATED_ANONYMOUSLY
    └── 包含 FULLY + RememberMe + Anonymous
    └── 任何已识别身份(包括匿名)

3. WebExpressionVoter

解析 Spring SpEL 表达式进行投票。HttpSecurity 中的 hasRole()、hasIpAddress()、hasAnyAuthority() 等最终都转换为 SpEL 表达式,由 WebExpressionVoter 评估。

// 配置中的 SpEL 表达式
.antMatchers("/admin/**").access("hasRole('ADMIN') and hasIpAddress('192.168.1.0/24')")

示例一:RoleVoter 的角色检查流程

场景说明

配置 antMatchers("/admin/**").hasRole("ADMIN"),观察 RoleVoter 如何投票。

操作前配置

@Configuration
@EnableWebSecurity
public class RoleVoterDemoConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(auth -> auth
                .antMatchers("/admin/**").hasRole("ADMIN")  // 转换为 ROLE_ADMIN
                .anyRequest().permitAll()
            )
            .formLogin(withDefaults());
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("admin").password("{noop}admin").roles("ADMIN")
            .and()
            .withUser("user").password("{noop}user").roles("USER");
    }
}

投票流程分析

结果分析

  • admin 用户访问 /admin/dashboard:

    • RoleVoter 检查 authentication.getAuthorities() 包含 ROLE_ADMIN → ACCESS_GRANTED
    • AuthenticatedVoter 对 ROLE_ADMIN 弃权 → ACCESS_ABSTAIN
    • AffirmativeBased 有 GRANTED → 放行
  • user 用户访问 /admin/dashboard:

    • RoleVoter 检查 authentication.getAuthorities() 包含 ROLE_ADMIN → 不包含 → ACCESS_DENIED
    • AffirmativeBased 无 GRANTED 有 DENY → 抛出 AccessDeniedException

示例二:AuthenticatedVoter 的认证状态检查

场景说明

敏感操作(如修改密码)要求用户必须是通过完整登录(而非 RememberMe)进入的。

操作前配置

@Configuration
@EnableWebSecurity
public class AuthVoterDemoConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(auth -> auth
                .antMatchers("/change-password").authenticated()  // 只要登录
                .antMatchers("/sensitive/**").hasRole("ADMIN")
                .anyRequest().permitAll()
            )
            .formLogin(withDefaults())
            .rememberMe(remember -> remember
                .key("uniqueAndSecretKey")
                .tokenValiditySeconds(86400)
            );
    }
}

操作后配置(使用 AuthenticatedVoter 精确控制)

@Configuration
@EnableWebSecurity
public class AuthVoterDemoConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(auth -> auth
                // 修改密码:必须是完整登录(拒绝 RememberMe)
                .antMatchers("/change-password")
                    .access("isAuthenticated() and not(rememberMe())")
                // 等价写法:使用 AuthenticatedVoter 支持的常量
                .antMatchers("/change-password-alt")
                    .access("IS_AUTHENTICATED_FULLY")
                .antMatchers("/sensitive/**").hasRole("ADMIN")
                .anyRequest().permitAll()
            )
            .formLogin(withDefaults())
            .rememberMe(remember -> remember
                .key("uniqueAndSecretKey")
                .tokenValiditySeconds(86400)
            );
    }
}

结果分析

用户状态访问 /change-password原因
表单登录后✅ 通过IS_AUTHENTICATED_FULLY 满足
RememberMe 自动登录❌ 403RememberMeAuthenticationToken 不满足 FULLY
匿名用户❌ 403AnonymousAuthenticationToken 不满足 FULLY

示例三:自定义 Voter(IP 白名单 + 角色双重检查)

场景说明

管理后台要求:拥有 ADMIN 角色 且 请求来自公司内网 IP。

操作前配置

public class IpAddressVoter implements AccessDecisionVoter<FilterInvocation> {

    private final List<String> allowedIpRanges;

    public IpAddressVoter(List<String> allowedIpRanges) {
        this.allowedIpRanges = allowedIpRanges;
    }

    @Override
    public int vote(Authentication authentication, FilterInvocation fi,
                    Collection<ConfigAttribute> attributes) {
        String requestIp = fi.getHttpRequest().getRemoteAddr();

        for (ConfigAttribute attribute : attributes) {
            if (attribute.getAttribute() != null && 
                attribute.getAttribute().startsWith("IP_")) {
                // 找到 IP 相关配置,默认改为 DENY
                if (allowedIpRanges.contains(requestIp)) {
                    return ACCESS_GRANTED;
                }
                return ACCESS_DENIED;
            }
        }
        return ACCESS_ABSTAIN;  // 没有 IP_ 配置,弃权
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return attribute.getAttribute() != null 
            && attribute.getAttribute().startsWith("IP_");
    }
}
@Configuration
public class IpVoterConfig {
    @Bean
    public AccessDecisionManager accessDecisionManager() {
        List<AccessDecisionVoter<?>> voters = Arrays.asList(
            new RoleVoter(),
            new IpAddressVoter(Arrays.asList("192.168.1.100", "192.168.1.101"))
        );
        // 使用 UnanimousBased:两个 Voter 都必须通过
        return new UnanimousBased(voters);
    }
}
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AccessDecisionManager accessDecisionManager;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(auth -> auth
                .accessDecisionManager(accessDecisionManager)
                .antMatchers("/admin/**")
                    .access("hasRole('ADMIN') and hasIpAddress('192.168.1.0/24')")
                .anyRequest().authenticated()
            )
            .formLogin(withDefaults());
    }
}

结果分析

用户IP 地址访问 /admin/dashboard投票详情
ADMIN192.168.1.100✅ 通过RoleVoter: GRANTED + IpAddressVoter: GRANTED
ADMIN10.0.0.50❌ 403RoleVoter: GRANTED + IpAddressVoter: DENY → UnanimousBased 拒绝
USER192.168.1.100❌ 403RoleVoter: DENY + IpAddressVoter: GRANTED → UnanimousBased 拒绝

易错场景:ROLE_ 前缀与 RoleVoter 的匹配陷阱

场景:开发者配置 hasRole("ADMIN") 同时在 UserDetails 中授予 "ADMIN"(不带 ROLE_ 前缀)。

问题:hasRole("ADMIN") 在 Spring Security 内部会被转换为 ROLE_ADMIN 的 ConfigAttribute。RoleVoter 只处理 ROLE_ 前缀的属性,因此它会检查用户权限中是否有 ROLE_ADMIN。如果 UserDetails 中只授予了 "ADMIN"(不带前缀),则 RoleVoter 投 ACCESS_DENIED。

正确做法:

// 错误:权限不带 ROLE_ 前缀
User.withUsername("admin")
    .password("{noop}admin")
    .authorities("ADMIN")  // ❌ RoleVoter 无法匹配
    .build();

// 正确:使用 roles() 方法自动添加前缀
User.withUsername("admin")
    .password("{noop}admin")
    .roles("ADMIN")  // ✅ 内部自动转换为 ROLE_ADMIN
    .build();

// 或显式使用 ROLE_ 前缀
User.withUsername("admin")
    .password("{noop}admin")
    .authorities("ROLE_ADMIN")  // ✅ 显式带前缀
    .build();

面试考点:

问:hasRole("ADMIN") 和 hasAuthority("ADMIN") 的区别?为什么 RoleVoter 找不到 "ADMIN" 权限? 答:hasRole("ADMIN") 在构建 ConfigAttribute 时会自动添加 ROLE_ 前缀,最终 RoleVoter 检查的是 ROLE_ADMIN。hasAuthority("ADMIN") 不做任何前缀处理,直接使用 "ADMIN" 检查。RoleVoter 的 supports() 方法只处理以 ROLE_ 开头的属性,因此如果 UserDetails 中只授予了 "ADMIN"(不带前缀),RoleVoter 对该属性投 ACCESS_ABSTAIN 而不是 ACCESS_GRANTED。hasRole() 必须配合 .roles() 或带 ROLE_ 前缀的 .authorities() 使用。

上一页
AccessDecisionManager
下一页
URL 级别授权