分类
Spring Security

Spring Security ACL 简介

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。字段如下:

字段名类型主外键备注
idbigintPK
classstring资源对应的JAVA类名,比如:club.codedemo.springsecurityacl.entity.Message

第二张表用于存放应用中的用户/角色,表名为acl_sid。字段如下:

字段名类型主外键备注
idbigintPK
principaltinyinit用户设置为1
角色设置为0
sidvarchar(100)用户名或角色名。
当principal为1时,此处存用户名,比如:zhangsan。
当principal为0时,此处存角色名,比如:ROLE_ADMIN

第三张存放项目所有的需要进行权限控制的资源信息,项目中的每个资源都对应有唯一一条记录,表名为:acl_object_identity。字段如下:

字段名类型主外键备注
idbigintPK
object_id_classbigintFK连接acl_class表,表示当前记录对应的资源类
该字段与object_id_identity字段组成unique索引
object_id_identitybigint资源的ID记录。
该字段与object_id_class组成unique索引
parent_objectbigintFK连接本表,代表父记录
owner_sidbigintFK 连接acl_sid表,表示当前资源的拥有者
entries_inheritingtinyinit此记录的ACL信息(即存在放在acl_entry表中),是否由父记录继承。
此值为0时,在进行权限验证时将参考父记录以及本记录对应的ACL信息。
此值为1时,在进行权限验证时仅参考本记录对应的ACL信息。

最后,还需要一张记录权限信息的表acl_entry,此表存储着详细的授权信息。字段如下:

字段名类型主外键备注
idbigintPK
acl_object_identitybigintFK连接acl_object_identity表,表示本授权记录对应的资源信息
与ace_order组成unique索引
ace_orderint排序(权重)
与acl_object_identity组成unique索引
sidbigintFK连接acl_sid表,表示本授权记录对应的授权人
maskint掩码(权限类型):
1为读
2为写
4为创建
8为删除
16为管理
grantingtinyint0为授与此项权限,1为拒绝此项权限
audit_successtinyint用于审核(本文不涉及)
audit_failuretinyint用于审核(本文不涉及)

你可以点击此处获取一份sql文件,用于快速的创建上述数据表。

2.2 maven依赖

Spring ACL依赖如下

<!--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,那么还需要参考以下的代码示例来亲自敲一敲、跑一跑,相信会有不一样的收获。

分类
Spring Security

Spring Security – @PreFilter 与 @PostFilter 注解

在继续阅读之前,可以点击此处以下链接获取一份与本文相同的初始化代码。

1. 概述

本文中我们将介绍如何在Spring项目中使用 @PreFilter @PostFilter 注解,从而实现一些特定的安全规则。

 @PreFilter @PostFilter 可以结合当前登录用户信息,使用SpEL(Spring Expression Language)实现更多的权限控制策略。

2. 初识 @PreFilter @PostFilter

简单来说@PreFilter以及@PostFilter的作用是:按设定的规则过滤数据列表,将符合规则的留下,将不符合规则的剔除。

@PostFilter用于事后过滤,过滤的对象是方法的返回值。在过滤的过程中,依次对返回的数据列表的项进行校验。当某个数据项经校验返回true时,则保留;返回false时,则剔除。

@Prefilter的原理也是如此。不同的是@Prefilter的过滤对象是传入方法的参数。

@PreFilter、@PostFilter支持添加到方法及类型上(类或接口)。本文中仅讨论其添加到方法如何使用。

Spring Security默认关闭了@PreFilter、@PostFilter,所以若想使其生效,则需要在@EnableGlobalMethodSecurity中加入prePostEnabled = true

/**
 * 全局安全配置
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class GlobalMethodSecurityConfig extends GlobalMethodSecurityConfiguration {
}

3. 定义安全规则

@PreFilter、@PostFilter均支持SpEL;在表达式中可以使用filterObject来表示传入的参数中(@PreFilter)的遍历项或返回值(@PostFilter)的遍历项。

Spring提供了一系列的像filterObject一样的表达式,详情请参考官方文档

比如可以使用@PostFilter遍历返回的列表,将遍历的值中的teacherName(班主任姓名)属性与当前登录用户的name值不相同的对象过滤掉:

    @PostFilter("filterObject.teacherName == authentication.principal.username")
    List<Student> findAll() {
        List<Student> students = new ArrayList<>();
        students.add(new Student("zhangsan"));
        students.add(new Student("lisi"));
        students.add(new Student("wangwu"));
        return students;
    }

上述代码将首先执行findAll方法并获取该方法的返回值,接下来按@PostFilter的过滤规则进行过滤,将符合条件的保留,不符合条件的移除

所以,假设当前登录的用户名是zhangsan,findAll方法最终将只返回当前教师名称为zhangsan的学生(lisi、wangwu的任务将被过滤掉)。

接下来,让我们展示一个稍微复杂些的表达式:

    @PostFilter("hasRole('TEACHER') or filterObject.teacherName == authentication.principal.username")
    List<Student> findAllWithRole() {
        List<Student> students = new ArrayList<>();
        students.add(new Student("zhangsan"));
        students.add(new Student("lisi"));
        students.add(new Student("wangwu"));
        return students;
    }

上述方法实现了:如果当前登录的用户角色是TEACHER(教师),则将返回全部的学生;如果登录的用户角色非TEACHER,则只返回当前教师负责的学生。

接下来,让我们看看@PreFilter是如何对传入的参数进行过滤的:

    @PreFilter("hasRole('TEACHER') or filterObject.teacherName == authentication.principal.username")
    List<Student> save(List<Student> students) {
        System.out.println(students.size());
        return students;
    }

该方法中我们使用了与前面@PosFilter参数相同的参数。此时,如果当前登录用户是TEACHER(教师),则不对传入参数进行过滤;否则则仅保留当前教师负责的学生。

4. 大数据量下的表现

@PosFilter虽然简单易用,但如果某方法中返回的数据量过大,则由于其需要遍历其每一项的特点,将对程序的执行效率产生影响。

比如我们想获取某个班级的所有同学,在使用@PosFilter时可以先获取数据库中的所有学生,然后依次对学生进行遍历判断。虽然最终也能够实现,但并不是一种好的方法。所以在使用的过程中,还要依据实际的情况判断是否适用使用@PosFilter

5. 总结

本文简单对 @PreFilter and @PostFilter注解的使用方法进行了介绍,希望能对你有所帮助。

若想获取更为详细的使用的方法,请参考在本文开头为大家准备的code demo以及同步视频。

分类
Spring Security

自定义Spring安全表达式

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以及同步视频。

分类
Spring Security

Spring Security 表达式 – hasRole 简介

1. 概述

Spring Security使用强大的Spring Expreession Language(SpEL)提供了多种表达式。这些表达式大多都是围绕应用上下文(当前的登录用户)实现的。

Spring Security表达式均是由SecurityExpressionRoot实现的,它是web安全以及在方法上加入验证的基础。

Spring Security 3.0的授权机制中开始使用了SpEL表达式,Spring Security 4.x中沿用了这一机制。你可以在本文中找到关于Spring Security表达式的更多内容。

2. 网站授权(Web Authorization)

Spring Security提供了两个web授权方法:基于URL对整个页面进行授权以及基于安全规则对某个页面的部分内容进行授权。

2.1 对整个页面授权

使用JAVA的配置方式如下:

@Configuration
@EnableWebSecurity
public class SecSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
          .authorizeRequests()
          .antMatchers("/admin/**").hasRole("ADMIN");
    }
    ...
}

注意:Spring Security将为hasRole中的ADMIN自动添加ROLE_前缀。

当用户访问的URL匹配到/admin/**hasRole表达式将校验当前登录用户是否拥有ROLE_ADMIN角色。

2.2 对页面中的部分进行授权

第二种授权方式是基于安全表达式对页面的部分进行授权。

2.2.1 JSP

如果使用的JSP技术,则该功能需要以下 Spring Security JSP taglib依赖的支持:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-taglibs</artifactId>
    <version>5.0.5.RELEASE</version>
</dependency>

使用以下代码来启用taglib支持:

<%@ taglib prefix="security"
  uri="http://www.springframework.org/security/tags" %>

此时便可以在当前页面中使用hasRole表达式了。以下代码展示了:当认证用户拥有ROLE_USER时,将显示第一段文字;拥有ROLE_ADMIN时,将显示第二段文字:

<security:authorize access="hasRole('ROLE_USER')">
    This text is only visible to a user
    <br/>
</security:authorize>
<security:authorize access="hasRole('ROLE_ADMIN')">
    This text is only visible to an admin
    <br/>
</security:authorize>

由于笔者没有使用JSP技术的相关经验,所以以上代码未验证。同时,笔者也没有能力在github仓库中提供相应的JSP示例。

2.2.2 thymeleaf

thymeleaf启用页面部分认证需要加入以下依赖:

		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity5</artifactId>
		</dependency>

在模板中的使用方法如下:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>hasRole在Thymeleaf中的应用</title>
</head>
<body>
<h2>Welcome</h2>
<p>hasRole在Thymeleaf中的应用</p>
<div sec:authorize="hasRole('USER')">user 角色能够查看到此信息</div>
<div sec:authorize="hasRole('ADMIN')">admin 用户能够看到此信息.</div>
<div sec:authorize="isAuthenticated()">
    登录用户能看到此信息
</div>
当前登录的用户是:
<div sec:authentication="name"></div>
如未登录,可以通过访问/admin/test来模拟登录,如想变更登录用户需要重新打开浏览器并重新启动后台。
</body>
</html>

上述代码在模板中应用了hasRole表达式,达到了特定的用户显示特定的信息的目的。

3. 方法级别的授权 - @PreAuthorize

能过特定的注解,Security表达式能够对特定的方法进行授权

@PreAuthorize以及@PostAuthorize (还包含@PreFilter 和 @PostFilter) 注解支持Spring Expression Language(SpEL)并实现了认证授权。

预使上述注解生效,需要使用@EnableGlobalMethodSecurity对Spring Security进行如下配置:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...
}

XML配置如下:

<global-method-security pre-post-annotations="enabled" />

接下来,便可以在方法中使用 @PreAuthorize 注解了:

@Service
public class FooService {
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    public List<Foo> findAll() { ... }
    ...
}

如此以来,只有拥有ROLE_ADMIN的登录用户才能够成功的访问findAll方法。

注意:@PreAuthorize 以及 @PostAuthorize 是基于代理机制生效的,这意味着使用该注解的方法不能够被声明为final,并且其类型必须为public。

4. 在代码中校验角色

可以在JAVA代码直接对当前登录用户的角色进行判断:

@RequestMapping
public boolean someControllerMethod(HttpServletRequest request) {
    return request.isUserInRole("ROLE_ADMIN");
}

上述代码实现了将接收到一个当前登录用户是否拥有ROLE_ADMIN角色的boolean值。

5. 总结

本文对hasRole表达式的几种使用方法进行了介绍,你学会了吗?

分类
Spring Security

详解Spring Security表达式

1. 简介

本文我们将与大家交流Spring Security表达式并给出相应的使用示例。

在学习如ACL等复杂的表达式以前,打下良好的Spring Security表达式的基础是非常有必要的!

本文是对Spring Security 表达式 – hasRole 简介的补充与延伸。

2. Maven依赖

使用Spring Security,需要添加以下依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-web</artifactId>
        <version>5.2.3.RELEASE</version>
    </dependency>
</dependencies>

注意:在Spring Boot中使用时,可以忽略指定版本号。

你可以点击此处获取最新的版本。

如果你没有使用Spring Boot,那么还需要手动添加以下spring-core 以及 spring-context 两个依赖;如果你使用了Spring Boot,Spring Boot将自动我们处理好这一切。

3. 配置

新建一个类并继承WebSecurityConfigurerAdapter:

@Configuration
@EnableAutoConfiguration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityJavaConfig extends WebSecurityConfigurerAdapter {
    ...
}

如果你使用的是XML进行配置,则参考以下代码:

<?xml version="1.0" encoding="UTF-8"?>
<beans:beans ...>
    <global-method-security pre-post-annotations="enabled"/>
</beans:beans>

4. Web 安全表达式(Web Security Expressions)

Sprring Security内置了以下安全表达式:

  • hasRolehasAnyRole
  • hasAuthorityhasAnyAuthority
  • permitAlldenyAll
  • isAnonymousisRememberMeisAuthenticatedisFullyAuthenticated
  • principalauthentication
  • hasPermission

在正式介绍上述表达式以前,让我们建立两个测试用户:user以及admin:

public class SecurityJavaConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("user").password(passwordEncoder().encode("user"))
            .authorities("ROLE_USER")
            .and().withUser("admin").password(passwordEncoder().encode("admin"))
            .authorities("ROLE_ADMIN");
    }
}

当然了,你也可以使用XML来配置以上信息:

<authentication-manager>
    <authentication-provider>
        <user-service>
            <user name="user" password="user" authorities="ROLE_USER"/>
            <user name="admin" password="admin" authorities="ROLE_ADMIN"/>
        </user-service>
    </authentication-provider>
</authentication-manager>
<bean name="passwordEncoder" 
  class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>

需要注意的是自Spring 5开始,需要为Spring Seuirty提从一个 PasswordEncoder 类型的bean(用于密码加密以及验证密码是否正确)。

接下来,依次对各个表达式进行介绍。

4.1 hasRole, hasAnyRole

我们可以使用上述表达式来对访问某些符合一定规则的URL授权。

比如:

@Override
protected void configure(final HttpSecurity http) throws Exception {
    ...
    .antMatchers("/auth/admin/*").hasRole("ADMIN")
    .antMatchers("/auth/*").hasAnyRole("ADMIN","USER")
    ...
}

以上代码实现了:当前登录用户访问任意以/auth/admin打头的地址时,必须拥有ADMIN角色;当当登录用户访问任意以/auth/ 打头的地址时,最少拥有USER或ADMIN角色的其中一个。

使用XML配置如下:

<http>
    <intercept-url pattern="/auth/admin/*" access="hasRole('ADMIN')"/>
    <intercept-url pattern="/auth/*" access="hasAnyRole('ADMIN','USER')"/>
</http>

4.2 hasAuthority, hasAnyAuthority

Spring Security中的Roles和authorities其实差不多。

它们两个唯一的不同便是:使用Roles的时候,系统会自动为其添加ROLE_前缀(自Spring Security 4版本以后)。

所以hasAuthority(‘ROLE_ADMIN') 与 hasRole(‘ADMIN') 是等价的。

@Override
protected void configure(final HttpSecurity http) throws Exception {
    ...
    .antMatchers("/auth/admin/*").hasAuthority("ROLE_ADMIN")
    .antMatchers("/auth/*").hasAnyAuthority("ROLE_ADMIN", "ROLE_USER")
    ...
}

上述两处代码中,我们完全忽略了ROLE_前缀,这么写是完全没有问题的。

以及:

<http>
    <intercept-url pattern="/auth/admin/*" access="hasAuthority('ROLE_ADMIN')"/>
    <intercept-url pattern="/auth/*" access="hasAnyAuthority('ROLE_ADMIN','ROLE_USER')"/>
</http>

同时authorities表达式与roles表达式的区别也仅限于是否自动添加ROLE_前缀,所以初始测试用户的代码还可以这样写:

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("user").password(passwordEncoder().encode("user"))
            .authorities("ROLE_USER")
            .and().withUser("admin").password(passwordEncoder().encode("admin"))
            .authorities("ROLE_ADMIN");
    }

4.3. permitAll, denyAll

这两个表达式也非常的容易理解。它们可以规定禁止/允许任何用户访问某些URL。

比如:

...
.antMatchers("/*").permitAll()
...

上述代码的设置了允许任意用户(无论是登录用户还是未登录的匿名用户)访问以/打头的地址(比如首页)

同样的还可以设置禁止任何人访问系统设置(systemConfig):

...
.antMatchers("/systemConfig").denyAll()
...

使用XML配置的话,示例配置如下:

<http auto-config="true" use-expressions="true">
    <intercept-url access="permitAll" pattern="/*" /> 
    <intercept-url access="denyAll" pattern="/systemConfig" /> 
</http>

4.4. isAnonymous, isRememberMe, isAuthenticated, isFullyAuthenticated

本小节将围绕用户的登录状态展开。当我们想设置:匿名用户(使用匿名信息登录,比如游客1234)的可以访问关于我们(aboutMe)时,使用以下代码:

...
.antMatchers("/aboutMe").anonymous()
...

使用XML的话,配置如下:

<http>
    <intercept-url pattern="/aboutMe" access="isAnonymous()"/>
</http>

如果我们想规定只有登录的用户才能够访问个人中心(personalCenter),则可以使用isAuthenticated() 方法:

...
.antMatchers("/personalCenter").authenticated()
...

使用XML的话,配置如下:

<http>
    <intercept-url pattern="/personalCenter" access="isAuthenticated()"/>
</http>

用户登录时可以通过记住我(RememberMe)以用使用用户名、密码登录两种方式。记住我的登录依赖为cookies,使用这种登录方式避免了每次登录都要输入的用户名、密码。如果你想了解更多关于记往我的登录方式,请点击这里

如果我们想规定允许通过记住我的方式登录站点的用户访问我的信息(message)时,则可以如下用如下代码:

...
.antMatchers("/message").rememberMe()
...

使用XML的话,配置如下:

<http>
    <intercept-url pattern="/message" access="isRememberMe()"/>
</http>

还有一种场景:当用户使用一些特殊敏感的服务时,即使用户当前的登录状态是已登录(使用记住我的方式),我们仍然规定用户必须重新输入用户名、密码等登录信息重新登录一次。

 isFullyAuthenticated()方法便是为解决上述需要而存在的:

...
.antMatchers("/balance").fullyAuthenticated()
...

上述代码实现了只有通过用户名、密码的形式登录的用户才能够访问当前余额界面(balance) ---- 禁止未登录用户以及通过记住我的方式登录的用户。

使用XML的话,配置如下:

<http>
    <intercept-url pattern="/balance" access="isFullyAuthenticated()"/>
</http>

4.5. principal, authentication

principal、authentication表达式可以获取当前认证(匿名)用户的认证主体信息,还可以获取当前上下文中的认证对象。

比如可以使用principal来获取当前登录用户的email、头像等信息(只有登录用户提供的,都可以获取到)。

可以使用authentication获取完整的认证对象,包含该对象被赋予的授权信息。

Spring Security中获取用户的基本信息一文中对上述表达式进行了更详细的介绍。

4.6 hasPermission 接口

表达式旨在为Spring Security表达式与Spring Security的ACL系统架起一座桥梁,通过hasPermission我们可以自定义一些认证的逻辑。

比如在系统中设置一个查看系统信息的后门,仅仅当输入的token值符合一定的算法时才允许访问,否则不允许访问,则示例代码如下:

    @RequestMapping("systemInfo/{token}")
    @PreAuthorize("hasPermission(#token, 'isCorrect')")
    public String systemInfo(@PathVariable String token) {
        ...
    }

上述代码实现了只有当前用户拥有isEditor权限时,才可以执行此方法。

若使上述代码正常工作,还可以在应用上下文中配置一个PermissionEvaluator

<global-method-security pre-post-annotations="enabled">
    <expression-handler ref="expressionHandler"/>
</global-method-security>
 
<bean id="expressionHandler"
    class="org.springframework.security.access.expression
      .method.DefaultMethodSecurityExpressionHandler">
    <property name="permissionEvaluator" ref="customInterfaceImplementation"/>
</bean>

上述配置信息中customInterfaceImplementation需要实现PermissionEvaluator接口。

使用JAVA配置的话,如下:

@Override
protected MethodSecurityExpressionHandler expressionHandler() {
    DefaultMethodSecurityExpressionHandler expressionHandler = 
      new DefaultMethodSecurityExpressionHandler();
    expressionHandler.setPermissionEvaluator(new CustomInterfaceImplementation());
    return expressionHandler;
}

更多详情请参考GIHHUB的code demo。如果你想获取关于自定义表达式的更多内容的话,我们还为你准备了如何自定义安全表达式一文。

5. 总结

本文中我们对Spring Security中的表达式进行了全面的介绍,结合Spring Boot的使用方法以及相关的测试代码请参考github同步代码。希望能对你有所帮助。

分类
Spring Security

Spring 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在方法进行权限认证的方法。

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