基于Spring6的重制版,截止目前,最新的版本是6.1.1版本。
前置课程:《Spring核心内容》《SpringMvc》《JavaWeb》《Java9-17新特性》
安全是开发者永远绕不开的话题,一个不安全的网站,往往存在着各种致命漏洞,只要被不法分子稍加利用,就能直接击溃整个网站,甚至破坏网站宝贵的用户数据。而用户的授权校验,则是网站安全系统的典型代表,这也是用户访问网站的第一关,我们需要一个更加安全和可靠的授权校验框架,才能让我们的网站更加稳定。
SpringSecurity是一个基于Spring开发的非常强大的权限验证框架,其核心功能包括:
本章,我们会从多个方面对此框架进行介绍,让各位小伙伴能够在自己的项目中使用更加安全和可靠的身份校验方案。
网络安全是当今互联网时代非常重要的一个议题。在信息爆炸的今天,网络安全问题频频发生,给个人和组织带来了严重的损失和威胁,随着技术的发展和互联网的普及,我们越来越意识到网络安全已经成为一个全球性的挑战。本部分,我们会给各位小伙伴介绍网络安全的相关概念,以及常见的Web服务器攻击形式,如果你已经了解相关内容,可以直接跳过本板块。
其中比较典型的案例有以下几个:
在2017年,一款名叫WannaCry的勒索软件席卷全球,导致全球大量计算机受到影响,得益于全球网络的发达,这款病毒从小规模很快发展至全球范围,勒索病毒是自熊猫烧香以来影响力最大的病毒之一。
WannaCry(又叫Wanna Decryptor),一种“蠕虫式”的勒索病毒软件,大小3.3MB,由不法分子利用NSA(National Security Agency,美国国家安全局)泄露的危险漏洞“EternalBlue”(永恒之蓝)进行传播 [1] 。勒索病毒肆虐,俨然是一场全球性互联网灾难,给广大电脑用户造成了巨大损失。最新统计数据显示,100多个国家和地区超过10万台电脑遭到了勒索病毒攻击、感染。 [2] 勒索病毒是自熊猫烧香以来影响力最大的病毒之一。WannaCry勒索病毒全球大爆发,至少150个国家、30万名用户中招,造成损失达80亿美元,已经影响到金融,能源,医疗等众多行业,造成严重的危机管理问题。
除了病毒在网络上传播外,还有非常恶心的DDOS攻击,这种攻击方式成本低,效率高,只要持续一段时间就能导致没有特殊防御的网站无法正常运作:
2022年4月,郑州警方接到报警称,某政府网站平台于近日遭受多次DDoS攻击,导致平台访问量瞬间暴增多倍,造成数十家政府网站和政务新媒体账号无法正常访问。接到报警后,郑州警方立即开展侦查调查,第一时间锁定犯罪嫌疑人张某某,仅22小时就将犯罪嫌疑人张某某抓捕到案。
经审查,张某某为某医院网络安全部技术维护人员,2022年4月2日,其个人网站被DDoS攻击,导致网站无法登陆。为减少攻击对其个人网站的影响,张某某私自将个人网站域名解析地址变更为某政府网站平台地址,“转嫁”攻击流量,致使该政府网站平台被恶意网络流量攻击,多家政府网站、政务新媒体账号不能正常使用。
目前,张某某因涉嫌破坏计算机信息系统犯罪被公安机关移送起诉,案件正在进一步侦办中。
信息泄露也是非常严重的网络安全问题,我们的个人信息是我们的隐私,而在网络发达的今天,在各大APP上填写的信息很有可能会被第三方获取,从中谋取利益。
今年早些时候便已发现相关安全漏洞的网络安全专家称, 一个常见漏洞导致了这场有记录以来最大规模的个人数据泄露,也是中国所遭遇最大规模的网络安全事件,造成数据被公开在互联网上,供人取用。
据这些网络安全专家称,上海警方的这些记录是被安全存储的,其中包含个人姓名、身份证号码、电话号码以及警情信息,涉及近10亿中国公民的数据。他们说,但一个用于管理和访问该数据库的显示界面(dashboard)被设置在一个公开网址上,没有加设密码,任何有相对基本技术知识的人都可以轻松访问、复制或窃取库中的海量信息。
暗网情报公司Shadowbyte的创始人Vinny Troia说:“他们把这么多数据暴露在外,这太疯狂了。”这家公司专门在网上扫描搜寻存在安全漏洞的数据库,早在今年1月扫描时发现了上海警方的这个数据库。
个人信息数据一旦泄露,相当于别人可以通过网络直接定位你的住址、电话号码、各种社交账号、身份证信息甚至是机票、火车票、酒店开房信息等,做的再绝一定,如果这个酒店被人偷偷放了针孔摄像头,甚至还能根据记录查到你开房视频,这些事情光是想想都可怕,这等同于在互联网上“裸奔”。
网络安全问题非同小可,而我们作为网站的开发者,更应该首当其冲解决这些潜在问题。接下来,我们会介绍几个常见的Web网站攻击方式,以及在后续使用SpringSecurity时如何去防范这些攻击行为。
为了测试我们之前的网站安全性,这里我们基于Mvc框架重新搭建一个采用之前的验证方式的简易网站,首先是登录界面部分:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录白马银行</title>
</head>
<body>
<form action="login" method="post">
<label>
用户名:
<input name="username" type="text">
</label>
<label>
密码:
<input name="password" type="password">
</label>
<button type="submit">登录</button>
</form>
<div th:if="${status}">登录失败,用户名或密码错误!</div>
</body>
</html>
接着是登录之后的首页:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>白马银行 - 首页</title>
</head>
<body>
</body>
</html>
接着是Controller部分:
@Controller
public class HelloController {
//处理登录操作并跳转
@PostMapping("/login")
public String login(@RequestParam String username,
@RequestParam String password,
HttpSession session,
Model model){
if("test".equals(username) && "123456".equals(password)) {
session.setAttribute("login", true);
return "redirect:/";
} else {
model.addAttribute("status", true);
return "login";
}
}
//处理首页或是登录界面跳转
@GetMapping("/")
public String index(HttpSession session){
if(session.getAttribute("login") != null) {
return "index";
}else {
return "login";
}
}
}
这样我们就可以进行简单登录了:
接着我们在首页加一个转账操作,要求填写转账人账号名称:
@ResponseBody
@PostMapping("/pay")
public JSONObject pay(@RequestParam String account,
HttpSession session){
JSONObject object = new JSONObject();
//登录之后才能转账
if(session.getAttribute("login") != null) {
System.out.println("转账给"+account+"成功,交易已完成!");
object.put("success", true);
} else {
System.out.println("转账给"+account+"失败,用户未登录!");
object.put("success", false);
}
return object;
}
接着我们在页面中添加一个简单的转账操作按键:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>白马银行 - 首页</title>
<script src="https://unpkg.com/axios@1.1.2/dist/axios.min.js"></script>
</head>
<body>
<div>
<label>
转账账号:
<input type="text" id="account"/>
</label>
<button onclick="pay()">立即转账</button>
</div>
</body>
</html>
<script>
function pay() {
const account = document.getElementById("account").value
axios.post('/mvc/pay', { account: account }, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}).then(({data}) => {
if(data.success)
alert("转账成功")
else
alert("转账失败")
})
}
</script>
这样我们就成功搭建好网络安全的测试项目了,各位小伙伴请将这个项目进行保存,后面需要重复使用。
CSRF是我们要介绍的第一种攻击形式,这种攻击方式非常好理解。
我们时常会在QQ上收到别人发送的钓鱼网站链接,只要你在上面登陆了你的QQ账号,那么不出意外,你的号已经在别人手中了。实际上这一类网站都属于恶意网站,专门用于盗取他人信息,执行非法操作,甚至获取他人账户中的财产,非法转账等。而这里,我们需要了解一种比较容易发生的恶意操作,从不法分子的角度去了解整个流程。
我们在JavaWeb阶段已经了解了Session和Cookie的机制,在一开始的时候,服务端会给浏览器一个名为JSESSIONID的Cookie信息作为会话的唯一凭据,只要用户携带此Cookie访问我们的网站,那么我们就可以认定此会话属于哪个浏览器用户。因此,只要此会话的用户执行了登录操作,那么就可以随意访问个人信息等内容。
我们来尝试模拟一下这种操作,来编写一个钓鱼网站:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>坤坤炒粉放鸡精视频在线观看</title>
<script src="https://unpkg.com/axios@1.1.2/dist/axios.min.js"></script>
</head>
<body>
<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只能在用户导航到与原始站点相同的站点时发送。这同样大大提升了用户的安全性,让黑客少了许多可乘之机,不过这个机制对做实施的同学(可能)不太友好。
这同样是利用Cookie中相同的JSESSIONID进行的攻击,会话固定攻击(Session fixation attack)是一种针对Web应用程序的安全漏洞攻击,攻击者利用这种漏洞,将一个有效的会话ID分配给用户,并诱使用户在该会话中进行操作,然后攻击者可以利用该会话ID获取用户的权限,或者通过此会话继续进行其他攻击。
简单来说,就是黑客把他的JSESSIONID直接给你,你一旦使用这个ID登录,那么在后端这个ID就被认定为已登录状态,那么也就等同于他直接进入了已登录状态,从而直接访问你账号的任意内容,执行任意操作。
攻击者通常使用以下几种方式进行会话固定攻击:
这里我们来尝试一下第一种方案,这里我们首先用另一个浏览器访问目标网站,此时需要登录,开始之前记得先清理一下两个浏览器的缓存,否则可能无法生效:
这里我们直接记录下这个JSESSIONID,然后将其编写到我们的诈骗网站中,这里有一个恶意脚本,会自动将对应用户的Cookie进行替换,变成我们的JSESSIONID值:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>冠希哥全套视频</title>
<script src="https://unpkg.com/axios@1.1.2/dist/axios.min.js"></script>
</head>
<body>
<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攻击常见的方式有三种:
在一些社交网站上,用户可以自由发帖,而帖子是以富文本形式进行编辑和上传的,发送给后台的帖子往往是直接以HTML代码的形式,这个时候就会给黑客可乘之机了。
正常情况下,用户发帖会向后端上传以下内容,这些是经过转换得到的正常HTML代码,方便后续直接展示:
<div class="content ql-editor">
<p>
<strong>萨达睡觉了大数据</strong>
</p>
<p>撒大大撒大声地</p>
</div>
而现在,黑客不走常规的方式发帖,而是发送以下内容给服务端:
<div class="content ql-editor">
<p οnlοad="alert('xss')">
<strong>萨达睡觉了大数据</strong>
</p>
<p>撒大大撒大声地</p>
</div>
可以看到p
标签上添加了一段JS恶意脚本,黑客可以利用这种特性,获取用户的各种信息,甚至直接发送到他的后台,这样,我们的个人信息就从网站内部被泄露了。
XSS漏洞最早被发现是在1996年,由于JavaScript的出现,导致在Web应用程序中存在了一些安全问题。在1997年,高智文(Gareth Owen)也就是“XSS之父”,在他的博客中描述了一种称为“脚本注入”(script injection)的攻击技术,这就是XSS漏洞的前身。从那时起,XSS漏洞便成为了Web应用程序中的一种常见安全漏洞。
这种攻击一般需要前端配合后端进行防御,或者后端对前端发送的内容进行安全扫描并处理,有机会我们会分享如何防范此类攻击。
在了解这么多攻击方式之后,想必各位小伙伴肯定有了一定的网络安全意识,不过我们并不是网络安全专业的课程,至于更多的攻击形式,还请各位小伙伴自行了解,从下一个板块开始,我们将会正式开始SpringSecurity框架的介绍。
我们继续使用之前的测试项目进行教学,首先我们需要导入SpringSecurity的相关依赖,它不仅仅是一个模块,我们可以根据需求导入需要的模块,常用的是以下两个:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>6.1.1</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>6.1.1</version>
</dependency>
接着我们需要配置SpringSecurity,与Mvc一样,需要一个初始化器:
public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
//不用重写任何内容
//这里实际上会自动注册一个Filter,SpringSecurity底层就是依靠N个过滤器实现的,我们之后再探讨
}
接着我们需要再创建一个配置类用于配置SpringSecurity:
@Configuration
@EnableWebSecurity //开启WebSecurity相关功能
public class SecurityConfiguration {
}
接着在根容器中添加此配置文件即可:
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class[]{MainConfiguration.class, SecurityConfiguration.class};
}
这样,SpringSecurity的配置就完成了,我们再次运行项目,会发现无法进入的我们的页面中,无论我们访问哪个页面,都会进入到SpringSecurity为我们提供的一个默认登录页面,之后我们会讲解如何进行配置。
至此,项目环境搭建完成。
认证是我们网站的第一步,用户需要登录之后才能进入,这一部分我们将详细介绍如何使用SpringSecurity实现用户登录。
首先我们来看看最简单的基于内存的配置,也就是说我们直接以代码的形式配置我们网站的用户和密码,配置方式非常简单,只需要在Security配置类中注册一个Bean即可:
@Configuration
@EnableWebSecurity
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/mvc/logout 地址,我们会进入到一个退出登录界面:
退出登录后就需要重新登录才能访问我们的网站了。
可以发现,在有了SpringSecurity之后,我们网站的登录验证模块相当于直接被接管了,因此,从现在开始,我们的网站不需要再自己编写登录模块了,这里我们可以直接去掉,只留下主页面:
@Controller
public class HelloController {
//现在所有接口不需要任何验证了,因为Security已经帮我们做了,没登录是根本进不来的
@GetMapping("/")
public String index(){
return "index";
}
@ResponseBody
@PostMapping("/pay")
public JSONObject pay(@RequestParam String account){
JSONObject object = new JSONObject();
System.out.println("转账给"+account+"成功,交易已完成!");
object.put("success", true);
return object;
}
}
这样,我们的网站就成功用上了更加安全的SpringSecurity框架了。细心的小伙伴可能发现了,我们在配置用户信息的时候,报了黄标,实际上这种方式存储密码并不安全:
这是因为SpringSecurity的密码校验不推荐直接使用原文进行比较,而是使用加密算法将密码进行加密(更准确地说应该进行Hash处理,此过程是不可逆的,无法解密),最后将用户提供的密码以同样的方式加密后与密文进行比较。对于我们来说,用户提供的密码属于隐私信息,直接明文存储并不好,而且如果数据库内容被窃取,那么所有用户的密码将全部泄露,这是我们不希望看到的结果,我们需要一种既能隐藏用户密码也能完成认证的机制,而Hash处理就是一种很好的解决方案,通过将用户的密码进行Hash值计算,计算出来的结果一般是单向的,无法还原为原文,如果需要验证是否与此密码一致,那么需要以同样的方式加密再比较两个Hash值是否一致,这样就很好的保证了用户密码的安全性。
因此,我们在配置用户信息的时候,可以使用官方提供的BCrypt加密工具:
@Configuration
@EnableWebSecurity
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);
}
}
这样,我们存储的密码就是更加安全的密码了:
这样,一个简单的权限校验就配置完成了,是不是感觉用起来还是挺简单的?
不过,可能会有小伙伴发现,所有的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 //携带此信息即可,否则会被拦截
}, {
...
如果后续各位小伙伴遇到那种需要再form表单中提交的情况,也可以直接像下面这样给塞到表单里:
<form action="/xxxx" method="post">
...
<input type="text" th:name="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>
...
</form>
实际上现在的浏览器已经很安全了,完全不需要使用自带的csrf防护,后面我们会讲解如何通过配置关闭csrf防护。这里温馨提醒一下,在后续各位小伙伴跟我们的实战项目时,如果遇到诸如401、403这种错误时,优先查看你的SpringSecurity配置是否错误。
从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字符串。