Spirng Security優勢之一就是為各種可能存在的漏洞提供了保護機制,而這些保護機制默認都是開啟的。
CSRF(跨站請求偽造) 也可以稱為一鍵式攻擊,CSRF攻擊時一種挾持用戶在當前已登錄的瀏覽器上發送惡意請求的攻擊方法,簡單來說,就是攻擊者通過一些技術手段欺騙用戶的瀏覽器,去訪問一個用戶曾經認證過的網站並執行惡意請求。由於客戶端(瀏覽器)已經在該網站上認證過,所以該網站會認為時真正的用戶在操作而執行請求。
CSRF防御
CSRF攻擊的根源在於瀏覽器默認的身份驗證機制(自動攜帶當前網站的Cookie信息),這種機制雖然可以保證請求是來自用戶的某個瀏覽器,但是無法確保該請求是用戶授權發送的。攻擊者和用戶發送的請求一模一樣,這意味着我們沒有辦法去直接拒絕這里的某一個請求,如果能在合法請求中額外攜帶一個攻擊者無法獲取的參數,就可以成功區分出兩種不同的請求,進而拒絕惡意請求。
Spring中提供了兩種機制來防御CSRF攻擊:令牌同步模式和在Cookie上指定SameSite屬性
令牌同步模式
這是目前主流的CSRF攻擊防御方案。
具體的操作方式就是在每一個HTTP請求中,除了默認自動攜帶的Cookie參數外,再額外提供一個安全的,隨機生成的字符串,我們稱之為CSRF令牌。這個CSRF令牌由服務端生成,生成后在HttpSession中保存一份。當前端請求到達后,將請求攜帶的CSRF令牌信息和服務端保存的令牌進行對比,如果二者不相等,則拒絕該HTTP請求。
考慮到會有一些外部站點鏈接到我們網站,所以我們要求請求是冪等的,這樣對於GET,HEAD,POTIONS,TRACE等方法就沒有必要使用CSRF令牌,強行使用可能會導致令牌泄露。(Spring Security默認沒有對GET,HEAD,POTIONS,TRACE請求進行CSRF校驗)
后端有兩種方式傳遞給前端csrf令牌信息
(1)放在request屬性中返回給前端
使用時需要注意前端傳給后端csrf令牌信息
<form action="/hello" method="post">
<input type="hidden" th:value="${_csrf.token}" th:name="{_csrf.parameterName}"
<input type="submit" value="hello">
</form>
(2)放在Cookie中
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
}
需要注意的是,這里將csrfTokenRepository配置為CookieCsrfTokenRepository,並設置為httpOnly屬性為false,否則前端將無法讀取到Cookie中的CSRF令牌
前端從Cookie中提取令牌信息。
實例:
<script>
$("#loginBtn").click(
function(){
let _csrf=$.cookie('XSRF-TOKEN');
$.post('/login.html',{
_csrf:_csrf;
})
}
)
</script>
這里使用JQuery和JQuery Cookie庫 來簡化Cookie操作,提取Cookie中的XSRF-TOKEN字段
CSRF令牌放在Cookie中會造成CSRF攻擊嗎?
答案是不會!CSRF攻擊的根源在於瀏覽器默認的身份認證機制,即發送請求時會自動攜帶Cookie,但是Cookie的內容是什么黑客是不知道的,必須要經過解析。
SameSite
SameSite是最近幾年才出現的一個解決方案,是Chrome 51開始支持的一個屬性,用來防止CSRF攻擊和用戶追蹤
這種方式通過在Cookie中指定SameSite屬性,要求瀏覽器從外部站點發送請求時,不應該攜帶Cookie信息,進而防止CSRF攻擊,添加了SameSite屬性的響應頭
SameSite屬性值有三種:
Strict:只有同一站點發送的請求才包含Cookie信息,不同站點發送的Cookie請求將不會包含Cookie信息
Lax:同一站點發送的請求或者導航到目標地址的GET請求會自動包含Cookie信息,否則不包含Cookie信息
None:Cookie將在所有上下文中發送,即允許跨域發送
使用SameSite還有一個需要考慮的因素就是瀏覽器的兼容性,雖然大部分現代瀏覽器都支持SameSite屬性,但是可能還是存在一些古董級瀏覽器不支持該屬性。所以,如果使用SameSite處理CSRF攻擊,建議作為一個備選方案,而不是主要方案
Spring Security對於SameSite並未直接提供支持,但是Spring Session提供了,因此,在使用時,需要先引入Spring Session和Redis依賴
然后提供一個CookieSerializer
@Bean
public CookieSerializer httpSessionIdResolver(){
DefaultCookieSerializer cookieSerializer=new DefaultCookieSerializer();
cookieSerializer.setSameSite("strict");
return cookieSerializer;
}
源碼分析
Spring Security提供了CsrfToken接口描述CSRF令牌信息
public interface CsrfToken extends Serializable {
String getHeaderName();
String getParameterName();
String getToken();
}
(1)getHeaderName:當CSRF令牌被放在請求頭時,獲得參數名
(2)getParameterName:當CSRF令牌被當做請求參數傳遞時,獲取參數名
(3)getToken:獲取具體的CSRF令牌
Spring Security提供了CsrfTokenRepository接口保存CsrfToken
public interface CsrfTokenRepository {
CsrfToken generateToken(HttpServletRequest var1);
void saveToken(CsrfToken var1, HttpServletRequest var2, HttpServletResponse var3);
CsrfToken loadToken(HttpServletRequest var1);
}
(1)generateToken:該方法用來生成一個CSRF令牌
(2)saveToken:該方法用來保存CSRF令牌
(3)loadToken:該方法用來讀取一個CSRF令牌
共有三個實現類,HttpSessionCsrfTokenRepository(保存在HttpSession中),CookieCsrfTokenRepository(保存在Cookie中) , LazyCsrfTokenRepository(代理類,延遲保存生成的CsrfToken)
在CsrfFilter中校驗客戶端傳來的CSRF令牌,該類繼承OncePerRequestFilter,是過濾器鏈的一環
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
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)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not protect against CSRF since request did not match " + this.requireCsrfProtectionMatcher);
}
filterChain.doFilter(request, response);
} else {
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
this.logger.debug(LogMessage.of(() -> {
return "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request);
}));
AccessDeniedException exception = !missingToken ? new InvalidCsrfTokenException(csrfToken, actualToken) : new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
} else {
filterChain.doFilter(request, response);
}
}
}
(1)首先調用this.tokenRepository.loadToken方法加載出CsrfToken對象,默認使用的tokenRepository對象類型是LazyCsrfTokenRepository
(2)如果CsrfToken對象不存在,則立馬生成CsrfToken對象並保存起來,需要注意,如果tokenRepository類型是LazyCsrfTokenRepository,則這里並未真正將CsrfToken令牌保存起來
(3)將生成的CsrfToken對象設置到request屬性中,這樣就可以在前端頁面中渲染出令牌信息了
(4)調用requireCsrfProtectionMatcher.matches判斷請求方法是否是GET,HEAD,TRACE,OPTIONS,如果是則跳過校驗(此時使用LazyCsrfTokenRepository的優勢就體現出來了,不必再保存CsrfToken信息了)
(5)請求方法如果不是GET,HEAD,TRACE,OPTIONS,則先從請求頭中提取出CSRF令牌,請求頭沒有則從請求參數中提取出CSRF令牌,將拿到的CSRF令牌和第一步中通過loadToken加載出來的令牌進行比對,判斷令牌是否合法
CsrfAuthenticationStrategy實現了SessionAuthenticationStrategy接口,在用戶登陸成功后觸發執行,刪除舊的CsrfToken 並生成新的CsrfToken
public final class CsrfAuthenticationStrategy implements SessionAuthenticationStrategy {
private final Log logger = LogFactory.getLog(this.getClass());
private final CsrfTokenRepository csrfTokenRepository;
public CsrfAuthenticationStrategy(CsrfTokenRepository csrfTokenRepository) {
Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
this.csrfTokenRepository = csrfTokenRepository;
}
public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException {
boolean containsToken = this.csrfTokenRepository.loadToken(request) != null;
if (containsToken) {
this.csrfTokenRepository.saveToken((CsrfToken)null, request, response);
CsrfToken newToken = this.csrfTokenRepository.generateToken(request);
this.csrfTokenRepository.saveToken(newToken, request, response);
request.setAttribute(CsrfToken.class.getName(), newToken);
request.setAttribute(newToken.getParameterName(), newToken);
this.logger.debug("Replaced CSRF Token");
}
}
}
