使用Spring Boot构建系统安全层
17 安全架构:如何理解 Spring 安全体系的整体架构?
在设计 Web 应用程序时,一方面,因为开发人员缺乏对 Web 安全访问机制的认识,所以系统安全性是一个重要但又容易被忽略的话题。另一方面,因为系统涉及的技术体系非常复杂,所以系统安全性又是一个非常综合的话题。因此,这一讲我们将讨论一个全新的话题—— Spring 中与安全性相关的需求和实现方案。
在 Spring 家族中,Spring Security 专门为开发人员提供了一个安全性开发框架,下面我们一起来看下Spring 中安全体系的整体架构。
Web 应用程序的安全性需求
在软件系统中,我们把需要访问的内容定义为一种资源(Resource),而安全性设计的核心目标是对这些资源进行保护,以此确保外部请求对它们的访问安全受控。
在一个 Web 应用程序中,我们把对外暴露的 RESTful 端点理解为资源,关于如何对 HTTP 端点这些资源进行安全性访问,业界存在一些常见的技术体系。
在讲解这些技术体系之前,我们先来看看安全领域中非常常见但又容易混淆的两个概念:认证(Authentication)和授权(Authorization)。
所谓认证,即首先需要明确“你是谁”这个问题,也就是说系统能针对每次访问请求判断出访问者是否具有合法的身份标识。
一旦明确了 “你是谁”,我们就能判断出“你能做什么”,这个步骤就是授权。一般来说,通用的授权模型都是基于权限管理体系,即对资源、权限、角色和用户的进行组合处理的一种方案。
当我们把认证与授权结合起来后,即先判断资源访问者的有效身份,然后确定其对这个资源进行访问的合法权限,整个过程就形成了对系统进行安全性管理的一种常见解决方案,如下图所示:
基于认证和授权机制的资源访问安全性示意图
上图就是一种通用方案,而在不同的应用场景及技术体系下,系统可以衍生出很多具体的实现策略,比如 Web 应用系统中的认证和授权模型虽然与上图类似,但是在具体设计和实现过程中有其特殊性。
在 Web 应用体系中,因为认证这部分的需求相对比较明确,所以我们需要构建一套完整的存储体系来保存和维护用户信息,并且确保这些用户信息在处理请求的过程中能够得到合理利用。
而授权的情况相对来说复杂些,比如对某个特定的 Web 应用程序而言,我们面临的第一个问题是如何判断一个 HTTP 请求具备访问自己的权限。解决完这个第一个问题后,就算这个请求具备访问该应用程序的权限,并不意味着它能够访问其所具有的所有 HTTP 端点,比如业务上的某些核心功能还是需要具备较高的权限才能访问,这就涉及我们需要解决的第二个问题——如何对访问的权限进行精细化管理?如下图所示:
Web 应用程序访问授权效果示意图
在上图中,假设该请求具备对 Web 应用程序的访问权限,但不具备访问应用程序中端点 1 的权限,如果想实现这种效果,一般我们的做法是引入角色体系:首先对不同的用户设置不同等级的角色(即角色等级不同对应的访问权限也不同),再把每个请求绑定到某个角色(即该请求具备了访问权限)。
接下来我们把认证和授权进行结合,梳理出了 Web 应用程序访问场景下的安全性实现方案,如下图所示:
认证和授权整合示意图
从上图我们可以看到,用户首先通过请求传递用户凭证完成用户认证,然后根据该用户信息中所具备的角色信息获取访问权限,最终完成对 HTTP 端点的访问授权。
对一个 Web 应用程序进行安全性设计时,我们首先需要考虑认证和授权,因为它们是核心考虑点。在技术实现场景中,只要涉及用户认证,势必会涉及用户密码等敏感信息的加密。针对用户密码的场景,我们主要使用单向散列加密算法对敏感信息进行加密。
关于单向散列加密算法,它常用于生成消息摘要(Message Digest),主要特点为单向不可逆和密文长度固定,同时具备“碰撞”少的优点,即明文的微小差异会导致生成的密文完全不同。其中,常见的单向散列加密实现算法为 MD5(Message Digest 5)和 SHA(Secure Hash Algorithm)。而在 JDK 自带的 MessageDigest 类中,因为它已经包含了这些算法的默认实现,所以我们直接调用方法即可。
在日常开发过程中,对于密码进行加密的典型操作时序图如下所示:
单向散列加密与加盐机制
上图中,我们引入了加盐(Salt)机制,进一步提升了加密数据的安全性。所谓加盐就是在初始化明文数据时,系统自动往明文中添加一些附加数据,然后再进行散列。
目前,单向散列加密及加盐思想已被广泛用于系统登录过程中的密码生成和校验过程中,比如接下来我们将要引入的 Spring Security 框架。
Spring Security 架构
Spring Security 是 Spring 家族中历史比较悠久的一个框架,在 Spring Boot 出现之前,Spring Security 已经发展了很多年,尽管 Spring Security 的功能非常丰富,相比 Apache Shiro 这种轻量级的安全框架,它的优势就不那么明显了,加之应用程序中集成和配置 Spring Security 框架的过程比较复杂,因此它的发展过程并不是那么顺利。
而正是随着 Spring Boot 的兴起,带动了 Spring Security 的发展。它专门针对 Spring Security 提供了一套完整的自动配置方案,使得开发人员可以零配置使用 Spring Security。
这一讲我们先不对如何使用 Spring Security 框架展开说明,而是先从高层次梳理该框架对前面提到的各项安全性需求提供的架构设计。
Spring Security 中的过滤器链
与业务中大多数处理 Web 请求的框架对比后,我们发现 Spring Security 中采用的是管道-过滤器(Pipe-Filter)架构模式,如下图所示:
管道-过滤器架构模式示意图
在上图中我们可以看到,处理业务逻辑的组件称为过滤器,而处理结果的相邻过滤器之间的连接件称为管道,它们构成了一组过滤器链,即 Spring Security 的核心。
项目一旦启动,过滤器链将会实现自动配置,如下图所示:
Spring Security 中的过滤器链
在上图中,我们看到了 BasicAuthenticationFilter、UsernamePasswordAuthenticationFilter 等几个常见的 Filter,这些类可以直接或间接实现 Servlet 类中的 Filter 接口,并完成某一项具体的认证机制。例如,上图中的 BasicAuthenticationFilter 用来认证用户的身份,而 UsernamePasswordAuthenticationFilter 用来检查输入的用户名和密码,并根据认证结果来判断是否将结果传递给下一个过滤器。
这里请注意,整个 Spring Security 过滤器链的末端是一个 FilterSecurityInterceptor,本质上它也是一个 Filter,但它与其他用于完成认证操作的 Filter 不同,因为它的核心功能是用来实现权限控制,即判定该请求是否能够访问目标 HTTP 端点。因为我们可以把 FilterSecurityInterceptor 对权限控制的粒度划分到方法级别,所以它能够满足前面提到的精细化访问控制。
通过上述分析,我们知道了在 Spring Security 中,认证和授权这两个安全性需求主要通过一系列的过滤器进行实现。
基于过滤器链,我们再来深入分析下 Spring Security 的核心类结构。
Spring Security 中的核心类
我们先以最基础的 UsernamePasswordAuthenticationFilter 为例,该类的定义及核心方法 attemptAuthentication 如下代码所示。
public class UsernamePasswordAuthenticationFilter extendsAbstractAuthenticationProcessingFilter {public Authentication attemptAuthentication(HttpServletRequest request,HttpServletResponse response) throws AuthenticationException {if (postOnly && !request.getMethod().equals("POST")) {throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());}String username = obtainUsername(request);String password = obtainPassword(request);if (username == null) {username = "";}if (password == null) {password = "";}username = username.trim();UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);// Allow subclasses to set the "details" propertysetDetails(request, authRequest);return this.getAuthenticationManager().authenticate(authRequest);}...
}
围绕上述方法,通过翻阅 Spring Security 源代码,我们引出了该框架中一系列核心类,并梳理了它们之间的交互结构,如下图所示:
Spring Security 核心类图
上图中的很多类,通过名称我们就能明白它的含义和作用。
以位于左下角的 SecurityContextHolder 为例,它是一个典型的 Holder 类,存储了应用的安全上下文对象 SecurityContext,包含系统请求中最近使用的认证信息。这里我们大胆猜想它的内部肯定使用了 ThreadLocal 来确保线程访问的安全性。
而正如 UsernamePasswordAuthenticationFilter 中的代码所示,一个 HTTP 请求到达系统后,将通过一系列的 Filter 完成用户认证,然后具体的工作交由 AuthenticationManager 完成,AuthenticationManager 成功验证后会返回填充好的 Authentication 实例。
AuthenticationManager 是一个接口,在其实现 ProviderManager 类时会进一步依赖 AuthenticationProvider 接口完成具体的认证工作。
而在 Spring Security 中存在一大批 AuthenticationProvider 接口的实现类,分别完成各种认证操作。在执行具体的认证工作时,Spring Security 势必会使用用户详细信息,上图位于右边的 UserDetailsService 服务就是用来对用户详细信息实现管理。
关于上图中的很多其他核心类,我们将在后续的 18 讲《用户认证:如何基于Spring Security 构建用户认证体系?》中持续深入展开。
小结与预告
这一讲我们开始探讨 Web 应用程序的安全性,在这个领域中,因为认证和授权仍然是最基本的安全性控制手段,因此我们系统分析了认证和授权的解决方案,并引入了 Spring 家族中的 Spring Security 框架实现这一解决方案,同时对 Spring Security 的基本架构做了分析。
介绍完认证和授权的基本概念之后,18 讲我们将基于 SpringCSS 案例系统详细介绍它们的实现过程,首先我们需要关注如何使用 Spring Security 框架完成对用户认证过程的管理。
18 用户认证:如何基于 Spring Security 构建用户认证体系?
在 17 讲中,我们梳理了 Web 应用程序的安全性需求,并引出了 Spring Security 这款 Spring 家族中专门用于处理安全性需求的开发框架,同时也明确了认证和授权是安全性框架的核心功能。
这一讲我们将先讨论与认证相关的话题,并给出 Spring Security 认证机制及其使用方法。因为 Spring Security 是日常开发过程中的基础组件,所以我们也会对如何实现数据加解密的过程做一些展开。
在 Spring Boot 中整合 Spring Security 框架的方式非常简单,我们只需要在 pom 文件中引入 spring-boot-starter-security 依赖即可,这与以往需要提供很多配置才能与 Spring Security 完成集成的开发过程不同,如下代码所示:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId>
</dependency>
请注意,只要我们在代码工程中添加了上述依赖,包含在该工程中的所有 HTTP 端点都将被保护起来。
例如,在 SpringCSS 案例的 account-service 中,我们知道存在一个 AccountController ,且它暴露了一个“accounts/ /{accountId}”端点。现在,我们启动 account-service 服务并访问上述端点,弹出了如下图所示的界面内容:
添加 Spring Security 之后自动出现的登录界面
同时,在系统的启动控制台日志中,我们发现了如下所示的新的日志信息。
Using generated security password: 17bbf7c4-456a-48f5-a12e-a680066c8f80
在这里可以看到,Spring Security 为我们自动生成了一个密码,我们可以基于“user”这个账号及上述密码登录这个界面,抽空你也可以尝试下。
如果我们使用了 Postman 可视化 HTTP 请求工具,可以设置授权类型为“Basic Auth”并输入对应的用户名和密码完成对 HTTP 端点的访问,设置界面如下图所示:
使用 Postman 来完成认证信息的设置
事实上,在引入 spring-boot-starter-security 依赖之后,Spring Security 会默认创建一个用户名为“user”的账号。很显然,每次启动应用时,通过 Spring Security 自动生成的密码都会有所变化,因此它不适合作为一种正式的应用方法。
如果我们想设置登录账号和密码,最简单的方式是通过配置文件。例如,我们可以在 account-service 的 application.yml 文件中添加如下代码所示的配置项:
spring:security:user:name: springcsspassword: springcss_password
重启 account-service 服务后,我们就可以使用上述用户名和密码完成登录。
虽然基于配置文件的用户信息存储方案简单且直接,但是显然缺乏灵活性,因此 Spring Security 为我们提供了多种存储和管理用户认证信息的方案,我们一起来看一下。
配置 Spring Security
在 SpringSecurity 中,初始化用户信息所依赖的配置类是 WebSecurityConfigurer 接口,该接口实际上是一个空接口,继承了更为基础的 SecurityConfigurer 接口。
在日常开发中,我们往往不需要自己实现这个接口,而是使用 WebSecurityConfigurerAdapter 类简化该配置类的使用方式。比如我们可以通过继承 WebSecurityConfigurerAdapter 类并且覆写其中的 configure(AuthenticationManagerBuilder auth) 的方法完成配置工作。
关于 WebSecurityConfigurer 配置类,首先我们需要明确配置的内容。实际上,初始化所使用的用户信息非常简单,只需要指定用户名(Username)、密码(Password)和角色(Role)这三项数据即可。
在 WebSecurityConfigurer 类中,使用 AuthenticationManagerBuilder 类创建一个 AuthenticationManager 就能够轻松实现基于内存、LADP 和 JDBC 的验证。
接下来,我们就围绕 AuthenticationManagerBuilder 提供的功能实现多种用户信息存储方案。
使用基于内存的用户信息存储方案
我们先来看看如何使用 AuthenticationManagerBuilder 完成基于内存的用户信息存储方案。
实现方法是调用 AuthenticationManagerBuilder 的 inMemoryAuthentication 方法,示例代码如下所示:
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {builder.inMemoryAuthentication().withUser("springcss_user").password("password1").roles("USER").and().withUser("springcss_admin").password("password2").roles("USER", "ADMIN");
}
从上面的代码中,我们看到系统中存在”springcss _user”和”springcss _admin”这两个用户,其密码分别是”password1”和”password2”,分别代表着普通用户 USER 及管理员 ADMIN 这两个角色。
在 AuthenticationManagerBuilder 中,上述 inMemoryAuthentication 的方法的实现过程如下代码所示:
public InMemoryUserDetailsManagerConfigurer<AuthenticationManagerBuilder> inMemoryAuthentication()throws Exception {return apply(new InMemoryUserDetailsManagerConfigurer<>());
}
这里的 InMemoryUserDetailsManagerConfigurer 内部又使用到了 InMemoryUserDetailsManager 对象,而通过深入该类,我们可以获取 Spring Security 中与用户认证相关的一大批核心对象,它们之间的关系如下图所示:
Spring Security 中用户认证相关类结构图
首先,我们来看上图中代表用户详细信息的 UserDetails 接口,如下代码所示:
public interface UserDetails extends Serializable {//获取该用户的权限信息Collection<? extends GrantedAuthority> getAuthorities();//获取密码String getPassword();//获取用户名String getUsername();//判断该账户是否已失效boolean isAccountNonExpired();//判断该账户是否已被锁定boolean isAccountNonLocked();//判断该账户的凭证信息是否已失效boolean isCredentialsNonExpired();//判断该用户是否可用boolean isEnabled();
}
在上述代码中,我们发现 UserDetails 存在一个子接口 MutableUserDetails,从命名上不难看出,后者是一个可变的 UserDetails,而可变的内容就是密码。
关于 MutableUserDetails 接口的定义如下代码所示:
interface MutableUserDetails extends UserDetails {//设置密码void setPassword(String password);
}
在 Spring Security 中,针对 UserDetails 还存在一个专门的 UserDetailsService,该接口专门用来管理 UserDetails,它的定义如下代码所示:
public interface UserDetailsService {//根据用户名获取用户信息UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
而 UserDetailsManager 继承了 UserDetailsService,并提供了一批针对 UserDetails 的操作接口,如下代码所示:
public interface UserDetailsManager extends UserDetailsService {//创建用户void createUser(UserDetails user);//更新用户void updateUser(UserDetails user);//删除用户void deleteUser(String username);//修改密码void changePassword(String oldPassword, String newPassword);//判断指定用户名的用户是否存在boolean userExists(String username);
}
介绍完 UserDetailsManager 后,我们再回到 InMemoryUserDetailsManager 类,它实现了 UserDetailsManager 接口中的所有方法,这些方法主要用来对用户信息进行维护,从而形成一条代码支线。
为了完成用户信息的配置,还存在另外一条代码支线,即 UserDetailsManagerConfigurer。该类维护了一个 UserDetails 列表,并提供了一组 withUser 方法完成用户信息的初始化,如下代码所示:
private final List<UserDetails> users = new ArrayList<>();public final C withUser(UserDetails userDetails) {this.users.add(userDetails);return (C) this;
}
从上述代码中,我们看到 withUser 方法返回的是一个 UserDetailsBuilder 对象,通过该对象可以实现类似 .withUser(“springcss_user”).password(“password1”).roles(“USER”) 这样的链式语法,从而完成用户信息的设置。
请注意,这里的 .roles() 方法实际上是 .authorities() 方法的一种简写,因为 Spring Security 会在每个角色名称前自动添加“ROLE_”前缀,我们可以通过如下所示的代码实现同样的功能:
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {builder.inMemoryAuthentication().withUser("springcss_user").password("password1").authorities("ROLE_USER").and().withUser("springcss_admin").password("password2").authorities("ROLE_USER", "ROLE_ADMIN");
}
我们可以看到,基于内存的用户信息存储方案也比较简单,但是由于用户信息写死在代码中,因此同样缺乏灵活性。
接下来我们将引出另一种更为常见的用户信息存储方案——数据库存储。
使用基于数据库的用户信息存储方案
既然是将用户信息存储在数据库中,我们势必需要创建表结构。因此,在 Spring Security 的源文件中,我们可以找到对应的 SQL 语句,如下代码所示:
create table users(username varchar_ignorecase(50) not null primary key,password varchar_ignorecase(500) not null,enabled boolean not null);create table authorities (username varchar_ignorecase(50) not null,authority varchar_ignorecase(50) not null,constraint fk_authorities_users foreign key(username) references users(username));create unique index ix_auth_username on authorities (username,authority);
一旦在自己的数据库中创建了这两张表,且添加了相应数据,我们就可以直接注入一个 DataSource 对象查询用户数据,如下代码所示:
@Autowired
DataSource dataSource;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.jdbcAuthentication().dataSource(dataSource).usersByUsernameQuery("select username, password, enabled from Users " + "where username=?").authoritiesByUsernameQuery("select username, authority from UserAuthorities " + "where username=?").passwordEncoder(new BCryptPasswordEncoder());
}
这里使用了 AuthenticationManagerBuilder 的 jdbcAuthentication 方法配置数据库认证方式,而内部则使用了 JdbcUserDetailsManager 工具类。
围绕 JdbcUserDetailsManager 整条代码链路的类层结构与 InMemoryUserDetailsManager 非常一致,在该类中定义了各种用户数据库查询的 SQL 语句,以及使用 JdbcTemplate 完成数据库访问的具体实现方法。这里我们不再具体展开,你可以对照前面给出的 InMemoryUserDetailsManager 类层结构图进行分析。
注意,在上述方法中,通过 jdbcAuthentication() 方法验证用户信息时,我们必须集成加密机制,即使用 passwordEncoder() 方法嵌入一个 PasswordEncoder 接口的实现类。
在 Spring Security 中,PasswordEncoder 接口代表一种密码编码器,定义如下代码所示:
public interface PasswordEncoder {//对原始密码进行编码String encode(CharSequence rawPassword);//对提交的原始密码与库中存储的加密密码进行比对boolean matches(CharSequence rawPassword, String encodedPassword);//判断加密密码是否需要再次进行加密,默认返回falsedefault boolean upgradeEncoding(String encodedPassword) {return false;}
}
Spring Security 中内置了一大批 PasswordEncoder 接口的实现类,如下图所示:
Spring Security 中的 PasswordEncoder 实现类
上图中,比较常用的算法如 SHA-256 算法的 StandardPasswordEncoder、bcrypt 强哈希算法的 BCryptPasswordEncoder 等。而在实际案例中,我们使用的是 BCryptPasswordEncoder,它的 encode 方法如下代码所示:
public String encode(CharSequence rawPassword) {String salt;if (random != null) {salt = BCrypt.gensalt(version.getVersion(), strength, random);} else {salt = BCrypt.gensalt(version.getVersion(), strength);}return BCrypt.hashpw(rawPassword.toString(), salt);
}
可以看到,上述 encode 方法执行了两个步骤,第一步是生成盐,第二步是根据盐和明文密码生成最终的密文密码。
实现定制化用户认证方案
通过前面内容的分析,我们明确了用户信息存储的实现过程实际上是完全可定制化,而 Spring Security 所做的工作只是把常见、符合一般业务场景的实现方式嵌入框架中。如果存在特殊的场景,开发人员完全可以通过自定义用户信息存储方案进行实现。
在前面的内容中,我们介绍了 UserDetails 接口代表用户详细信息,而 UserDetailsService 接口负责对 UserDetails 进行各种操作 。因此,实现定制化用户认证方案的关键是实现 UserDetails 和 UserDetailsService 这两个接口。
扩展 UserDetails
扩展 UserDetails 的方法的实质是直接实现该接口,例如我们可以构建如下所示的 SpringCssUser 类:
public class SpringCssUser implements UserDetails {private static final long serialVersionUID = 1L;private Long id;private final String username;private final String password;private final String phoneNumber;//省略getter/setter@Overridepublic String getUsername() {return username;}@Overridepublic String getPassword() {return password;}@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}
}
显然,这里我们使用了一种更简单的方法满足 UserDetails 中各个接口的实现需求。一旦我们构建了一个 SpringCssUser 类,就可以创建对应的表结构存储类中所定义的字段。同时,我们也可以基于 Spring Data JPA 创建一个自定义的 Repository,如下代码所示:
public interface SpringCssUserRepository extends CrudRepository<SpringCssUser, Long> {SpringCssUser findByUsername(String username);
}
SpringCssUserRepository 扩展了 CrudRepository 接口,并提供了一个方法名衍生查询 findByUsername。
关于 Spring Data JPA 的使用方法,你还可以回顾《ORM 集成:如何使用 Spring Data JPA 访问关系型数据库?》。
扩展 UserDetailsService
接着,我们来实现 UserDetailsService 接口,如下代码所示:
@Service
public class SpringCssUserDetailsService implements UserDetailsService {@Autowiredprivate SpringCssUserRepository repository;@Overridepublic UserDetails loadUserByUsername(String username)throws UsernameNotFoundException {SpringCssUser user = repository.findByUsername(username);if (user != null) {return user;}throw new UsernameNotFoundException("SpringCSS User '" + username + "' not found");}
}
在 UserDetailsService 接口中,我们只需要实现 loadUserByUsername 方法就行。因此,我们可以基于 SpringCssUserRepository 的 findByUsername 方法,再根据用户名从数据库中查询数据。
整合定制化配置
最后,我们再次回到 SpringCssSecurityConfig 类。
这次我们将使用自定义的 SpringCssUserDetailsService 完成用户信息的存储和查询,此时我们只需要对配置策略做一些调整,调整后的完整 SpringCssSecurityConfig 类如下代码所示:
@Configuration
public class SpringCssSecurityConfig extends WebSecurityConfigurerAdapter {@AutowiredSpringCssUserDetailsService springCssUserDetailsService;@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(springCssUserDetailsService);}
}
这里我们注入了 SpringCssUserDetailsService,并将其添加到 AuthenticationManagerBuilder 中,这样 AuthenticationManagerBuilder 将基于自定义的 SpringCssUserDetailsService 完成 UserDetails 的创建和管理。
小结与预告
这一讲我们详细介绍了如何使用 Spring Security 构建用户认证体系的系统方法。
一方面,我们可以分别基于内存和数据库方案存储用户信息,这两种方案都是 Spring Security 内置的。另一方面,我们可以通过扩展 UserDetails 接口的方式实现定制化用户的认证方案。同时,为了方便你理解和掌握这部分内容,我们还梳理了与用户认证相关的核心类。
介绍完用户认证信息后,19 讲我们将介绍如何基于 Spring Security 确保 Web 请求的安全访问。
19 服务授权:如何基于 Spring Security 确保请求安全访问?
18 讲中,我们集中讨论了如何通过 WebSecurityConfigurerAdapter 完成对用户认证体系的构建。这一讲我们将继续使用这个配置类完成对服务访问的授权控制。
在日常开发过程中,我们需要对 Web 应用中的不同 HTTP 端点进行不同粒度的权限控制,并且希望这种控制方法足够灵活。而借助 Spring Security 框架,我们就可以对其进行简单实现,下面我们一起来看下。
对 HTTP 端点进行访问授权管理
在一个 Web 应用中,权限管理的对象是通过 Controller 层暴露的一个个 HTTP 端点,而这些 HTTP 端点就是需要授权访问的资源。
开发人员使用 Spring Security 中提供的一系列丰富技术组件,即可通过简单的设置对权限进行灵活管理。
使用配置方法
实现访问授权的第一种方法是使用配置方法,关于配置方法的处理过程也是位于 WebSecurityConfigurerAdapter 类中,但使用的是 configure(HttpSecurity http) 方法,如下代码所示:
protected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic();
}
上述代码就是 Spring Security 中作用于访问授权的默认实现方法,这里用到了多个常见的配置方法。
回想 18 课时中的内容,访问任何端点时,一旦在代码类路径中引入了 Spring Security 框架,就会弹出一个登录界面从而完成用户认证。因为认证是授权的前置流程,认证结束后就可以进入授权环节。
结合这些配置方法的名称,我们简单分析一下实现这种默认的授权效果的具体步骤。
首先,通过 HttpSecurity 类的 authorizeRequests() 方法,我们可以对所有访问 HTTP 端点的 HttpServletRequest 进行限制。
其次,anyRequest().authenticated() 语句指定了所有请求都需要执行认证,也就是说没有通过认证的用户无法访问任何端点。
然后,formLogin() 语句指定了用户需要使用表单进行登录,即会弹出一个登录界面。
最后, httpBasic() 语句使用 HTTP 协议中的 Basic Authentication 方法完成认证。
18 讲中我们也演示了如何使用 Postman 完成认证的方式,这里就不过多赘述了。
当然,Spring Security 中还提供了很多其他有用的配置方法供开发人员灵活使用,下表中我们进行了列举,一起来看下。
基于上表中的配置方法,我们就可以通过 HttpSecurity 实现自定义的授权策略。
比方说,我们希望针对“/orders”根路径下的所有端点进行访问控制,且只允许认证通过的用户访问,那么可以创建一个继承了 WebSecurityConfigurerAdapter 类的 SpringCssSecurityConfig,并覆写其中的 configure(HttpSecurity http) 方法来实现,如下代码所示:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Overridepublic void configure(HttpSecurity http) throws Exception {http.authorizeRequests().antMatchers("/orders/**").authenticated();}
}
请注意:虽然上表中的这些配置方法非常有用,但是由于我们无法基于一些来自环境和业务的参数灵活控制访问规则,也就存在一定的局限性。
为此,Spring Security 还提供了一个 access() 方法,该方法允许开发人员传入一个表达式进行更细粒度的权限控制,这里,我们将引入Spring 框架提供的一种动态表达式语言—— SpEL(Spring Expression Language 的简称)。
只要 SpEL 表达式的返回值为 true,access() 方法就允许用户访问,如下代码所示:
@Override
public void configure(HttpSecurity http) throws Exception {http.authorizeRequests().antMatchers("/orders").access("hasRole('ROLE_USER')");
}
上述代码中,假设访问“/orders”端点的请求必须具备“ROLE_USER”角色,通过 access 方法中的 hasRole 方法我们即可灵活地实现这个需求。当然,除了使用 hasRole 外,我们还可以使用 authentication、isAnonymous、isAuthenticated、permitAll 等表达式进行实现。因这些表达式的作用与前面介绍的配置方法一致,我们就不过多赘述。
使用注解
除了使用配置方法,Spring Security 还为我们提供了 @PreAuthorize 注解实现类似的效果,该注解定义如下代码所示:
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PreAuthorize {//通过 SpEL 表达式设置访问控制String value();
}
可以看到 @PreAuthorize 的原理与前面介绍的 access() 方法一样,即通过传入一个 SpEL 表达式设置访问控制,如下所示代码就是一个典型的使用示例:
@RestController
@RequestMapping(value="orders")
public class OrderController {@PostMapping(value = "/")@PreAuthorize("hasRole(ROLE_ADMIN)")public void addOrder(@RequestBody Order order) {...}
}
从这个示例中可以看到,在“/orders/”这个 HTTP 端点上,我们添加了一个 @PreAuthorize 注解用来限制只有角色为“ROLE_ADMIN”的用户才能访问该端点。
其实,Spring Security 中用于授权的注解还有 @PostAuthorize,它与 @PreAuthorize 注解是一组,主要用于请求结束之后检查权限。因这种情况比较少见,这里我们不再继续展开,你可以翻阅相关资料学习。
实现多维度访问授权方案
我们知道 HTTP 端点是 Web 应用程序的一种资源,而每个 Web 应用程序对于自身资源的保护粒度因服务而异。对于一般的 HTTP 端点,用户可能通过认证就可以访问;对于一些重要的 HTTP 端点,用户在已认证的基础上还会有一些附加要求。
接下来,我们将讨论对资源进行保护的三种粒度级别。
- 用户级别: 该级别是最基本的资源保护级别,只要是认证用户就可能访问服务内的各种资源。
- 用户+角色级别: 该级别在认证用户级别的基础上,还要求用户属于某一个或多个特定角色。
- 用户+角色+操作级别: 该级别在认证用户+角色级别的基础上,对某些 HTTP 操作方法做了访问限制。
基于配置方法和注解,我们可以轻松实现上述三种访问授权方案。
使用用户级别保护服务访问
这次,我们来到 SpringCSS 案例系统中的 customer-service,先来回顾一下 CustomerController 的内容,如下所示:
@RestController
@RequestMapping(value="customers")
public class CustomerController {@Autowiredprivate CustomerTicketService customerTicketService; @PostMapping(value = "/{accountId}/{orderNumber}")public CustomerTicket generateCustomerTicket( @PathVariable("accountId") Long accountId,@PathVariable("orderNumber") String orderNumber) {CustomerTicket customerTicket = customerTicketService.generateCustomerTicket(accountId, orderNumber);return customerTicket;}@GetMapping(value = "/{id}")public CustomerTicket getCustomerTicketById(@PathVariable Long id) {CustomerTicket customerTicket = customerTicketService.getCustomerTicketById(id);return customerTicket;}@GetMapping(value = "/{pageIndex}/{pageSize}")public List<CustomerTicket> getCustomerTicketList( @PathVariable("pageIndex") int pageIndex, @PathVariable("pageSize") int pageSize) {List<CustomerTicket> customerTickets = customerTicketService.getCustomerTickets(pageIndex, pageSize);return customerTickets;}@DeleteMapping(value = "/{id}")public void deleteCustomerTicket( @PathVariable("id") Long id) {customerTicketService.deleteCustomerTicket(id);}
}
因为 CustomerController 是 SpringCSS 案例中的核心入口,所以我们认为它的所有端点都应该受到保护。于是,在 customer-service 中,我们创建了一个 SpringCssSecurityConfig 类继承 WebSecurityConfigurerAdapter,如下代码所示:
@Configuration
public class SpringCssSecurityConfig extends WebSecurityConfigurerAdapter {@Overridepublic void configure(HttpSecurity http) throws Exception {http.authorizeRequests().anyRequest().authenticated();}
}
位于 configure() 方法中的 .anyRequest().authenticated() 语句指定了访问 customer-service 下的所有端点的任何请求都需要进行验证。因此,当我们使用普通的 HTTP 请求访问 CustomerController 中的任何 URL(例如http://localhost:8083/customers/1),将会得到如下图代码所示的错误信息,该错误信息明确指出资源的访问需要进行认证。
{"error": "access_denied","error_description": "Full authentication is required to access to this resource"
}
记得 18 讲中覆写 WebSecurityConfigurerAdapter 的 config(AuthenticationManagerBuilder auth) 方法时提供了一个用户名“springcss_user”,现在我们就用这个用户名来添加用户认证信息并再次访问该端点。显然,因为此时我们传入的是有效的用户信息,所以可以满足认证要求。
使用用户+角色级别保护服务访问
对于某些安全性要求比较高的 HTTP 端点,我们通常需要限定访问的角色。
例如,customer-service 服务中涉及客户工单管理等核心业务,我们认为不应该给所有的认证用户开放资源访问入口,而应该限定只有角色为“ADMIN”的管理员才开放。这时,我们就可以使用认证用户+角色保护服务的访问控制机制,具体的示例代码如下所示:
@Configuration
public class SpringCssSecurityConfig extends WebSecurityConfigurerAdapter {@Overridepublic void configure(HttpSecurity http) throws Exception {http.authorizeRequests().antMatchers("/customers/**").hasRole("ADMIN").anyRequest().authenticated();}
}
在上述代码中可以看到,我们使用了 HttpSecurity 类中的 antMatchers(“/customer/”) 和 hasRole(“ADMIN”) 方法为访问”/customers/“的请求限定了角色,只有”ADMIN”角色的认证用户才能访问以”/customers/“为根地址的所有 URL。
如果我们使用了认证用户+角色的方式保护服务访问,使用角色为“USER”的认证用户“springcss_user”访问 customer-service 时就会出现如下所示的“access_denied”错误信息:
{"error": "access_denied","error_description": "Access is denied"
}
而我们使用具有“ADMIN”角色的“springcss_admin”用户访问 customer-service 时,将会得到正常的返回信息,关于这点你可以自己做一些尝试。
使用用户+角色+操作级别保护服务访问
最后一种保护服务访问的策略粒度划分最细,在认证用户+角色的基础上,我们需要再对具体的 HTTP 操作进行限制。
在 customer-service 中,我们认为所有对客服工单的删除操作都很危险,因此可以使用 http.antMatchers(HttpMethod.DELETE, “/customers/**”) 方法对删除操作进行保护,示例代码如下:
@Configuration
public class SpringCssSecurityConfig extends WebSecurityConfigurerAdapter {@Overridepublic void configure(HttpSecurity http) throws Exception{http.authorizeRequests().antMatchers(HttpMethod.DELETE, "/customers/**").hasRole("ADMIN").anyRequest().authenticated();}
}
上述代码的效果在于对“/customers”端点执行删除操作时,我们需要使用具有“ADMIN”角色的“springcss_admin”用户,执行其他操作时不需要。因为如果我们使用“springcss_user”账户执行删除操作,还是会出现“access_denied”错误信息。
小结与预告
通过 19 讲的学习,我们明确了 Web 应用程序中访问授权控制的三种粒度,并基于 SpringCSS 案例给出了三种粒度下的控制实现方式。