1. 概述
访问控制列表ACL(Access Control List)其实是个泊来语。大多指网络中的数据包转发控制。华为的官方网站上如下解释:访问控制列表ACL(Access Control List)是由一条或多条规则组成的集合。所谓规则,是指描述报文匹配条件的判断语句,这些条件可以是报文的源地址、目的地址、端口号等。
本文中的ACL与网络中的ACL大同小异,指在项目中对资源的访问、修改、删除进行控制。
Spring Security中的ACL提供了基于用户user、role对资源进行访问控制的策略。
我们以消息中心为例:管理员角色则能查看、修改所有的消息;而普通的用户仅够查看发送给自己的消息或是修改尚在草稿箱中的消息。
上述情况我们大概需要一个如下的访问控制策略:不同的用户/角色对某一资源应当拥有不同的权限。而这些权限则应该统一的记录在某一列表上。Spring ACL便是解决此类问题的实现之一。
2. 配置
2.1 创建特定数据表
使用Spring Security ACL时,我们应当在数据库中建立如下四张数据表:
第一张表用户存储资源映射,表名为acl_class。字段如下:
字段名 | 类型 | 主外键 | 备注 |
id | bigint | PK | |
class | string | 资源对应的JAVA类名,比如:club.codedemo.springsecurityacl.entity.Message |
第二张表用于存放应用中的用户/角色,表名为acl_sid。字段如下:
字段名 | 类型 | 主外键 | 备注 |
id | bigint | PK | |
principal | tinyinit | 用户设置为1 角色设置为0 | |
sid | varchar(100) | 用户名或角色名。 当principal为1时,此处存用户名,比如:zhangsan。 当principal为0时,此处存角色名,比如:ROLE_ADMIN |
第三张存放项目所有的需要进行权限控制的资源信息,项目中的每个资源都对应有唯一一条记录,表名为:acl_object_identity。字段如下:
字段名 | 类型 | 主外键 | 备注 |
id | bigint | PK | |
object_id_class | bigint | FK | 连接acl_class表,表示当前记录对应的资源类 该字段与object_id_identity字段组成unique索引 |
object_id_identity | bigint | 资源的ID记录。 该字段与object_id_class组成unique索引 | |
parent_object | bigint | FK | 连接本表,代表父记录 |
owner_sid | bigint | FK | 连接acl_sid表,表示当前资源的拥有者 |
entries_inheriting | tinyinit | 此记录的ACL信息(即存在放在acl_entry表中),是否由父记录继承。 此值为0时,在进行权限验证时将参考父记录以及本记录对应的ACL信息。 此值为1时,在进行权限验证时仅参考本记录对应的ACL信息。 |
最后,还需要一张记录权限信息的表acl_entry,此表存储着详细的授权信息。字段如下:
字段名 | 类型 | 主外键 | 备注 |
id | bigint | PK | |
acl_object_identity | bigint | FK | 连接acl_object_identity表,表示本授权记录对应的资源信息 与ace_order组成unique索引 |
ace_order | int | 排序(权重) 与acl_object_identity组成unique索引 | |
sid | bigint | FK | 连接acl_sid表,表示本授权记录对应的授权人 |
mask | int | 掩码(权限类型): 1为读 2为写 4为创建 8为删除 16为管理 | |
granting | tinyint | 0为授与此项权限,1为拒绝此项权限 | |
audit_success | tinyint | 用于审核(本文不涉及) | |
audit_failure | tinyint | 用于审核(本文不涉及) |
你可以点击此处获取一份sql文件,用于快速的创建上述数据表。
2.2 maven依赖
<!--spring security acl--> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-acl</artifactId> </dependency> <!-- spring security config--> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> </dependency> <!-- spring 缓存--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <!-- spring 缓存实现:ehcache--> <dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> <version>3.8.1</version> </dependency> <!--ehcache则需要Spring上下文支持--> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context-support</artifactId> </dependency>
如上述代码所示:我们引入了acl以及acl需要的缓存相关依赖。如果你当前的项目并不是基于Spring Boot创建,那么还需要手动指定相应的版本号。你可以在maven仓库中使用相应的关键字来找到它们。
2.3 相关配置
必须为ehcache指定xml配置文件,在classpath中创建ehcache.xml文件:
<?xml version="1.0" encoding="UTF-8"?> <ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" updateCheck="true" monitoring="autodetect" dynamicConfig="true"> </ehcache>
接着创建继承于GlobalMethodSecurityConfiguration的配置文件AclMethodSecurityConfiguration,用于配置ACL:
@Configuration @EnableCaching @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) public class AclMethodSecurityConfiguration extends GlobalMethodSecurityConfiguration {
Spring ACL依赖于缓存。@EnableCaching将启用Spring Boot的缓存功能,如此以来我们便可以在项目中注入CacheManager以获取缓存支持。
@EnableGlobalMethodSecurity启用了SpEL表达式以及方法安全认证。如此以来我们便可以将相应的注解添加到一些需要配置ACL方法上了。
Spring Security实现ACL,其实只需要重写GlobalMethodSecurityConfiguration中的MethodSecurityExpressionHandler createExpressionHandler()方法,从而提供一个自定义的MethodSecurityExpressionHandler。
/** * 用于验证Spring Security权限注解 * * @return */ @Override protected MethodSecurityExpressionHandler createExpressionHandler() { DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); AclPermissionEvaluator permissionEvaluator = new AclPermissionEvaluator(this.aclService()); expressionHandler.setPermissionEvaluator(permissionEvaluator); return expressionHandler; }
this.aclService()返回了一个基于数据源的ACL权限控制服务:
/** * 基于数据源的ACL权限控制服务 * dataSource 数据源 * lookupStrategy 查找实体ID,实体对应的CLASS,应该此两项在acl_object_identity中的记录 * aclCache 根据acl_object_identity记录、当前登录用户/角色,查找acl_entry表,最终获取相应的权限 * @return */ private JdbcMutableAclService aclService() { return new JdbcMutableAclService( dataSource, this.lookupStrategy(), this.aclCache() ); }
其中dataSource可以由Spring自动注入,this.lookupStrategy()指定了查找策略,this.aclCache()指定了使用了缓存ACL。相关方法如下:
/** * 判断某用户/角色是否有对某个资源有某项访问权限的策略 * 这里使用默认策略,表示: * 根据acl_entry表中的记录做判断 * 该表中有个ace_order字段,在进行权限判断时,会按该字段进行排序。 * 然后进行遍历。 * 如果找到了granting为1的记录,则不再遍历而返回true(有权限) * 否则会继续遍历下一条,直接遍历到granting为1记录或是遍历完毕为止。 * 如果没有遍历到granting为1的记录,则将返回首条granting为0的记录中的原因(audit_failure)做为无访问权限的原因返回 * * 在构造函数中传入的new ConsoleAuditLogger()作用是:在控制台上直接打印权限判断的结果。 * 此时将校验通过或未通过时,将在控制台看到相应的校验结果。 * * 想了解更多详细信息,可参考:https://docs.spring.io/spring-security/site/docs/4.2.15.RELEASE/apidocs/org/springframework/security/acls/domain/DefaultPermissionGrantingStrategy.html * * @return */ private PermissionGrantingStrategy permissionGrantingStrategy() { return new DefaultPermissionGrantingStrategy(new ConsoleAuditLogger()); } /** * 设置谁可以管理ACL控制策略,即设置ACL的管理员。 * AclAuthorizationStrategyImpl将管理权限细分为3种。 * 当传入1个参数时,3种管理权限将统一赋值为该参数。 * 除此此外,还可以传入3个参数分别对3种管理权限进行配置。 * * 更多详情可参考:https://docs.spring.io/spring-security/site/docs/4.2.15.RELEASE/apidocs/org/springframework/security/acls/domain/AclAuthorizationStrategyImpl.html * * @return */ private AclAuthorizationStrategy aclAuthorizationStrategy() { return new AclAuthorizationStrategyImpl( new SimpleGrantedAuthority("ROLE_ADMIN")); } /** * Acl会被频繁访问,所以设置缓存相当有必要 * * @return */ private SpringCacheBasedAclCache aclCache() { return new SpringCacheBasedAclCache( this.cacheManager.getCache("acl"), this.permissionGrantingStrategy(), this.aclAuthorizationStrategy() ); } /** * LookupStrategy主要提供两个功能: * 1. lookupPrimaryKeys 查找资源的主健 * 2. lookupObjectIdentities 根据资源主键、资源对应的Class,近而查找资源对应的acl_object_identity中的主键 * 该acl_object_identity主键将被PermissionGrantingStrategy调用,用于在acl_entry查找对应权限策略 * * @return */ private LookupStrategy lookupStrategy() { return new BasicLookupStrategy( dataSource, this.aclCache(), this.aclAuthorizationStrategy(), new ConsoleAuditLogger() ); }
上述代码的基本作用请参考相应的注释,不在展开描述。最后,我们为AclMethodSecurityConfiguration注入数据源及缓存服务:
public AclMethodSecurityConfiguration(DataSource dataSource, CacheManager cacheManager) { this.dataSource = dataSource; this.cacheManager = cacheManager; }
3. 为方法加入权限认证
配置完成后,下现我们开始为相应方法配置安全策略。
我们在前面介绍数据表acl_entry中的mask掩码时给出了5种权限:读、写、创建、删、管理。其实这5种权限被定义在了org.springframework.security.acls.domain.BasePermission中。在进行权限设定时,分别使用:READ WRITE CREATE DELETE ADMINISTRATION来表示。
比如我们加入以下验证规则:
@PostFilter("hasPermission(filterObject, 'READ')") List<Message> findAll() { List<Message> messages = this.messageRepository.findAll(); return messages; } @PostAuthorize("hasPermission(returnObject, 'READ')") Message findById(Long id) { return this.messageRepository.findById(id).orElse(null); } @PreAuthorize("hasPermission(#message, 'WRITE')") Message save(@Param("message") Message message) { return this.messageRepository.save(message); }
上述代码表示:
在执行完findAll()方法后将触发@PostFilter注解的相关功能,该功能将对返回的List中的内容进行遍历校验。如果当前用户对某个资源并不拥有读权限,则将被过滤掉。
同样@PostAuthorize注解用于对返回资源的权限校验,如果当前登录用户不具有findById方法返回值的读权限,则将发生AccessDeniedException异常。
@PreAuthorize用于事前校验,当前登录用户如果不具有对传入的message的写权限时,将发生AccessDeniedException异常。
4. 测试
若要使得上述ACL能够成功运行起来,则还需要提供一个可用的DataSource,在这里我们使用H2数据库:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> </dependency>
4.1 初始化测试数据
此外,还需要初始化一些供测试用的数据 :
-- 新增3条测试消息 INSERT INTO message(id, content) VALUES (1, '第一条给张三的消息'), (2, '第二条给李四消息'), (3, '第三条给王五的消息'); -- 建立两个用户zhangsan, lisi,一个角色ROLE_ADMIN INSERT INTO acl_sid (id, principal, sid) VALUES (1, 1, 'zhangsan'), (2, 1, 'lisi'), (3, 0, 'ROLE_ADMIN'); -- 建立实体类映射 INSERT INTO acl_class (id, class) VALUES (1, 'club.codedemo.springsecurityacl.entity.Message'); -- 创建ACL基表,用于关联实体类中的id。实际使用中的权限策略将关联此基表。 INSERT INTO acl_object_identity (id, object_id_class, object_id_identity, parent_object, owner_sid, entries_inheriting) VALUES -- id为1的message的拥有者为1号zhangsan(注意:拥有者是谁并不影响本文中的权限判断) (1, 1, 1, NULL, 1, 0), -- id为2的message的拥有者为2号lisi(注意:拥有者是谁并不影响本文中的权限判断) (2, 1, 2, NULL, 2, 0), -- id为3的message的拥有者为3号ROLE_EDITOR(注意:拥有者是谁并不影响本文中的权限判断) (3, 1, 3, NULL, 3, 0); -- BasePermission 权限策略依赖于此表。 INSERT INTO acl_entry (id, acl_object_identity, ace_order, sid, mask, granting, audit_success, audit_failure) VALUES -- 1号zhangsan用户对消息1拥有read读权限 (1, 1, 1, 1, 1, 1, 1, 1), -- 1号zhangsan用户对消息1拥有write写权限 (2, 1, 2, 1, 2, 1, 1, 1), -- 3号ROLE_EDITOR角色对消息1拥有read读权限 (3, 1, 3, 3, 1, 1, 1, 1), -- 2号lisi用户对消息2拥有read读权限 (4, 2, 1, 2, 1, 1, 1, 1), -- 3号ROLE_EDITOR角色对消息2拥有read读权限 (5, 2, 2, 3, 1, 1, 1, 1), -- 3号ROLE_EDITOR角色对消息3拥有read+write读写权限 (6, 3, 1, 3, 1, 1, 1, 1), (7, 3, 2, 3, 2, 1, 1, 1);
为了使Spring Boot在启动的时候不自动仓库新数据表,则还需要配置ddl-auto为update:
# 防止Spring boot启动时创建新表而误删由data.sql导入的数据 spring.jpa.hibernate.ddl-auto=update
4.2 测试用例
本文中使用了Spring Boot 2.3.3版本。该版本中的JUnit的默认版本为5(如果你习惯于使用JUnit4,同样也提供了完全支持)。如果你并没有使用Spring Boot,那么还需要手动的导入相应的测试库。
测试登录用户仅能够获取到自己拥有读权限的消息:
/** * 使用用户zhangsan获取所有的消息时,仅能够获取到张三拥有read权限的消息1 */ @Test @WithMockUser(username = "zhangsan") void findAllByUser() { List<Message> messages = this.messageService.findAll(); assertNotNull(messages); assertEquals(1, messages.size()); assertEquals(1, messages.get(0).getId()); }
拥有ADMIN角色则将获取所有的消息:
/** * 角色ADMIN获取全部的消息 */ @Test @WithMockUser(roles = "ADMIN") void findAllByRole() { List<Message> messages = this.messageService.findAll(); assertNotNull(messages); assertEquals(3, messages.size()); }
用户成功获取到自己的消息,当尝试获取其它用户的消息时将发生异常:
/** * 张三只能获取到自己的消息 * 获取其它2个消息时发生权限异常 */ @Test @WithMockUser(username = "zhangsan") void findByIdByUser() { assertNotNull(this.messageService.findById(1L)); assertThrows(AccessDeniedException.class, () -> this.messageService.findById(2L)); assertThrows(AccessDeniedException.class, () -> this.messageService.findById(3L)); }
ADMIN能够分别获取所有的消息:
/** * ADMIN能获取所有的消息 */ @Test @WithMockUser(roles = "ADMIN") void findByIdByRole() { assertNotNull(this.messageService.findById(1L)); assertNotNull(this.messageService.findById(2L)); assertNotNull(this.messageService.findById(3L)); }
用户更新自己没有写权限的消息时发生异常:
/** * 李四拥有2号消息的读权限,但并不拥有写权限,当发生写操作时发生权限异常 */ @Test @WithMockUser(username = "lisi") void saveWithUserAndCatchException() { Message message = this.messageService.findById(2L); message.setContent(RandomString.make()); assertThrows(AccessDeniedException.class, () -> this.messageService.save(message)); }
用户成功更新拥有写权限的消息:
/** * 张三拥有1号消息的读写权限 */ @Test @WithMockUser(username = "zhangsan") void saveWithUser() { Message message = this.messageService.findById(1L); message.setContent(RandomString.make()); this.messageService.save(message); }
管理员成功更新拥有写权限的消息:
/** * 管理员拥有1号消息的读写权限 */ @Test @WithMockUser(roles = "ADMIN") void saveWithRole() { Message message = this.messageService.findById(3L); message.setContent(RandomString.make()); this.messageService.save(message); }
5. 总结
本文中我们介绍了Spring ACL基本配置与使用方法。
正如我们在本文中所见,Spring ACL在使用默认策略的时候需要特定的数据表来配合。我们通过定义几个特定的数据表、一些简单的配置,便可在Spring Securitity中的基于方法进行权限控制的配合下完成对资源访问、修改、删除、管理等的权限控制。这不仅简化了我们的操作,同时也紧贴DDD领域驱动设计模式,为我们自建权限控制策略提供了一种新的思路。
凡事都具有两面性,Spring ACL由于部分数据表中的字段类型限制,使用了ACL进行权限控制的资源必须存在单一主键且主键类型为bigint;加之其直接配合Spring Securitiy中的user/role机制以及Spring Security应用于方法上的注解。使得其对应用场景要求较高,而且由于ACL未直接参与数据库底层的CRUD操作,所以也无法处理一些诸如分页的业务逻辑。
纸上得来终觉浅 绝知此事要躬行。如果你不仅仅是只想了解一下Spring ACL,那么还需要参考以下的代码示例来亲自敲一敲、跑一跑,相信会有不一样的收获。