分类
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

Spring Bean 注解

1. 概述

本文中我们将讨论Spring中用于定义Bean的几种注解。

在Spring中有多种定义Bean的方式:比如可以使用xml文件来配置bean、在配置类中使用@Bean注解来标识某个方法,或者使用来自于 org.springframework.stereotype 包中的注解来标识当前应用包中的某个类。

2. 扫描组件

扫描组件功能被开启的情况下,Spring能够自动的扫描特定包(包含子包)下的定义的所有bean。

在@Configuration的类上同时使用@ComponentScan注解可以自定义Spring在启动时扫描的包

@ComponentScan(basePackages = "club.codedemo.outpackage")

如上代码上,basePackages属性指明了要扫描的包的名称为club.codedemo.outpackage,则Spring应用在启动时会自动扫描该包下所有的可用Bean。

其实正是由于Spring Boot应用中的@SpringBootApplication包含了@ComponentScan注解,所以Spring Boot项目在启动时会扫描项目启动类所在包以及子包中的所有可用的Bean。

在Spring Boot项目中,往往使用@ComponentScan注解来定义扫描当前Spring Boot项目以外的包

同样的道理,如果我们想在项目启动时扫描某个类(该类必须使用@Configuration标识)中的Bean,则可以使用basePackageClasses属性:

@ComponentScan(basePackageClasses = OutClass.class)

通过观察属性名(basePackages,basePackageClasses)可以猜想出,该属性对应的值是可以接收数组类型的。当有多个包或是类需要被定义时可以如下使用:

@ComponentScan(basePackages = {"club.codedemo.outpackage", "xxx.xxx.xxx"})
@ComponentScan(basePackageClasses = {OutClass.class, Xxxx.class})

如果我们未给@ComponentScan注解传入任何参数则表示:扫描当前文件所在包以及子包下的所有Bean。

自java8开发,允许我们在同一个类上重复使用某一注解,比如我们可以重复使用@ComponentScan注解来标识ComponentScanConfig类:

@ComponentScan(basePackages = "club.codedemo.outpackage")
@ComponentScan(basePackageClasses = OutClass.class)
public class ComponentScanConfig {

如果你不喜欢这种方式,还可以使用@ComponentScans来合并多个@ComponentScan

@ComponentScans({
        @ComponentScan(basePackages = "club.codedemo.outpackage"),
        @ComponentScan(basePackageClasses = OutClass.class)
})
public class ComponentScanConfig {

当使用xml配置时,代码也很简单:

<context:component-scan base-package="club.codedemo.outpackage" />

注意:受限于笔者水平,使用xml文件进行配置的方法并未在示例代码中体现。

3. @Component 注解

@Component注解作用于类中。Spring在进行Bean扫描时,能够检测到使用@Component注解的类。

比如:

@Component
public class Student {
}

默认情况下Spring在实例化一个Student作为Bean放置到自己管理的容器中,并且使用Student的首字小写(student)来作用bean的名称。如果你想自定义该bean的名称,则可以设置@Component注解的value属性:

@Component(value = "student")
public class Student {
}

而由于@Repository@Service@Configuration 以及 @Controller均是@Component的元注解,所以上述注解拥有@Component的特性及"待遇"。Spring应用在进行组件扫描时,也将扫描上述注解并按相同的命名规则来命名相应的bean。

4. @Repostiory 注解

在Spring应用中,一般使用DAO或是数据仓库Repository来充当数据访问层,进而完成与数据库的交互功能。我们往往使用@Repository注解来标识属于该层的类:

@Repository
public class StudentRepository {

@Repository注解可以自动对其内部发生的异常进行转换。比如当我们使用Hibernate作用JPA的实现时, Hibernate在数据操作中发生的异常被自动被捕获并转换为Spring中的DataAccessException异常子类被抛出。这种方式使我们能够使用统一的方式来处理数据访问层的异常。

预开启上述异常转换,则还需要声明一个PersistenceExceptionTranslationPostProcessor bean:

    @Bean
    public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
        return new PersistenceExceptionTranslationPostProcessor();
    }

XML配置如下:

<bean class=
  "org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor"/>

5. @Service 注解

一般情况下,我们会使用@Service来标识一个负责处理业务逻辑的service layer服务层。

@Service
public class CourseServiceImpl implements CourseService {
}

6. @Controller 注解

@Controller用于标识Spring MVC中的控制器:

@Controller
public class StudentController {
}

7. @Configuration 注解

当某个类想在方法中中使用@Bean注解定义bean时,则需要在该类上启用@Configuration注解

@Configuration
public class ComponentScanConfig {
 
    @Bean
    public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
        return new PersistenceExceptionTranslationPostProcessor();
    }
}

8. Stereotype固有注解以及AOP

当我们使用Spring的固有注解时,我们可以非常轻松地创建一个针对Spring固有注解的切点。

比如在实际的生产项目中我们需要获取用户在使用过程中的慢查询。则可以配合@AspectJ注解如下实现:

@Aspect
@Component
public class PerformanceAspect {

    @Pointcut("within(@org.springframework.stereotype.Repository *)")
    public void repositoryClassMethods() {}

    @Around("repositoryClassMethods()")
    public Object measureMethodExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取切点方法执行的起始、结束时间
        long start = System.nanoTime();
        Object returnValue = joinPoint.proceed();
        long end = System.nanoTime();

        // 获取切点方法的类名、方法名并打印执行时间
        String className = joinPoint.getTarget().getClass().getName();
        String methodName = joinPoint.getSignature().getName();
        Long costTime = TimeUnit.NANOSECONDS.toMillis(end - start);
        System.out.println("执行:" + className + "->" + methodName +
                "耗时:" + costTime  + "ms");

        if (costTime > 1000) {
            // 耗时大于1秒的认为是慢查询,根据实际情况进行后续操作
            // 比如可以实时的短信预警、钉钉预警、邮件预警、推送到日志服务器等
        }

        return returnValue;
    }
}

上述代码中我们创建了一个切点,该切点的作用范围是:以@Repository为注解的类下的所有方法。然后使用了@Around 注解来关联该切点并拦截对应的方法、记录方法执行的时间等。

如上述代码注释所示,当执行花费的时间大于设定的上限时,我们则可以根据实现的需求发送相应的预警信息。

9. 总结

本文介绍了定义Bean的几种注解。同时介绍了自定义扫描包、类的方法。

在文章的最后以@Repository为例,定义了AOP切面并完成了获取慢查询的方法。通过该方法不难看出:数据访问层可以完全的专注了数据操作,而AOP切面则可以完全关注于查询时间。这或许就是程序开发时关注点分离的具体体现吧。

分类
spring-boot

Spring Boot Starters简介

1. 概述

在复杂项中,依赖管理显得非常的关键。手动管理依赖往往并不理想,项目越大依赖往往越多,依赖越多,产生交差依赖的可能性就越大。我们往往不得不在项目的依赖管理上花费大量的时间与精力。当然了,在依赖管理上花费的时间越多,也同时意味着在其它的方面所花的时间就越少。

Spring Boot Starters旨在解决上述问题。Spring Boot提供了超过30个Starters来解决自动依赖的问题。以下将展示较常见的几个。

2. Web Starter

通常在开发一个REST服务时,我们需要添加诸如:Spring MVC,Tomcat以及Jackson等等多种依赖。

而Spring Boot Starters可以简化这一切 ---- 添加一个starter:

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

然后,便可以创建REST控制器了(为了简单起见,我们将不使用数据库,而是专注于REST控制器):

@RestController
@RequestMapping("student")
public class StudentController {
    private List<Student> students = new ArrayList<>();

    @GetMapping
    public List<Student> all() {
        return this.students;
    }

    @PostMapping
    public void add(@RequestBody Student student) {
        this.students.add(student);
    }

    @GetMapping("{id}")
    public Student findById(@PathVariable Long id) {
        return this.students.stream()
                .filter(student -> student.getId().equals(id))
                .findFirst().get();
    }
}

Student是一个简单的bean,其id为Long类型,name为String类型。

public class Student {
    private Long id;
    private String name;

    public Student(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

运行应用后,便可以访问:http://localhost:8080/student/来查看控制器是否正常工作了。

如此,通过引用唯一的依赖spring-boot-starter-web,我们轻松的创建了一个具有最小配置的REST应用程序。

3. Test Starter

启用项目单元测试时,我们通常需要使用以下一组库:Spring Test、JUnit、Hamcrest以及Mockito。我们当然可以手动来包含这些库并指名其版本号,但使用Spring Boot starter会显得更加简单:

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

注意:你无需指定artifact的版本号。Spring Boot将确定要使用的版本 ---- 根据pom.xml中的spring-boot-starter-parent artifact版本可。如果后期需要升级Spring Boot库以及依赖,在使用Spring Boot Starter的基础上,只需要升级spring-boot-starter-parent的版本的即可,至于其它库的版本将会由Spring Boot Starter自动处理。

下面,让我们使用刚刚引用的单元测试来测试一下StudentController。

对控制器进行测试有两种方式供我们选择:

  1. 使用模拟环境
  2. 使用嵌入式Servlet容器(例如Tomcat或Jetty)

在此示例中,我们将使用模拟环境

package club.codedemo.springbootstarters.controller;

import club.codedemo.springbootstarters.entity.Student;
import org.hamcrest.Matchers;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@AutoConfigureMockMvc
class StudentControllerTest {
    @SpyBean
    StudentController studentController;

    @Autowired
    MockMvc mockMvc;

    @BeforeEach
    void beforeEach() {
        this.studentController.students =
                new ArrayList<>();
        this.studentController.students.add(new Student(1L, "zhangsan"));
        this.studentController.students.add(new Student(2L, "lisi"));
    }

    @Test
    void all() throws Exception {
        this.mockMvc.perform(MockMvcRequestBuilders.get("/student/"))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$", Matchers.hasSize(2)))
                .andExpect(MockMvcResultMatchers.jsonPath("$[0].id").value(1))
                .andExpect(MockMvcResultMatchers.jsonPath("$[0].name").value("zhangsan"))
                .andExpect(MockMvcResultMatchers.jsonPath("$[1].id").value(2))
                .andExpect(MockMvcResultMatchers.jsonPath("$[1].name").value("lisi"))
        ;
    }

    @Test
    void add() throws Exception {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("id", "3");
        jsonObject.put("name", "wangwu");

        this.mockMvc.perform(
                MockMvcRequestBuilders.post("/student/")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(jsonObject.toString()))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.status().isCreated());

        Assertions.assertEquals(3, this.studentController.students.size());
    }

    @Test
    void findById() throws Exception {
        this.mockMvc.perform(
                MockMvcRequestBuilders.get("/student/1"))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.id").value(1))
                .andExpect(MockMvcResultMatchers.jsonPath("$.name").value("zhangsan"))
        ;
    }
}

上述代码分别对all、save以及findById方法进行了测试。其中有属于spring-test 模块@AutoConfigureMockMvc等注解;有属于Hamcrest的hasSize()匹配器;有属于JUnit的而@BeforeEach注解以及属于mockito的SpyBean注解。重要的是:这些依赖都是test starter帮我们自动引入的。

4. Data JPA Starter

大多数的应用程序都依赖于数据库,Spring Boot提供的Data JPA Starter能够快速的完成对数据库的依赖:

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

值得一提的是:在引入Data JPA Starter以后我们必须为其提供一个可用的数据库。Spring Boot 可以零配置的支持H2, Derby 以及 Hsqldb数据库,比如我们在项目中添加h2数据库:

<dependency>
	<groupId>com.h2database</groupId>
	<artifactId>h2</artifactId>
	<scope>runtime</scope>
</dependency>

然后定义一个实体:

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;

    public Student() {
    }

一个数据仓库:

public interface StudentRepository extends JpaRepository<Student, Long> {
}

最后进行单元测试:

@DataJpaTest
class StudentRepositoryTest {

    @Autowired
    StudentRepository studentRepository;

    @Test
    void saveAndFind() {
        Student student = new Student();
        student.setName("zhangsan");
        this.studentRepository.save(student);
        assertNotNull(student.getId());

        student = this.studentRepository.findById(student.getId()).get();
        assertEquals("zhangsan",
                student.getName());
    }
}

如你所见,我们引用h2数据库后并没有进行任何配置,Data JPA Starter在检测到H2数据库后,自动的完成了这一切。

5. 自定义配置

Spring Boot Starters在提供了便利性的同时,并没有抹杀用户的自定义配置。比如我们可以如下启用h2控制台并修改系统默认生成的数据库实例名称:

# 启用h2控制台
spring.h2.console.enabled=true

# 将H2数据库名称变更为testdb
spring.datasource.url=jdbc:h2:~/testdb

此时启动应用后便可以访问http://localhost:8080/h2-console来打开H2数据库的登录界面,并将JDBC URL一项更改为jdbc:h2:~/testdb来查看数据库信息了。

6. 总结

本文中我们对Spring Boot 中的Starter进行了简单的介绍。在Spring Boot Starter的帮助下,我们能够:

  • 瘦身pom文件,使其更易读,更易维护。
  • 一站式的配置好开发、测试、生产所需要的依赖。
  • 规避依赖冲突、循环依赖等问题,大幅缩短项目配置时间。

Spring Boot除提供本文提供的Web Starter、Test Starter以及Data JPA Starter以外,还提示了一系列的Starter供我们使用。

分类
spring-boot

Spring Boot 创建自定义自动配置

1. 概述

Spring Boot提供了一种自动配置的机制,它可以根据当前的Spring应用所加载的依赖项对项目进行自动的配置。比如当Spring Boot检测到项目仅依赖于H2数据库时,将自动启动H2数据库做为项目的默认数据库。

该自动配置机制的存在无疑使得应用开发起来更轻松、更简单。本文我们将围绕Spring Boot的自动配置展开阐述。

2. 版本信息

本文基于Spring Boot版本为2.3.3.RELEASE,采用java1.8

3. 按是否存在特定的Class配置

按Class判断是否加载配置信息指:

  1. 当前Spring项目中存在指定的class时,加载配置信息,否则不加载配置信息。使用 @ConditionalOnClass 注解。
  2. 当前Spring项目中不存在指定的class时,加载配置信息,否则不加载配置信息。使用 @ConditionalOnMissingClass 注解。

3.1 @ConditionalOnClass 适用环境

@ConditionalOnClass接收的参数为Class<?>,也就是我们需要如下使用:@ConditionalOnClass<Student.class>。这同时意味着如果该语句能过顺利的通过编译器,首先要保证Student.class是存在的。

细想下会发现以下问题:只有当Student.class存在,@ConditionalOnClass<Student.class>才能通过编译,项目才能成功启动;而当Student.class不存在时,@ConditionalOnClass<Student.class>不能通过编译,项目同时无法启动。

再总结一下:使用@ConditionalOnClass<Student.class>注解时,只有存在Student.class时编译才能通过。也就是说使用了@ConditionalOnClass<Student.class>注解的项目,能启动的前提是存在Student.class。那么在@ConditionalOnClass<Student.class>注解下的方法或是类恒为ture,当然也就失去了使用此注解的意义。

其实该注解的用武之地并不是普通的Spring项目,而是基于Spring开发的第三方包。@ConditionalOnClass(Class<?>)中的Class往往是指其它的依赖中的类。假设开发一个发送短信的第三方jar包,开发的思想为:如果依赖于该包的项目同时依赖于阿里短信,则启动短信发送功能。

@ConditionalOnClass(AliSmsService.class)
public class AliSmsAutoconfigration {
    // 配置相关的BEAN
}

则其它依赖于上述第三方包的Spring应用如果依赖了阿里短信,则会启动该配置文件,从而达到了:如果该Spring应用依赖阿里短信,则启用短信发送功能,否则不启动短信功能的目的。

3.2 使用方法

Spring Boot在启动时,会扫描所依赖包的资源文件:resources/META-INF/spring.factories,并根据该文件中的相应值加载自动配置文件:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=club.codedemo.springbootcustomautoconfiguration.MyzAutoConfiguration

MyzAutoConfiguration

@Configuration
@ConditionalOnClass(YunzhiService.class)
public class MyzAutoConfiguration {
    @Bean
    SmsService smsService() {
        return new SmsServiceMyzImpl();
    }
}

上述代码使用@Configuration表示当前为配置类,使用@ConditionalOnClass(YunzhiService.class)表示当项目中存在于YunzhiService时,该配置文件下生效。YunzhiService依赖于com.mengyunzhi.core

<dependency>
	<groupId>com.mengyunzhi</groupId>
	<artifactId>core</artifactId>
	<version>2.1.7.0</version>
</dependency>

此时其它依赖于本第三方包的应用如果同时依赖了com.mengyunzhi.core,则将自动配置MyzAutoConfiguration。接下来便可以在该项目中注入SmsService了。

3.3 ConditionalOnMissingClass

@ConditionalOnClass不同,@ConditionalOnMissingClass注解收到的参数为String而非Class<?>,表示:当某个类不存在时....。

@Configuration
@ConditionalOnMissingClass("com.mengyunzhi.core.service.YunzhiService")
public class OnMissingClassAutoConfiguration {
    @Bean
    SmsService smsService() {
        return new SmsServiceErrorImpl();
    }
}

如果当前项目的定位为第三方包,则还应该将其加入到resources/META-INF/spring.factories中,以使依赖于该包的Spring项目能够启用该自动配置文件:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=club.codedemo.springbootcustomautoconfiguration.MyzAutoConfiguration,club.codedemo.springbootcustomautoconfiguration.OnMissingClassAutoConfiguration

注意:两个文件以,相隔。

3.4 加载顺序

当存在多个自动配置文件时,还可以使用@AutoConfigureOrder(int 权重)来指定其加载的顺序,比如将MyzAutoConfiguration的加载顺序设置为最前(优先级最高):

@ConditionalOnClass(YunzhiService.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
public class MyzAutoConfiguration {

其中权重的范围为:-21474836482147483647,值越小则权重越高,加载的顺序越靠前。

注意:必须将@AutoConfigureOrder注解的类同步添加到resources/META-INF/spring.factories中时,该注解才会生效。

4. 按是否存在特定的Bean配置

可以使用@ConditionalOnBean@ConditionalOnMissingBean来根据某个Bean是否存在来选择加载配置项。

@Configuration
@AutoConfigureOrder(1)
public class BeanConditionalAutoConfiguration {

    /**
     * 当前容器中存在SmsService时生效
     * @return 邮件服务
     */
    @Bean
    @ConditionalOnBean(SmsService.class)
    public EmailService emailService() {
        return new EmailServiceImpl();
    }

    /**
     * 当前容器中 不 存在SmsService时生效
     * @return 邮件服务
     */
    @Bean
    @ConditionalOnMissingBean(SmsService.class)
    public EmailService customEmailService() {
        return (address, title, description) -> {
            throw new RuntimeException("未找到默认的emailService实现");
        };
    }
}

上述代码的作用是:按SmsService Bean是否存在为项目装配不同的EmailService实现。

5. 按配置项自动配置

在Spring中可以使用@ConditionalOnProperty注解来关联相应的配置文件,比如在classPath中存在ding.properties配置文件,则可以使用@ConditionalOnProperty注解来完成配置文件与类之间的关联。

@PropertySource("classpath:ding.properties")
@Configuration
public class ConditionalOnPropertyAutoConfiguration {

@PropertySource关联ding.properties,@Configuration以表明此类为配置类。

则可以根据ding.properties中的属性值来决定自动装配的情况:

@PropertySource("classpath:ding.properties")
@Configuration
public class ConditionalOnPropertyAutoConfiguration {

    /**
     * 当url 值为alibaba 时,装配此bean
     * bean名起为dingService
     * @return
     */
    @Bean(name = "dingService")
    @ConditionalOnProperty(
            name = "url",
            havingValue = "alibaba")
    DingService dingService() {
        return message -> {
            // 处理钉钉消息
        };
    }

    /**
     * 当URL值为codedemo时,装配此bean
     * bean名称为ding1Service
     * @return
     */
    @Bean(name = "ding1Service")
    @ConditionalOnProperty(
            name = "url",
            havingValue = "codedemo")
    DingService dingService1() {
        return message -> {
            // 处理钉钉消息
        };
    }
}

6. 按配置文件存在与否自动配置

除了可以根据配置文件中的属性来进行自动配置外,还可以根据是否存在某个配置文件来配置。

比如当存在codedemo.properties时,装配ding3Service:

@Configuration
@ConditionalOnResource(resources = "classpath:codedemo.properties")
public class ConditionalOnResourceAutoConfiguration {
    @Bean("ding2Service")
    DingService dingService() {
        return (message) -> {

        };
    }
}

当存在alibaba.properties时,装配ding2Service:

@Configuration
@ConditionalOnResource(resources = "classpath:alibaba.properties")
public class ConditionalOnResourceAutoConfiguration1 {
    @Bean("ding3Service")
    DingService dingService() {
        return (message) -> {

        };
    }
}

7. 自定义自动配置条件

当Spring提供的自动配置条件注解不能满足我们的要求时,还可以自定义自动配置条件。

自定自动配置条件仅需要继承SpringBootCondition抽象类并重写其getMatchOutcome() 方法:

public class CustomerConditionTrue extends SpringBootCondition {

    @Override
    public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
        // 第一个参数返回true,表示该注解下的配置生效。生产条件应该根据当前情景动态计算出true或false
        return new ConditionOutcome(true, "message");
    }
}

在方法上使用该注解:

    @Bean("ding4Service")
    @Conditional(CustomerConditionTrue.class)
    DingService ding4Service() {
        return message -> {

        };
    }

8. 根据是否为WEB应用进行配置

还可以通过 @ConditionalOnWebApplication 以及 @ConditionalOnNotWebApplication注解以达到:当前应用为web应用时自动配置某些bean,以及当前应用非web应用时,自动配置某些bean的目的。

9. 禁用自动配置类

有些时候我们并不希望某些自动配置类在本项目中生效,则可以将该类加入到@EnableAutoConfiguration注解的exclude属性中:

//@SpringBootApplication
@SpringBootConfiguration
@EnableAutoConfiguration(exclude = DisableAutoConfiguration.class)
@ComponentScan(excludeFilters = { @ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public class SpringBootCustomAutoConfigurationApplication {

注意:被排除的类必须存在于resources/META-INF/spring.factories文件的org.springframework.boot.autoconfigure.EnableAutoConfiguration属性中。

由于某些原因@SpringBootApplication不能够与@EnableAutoConfiguration同时使用,导致上述代码看起来比较臃肿。所以在条件允许的情况下,推荐使用配置项目的spring.autoconfigure.exclude来替换上述使用注解的方案:

spring.autoconfigure.exclude=club.codedemo.springbootcustomautoconfiguration.DisableAutoConfiguration

10. 总结

本文阐述了Spring提供的几种自动配置方法。合理的规划自动配置可以提升项目的健壮性与适用性,而Spring自动配置则是一把利器,使用Spring自动配置往往能起到事半功倍的效果。

分类
spring spring-boot

Spring Boot注解

1、概述

Spring Boot自动配置的特性使得Spring在配置上很简约。本文中,我们将围绕Spring中两个核心包org.springframework.boot.autoconfigure以及org.springframework.boot.autoconfigure.condition包中的注解展开介绍。

2、@SpringBootApplication

@SpringBootApplication是Spring Boot项目接触到的第一个注解,用与标识应用的启动主类。

@SpringBootApplication
class SpringBootAnnotations {
 
    public static void main(String[] args) {
        SpringApplication.run(SpringBootAnnotations.class, args);
    }
}

从根本上讲@SpringBootApplication其实是@Configuration,@EnableAutoConfiguration和@ComponentScan三个注解的缩写,也可以认为@SpringBootApplication封装了上述三个注解。稍有些不同的是,@SpringBootApplication在封装@ComponentScan注解时,加入了一些默认的属性:

@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(
		excludeFilters = {@ComponentScan.Filter(
				type = FilterType.CUSTOM,
				classes = {TypeExcludeFilter.class}
		), @ComponentScan.Filter(
				type = FilterType.CUSTOM,
				classes = {AutoConfigurationExcludeFilter.class}
		)}
)

也就是说在 Spring Boot项目中,完全可以使用上述注解代码段来代替@SpringBootApplication。当然了,实际的项目中肯定没有人这么做,试想谁又会舍近求远放着方便的不用,非要把简单的事情复杂化呢。

下面,让我们深入的了解一下Spring Boot的核心注解。

3、@EnableAutoConfiguration

@EnableAutoConfiguration的英文原意是:启用自动配置。该注解使得Spring Boot可以根据环境来自动检测并对项目进行一些自动化设置。比如当我们在pom.xml中同时引入Spring Data JPA以及H2数据库时,@EnableAutoConfiguration则会将Spring Data JPA的数据源自动的配置为H2。

需要注意的是该注解必须与@Configuration一起使用:

@Configuration
@EnableAutoConfiguration
class SpringBootAnnotationsConfig {}

4、条件注入(配置)

通常情况下,我们需要针对不同的环境(条件)来启用不同的配置,这一般被称为条件注入,可以借助本节中的条件注解来实现。

我们可以在使用@Configuration注解的类或使用@Bean注解的方法上放置条件注解,从而达到在特定的情况下使用特定的类或特定的方法的目的。在此,本文仅对其基本的使用方法进行介绍,预了解更多有关于本方面的知识,请访问此文

4.1. @ConditionalOnClass 以及@ConditionalOnMissingClass注解

@ConditionalOnClass表示:当某些类存在时,启用当前的配置;相反@ConditionalOnMissingClass表示:当某些类不存在时,启用当前配置。

@Configuration
@ConditionalOnClass(DataSource.class)
class MySQLAutoconfiguration {
    public MySQLAutoconfiguration() {
        System.out.println("DataSource.class存在")}
}

以下代码实现了:当DataSource.class存在时,Spring将使用该bean。

@Configuration
@ConditionalOnMissingClass("club.codedemo.springbootannotations.DataSource")
public class MySQLAutoconfiguration {
    public MySQLAutoconfiguration() {
        System.out.println("DataSource.class不存在“); }
}

以上代码实现了:当DataSource.class不存在时,Spring将使用该bean

4.2. @ConditionalOnBean以及@ConditionalOnMissingBean注解

除了可以基于类存在与否进行条件注入以外,还可以根据Bean是否存在来进行条件注入:

@Bean
@ConditionalOnBean(name = "dataSource")
LocalContainerEntityManagerFactoryBean entityManagerFactory() {
    // ...
}

以上代码实现了:当名称为dataSource的bean存在时,注入本方法返回的Bean。

@Bean
@ConditionalOnMissingBean(name = "dataSource")
LocalContainerEntityManagerFactoryBean entityManagerFactory() {
    // ...
}

以上代码实现了:当名称为dataSource的bean不存在时,注入本方法返回的Bean。

具体的验证代码请参阅本文提供的code demo。

4.3. @ConditionalOnProperty

使用@ConditionalOnProperty可以实现依据项目的属性值来进行注入。

@Bean
@ConditionalOnProperty(
    name = "usemysql", 
    havingValue = "local"
)
DataSource dataSource() {
    // ...
}

以上代码实现了:只有在项目的配置信息满足usemysql值为local时,注入该Bean。

4.4. @ConditionalOnResource

除根据项目的属性进行条件注入外,还可以根据项目资源文件夹中是否存在某配置文件来进行注入:

@Bean
//资源文件夹中存在mysql.properties文件时,该Bean生效
@ConditionalOnResource(resources = "classpath:mysql.properties")
Properties additionalProperties() {
    // ...
}
4.5.@ConditionalOnWebApplication以及@ConditionalOnNotWebApplication注解

@ConditionalOnWebApplication以及@ConditionalOnNotWebApplication注解可以基于当前应用程序是否为Web应用程序来进行配置。

@Bean
@ConditionalOnWebApplication
HealthCheckController healthCheckController() {
    // ...
}

如果当前应用属于Web应用,则上述Bean生效。

4.6. @ConditionalExpression

处理些稍复杂的注入需求,还可以使用@ConditionalExpression结合SpEL表达式来完成:

@Bean
@ConditionalOnExpression(${usemysql} &amp;&amp; ${mysqlserver == 'local'})
DataSource dataSource() {
    // ...
}

@ConditionalOnExpression注解接收的是SpEL表达式,当该表达式返回true时,该Bean生效;返回false,不生效。

4.7. @Conditional

对于更复杂的条件,还可以通过@Conditional结合创建自定义类的方式来实现:

@Conditional(CustomCondition.class)
Properties additionalProperties() {
    //...
}

5. 结论

本从由SpringBootApplication注解入手,对Spring Boot的条件配置进行了讲解。在实际的使用过程中,还需要根据项目的实际情况选择适合的技术。我们说适用的就是最好的,切不可在实际的项目为了实现技术而实现技术。

本文资源:

分类
Spring MVC

在Spring中使用Thymeleaf模板引擎

1. 概述

Thymeleaf是一款优秀的JAVA模板引擎。该引擎工作在服务端,能够处理HTML、XML、JavaScript、css甚至纯文本。Thymeleaf拥有良好的扩展性,与其他流行的模板引擎(例如JSP)相比,拥有更快的开发效率,特别是在团队合作开发中。

本文将阐述如何在Spring MVC应用中的视图层中使用Thymeleaf模板引擎。

2. Thymeleaf与Spring集成

Thymeleaf能够非常轻松的与Spring结合在一起,在Spring MVC项目中,只需要引入依赖spring boot提供的Thymeleaf即可:

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

如果你没有使用Spring Boot,则可参考官方文档进行配置。当然了,那将比使用Spring Boot麻烦的多。

3. 显示多语言文件(Message Source)中的值

th:text=”#{key}”标签可以显示多语言文件中的值。Spring Boot项目默认加载messages多语言系列源。若加载自定义的多语言源,则可以参考以下实现

    /**
     * 手动注册多国语言文件messages
     * Spring Boot其实已经默认注册了messages,所以删除该方法后不会对应用造成影响
     * 在此的代码仅做演示用
     *
     * @return
     */
    @Bean
    public ResourceBundleMessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("messages");
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }

在Thymeleaf模板中,便可如下使用:

<h1 th:text="#{welcome}"></h1>

则H1中的值将根据当前用户的地区及语言,对应显示不同的内容。比如对简体中文用户显示“您好”,而对英文用户显示"welcome"。

4. 显示模型属性

4.1 简单属性

th:text=”${attributename}”用于显示模型model上的属性。比如我们在模型上添加当前用户currentUser

model.addAttribute("currentUser", "zhangsan");

则在Thymeleaf模板中可以如下显示:

<h2 th:text="${currentUser}"></h2>

4.2 集合Collection属性

如果model上的某个属性类型为集合Collection,则可以使用th:each来达到遍历集合的目的。比如有学生Student类有如下字段:

public class Student {

    private Long id;
    private String name;
    private Boolean sex;
    
    // 省略getter/setter

控制器中绑定如下:

List<Student> students = new ArrayList<>();
for (int i = 0; i < 10; i++) {
    students.add(new Student(Long.valueOf(i), "name" + i, i % 2 == 0));
}
model.addAttribute("students", students);

则在Themeleaf模板中如下进行遍历:

<tr th:each="student: ${students}">
    <td th:text="${student.id}"></td>
    <td th:text="${student.name}"></td>
</tr>

5. 条件判断

5.1 if以及unless

th:if=”${condition}”用以当condition成立时进行显示。相反的th:unless=”${condition}” 用以当condition不成立时显示。

比如我们可以如下显示性别:

    <td th:if="${student.sex}" th:text="男"></td>
    <td th:unless="${student.sex}" th:text="女"></td>

5.2 switch以及case

th:switch和th:case的用法当然也不能理解,比如可以使用switch、case如下显示性别:

    <td th:switch="${student.sex}">
        <span th:case="true" th:text="男"></span>
        <span th:case="false" th:text="女"></span>
    </td>

6. 处理输入

Form表单输入可以使用 th:action=”@{url}”以及th:object=”${object}” 轻松完成。th:action用于表示Form表单的提交地址,th:object用以标注Form表单绑定的对象。在表单中直接使用th:field=”*{name}” 来绑定表单中的字段,其中name关键字与object上的属性相对应。比如新建如下添加学生表单:

<form action="#" th:action="@{/student/save}" th:object="${student}" method="post">
    <table border="1">
        <tr>
            <td>ID</td>
            <td><input type="number" th:field="*{id}"/></td>
        </tr>
        <tr>
            <td>姓名</td>
            <td><input type="text" th:field="*{name}"/></td>
        </tr>
        <tr>
            <td><input type="submit" value="提交"/></td>
        </tr>
    </table>
</form>

上述代码中th:action上的/student/save标明了该表单的提交地址,th:object上的student则是该表单绑定的对象。

然后在C层中可以如下接收:

    @PostMapping("save")
    String save(Student student) {
        // 处理新增学生逻辑
    }

7. 显示校验信息

借助于spring-boot-starter-validation,可以轻松的完成输入数据的校验工作:

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

在C层中完成校验仅仅需要加入@Valid注解以及注入BingdingResult:

    @PostMapping("save")
    String save(@Valid Student student, BindingResult errors) {
        if (errors.hasErrors()) {
            return "student/add";
        }

        // 具体持久化学生的代码略过

        return "redirect:/student/success";
    }

在模板中,可以使用#fields.hasErrors()方法来获取是否发生校验错误;使用#fields.hasErrors(final String field), #fields.errors(final String field)方法来更精确到获取到某个字段是否发生校验错误;使用th:errors 属性来显示具体的错误信息:

        <tr>
            <td>ID</td>
            <td><input type="number" th:field="*{id}"/></td>
            <td th:if="${#fields.errors('id')}" th:errors="*{id}">id校验信息</td>
        </tr>
        <tr>
            <td>姓名</td>
            <td><input type="text" th:field="*{name}"/></td>
            <td th:if="${#fields.hasErrors('name')}" th:errors="*{name}">name校验信息</td>
        </tr>

此外#fields.errors()还可以接收*all做为参数值,表示:发生的所有的错误的集合。比如实现遍历错误信息并依次输出:

    <h1 th:if="${#fields.hasErrors()}">发生错误</h1>
    <ul>
        <li th:each="err : ${#fields.errors('*')}" th:text="${err}"/>
    </ul>

或者:

<li th:each="err : ${#fields.errors('all')}" th:text="${err}"/>

或者传入global来获取全局的错误信息:

<li th:each="err : ${#fields.errors('global')}" th:text="${err}" />

8. 数据转换

在Thymeleaf模板中,可以使用{{}}来格式化输出字段。比如我们在输出学生姓名name时,将首字母进行大写,则可以建立实现Formatter接口的如下NameFormatter:

/**
 * 名称首写字大写转换器
 */
public class NameFormatter implements Formatter<String> {

    @Override
    public String parse(String s, Locale locale) throws ParseException {
        if (s != null &amp;&amp; !s.isEmpty()) {
            return s.toUpperCase().charAt(0) + s.substring(1);
        }

        return "";
    }

    @Override
    public String print(String s, Locale locale) {
        return s;
    }
}

并于配置文件中注册该转换器:

@Configuration
public class SpringWebConfig implements WebMvcConfigurer {

    /**
     * 注册转换器
     *
     * @param registry 转换器注册商
     */
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addFormatter(new NameFormatter());
    }

然后便可以在模板中引入{{}}使得Thymeleaf自动调用该转换器了:

<td th:text="${{student.name}}"></td>

除按字段名进行转换外,还支持按类型进行转换。比如我们新建如下StudentFormatter用以显示Student类型:

/**
 * 学生实体 转换器
 */
public class StudentFormatter implements Formatter<Student> {

    /**
     * 将字符串转换为Student实体
     * 本例中未使用该方法,直接抛出异常
     *
     * @param text   字符器
     * @param locale 地区
     * @return
     * @throws ParseException
     */
    @Override
    public Student parse(String text, Locale locale) throws ParseException {
        throw new RuntimeException("方法未实现");
    }

    /**
     * 将学生实体转为输出的字符串
     * @param student 学生
     * @param locale 地区
     * @return
     */
    @Override
    public String print(Student student, Locale locale) {
        return student.getId().toString() + "-" + (student.getSex() ? "男" : "女") + "-" + student.getName();
    }
}

注册如下:

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addFormatterForFieldType(Student.class, new StudentFormatter());
        registry.addFormatter(new NameFormatter());
    }

便可在模板中使用如下代码来显示学生的整体信息:

<td th:text="${{student}}"></td>

最后,还可以使用#conversions 模板中将对象的进行强制转换。#conversions.convert(Object, Class)语法表示将Object转换为Class类型。比如我们将性别sex字段由Boolean类型转换为String类型:

<td th:text="${#conversions.convert(student.sex, 'String')}"></td>

9. 总结

本文对Spring MVC应用集成Thymeleaf模板引擎进行了简单的介绍。Thymeleaf模板引擎可以基于Spring MVC快速的创建应用程序,数据绑定方式灵活、易用。

最后,我们一如既往的为本文提供了完整、可运行的code demo,希望能对你有所帮助。

分类
Spring Security

Spring Security如何获取当前登录用户

1. 前言

本文将展示几种在Spring Security中获取当前登录用户的方法。

在Spring中有多种获取当前登录用户的方法,下面我们开始介绍几种较常见的方法。

2. 直接获取

最直接的获取方法是调用SecurityContextHolder的静态方法:

/**
 * 获取当前登录用户
 * @return
 */
String getCurrentLoginUser() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    return authentication.getName();
}

上述代码并不完美:如果当前没有用户登录,则获取到的authentication的值为null,上述代码将发生RuntimeException异常。所以如果我们不能保证代码运行时肯定有用户已登录,则需要加入null判断:

/**
 * 获取当前登录用户
 * @return
 */
String getCurrentLoginUser() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (authentication == null) {
        throw new RuntimeException("当前无用户登录");
    } else {
        return authentication.getName();
    }
}

本方案虽然可行,但对单元测试的支持并不友好。所以在大多时候,我们并不推荐直接这么使用。

3. 在控制器中获取

如果一个类使用了@Controller注解被声明为了控制器,那么在该类的方法中可以直接声明Principal来获取当前认证用户:

@Controller
public class SecurityController {
    
    @GetMapping("/username")
    @ResponseBody
    public String currentUserName(Principal principal) {
        return principal.getName();
    }
}

当然了,也可以声明为Authentication:

    @RequestMapping(value = "/username1", method = RequestMethod.GET)
    @ResponseBody
    public String currentUserName1(Authentication authentication) {
        return authentication.getName();
    }

在Authentication中将认证用户类型声明为了 Object,这使得我们可以将任意的类型传入到Authentication中。这充分的保障了系统的灵活性,但同时也要求我们要使用时进行必要的类型转换,比如Spring Security中设置的类型为UserDeatils,则需要进行如下转换:

UserDetails userDetails = (UserDetails) authentication.getPrincipal();
System.out.println("User has authorities: " + userDetails.getAuthorities());

或者还可以通过注入的HttpServletRequest来直接获取认证用户:

    @RequestMapping(value = "/username2", method = RequestMethod.GET)
    @ResponseBody
    public String currentUserName2(HttpServletRequest request) {
        Principal principal = request.getUserPrincipal();
        return principal.getName();
    }

4. 使用自定义接口获取

充分的利用Spring的依赖注入功能将能够使得在应用的任意位置非常方便的获取当前登录用户信息,更重要的:这同时使得依赖于登录用户的单元测试变的异常容易。

public interface IAuthenticationFacade {
    Authentication getAuthentication();
}
@Service
public class IAuthenticationFacadeImpl implements IAuthenticationFacade {
    @Override
    public Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }
}

如上代码对SecurityContextHolder的静态方法进行了封装,使得其它依赖于SecurityContextHolder的类与SecurityContextHolder解耦,同时在单元测试中可以轻松的Mock掉真实的getAuthentication方法,进而降低其它依赖于当前登录用户方法的测试难度:

@Controller
public class SecurityController {
    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private IAuthenticationFacade authenticationFacade;

    //...

    @RequestMapping(value = "/username3", method = RequestMethod.GET)
    @ResponseBody
    public String currentUserName3() {
        Authentication authentication = authenticationFacade.getAuthentication();
        return authentication.getName();
    }

5. 在JSP中获取登录用户

注意:由于笔者的能力原因,JSP中获取登录用户加入到code demo中。同时,将部分内容也未经验证,可能会有一定的偏差。

当前登录用户可以轻构在JSP页面中获取,你需要做的仅仅是在JSP页面中加入spring security标签库的支持:

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

此时,便可以获取到当前的认证用户了:

<security:authorize access="isAuthenticated()">
    authenticated as <security:authentication property="principal.username" /> 
</security:authorize>

6. 在Thymeleaf中获取登录用户

Themeleaf是一款现代的服务端渲染模板引擎,它非常完美的与Spring MVC集成在一起。接下来我们展示在Themeleaf中如何获取当前登录用户。

首先我们加入 thymeleaf-spring5 以及 thymeleaf-extras-springsecurity5 依赖:

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

<dependency>
	<groupId>org.thymeleaf</groupId>
	<artifactId>thymeleaf-spring5</artifactId>
</dependency>

现在便可以在HTML模板中使用sec:authorize来获取当前登录用户了:

<html xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<body>
<div sec:authorize="isAuthenticated()">
    当前登录用户是: <span sec:authentication="name"></span></div>
</body>
</html>

7. 总结

本文展示了几种获取当前登录用户的方法。如果您想了解更多关于获取当前登录用户的细节,请点击文章顶部的code demo链接来获取一份详尽的代码示例。同时我们在该示例为您准备了充分的测试以更好的观察代码示例的运行结果。

分类
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 Security在@Async异步方法中获取登录用户

Spring Security在默认情况下无法在使用@Async注解的方法中获取当前登录用户的。若想在@Async方法中获取当前登录用户,则需要调用SecurityContextHolder.setStrategyName方法并设置相关的策略:

@SpringBootApplication
@EnableAsync
public class SpringSecurityAsyncPrincipalPropagationApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringSecurityAsyncPrincipalPropagationApplication.class, args);
        SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
    }
}

SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);决定了Spring Security可以在@Async注解的方法中可以成功的获取到当前登录用户。

以下将对该问题展开描述。

1. 简介

本文中我们将讨论@Async如何在Spring Security上下文中传播用户认证信息。

默认情况下Spring Security相关的认证信息是绑定到某个线程上的,也就是说在此线程以外的其它线程上我们无法获取当前登录用户的信息。比如在我们使用@Async来启用一个新的线程的情况下。

下面我们讨论如何在使用@Async启用的新的线程中获取到Spring Security的认证信息

2. Maven依赖

在pom.xml添加以下依赖:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
    <version>4.2.1.RELEASE</version>
</dependency>

更多的版本信息请参考:https://search.maven.org/classic/#search%7Cga%7C1%7Cg%3A%22org.springframework.security%22

3. 准备工作

接下来,我们做一些准备工作。首先新建如下方法:

@RequestMapping(method = RequestMethod.GET, value = "/async")
@ResponseBody
public Object standardProcessing() throws Exception {
    log.info("在调用使用@Async注解的方法前,在主前线程中获取用户认证信息: "
      + SecurityContextHolder.getContext().getAuthentication().getPrincipal());
    
    // 调用异步方法
    asyncService.asyncCall();
    
    log.info("在调用使用@Async注解的方法后,在主前线程中获取用户认证信息: "
      + SecurityContextHolder.getContext().getAuthentication().getPrincipal());
    
    return SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}

并在被调用异步方法中尝试获取当前认证信息:

/**
 * 异步方法
 * 将启用新的线程来运行该方法
 */
@Async
public void asyncCall() {
    try {
        log.info("在@Async注解的异步方法中获取当前登录用户信息: "
                + SecurityContextHolder.getContext().getAuthentication().getPrincipal());
    } catch (RuntimeException e) {
        log.info("未能成功的获取到当前登录用户信息");
    }
}

4. 验证

接下来让我们运行一些测试代码并查看控制台的打印内容:

2020-08-05 14:29:03.289  INFO 68033 --- [nio-8080-exec-1] ication$$EnhancerBySpringCGLIB$$149c1f0f : 
在调用使用@Async注解的方法前,在主前线程中获取用户认证信息: org.springframework.security.core.userdetails.
User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_
2020-08-05 14:29:03.292  INFO 68033 --- [nio-8080-exec-1] ication$$EnhancerBySpringCGLIB$$149c1f0f : 
在调用使用@Async注解的方法后,在主前线程中获取用户认证信息: org.springframework.security.core.userdetails.
User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_
2020-08-05 14:29:03.297  INFO 68033 --- [         task-1] c.c.s.AsyncService                       : 
未能成功的获取到当前登录用户信息

由日志信息可以轻易的得出:在使用@Async注解的异步方法中并不能够获取到当前登录用户的认证信息。

5. SecurityContextHolder

若想在新的线程中获取当前登录用户的认证信息,则需要启用SecurityContextHolder.MODE_INHERITABLETHREADLOCAL策略:

SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);

再次测试日志打印如下:

2020-08-05 14:30:21.007  INFO 69399 --- [nio-8080-exec-1] ication$$EnhancerBySpringCGLIB$$50a90aa6 : 
在调用使用@Async注解的方法前,在主前线程中获取用户认证信息: org.springframework.security.core.userdetails.
User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_
2020-08-05 14:30:21.009  INFO 69399 --- [nio-8080-exec-1] ication$$EnhancerBySpringCGLIB$$50a90aa6 : 
在调用使用@Async注解的方法后,在主前线程中获取用户认证信息: org.springframework.security.core.userdetails.
User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_
2020-08-05 14:30:21.013  INFO 69399 --- [         task-1] c.c.s.AsyncService                       : 
在@Async注解的异步方法中获取当前登录用户信息: org.springframework.security.core.userdetails.
User@586034f: Username: admin; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_

此时便可以在asyncCall方法中获取到用户的登录用户信息了。

6. 应用场景

在生产环境中,我们可能需要SecurityContext像这样传播:

  • 比如需要同时发起多个外部的请求,而这些请求在短时间内无法完成,此时便需要使用异步方法。
  • 再比如接收用户的请求时,我们需要做一些相对比较耗时的计算,而此时并不想让用户在此过程中等待。
  • 或者用户在找回密码环节中,我们向其注册的邮箱中发送密码找回的邮件。
  • 再或者用户的登录、注册过程中发送短信校验码。

总之如果我们需要在异步方法中获取Spring Security中的认证信息,则需要利用其传播机制,将认证信息传入到异步的执行方法中。

7. 总结

本文中,我们介绍了Spring Secruity在异步上下文的传播机制。内容虽不难,主要解决了异步方法中无法获取认证用户的问题。

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