安全是开发者永远绕不开的话题,一个不安全的网站,往往存在着各种致命漏洞,只要被不法分子稍加利用,就能直接击溃整个网站,甚至破坏网站宝贵的用户数据。而用户的授权校验,则是网站安全系统的典型代表,这也是用户访问网站的第一关,我们需要一个更加安全和可靠的授权校验框架,才能让我们的网站更加稳定。
SpringSecurity是一个基于Spring开发的非常强大的权限验证框架,其核心功能包括:
认证 (用户登录) 授权 (此用户能够做哪些事情) 攻击防护 (防止伪造身份攻击) 本章,我们会从多个方面对此框架进行介绍,让各位小伙伴能够在自己的项目中使用更加安全和可靠的身份校验方案。
接下来,我们会介绍几个常见的Web网站攻击方式,以及在后续使用SpringSecurity时如何去防范这些攻击行为。
CSRF跨站请求伪造攻击
CSRF是我们要介绍的第一种攻击形式,这种攻击方式非常好理解。
我们时常会在QQ上收到别人发送的钓鱼网站链接,只要你在上面登陆了你的QQ账号,那么不出意外,你的号已经在别人手中了。实际上这一类网站都属于恶意网站,专门用于盗取他人信息,执行非法操作,甚至获取他人账户中的财产,非法转账等。而这里,我们需要了解一种比较容易发生的恶意操作,从不法分子的角度去了解整个流程。
我们在JavaWeb阶段已经了解了Session和Cookie的机制,在一开始的时候,服务端会给浏览器一个名为JSESSIONID的Cookie信息作为会话的唯一凭据,只要用户携带此Cookie访问我们的网站,那么我们就可以认定此会话属于哪个浏览器用户。因此,只要此会话的用户执行了登录操作,那么就可以随意访问个人信息等内容。
我们来尝试模拟一下这种操作,来编写一个钓鱼网站:
<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<title>坤坤炒粉放鸡精视频在线观看</title>
<script src="https://unpkg.com/[email protected]/dist/axios.min.js"></script>
</head>
<iframe name="hiddenIframe" hidden></iframe>
<form action="http://localhost:8080/mvc/pay" method="post" target="hiddenIframe">
<input type="text" name="account" value="黑客" hidden>
<button type="submit">点击下载全套视频</button>
</form>
</body>
</html>
这个页面并不是我们官方提供的页面,而是不法分子搭建的恶意网站。我们发现此页面中有一个表单,但是表单中的输入框被隐藏了,而我们看到的只有一个按钮,我们不知道这是一个表单,也不知道表单会提交给那个地址,这时整个页面就非常有迷惑性了。如果我们点击此按钮,那么整个表单的数据会以POST的形式发送给我们的服务端(会携带之前登陆我们网站的Cookie信息),但是这里很明显是另一个网站跳转,通过这样的方式,恶意网站就成功地在我们毫不知情的情况下引导我们执行了转账操作,当你发现上当受骗时,钱已经被转走了。
我们首先作为用户,先在正常的网站进行登录,接着我们假装自己上当,进入到我们的钓鱼网站,现在我们毫不知情,如果是正常人思维的话,就会直接点击下载全套视频,恭喜,此时后台已经转账成功了,留下一脸懵逼的你。
而这种构建恶意页面,引导用户访问对应网站执行操作的方式称为:
跨站请求伪造
(CSRF,Cross Site Request Forgery)
而SpringSecurity就解决了这样的问题。
当然,除了通过我们自己SpringSecurity使用去解决之外,随着现在的浏览器不断发展,安全性越来越受到重视,很多浏览器都有SameSite保护机制,当用户在两个不同域名的站点操作时,默认情况下Cookie就会被自动屏蔽:
SameSite是一种安全机制,旨在防止跨站点请求伪造(CSRF)攻击,它通过限制第三方Cookie的使用来实现这一目的。在Chrome浏览器中,SameSite默认为Lax,这意味着第三方Cookie只能在用户导航到与原始站点相同的站点时发送。这同样大大提升了用户的安全性,让黑客少了许多可乘之机,不过这个机制对做实施的同学(可能)不太友好
SFA会话固定攻击
这同样是利用Cookie中相同的JSESSIONID进行的攻击,会话固定攻击(Session fixation attack)是一种针对Web应用程序的安全漏洞攻击,攻击者利用这种漏洞,将一个有效的会话ID分配给用户,并诱使用户在该会话中进行操作,然后攻击者可以利用该会话ID获取用户的权限,或者通过此会话继续进行其他攻击。
简单来说,就是黑客把他的JSESSIONID直接给你,你一旦使用这个ID登录,那么在后端这个ID就被认定为已登录状态,那么也就等同于他直接进入了已登录状态,从而直接访问你账号的任意内容,执行任意操作。
攻击者通常使用以下几种方式进行会话固定攻击:
会话传递:攻击者通过URL参数、表单隐藏字段、cookie等方式将会话ID传递给用户。当用户使用该会话ID登录时,攻击者就能利用该会话ID获取用户的权限。 会话劫持:攻击者利用劫持用户与服务器之间的通信流量,获取到用户的会话ID,然后利用该会话ID冒充用户进行操作。 会话劫持:攻击者事先获取到会话ID,并将其分配给用户,之后通过其他方式欺骗用户登录该会话。这样,攻击者就可以利用会话ID获取用户的权限。 这里我们来尝试一下第一种方案,这里我们首先用另一个浏览器访问目标网站,此时需要登录,开始之前记得先清理一下两个浏览器的缓存,否则可能无法生效:
这里我们直接记录下这个JSESSIONID,然后将其编写到我们的诈骗网站中,这里有一个恶意脚本,会自动将对应用户的Cookie进行替换,变成我们的JSESSIONID值:
<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<title>冠希哥全套视频</title>
<script src="https://unpkg.com/[email protected]/dist/axios.min.js"></script>
</head>
<script>
//第三方网站恶意脚本,自动修改Cookie信息
document.cookie = "JSESSIONID=6AAF677EC2B630704A80D36311F08E01; path=/mvc; domain=localhost"
//然后给你弄到原来的网站
location.href = 'http://localhost:8080/mvc/'
</script>
</body>
</html>
接着我们访问这个恶意网站,然后再作为用户,去正常访问目标网站进行登录操作
可以看到此时用户的浏览器JSESSIONID值为刚刚恶意网站伪造的值,现在我们来进行登录操作:
此时我们回到一开始的浏览器,刷新之后,我们发现这个浏览器同样已经登录成功了,原理其实很简单,相当于让用户直接帮我们登录了,是不是感觉特别危险?
当然,现在的浏览器同样有着对应的保护机制,Tomcat发送的SESSIONID默认是勾选了HttpOnly选项的,一旦被设定是无法被随意修改的,当然前提是先得正常访问一次网站才行,否则仍然存在安全隐患。
HttpOnly是Cookie中一个属性,用于防止客户端脚本通过document.cookie属性访问Cookie,有助于保护Cookie不被跨站脚本攻击窃取或篡改。但是,HttpOnly的应用仍存在局限性,一些浏览器可以阻止客户端脚本对Cookie的读操作,但允许写操作;此外大多数浏览器仍允许通过XMLHTTP对象读取HTTP响应中的Set-Cookie头。
为了彻底杜绝这个问题,登录成功之后应该重新给用户分配一个新的JSESSIONID才行,而这些都由SpringSecurity帮我们实现了。
XSS跨站脚本攻击
前面我们介绍了两种攻击方式,不过都是从外部干涉,在外部无法干涉的情况下,我们也可以从内部击溃网站,接下来我们隆重介绍XSS跨站脚本攻击方式。
XSS(跨站脚本攻击)是一种常见的网络安全漏洞,攻击者通过在合法网站中注入恶意脚本代码来攻击用户。当用户访问受到注入攻击的页面时,恶意代码会在用户的浏览器中执行,从而导致攻击者能够窃取用户的敏感信息、诱导用户操作、甚至控制用户的账号。
XSS攻击常见的方式有三种:
存储型XSS攻击:攻击者将恶意代码存储到目标网站的数据库中,当其他用户访问包含恶意代码的页面时,恶意代码会被执行。 反射型XSS攻击:攻击者将恶意代码嵌入到URL中,当用户点击包含恶意代码的URL时,恶意代码会被执行。 DOM-based XSS攻击:攻击者利用前端JavaScript代码的漏洞,通过修改页面的DOM结构来执行恶意代码。 在一些社交网站上,用户可以自由发帖,而帖子是以富文本形式进行编辑和上传的,发送给后台的帖子往往是直接以HTML代码的形式,这个时候就会给黑客可乘之机了。
正常情况下,用户发帖会向后端上传以下内容,这些是经过转换得到的正常HTML代码,方便后续直接展示:
<div class="content ql-editor">
<strong>萨达睡觉了大数据</strong>
<p>撒大大撒大声地</p>
</div>
而现在,黑客不走常规的方式发帖,而是发送以下内容给服务端:
<div class="content ql-editor">
<p οnlοad="alert('xss')">
<strong>萨达睡觉了大数据</strong>
<p>撒大大撒大声地</p>
</div>
可以看到p标签上添加了一段JS恶意脚本,黑客可以利用这种特性,获取用户的各种信息,甚至直接发送到他的后台,这样,我们的个人信息就从网站内部被泄露了。
XSS漏洞最早被发现是在1996年,由于JavaScript的出现,导致在Web应用程序中存在了一些安全问题。在1997年,高智文(Gareth Owen)也就是“XSS之父”,在他的博客中描述了一种称为“脚本注入”(script injection)的攻击技术,这就是XSS漏洞的前身。从那时起,XSS漏洞便成为了Web应用程序中的一种常见安全漏洞。
这种攻击一般需要前端配合后端进行防御,或者后端对前端发送的内容进行安全扫描并处理,有机会我们会分享如何防范此类攻击。
在了解这么多攻击方式之后,想必各位小伙伴肯定有了一定的网络安全意识,不过我们并不是网络安全专业的课程,至于更多的攻击形式,还请各位小伙伴自行了解,从下一个板块开始,我们将会正式开始SpringSecurity框架的介绍。
开发环境配置
首先我们需要导入SpringSecurity的相关依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
然后我们运行项目,会发现无法进入的我们的页面中,无论我们访问哪个页面,都会进入到SpringSecurity为我们提供的一个默认登录页面,之后我们会讲解如何进行配置。
认证
认证是我们网站的第一步,用户需要登录之后才能进入,这一部分我们将详细介绍如何使用SpringSecurity实现用户登录。
基于内存验证
首先我们来看看最简单的基于内存的配置,也就是说我们直接以代码的形式配置我们网站的用户和密码,配置方式非常简单,只需要创建Security配置类中注册一个Bean即可:
@Configuration
public class SecurityConfiguration {
@Bean //UserDetailsService就是获取用户信息的服务
public UserDetailsService userDetailsService() {
//每一个UserDetails就代表一个用户信息,其中包含用户的用户名和密码以及角色
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER") //角色目前我们不需要关心,随便写就行,后面会专门讲解
.build();
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("ADMIN", "USER")
.build();
return new InMemoryUserDetailsManager(user, admin);
//创建一个基于内存的用户信息管理器作为UserDetailsService
}
配置完成后,我们就可以前往登录界面,进行登录操作了:
并且为了防止我们之前提到的会话固定问题,在登录之后,JSESSIONID会得到重新分配:
当我们想要退出时,也可以直接访问:
http://localhost:8080/logout
地址,我们会进入到一个退出登录界面,退出登录后就需要重新登录才能访问我们的网站了。
可以发现,在有了SpringSecurity之后,我们网站的登录验证模块相当于直接被接管了,因此,从现在开始,我们的网站不需要再自己编写登录模块了。
这样,我们的网站就成功用上了更加安全的SpringSecurity框架了。细心的小伙伴可能发现了,我们在配置用户信息的时候,报了黄标,实际上这种方式存储密码并不安全:
这是因为SpringSecurity的密码校验不推荐直接使用原文进行比较,而是使用加密算法将密码进行加密(更准确地说应该进行Hash处理,此过程是不可逆的,无法解密),最后将用户提供的密码以同样的方式加密后与密文进行比较。对于我们来说,用户提供的密码属于隐私信息,直接明文存储并不好,而且如果数据库内容被窃取,那么所有用户的密码将全部泄露,这是我们不希望看到的结果,我们需要一种既能隐藏用户密码也能完成认证的机制,而Hash处理就是一种很好的解决方案,通过将用户的密码进行Hash值计算,计算出来的结果一般是单向的,无法还原为原文,如果需要验证是否与此密码一致,那么需要以同样的方式加密再比较两个Hash值是否一致,这样就很好的保证了用户密码的安全性。
因此,我们在配置用户信息的时候,可以使用官方提供的BCrypt加密工具:
@Configuration
public class SecurityConfiguration {
//这里将BCryptPasswordEncoder直接注册为Bean,Security会自动进行选择
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
UserDetails user = User
.withUsername("user")
.password(encoder.encode("password")) //这里将密码进行加密后存储
.roles("USER")
.build();
System.out.println(encoder.encode("password")); //一会观察一下加密出来之后的密码长啥样
UserDetails admin = User
.withUsername("admin")
.password(encoder.encode("password")) //这里将密码进行加密后存储
.roles("ADMIN", "USER")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
这样,我们存储的密码就是更加安全的密码了:
基于数据库验证
前面我们已经实现了直接认证的方式,但是实际项目中往往都是将用户信息存储在数据库中,那么如何将其连接到数据库,通过查询数据库中的用户信息来进行用户登录呢?
create table users(username varchar(50) not null primary key,password varchar(500) not null,enabled boolean not null);
create table authorities (username varchar(50) not null,authority varchar(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);
接着我们编写配置类:
@Configuration
public class SecurityConfiguration {
@Bean PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
@Bean
public DataSource dataSource(){
//数据源配置
return new PooledDataSource("com.mysql.cj.jdbc.Driver",
"jdbc:mysql://localhost:3306/test", "root", "123456");
@Bean
public UserDetailsService userDetailsService(DataSource dataSource,
PasswordEncoder encoder) {
JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
//仅首次启动时创建一个新的用户用于测试,后续无需创建
manager.createUser(User.withUsername("user")
.password(encoder.encode("password")).roles("USER").build());
return manager;
}
启动后,可以看到两张表中已经自动添加好对应的数据了。
我们可以直接尝试进行登录,使用方式和之前是完全一样的
这样,当我们下次需要快速创建一个用户登录的应用程序时,直接使用这种方式就能快速完成了,是不是感觉特别方便。
无论是我们认识的InMemoryUserDetailsManager还是现在认识的JdbcUserDetailsManager,他们都是实现自UserDetailsManager接口,这个接口中有着一套完整的增删改查操作,方便我们直接对用户进行处理:
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对象,我们就能快速执行用户相关的管理操作,比如我们可以直接在网站上添加一个快速重置密码的接口,首先需要配置一下JdbcUserDetailsManager,为其添加一个AuthenticationManager用于原密码的校验:
@Configuration
public class SecurityConfiguration {
//手动创建一个AuthenticationManager用于处理密码校验
private AuthenticationManager authenticationManager(UserDetailsManager manager,
PasswordEncoder encoder){
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(manager);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
@Bean
public UserDetailsManager userDetailsService(DataSource dataSource,
PasswordEncoder encoder) throws Exception {
JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
//为UserDetailsManager设置AuthenticationManager即可开启重置密码的时的校验
manager.setAuthenticationManager(authenticationManager(manager, encoder));
return manager;
}
接着我们编写一个快速重置密码的接口:
@ResponseBody
@PostMapping("/change-password")
public JSONObject changePassword(@RequestParam String oldPassword,
@RequestParam String newPassword) {
manager.changePassword(oldPassword, encoder.encode(newPassword));
JSONObject object = new JSONObject();
object.put("success", true);
return object;
}
接着我们在主界面中添加一个重置密码的操作:
<div>
<label>
修改密码:
<input type="text" id="oldPassword" placeholder="旧密码"/>
<input type="text" id="newPassword" placeholder="新密码"/>
</label>
<button onclick="change()">修改密码</button>
</div>
function change() {
const oldPassword = document.getElementById("oldPassword").value
const newPassword = document.getElementById("newPassword").value
const csrf = document.getElementById("_csrf").value
axios.post('/mvc/change-password', {
oldPassword: oldPassword,
newPassword: newPassword,
_csrf: csrf
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}).then(({data}) => {
alert(data.success ? "密码修改成功" : "密码修改失败,请检查原密码是否正确")
}
当然,这种方式的权限校验虽然能够直接使用数据库,但是存在一定的局限性,只适合快速搭建Demo使用,不适合实际生产环境下编写,下一节我们将介绍如何实现自定义验证,以应对各种情况。
自定义验证
有些时候,我们的数据库可能并不会像SpringSecurity默认的那样进行设计,而是采用自定义的表结构,这种情况下,上面两种方式就很难进行验证了,此时我们得编写自定义验证,来应对各种任意变化的情况。
既然需要自定义,那么我们就需要自行实现UserDetailsService或是功能更完善的UserDetailsManager接口,这里为了简单,我们直接选择前者进行实现:
@Service
public class AuthorizeService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
现在我们需要去实现这个loadUserByUsername方法,表示在验证的时候通过自定义的方式,根据给定的用户名查询用户,并封装为UserDetails对象返回,然后由SpringSecurity将我们返回的对象与用户登录的信息进行核验,基本流程实际上跟之前是一样的,只是现在由我们自己来提供用户查询方式。
现在我们在数据库中创建一个自定义的用户表:
然后我们来到Service这边进行一下完善,从数据库中进行查询:
@Service
public class AuthorizeService implements UserDetailsService {
@Resource
UserMapper mapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Account account = mapper.findUserByName(username);
if(account == null)
throw new UsernameNotFoundException("用户名或密码错误");
return User
.withUsername(username)
.password(account.getPassword())
.build();
}
这样,我们就通过自定义的方式实现了数据库信息查询,并完成用户登录操作。
csrf防护
不过,可能会有小伙伴发现,所有的POST请求都被403了:
这是因为SpringSecurity自带了csrf防护,需求我们在POST请求中携带页面中的csrfToken才可以,否则一律进行拦截操作,这里我们可以将其嵌入到页面中,随便找一个地方添加以下内容:
<input type="text" th:id="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>
接着在axios发起请求时,携带这个input的value值:
function pay() {
const account = document.getElementById("account").value
const csrf = document.getElementById("_csrf").value
axios.post('/mvc/pay', {
account: account,
_csrf: csrf //携带此信息即可,否则会被拦截
...
实际上现在的浏览器已经很安全了,完全不需要使用自带的csrf防护,后面我们会讲解如何通过配置关闭csrf防护。
从Spring Security 4.0开始,默认情况下会启用CSRF保护,以防止CSRF攻击应用程序,Spring Security CSRF会针对PATCH,POST,PUT和DELETE方法的请求(不仅仅只是登陆请求,这里指的是任何请求路径)进行防护,而这里的登陆表单正好是一个POST类型的请求。在默认配置下,无论是否登陆,页面中只要发起了PATCH,POST,PUT和DELETE请求一定会被拒绝,并返回403错误
(注意,这里是个究极大坑,这个没有任何提示,直接403,因此如果你不知道的话根本不清楚是什么问题,就一直卡这里了)
,需要在请求的时候加入csrfToken才行,也就是"83421936-b84b-44e3-be47-58bb2c14571a",正是csrfToken,如果提交的是表单类型的数据,那么表单中必须包含此Token字符串,键名称为"_csrf";如果是JSON数据格式发送的,那么就需要在请求头中包含此Token字符串。
其他配置
前面我们介绍了如果将SpringSecurity作为我们的登录校验框架,并且实现了三种方式的校验,但是光是这样,自由度还远远不够,在实际开发场景中,我们还会面对各种各样的需求,这一部分我们接着来进行更加深层次的配置。
自定义登录界面
虽然SpringSecurity为我们提供了一个还行的登录界面,但是很多情况下往往都是我们使用自定义的登录界面,这个时候就需要进行更多的配置了。
@Configuration
public class SecurityConfiguration {
//如果你学习过SpringSecurity 5.X版本,可能会发现新版本的配置方式完全不一样
//新版本全部采用lambda形式进行配置,无法再使用之前的and()方法进行连接了
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
//以下是验证请求拦截和放行配置
.authorizeHttpRequests(auth -> {
auth.anyRequest().authenticated(); //将所有请求全部拦截,一律需要验证
//以下是表单登录相关配置
.formLogin(conf -> {
conf.loginPage("/login"); //将登录页设置为我们自己的登录页面
conf.loginProcessingUrl("/doLogin"); //登录表单提交的地址,可以自定义
conf.defaultSuccessUrl("/"); //登录成功后跳转的页面
conf.permitAll(); //将登录相关的地址放行,否则未登录的用户连登录界面都进不去
//用户名和密码的表单字段名称,不过默认就是这个,可以不配置,除非有特殊需求
conf.usernameParameter("username");
conf.passwordParameter("password");
.build();
}
但是我们发现,我们的页面只有一个纯文本,这是因为在获取静态资源的时候,所有的静态资源默认情况下也会被拦截,因此全部被302重定向到登录页面,这显然是不对的:
因此,现在我们需要将所有的静态资源也给放行,否则登录界面都没法正常展示:
.authorizeHttpRequests(auth -> {
auth.requestMatchers("/static/**").permitAll(); //将所有的静态资源放行,一定要添加在全部请求拦截之前
// "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**","*/api-docs"
auth.anyRequest().authenticated(); //将所有请求全部拦截,一律需要验证
})
再次访问我们的网站,就可以看到正常显示的登录界面了
退出登录也是同样的操作,我们只需要稍微进行一下配置就可以实现,我们首先继续完善配置类:
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
//以下是退出登录相关配置
.logout(conf -> {
conf.logoutUrl("/doLogout"); //退出登录地址,跟上面一样可自定义
conf.logoutSuccessUrl("/login"); //退出登录成功后跳转的地址,这里设置为登录界面
conf.permitAll();
.build();
}
关闭csrf校验
不过,可能会有小伙伴觉得,我们现在无论提交什么请求都需要Csrf校验,有些太麻烦了,实际上现在浏览器已经很安全了,没必要防御到这种程度,我们也可以
直接在配置中关闭csrf校验:
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
//以下是csrf相关配置
.csrf(conf -> {
conf.disable(); //此方法可以直接关闭全部的csrf校验,一步到位
conf.ignoringRequestMatchers("/xxx/**"); //此方法可以根据情况忽略某些地址的csrf校验
.build();
}
这样,我们就不需要再往页面中嵌入CSRF相关的输入框了,发送请求时也不会进行校验,至此,我们就完成了简单的自定义登录界面配置。
记住我功能
我们的网站还有一个重要的功能,就是记住我,也就是说我们可以在登陆之后的一段时间内,无需再次输入账号和密码进行登陆,相当于服务端已经记住当前用户,再次访问时就可以免登陆进入,这是一个非常常用的功能。
我们之前使用本地Cookie存储的方式实现了记住我功能,但是这种方式并不安全,同时在代码编写上也比较麻烦,那么能否有一种更加高效的记住我功能实现呢?
SpringSecurity为我们提供了一种优秀的实现,它为每个已经登陆的浏览器分配一个携带Token的Cookie,并且此Cookie默认会被保留14天,只要我们不清理浏览器的Cookie,那么下次携带此Cookie访问服务器将无需登陆,直接继续使用之前登陆的身份,这样显然比我们之前的写法更加简便。并且我们需要进行简单配置,即可开启记住我功能:
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.rememberMe(conf -> {
conf.alwaysRemember(false); //这里不要开启始终记住,我们需要配置为用户自行勾选
conf.rememberMeParameter("remember-me"); //记住我表单字段,默认就是这个,可以不配置
conf.rememberMeCookieName("xxxx"); //记住我设置的Cookie名字,也可以自定义,不过没必要
.build();
}
配置完成后,我们需要修改一下前端页面中的表单,将记住我勾选框也作为表单的一部分进行提交:
<div class="ad-checkbox">
<label>
<input type="checkbox" name="remember-me" class="ad-checkbox">
<span>Remember Me</span>
</label>
</div>
接着我们来尝试勾选记住我选项进行登录:
此时提交的表单中就已经包含记住我字段了,我们会发现,服务端返回给我们了一个记住我专属的Cookie信息:
这个Cookie信息的过期时间并不是仅会话,而是默认保存一段时间,因此,我们关闭浏览器后下次再次访问网站时,就不需要我们再次进行登录操作了,而是直接继续上一次的登录状态。
当然,由于记住我信息是存放在内存中的,我们需要保证服务器一直处于运行状态,如果关闭服务器的话,记住我信息会全部丢失,因此,如果我们希望记住我能够一直持久化保存,我们就需要进一步进行配置。我们需要创建一个基于JDBC的TokenRepository实现:
@Bean
public PersistentTokenRepository tokenRepository(DataSource dataSource){
JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
//在启动时自动在数据库中创建存储记住我信息的表,仅第一次需要,后续不需要
repository.setCreateTableOnStartup(true);
repository.setDataSource(dataSource);
return repository;
}
然后添加此配置:
.rememberMe(conf -> {
conf.rememberMeParameter("remember-me");
conf.tokenRepository(repository); //设置刚刚的记住我持久化存储库
conf.tokenValiditySeconds(3600 * 24 * 7); //设置记住我有效时间为7天
})
这样,我们就成功配置了数据库持久化存储记住我信息,即使我们重启服务器也不会导致数据丢失。当我们登录之后,数据库中会自动记录相关的信息
跨域配置
我们在发起登录请求时,前端得到了一个跨域请求错误,这是因为我们前端的站点和后端站点不一致导致的,浏览器为了用户的安全,防止网页中一些恶意脚本跨站请求数据,会对未经许可的跨域请求发起拦截。那么,我们怎么才能让这个请求变成我们许可的呢?对于跨域问题,是属于我们后端需要处理的问题,跟前端无关,我们需要在响应的时候,在响应头中添加一些跨域属性,来告诉浏览器从哪个站点发来的跨域请求是安全的,这样浏览器就不会拦截了
//关闭cors
.cors(conf -> {
CorsConfiguration cors = new CorsConfiguration();
//添加前端地址
//虽然也可以像这样允许所有 cors.addAllowedOrigin("*");
// 但是这样并不安全,我们应该只许可给我们信任的站点
cors.addAllowedOrigin("");
//允许跨域请求中携带Cookie
cors.setAllowCredentials(true);
//其他的也可以配置,为了方便这里就 * 了
cors.addAllowedHeader("*");
cors.addAllowedMethod("*");
cors.addExposedHeader("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
//对所有地址生效
source.registerCorsConfiguration("/**", cors);
conf.configurationSource(source);
})
授权
SpringSecurity为我们提供了两种授权方式:
两者只是概念上的不同,实际上使用起来效果差不多。这里我们就先演示以角色方式来进行授权。
基于角色授权
现在我们希望创建两个角色,普通用户和管理员,普通用户只能访问index页面,而管理员可以访问任何页面。
首先我们需要对数据库中的角色表进行一些修改,添加一个用户角色字段,并创建一个新的用户,Test用户的角色为user,而Admin用户的角色为admin。
接着我们需要配置SpringSecurity,决定哪些角色可以访问哪些页面:
.authorizeHttpRequests(auth -> {
//静态资源依然全部可以访问
auth.requestMatchers("/static/**").permitAll();
//只有具有以下角色的用户才能访问路径"/"
auth.requestMatchers("/").hasAnyRole("user", "admin");
//其他所有路径必须角色为admin才能访问
auth.anyRequest().hasRole("admin");
})
通过使用角色控制页面的访问,我们就可以让某些用户只能访问部分页面。
基于权限授权
基于权限的授权与角色类似,需要以
hasAnyAuthority
或
hasAuthority
进行判断:
.authorizeHttpRequests(auth -> {
//静态资源依然全部可以访问
auth.requestMatchers("/static/**").permitAll();
//基于权限和基于角色其实差别并不大,使用方式是相同的
auth.anyRequest().hasAnyAuthority("page:index");
})
实际上权限跟角色相比只是粒度更细,由于使用方式差不多,这里不多做阐述。
使用注解权限判断
除了直接配置以外,我们还可以以注解形式直接配置,首先需要在配置类(注意这里是在Mvc的配置类上添加,因为这里只针对Controller进行过滤,所有的Controller是由Mvc配置类进行注册的,如果需要为Service或其他Bean也启用权限判断,则需要在Security的配置类上添加)上开启:
@Configuration
@EnableMethodSecurity //开启方法安全校验
public class SecurityConfiguration {
}
现在我们就可以在我们想要进行权限校验的方法上添加注解了:
@Controller
public class HelloController {
@PreAuthorize("hasRole('user')") //直接使用hasRole方法判断是否包含某个角色
@GetMapping("/")
public String index(){
return "index";
}
通过添加@PreAuthorize注解,在执行之前判断判断权限,如果没有对应的权限或是对应的角色,将无法访问页面。
这里其实是使用的是SpEL表达式,我们可以直接在这里使用权限判断相关的方法。所有可以进行权限判断的方法在SecurityExpressionRoot类中有定义,各位小伙伴可以自行前往查看。
同样的
还有@PostAuthorize注解,但是它是在方法执行之后再进行拦截:
@PostAuthorize("hasRole('user')")
@RequestMapping("/")
public String index(){
System.out.println("执行了");
return "index";
}
除了Controller以外,只要是由Spring管理的Bean都可以使用注解形式来控制权限,我们可以在任意方法上添加这个注解,只要不具备表达式中指定的访问权限,那么就无法执行方法并且会返回403页面。
@Service
public class UserService {
@PreAuthorize("hasAnyRole('user')")
public void test(){
System.out.println("成功执行");
}
与具有相同功能的还有
@Secured
但是它不支持SpEL表达式的权限表示形式,并且需要添加"ROLE_"前缀,这里就不做演示了。
我们还可以使用
@PreFilter
和
@PostFilter
对集合类型的参数或返回值进行过滤。
比如:
@PreFilter("filterObject.equals('lbwnb')") //filterObject代表集合中每个元素,只要满足条件的元素才会留下
public void test(List<String> list){
System.out.println("成功执行"+list);
}
@RequestMapping("/")
public String index(){
List<String> list = new LinkedList<>();
list.add("lbwnb");
list.add("yyds");
service.test(list);
return "index";
}
与
@PreFilter
类似的
@PostFilter
这里就不做演示了,它用于处理返回值,使用方法是一样的。
当有多个集合时,需要使用
filterTarget
进行指定:
@PreFilter(value = "filterObject.equals('lbwnb')", filterTarget = "list2")
public void test(List<String> list, List<String> list2){
System.out.println("成功执行"+list);
}
基于Session的分离 (有状态)
基于Cookie的前后端分离是最简单的一种。在之前,我们都是使用SpringSecurity提供的默认登录流程完成验证。 我们发现,实际上SpringSecurity在登录之后,会利用Session机制记录用户的登录状态,这就要求我们每次请求的时候都需要携带Cookie才可以,因为Cookie中存储了用于识别的JSESSIONID数据。因此,要实现前后端分离,我们只需要稍微修改一下就可以实现了。这对于小型的单端应用程序非常友好。
在之前,我们的登录操作以及登录之后的页面跳转都是由SpringSecurity来完成,但是现在前后端分离之后,整个流程发生了变化,现在前端仅仅是调用登录接口进行一次校验即可,而后端只需要返回本次校验的结果,由前端来判断是否校验成功并跳转页面:
因此,现在我们只需要让登录模块响应一个JSON数据告诉前端登录成功与否即可,当然,前端在发起请求的时候依然需要携带Cookie信 息,否则后端不认识是谁。
@Configuration
//开启WebSecurity相关功能
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChin (HttpSecurity http) throws Exception {
//此方法可以直接关闭全部的csrf校验,一步到位
return http
//以下是验证请求拦截和放行配置
.authorizeHttpRequests(auth ->{
//将所有请求全部拦截,一律需要验证
auth.anyRequest().authenticated();
//以下是csrf相关配置
.csrf(AbstractHttpConfigurer::disable)
//关闭cors跨域
.cors(conf -> {
CorsConfiguration cors = new CorsConfiguration();
//添加前端地址
//虽然也可以像这样允许所有 cors.addAllowedOrigin("*");
// 但是这样并不安全,我们应该只许可给我们信任的站点
cors.addAllowedOrigin("");
//允许跨域请求中携带Cookie
cors.setAllowCredentials(true);
//其他的也可以配置,为了方便这里就 * 了
cors.addAllowedHeader("*");
cors.addAllowedMethod("*");
cors.addExposedHeader("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
//对所有地址生效
source.registerCorsConfiguration("/**", cors);
conf.configurationSource(source);
.formLogin(conf -> {
conf.loginProcessingUrl("/auth/login"); //登录表单提交的地址
conf.successHandler(this::handleProcess);
conf.failureHandler(this::handleProcess);
conf.permitAll(); //将登录相关的地址放行,否则未登录的用户连登录界面都进不去
//记住我
.rememberMe(conf -> {
conf.alwaysRemember(false); //这里不要开启始终记住,我们需要配置为用户自行勾选
//异常处理
.exceptionHandling(conf -> {
conf.accessDeniedHandler(this:: handleProcess);
conf.authenticationEntryPoint(this:: handleProcess);
.build();
//自定义处理器
private void handleProcess(HttpServletRequest request, HttpServletResponse response, Object exceptionOrAuthentication) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
//未登录状态
if (exceptionOrAuthentication instanceof AuthenticationException exception){
writer.write(RestBean.error(HttpStatus.FORBIDDEN,exception.getMessage()).asJsonString());
//异常状态
else if (exceptionOrAuthentication instanceof Exception exception){
writer.write(RestBean.error(HttpStatus.UNAUTHORIZED,exception.getMessage()).asJsonString());
//登录成功
else if (exceptionOrAuthentication instanceof Authentication authentication ){
writer.write(RestBean.success(authentication.getName()).asJsonString());
}
基于Token的分离(无状态)
基于Token的前后端分离主打无状态,无状态服务是指在处理每个请求时,服务本身不会维持任何与请求相关的状态信息。每个请求被为独立的、自包含的操作,服务只关注处理请求本身,而不关心前后请求之间的状态变化。也就是说,用户在发起请求时,服务器不会i录其信息,而是通过用户携带的Token信息来判断是哪一个用户:
无状态服务的优点包括
-
服务端无需存储会话信息:传统的会话管理方式需要服务端存储用户的会话信息,包括用户的身份认证信息和会话状态。而使用Token,服务端无需存储任何会话信息,所有的认证信息都包含在Token中,使得服务端变得无状态,减轻了服务器的负担,同时也方便了服务的水平扩展。
-
减少网络延迟: 传统的会话管理方式需要在每次请求中都携带会话标识,即使是无状态的RESTul API也需要携带身份认证信息。而使用Token,身份认证信息已经包含在Token中,只需要在请求的Authorization头部携带Token即可,减少了每次请求的数据量,减少了网络延迟
-
客户端无需存储会话信息: 传统的会话管理方式中,客户端需要存储会话标识,以便在每次请求中携带。而使用Token,客户端只需要保存Token即可,方便了客户端的存储和管理。
-
跨域支持: Token可以在各个不同的域名之间进行传递和使用,因为Token是通过签名来验证和保护数据完整性的,可以防止未经授权的修改。
认识JWT令牌
在认识Token前后端分离之前,我们需要先学习最常见的JWT令牌,官网:
https://jwt.io
ISON Web Token令牌JWT)是一个开放标准(RFC 7519),它定义了一种紧凑和自成一体的方式,用于在各方之间作为JSON对象安全地传输信息。这些信息可以被验证和信任,因为它是数字签名的。JWT可以使用密钥《使用HMAC算法)或使用RSA或ECDSA进行公钥/私钥对进行签名。 JWT令牌的格式如下:
个JWT令牌由3部分组成:标头(Header)、有效载荷(Paoad 和签名(Sianatur)。在传输的时候,会将JWT的3部分分别进行Base64编码 后用.进行连接形成最终需要传输的字符串。
-
标头:包含一些元数据信息,比如JWT签名所使用的加密算法,还有类型,这里统一都是JWT。
-
有效载荷:包括用户名称、令牌发布时间、过期时间、JWT ID等,当然我们也可以自定义添加字段,我们的用户信息一般都在这里存放
-
签名:首先需要指定一个密钥,该密钥仅仅保存在服务器中,保证不能让其他用户知道。然后使用Header中指定的算法对Header和Payload进行base64加密之后的结果通过密钥计算哈希值,然后就得出一个签名哈希。这个会用于之后验证内容是否被篡改。
因此,JWT令牌实际上是一种经过加密的JSON数据,其中包含了用户名字、用户ID等信息,我们可以直接解密JWT令牌得到用户的信 息,我们可以写一个小测试来看看,导入JWT支持库依赖:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.3.0</version>
</dependency>
要生成一个JWT令牌非常简单:
public static void main(String[] args) {
String jwtKey = "abcdefg123";
Algorithm algorithm = Algorithm.HMAC256(jwtKey);
String jwtToken = JWT.create()
//自定义数据
.withClaim("id", 1)
.withClaim("name", "小明")
//令牌生效时间
.withExpiresAt(new Date(2024, Calendar.FEBRUARY,1))
//签发时间
.withExpiresAt(new Date())
//使用加密算法加密 签名
.sign(algorithm);
System.out.println(jwtToken);
}
我们可以使用Base64将其还原为原本的样子
可以看到确实是经过Base64加密的JSON格式数据,包括我们的自定义数据也在其中,而我们可以直接使用JWT令牌来作为我们权限校验 的核心,我们可以像这样设计我们的系统:
所以,我们只需要在后续请求中携带这个Token即可(可以放在Cookie中,也可以放在请求头中)这样服务器就可以直接从Token中解密 读取到我们用户的相关信息以及判断用户是否登录过期了。
不过这个时候会有小伙伴疑问,既然现在用户信息都在JWT中,那要是用户随便修改里面的内容,岂不是可以以任意身份访问服务器了?这会不会存在安全隐患?对于这个问题,前面我们已经说的很清楚了,JWT实际上最后会有一个加密的签名,这个是根据秘钥+JWT本体内容计算得到的,用户在没有持有秘钥的情况下,是不可能计算得到正确的签名的,所以说服务器会在收到JWT时对签名进行重新计算,比较是否一致,来验证JWT是否被用户恶意修改,如果被修改肯定也是不能通过的
SpringSecurity实现JWT校验
JWT可以存放在Cookie或是请求头中,不过不管哪种方式,我们都可以通过equest获取到对应的JWT令牌,这里我们使用 比较常见的请求头携带JWT的方案,客户端发起的请求中会携带这样的的特殊请求头:
这里的Authorization请求头就是携带JWT的专用属性,值的格式为"Bearer Token",前面的Bearer代表身份验证方式,默认情况下有两种:
Basic 和 Bearer 是两种不同的身份验证方式 Basic 是一种基本的身份验证方式,它将用户名和密码进行base64编码后,放在 Authorization 请求头中,用于向服务器验证用户身份。这种方式不够安全,因为它将密码以明文的形式传输,容易受到中间人攻击。 Bearer 是一种更安全的身份验证方式,它基于令牌(Token)来验证用户身份。Bearer 令牌是由身份验证服务器颁发给客户端的,客户端在每个请求中将令牌放在 Authorization 请求头的 Bearer 字段中。服务器会验证令牌的有效性和权限,以确定用户的身份。Bearer 令牌通常使用 JSON Web Token (WT) 的形式进行传递和验证
首先我们先把用于处理JWT令牌的工具类完成一下
public class JwtUtils {
// 令牌自定义标识
@Value("${token.header}")
private String header;
// 令牌秘钥
@Value("${token.secret}")
private static String secret;
// 令牌有效期(默认30分钟)
@Value("${token.expireTime}")
private int expireTime;
//根据用户信息创建Jwt令牌
public String createJwt(UserDetails user){
Algorithm algorithm = Algorithm.HMAC256(secret);
Calendar calendar = Calendar.getInstance();
Date time = calendar.getTime();
calendar.add(Calendar.SECOND, 3600 * expireTime);
return JWT.create()
//自定义数据
.withClaim("id", 1)
.withClaim("name", user.getUsername())
//令牌生效时间
.withExpiresAt(calendar.getTime())
//签发时间
.withExpiresAt(time)
//使用加密算法加密 签名
.sign(algorithm);
//根据Jwt验证并解析用户信息
public UserDetails resolveJwt(String token){
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier jwtVerifier = JWT.require(algorithm).build();
//对Jwt令牌进行验证 看看是否被修改
DecodedJWT verify = jwtVerifier.verify(token);
//获取令牌中的内容
Map<String, Claim> claims = verify.getClaims();
try {
//如果令牌过期则返回null
if (new Date().after(claims.get("exp").asDate())){
return null;
}else {
//重新组装为user对象
return User
.withUsername(claims.get("name").asString())
.password(claims.get("password").asString())
.build();
}catch (JWTVerificationException e){
return null;
}
接着我们需要自行实现一个JwtAuthenticationFilter加入到SpringSecurity默认提供的过滤器链中,用于处理请求头中携带的JWT令牌,并配置登录状态:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
//继承OncePerRequestFilter表示每次请求过滤一次,用于快速编写JWT校验规则
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//首先从Header中取出JWT
String authorization = request.getHeader("Authorization");
//判断是否包含JWT且格式正确
if (authorization != null && authorization.startsWith("Bearer ")) {
String token = authorization.substring(7);
//开始解析成UserDetails对象,如果得到的是null说明解析失败,JWT有问题
UserDetails user = JwtUtils.resolveJwt(token);
if (user != null) {
//验证没有问题,那么就可以开始创建Authentication了,这里我们跟默认情况保持一致
//使用UsernamePasswordAuthenticationToken作为实体,填写相关用户信息进去
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null,user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//然后直接把配置好的Authentication塞给SecurityContext表示已经完成验证
SecurityContextHolder.getContext().setAuthentication(authentication);
//最后放行,继续下一个过滤器 如果没有验证失败上面是不会给SecurityContext设置Authentication的,后面直接就被拦截掉了 而且有可能用户发起的是用户名密码登录请求,这种情况也要放行的
filterChain.doFilter(request,response);
}
最后我们来配置一下SecurityConfiguration配置类,其实配置方法跟之前还是差不多,用户依然可以使用表单进行登录,就是有两个新增的部分需要我们注意一下:
@Configuration
//开启WebSecurity相关功能
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChin (HttpSecurity http) throws Exception {
//此方法可以直接关闭全部的csrf校验,一步到位
return http
//以下是验证请求拦截和放行配置
.authorizeHttpRequests(auth ->{
//将所有请求全部拦截,一律需要验证
auth.anyRequest().authenticated();
//以下是csrf相关配置
.csrf(AbstractHttpConfigurer::disable)
//关闭cors跨域
.cors(conf -> {
CorsConfiguration cors = new CorsConfiguration();
//添加前端地址
//虽然也可以像这样允许所有 cors.addAllowedOrigin("*");
// 但是这样并不安全,我们应该只许可给我们信任的站点
cors.addAllowedOrigin("");
//允许跨域请求中携带Cookie
cors.setAllowCredentials(false);
//其他的也可以配置,为了方便这里就 * 了
cors.addAllowedHeader("*");
cors.addAllowedMethod("*");
cors.addExposedHeader("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
//对所有地址生效
source.registerCorsConfiguration("/**", cors);
conf.configurationSource(source);
.formLogin(conf -> {
conf.loginProcessingUrl("/auth/login"); //登录表单提交的地址
conf.successHandler(this::handleProcess);
conf.failureHandler(this::handleProcess);
conf.permitAll(); //将登录相关的地址放行,否则未登录的用户连登录界面都进不去
//记住我
.rememberMe(conf -> {
conf.alwaysRemember(false); //这里不要开启始终记住,我们需要配置为用户自行勾选
//异常处理
.exceptionHandling(conf -> {
conf.accessDeniedHandler(this:: handleProcess);
conf.authenticationEntryPoint(this:: handleProcess);
//将session策略改为无状态
.sessionManagement(conf ->{
conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// token过滤器 验证token有效性
.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.build();
//自定义处理器
private void handleProcess(HttpServletRequest request, HttpServletResponse response, Object exceptionOrAuthentication) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
//未登录状态
if (exceptionOrAuthentication instanceof AuthenticationException exception){
writer.write(RestBean.error(HttpStatus.FORBIDDEN,exception.getMessage()).asJsonString());
//异常状态
else if (exceptionOrAuthentication instanceof Exception exception){
writer.write(RestBean.error(HttpStatus.UNAUTHORIZED,exception.getMessage()).asJsonString());
//登录成功 返回Token
else if (exceptionOrAuthentication instanceof Authentication authentication ){
writer.write(RestBean.success(JwtUtils.createJwt((UserDetails) authentication.getPrincipal())).asJsonString());
退出登录JWT处理
虽然我们使用JWT已经很方便了,但是有一个很严重的问题就是,我们没办法像Session那样去踢用户下线,什么意思呢? 我们之前可以使用退出登录接口直接退出,用户Session中的验证信息也会被销毁,但是现在是无状态的,用户来管理Token令牌,服务端只认Token是否合法,那这个时候该怎么让用户正确退出登录呢? 首先我们从最简单的方案开始,我们可以直接让客户端删除自己的JMT令牌,这样不就相当于退出登录了吗,这样甚至不需要请求服务器,直接就退了。
这样虽然是最简单粗暴的,但是存在一个问题,用户可以自行保存这个Token拿来使用。虽然客户端已经删除掉了,但是这个令牌仍然是可用的,如果用户私自保存过,那么依然可以正常使用这个令牌,这显然是有问题的。
目前有两种比较好的方案
-
黑名单方案: 所有黑名单中的JWT将不可使用。
-
白名单方案: 不在白名单中的JWT将不可使用
这里我们以黑名单机制为例,让用户退出登录之后,无法再次使用之前的JMT进行操作,首先我们需要给JWT额外添加一个用于判断的唯 一标识符,这里就用UUID好了:
//根据用户信息创建Jwt令牌
public static String createJwt(UserDetails user){
Algorithm algorithm = Algorithm.HMAC256(secret);
Calendar calendar = Calendar.getInstance();
Date time = calendar.getTime();
calendar.add(Calendar.SECOND, 3600 * expireTime);
return JWT.create()
//设置唯一标识用于黑名单
.withJWTId(UUID.randomUUID().toString())
//自定义数据
.withClaim("id", 1)
.withClaim("name", user.getUsername())
//令牌生效时间
.withExpiresAt(calendar.getTime())
//签发时间
.withExpiresAt(time)
//使用加密算法加密 签名
.sign(algorithm);
}
这样我们发出去的所有令牌都会携带一个UUID作为唯一凭据,然后就可以使用Redis来存储黑名单了。
//加入黑名单方法
public static boolean invalidate(String token) {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier jwtVerifier = JWT.require(algorithm).build();
try {
DecodedJWT verify = jwtVerifier.verify(token);
Map<String, Claim> claims = verify.getClaims();
//取出UUID丢进黑名单中 设置过期时间
return blackList.add(verify.getId());
} catch (JWTVerificationException e) {
return false;
//根据Jwt验证并解析用户信息
public static UserDetails resolveJwt(String token) {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier jwtVerifier = JWT.require(algorithm).build();
//对Jwt令牌进行验证 看看是否被修改
DecodedJWT verify = jwtVerifier.verify(token);
//判断是否存在于黑名单中,如果存在,则返回null表示失效
if(blackList.contains(verify.getId())){
return null;
//获取令牌中的内容
Map<String, Claim> claims = verify.getClaims();
try {
//如果令牌过期则返回null
if (new Date().after(claims.get("exp").asDate())) {
return null;
} else {
//重新组装为user对象
return User
.withUsername(claims.get("name").asString())
.password(claims.get("password").asString())
.build();
} catch (JWTVerificationException e) {
return null;
}
接着我们来SecurityConfiguration中配置一下退出登录操作:
//退出登录
.logout(conf -> {
conf.logoutUrl("/auth/logout");
conf.logoutSuccessHandler(this:: onLogoutSuccess);
//退出登录
private void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
String authorization = request.getHeader("Authorization");
if (authorization != null && authorization.startsWith("Bearer ")) {
String token = authorization.substring(7);
//将Token加入黑名单
if (JwtUtils.invalidate(token)) {
//只有成功加入黑名单才会退出成功
writer.write(RestBean.success("退出登录成功").asJsonString());
return;
writer.write(RestBean.error(HttpStatus.ERROR, "退出登录失败").asJsonString());
使用Redis服务器来存放黑名弹列表,这样就可以实现多个服务器共享,并且根据JMT的过期时间合理设定黑名单中UUID的过期时间,自动清理。
自动续签JWT令牌
在有些时候,我们可能希望用户能够一直使用我们的网站,而不是JMT令牌到期之后就需要重新登录,这种情况下前端就可以配置JWT自动续签,在发起请求时如果令牌即将到期,那么就向后端发起续签请求得到一个新的JWT令牌。这里我们写一个接口专门用于令牌刷新:
这里我们写一个接口专门用于令牌刷新
这样,前端在发现令牌可用时间不足时,就会先发起一个请求自动完成续期,得到一个新的Token:
我们可能还需要配置一下这种方案的请求频率,不然用户疯狂请求刷新Token就不太好了,我们同样可以借助Redis进行限流等操作,防止 频繁请求。
JWT校验方案的优点:
-
无状态:JWT是无状态的,服务器不需要在后端维护用户的会话信息,可以在分布式系统中进行水平扩展,减轻服务器的负担。
-
基于Token: JWT使用token作为身份认证信息,该token可以存储用户相关的信息和权限。这样可以减少与数据库的频繁交互,提高性能。
-
安全性: JWT使用数字签名或加密算法保证token的完整性和安全性。每次请都会验证token的合法性,防止伪造或篡改。
-
跨域支持:JWT可以在不同域之间进行数据传输,适合前后端分离的架构。
JWT校验方案的缺点:
-
无法做到即时失效:JWT中的token通常具有较长的有效期,一旦签发,就无法立即失效。如果需要即时失效,需要在服务端进行额外的处理。
-
信息无法撤销: JWT中的token一旦签发,除非到期或者客户端清除,无法撤销。无法中途取消和修改权限。
-
Token增大的问题: JWT中包含了用户信息和权限等,token的体积较大,每次请求都需要携带,增加了网络传输的开销
-
动态权限管理问题: JWT无法处理动态权限管理,一旦签发的token权限发生变化,仍然有效,需要其他手段进行处理
内部机制探究
授权校验流程
最后我们再来聊一下SpringSecurity的实现原理,它本质上是依靠N个Filter实现的,也就是一个完整的过滤链(注意这里是过滤器,不是拦截器)
我们就从AbstractSecurityWebApplicationInitializer开始下手,我们来看看它配置了什么:
//此方法会在启动时被调用
public final void onStartup(ServletContext servletContext) {
this.beforeSpringSecurityFilterChain(servletContext);
if (this.configurationClasses != null) {
AnnotationConfigWebApplicationContext rootAppContext = new AnnotationConfigWebApplicationContext();
rootAppContext.register(this.configurationClasses);
servletContext.addListener(new ContextLoaderListener(rootAppContext));
if (this.enableHttpSessionEventPublisher()) {
servletContext.addListener("org.springframework.security.web.session.HttpSessionEventPublisher");
servletContext.setSessionTrackingModes(this.getSessionTrackingModes());
//重点在这里,这里插入了关键的FilterChain
this.insertSpringSecurityFilterChain(servletContext);
this.afterSpringSecurityFilterChain(servletContext);
}
private void insertSpringSecurityFilterChain(ServletContext servletContext) {
String filterName = "springSecurityFilterChain";
//创建了一个DelegatingFilterProxy对象,它本质上也是一个Filter,但是是多个Filter的集合
DelegatingFilterProxy springSecurityFilterChain = new DelegatingFilterProxy(filterName);
String contextAttribute = this.getWebApplicationContextAttribute();
if (contextAttribute != null) {
springSecurityFilterChain.setContextAttribute(contextAttribute);
//通过ServletContext注册DelegatingFilterProxy这个Filter
this.registerFilter(servletContext, true, filterName, springSecurityFilterChain);
}
我们接着来看看,
DelegatingFilterProxy
在做什么:
//这个是初始化方法,它由GenericFilterBean(父类)定义,在afterPropertiesSet方法中被调用
protected void initFilterBean() throws ServletException {
synchronized(this.delegateMonitor) {
if (this.delegate == null) {
if (this.targetBeanName == null) {
this.targetBeanName = this.getFilterName();
WebApplicationContext wac = this.findWebApplicationContext();
if (wac != null) {
//耐心点,套娃很正常
this.delegate = this.initDelegate(wac);
}
protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
String targetBeanName = this.getTargetBeanName();
Assert.state(targetBeanName != null, "No target bean name set");
//这里通过WebApplicationContext获取了一个Bean
Filter delegate = (Filter)wac.getBean(targetBeanName, Filter.class);
if (this.isTargetFilterLifecycle()) {
delegate.init(this.getFilterConfig());
//返回Filter
return delegate;
}
这里我们需要添加一个断点来查看到底获取到了什么Bean。
通过断点调试,我们发现这里放回的对象是一个FilterChainProxy类型的,并且调用了它的初始化方法。
我们倒回去看,当Filter返回之后,DelegatingFilterProxy的一个成员变量delegate被赋值为得到的Filter,也就是FilterChainProxy对象,接着我们来看看,DelegatingFilterProxy是如何执行doFilter方法的。
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Filter delegateToUse = this.delegate;
if (delegateToUse == null) {
//非正常情况,这里省略...
//这里才是真正的调用,别忘了delegateToUse就是初始化的FilterChainProxy对象
this.invokeDelegate(delegateToUse, request, response, filterChain);
}
protected void invokeDelegate(Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//最后实际上调用的是FilterChainProxy的doFilter方法
delegate.doFilter(request, response, filterChain);
}
所以我们接着来看,
FilterChainProxy
的doFilter方法又在干什么:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
if (!clearContext) {
//真正的过滤在这里执行
this.doFilterInternal(request, response, chain);
} else {
//...
}
private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest)request);
HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse)response);
//这里获取了一个Filter列表,实际上SpringSecurity就是由N个过滤器实现的,这里获取的都是SpringSecurity提供的过滤器
//但是请注意,经过我们之前的分析,实际上真正注册的Filter只有DelegatingFilterProxy
//而这里的Filter列表中的所有Filter并没有被注册,而是在这里进行内部调用
List<Filter> filters = this.getFilters((HttpServletRequest)firewallRequest);
//只要Filter列表不是空,就依次执行内置的Filter
if (filters != null && filters.size() != 0) {
if (logger.isDebugEnabled()) {
logger.debug(LogMessage.of(() -> {
return "Securing " + requestLine(firewallRequest);
//这里创建一个虚拟的过滤链,过滤流程是由SpringSecurity自己实现的
FilterChainProxy.VirtualFilterChain virtualFilterChain = new FilterChainProxy.VirtualFilterChain(firewallRequest, chain, filters);
//调用虚拟过滤链的doFilter
virtualFilterChain.doFilter(firewallRequest, firewallResponse);
} else {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.of(() -> {
return "No security for " + requestLine(firewallRequest);
firewallRequest.reset();
chain.doFilter(firewallRequest, firewallResponse);
}
我们来看一下虚拟过滤链的doFilter是怎么处理的:
//看似没有任何循环,实际上就是一个循环,是一个递归调用
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
//判断是否已经通过全部的内置过滤器,定位是否等于当前大小
if (this.currentPosition == this.size) {
if (FilterChainProxy.logger.isDebugEnabled()) {
FilterChainProxy.logger.debug(LogMessage.of(() -> {
return "Secured " + FilterChainProxy.requestLine(this.firewalledRequest);
this.firewalledRequest.reset();
//所有的内置过滤器已经完成,按照正常流程走DelegatingFilterProxy的下一个Filter
//也就是说这里之后就与DelegatingFilterProxy没有任何关系了,该走其他过滤器就走其他地方配置的过滤器,SpringSecurity的过滤操作已经结束
this.originalChain.doFilter(request, response);
} else {
//定位自增
++this.currentPosition;
//获取当前定位的Filter
Filter nextFilter = (Filter)this.additionalFilters.get(this.currentPosition - 1);
if (FilterChainProxy.logger.isTraceEnabled()) {
FilterChainProxy.logger.trace(LogMessage.format("Invoking %s (%d/%d)", nextFilter.getClass().getSimpleName(), this.currentPosition, this.size));
//执行内部过滤器的doFilter方法,传入当前对象本身作为Filter,执行如果成功,那么一定会再次调用当前对象的doFilter方法
//可能最不理解的就是这里,执行的难道不是内部其他Filter的doFilter方法吗,怎么会让当前对象的doFilter方法递归调用呢?
//没关系,下面我们接着了解了其中一个内部过滤器就明白了
nextFilter.doFilter(request, response, this);
}
因此,我们差不多已经了解了整个SpringSecurity的实现机制了,那么我们来随便看一个内部的过滤器在做什么。
比如用于处理登陆的过滤器UsernamePasswordAuthenticationFilter,它继承自AbstractAuthenticationProcessingFilter,我们来看看它是怎么进行过滤的:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//如果不是登陆请求,那么根本不会理这个请求
if (!this.requiresAuthentication(request, response)) {
//直接调用传入的FilterChain的doFilter方法
//而这里传入的正好是VirtualFilterChain对象
//这下知道为什么上面说是递归了吧
chain.doFilter(request, response);
} else {
//如果是登陆请求,那么会执行登陆请求的相关逻辑,注意执行过程中出现任何问题都会抛出异常
//比如用户名和密码错误,我们之前也已经测试过了,会得到一个BadCredentialsException
try {
//进行认证
Authentication authenticationResult = this.attemptAuthentication(request, response);
if (authenticationResult == null) {
return;
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
//如果一路绿灯,没有报错,那么验证成功,执行successfulAuthentication
this.successfulAuthentication(request, response, chain, authenticationResult);
} catch (InternalAuthenticationServiceException var5) {
this.logger.error("An internal error occurred while trying to authenticate the user.", var5);
//验证失败,会执行unsuccessfulAuthentication
this.unsuccessfulAuthentication(request, response, var5);
} catch (AuthenticationException var6) {
this.unsuccessfulAuthentication(request, response, var6);
}
那么我们来看看successfulAuthentication和unsuccessfulAuthentication分别做了什么:
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//向SecurityContextHolder添加认证信息,我们可以通过SecurityContextHolder对象获取当前登陆的用户
SecurityContextHolder.getContext().setAuthentication(authResult);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
//记住我实现
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
//调用默认的或是我们自己定义的AuthenticationSuccessHandler的onAuthenticationSuccess方法
//这个根据我们配置文件决定
//到这里其实页面就已经直接跳转了
this.successHandler.onAuthenticationSuccess(request, response, authResult);
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
//登陆失败会直接清理掉SecurityContextHolder中的认证信息
SecurityContextHolder.clearContext();
this.logger.trace("Failed to process authentication request", failed);
this.logger.trace("Cleared SecurityContextHolder");
this.logger.trace("Handling authentication failure");
//登陆失败的记住我处理
this.rememberMeServices.loginFail(request, response);
//同上,调用默认或是我们自己定义的AuthenticationFailureHandler
this.failureHandler.onAuthenticationFailure(request, response, failed);
}
了解了整个用户验证实现流程,其实其它的过滤器是如何实现的也就很容易联想到了,SpringSecurity的过滤器从某种意义上来说,更像是一个处理业务的Servlet,它做的事情不像是拦截,更像是完成自己对应的职责,只不过是使用了过滤器机制进行实现罢了,从而将所有的验证提前到进入Controller之前。
最后附上完整的过滤器清单,这里列出14个常见的内部过滤器:
过滤器名称
|
职责
|
DisableEncodeUrlFilter
|
禁止 HttpServletResponse 对 URL 进行编码,以防止在 URL 中包含 Session ID,此类 URL 不被视为 URL,因为会话 ID 可能会在 HTTP 访问日志等内容中泄露。
|
WebAsyncManagerIntegrationFilter
|
实现了对SecurityContext与WebAsyncManager的集成,使 Controller 中能够线程安全地获取到用户上下文认证信息。
|
SecurityContextHolderFilter
|
通过HttpSessionSecurityContextRepository接口从Session中读取SecurityContext或是直接创建新的,然后存入到SecurityContextHolder中,最后请求结束时会进行清理。
|
HeaderWriterFilter
|
给HTTP响应添加一些Header属性,如:X-Frame-Options、X-XSS-Protection、X-Content-Type-Options等。
|
CsrfFilter
|
针对Csrf相关校验。
|
LogoutFilter
|
对退出登录的请求进行处理,执行登出操作。
|
UsernamePasswordAuthenticationFilter
|
对登录的请求进行处理,执行登录操作。
|
ConcurrentSessionFilter
|
检查SessionRegistry保存的Session信息是否过期。
|
RequestCacheAwareFilter
|
缓存Request请求,可以用于恢复因登录而打断的请求。
|
SecurityContextHolderAwareRequestFilter
|
对ServletRequest进行进一步包装,让Request具有更加丰富的内容。
|
RememberMeAuthenticationFilter
|
针对于记住我Cookie进行校验。
|
AnonymousAuthenticationFilter
|
未验证成功的情况下进行匿名登录操作。
|
SessionManagementFilter
|
Session管理相关。
|
ExceptionTranslationFilter
|
异常转换处理,比如最常见的AccessDenied之类的。
|
各位小伙伴感兴趣的话可以自行了解。
安全上下文
用户登录之后,怎么获取当前已经登录用户的信息呢?通过使用SecurityContextHolder就可以很方便地得到SecurityContext对象了,我们可以直接使用SecurityContext对象来获取当前的认证信息:
@RequestMapping("/index")
public String index(){
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
User user = (User) authentication.getPrincipal();
System.out.println(user.getUsername());
System.out.println(user.getAuthorities());
return "index";
}
通过SecurityContext我们就可以快速获取当前用户的名称和授权信息等。
除了这种方式以外,我们还可以直接从Session中获取:
@RequestMapping("/index")
public String index(@SessionAttribute("SPRING_SECURITY_CONTEXT") SecurityContext context){
Authentication authentication = context.getAuthentication();
User user = (User) authentication.getPrincipal();
System.out.println(user.getUsername());
System.out.println(user.getAuthorities());
return "index";
}
注意SecurityContextHolder是有一定的存储策略的,SecurityContextHolder中的SecurityContext对象会在一开始请求到来时被设定,至于存储方式其实是由存储策略决定的,如果我们这样编写,那么在默认情况下是无法获取到认证信息的:
@RequestMapping("/index")
public String index(){
new Thread(() -> { //创建一个子线程去获取
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
User user = (User) authentication.getPrincipal(); //NPE
System.out.println(user.getUsername());
System.out.println(user.getAuthorities());
}).start();
return "index";
}
这是因为SecurityContextHolder的存储策略默认是
MODE_THREADLOCAL
,它是基于ThreadLocal实现的,
getContext()
方法本质上调用的是对应的存储策略实现的方法:
public static SecurityContext getContext() {
return strategy.getContext();
}
SecurityContextHolderStrategy有三个实现类:
-
GlobalSecurityContextHolderStrategy:全局模式,不常用
-
ThreadLocalSecurityContextHolderStrategy:基于ThreadLocal实现,线程内可见
-
InheritableThreadLocalSecurityContextHolderStrategy:基于InheritableThreadLocal实现,线程和子线程可见
因此,如果上述情况需要在子线程中获取,那么需要修改SecurityContextHolder的存储策略,在初始化的时候设置:
@PostConstruct
public void init(){
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
这样在子线程中也可以获取认证信息了。
因为用户的验证信息是基于SecurityContext进行判断的,我们可以直接修改SecurityContext的内容,来手动为用户进行登陆:
@RequestMapping("/auth")
@ResponseBody
public String auth(){
SecurityContext context = SecurityContextHolder.getContext(); //获取SecurityContext对象(当前会话肯定是没有登陆的)
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("Test", null,
AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_user")); //手动创建一个UsernamePasswordAuthenticationToken对象,也就是用户的认证信息,角色需要添加ROLE_前缀,权限直接写
context.setAuthentication(token); //手动为SecurityContext设定认证信息
return "Login success!";
}
在未登陆的情况下,访问此地址将直接进行手动登陆,再次访问/index页面,可以直接访问,说明手动设置认证信息成功。
疑惑:
SecurityContext这玩意不是默认线程独占吗,那每次请求都是一个新的线程,按理说上一次的SecurityContext对象应该没了才对啊,为什么再次请求依然能够继续使用上一次SecurityContext中的认证信息呢?
SecurityContext的生命周期:请求到来时从Session中取出,放入SecurityContextHolder中,请求结束时从SecurityContextHolder取出,并放到Session中,实际上就是依靠Session来存储的,一旦会话过期验证信息也跟着消失。
安全上下文持久化过滤器
SecurityContextHolderFilter也是内置的Filter,它就是专门用于处理SecurityContext的,这里先说一下大致流程,以便我们后续更加方便地理解:
当过滤器链执行到SecurityContextHolderFilter时,它会从HttpSession中把SecurityContext对象取出来(是存在Session中的,跟随会话的消失而消失),然后放入SecurityContextHolder对象中。请求结束后,再把SecurityContext存入HttpSession中,并清除SecurityContextHolder内的SecurityContext对象。
我们还是直接进入到源码中:
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
//开始套娃
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
//防止重复的安全请求,不需要关心,一般是直接走下面
if (request.getAttribute(FILTER_APPLIED) != null) {
chain.doFilter(request, response);
return;
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
//这里通过SecurityContextRepository的loadDeferredContext获取到SecurityContext对象的Supplier
Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
}
我们接着来看loadDeferredContext的实现细节,其中SecurityContextRepository的实现类是DelegatingSecurityContextRepository类,这个类中维护了多个SecurityContextRepository实现类,而其本身并没有实现loadDeferredContext方法,而是靠内部维护的其他SecurityContextRepository实现类来完成:
@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
//DeferredSecurityContext是一个支持延时生成的SecurityContext,本质是一个SecurityContext的Supplier
DeferredSecurityContext deferredSecurityContext = null;
//遍历内部维护的其他SecurityContextRepository实现,一般包含以下两个:
//1. HttpSessionSecurityContextRepository
//2. RequestAttributeSecurityContextRepository
for (SecurityContextRepository delegate : this.delegates) {
//这个if-else语句其实为了添加多个SecurityContextRepository提供的SecurityContext并将其组成一个链状结构的DelegatingDeferredSecurityContext(至于为什么,我们接着往下看)
if (deferredSecurityContext == null) {
deferredSecurityContext = delegate.loadDeferredContext(request);
else {
DeferredSecurityContext next = delegate.loadDeferredContext(request);
deferredSecurityContext = new DelegatingDeferredSecurityContext(deferredSecurityContext, next);
return deferredSecurityContext;
}
首先我们来看第一个HttpSessionSecurityContextRepository,它是第一个被遍历的实现:
@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
Supplier<SecurityContext> supplier = () -> readSecurityContextFromSession(request.getSession(false)); //从Session中取出SecurityContext
return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT";
private String springSecurityContextKey = SPRING_SECURITY_CONTEXT_KEY;
private SecurityContext readSecurityContextFromSession(HttpSession httpSession) {
//实际上这里就是从Session中通过键“SPRING_SECURITY_CONTEXT”取出的SecurityContext
//跟我们上一节使用的是完全一样的,这下就很清晰了
//如果用户没有登录验证,那么这里获取到的SecurityContext就是null了
Object contextFromSession = httpSession.getAttribute(this.springSecurityContextKey);
return (SecurityContext) contextFromSession;
}
最后返回回去的是一个SupplierDeferredSecurityContext对象:
final class SupplierDeferredSecurityContext implements DeferredSecurityContext {
private static final Log logger = LogFactory.getLog(SupplierDeferredSecurityContext.class);
private final Supplier<SecurityContext> supplier;
private final SecurityContextHolderStrategy strategy;
private SecurityContext securityContext;
private boolean missingContext;
SupplierDeferredSecurityContext(Supplier<SecurityContext> supplier, SecurityContextHolderStrategy strategy) {
this.supplier = supplier;
this.strategy = strategy;
@Override
public SecurityContext get() {
//在获取SecurityContext时会进行一次初始化
init();
return this.securityContext;
@Override
public boolean isGenerated() {
init();
//初始化后判断是否为未登录的SecurityContext
return this.missingContext;
private void init() {
//如果securityContext不为null表示已经初始化过了
if (this.securityContext != null) {
return;
//直接通过supplier获取securityContext对象
this.securityContext = this.supplier.get();
//如果securityContext对象为null,那么就标记missingContext
this.missingContext = (this.securityContext == null);
if (this.missingContext) {
//当missingContext为真时,说明没有securityContext(一般是未登录的情况)
//那么就创建一个空的securityContext,不包含任何认证信息
this.securityContext = this.strategy.createEmptyContext();
//日志无视就好
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Created %s", this.securityContext));
}
接着是第二个被遍历的实现RequestAttributeSecurityContextRepository类:
@Override
public DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
Supplier<SecurityContext> supplier = () -> getContext(request);
//同样是返回SupplierDeferredSecurityContext对象
return new SupplierDeferredSecurityContext(supplier, this.securityContextHolderStrategy);
private SecurityContext getContext(HttpServletRequest request) {
//通过HttpServletRequest的Attribute获取SecurityContext
//由于一般情况下没有设定过,因此得到的就是null
return (SecurityContext) request.getAttribute(this.requestAttributeName);
}
最后,两个SecurityContext就会以链式存放在DelegatingDeferredSecurityContext对象中,一并返回了,它的内部长这样:
static final class DelegatingDeferredSecurityContext implements DeferredSecurityContext {
private final DeferredSecurityContext previous;
private final DeferredSecurityContext next;
DelegatingDeferredSecurityContext(DeferredSecurityContext previous, DeferredSecurityContext next) {
this.previous = previous;
this.next = next;
@Override
public SecurityContext get() {
//在获取SecurityContext时,会首先从最前面的开始获取
SecurityContext securityContext = this.previous.get();
//如果最前面的SecurityContext是已登录的,那么直接返回这个SecurityContext
if (!this.previous.isGenerated()) {
return securityContext;
//否则继续看后面的,也许后面的会有已登录的(实在没有就直接返回一个空的SecurityContext了)
return this.next.get();
@Override
public boolean isGenerated() {
return this.previous.isGenerated() && this.next.isGenerated();
}
兜了这么大一圈,现在回到一开始的Filter中:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
//拿到最终的SecurityContext的Supplier后,继续下面的语句
try {
//向securityContextHolderStrategy中设置我们上面得到的DeferredSecurityContext
this.securityContextHolderStrategy.setDeferredContext(deferredContext);
//请求前的任务已完成,继续其他过滤器了
chain.doFilter(request, response);
finally {
//请求结束后,清理掉securityContextHolderStrategy中的DeferredSecurityContext
this.securityContextHolderStrategy.clearContext();
request.removeAttribute(FILTER_APPLIED);
}
最后我们再来看一下我们之前通过SecurityContextHolder是如何获取到SecurityContext的:
public class SecurityContextHolder {
private static String strategyName = System.getProperty(SYSTEM_PROPERTY);
private static SecurityContextHolderStrategy strategy;
private static int initializeCount = 0;
static {
//类加载时会进行一次初始化
initialize();
private static void initialize() {
//初始化会将对应的SecurityContextHolderStrategy对象给创建
initializeStrategy();
initializeCount++;
//初始化SecurityContextHolderStrategy对象
private static void initializeStrategy() {
// 尝试加载系统配置中设定的Strategy实现类,默认是MODE_THREADLOCAL
try {
Class<?> clazz = Class.forName(strategyName);
Constructor<?> customStrategy = clazz.getConstructor();
// 这里直接根据配置中的类名,用反射怒艹一个对象出来
strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
catch (Exception ex) {
ReflectionUtils.handleReflectionException(ex);
//清除Context中的内容,实际上就是清理SecurityContextHolderStrategy中的内容
public static void clearContext() {
strategy.clearContext();
//获取SecurityContext对象
public static SecurityContext getContext() {
//获取SecurityContext实际上也是通过SecurityContextHolderStrategy根据策略来获取
return strategy.getContext();
}
我们发现,实际上SecurityContextHolder获取SecurityContext对象,就是通过SecurityContextHolderStrategy根据策略来获取,我们直接来看SecurityContextHolderStrategy的实现类:
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
//内部维护一个ThreadLocal对象,按线程存储对应的DeferredSecurityContext
private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal<>();
@Override
public void clearContext() {
//清理实际上是直接清理掉ThreadLocal中存的对象
contextHolder.remove();
@Override
public SecurityContext getContext() {
//获取也很简单,直接通过Supplier拿到需要的SecurityContext对象
return getDeferredContext().get();
@Override
public Supplier<SecurityContext> getDeferredContext() {
Supplier<SecurityContext> result = contextHolder.get();
//如果存储的DeferredSecurityContext为null,这里临时创建一个空的SecurityContext并保存
if (result == null) {
SecurityContext context = createEmptyContext();
result = () -> context;
contextHolder.set(result);
return result;
}
这样,整个流程其实就很清楚了,项目启动时,SecurityContextHolder会自动根据配置创建对应的SecurityContextHolderStrategy对象。当我们的请求到来之后,首先会经过SecurityContextHolderFilter,然后在这个阶段,通过SecurityContextRepository来将不同地方存储(一般是Session中存储)的SecurityContext对象取出并封装为DefferdSecurityContext,然后将其添加到一开始创建好的SecurityContextHolderStrategy对象中,这样,我们的Controller在处理时就能直接从SecurityContextHolder取出SecurityContext对象了,最后在处理结束返回响应时,SecurityContextHolderFilter也会将SecurityContextHolderStrategy存储的DefferdSecurityContext清除掉,至此,一个完整流程结束。