1. 程式人生 > 其它 >spring security框架中,自定義登入頁面和校驗路徑,CSRF防禦引起的403 Forbidden

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

好了,這個問題算徹底清楚了,就這樣,打完收工。。。