1. 会话管理
配套视频:45.前后端分离开发记住我实现_哔哩哔哩_bilibili
-
简介
-
会话并发管理
-
会话共享实战
1. 1 简介
当浏览器调用登录接口登录成功后,服务端会和浏览器之间建立一个会话 (Session) ,浏览器在每次发送请求时都会携带一个 Sessionld,服务端则根据这个 Sessionld 来判断用户身份。当浏览器关闭后,服务端的 Session 并不会自动销毁,需要开发者手动在服务端调用 Session销毁方法,或者等 Session 过期时间到了自动销毁(默认在Java Web中Tomcat的session过期时间为30分钟)。
在Spring Security 中,与HttpSession相关的功能由 SessionManagementFiter 和SessionAutheaticationStrateey 接口来处理,SessionManagomentFilter 过滤器将 Session 相关操作委托给 SessionAuthenticationStrateey 接口去完成。
1.2 会话并发管理
配套视频:46.会话管理以及简介_哔哩哔哩_bilibili
1.2.1 简介
会话并发管理就是指在当前系统中,同一个用户可以同时创建多少个会话。如果一个设备对应一个会话,那么也可以简单理解为同一个用户可以同时在多少台设备上进行登录。默认情况下,同一用户在多少台设备上登录并没有限制,不过开发者可以在 Spring Security 中对此进行配置。举例:比如某某会员,一个账号可以在多个设备上进行登录。
1.2.2 开启会话管理
(1)新建SpringInitializr项目spring-security-session,引入Spring Web、Spring Security依赖。
(2)新建controller,测试项目是否创建成功
package com.study.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; /** * @ClassName IndexController * @Description TODO * @Author Jiangnan Cui * @Date 2022/10/6 22:07 * @Version 1.0 */ @RestController public class IndexController { @GetMapping("/index") public String index() { System.out.println("index ok"); return "Index Ok"; } }
(3)配置SpringSecurity,开启会话管理
package com.study.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.web.session.HttpSessionEventPublisher; /** * @ClassName SecurityConfig * @Description SpringSecurity配置类 * @Author Jiangnan Cui * @Date 2022/10/6 22:21 * @Version 1.0 */ @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeHttpRequests() .anyRequest().authenticated() .and() .formLogin() .and() .csrf().disable() .sessionManagement()// 开启会话管理 .maximumSessions(1);// 允许会话最大并发数为1个,即只有一个客户端能正常访问 } // 监听会话的创建和过期,过期移除 @Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } }
其中:
-
sessionManagement() 用来开启会话管理,maximumSessions用于在会话开启后指定会话的并发数为 1。
-
HttpSessionEventPublisher 提供了一个HttpSessionEvenePubishor实例。Spring Security中通过一个 Map 集合来维护当前的Http Session记录,进而实现会话的并发管理。当用户登录成功时,就向集合中添加一条HttpSession记录;当会话销毁时,就从集合中移除一条Httpsession记录。HttpSessionEventPublisher实现了HttpSessionListener 接口,可以监听到 HttpSession 的创建和销毀事件,并将 HttpSession的创建/销毁事件发布出去,这样,当有 HttpSession销毀时,Spring Security 就可以感知到该事件了。
-
最新版本已经不在添加httpSessionEventPublisher()方法了,为了更方便的监听会话的创建和删除,此处加上。
1.3 测试会话并发管理
配置完成后,启动项目。这次测试我们需要两个浏览器,如果使用了 Chrome 浏览器,可以使用 Chrome 浏览器中的多用户方式(相当于两个浏览器),先在第一个浏览器中输入http://localhost:8080/index,此时会自动跳转到登录页面,完成登录操作,就可以访问到数据了;接下来在第二个浏览器中也输入http://localhost:8080/index,也需要登录, 完成登录操作;当第二个浏览器登录成功后,再回到第一个浏览器,刷新页面。结果出现下图:
此处使用Edge浏览器和联想浏览器进行测试:
(1)两个浏览器同时登录
(2)刷新Edge浏览器
以上测试说明只能一个浏览器进行正常访问,额外的登录被挤下线,说明SpringSecurity的会话管理设置生效。
1.4 会话失效处理
1.4.1 传统 web 开发处理(跳转到登录界面)
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .and() .csrf().disable() .sessionManagement()// 开启会话管理 .maximumSessions(1)// 允许会话最大并发数为1个,即只有一个客户端能正常访问 .expiredUrl("/login");// 当用户被挤下线之后的跳转路径<---传统web开发 }
测试结果:
1.4.2 前后端分离开发处理(错误提示)
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .and() .csrf().disable() .sessionManagement()// 开启会话管理 .maximumSessions(1)// 允许会话最大并发数为1个,即只有一个客户端能正常访问 //.expiredUrl("/login")// 当用户被挤下线之后的跳转路径 传统web开发 // .expiredSessionStrategy(new SessionInformationExpiredStrategy() { // 前后端分离处理方案,利用匿名内部类形式创建 // @Override // public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException { // HttpServletResponse response = event.getResponse(); // // map转json进行输出 // Map<String, Object> result = new HashMap<>(); // result.put("status", 500); // result.put("msg", "当前会话已经失效,请重新登录!"); // String s = new ObjectMapper().writeValueAsString(result); // response.setContentType("application/json;charset=UTF-8"); // response.getWriter().println(s); // response.flushBuffer(); // } // }); .expiredSessionStrategy(event -> {// 匿名内部类优化 HttpServletResponse response = event.getResponse(); // map转json进行输出 Map<String, Object> result = new HashMap<>(); result.put("status", 500); result.put("msg", "当前会话已经失效,请重新登录!"); String s = new ObjectMapper().writeValueAsString(result); response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(s); response.flushBuffer(); }); }
测试结果:
1.5 禁止再次登录
配套视频:48.会话被挤下线时处理方案_哔哩哔哩_bilibili
默认的效果是一种被 “挤下线”的效果,后面登录的用户会把前面登录的用户 “挤下线”。
还有一种是禁止后来者登录,即一旦当前用户登录成功,后来者无法再次使用相同的用户登录,直到当前用户主动注销登录,配置如下:
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .and() .logout() // 退出登录 .and() .csrf().disable() .sessionManagement()// 开启会话管理 .maximumSessions(1)// 允许会话最大并发数为1个,即只有一个客户端能正常访问 .expiredSessionStrategy(event -> {// 匿名内部类优化 HttpServletResponse response = event.getResponse(); // map转json进行输出 Map<String, Object> result = new HashMap<>(); result.put("status", 500); result.put("msg", "当前会话已经失效,请重新登录!"); String s = new ObjectMapper().writeValueAsString(result); response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(s); response.flushBuffer(); }) .maxSessionsPreventsLogin(true);// 一旦一个登录成功,禁止其它再次登录 }
测试结果:
1.6 会话共享
配套视频:50.集群下会话共享解决方案_哔哩哔哩_bilibili
前面所讲的会话管理都是单机上的会话管理,如果当前是集群环境,前面所讲的会话管理方案就会失效。
模拟集群测试:在idea中复制一个Configuration进行启动
在VM Options下修改端口号:
启动两个服务:
测试:
这种情况下,会话管理失效。
此时可以利用redis或者memcached来解决,此处通过spring-session 结合 redis 实现 session 共享。
1.6.1 引入依赖
<!--操作redis--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--session序列化到redis--> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
1.6.2 编写配置
# 配置redis spring.redis.host=localhost spring.redis.port=6379 spring.redis.database=0
1.6.3 配置Security
package com.study.config; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; 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.session.FindByIndexNameSessionRepository; import org.springframework.session.security.SpringSessionBackedSessionRegistry; import javax.servlet.http.HttpServletResponse; import java.util.HashMap; import java.util.Map; /** * @ClassName SecurityConfig * @Description SpringSecurity配置类 * @Author Jiangnan Cui * @Date 2022/10/6 22:21 * @Version 1.0 */ @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { // 注入FindByIndexNameSessionRepository,操作session private final FindByIndexNameSessionRepository findByIndexNameSessionRepository; @Autowired public SecurityConfig(FindByIndexNameSessionRepository findByIndexNameSessionRepository) { this.findByIndexNameSessionRepository = findByIndexNameSessionRepository; } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .and() .logout() // 退出登录 .and() .csrf().disable() .sessionManagement()// 开启会话管理 .maximumSessions(1)// 允许会话最大并发数为1个,即只有一个客户端能正常访问 //.expiredUrl("/login")// 当用户被挤下线之后的跳转路径 传统web开发 .expiredSessionStrategy(event -> {// 匿名内部类优化 HttpServletResponse response = event.getResponse(); // map转json进行输出 Map<String, Object> result = new HashMap<>(); result.put("status", 500); result.put("msg", "当前会话已经失效,请重新登录!"); String s = new ObjectMapper().writeValueAsString(result); response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(s); response.flushBuffer(); }) .maxSessionsPreventsLogin(true)// 一旦一个登录成功,禁止其它再次登录 .sessionRegistry(sessionRegistry());// 将session交给谁管理 } // 创建session同步到redis中的方案 @Bean public SpringSessionBackedSessionRegistry sessionRegistry() { return new SpringSessionBackedSessionRegistry(findByIndexNameSessionRepository); } // 此时自身就不需要再去监听了,直接交给redis管理 ↓↓↓↓↓↓ // 监听会话的创建和过期,过期移除 // @Bean // public HttpSessionEventPublisher httpSessionEventPublisher() { // return new HttpSessionEventPublisher(); // } }
1.6.4 测试
先启动redis,在分别启动两个服务,分别进行访问:
redis中结果:
上述操作解决了集群环境下的session共享问题,保证只有一个可以登录成功。
1.7 最终项目目录结构
2. CSRF 漏洞保护
配套视频:51.CSRF 漏洞保护简介_哔哩哔哩_bilibili
-
CSRF 简介
-
CSRF 防御&基本配置
-
实战
2.1 简介
CSRF (Cross-Site Request Forgery 跨站请求伪造),也可称为一键式攻击 (one-click-attack),通常缩写为 CSRF
或者 XSRF
。
CSRF
攻击是一种挟持用户在当前已登录的浏览器上发送恶意请求的攻击方法。相对于XSS利用用户对指定网站的信任而言,CSRF则是利用网站对用户网页浏览器的信任。简单来说,CSRF是攻击者通过一些技术手段欺骗用户的浏览器,去访问一个用户曾经认证过的网站,并执行恶意请求,例如发送邮件、发消息、甚至财产操作 (如转账和购买商品)。由于客户端(浏览器)已经在该网站上认证过,所以该网站会认为是真正用户在操作而执行请求,实际上这个并非用户的本意。
举个简单的例子:
假设 blr 现在登录了某银行的网站准备完成一项转账操作,转账的链接如下:
https: //bank .xxx .com/withdraw?account=blr&amount=1000&for=zhangsan
可以看到,这个链接是想从 blr 这个账户下转账 1000 元到 zhangsan 账户下,假设blr 没有注销登录该银行的网站,就在同一个浏览器新的选项卡中打开了一个危险网站,这个危险网站中有一幅图片,代码如下:
<img src="https ://bank.xxx.com/withdraw?account=blr&amount=1000&for=1isi">
一旦用户打开了这个网站,这个图片链接中的请求就会自动发送出去。由于是同一个浏览器,并且用户尚未注销登录,所以该请求会自动携带上对应的有效的 Cookie 信息,进而完成一次转账操作,这就是跨站请求伪造。
注意:应用工作时无法区分哪些是用用户自己想要执行的,哪些是攻击的,只要有请求来,它就会执行。
2.2 CSRF攻击演示
配套视频:52.CSRF 攻击演示_哔哩哔哩_bilibili
2.2.1 创建银行应用
在SpringSecurity项目下新建module:spring-security-csrf-bank,引入Spring Web、Spring Security依赖。
-
pom.xml中添加依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
-
config包下新建SpringSecurity配置类
package com.study.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 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.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; /** * @ClassName SecurityConfig * @Description SpringSecurity配置类 * @Author Jiangnan Cui * @Date 2022/12/17 10:29 * @Version 1.0 */ @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { // 使用内存数据源 @Bean public UserDetailsService userDetailsService() { InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager(); inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123456").roles("admin").build()); return inMemoryUserDetailsManager; } // 使用内存数据源指定全局的AuthenticationManager @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService()); } // SpringSecurity基本配置 @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().authenticated() .and().formLogin() .and() .csrf().disable(); //关闭CSRF跨站请求保护 ←--←--←--重点看这里 } }
-
创建 BankController用于项目测试转账业务
package com.study.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; /** * @ClassName BankController * @Description 银行转账相关操作 * @Author Jiangnan Cui * @Date 2022/12/17 10:33 * @Version 1.0 */ @RestController public class BankController { @PostMapping("/withdraw") public String withdraw() { System.out.println("执行一次银行转账操作"); return "Perform a bank transfer operation."; } // 主页 @GetMapping("index") public String index() { return "欢迎光临!"; } }
2.2.2 创建恶意应用
在SpringSecurity项目下新建module:spring-security-csrf-attack,引入Spring Web依赖。
-
pom.xml中添加依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
-
application.properties修改端口配置:避免8080被重复使用,导致新的项目启动不起来
server.port: 8081
-
resources目录下新建index.html作为攻击页面
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>模拟csrf跨站请求伪造</title> </head> <body> <form action="http://localhost:8080/withdraw" method="post"> <input name="name" type="hidden" value="cjn"/> <input name="money" type="hidden" value="10000"> <input type="submit" value="点我"> </form> </body> </html>
注意:上面的localhost或者127.0.0.1要保持一致。
2.2.3 测试
(1)启动两个module服务,浏览器先访问:http://localhost:8080/index,输入用户名:root,密码:123456,提示登录成功:
(2)然后,在同一浏览器新建标签页,浏览器访问:http://localhost:8081,点击“点我”,输出转账成功提示,表示已经遭受了CSRF攻击。
2.2.4 报错总结
-
报错1:
java: 无法访问org.springframework.boot.SpringApplication 错误的类文件: /C:/.../.m2/repository/org/springframework/boot/spring-boot/3.0.0/spring-boot-3.0.0.jar!/org/springframework/boot/SpringApplication.class 类文件具有错误的版本 61.0, 应为 52.0 请删除该文件或确保该文件位于正确的类路径子目录中。
解决办法:报错信息里所说的类文件版本指的是java class file version,即:java类文件版本,该版本号与jdk版本号存在对应关系,61.0对应jdk17,52.0对应jdk8。完整对应关系参见: List of Java class file format major version numbers? - Stack Overflow,所以报错是因为某个依赖的版本太高,降低版本即可,具体是哪个依赖需要自己排查。
参考链接:java: 无法访问org.springframework.boot.SpringApplication_我叫嘻嘻呵呵的博客-CSDN博客
我的报错来源:
此处只需要降低boot版本,我的由3.0.0修改为2.6.12,重新刷新maven后就可以正常启动项目了。
同时,还发现了一个问题:如果创建SpringBoot项目时boot版本选3.0.0,即使jdk版本选8,生成后的pom文件也会指定jdk版本为17,后续还需要自己修改成8,否则编译不成功,这点还需要进一步研究一下。
2.3 CSRF防御
配套视频:54.CSRF防御配置_哔哩哔哩_bilibili
CSRF攻击的根源在于浏览器默认的身份验证机制(自动携带当前网站的Cookie信息),这种机制虽然可以保证请求是来自用户的某个浏览器,但是无法确保这请求是用户授权发送。攻击者和用户发送的请求一模一样,这意味着我们没有办法去直接拒绝这里的某一个请求。如果能在合法清求中额外携带一个攻击者无法获取的参数,就可以成功区分出两种不同的请求,进而直接拒绝掉恶意请求。在 SpringSecurity 中就提供了这种机制来防御 CSRF 攻击,这种机制我们称之为令牌同步模式
。
2.3.1 令牌同步模式
这是目前主流的 CSRF 攻击防御方案。具体的操作方式就是在每一个 HTTP 请求中,除了默认自动携带的 Cookie 参数之外,再提供一个安全的、随机生成的宇符串,我们称之为 CSRF 令牌。这个 CSRF 令牌由服务端生成,生成后在 HttpSession 中保存一份。当前端请求到达后,将请求携带的 CSRF 令牌信息和服务端中保存的令牌进行对比,如果两者不相等,则拒绝掉该 Http 请求。
注意: 考虑到会有一些外部站点链接到我们的网站,所以我们要求请求是幂等的(多次请求结果一致),这样对子HEAD、OPTIONS、TRACE 等方法就没有必要使用 CSRF 令牌了,强行使用可能会导致令牌泄露!
2.3.2 开启CSRF防御
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http. ... formLogin() .and() .csrf(); // 开启csrf } }
通过这种方式能够在一定程度上保证当前应用的安全性!
2.3.3 测试
(1)启动两个module服务,浏览器先访问:http://localhost:8080/index,输入用户名:root,密码:123456,提示登录成功,查看网页源代码可找到页面渲染时自动生成的随机token令牌信息:
(2)然后,在同一浏览器新建标签页,浏览器访问:http://localhost:8081,点击“点我”,无法进行转账操作,表示开启了csrf攻击防护:
2.4 传统web开发使用CSRF
配套视频:55.CSRF 传统 web 开发使用_哔哩哔哩_bilibili
开启CSRF防御后会自动在提交的表单中加入如下代码,如果不能自动加入,需要在开启之后手动加入如下代码,并随着请求提交。获取服务端令牌方式如下:
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
2.4.1 开发流程
在SpringSecurity下面新建module:spring-security-csrf-web
-
pom.xml引入依赖:
<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>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity5</artifactId> </dependency>
-
application.properties添加配置:
spring.thymeleaf.cache=false spring.thymeleaf.mode=HTML spring.thymeleaf.encoding=UTF-8 spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html
-
创建config包,包下创建SpringSecurity配置类SecurityConfig:
package com.study.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 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.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; /** * @ClassName SecurityConfig * @Description SpringSecurity配置类 * @Author Jiangnan Cui * @Date 2022/12/18 11:54 * @Version 1.0 */ @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { // 使用内存数据源 @Bean public UserDetailsService userDetailsService() { InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager(); inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123456").roles("admin").build()); return inMemoryUserDetailsManager; } // 使用内存数据源指定全局的AuthenticationManager @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService()); } // SpringSecurity基本配置 @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().authenticated() .and().formLogin() .and() .csrf(); //开启CSRF跨站请求保护 ←--←--←--重点看这里 } }
-
开发测试controller: HelloController
package com.study.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; /** * @ClassName HelloController * @Description TODO * @Author Jiangnan Cui * @Date 2022/12/18 11:57 * @Version 1.0 */ @RestController public class HelloController { @PostMapping("/hello") public String hello() { System.out.println("hello success"); return "hello success"; } @GetMapping("/index.html") public String index() { return "index"; } }
-
templates路径下添加主页index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>测试开启CSRF</title> </head> <body> <h1>主页</h1> <form method="post" th:action="@{/hello}"> 信息: <input name="name" type="text"> <br> <input type="submit" value="提交"> </form> </body> </html>
2.4.2 测试
启动服务,访问路径:http://localhost:8080/,输入用户名:root,密码:123456,登录后,查看网页源代码:
发现渲染页面时,自动开启csrf防护。
总结:传统web开发使用csrf时,只要开启csrf防护即可,无需额外操作。
2.5 前后端分离使用 CSRF
配套视频:56.CSRF 前后端分离使用&原理分析_哔哩哔哩_bilibili
注意:前后端分离开发时,由于前后端分别属于两个不同的系统,所以前端系统没法使用这种操作作用域(${_csrf})的,因此前后端请求都是通过Ajax处理的。在服务端开启csrf后,默认开启的csrf是保存在Session作用域的,不适用于前后端分离开发的系统,因此要重新指定令牌的存储方式。
解决办法:前后端分离开发时,只需要将生成 csrf 放入到cookie 中,并在请求时获取 cookie 中令牌信息进行提交即可,即令牌在浏览器本身进行保存。
2.5.1 开发流程
在SpringSecurity项目下新建module:spring-security-csrf-split
-
pom.xml中引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
-
创建SpringSecurity配置类SecurityConfig类:
package com.study.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.study.filter.LoginFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 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.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import java.util.HashMap; import java.util.Map; /** * @ClassName SecurityConfig * @Description SpringSecurity配置类 * @Author Jiangnan Cui * @Date 2022/12/18 16:58 * @Version 1.0 */ @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { // 使用内置的数据源 @Bean public UserDetailsService userDetailsService() { InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager(); inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123456").roles("admin").build()); return inMemoryUserDetailsManager; } // 使用内置的数据源指定AuthenticationManager @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService()); } // 指定全局的authenticationManagerBean,并对外暴露 @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } // 自定义filter,并交给工厂管理 @Bean public LoginFilter loginFilter() throws Exception { LoginFilter loginFilter = new LoginFilter(); loginFilter.setFilterProcessesUrl("/doLogin");//指定认证 url loginFilter.setUsernameParameter("uname");//指定接收json 用户名 key loginFilter.setPasswordParameter("passwd");//指定接收 json 密码 key loginFilter.setAuthenticationManager(authenticationManagerBean()); //认证成功处理 loginFilter.setAuthenticationSuccessHandler((req, resp, authentication) -> { Map<String, Object> result = new HashMap<String, Object>(); result.put("msg", "登录成功"); result.put("用户信息", authentication.getPrincipal()); resp.setContentType("application/json;charset=UTF-8"); resp.setStatus(HttpStatus.OK.value()); String s = new ObjectMapper().writeValueAsString(result); resp.getWriter().println(s); }); //认证失败处理 loginFilter.setAuthenticationFailureHandler((req, resp, ex) -> { Map<String, Object> result = new HashMap<String, Object>(); result.put("msg", "登录失败: " + ex.getMessage()); resp.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); resp.setContentType("application/json;charset=UTF-8"); String s = new ObjectMapper().writeValueAsString(result); resp.getWriter().println(s); }); return loginFilter; } // Security基本配置 @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().authenticated() .and() .formLogin() //.and().csrf().disable();// 注意此处单独注释csrf()是不生效的,默认开始csrf,要csrf().disable()一起写才能关闭csrf防御 .and() .csrf()// 开启csrf防御 .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());// 作用:1.将令牌保存到cookie中; 2.允许cookie被前端获取 http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);// 使用LoginFilter替换UsernamePasswordAuthenticationFilter } }
注意:上面的username已经修改成uname、password已经修改成:passwd,Postman JSON格式请求数据时要注意修改!!!
-
创建filter包,包下新建LoginFilter替换UsernamePasswordAuthenticationFilter:
package com.study.filter; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Map; /** * @ClassName LoginFilter * @Description 自定义前后端分离认证Filter * @Author Jiangnan Cui * @Date 2022/12/18 16:59 * @Version 1.0 */ public class LoginFilter extends UsernamePasswordAuthenticationFilter { @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { System.out.println("========================================"); //1. 判断是否是post方式请求 if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } //2. 判断是否是json格式请求类型 if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) { //3. 从json数据中获取用户输入用户名和密码进行认证{"uname":"xxx","password":"xxx"} try { Map<String, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class); String username = userInfo.get(getUsernameParameter()); String password = userInfo.get(getPasswordParameter()); System.out.println("用户名: " + username + ", 密码: " + password); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } catch (IOException e) { e.printStackTrace(); } } return super.attemptAuthentication(request, response); } }
-
创建controller包,包下新建HelloController测试类:
package com.study.controller; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; /** * @ClassName HelloController * @Description 测试类 * @Author Jiangnan Cui * @Date 2022/12/18 17:05 * @Version 1.0 */ @RestController public class HelloController { @PostMapping("/hello") public String hello() { System.out.println("hello"); return "hello"; } }
2.5.2 测试流程
(1)启动服务,打开Postman,以Post方式请求路径:http://localhost:8080/doLogin,请求体内容为:
{ "uname": "root", "passwd": "123456" }
注意:此处不再是username、password,SecurityConfig类中已经对名称进行修改,否则会一直登录不成功!!!
测试结果:
通过返回信息可知,没有登录成功,是因为没有携带token令牌信息。
(2)在上面请求的结果中打开Cookies,获得令牌信息:XSCF-TOKEN:value:
添加值该请求路径的请求头header,再次发起请求:
提示登录成功,表示令牌生效。
(3)以Post方式请求路径:http://localhost:8080/hello,请求体内容为:
{ "uname": "root", "passwd": "123456" }
注意:此处不再是username、password,SecurityConfig类中已经对名称进行修改,否则会一直登录不成功!!!
另外,还需在请求头header中添加token令牌信息,注意是“X-XSRF-TOKEN"而不是”XSRF-TOKEN“:
返回hello表示请求成功!!!
2.5.3 总结
发送请求时携带令牌即可:
-
方式1:请求参数中携带令牌
key: _csrf value:"xxx"
-
方式2:请求头中携带令牌
X-XSRF-TOKEN:value
2.6 项目目录结构
初次接触,如有问题,欢迎评论区批评指正。