分类
Spring Security

自定义Spring安全表达式

A Custom Security Expression with Spring Security

1. 概述

本文将主要对如果在Spring Security建立自定义安全表达式进行讲解。

有些时候Spring Security自带的表达式可能无法满足复杂的业务需求,此时则需要自定义安全表达式。

本文中,我们首先展示如何创建一个自定义的PermissionEvaluator(权限评审者),然后对其功能进行完善;在文章的最后将展示如何覆盖Spring Security内置的默认表达式。

2. 用户实体

首先让我们做一些基础工作:

创建一个User实体 ---- 该实体拥有Privileges(权限)以及Organization(组织\部门)字段如下:

@Entity
public class User{
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
 
    @Column(nullable = false, unique = true)
    private String username;
 
    private String password;
 
    @ManyToMany(fetch = FetchType.EAGER) 
    @JoinTable(name = "users_privileges", 
      joinColumns = 
        @JoinColumn(name = "user_id", referencedColumnName = "id"),
      inverseJoinColumns = 
        @JoinColumn(name = "privilege_id", referencedColumnName = "id")) 
    private Set<Privilege> privileges;
 
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "organization_id", referencedColumnName = "id")
    private Organization organization;
 
    // standard getters and setters
}

Privilege(权限)实体如下:

@Entity
public class Privilege {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
 
    @Column(nullable = false, unique = true)
    private String name;
 
    // standard getters and setters
}

Organization(组织)实体如下:

@Entity
public class Organization {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
 
    @Column(nullable = false, unique = true)
    private String name;
 
    // standard setters and getters
}

最后创建一个自定义的Principal(认证用户):

public class MyUserPrincipal implements UserDetails {
 
    private User user;
 
    public MyUserPrincipal(User user) {
        this.user = user;
    }
 
    @Override
    public String getUsername() {
        return user.getUsername();
    }
 
    @Override
    public String getPassword() {
        return user.getPassword();
    }
 
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
        for (Privilege privilege : user.getPrivileges()) {
            authorities.add(new SimpleGrantedAuthority(privilege.getName()));
        }
        return authorities;
    }
    
    ...
}

上述基础类准备完毕后,接下来创建一个UserDetailsService的实现:

@Service
public class MyUserDetailsService implements UserDetailsService {
 
    @Autowired
    private UserRepository userRepository;
 
    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        return new MyUserPrincipal(user);
    }
}

上述代码简单的实现了:一个用户拥有1个或多个权限(Privileges)以及1个或多个组织(Organization)。

3. 数据初始化

接下来,我们在数据库中初始化一些数据:

@Component
public class SetupData {
    @Autowired
    private UserRepository userRepository;
 
    @Autowired
    private PrivilegeRepository privilegeRepository;
 
    @Autowired
    private OrganizationRepository organizationRepository;
 
    @PostConstruct
    public void init() {
        initPrivileges();
        initOrganizations();
        initUsers();
    }
}

上述代码中的init()方法将在Spring Boot应用启动时被执行,在该方法中分别进行了权限数据初始化、组织数据初始化以用户数据初始化工作。

对应数据初始化代码如下:

private void initPrivileges() {
    Privilege privilege1 = new Privilege("FOO_READ_PRIVILEGE");
    privilegeRepository.save(privilege1);
 
    Privilege privilege2 = new Privilege("FOO_WRITE_PRIVILEGE");
    privilegeRepository.save(privilege2);
}
private void initOrganizations() {
    Organization org1 = new Organization("FirstOrg");
    organizationRepository.save(org1);
    
    Organization org2 = new Organization("SecondOrg");
    organizationRepository.save(org2);
}
private void initUsers() {
    Privilege privilege1 = privilegeRepository.findByName("FOO_READ_PRIVILEGE");
    Privilege privilege2 = privilegeRepository.findByName("FOO_WRITE_PRIVILEGE");
    
    User user1 = new User();
    user1.setUsername("john");
    user1.setPassword("123");
    user1.setPrivileges(new HashSet<Privilege>(Arrays.asList(privilege1)));
    user1.setOrganization(organizationRepository.findByName("FirstOrg"));
    userRepository.save(user1);
    
    User user2 = new User();
    user2.setUsername("tom");
    user2.setPassword("111");
    user2.setPrivileges(new HashSet<Privilege>(Arrays.asList(privilege1, privilege2)));
    user2.setOrganization(organizationRepository.findByName("SecondOrg"));
    userRepository.save(user2);
}

上述代码我们分别建立了一个Read读权限,一个Write写权限;分别新建了FirstOrg组织1以及SecondOrg组织2;新建了拥有读权限的用户john,以及拥有读、写权限的tom。

4. 自定义权限评审者(Permission Evaluator)

下面,我们开始通过新建一个自定义的Permission Evaluator,来实现自定义安全表达式。

我们将不在使用hard code的方法对方法进行控权,取而代之是根据user中的privileges。没错,这正是我们想要的:根据用户的权限来动态的判断该用户是否有访问某个方法的权限。

4.1 PermissionEvaluator

自定义的PermissionEvaluator需要实现PermissionEvaluator接口:

public class CustomPermissionEvaluator implements PermissionEvaluator {
    @Override
    public boolean hasPermission(
      Authentication auth, Object targetDomainObject, Object permission) {
        if ((auth == null) || (targetDomainObject == null) || !(permission instanceof String)){
            return false;
        }
        String targetType = targetDomainObject.getClass().getSimpleName().toUpperCase();
        
        return hasPrivilege(auth, targetType, permission.toString().toUpperCase());
    }
 
    @Override
    public boolean hasPermission(
      Authentication auth, Serializable targetId, String targetType, Object permission) {
        if ((auth == null) || (targetType == null) || !(permission instanceof String)) {
            return false;
        }
        return hasPrivilege(auth, targetType.toUpperCase(), 
          permission.toString().toUpperCase());
    }
}

以下是权限校验方法hasPrivilege的代码:

private boolean hasPrivilege(Authentication auth, String targetType, String permission) {
    for (GrantedAuthority grantedAuth : auth.getAuthorities()) {
        if (grantedAuth.getAuthority().startsWith(targetType)) {
            if (grantedAuth.getAuthority().contains(permission)) {
                return true;
            }
        }
    }
    return false;
}

此时便可以使用hasPermission完成相应的权限认证了,同时我们再也不需要像以前一样如下的使用hard code了:

@PostAuthorize("hasAuthority('FOO_READ_PRIVILEGE')")

取而代之的是可以在权限验证时进行逻辑处理的:

@PostAuthorize("hasPermission(returnObject, 'read')")

或者:

@PreAuthorize("hasPermission(#id, 'Foo', 'read')")

上述方法中: #id代码方法中的参数 Foo代码目标对象的类型。

4.2 启用方法级别的安全授权

默认情况下@PreAuthorize以及@PostAuthorize并未启用,所以预使用自定义的PermissionEvaluator生效,还需要做如下配置:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
 
    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler = 
          new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setPermissionEvaluator(new CustomPermissionEvaluator());
        return expressionHandler;
    }
}

4.3 实践中的例子

此时在生产环境中,我们便可以在控制器的方法上对应相入相应的注解,进而实现仅当当前登录用户拥有特定的权限时才能够顺利访问某些方法的目的:

@Controller
public class MainController {
    
    @PostAuthorize("hasPermission(returnObject, 'read')")
    @GetMapping("/foos/{id}")
    @ResponseBody
    public Foo findById(@PathVariable long id) {
        return new Foo("Sample");
    }
 
    @PreAuthorize("hasPermission(#foo, 'write')")
    @PostMapping("/foos")
    @ResponseStatus(HttpStatus.CREATED)
    @ResponseBody
    public Foo create(@RequestBody Foo foo) {
        return foo;
    }
}

4.4 单元测试

单元测试是保障项目质量最行之有效的方法,下面我们来看看如何在单元测试中对上述的方法进行测试,以验证上述安全注解是正常工作的:

@Test
public void givenUserWithReadPrivilegeAndHasPermission_whenGetFooById_thenOK() {
    Response response = givenAuth("john", "123").get("http://localhost:8082/foos/1");
    assertEquals(200, response.getStatusCode());
    assertTrue(response.asString().contains("id"));
}
 
@Test
public void givenUserWithNoWritePrivilegeAndHasPermission_whenPostFoo_thenForbidden() {
    Response response = givenAuth("john", "123").contentType(MediaType.APPLICATION_JSON_VALUE)
                                                .body(new Foo("sample"))
                                                .post("http://localhost:8082/foos");
    assertEquals(403, response.getStatusCode());
}
 
@Test
public void givenUserWithWritePrivilegeAndHasPermission_whenPostFoo_thenOk() {
    Response response = givenAuth("tom", "111").contentType(MediaType.APPLICATION_JSON_VALUE)
                                               .body(new Foo("sample"))
                                               .post("http://localhost:8082/foos");
    assertEquals(201, response.getStatusCode());
    assertTrue(response.asString().contains("id"));
}

如下是givenAuth()方法:

private RequestSpecification givenAuth(String username, String password) {
    FormAuthConfig formAuthConfig = 
      new FormAuthConfig("http://localhost:8082/login", "username", "password");
    
    return RestAssured.given().auth().form(username, password, formAuthConfig);
}

5. 新建表达式

前面我们介绍了如果自定义PermissionEvaluator从而在hasPermission应用,最终达到了自定义权限处理逻辑的目的。

这种方法虽然能够实现一些自定义的权限验证功能,但仍然受到hasPermission自身特性的一些限制。

本节中,我们将展示如何定义一个名为isMember() 的表达式,用以校验当前登录用户是否属于某个特定的Organization组织。

5.1 自定义应用于方法上的安全表达式

自定义应用于方法上的表达式需要继承SecurityExpressionRoot并实现MethodSecurityExpressionOperations接口:

public class CustomMethodSecurityExpressionRoot 
  extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {
 
    public CustomMethodSecurityExpressionRoot(Authentication authentication) {
        super(authentication);
    }
 
    public boolean isMember(Long OrganizationId) {
        User user = ((MyUserPrincipal) this.getPrincipal()).getUser();
        return user.getOrganization().getId().longValue() == OrganizationId.longValue();
    }
 
    ...
}

如上我们新建了一个isMember方法用以校验当前登录用户是否属于某个Organization组织。

5.2 自定义表达式处理器(Handler)

有了自定义的安全表达式,接下来我们将其注入到表达器处理器中:

public class CustomMethodSecurityExpressionHandler 
  extends DefaultMethodSecurityExpressionHandler {
    private AuthenticationTrustResolver trustResolver = 
      new AuthenticationTrustResolverImpl();
 
    @Override
    protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
      Authentication authentication, MethodInvocation invocation) {
        CustomMethodSecurityExpressionRoot root = 
          new CustomMethodSecurityExpressionRoot(authentication);
        root.setPermissionEvaluator(getPermissionEvaluator());
        root.setTrustResolver(this.trustResolver);
        root.setRoleHierarchy(getRoleHierarchy());
        return root;
    }
}

5.3 配置

最后使用新建的CustomMethodSecurityExpressionHandler来替换原来的DefaultMethodSecurityExpressionHandler:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        CustomMethodSecurityExpressionHandler expressionHandler = 
          new CustomMethodSecurityExpressionHandler();
        expressionHandler.setPermissionEvaluator(new CustomPermissionEvaluator());
        return expressionHandler;
    }
}

此时CustomMethodSecurityExpressionHandler正式生效。

5.4 使用自定义表达式

isMember()可以像其它安全表达式一样应用于某个方法上,比如:

@PreAuthorize("isMember(#id)")
@GetMapping("/organizations/{id}")
@ResponseBody
public Organization findOrgById(@PathVariable long id) {
    return organizationRepository.findOne(id);
}

5.5 单元测试

单元测试示例如下:

@Test
public void givenUserMemberInOrganization_whenGetOrganization_thenOK() {
    Response response = givenAuth("john", "123").get("http://localhost:8082/organizations/1");
    assertEquals(200, response.getStatusCode());
    assertTrue(response.asString().contains("id"));
}
 
@Test
public void givenUserMemberNotInOrganization_whenGetOrganization_thenForbidden() {
    Response response = givenAuth("john", "123").get("http://localhost:8082/organizations/2");
    assertEquals(403, response.getStatusCode());
}

如上代码分别测试了使用john用户尝试获取不同组织的信息时,在自定义安全表达式的前提下分别获得了200访问正常以及403权限不允许的状态码。

6. 禁用内置的安全表达式

最后,我们来展示如何重写一个内置的安全表达式从而达到禁用该表达式的目的----以hasAuthority()表达式为例。

6.1 自定义Security Expression Root

在自定义的表达式中,我们可以使用如下方法来覆盖hasAuthority()方法:

public class MySecurityExpressionRoot implements MethodSecurityExpressionOperations {
    public MySecurityExpressionRoot(Authentication authentication) {
        if (authentication == null) {
            throw new IllegalArgumentException("Authentication object cannot be null");
        }
        this.authentication = authentication;
    }
 
    @Override
    public final boolean hasAuthority(String authority) {
        throw new RuntimeException("method hasAuthority() not allowed");
    }
    ...
}

如上代码将hasAuthority方法声明为final从而保证了该方法不会被重写,在将方法中直接抛出了RuntimeException异常从而达到了禁用该hasAuthority表达式的目的。

接下来我们便可以参考本文第5部分将此自定义的安全表达式注入到相应的处理者中了。

6.2 示例

此时,如果我们如下使用hasAuthority()表达式,则将发生一个RuntimeException类型的异常。

@PreAuthorize("hasAuthority('FOO_READ_PRIVILEGE')")
@GetMapping("/foos")
@ResponseBody
public Foo findFooByName(@RequestParam String name) {
    return new Foo(name);
}

6.3 单元测试

测试代码如下:

@Test
public void givenDisabledSecurityExpression_whenGetFooByName_thenError() {
    Response response = givenAuth("john", "123").get("http://localhost:8082/foos?name=sample");
    assertEquals(500, response.getStatusCode());
    assertTrue(response.asString().contains("method hasAuthority() not allowed"));
}

7. 总结

本文讨论了当Spring Security中内置的表达式无法满足需要时,如果自定义安全达式,在文章的最后给出一种通过重写方法、抛出异常的方案来达到禁用Spring Security某个内置表达式的目的。

希望本文能帮到你。预获取更多信息还访问我们在文章起始位置提供的code demo以及同步视频。