访问授权:如何对请求的安全访问过程进行有效配置?
通过前面几讲的介绍,相信你已经对 Spring Security 中的认证流程有了更全面的了解。认证是实现授权的前提和基础,通常我们在执行授权操作时需要明确目标用户,只有明确目标用户才能明确它所具备的角色和权限,用户、角色和权限也是 Spring Security 中所采用的授权模型,今天我就和你一起探讨授权模型的实现过程以及在日常开发过程中的应用方式。
Spring Security 中的权限和角色
实现访问授权的基本手段是使用配置方法,我们已经在“用户认证:如何基于 Spring Security 对用户进行有效认证?”一讲中介绍了 Spring Security 中的配置体系,你可以回顾学习。配置方法的处理过程同样位于 WebSecurityConfigurerAdapter 类中,但使用的是另一个 configure(HttpSecurity http) 方法,示例代码如下所示:
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.and()
.httpBasic();
}
同样,在 02 讲中我们也已经看到过上述代码,这是 Spring Security 中作用于访问授权的默认实现方法。
基于权限进行访问控制
我们先来回顾一下 03 讲“账户体系:如何深入理解 Spring Security 的认证机制?”中介绍的用户对象以及它们之间的关联关系:
Spring Security 中的核心用户对象
上图中的 GrantedAuthority 对象代表的就是一种权限对象,而一个 UserDetails 对象具备一个或多个 GrantedAuthority 对象。通过这种关联关系,实际上我们就可以对用户的权限做一些限制,如下所示:
使用权限实现访问控制示意图
如果用代码来表示这种关联关系,可以采用如下所示的实现方法:
UserDetails user = User.withUsername("jianxiang")
.password("123456")
.authorities("create", "delete")
.build();
可以看到,这里我们创建了一个名为“jianxiang”的用户,该用户具有“create”和“delete”这两个权限。在 Spring Security 中,提供了一组针对 GrantedAuthority 的配置方法。例如:
-
hasAuthority(String),允许具有特定权限的用户进行访问;
-
hasAnyAuthority(String),允许具有任一权限的用户进行访问。
你可以使用上述两个方法来判断用户是否具备对应的访问权限,我们在 WebSecurityConfigurerAdapter 的 configure 方法中添加如下代码:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
http.authorizeRequests().anyRequest().hasAuthority("CREATE");
}
这段代码的作用是对于任何请求,只有权限为“CREATE”才能采用访问。如果我们修改一下代码:
http.authorizeRequests().anyRequest().hasAnyAuthority("CREATE", "DELETE");
此时,只要具备“CREATE”和“DELETE”中任意一种权限的用户都能进行访问。
这两个方法实现起来都比较简单,但局限性也很大,因为我们无法基于一些来自环境和业务的参数灵活控制访问规则。为此,Spring Security 还提供了一个 access() 方法,该方法允许开发人员传入一个表达式进行更加细粒度的权限控制。
这里,我们将引入 SpEL,它是 Spring Expression Language 的简称,是 Spring 框架提供的一种动态表达式语言。基于 SpEL,只要该表达式的返回值是 true,access() 方法就会允许用户访问。如下示例:
http.authorizeRequests().anyRequest().access("hasAuthority('CREATE')");
上述代码与使用 hasAuthority() 方法的效果是完全一致的,但如果是更为复杂的场景,access() 方法的优势就很明显了。我们可以灵活创建一个表达式,然后通过 access() 方法确定最后的结果,示例代码如下所示:
String expression = "hasAuthority('CREATE') and !hasAuthority('Retrieve')";
http.authorizeRequests().anyRequest().access(expression);
上述代码的效果是只有拥有“CREATE”权限且不拥有“Retrieve”权限的用户才能进行访问。
基于角色进行访问控制
讨论完权限,我们再来看角色,你可以把角色看成是拥有多个权限的一种数据载体,如下图所示,这里我们分别定义了两个不同的角色“User”和“Admin”,它们拥有不同的权限:
使用角色实现访问控制示意图
讲到这里,你可能会认为 Spring Security 应该提供了一个独立的数据结构来承载角色的含义。但事实上,在 Spring Security 中,并没有定义类似“GrantedRole”这种专门用来定义用户角色的对象,而是复用了 GrantedAuthority 对象。事实上,以“ROLE_”为前缀的 GrantedAuthority 就代表了一种角色,因此我们可以使用如下方式初始化用户的角色:
UserDetails user = User.withUsername("jianxiang")
.password("123456")
.authorities("ROLE_ADMIN")
.build();
上述代码相当于为用户“jianxiang”指定了“ADMIN”这个角色。为了给开发人员提供更好的开发体验,Spring Security 还提供了另一种简化的方法来指定用户的角色,如下所示:
UserDetails user = User.withUsername("jianxiang")
.password("123456")
.roles("ADMIN")
.build();
在“用户认证:如何基于 Spring Security 对用户进行有效认证?”一讲中,我们介绍使用基于内存的用户信息存储方案时就已经看到过这种使用方法,你可以做一些回顾。
和权限配置一样,Spring Security 也通过使用对应的 hasRole() 和 hasAnyRole() 方法来判断用户是否具有某个角色或某些角色,使用方法如下所示:
http.authorizeRequests().anyRequest().hasRole("ADMIN");
当然,针对角色,我们也可以使用 access() 方法完成更为复杂的访问控制。而 Spring Security 还提供了其他很多有用的控制方法供开发人员进行灵活使用。作为总结,下表展示了常见的配置方法及其作用:
配置方法 | 作用 |
---|---|
anonymous() | 允许匿名访问 |
authenticated() | 允许认证用户访问 |
denyAll() | 无条件禁止一切访问 |
hasAnyAuthority(String) | 允许具有任一权限的用户进行访问 |
hasAnyRole(String) | 允许具有任一角色的用户进行访问 |
hasAuthority(String) | 允许具有特定权限的用户进行访问 |
hasIpAddress(String) | 允许来自特定 IP 地址的用户进行访问 |
hasRole(String) | 允许具有特定角色的用户进行访问 |
permitAll() | 无条件允许一切访问 |
Spring Security 中的配置方法列表
使用配置方法控制访问权限
讨论完权限和角色,让我们回到 HTTP 请求和响应过程。我们知道确保访问安全的手段是对访问进行限制,只有那些具有访问权限的请求才能被服务器处理。那么问题就来了,如何让 HTTP 请求与权限控制过程关联起来呢?答案还是使用 Spring Security 所提供的配置方法。Spring Security 提供了三种强大的匹配器(Matcher)来实现这一目标,分别是MVC 匹配器、Ant 匹配器以及正则表达式匹配器。
为了验证这些匹配器的配置方法,我们提供了如下所示的一个 Controller:
@RestController
public class TestController {
@GetMapping("/hello_user")
public String helloUser() {
return "Hello User!";
}
@GetMapping("/hello_admin")
public String helloAdmin() {
return "Hello Admin!";
}
@GetMapping("/other")
public String other() {
return "Other!";
}
}
同时,我们也创建两个具有不同角色的用户,如下所示:
UserDetails user1 = User.withUsername("jianxiang1")
.password("12345")
.roles("USER")
.build();
UserDetails user2 = User.withUsername(“jianxiang2”)
.password(“12345”)
.roles(“ADMIN”)
.build();
接下来,我们将基于这个 Controller 中暴露的各个 HTTP 端点,对三种不同的匹配器一一展开讲解。
MVC 匹配器
MVC 匹配器的使用方法比较简单,就是基于 HTTP 端点的访问路径进行匹配,如下所示:
http.authorizeRequests()
.mvcMatchers("/hello_user").hasRole("USER")
.mvcMatchers("/hello_admin").hasRole("ADMIN");
现在,如果你使用角色为“USER”的用户“jianxiang1”来访问“/hello_admin”端点,那么将会得到如下所示的响应:
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/hello_admin"
}
显然,MVC 匹配器已经生效了,因为“/hello_admin”端点只有角色为“ADMIN”的用户才能访问。如果你使用拥有“ADMIN”角色的“jianxiang2”来访问这个端点就可以得到正确的响应结果。
你可能会问,我们通过 MVC 匹配器只指定了这两个端点的路径,那剩下的“/other”路径呢?答案就是:没有被 MVC 匹配器所匹配的端点,其访问不受任何的限制,效果相当于如下所示的配置:
http.authorizeRequests()
.mvcMatchers("/hello_user").hasRole("USER")
.mvcMatchers("/hello_admin").hasRole("ADMIN");
.anyRequest().permitAll();
显然,这种安全访问控制策略不是特别合理,更好的做法是对那些没有被 MVC 匹配器所匹配到的请求也加以控制,需要进行认证之后才能被访问,实现方式如下所示:
http.authorizeRequests()
.mvcMatchers("/hello_user").hasRole("USER")
.mvcMatchers("/hello_admin").hasRole("ADMIN");
.anyRequest().authenticated();
讲到这里,又出现了一个新问题:如果一个 Controller 中存在两个路径完全一样的 HTTP 端点呢?
这种情况是存在的,因为对于 HTTP 端点而言,就算路径一样,只要所使用的 HTTP 方法不同,那就是不同的两个端点。针对这种场景,MVC 匹配器还提供了重载的 mvcMatchers 方法,如下所示:
mvcMatchers(HttpMethod method, String... patterns)
这样,我们就可以把 HTTP 方法作为一个访问的维度进行控制,示例代码如下所示:
http.authorizeRequests()
.mvcMatchers(HttpMethod.POST, "/hello").authenticated()
.mvcMatchers(HttpMethod.GET, "/hello").permitAll()
.anyRequest().denyAll();
在上面这段配置代码中,如果一个 HTTP 请求使用了 POST 方法来访问“/hello”端点,那么就需要进行认证。而对于使用 GET 方法来访问“/hello”端点的请求则全面允许访问。最后,其余访问任意路径的所有请求都会被拒绝。
同时,如果我们想要对某个路径下的所有子路径都指定同样的访问控制,那么只需要在该路径后面添加“*”号即可,示例代码如下所示:
http.authorizeRequests()
.mvcMatchers(HttpMethod.GET, "/user/*").authenticated()
通过上述配置方法,如果我们访问“/user/jianxiang”“/user/jianxiang/status”等路径时,都会匹配到这条规则。
Ant 匹配器
Ant 匹配器的表现形式和使用方法与前面介绍的 MVC 匹配器非常相似,它也提供了如下所示的三个方法来完成请求与 HTTP 端点地址之间的匹配关系:
-
antMatchers(String patterns)
-
antMatchers(HttpMethod method)
-
antMatchers(HttpMethod method, String patterns)
从方法定义上不难明白,我们可以组合指定请求的 HTTP 方法以及匹配的模式,例如:
http.authorizeRequests()
.antMatchers( "/hello").authenticated();
虽然,从使用方式上看,Ant 匹配器和 MVC 匹配器并没有什么区别,但在日常开发过程中,我想推荐你使用 MVC 匹配器而不是 Ant 匹配器,原因就在于 Ant 匹配器在匹配路径上有一些风险,主要体现在对于"/"的处理上。为了更好地说明,我举一个简单的例子。
基于上面的这行配置,如果你发送一个这样的 HTTP 请求:
http://localhost:8080/hello
你肯定认为 Ant 匹配器是能够匹配到这个端点的,但结果却是:
{
"status":401,
"error":"Unauthorized",
"message":"Unauthorized",
"path":"/hello"
}
现在,如果你把 HTTP 请求调整为这样,请注意,我们在请求地址最后添加了一个”/”符号,那么就会得到正确的访问结果:
http://localhost:8080/hello/
显然,Ant 匹配器处理请求地址的方式有点让人感到困惑,而 MVC 匹配器则没有这个问题,无论在请求地址最后是否存在“/”符号,它都能完成正确的匹配。
正则表达式匹配器
最后我要介绍的是正则表达式匹配器,同样,它也提供了如下所示的两个配置方法:
-
regexMatchers(HttpMethod method, String regex)
-
regexMatchers(String regex)
使用这一匹配器的主要优势在于它能够基于复杂的正则表达式对请求地址进行匹配,这是 MVC 匹配器和 Ant 匹配器无法实现的,你可以看一下如下所示的这段配置代码:
http.authorizeRequests()
.mvcMatchers("/email/{email:.*(.+@.+\\.com)}")
.permitAll()
.anyRequest()
.denyAll();
可以看到,这段代码就对常见的邮箱地址进行了匹配,只有输入的请求是一个合法的邮箱地址才能允许访问。
小结与预告
这一讲我们关注的是对请求访问进行授权,而这个过程需要明确 Spring Security 中的用户、权限和角色之间的关联关系。一旦我们对某个用户设置了对应的权限和角色,那么就可以通过各种配置方法来有效控制访问权限。为此,Spring Security 也提供了 MVC 匹配器、Ant 匹配器以及正则表达式匹配器来实现复杂的访问控制。
本讲内容总结如下:
最后我想给你留一道思考题:在 Spring Security 中,你知道用户角色与用户权限之间有什么区别和联系吗?欢迎你在留言区和我分享自己的观点。
介绍完授权机制的使用方式之后,下一讲,我们将进一步探讨这个话题,尝试剖析 Spring Security 授权机制背后的实现原理。
权限管理:如何剖析 Spring Security 的授权原理?
上一讲,我们分析了 Spring Security 中提供的授权功能。你可以发现使用这一功能的方法很简单,只需要基于 HttpSecurity 对象提供的一组工具方法就能实现复杂场景下的访问控制。但是,易于使用的功能往往内部实现都没有表面看起来那么简单,今天我就来和你一起深入分析授权功能背后的实现机制。针对授权功能,Spring Security 在实现过程中采用了很多优秀的设计理念和实现技巧,值得我们深入学习。
Spring Security 授权整体流程
我们先来简单回顾一下上一讲的内容。我们知道在 Spring Security 中,实现对所有请求权限控制的配置方法只需要如下所示的一行代码:
http.authorizeRequests();
我们可以结合 HTTP 请求的响应流程来理解这行代码的执行效果。当一个 HTTP 请求来到 Servlet 容器时,会被容器拦截,并添加一些附加的处理逻辑。在 Servlet 中,这种处理逻辑就是通过过滤器(Filter)来实现的,多个过滤器按照一定的顺序组合在一起就构成了一个过滤器链。关于过滤器的详细讨论我们会在 08 讲“管道过滤:如何基于 Spring Security 过滤器扩展安全性?”中展开,在本讲中,我们只需要知道 Spring Security 同样也基于过滤器拦截请求,从而实现对访问权限的限制即可。
在 Spring Security 中,存在一个叫 FilterSecurityInterceptor 的拦截器,它位于整个过滤器链的末端,核心功能是对权限控制过程进行拦截,以此判定该请求是否能够访问目标 HTTP 端点。FilterSecurityInterceptor 是整个权限控制的第一个环节,我们把它称为拦截请求。
我们对请求进行拦截之后,下一步就要获取该请求的访问资源,以及访问这些资源需要的权限信息。我们把这一步骤称为获取权限配置。在 Spring Security 中,存在一个 SecurityMetadataSource 接口,该接口保存着一系列安全元数据的数据源,代表权限配置的抽象。我们在上一讲中已经通过配置方法设置了很多权限信息,例如:
http.authorizeRequests().anyRequest().hasAuthority("CREATE");
请注意,http.authorizeRequests() 方法的返回值是一个 ExpressionInterceptUrlRegistry,anyRequest() 方法返回值是一个 AuthorizedUrl,而 hasAuthority() 方法返回的又是一个 ExpressionInterceptUrlRegistry。这些对象在今天的内容中都会介绍到。
SecurityMetadataSource 接口定义了一组方法来操作这些权限配置,具体权限配置的表现形式是ConfigAttribute 接口。通过 ExpressionInterceptUrlRegistry 和 AuthorizedUrl,我们能够把配置信息转变为具体的 ConfigAttribute。
当我们获取了权限配置信息后,就可以根据这些配置决定 HTTP 请求是否具有访问权限,也就是执行授权决策。Spring Security 专门提供了一个 AccessDecisionManager 接口完成该操作。而在 AccessDecisionManager 接口中,又把具体的决策过程委托给了 AccessDecisionVoter 接口。AccessDecisionVoter 可以被认为是一种投票器,负责对授权决策进行表决。
以上三个步骤构成了 Spring Security 的授权整体工作流程,可以用如下所示的时序图表示:
Spring Security 的授权整体工作流程
接下来,我们基于这张类图分别对拦截请求、获取权限配置、执行授权决策三个步骤逐一展开讲解。
拦截请求
作为一种拦截器,FilterSecurityInterceptor 实现了对请求的拦截。我们先来看它的定义,如下所示:
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter
FilterSecurityInterceptor 实现了 Servlet 的 Filter 接口,所以本质上也是一种过滤器,并实现了 Filter 接口的 invoke 方法。在它的 invoke 方法中,FilterSecurityInterceptor 自身并没有执行任何特殊的操作,只是获取了 HTTP 请求并调用了基类 AbstractSecurityInterceptor 中的 beforeInvocation() 方法对请求进行拦截:
public void invoke(FilterInvocation fi) throws IOException, ServletException {
…
InterceptorStatusToken token = super.beforeInvocation(fi);
…
super.afterInvocation(token, null);
}
AbstractSecurityInterceptor 中的 beforeInvocation() 方法非常长,我们把它裁剪之后,可以得到如下所示的主流程代码:
protected InterceptorStatusToken beforeInvocation(Object object) {
…
//获取 ConfigAttribute 集合
Collection< ConfigAttribute > attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
…
//获取认证信息
Authentication authenticated = authenticateIfRequired();
//执行授权
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
…
}
…
}
可以看到,上述操作从配置好的 SecurityMetadataSource 中获取当前请求所对应的 ConfigAttribute,即权限信息。那么,这个 SecurityMetadataSource 又是怎么来的呢?
获取访问策略
我们注意到在 FilterSecurityInterceptor 中定义了一个 FilterInvocationSecurityMetadataSource 变量,并通过一个 setSecurityMetadataSource() 方法进行注入,显然,这个变量就是一种 SecurityMetadataSource。
MetadataSource
通过翻阅 FilterSecurityInterceptor 的调用关系,我们发现初始化该类的地方是在 AbstractInterceptUrlConfigurer 类中,如下所示:
private FilterSecurityInterceptor createFilterSecurityInterceptor(H http,
FilterInvocationSecurityMetadataSource metadataSource,
AuthenticationManager authenticationManager) throws Exception {
FilterSecurityInterceptor securityInterceptor = new FilterSecurityInterceptor();
securityInterceptor.setSecurityMetadataSource(metadataSource);
securityInterceptor.setAccessDecisionManager(getAccessDecisionManager(http));
securityInterceptor.setAuthenticationManager(authenticationManager);
securityInterceptor.afterPropertiesSet();
return securityInterceptor;
}
而 FilterInvocationSecurityMetadataSource 对象的创建则是基于 AbstractInterceptUrlConfigurer 中提供的抽象方法:
abstract FilterInvocationSecurityMetadataSource createMetadataSource(H http);
这个方法的实现过程由 AbstractInterceptUrlConfigurer 的子类 ExpressionUrlAuthorizationConfigurer 提供,如下所示:
@Override
ExpressionBasedFilterInvocationSecurityMetadataSource createMetadataSource(H http) {
LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap = REGISTRY.createRequestMap();
…
return new ExpressionBasedFilterInvocationSecurityMetadataSource(requestMap,
getExpressionHandler(http));
}
请你注意:这里有个REGISTRY 对象,它的类型是 ExpressionInterceptUrlRegistry。这和前面介绍的内容相对应,我们在前面已经提到 http.authorizeRequests() 方法的返回值类型就是这个 ExpressionInterceptUrlRegistry。
ExpressionInterceptUrlRegistry
我们继续看 ExpressionInterceptUrlRegistry 中 createRequestMap() 的实现过程,如下所示:
final LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> createRequestMap() {
…
LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap = new LinkedHashMap<>();
for (UrlMapping mapping : getUrlMappings()) {
RequestMatcher matcher = mapping.getRequestMatcher();
Collection<ConfigAttribute> configAttrs = mapping.getConfigAttrs();
requestMap.put(matcher, configAttrs);
}
return requestMap;
}
这段代码把配置的 http.authorizeRequests() 转化为 UrlMappings,然后进一步转换为 RequestMatcher 与 Collection<ConfigAttribute>
之间的映射关系。那么,创建这些 UrlMappings 的入口又在哪里呢?同样也是在 ExpressionUrlAuthorizationConfigurer 中的 interceptUrl 方法,如下所示:
private void interceptUrl(Iterable<? extends RequestMatcher> requestMatchers,
Collection<ConfigAttribute> configAttributes) {
for (RequestMatcher requestMatcher : requestMatchers) {
REGISTRY.addMapping(new AbstractConfigAttributeRequestMatcherRegistry.UrlMapping(
requestMatcher, configAttributes));
}
}
AuthorizedUrl
我们进一步跟踪代码的运行流程,发现上述 interceptUrl() 方法的调用入口是在如下所示的 access() 方法中:
public ExpressionInterceptUrlRegistry access(String attribute) {
if (not) {
attribute = "!" + attribute;
}
interceptUrl(requestMatchers, SecurityConfig.createList(attribute));
return ExpressionUrlAuthorizationConfigurer.this.REGISTRY;
}
结合上一讲的内容,我们不难理解这个 access() 方法的作用。请注意,这个方法位于 AuthorizedUrl 类中,而我们执行 http.authorizeRequests().anyRequest() 方法的返回值就是这个 AuthorizedUrl。在该类中定义了一批我们已经熟悉的配置方法,例如 hasRole、hasAuthority 等,而这些方法在内部都是调用了上面这个 access() 方法:
public ExpressionInterceptUrlRegistry hasRole(String role) {
return access(ExpressionUrlAuthorizationConfigurer.hasRole(role));
}
public ExpressionInterceptUrlRegistry hasAuthority(String authority) {
return access(ExpressionUrlAuthorizationConfigurer.hasAuthority(authority));
}
讲到这里,获取访问策略的流程就基本完成了,我们得到了一组代表权限的 ConfigAttribute 对象。
执行授权决策
执行授权决策的前提是获取认证信息,因此,我们在 FilterSecurityInterceptor 的拦截流程中发现了如下一行执行认证操作的代码:
Authentication authenticated = authenticateIfRequired();
这里的 authenticateIfRequired() 方法执行认证操作,该方法实现如下:
private Authentication authenticateIfRequired() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
…
authentication = authenticationManager.authenticate(authentication);
…
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication;
}
可以看到认证逻辑并不复杂,首先根据上下文对象中是否存在 Authentication 对象来判断当前用户是否已通过认证。如果尚未通过身份认证,则调用 AuthenticationManager 进行认证,并把 Authentication 存储到上下文对象中。关于用户认证流程的详细介绍你可以回顾“认证体系:如何深入理解 Spring Security 的认证机制?”中的内容。
AccessDecisionManager
AccessDecisionManager 是用来进行授权决策的入口,其最核心的方法就是如下所示的 decide() 方法,前面我们已经看到了这个方法的执行过程:
this.accessDecisionManager.decide(authenticated, object, attributes);
而在前面介绍 AbstractInterceptUrlConfigurer 类时,我们同样发现了获取和创建 AccessDecisionManager 的对应方法:
private AccessDecisionManager getAccessDecisionManager(H http) {
if (accessDecisionManager == null) {
accessDecisionManager = createDefaultAccessDecisionManager(http);
}
return accessDecisionManager;
}
private AccessDecisionManager createDefaultAccessDecisionManager(H http) {
AffirmativeBased result = new AffirmativeBased(getDecisionVoters(http));
return postProcess(result);
}
显然,如果没有设置自定义的 AccessDecisionManager,默认会创建一个 AffirmativeBased 实例。AffirmativeBased 的 decide() 方法如下所示:
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
int deny = 0;
for (AccessDecisionVoter voter : getDecisionVoters()) {
int result = voter.vote(authentication, object, configAttributes);
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
if (deny > 0) {
throw new AccessDeniedException(messages.getMessage(
"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
checkAllowIfAllAbstainDecisions();
}
可以看到,这里把真正计算是否具有访问权限的过程委托给了一组 AccessDecisionVoter 对象,只要其中有任意一个的结果是拒绝,就会抛出一个 AccessDeniedException。
AccessDecisionVoter
AccessDecisionVoter 同样是一个接口,提供了如下所示的 vote() 方法:
int vote(Authentication authentication, S object,
Collection<ConfigAttribute> attributes);
我们再次在 AbstractInterceptUrlConfigurer 类中找到了获取 AccessDecisionVoter 的 getDecisionVoters() 抽象方法定义,如下所示:
abstract List<AccessDecisionVoter<?>> getDecisionVoters(H http);
同样是在它的子类 ExpressionUrlAuthorizationConfigurer 中,我们找到了这个抽象方法的具体实现:
@Override
List<AccessDecisionVoter<?>> getDecisionVoters(H http) {
List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<>();
WebExpressionVoter expressionVoter = new WebExpressionVoter();
expressionVoter.setExpressionHandler(getExpressionHandler(http));
decisionVoters.add(expressionVoter);
return decisionVoters;
}
可以看到,这里创建的 AccessDecisionVoter 实际上都是 WebExpressionVoter,它的 vote() 方法如下所示:
public int vote(Authentication authentication, FilterInvocation fi, Collection<ConfigAttribute> attributes) {
…
WebExpressionConfigAttribute weca = findConfigAttribute(attributes);
…
EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, fi);
ctx = weca.postProcess(ctx, fi);
return ExpressionUtils.evaluateAsBoolean(weca.getAuthorizeExpression(), ctx) ? ACCESS_GRANTED: ACCESS_DENIED;
}
这里出现了一个 SecurityExpressionHandler,看类名就可以发现与 Spring 中的表达式语言相关,它会构建一个用于评估的上下文对象 EvaluationContext。而 ExpressionUtils.evaluateAsBoolean() 方法就是根据从 WebExpressionConfigAttribute 获取的授权表达式,以及这个 EvaluationContext 上下文对象完成最终结果的评估:
public static boolean evaluateAsBoolean(Expression expr, EvaluationContext ctx) {
try {
return expr.getValue(ctx, Boolean.class);
}
catch (EvaluationException e) {
…
}
}
显然,最终的评估过程只是简单使用了 Spring 所提供的 SpEL 表达式语言。
作为总结,我们把这一流程中涉及的核心组件以类图的形式进行了梳理,如下图所示:
Spring Security 授权相关核心类图
小结与预告
这一讲我们关注的是 Spring Security 授权机制的实现原理,我们把整个授权过程拆分成拦截请求、获取访问策略和执行授权决策这三大步骤。针对每一个步骤,都涉及了一组核心类及其它们之间的交互关系。针对这些核心类的讲解思路是围绕着上一讲介绍的基本配置方法展开讨论的,确保实际应用能与源码分析衔接在一起。
本讲内容总结如下:
最后给你留一道思考题:在 Spring Security 中,你能简要描述整个授权机制的执行过程吗?
介绍完授权机制的实现原理,我们关于 Spring Security 的基础功能讲解就接近尾声了。下一讲我们来设计并实现一个案例系统,基于目前已经讲解的内容来保护 Web 应用程序。
案例实战:使用 Spring Security 基础功能保护 Web 应用
前面几讲我们系统地介绍了 Spring Security 的认证和授权功能,这是该框架为我们提供的最基础、也是最常用的安全性功能。作为阶段性的总结,今天我们就把前面几讲的内容整合在一起,基于 Spring Security 的认证和授权功能保护 Web 应用程序。
案例设计和初始化
在今天的案例中,我们将构建一个简单但完整的小型 Web 应用程序。当合法用户成功登录系统之后,浏览器会跳转到一个系统主页,并展示一些个人健康档案(HealthRecord)数据。
案例设计
这个 Web 应用程序将采用经典的三层架构,即Web 层、服务层和数据访问层,因此我们会存在 HealthRecordController、HealthRecordService 以及 HealthRecordRepository,这是一条独立的代码流程,用来完成系统业务逻辑处理。
另一方面,本案例的核心功能是实现自定义的用户认证流程,所以我们需要构建独立的 UserDetailsService 以及 AuthenticationProvider,这是另一条独立的代码流程。而在这条代码流程中,势必还需要 User 以及 UserRepository 等组件。
我们可以把这两条代码线整合在一起,得到案例的整体设计蓝图,如下图所示:
案例中的业务代码流程和用户认证流程
系统初始化
要想实现上图中的效果,我们需要先对系统进行初始化。这部分工作涉及领域对象的定义、数据库初始化脚本的整理以及相关依赖组件的引入。
针对领域对象,我们重点来看如下所示的 User 类定义:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String username;
private String password;
@Enumerated(EnumType.STRING)
private PasswordEncoderType passwordEncoderType;
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private List<Authority> authorities;
…
}
可以看到,这里除了指定主键 id、用户名 username 和密码 password 之外,还包含了一个加密算法枚举值 EncryptionAlgorithm。在案例系统中,我们将提供 BCryptPasswordEncoder 和 SCryptPasswordEncoder 这两种可用的密码解密器,你可以通过该枚举值进行设置。
同时,我们在 User 类中还发现了一个 Authority 列表。显然,这个列表用来指定该 User 所具备的权限信息。Authority 类的定义如下所示:
@Entity
public class Authority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
@JoinColumn(name = "user")
@ManyToOne
private User user;
…
}
通过定义不难看出 User 和 Authority 之间是一对多的关系,这点和 Spring Security 内置的用户权限模型是一致的。我们注意到这里使用了一系列来自 JPA(Java Persistence API,Java 持久化 API)规范的注解来定义领域对象之间的关联关系。关于这些注解的使用方式你可以参考拉勾教育上的《Spring Data JPA 原理与实战》专栏进行学习。
基于 User 和 Authority 领域对象,我们也给出创建数据库表的 SQL 定义,如下所示:
CREATE TABLE IF NOT EXISTS `spring_security`.`user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(45) NOT NULL,
`password` TEXT NOT NULL,
`password_encoder_type` VARCHAR(45) NOT NULL,
PRIMARY KEY (`id`));
CREATE TABLE IF NOT EXISTS `spring_security`.`authority` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(45) NOT NULL,
`user` INT NOT NULL,
PRIMARY KEY (`id`));
在运行系统之前,我们同样也需要初始化数据,对应脚本如下所示:
INSERT IGNORE INTO `spring_security`.`user` (`id`, `username`, `password`, `password_encoder_type`) VALUES ('1', 'jianxiang', '$2a$10$xn3LI/AjqicFYZFruSwve.681477XaVNaUQbr1gioaWPn4t1KsnmG', 'BCRYPT');
INSERT IGNORE INTO `spring_security`.`authority` (`id`, `name`, `user`) VALUES ('1', 'READ', '1');
INSERT IGNORE INTO `spring_security`.`authority` (`id`, `name`, `user`) VALUES ('2', 'WRITE', '1');
INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('1', 'jianxiang', 'weight', '70');
INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('2', 'jianxiang', 'height', '177');
INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('3', 'jianxiang', 'bloodpressure', '70');
INSERT IGNORE INTO `spring_security`.`health_record` (`id`, `username`, `name`, `value`) VALUES ('4', 'jianxiang', 'pulse', '80');
请注意,这里初始化了一个用户名为 “jianxiang”的用户,同时指定了它的密码为“12345”,加密算法为“BCRYPT”。
现在,领域对象和数据层面的初始化工作已经完成了,接下来我们需要在代码工程的 pom 文件中添加如下所示的 Maven 依赖:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
这些依赖包都是很常见的,相信从包名中你就能明白各依赖包的作用。
实现自定义用户认证
实现自定义用户认证的过程通常涉及两大部分内容,一方面需要使用 User 和 Authority 对象来完成定制化的用户管理,另一方面需要把这个定制化的用户管理嵌入整个用户认证流程中。下面我们分别详细分析。
实现用户管理
我们知道在 Spring Security 中,代表用户信息的就是 UserDetails 接口。我们也在 03讲 “认证体系:如何深入理解 Spring Security 的用户认证机制?”中介绍过 UserDetails 接口的具体定义。如果你想实现自定义的用户信息,扩展这个接口即可。实现方式如下所示:
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getAuthorities().stream()
.map(a -> new SimpleGrantedAuthority(a.getName()))
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public final User getUser() {
return user;
}
}
上述 CustomUserDetails 类实现了 UserDetails 接口中约定的所有需要实现的方法。请注意,这里的 getAuthorities() 方法中,我们将 User 对象中的 Authority 列表转换为了 Spring Security 中代表用户权限的SimpleGrantedAuthority 列表。
当然,所有的自定义用户信息和权限信息都是维护在数据库中的,所以为了获取这些信息,我们需要创建数据访问层组件,这个组件就是 UserRepository,定义如下:
public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findUserByUsername(String username);
}
这里只是简单扩展了 Spring Data JPA 中的 JpaRepository 接口,并使用方法名衍生查询机制定义了根据用户名获取用户信息的 findUserByUsername 方法。
现在,我们已经能够在数据库中维护自定义用户信息,也能够根据这些用户信息获取到 UserDetails 对象,那么接下来要做的事情就是扩展 UserDetailsService。自定义 CustomUserDetailsService 实现如下所示:
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public CustomUserDetails loadUserByUsername(String username) {
Supplier<UsernameNotFoundException> s =
() -> new UsernameNotFoundException("Username" + username + "is invalid!");
User u = userRepository.findUserByUsername(username).orElseThrow(s);
return new CustomUserDetails(u);
}
}
这里我们通过 UserRepository 查询数据库来获取 CustomUserDetails 信息,如果传入的用户名没有对应的 CustomUserDetails 则会抛出异常。
实现认证流程
我们再次回顾 AuthenticationProvider 的接口定义,如下所示:
public interface AuthenticationProvider {
//执行认证,返回认证结果
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
//判断是否支持当前的认证对象
boolean supports(Class<?> authentication);
}
实现自定义认证流程要做的也是实现 AuthenticationProvider 中的这两个方法,而认证过程势必要借助于前面介绍的 CustomUserDetailsService。
我们先来看一下 AuthenticationProvider 接口的实现类 AuthenticationProviderService,如下所示:
@Service
public class AuthenticationProviderService implements AuthenticationProvider {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Autowired
private SCryptPasswordEncoder sCryptPasswordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
//根据用户名从数据库中获取 CustomUserDetails
CustomUserDetails user = userDetailsService.loadUserByUsername(username);
//根据所配置的密码加密算法分别验证用户密码
switch (user.getUser().getPasswordEncoderType()) {
case BCRYPT:
return checkPassword(user, password, bCryptPasswordEncoder);
case SCRYPT:
return checkPassword(user, password, sCryptPasswordEncoder);
}
throw new BadCredentialsException("Bad credentials");
}
@Override
public boolean supports(Class<?> aClass) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(aClass);
}
private Authentication checkPassword(CustomUserDetails user, String rawPassword, PasswordEncoder encoder) {
if (encoder.matches(rawPassword, user.getPassword())) {
return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), user.getAuthorities());
} else {
throw new BadCredentialsException("Bad credentials");
}
}
}
AuthenticationProviderService 类虽然看起来比较长,但代码基本都是自解释的。我们首先通过 CustomUserDetailsService 从数据库中获取用户信息并构造成 CustomUserDetails 对象。然后,根据指定的密码加密器对用户密码进行验证,如果验证通过则构建一个 UsernamePasswordAuthenticationToken 对象并返回,反之直接抛出 BadCredentialsException 异常。而在 supports() 方法中指定的就是这个目标 UsernamePasswordAuthenticationToken 对象。
安全配置
最后,我们要做的就是通过 Spring Security 提供的配置体系将前面介绍的所有内容串联起来,如下所示:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationProviderService authenticationProvider;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SCryptPasswordEncoder sCryptPasswordEncoder() {
return new SCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(authenticationProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.defaultSuccessUrl("/healthrecord", true);
http.authorizeRequests().anyRequest().authenticated();
}
}
这里注入了已经构建完成的 AuthenticationProviderService,并初始化了两个密码加密器 BCryptPasswordEncoder 和 SCryptPasswordEncoder。最后,我们覆写了 WebSecurityConfigurerAdapter 配置适配器类中的 configure() 方法,并指定用户登录成功后将跳转到"/main"路径所指定的页面。
对应的,我们需要构建如下所示的 MainController 类来指定"/main"路径,并展示业务数据的获取过程,如下所示:
@Controller
public class HealthRecordController {
@Autowired
private HealthRecordService healthRecordService;
@GetMapping("/healthrecord")
public String main(Authentication a, Model model) {
String userName = a.getName();
model.addAttribute("username", userName);
model.addAttribute("healthRecords", healthRecordService.getHealthRecordsByUsername(userName));
return "health_record.html";
}
}
我们通过 Authentication 对象获取了认证用户信息,同时通过 HealthRecordService 获取了健康档案信息。关于 HealthRecordService 的实现逻辑不是今天内容的重点,你可以参考案例源码进行学习:https://github.com/lagouEdAnna/SpringSecurity-jianxiang/tree/main/SpringSecurityBasicDemo。
请注意,这里所指定的 health_record.html 位于 resources/templates 目录下,该页面基于 thymeleaf 模板引擎构建,如下所示:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>健康档案</title>
</head>
<body>
<h2 th:text="'登录用户:' + ${username}" />
<p><a href="/logout">退出登录</a></p>
<h2>个人健康档案:</h2>
<table>
<thead>
<tr>
<th> 健康指标名称 </th>
<th> 健康指标值 </th>
</tr>
</thead>
<tbody>
<tr th:if="${healthRecords.empty}">
<td colspan="2"> 无健康指标 </td>
</tr>
<tr th:each="healthRecord : ${healthRecords}">
<td><span th:text="${healthRecord.name}"> 健康指标名称 </span></td>
<td><span th:text="${healthRecord.value}"> 健康指标值 </span></td>
</tr>
</tbody>
</table>
</body>
</html>
这里我们从 Model 对象中获取了认证用户信息以及健康档案信息,并渲染在页面上。
案例演示
现在,让我们启动 Spring Boot 应用程序,并访问http://localhost:8080端点。因为访问系统的任何端点都需要认证,所以 Spring Security 会自动跳转到如下所示的登录界面:
用户登录界面
我们分别输入用户名“jianxiang”和密码“12345”,系统就会跳转到健康档案主页:
健康档案主页
在这个主页中,我们正确获取了登录用户的用户名,并展示了个人健康档案信息。这个结果也证实了自定义用户认证体系的正确性。你可以根据示例代码做一些尝试。
小结与预告
这一讲我们动手实践了“利用 Spring Security 基础功能保护 Web 应用程序”。综合第 2 讲到 6 讲中的核心知识点,我们设计了一个简单而又完整的案例,并通过构建用户管理和认证流程讲解了实现自定义用户认证机制的过程。
本讲内容总结如下:
最后给你留一道思考题:在 Spring Security 中,实现一套自定义的用户认证体系需要哪些开发步骤?
介绍完今天的案例之后,从下一讲开始,我们将进入到 Spring Security 高级主题的学习,首先要引出的就是 Spring Security 中应用非常广泛的过滤器机制。