spring security框架中,自定义登录页面和校验路径,CSRF防御引起的403 Forbidden

问题现象想自定义登录页面和校验地址,按照如下配置后,能正常返回登录页面,但使用表单提交账号密码时,总是出现403 Forbidden,无法正常登录

import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

/**
 * 登录验证
 */
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class CustomWebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                //自定义登录页面
                .loginPage("/loginPage.html")
                //自定义验证账号密码的地址
                .loginProcessingUrl("/loginVerify");
    }
}

 

 

 出现原因:后台默认开启了CSRF(跨站请求伪造)保护导致的,不知道CSRF是什么的可以自行搜索,相关文章挺多的,我这里就不赘述了,反正明白一点,这个是为了安全设计的就行

网上搜索的解决方案,绝大多数都是简单粗暴的直接关闭CSRF保护,像这样

 

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .formLogin()
                //自定义登录页面
                .loginPage("/loginPage.html")
                //自定义验证账号密码的地址
                .loginProcessingUrl("/loginVerify");
    }

 

这样可以解决问题,但我还是想知道,为什么会出现这种情况呢???毕竟我是在同一ip,同一端口下的呀,应该不符合CSRF才对呀,带着问题,我们来看一下触发的原因

首先找到起作用的拦截器 org.springframework.security.web.csrf.CsrfFilter,找到过滤器的实现方法 doFilterInternal,所有原因都在这里,只贴部分关键代码

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
     //尝试用request中的某些参数来获取token,这里debug时,是获取session中的一个键为org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN的值 CsrfToken csrfToken
= this.tokenRepository.loadToken(request); boolean missingToken = (csrfToken == null); if (missingToken) {
       //获取不到就创建一个token csrfToken
= this.tokenRepository.generateToken(request);
       //同时将创建出来的token缓存起来
this.tokenRepository.saveToken(csrfToken, request, response); }
     //将token放到request中,后面想用可以方便的取到 request.setAttribute(CsrfToken.
class.getName(), csrfToken); request.setAttribute(csrfToken.getParameterName(), csrfToken);
     //这里主要匹配请求 method 为"GET", "HEAD", "TRACE", "OPTIONS"的,这样的直接放行
if (!this.requireCsrfProtectionMatcher.matches(request)) { if (this.logger.isTraceEnabled()) { this.logger.trace("Did not protect against CSRF since request did not match " + this.requireCsrfProtectionMatcher); } filterChain.doFilter(request, response); return; }
     //尝试从header或参数中获取token String actualToken
= request.getHeader(csrfToken.getHeaderName()); if (actualToken == null) { actualToken = request.getParameter(csrfToken.getParameterName()); }
     //比较session中获取的token和上一步中从header或参数中获取的token是否一致,如果不一致,就会返回我们看到的403 Forbidden问题
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) { this.logger.debug( LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request))); AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken) : new MissingCsrfTokenException(actualToken); this.accessDeniedHandler.handle(request, response, exception); return; } filterChain.doFilter(request, response); }

到这里,基本就清楚了触发条件了

1)请求 method 不是"GET", "HEAD", "TRACE", "OPTIONS"的,如POST、PUT、DELETE

2)tokenRepository中没有找到CsrfToken

3)从header或参数中未获取到token

有了以上三个条件,那我们就一 一对应来试着找找解决办法

1)首先就是请求方法,如果我们使用get提交,就可以直接绕过这个拦截器了,但是呢,security框架中,要想使用它的账号密码谁流程,只能使用POST提交,详见 UsernamePasswordAuthenticationFilter中

    public Authentication attemptAuthentication(HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException {
     //呐,就是这里,不是POST直接不通过
if (postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } }

2)这里会尝试到缓存中找token,没有的话,就会新创建一个,并且保存起来,也就是说,你在提交前请求过系统,就会有这个token,就算没有,也会新创一个,基本没有其他需要解决的

//尝试用request中的某些参数来获取token,这里debug时,是获取session中的一个键为org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN的值
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
boolean missingToken = (csrfToken == null);
if (missingToken) {
  //获取不到就创建一个token
   csrfToken = this.tokenRepository.generateToken(request);
  //同时将创建出来的token缓存起来
   this.tokenRepository.saveToken(csrfToken, request, response);
}

3)在我们请求时传过来一个token,并且要跟缓存中的对应上,一致,这样才可以,这也是security框架中,默认登录页面的解决方式

我们先来看一下他默认登录页面的实现方式

源码在 org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter 的 generateLoginPageHtml 方法中,其中有一句关键代码

 

 

 翻译过来就是渲染隐藏的输入框,那它要隐藏什么呢?进来再看

 

 

 就是一个名为_csrf,值为前面缓存起来的token,打开登录页面的html可以确认,就是这个

 

 

 登录页面是form表单,点击登按钮时,会随着表单一起提交

这样,我们前面看到的 actualToken = request.getParameter(csrfToken.getParameterName());就能获取到token了,这样就通过了CsrfFilter了

源码中的原理清楚了,那剩下就是我们根据业务需要,来采取不同的实现方法了

第一种:登录页面支持thymeleaf返回,而request中能取到token,所以我们可以在返回自定义的登录界面中,使用thymeleaf表达式取出来放在页面中,提交账号密码时带上就好了(前后端分离开发好久了,thymeleaf忘得差不多了,就不在这里举例了... ...)

第二种:开放一个接口,专门用来获取token,提交账号密码前先获取到token,然后和账号密码一起提交上去验证(个人比较推荐)

   //这里url可随意,什么都行 
  @GetMapping("/salt") public String salt(HttpServletRequest request){ return ((CsrfToken)request.getAttribute("_csrf")).getToken(); }

推荐这个的原因是当PC端时,服务器返回登录页面,可以直接把token从服务端带回去,但当我们的登录接口想PC端和APP端通用时,APP一般打包时早都把登录页面打包了,是不需要服务器返回的,而这种方式就可以保证APP也能获取到token

好了,这个问题算彻底清楚了,就这样,打完收工。。。

 

posted on 2022-03-09 15:02  待那枫叶红成海  阅读(623)  评论(0编辑  收藏  举报

导航