本专栏将从基础开始,循序渐进,以实战为线索,逐步深入SpringSecurity相关知识相关知识,打造完整的SpringSecurity学习步骤,提升工程化编码能力和思维能力,写出高质量代码。希望大家都能够从中有所收获,也请大家多多支持。
专栏地址:SpringSecurity专栏
本文涉及的代码都已放在gitee上:gitee地址
如果文章知识点有错误的地方,请指正!大家一起学习,一起进步。
专栏汇总:专栏汇总
几年前,我在美丽的喀尔巴阡山脉滑雪时,目睹了一个有趣的场景。有一群人在排队进入机舱,准备去滑雪场的顶端。一位知名的大网红在两名保镖的陪同下出现了。他自信满满地走上前去,期待着以他的名人身份跳过排队。走到队伍的最前面,他得到了一个惊喜。"请出示车票!"管理登机的人说:"你首先需要一张车票,其次,这次登机没有优先队,对不起,请站在后面排队.。"他指了指队伍的尽头。正如生活中的大多数情况,你是谁并不重要。对程序来说也正是如此。当试图访问一个特定的函数或数据时,你是谁并不重要!
到目前为止,我们只讨论了认证,正如你所了解的,这是应用程序识别资源调用者的过程。在前几章的例子中,我们并没有实现任何规则来决定是否批准一个请求。我们只关心系统是否认识这个用户。在大多数应用中,并不是一个用户可以访问系统中的每一个资源。在本章中,我们将讨论授权。授权是一个过程,在这个过程中,系统决定一个被识别的客户是否具有访问所请求资源的权限。
图7.1 授权是一个过程,在这个过程中,应用程序决定是否允许经过认证的实体访问一个资源。授权总是发生在认证之后。
在Spring Security中,一旦应用程序结束认证流程,它就会将请求委托给授权过滤器。该过滤器根据配置的授权规则允许或拒绝该请求(图7.2)。
图7.2 当客户端发出请求时,认证过滤器对用户进行认证。认证成功后,认证过滤器将用户的详细信息存储在安全上下文中,并将请求转发给授权过滤器。授权过滤器决定该呼叫是否被允许。为了决定是否授权该请求,授权过滤器使用安全上下文中的细节。
在这一节中,你将了解到授权和角色的概念,并用这些来保护你的应用程序的所有api。只有了解这些概念,然后才能将它们应用于现实世界的场景中,在这些场景中,不同的用户有不同的权限。根据用户拥有的权限,他们只能执行一个特定的动作。
在第三章中,我们实现了GrantedAuthority接口。当时我们没有使用GrantedAuthority,这个接口主要与授权过程有关。现在我们可以回到GrantedAuthority来研究它的目的。图7.3展示了UserDetails接口的约定和GrantedAuthority接口之间的关系。 一旦我们讨论完这个接口,我们将学会如何单独使用这些规则或为特定的请求使用。
图7.3 一个用户有一个或多个权限(用户可以做的动作)。在认证过程中,UserDetailsService获得了关于用户的所有细节,包括权限。 应用程序在成功认证用户后,使用GrantedAuthority接口所代表的权限进行授权。
代码清单7.1显示了GrantedAuthority接口的定义。每个授权都表示用户可以对一系列程序资源操作的权限。每个权限都有对应的名字,对象的getAuthority()行为将其作为一个字符串返回。通常情况下,一个授权规则可以是这样的:"允许Jane删除产品记录,"或 “允许John读取文档记录”。在这些情况下,删除和读取是被授予的权限。应用程序允许用户Jane和John执行这些操作,这些操作的名称通常是读、写或删除。
代码清单 7.1 GrantedAuthority 接口 The GrantedAuthority contract
public interface GrantedAuthority extends Serializable {
String getAuthority();
}
UserDetails是Spring Security中描述用户的接口,它有一个GrantedAuthority实例的集合,如图7.3所示。你可以允许一个用户拥有一个或多个权限。getAuthorities()方法返回GrantedAuthority实例的集合。在代码清单7.2中,我们可以查看UserDetails接口中的这个方法。我们可以实现这个方法,使其返回所有授予用户的权限。在认证结束后,这些授权是关于登录用户的细节的一部分,应用程序可以用它来授予权限。
代码清单7.2 来自UserDetails接口的getAuthorities(方法
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
// 剩余代码省略
}
在本节中,我们将讨论限制特定用户对api的访问。到目前为止,在我们的例子中,任何经过认证的用户都可以调用应用程序的任何api。从现在开始,你将学习如何定制这种访问。我们将写几个例子,让你了解用Spring Security应用这些限制的各种方法。
图7.4 授权是用户在应用程序中可以执行的动作。基于这些操作,你可以实现授权规则。只有拥有特定权限的用户才能向一个端点提出特定的请求。例如,Jane只能读取和写入端点,而John可以读取、写入、删除和更新端点。
现在我们已经了解了UserDetails和GrantedAuthority接口以及它们之间的关系,现在是时候写一个应用授权规则的小程序了。通过这个例子,我们可以学到一些替代方案,根据用户的权限来配置对终端的访问。我们开始一个新项目,我把它命名为ch07-001-authorization。这里展示三种方法,你可以使用这些方法配置所提到的api:
我推荐使用这个方法或hasAuthority()方法,因为它们很简单,这取决于你分配给用户的权限数量。这些都是简单的阅读配置,使我们的代码更容易理解。
pom.xml文件中唯一需要依赖的是spring-boot-starter-web和spring-boot-starter-security。这些依赖关系足以接近之前列举的所有三种解决方案。你可以在项目ch07-001-authorization中找到这个例子。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
我们还在应用程序中添加一个api来测试我们的授权配置。
package com.hashnode.proj0001firstspringsecurity.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
return "Hello!";
}
}
在一个配置类中,我们声明一个InMemoryUserDetailsManager作为我们的UserDetailsService,并添加两个用户,John和Jane,由这个实例来管理。每个用户都有不同的权限。你可以在下面的列表中看到如何做到这一点。
代码清单7.3 声明UserDetailsService并分配用户
package com.hashnode.proj0001firstspringsecurity.controller;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class ProjectConfig {
@Bean
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
UserDetails user1 = User.withUsername("john").password("12345").authorities("READ").build();
UserDetails user2 = User.withUsername("jane").password("12345").authorities("WRITE").build();
manager.createUser(user1);
manager.createUser(user2);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}
接下来要做的是添加授权配置。在第2章中,我们配置了如何使所有的api对每个人都能访问。为了做到这一点,我们扩展了WebSecurityConfigurerAdapter类,并重载了configure()方法,如代码清单7.4所示:
代码清单7.4 使每个人都能访问所有的端点,而不需要认证
authorizeRequests()方法可以让我们在api上指定授权规则。anyRequest()方法表示该规则适用于所有的请求,无论使用的是什么URL或HTTP方法。permitAll()方法允许访问所有请求,无论是否经过验证。
比方说,我们想确保只有拥有WRITE权限的用户才能访问所有的端点。对于我们的例子,这意味着只有Jane可以访问。这次我们可以实现我们的目标,根据用户的权限来限制访问。看看下面列表中的代码吧。
清单7.5 限制只有拥有WRITE权限的用户才能访问
package com.hashnode.proj0001firstspringsecurity.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/**
* @author Guowei Chi
* @date 2023/1/19
* @description:
**/
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
//省略部分代码
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
http.authorizeRequests()
.anyRequest()
.hasAuthority("WRITE"); //限制只有 WRITE 权限的用户可以访问
}
}
可以看到,这里使用hasAuthority()方法替换了 permitAll()方法,这里给出了允许用户使用的权限名称作为hasAuthority()方法的参数。应用程序首先需要对请求进行认证,然后根据用户的权限,决定是否允许该调用。
接下来测试应用程序,分别使用不同用户调用api。当我们用用户Jane调用api时,HTTP响应状态是200 OK,我们看到的响应体是 "Hello!"当我们用用户John调用时,HTTP响应状态是403 Forbidden,我们得到一个空的响应体。
curl -u jane:12345 http://localhost:8080/hello
Hello!
curl -u john:12345 http://localhost:8080/hello
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/hello"
}
以类似的方式,还可以使用hasAnyAuthority()方法,这个方法可以接收多个权限名称,表示只要用户有任意权限就可以访问某些api。
可以用hasAnyAuthority(“WRITE”)替换前面的hasAuthority(),在这种情况下,程序以同样的方式工作。然而,如果你将hasAuthority()替换为hasAnyAuthority(“WRITE”, “READ”),那么来自具有两种权限的用户的请求都会被接受。 在我们的例子中,应用程序允许来自John和Jane的请求。在下面的列表中,你可以看到如何应用hasAnyAuthority()方法。
代码清单 7.6 应用 hasAnyAuthority() 方法
package com.hashnode.proj0001firstspringsecurity.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Bean
@Override
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
UserDetails user1 = User.withUsername("john")
.password("12345").
authorities("READ").
build();
UserDetails user2 = User.withUsername("jane")
.password("12345")
.authorities("WRITE")
.build();
manager.createUser(user1);
manager.createUser(user2);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
//允许具有WRITE或READ权限的用户访问
http.authorizeRequests()
.anyRequest()
.hasAnyAuthority("WRITE","READ");
}
}
为了指定基于用户权限的访问,第三种方式是access()方法。然而,access()方法更为通用。它接收一个指定授权条件的Spring表达式(SpEL)作为参数。这个方法很强大,而且它不仅仅指的是授权。然而,这个方法也使代码更难阅读和理解。出于这个原因,我推荐它作为最后的选择,而且只有在你不能应用本节前面介绍的hasAuthority()或hasAnyAuthority()方法之一的情况下。
为了使这个方法更容易理解,我首先把它作为用hasAuthority()和hasAnyAuthority()方法指定权限的替代方法。该方法必须提供一个Spring表达式作为方法的参数。然而,access()方法的优点是允许你通过你提供的表达式作为参数来定制规则。
注意 在大多数情况下,可以用hasAuthority()和hasAnyAuthority()方法实现所需的限制,推荐使用这些方法。只有在其他两个选项不合适,并且你想实现更多的通用授权规则时,才使用access()方法。
我们从一个简单的例子开始,以配合前面案例中的相同要求。 如果只需要测试用户是否有特定的权限,需要与access()方法一起使用的表达式可以是以下的一种。
请注意,这些表达式的名称与本节前面介绍的方法相同。下面的代码演示了如何使用access()方法。
代码清单7.7 使用access()方法来配置对api的访问
从代码清单7.7中的例子可以看出,如果你将access()方法用于直接的要求,那么它是如何使语法复杂化的。在这种情况下,应该直接使用 hasAuthority() 或 hasAnyAuthority() 方法。但是access()方法并不全是不可取的,正如前面所说,它为你提供了灵活性。在现实世界的场景中,你可以用它来写更复杂的表达式,根据这些表达式,应用程序授予访问权。如果没有access()方法,你就无法实现这些场景。
在代码清单7.8中,我们发现如果access()方法不应用了表达式,就不容易写出来这样的权限控制。准确地说,代码清单7.8中的配置定义了两个用户,John和Jane,他们有不同的权限。用户John只有读取权限,而Jane有读取、写入和删除权限。api应该被那些有阅读权限的用户所访问,而不是那些有删除权限的用户。
代码清单7.8 用一个更复杂的表达式来应用access()方法
当然,这只是一个假设的例子,但它足够简单,容易理解,也足够复杂,可以证明为什么access()方法更强大。
在本节中,我们将讨论根据角色来限制对api的访问。角色是指用户可以做什么的另一种方式(图7.5)。你在现实世界的应用中也会发现这些,所以这就是为什么理解角色以及角色和权限之间的区别很重要。在本节中,我们将应用几个使用角色的例子,这样你就会知道应用程序使用角色的所有实际情况,以及如何为这些情况编写配置。
图7.5 角色是粗粒度的。每个拥有特定角色的用户只能做该角色所授予的动作。在授权中应用这种理念时,根据用户在系统中的目的来允许请求。只有拥有特定角色的用户才能调用某个api。
Spring Security将权限理解为细粒度的特权,并对其施加限制。角色赋予用户一组行动的权限。例如在你的程序中,一个用户要么只拥有读取权限,要么拥有所有:读取、写入和删除权限。在这种情况下,如果认为那些只能阅读的用户拥有一个名为READER的角色,而其他用户拥有ADMIN的角色,拥有ADMIN角色意味着应用程序授予你读、写、更新和删除权限。程序中可能有更多的角色。例如,如果在某个时候,还需要一个只允许读和写的用户,你可以为你的应用程序创建第三个角色,名为MANAGER。
注意 当在应用程序中使用带有角色的方法时,我们将不必再定义权限。但是在应用程序中,需要定义一个角色来涵盖一个或多个用户被授权的行为。
用户可以自定义角色的名字,与授权相比,角色是粗粒度的,一个角色含有多个授权。 当定义一个角色时,它的名字应该以ROLLE_的前缀开始。在实现层面上,这个前缀指定了角色和权限之间的区别。在下一个代码清单中,看看我对前面的例子所做的修改。
清单7.9 为用户设置角色
package com.hashnode.proj0001firstspringsecurity.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/**
* @author Guowei Chi
* @date 2023/1/19
* @description:
**/
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Bean
@Override
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
UserDetails user1 = User.withUsername("john")
.password("12345")
//具有ROLE_前缀,表示角色
.authorities("ROLE_ADMIN")
.build();
UserDetails user2 = User.withUsername("jane")
.password("12345")
.authorities("ROLE_MANAGER")
.build();
manager.createUser(user1);
manager.createUser(user2);
return manager;
}
//省略其他代码
}
要为用户角色设置约束,你可以使用以下方法之一。
正如你所看到的,这些名称与第7.1.1节中介绍的方法类似。在下一个代码清单中,你可以看到configure()方法现在是什么样子。
package com.hashnode.proj0001firstspringsecurity.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {
@Bean
@Override
public UserDetailsService userDetailsService(){
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
UserDetails user1 = User.withUsername("john")
.password("12345")
//具有ROLE_前缀,表示角色
.authorities("ROLE_ADMIN")
.build();
UserDetails user2 = User.withUsername("jane")
.password("12345")
.authorities("ROLE_MANAGER")
.build();
manager.createUser(user1);
manager.createUser(user2);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic();
//hasRole()方法现在指定了允许访问该api的角色。注意,ROLLE_前缀不会出现在这里。
http.authorizeRequests()
.anyRequest().hasRole("ADMIN");
}
}
注意 需要注意是,我们只使用ROLE_前缀来声明角色。但当我们使用角色时,我们只用它的名字来做。
测试结果如下:
curl -u john:12345 http://localhost:8080/hello
Hello!
curl -u jane:12345 http://localhost:8080/hello
{
"status":403,
"error":"Forbidden",
"message":"Forbidden",
"path":"/hello"
}
注意 确保你为role()方法提供的参数不能包括ROLLE_前缀。如果不小心在role()参数中包含了该前缀,该方法会抛出一个异常。简而言之,当使用authorities()方法时,要包括ROLLE_前缀。当使用role()方法时,不要包括ROLLE_前缀。
在下面的代码清单中,你可以看到当设计基于角色的访问时,使用role()方法而不是authorities()方法的正确使用方式。
代码清单7.11 用role()方法设置角色
在第7.1.1节和第7.1.2节中,我们学习了如何使用access()方法来应用提及权限和角色的授权规则。一般来说,在一个应用程序中,授权限制是与权限和角色相关的。但重要的是要记住,access()方法是通用的。在我介绍的例子中,我主要是教你如何将它应用于权限和角色,但在实践中,它可以接受任何SpEL表达。它不需要与权限和角色相关联。一个直接的例子是将对端点的访问配置为只允许在晚上12:00以后。要解决这样的问题,你可以使用下面的SpEL表达式:
T(java.time.LocalTime).now().isAfter(T(java.time.LocalTime).of(12, 0))
关于SpEL表达式的更多信息,请参见Spring文档:Core Technologies (spring.io)
通过access()方法,基本上可以实现任何种类的规则。这种可能性是无穷无尽的。只是别忘了,在应用程序中,我们总是努力使语法尽可能地简单。只有当你没有其他选择的时候,才会使你的配置复杂化。
在这一节中,我们将讨论限制对所有请求的访问。我们在第5章中了解到,通过使用permitAll()方法,可以允许对所有请求的访问。你还了解到,可以根据权限和角色来应用访问规则,不仅如此还可以拒绝所有请求。denyAll()方法与permitAll()方法正好相反。在接下来的代码清单中,你可以看到如何使用denyAll()方法。
代码清单7.12 使用denyAll(方法来限制对端点的访问
那么,可以在什么地方使用这种限制呢?假设现在有一个api,以电子邮件地址作为入参,我们想要允许那些变量地址的值以.com结尾的请求,不希望应用程序接受任何其他格式的电子邮件地址。对于这个需求,我们可以使用一个正则表达式来分组符合规则的请求,然后使用denyAll()方法来使应用程序拒绝所有这些请求(图7.6)。
图7.6 当用户调用端点并提供以.com结尾的参数值时,应用程序接受该请求。当用户调用端点并提供以.net结尾的电子邮件地址时,应用程序会拒绝该调用。为了实现这种行为,你可以对所有参数值不以.com结尾的端点使用denyAll()方法。
在微服务的场景,如图7.7所示,不同微服务有不同的功能,这些用例可以通过调用不同路径上的api进行访问。但是为了调用一个api,需要请求网关,在这个架构中,有两个网关服务。 在图7.7中,我把它们称为网关A和网关B。 客户端如果想访问/产品路径,就请求网关A。但对于/文章路径,客户必须请求网关B。每一个网关服务都被设计为拒绝所有对其他路径的请求,这些服务不为这些路径服务。这个简化的场景可以帮助你轻松理解denyAll()方法。在一个生产应用中,你可以在更复杂的架构中找到类似的情况。
图7.7 通过网关A和网关B进行访问。每个网关只接收特定路径的请求,拒绝其他所有的请求。
总结