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 { @Overrideprotected 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
好了,這個問題算徹底清楚了,就這樣,打完收工。。。