分类
Spring Security

Spring Security在方法上进行权限认证

Introduction to Spring Method Security

资源列表:

在正式开始前,可以点击以下链接获取一份与本文相同的初始化代码。

https://github.com/codedemo-club/spring-security-method-security/archive/init.zip

1 概述

Spring Security支持方法级别的权限控制。在此机制上,我们可以在任意层的任意方法上加入权限注解,加入注解的方法将自动被Spring Security保护起来,仅仅允许特定的用户访问,从而还到权限控制的目的。

本文将首先介绍几种权限控制注解的使用方法,接着将介绍如何进行相应的单元测试。

2. 启用方法认证

首先加入security依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

接着新建安全配置类:

@Configuration
@EnableGlobalMethodSecurity(
  prePostEnabled = true, 
  securedEnabled = true, 
  jsr250Enabled = true)
public class MethodSecurityConfig 
  extends GlobalMethodSecurityConfiguration {
}
  • prePostEnabled = true 的作用的是启用Spring Security的  @PreAuthorize 以及 @PostAuthorize 注解。
  • securedEnabled = true 的作用是启用Spring Security的@Secured 注解。
  • jsr250Enabled = true 的作用是启用@RoleAllowed 注解

3. 在方法上设置权限认证

3.1 @Secured 注解

@Secured 注解规定了访问访方法的角色列表,在列表中最少指定一种角色。

比如:

@Secured("ROLE_VIEWER")
public String getUsername() {
    SecurityContext securityContext = SecurityContextHolder.getContext();
    return securityContext.getAuthentication().getName();
}

@Secured("ROLE_VIEWER") 表示只有拥有ROLE_VIEWER角色的用户,才能够访问getUsername()方法。

再比如:

@Secured({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername(String username) {
    return userRoleRepository.isValidUsername(username);
}

@Secured({ "ROLE_VIEWER", "ROLE_EDITOR" }) 表示用户拥有"ROLE_VIEWER", "ROLE_EDITOR" 两个角色中的任意一个角色,均可访问 isValidUsername 方法。

注意:@Secured 注解并不支持Spring表示式语言(SpEL)

3.2 @RoleAllowed 注解

@RoleAllowed 遵守了JSR-250标准,在功能及使用方法上与 @Secured 完全相同。所以3.1上的示例代码完全可以改写为:

@RolesAllowed("ROLE_VIEWER")
public String getUsername2() {
    //...
}
    
@RolesAllowed({ "ROLE_VIEWER", "ROLE_EDITOR" })
public boolean isValidUsername2(String username) {
    //...
}

3.3 @PreAuthorize 和 @PostAuthorize 注解

@PreAuthorize 以及 @PostAuthorize 注解均支持SpEL(Spring Express Language)。 

@PreAuthorize 注解用于执行方法前,而@PostAuthorize注解用于执行方法后并且可以影响执行方法的返回值。

比如:

@PreAuthorize("hasRole('ROLE_VIEWER')")
public String getUsernameInUpperCase() {
    return getUsername().toUpperCase();
}

@PreAuthorize("hasRole('ROLE_VIEWER')") 相当于@Secured(“ROLE_VIEWER”) 。Spring Security表达式 - hasRole的使用的示例一文中详细对hasRole表达式进行了详解。

同样的,前面出现的 @Secured({“ROLE_VIEWER”,”ROLE_EDITOR”}) 也可以替换为:@PreAuthorize(“hasRole(‘ROLE_VIEWER') or hasRole(‘ROLE_EDITOR')”)

@PreAuthorize("hasRole('ROLE_VIEWER') or hasRole('ROLE_EDITOR')")
public boolean isValidUsername3(String username) {
    //...
}

除此以外,我们还可以在方法的参数上使用表达式:

@PreAuthorize("#username == authentication.principal.username")
public String getMyRoles(String username) {
    //...
}

如上代码限制了只有当username的值与当前系统登录用户的用户名相同时,才允许访问该方法。

从语法上讲@PreAuthorize中的表达式作用于@PostAuthorize同样生效。

比如:

@PostAuthorize("#username == authentication.principal.username")
public String getMyRoles2(String username) {
    //...
}

稍微不同的是:判断username是否与当前登录用户的username相同的操作被放在了方法执行以后。

另外@PostAuthorize注解还可以获取到方法的返回值,并且可以根据该方法来决定最终的授权结果(是允许访问还是不允许访问):

@PostAuthorize
  ("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

上述代码中,仅当loadUserDetail方法的返回值中的username与当前登录用户的username相同时才被允许访问。

本节中我们介绍了几种简单的Spring表达式(SpEL)的使用方法。在Spring Security自定义安全表达式一文中给出了更详细的讲解。

3.4 @PreFilter 以及 @PostFilter 注解

Spring Security提供了一个@PreFilter 注解来对传入的参数进行过滤

@PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
    return usernames.stream().collect(Collectors.joining(";"));
}

当usernames中的子项与当前登录用户的用户名不同时,则保留;当usernames中的子项与当前登录用户的用户名相同时,则移除。比如当前使用用户的用户名为zhangsan,此时usernames的值为{"zhangsan", "lisi", "wangwu"},则经@PreFilter过滤后,实际传入的usernames的值为{"lisi", "wangwu"}

如果执行方法中包含有多个类型为Collection的参数,filterObject 就不太清楚是对哪个Collection参数进行过滤了。此时,便需要加入 filterTarget 属性来指定具体的参数名称:

@PreFilter
  (value = "filterObject != authentication.principal.username",
  filterTarget = "usernames")
public String joinUsernamesAndRoles(
  List<String> usernames, List<String> roles) {
 
    return usernames.stream().collect(Collectors.joining(";")) 
      + ":" + roles.stream().collect(Collectors.joining(";"));
}

同样的我们还可以使用@PostFilter 注解来过返回的Collection进行过滤:

@PostFilter("filterObject != authentication.principal.username")
public List<String> getAllUsernamesExceptCurrent() {
    return userRoleRepository.getAllUsernames();
}

此时 filterObject 代表返回值。如果以来上述代码则实现了:移除掉返回值中与当前登录用户的用户名相同的子项。

你可以访问详解Spring Security @PreFilter以及@PostFilter 一文中来获取更多的信息。

3.5 自定义元注解

如果我们需要在多个方法中使用相同的安全注解,则可以通过创建元注解的方式来提升项目的可维护性。

比如创建以下元注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ROLE_VIEWER')")
public @interface IsViewer {
}

然后可以直接将该注解添加到对应的方法上:

@IsViewer
public String getUsername4() {
    //...
}

在生产项目中,由于元注解分离了业务逻辑与安全框架,所以使用元注解是一个非常不错的选择。

3.6 在类上使用安全注解

如果一个类中的所有的方法我们全部都是应用的同一个安全注解,那么此时则应该把安全注解提升到类的级别上:

@Service
@PreAuthorize("hasRole('ROLE_ADMIN')")
public class SystemService {
 
    public String getSystemYear(){
        //...
    }
 
    public String getSystemDate(){
        //...
    }
}

上述代码实现了:访问getSystemYear 以及getSystemDate 方法均需要ROLE_ADMIN权限。

3.7 在一个方法上应用多个安全注解

在一个安全注解无法满足我们的需求时,还可以应用多个安全注解:

@PreAuthorize("#username == authentication.principal.username")
@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser securedLoadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

此时Spring Security将在执行方法前执行@PreAuthorize的安全策略,在执行方法后执行@PostAuthorize的安全策略。

4. 重要提示

在此结合我们的使用经验,给出以下两点提示:

  • 默认情况下,在方法中使用安全注解是由Spring AOP代理实现的,这意味着:如果我们在方法1中去调用同类中的使用安全注解的方法2,则方法2上的安全注解将失效。
  • Spring Security上下文是线程绑定的,这意味着:安全上下文将不会传递给子线程。在Spring Security 上下文传送中我们对此做了更多的介绍。
    public boolean isValidUsername4(String username) {
        // 以下的方法将会跳过安全认证
        this.getUsername();
        return true;
    }

5. 单元测试

5.1 配置

和测试其它的SpringBoot项目相同,新建如下测试类。

@SpringBootTest
public class TestMethodSecurity {
}

如上,在测试类中添加@SpringBootTest注解。然后在pom.xml中添加如下依赖:

		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>

5.2 测试用户名以及角色

比如测试如下方法:

@Secured("ROLE_VIEWER")
public String getUsername() {
    SecurityContext securityContext = SecurityContextHolder.getContext();
    return securityContext.getAuthentication().getName();
}

上述方法使用了@Secured进行权限认证,在调用该方法时:如果当前登录用户拥有ROLE_VIEWER角色,则将正常执行;否则将抛出AuthenticationCredentialsNotFoundException

在单元测试中,可以使用@WithMockUser快速的设置当前的登录用户。

@Test
@WithMockUser(username = "john", roles = { "VIEWER" })
public void givenRoleViewer_whenCallGetUsername_thenReturnUsername() {
    String userName = userRoleService.getUsername();
    
    assertEquals("john", userName);
}

上述代码中我们使用@WithMockUser提供了一个名为john的登录用户,该用户拥有ROLE_VIEWER角色。

注意:在设置测试用户的角色时,必须省略ROLE_前缀。

如果你不喜欢这个默认设置,可以使用authority来代替role

比如定义如下getUsernameInLowerCase方法:

    @PreAuthorize("hasAuthority('SYS_ADMIN')")
    public String getUsernameInLowerCase(){
        return getUsername().toLowerCase();
    }

则在单元测试中同样应该使用全称:

@Test
@WithMockUser(username = "JOHN", authorities = { "SYS_ADMIN" })
public void givenAuthoritySysAdmin_whenCallGetUsernameLC_thenReturnUsername() {
    String username = userRoleService.getUsernameInLowerCase();
 
    assertEquals("john", username);
}

同样的,如果我们希望在某个单元测试的所有方法上都使用同一个登录用户,则可以将 @WithMockUser注解提升到类的展面上

@RunWith(SpringRunner.class)
@ContextConfiguration
@WithMockUser(username = "john", roles = { "VIEWER" })
public class TestWithMockUserAtClassLevel {
    //...
}

如果需要模拟匿名用户,则需要使用 @WithAnonymousUser 注解:

    @Test
    @WithAnonymousUser
    public void givenAnomynousUser_whenCallGetUsername_thenAccessDenied() {
        assertThrows(AccessDeniedException.class, () -> userRoleService.getUsername());
    }

如上代码断言在匿名用户登录的情况下调用userRoleService.getUsername()将得到一个AccessDeniedException

5.3 在单元测试中使用自定义的UserDetailsService

在大多数的项目中,通常会自定义一个认证主体类:新建一个类并实现org.springframework.security.core.userdetails.UserDetails接口。

本文中将新建CustomUser做为认证主体类,设置该类继承Spring的内置org.springframework.security.core.userdetails.Use类,该类实现了UserDetails接口:

public class CustomUser extends User {
    private String nickName;
    // getter and setter
}

然后使用@PostAuthorize 注解举例如下:

@PostAuthorize("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
    return userRoleRepository.loadUserByUserName(username);
}

上述代码实现了:对返回值中的username与自定义认证主体中的nickName进行权限认证。

可以通过提供一个UserDetailsService的实现来测试上述代码:

@Test
@WithUserDetails(
  value = "john", 
  userDetailsServiceBeanName = "userDetailService")
public void whenJohn_callLoadUserDetail_thenOK() {
 
    CustomUser user = userService.loadUserDetail("jane");
 
    assertEquals("jane", user.getNickName());
}

@WithUserDetails注解的userDetailsServiceBeanName属性指定了使用UserDetailsService来初始化认证用户。UserDetailsService可以是一个真实的实现,也可以是一个模拟的实现。

@WithUserDetails注解的value值指当前登录用户的用户名。

和前面两个注解一样,@WithUserDetails注解还可以直接声明在类上表示此类中的所有的方法均使用相同的模拟认证信息。

5.4 测试元注解

假设拥有以下元注解:

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(value = "john", roles = "VIEWER")
public @interface WithMockJohnViewer { }

该注解应用于以下方法:

@WithMockJohnViewer
public String getUsername() {
    //...
}

可以在单元测试中使用 @WithMockJohnViewer 模拟当前登录用户:

@Test
@WithMockJohnViewer
public void givenMockedJohnViewer_whenCallGetUsername_thenReturnUsername() {
    String userName = userRoleService.getUsername();
 
    assertEquals("john", userName);
}

除此以外,也可以使用@WithUserDetails来声明模拟登录用户、角色达到与@WithMockJohnViewer相同的效果。

6 总结

本文中,我们先后介绍了几种Spring Security在方法进行权限认证的方法。

最后将单独的讲解了几个权限认证方法对应的单元测试方法。希望能你能有所帮助。