目录
一、简介
二、CSRF 防御
2.1 令牌同步模式
三、Spring Security 开启 CSRF
3.1 CSRF 配置
3.2 查看登录⻚⾯源码
3.3 CSRF的应用
1. 传统 Web 开发使用 CSRF
2.前后端分离使用 CSRF
3.注意
3.4 源码分析
1. CsrfFilter 执行流程
2.CSRFFilter 的配置源码
CSRF (Cross-Site Request Forgery 跨站请求伪造),也可称为一键式攻击 (one-clike-attack),通常缩写为 CSRF
或者XSRF
。
CSRF
攻击是一种挟持用户在当前已登录的浏览器上发送恶意请求的攻击方法。相对于XSS 利用用户对指定网站的信任,CSRF 则是利用网站对用户网页浏览器的信任。简单来说,CSRF是致击者通过一些技术手段欺骗用户的浏览器,去访问一个用户曾经认证过的网站并执行恶意请求,例如发送邮件、发消息、甚至财产操作(如转账和购买商品)。由于客户端(浏览器)已经在该网站上认证过,所以该网站会认为是真正用户在操作而执行请求(实际上这个并非用户的本意)
举例说明:
假设 小明 现在登录某银行的网站准备完成一项转账操作,转账的连接如下:
https: //bank .xxx .com/withdraw?account=blr&amount=1000&for=zhangsan
可以看到,这个连接是想从 小明 这个账户下转账 1000 元到 zhangsan 账户下,假设 小明没有注销登录该银行的网站,就在同一个浏览器新的选项卡中打开一个危险网站,这个危险网站中有一副图片,代码如下:
一旦用户打开了这个网站,这个图片链接中的请求就会自动发送出去。由于是同一个浏览器并且用户尚未注销登录,所以该请求会自动携带上对应的有效的 Cookie 信息,进而成功完成一次转账操作。这就是跨站请求伪造。
介绍
1.这个CSRF 跨站请求伪造实际上就是利用你在 A网站的登录凭证进行获取浏览器历史记录从而发起攻击
2.CSRF 的条件 :
1. 用户已经有登录过要攻击的网站,并且有相应的用户信息如Cookie,heard 等相关信息。
2. 用户需要在其他网站没有关闭和用户信息过期前,打开 恶意网站 的指定连接。注意: 以上前提都最基础的,有可能可以在攻击之前的历史记录。
3.CSRF 的攻击原理 :
CSRF 实际上就是在用户访问了其他重要网站还没注销信息是,通过恶意网站吸引用户点击进来,通过一些列的脚本获取之前的浏览器记录等相关信息,并再次发送请求,从而实现攻击的效果。
他利用了服务器对浏览器的信任,因为浏览器默认请求会携带当前的Cookie等信息交给服务器的。csfr 攻击是通过 网站对浏览器的信任进行攻击,通过获取访问的历史记录进行攻击
解决方法就是添加了一个令牌参数,这令牌值是从服务器端生成,每次请求时都需要携带这个值,不然就会访问不成功!
令牌每次重新访问后都会重新生成!
CSRF 攻击的根源在于浏览器默认的身份验证机制(自动携带当前网站的 Cookie信息),这种机制虽然可以保证请求是来自用户的某个浏览器,但是无法保证这请求是用户授权发送。攻击者和用户发送的请求一模一样,这意味着我们没有办法去直接拒绝这里的某一个请求。如果能在合法请求中额外携带一个攻击者无法获取的参数。就可以成功分为两种不同的请求,进而直接拒绝掉恶意请求。在 SpringSecurity 中就提供了这种机制来防御 CSRF 攻击,这种机制我们称之为 令牌同步模式
。
这是目前主流的 CSRF 攻击防御方案。具体的操作方式就是在每一个 HTTP 请求中,除了默认自动携带的 Cookie 参数之外,在提供一个安全的、随机生成的字符串,我们称之为 CSRF 令牌。这个 CSRF 令牌由服务端生成,生成后在 HttpSession 中保存一份。当前端请求到达后,将请求携带的 CSRF 令牌信息和服务端中保存的令牌进行比对,如果两者不相等,则拒绝掉该 HTTP 请求。
解决方法就是添加了一个令牌参数,这令牌值是从服务器端生成,每次请求时都需要携带这个值,不然就会访问不成功! 令牌每次重新访问后都会重新生成!
注意:考虑到会有一些外部站点链接到我们的网站,所以我们要求请求是幂等的,这样对子 HEAD、OPTIONS、TRACE 等方法就没有必要使用 CSRF 令牌了,强行使用可能会导致令牌泄露!
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public UserDetailsService userDetailsService (){
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
inMemoryUserDetailsManager.createUser(User.withUsername("zs").password("{noop}123").roles("admin").build());
return inMemoryUserDetailsManager ;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf(); // 开启 csrf 默认也是开启的
}
}
此时 Post 请求的会默认多一个隐藏属性 _csrf ,值会默认从HttpSession中获取到,请求时会默认携带该参数。
开启 CSRF 防御后会自动在提交的表单中加入如下代码,如果不能自动加入,需要在开启后手动加入如下代码,并随着请求提交。
web 请求会默认生成,默认保存在session作用域中。他会为所有非幂等的请求创建csrf,默认开启csrf 后是保存到session作用域中的。不符合前后端分离项目的 ,前后端分离系统,csrf令牌不再有服务器保存 ,是保存到cookie,但是不会被恶意攻击
获取服务端令牌方式如下:
前后端分离开发时,只需要将生成 csrf 放入到 cookie 中,并在请求时获取 cookie 中令牌信息进行提交即可。
前后端分离系统,csrf令牌不再有服务器保存 ,是保存到cookie,但是不会被恶意攻击,因为传过来是需要放在heard中,并且还需要有一个key value 就是csrf的值,在spring Security 的Filter 会判断这个 key 和value 的格式和值是否相同 ,所以可以避免恶意攻击
1.修改 CSRF 存入 Cookie
设置 CSRF允许存放到Cookie 中,并且只允许Http 使用
核心代码就是
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())// 将令牌保存到cookie 中允许 cookie 被前端获取。
package com.bjpowernode.config;
import com.bjpowernode.filter.LoginJSONFilter;
import com.bjpowernode.hanlder.LoginFailureHandler;
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.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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.AuthenticationException;
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.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* @author 千城丶Y
* @className : WebSecurityConfig
* @PACKAGE_NAME : com.bjpowernode.security.config
* @date : 2022/6/8 17:23
* @Description Security的配置
*/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final LoginFailureHandler loginFailureHandler ;
@Autowired
public WebSecurityConfig(LoginFailureHandler loginFailureHandler) {
this.loginFailureHandler = loginFailureHandler;
}
/**
* 配置数据源
* @return
*/
@Bean
@Override
public UserDetailsService userDetailsService (){
InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
userDetailsManager.createUser(User.withUsername("root").password("{noop}123").roles("admin").build());
return userDetailsManager;
}
/**
* 配置自定义的数据源
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService());
}
/**
* 公共AuthenticationManager
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 创建自定义Filter
* @return
*/
@Bean
public LoginJSONFilter loginFilter() throws Exception {
LoginJSONFilter loginFilter = new LoginJSONFilter();
// 需要给自定义的Filter初始化
// 因为在配置时后面替换掉了之前的 UserNamePasswordAuthenticationFilter.所以配置都失效了
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setUsernameParameter("uname"); // 指定接收 json 用户名 key
loginFilter.setFilterProcessesUrl("/doLogin"); // 指定认证 url
loginFilter.setPasswordParameter("pwd"); // 指定接收 json 密码 key
// 认证失败处理
loginFilter.setAuthenticationFailureHandler(loginFailureHandler);
// 认证成功处理
loginFilter.setAuthenticationSuccessHandler(
(res,response,auth)->{
Map rs = new HashMap<>();
rs.put("code",200);
rs.put("msg","登录成功");
rs.put("authention",auth);
String s = new ObjectMapper().writeValueAsString(rs);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(s);
}
);
return loginFilter;
// loginFilter
}
/**
* 安全拦截认证
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/doLogin").permitAll() // 匿名访问
.anyRequest().authenticated() // 认证
.and()
// 当发送请求,还没有认证的时候,会抛出一个异常,所以返回json数据需要配合异常处理
// 就是当出现认证失败后是否要跳转到登录页面
.exceptionHandling() // 当认证失败的处理器
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Map rs = new HashMap<>();
rs.put("msg",authException.getMessage());
rs.put("error","未认证");
// 未认证状态
response.setStatus(HttpStatus.UNAUTHORIZED.value());
String json = new ObjectMapper().writeValueAsString(rs);
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.getWriter().println(json);
}
})
.and()
.formLogin()
.loginProcessingUrl("/doLogin")
.usernameParameter("uname")
.passwordParameter("pwd")
.failureHandler(loginFailureHandler)
.successHandler((res,response,auth)->{
Map rs = new HashMap<>();
rs.put("code",200);
rs.put("msg","登录成功");
rs.put("authention",auth);
String s = new ObjectMapper().writeValueAsString(rs);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(s);
})
.and()
.logout()
.logoutRequestMatcher(new OrRequestMatcher(
new AntPathRequestMatcher("/logout", HttpMethod.GET.name()),
new AntPathRequestMatcher("/logout",HttpMethod.POST.name())
))
.logoutSuccessHandler((res,resp,authentication)->{
Map rs = new HashMap<>();
rs.put("msg","退出登录成功");
rs.put("用户信息",authentication);
resp.setStatus(HttpStatus.OK.value());
String json = new ObjectMapper().writeValueAsString(rs);
resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
resp.getWriter().println(json);
})
.and()
.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())// 将令牌保存到cookie 中允许 cookie 被前端获取。
.and();
// 替换掉这个Filter
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
2.测试
3. 发送请求携带令牌方式
key: _csrf // 这个可以在后端该不然不安全
value:"xxx"
X-XSRF-TOKEN : value // 这个key 也需要改不然不安全
如果开启了 CSRF 功能后,并设置允许生成cookie 也会到前端,那么每一次请求会生成cookie被写回。
注意:这个 CSRF 只适用于 Session 项目 ,如果使用 JWT TOKEN 则就不需要了,因为 jwt是保存在 heard 中而非cookie 。
首先可以知道核心的Filter 是 CsrfFilter 进行处理的令牌认证工作 。
通过以下源码可以查看到 CsrfFilter 里需要一个 tokenRepostroy 的类,这个类就是在配置里添加的,我们也可以自定义,通过这里可以得知,cookie里保存的名称是 _csrf ,heard 里的名称是 X-XSRF-TOKEN 和 requestParameter 的参数是 XSRF-TOKEN 的这些名称我们为了安全起见需要自定义替换掉的。
通过源码得知 Csrf 的核心处理类是 CsrfTokenRepository,它是一个接口
结论:
1.首先他会前去 cookie中拿cookie,如果没有则生成
2.然后回去请求头去获取,如果没有获取到则会去请求参数中获取
3.进行cookie的令牌和取出的令牌对比认证。
4.最终得出 请求参数和 请求头中只需要有一个有值就可以了
5.自定义值的话则直接修改这些属性就可以了。
6.正常令牌的验证没有通过 和服务器端的令牌进行比较,而是先从请求携带的cookie或者令牌,再去从请求或请求值中获取,进行比较。还是存在一定的安全问题。
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
// 1.从请求对象中去查询是否有对应的cookie 数据
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
final boolean missingToken = csrfToken == null;
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!csrfToken.getToken().equals(actualToken)) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Invalid CSRF token found for "
+ UrlUtils.buildFullRequestUrl(request));
}
if (missingToken) {
this.accessDeniedHandler.handle(request, response,
new MissingCsrfTokenException(actualToken));
}
else {
this.accessDeniedHandler.handle(request, response,
new InvalidCsrfTokenException(csrfToken, actualToken));
}
return;
}
filterChain.doFilter(request, response);
}
这个源码在CsrfConfigurer 里面定义一些 CsrfFilter 所需要的属性和配置信息。
核心方法 在里面创建了CsrfFilter
@Override
public void configure(H http) {
CsrfFilter filter = new CsrfFilter(this.csrfTokenRepository);
RequestMatcher requireCsrfProtectionMatcher = getRequireCsrfProtectionMatcher();
if (requireCsrfProtectionMatcher != null) {
filter.setRequireCsrfProtectionMatcher(requireCsrfProtectionMatcher);
}
AccessDeniedHandler accessDeniedHandler = createAccessDeniedHandler(http);
if (accessDeniedHandler != null) {
filter.setAccessDeniedHandler(accessDeniedHandler);
}
LogoutConfigurer logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
if (logoutConfigurer != null) {
logoutConfigurer
.addLogoutHandler(new CsrfLogoutHandler(this.csrfTokenRepository));
}
SessionManagementConfigurer sessionConfigurer = http
.getConfigurer(SessionManagementConfigurer.class);
if (sessionConfigurer != null) {
sessionConfigurer.addSessionAuthenticationStrategy(
getSessionAuthenticationStrategy());
}
filter = postProcess(filter);
http.addFilter(filter);
}