@EnableMethodSecurity 注解

1、概览

使用 Spring Security,可以为应用配置身份认证和授权,以控制方法(如端点)的访问权限。

在 5.6 之前,使用 @EnableGlobalMethodSecurity 注解是标准的做法,在 5.6 之后,@EnableMethodSecurity 引入了一种更灵活的方法来配置方法安全授权(Method Security)。

在本教程中,我们将通过示例代码了解 @EnableMethodSecurity 如何代替 @EnableGlobalMethodSecurity,以及他们之间的区别。

2、@EnableMethodSecurity@EnableGlobalMethodSecurity

让我们来看看方法授权如何与 @EnableMethodSecurity@EnableGlobalMethodSecurity 配合使用。

2.1、@EnableMethodSecurity

通过 @EnableMethodSecurity,可以看到 Spring Security 为授权类型(Authorization Type)采用基于 Bean 的配置。

我们现在为每种类型都设置了一个配置,而不是全局配置。例如,Jsr250MethodSecurityConfiguration

@Configuration(proxyBeanMethods = false)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
class Jsr250MethodSecurityConfiguration {
    // ...
    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    Advisor jsr250AuthorizationMethodInterceptor() {
        return AuthorizationManagerBeforeMethodInterceptor.jsr250(this.jsr250AuthorizationManager);
    }

    @Autowired(required = false)
    void setGrantedAuthorityDefaults(GrantedAuthorityDefaults grantedAuthorityDefaults) {
        this.jsr250AuthorizationManager.setRolePrefix(grantedAuthorityDefaults.getRolePrefix());
    }
}

MethodInterceptor 主要包含一个 AuthorizationManager,它现在将 “检查和返回最终决策的 AuthorizationDecision 对象” 的责任委托给适当的实现,这里是 AuthenticatedAuthorizationManager

@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
    boolean granted = isGranted(authentication.get());
    return new AuthorityAuthorizationDecision(granted, this.authorities);
}

private boolean isGranted(Authentication authentication) {
    return authentication != null && authentication.isAuthenticated() && isAuthorized(authentication);
}

private boolean isAuthorized(Authentication authentication) {
    Set<String> authorities = AuthorityUtils.authorityListToSet(this.authorities);
    for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) {
        if (authorities.contains(grantedAuthority.getAuthority())) {
            return true;
        }
    }
    return false;
}

如果我们无法访问资源,MethodInterceptor 会抛出 AccesDeniedException

AuthorizationDecision decision = this.authorizationManager.check(AUTHENTICATION_SUPPLIER, mi);
if (decision != null && !decision.isGranted()) {
    // ...
    throw new AccessDeniedException("Access Denied");
}

2.2、@EnableGlobalMethodSecurity

@EnableGlobalMethodSecurity 是一个函数接口,需要与 @EnableWebSecurity 一起使用,用于创建安全层(Security Layer)并进行方法授权。

创建一个示例配置类:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
@Configuration
public class SecurityConfig {
    // security bean
}

所有方法授权实现都使用 MethodInterceptor,该拦截器会在需要授权时触发。在这种情况下,GlobalMethodSecurityConfiguration 类是启用全局方法授权的基础配置。

methodSecurityInterceptor() 方法使用不同授权类型的元数据创建 MethodInterceptor Bean。

Spring Security 支持三种内置的方法授权注解:

  • prePostEnabled 用于 Spring pre/post 注解。
  • securedEnabled 用于 Spring @Secured 注解。
  • jsr250Enabled 用于标准的 Java @RoleAllowed 注解。

此外,在 methodSecurityInterceptor() 方法中,还进行了以下设置:

该框架有一个投票机制,用于拒绝或授予对特定方法的访问权限。以 Jsr250Voter 为例进行验证:

@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> definition) {
    boolean jsr250AttributeFound = false;
    for (ConfigAttribute attribute : definition) {
        if (Jsr250SecurityConfig.PERMIT_ALL_ATTRIBUTE.equals(attribute)) {
            return ACCESS_GRANTED;
        }
        if (Jsr250SecurityConfig.DENY_ALL_ATTRIBUTE.equals(attribute)) {
            return ACCESS_DENIED;
        }
        if (supports(attribute)) {
            jsr250AttributeFound = true;
            // 尝试查找匹配的授权
            for (GrantedAuthority authority : authentication.getAuthorities()) {
                if (attribute.getAttribute().equals(authority.getAuthority())) {
                    return ACCESS_GRANTED;
                }
            }
        }
    }
    return jsr250AttributeFound ? ACCESS_DENIED : ACCESS_ABSTAIN;
}

投票时,Spring Security 会从当前方法(例如我们的 REST 端点)中提取元数据属性。然后将其与用户授予的权限进行检查。

注意,有可能存在某个投票者不支持投票系统并选择弃权的情况。

然后,AccessDecisionManager(访问决策管理器)会对所有可用投票者的回复进行评估:

for (AccessDecisionVoter voter : getDecisionVoters()) {
    int result = voter.vote(authentication, object, configAttributes);
    switch (result) {
        case AccessDecisionVoter.ACCESS_GRANTED:
            return;
        case AccessDecisionVoter.ACCESS_DENIED:
            deny++;
            break;
        default:
            break;
    }
}
if (deny > 0) {
    throw new AccessDeniedException(this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}

如果我们想自定义 Bean,可以继承 GlobalMethodSecurityConfiguration 类。例如,我们可能需要自定义 Security 表达式,而不是 Spring Security 内置的 Spring EL。或者,我们可能想自己定制 Security Voter。

3、 @EnableMethodSecurity 特性

与之前的传统实现相比,@EnableMethodSecurity 带来了一些改进。

3.1、小的改进

仍然支持所有授权类型。例如,它仍然符合 JSR-250。不过,我们不需要在注解中添加 prePostEnabled,因为它现在默认为 true

@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)

如果要禁用,我们需要将 prePostEnabled 设置为 false

3.2、重大的改进

GlobalMethodSecurityConfiguration 类已不再使用。Spring Security 使用分段配置和 AuthorizationManager 取代了它,这意味着我们无需继承任何基础配置类就能定义我们的授权 Bean。

值得注意的是,AuthorizationManager 接口是泛型的,可以适应任何对象,尽管 Standard Security 适用于 MethodInvocation

AuthorizationDecision check(Supplier<Authentication> authentication, T object);

总的来说,这为我们提供了使用 Delegation(委托,面向对象设计) 进行细粒度授权的能力。因此,在实践中,每种类型都有一个 AuthorizationManager。当然,我们也可以构建自己的 AuthorizationManager

此外,这也意味着 @EnableMethodSecurity 不允许像传统实现中那样使用 @AspectJ 注解和 AspectJ 方法拦截器:

public final class AspectJMethodSecurityInterceptor extends MethodSecurityInterceptor {
    public Object invoke(JoinPoint jp) throws Throwable {
        return super.invoke(new MethodInvocationAdapter(jp));
    }
    // ...
}

不过,我们仍然完全支持 AOP。例如,让我们看看上述 Jsr250MethodSecurityConfiguration 使用的拦截器:

public final class AuthorizationManagerBeforeMethodInterceptor
  implements Ordered, MethodInterceptor, PointcutAdvisor, AopInfrastructureBean {
    // ...
    public AuthorizationManagerBeforeMethodInterceptor(
      Pointcut pointcut, AuthorizationManager<MethodInvocation> authorizationManager) {
        Assert.notNull(pointcut, "pointcut cannot be null");
        Assert.notNull(authorizationManager, "authorizationManager cannot be null");
        this.pointcut = pointcut;
        this.authorizationManager = authorizationManager;
    }
    
    @Override
    public Object invoke(MethodInvocation mi) throws Throwable {
        attemptAuthorization(mi);
        return mi.proceed();
    }
}

4、自定义 AuthorizationManager 应用

下面我们来看看如何创建自定义授权管理器(authorization manager)。

假设我们有一些端点,我们希望应用某个策略。我们希望只有在用户可以访问该策略时,才对其进行授权。否则,我们将阻止该用户。

第一步,添加一个字段来定义我们的用户,以便访问受限策略:

public class SecurityUser implements UserDetails {
    private String userName;
    private String password;
    private List<GrantedAuthority> grantedAuthorityList;
    private boolean accessToRestrictedPolicy;

    // get、set 方法省略
}

现在,让我们来看看身份验证层,以定义系统中的用户。创建一个自定义的 UserDetailService。使用一个内存 Map 来存储用户:

public class CustomUserDetailService implements UserDetailsService {
    private final Map<String, SecurityUser> userMap = new HashMap<>();

    public CustomUserDetailService(BCryptPasswordEncoder bCryptPasswordEncoder) {
        userMap.put("user", createUser("user", bCryptPasswordEncoder.encode("userPass"), false, "USER"));
        userMap.put("admin", createUser("admin", bCryptPasswordEncoder.encode("adminPass"), true, "ADMIN", "USER"));
    }

    @Override
    public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
        return Optional.ofNullable(map.get(username))
          .orElseThrow(() -> new UsernameNotFoundException("User " + username + " does not exists"));
    }

    private SecurityUser createUser(String userName, String password, boolean withRestrictedPolicy, String... role) {
        return SecurityUser.builder().withUserName(userName)
          .withPassword(password)
          .withGrantedAuthorityList(Arrays.stream(role)
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList()))
          .withAccessToRestrictedPolicy(withRestrictedPolicy);
    }
}

用户存在于系统中后,我们希望通过检查他是否有访问某些受限策略的权限来限制他可以访问的信息。

为了演示,创建一个 Java 注解 @Policy 来应用在方法上,并创建一个策略枚举:

@Target(METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Policy {
    PolicyEnum value();
}
public enum PolicyEnum {
    RESTRICTED, OPEN
}

创建要应用此策略的服务:

@Service
public class PolicyService {
    @Policy(PolicyEnum.OPEN)
    public String openPolicy() {
        return "Open Policy Service";
    }

    @Policy(PolicyEnum.RESTRICTED)
    public String restrictedPolicy() {
        return "Restricted Policy Service";
    }
}

我们不能使用内置的授权管理器(authorization manager),如 Jsr250AuthorizationManager。因为它不知道何时以及如何拦截服务策略检查。因此,需要自定义管理器:

public class CustomAuthorizationManager<T> implements AuthorizationManager<MethodInvocation> {
    ...
    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, MethodInvocation methodInvocation) {
        if (hasAuthentication(authentication.get())) {
            Policy policyAnnotation = AnnotationUtils.findAnnotation(methodInvocation.getMethod(), Policy.class);
            SecurityUser user = (SecurityUser) authentication.get().getPrincipal();
            return new AuthorizationDecision(Optional.ofNullable(policyAnnotation)
              .map(Policy::value).filter(policy -> policy == PolicyEnum.OPEN 
                || (policy == PolicyEnum.RESTRICTED && user.hasAccessToRestrictedPolicy())).isPresent());
        }
        return new AuthorizationDecision(false);
    }

    private boolean hasAuthentication(Authentication authentication) {
        return authentication != null && isNotAnonymous(authentication) && authentication.isAuthenticated();
    }

    private boolean isNotAnonymous(Authentication authentication) {
        return !this.trustResolver.isAnonymous(authentication);
    }
}

当 service 方法被触发时,我们会仔细检查用户是否通过了身份验证。然后,如果策略是开放(OPEN)的,我们就授予访问权限。如果有限制,我们会检查用户是否能访问受限策略。

为此,我们需要定义一个 MethodInterceptor,例如在执行之前,但也可以在执行之后。因此,让我们将其与 Security 配置类封装在一起:

@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
    @Bean
    public AuthenticationManager authenticationManager(
      HttpSecurity httpSecurity, UserDetailsService userDetailsService, BCryptPasswordEncoder bCryptPasswordEncoder) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder = httpSecurity.getSharedObject(AuthenticationManagerBuilder.class);
        authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
        return authenticationManagerBuilder.build();
    }

    @Bean
    public UserDetailsService userDetailsService(BCryptPasswordEncoder bCryptPasswordEncoder) {
        return new CustomUserDetailService(bCryptPasswordEncoder);
    }

    @Bean
    public AuthorizationManager<MethodInvocation> authorizationManager() {
        return new CustomAuthorizationManager<>();
    }

    @Bean
    @Role(ROLE_INFRASTRUCTURE)
    public Advisor authorizationManagerBeforeMethodInterception(AuthorizationManager<MethodInvocation> authorizationManager) {
        JdkRegexpMethodPointcut pattern = new JdkRegexpMethodPointcut();
        pattern.setPattern("com.baeldung.enablemethodsecurity.services.*");
        return new AuthorizationManagerBeforeMethodInterceptor(pattern, authorizationManager);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf()
          .disable()
          .authorizeRequests()
          .anyRequest()
          .authenticated()
          .and()
          .sessionManagement()
          .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

我们使用 AuthorizationManagerBeforeMethodInterceptor。它与我们的策略服务模式相匹配,并使用自定义授权管理器。

此外,我们还需要让 AuthenticationManager 注入自定义的 UserDetailsService。这样,当 Spring Security 拦截该服务方法时,我们就可以访问自定义用户并检查用户的策略访问权限。

5、测试

定义一个 REST Controller:

@RestController
public class ResourceController {
    // ...
    @GetMapping("/openPolicy")
    public String openPolicy() {
        return policyService.openPolicy();
    }

    @GetMapping("/restrictedPolicy")
    public String restrictedPolicy() {
        return policyService.restrictedPolicy();
    }
}

使用 Spring Boot Test 来模拟方法授权:

@SpringBootTest(classes = EnableMethodSecurityApplication.class)
public class EnableMethodSecurityTest {
    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @BeforeEach
    public void setup() {
        mvc = MockMvcBuilders.webAppContextSetup(context)
          .apply(springSecurity())
          .build();
    }

    @Test
    @WithUserDetails(value = "admin")
    public void whenAdminAccessOpenEndpoint_thenOk() throws Exception {
        mvc.perform(get("/openPolicy"))
          .andExpect(status().isOk());
    }

    @Test
    @WithUserDetails(value = "admin")
    public void whenAdminAccessRestrictedEndpoint_thenOk() throws Exception {
        mvc.perform(get("/restrictedPolicy"))
          .andExpect(status().isOk());
    }

    @Test
    @WithUserDetails()
    public void whenUserAccessOpenEndpoint_thenOk() throws Exception {
        mvc.perform(get("/openPolicy"))
          .andExpect(status().isOk());
    }

    @Test
    @WithUserDetails()
    public void whenUserAccessRestrictedEndpoint_thenIsForbidden() throws Exception {
        mvc.perform(get("/restrictedPolicy"))
          .andExpect(status().isForbidden());
    }
}

所有响应都应该是经过授权的,除非用户调用的服务与他没有访问受限策略的权限相关。

6、总结

在本文中,我们了解了 @EnableMethodSecurity 的主要功能,以及它是如何取代 @EnableGlobalMethodSecurity 的。

然后,我们学习了 @EnableMethodSecurity 如何为基于 Bean 的配置提供更大的灵活性。最后,我们了解了如何创建自定义授权管理器和 MVC 测试。


参考:https://www.baeldung.com/spring-enablemethodsecurity