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
好了,这个问题算彻底清楚了,就这样,打完收工。。。